internal/httpd/accounts.go
512 lines · 15311 bytes
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "slices"
7 "strconv"
8 "strings"
9 "time"
10
11 gossh "golang.org/x/crypto/ssh"
12
13 "gitbay.org/gitbay/internal/control"
14 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/policy"
16 "gitbay.org/gitbay/internal/store"
17)
18
19const sessionCookie = "gitbay_session"
20
21// viewer returns the logged-in user, or a zero User for anonymous visitors.
22// Only meaningful in accounts mode; in view_only no session route exists so
23// every request is anonymous.
24func (s *Server) viewer(r *http.Request) store.User {
25 ck, err := r.Cookie(sessionCookie)
26 if err != nil {
27 return store.User{}
28 }
29 u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
30 if err != nil {
31 return store.User{}
32 }
33 return u
34}
35
36// requireUser wraps a handler that needs a session.
37func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
38 return func(w http.ResponseWriter, r *http.Request) {
39 u := s.viewer(r)
40 if u.ID == 0 {
41 http.Redirect(w, r, "/login", http.StatusSeeOther)
42 return
43 }
44 h(w, r, u)
45 }
46}
47
48// checkOrigin rejects cross-site POSTs. Sessions also use SameSite=Strict;
49// this is the second layer.
50func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
51 return func(w http.ResponseWriter, r *http.Request) {
52 if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
53 host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
54 if host != r.Host {
55 http.Error(w, "cross-origin request refused", http.StatusForbidden)
56 return
57 }
58 }
59 h(w, r)
60 }
61}
62
63func (s *Server) login(w http.ResponseWriter, r *http.Request) {
64 token := r.URL.Query().Get("token")
65 if token == "" {
66 s.render(w, "login.html", struct {
67 Site string
68 Viewer string
69 Error string
70 }{s.siteName(), "", ""})
71 return
72 }
73 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
74 if err != nil {
75 s.render(w, "login.html", struct {
76 Site string
77 Viewer string
78 Error string
79 }{s.siteName(), "", "that login link is invalid, expired, or already used — mint a new one"})
80 return
81 }
82 sessTok, sessHash, err := store.NewToken()
83 if err != nil {
84 http.Error(w, "internal error", http.StatusInternalServerError)
85 return
86 }
87 if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
88 http.Error(w, "internal error", http.StatusInternalServerError)
89 return
90 }
91 http.SetCookie(w, &http.Cookie{
92 Name: sessionCookie, Value: sessTok, Path: "/",
93 HttpOnly: true, SameSite: http.SameSiteStrictMode,
94 Secure: s.cfg.HTTP.TLS != "off",
95 MaxAge: 7 * 24 * 3600,
96 })
97 http.Redirect(w, r, "/", http.StatusSeeOther)
98}
99
100func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
101 if ck, err := r.Cookie(sessionCookie); err == nil {
102 s.st.DeleteWebSession(store.HashToken(ck.Value))
103 }
104 http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
105 http.Redirect(w, r, "/", http.StatusSeeOther)
106}
107
108// adminOrgs lists organizations the user administers, for owner pickers.
109func (s *Server) adminOrgs(u store.User) []string {
110 var out []string
111 if orgs, err := s.st.ListOrgsForUser(u.ID); err == nil {
112 for _, o := range orgs {
113 if o.Role == "admin" {
114 out = append(out, o.Username)
115 }
116 }
117 }
118 return out
119}
120
121func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
122 s.render(w, "new.html", struct {
123 Site string
124 Viewer string
125 Orgs []string
126 Error string
127 }{s.siteName(), u.Username, s.adminOrgs(u), errMsg})
128}
129
130func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
131 s.renderNewRepo(w, u, "")
132}
133
134func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
135 name := r.FormValue("name")
136 visibility := "public"
137 if r.FormValue("visibility") == "private" {
138 visibility = "private"
139 }
140 fail := func(msg string) { s.renderNewRepo(w, u, msg) }
141 if err := policy.ValidateName(name); err != nil {
142 fail(err.Error())
143 return
144 }
145 // Owner: yourself, or an org you admin — same rule as repo create.
146 owner := r.FormValue("owner")
147 ownerKind, ownerID := "user", u.ID
148 if owner == "" {
149 owner = u.Username
150 }
151 if owner != u.Username {
152 org, err := s.st.OrgByName(owner)
153 if err != nil {
154 fail("no such organization")
155 return
156 }
157 role, _ := s.st.OrgRole(org.ID, u.ID)
158 if role != "admin" {
159 fail("only admins of " + owner + " can create repositories there")
160 return
161 }
162 ownerKind, ownerID = "org", org.ID
163 }
164 id, err := s.st.CreateRepo(ownerKind, ownerID, name, visibility)
165 if err != nil {
166 fail(err.Error())
167 return
168 }
169 dir := control.RepoDir(s.cfg.Server.Root, owner, name)
170 if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
171 s.st.DeleteRepo(id)
172 fail("initializing repository failed")
173 return
174 }
175 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
176}
177
178// pinToggle pins or unpins the repo for the logged-in viewer.
179func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
180 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
181 if !ok {
182 return
183 }
184 if s.st.IsPinned(u.ID, repo.ID) {
185 s.st.UnpinRepo(u.ID, repo.ID)
186 } else {
187 s.st.PinRepo(u.ID, repo.ID)
188 }
189 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
190}
191
192// repoForUser is repoFor with a write/read permission requirement for a
193// logged-in user.
194func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
195 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
196 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
197 if err != nil {
198 http.NotFound(w, r)
199 return store.Repo{}, false
200 }
201 grant, err := s.st.AccessRole(repo.ID, u.ID)
202 if err != nil {
203 http.Error(w, "internal error", http.StatusInternalServerError)
204 return store.Repo{}, false
205 }
206 if !policy.CanRead(u, repo, grant) {
207 http.NotFound(w, r) // invisible: same as nonexistent
208 return store.Repo{}, false
209 }
210 if !perm(u, repo, grant) {
211 http.Error(w, "permission denied", http.StatusForbidden)
212 return store.Repo{}, false
213 }
214 return repo, true
215}
216
217// signupForm and signupSubmit front the SSH registration path for open
218// and invite instances: same store transactions, same rules, a pasted
219// public key instead of the connecting one.
220func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
221 s.renderSignup(w, "", "")
222}
223
224func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
225 s.render(w, "register.html", struct {
226 Site string
227 Viewer string
228 Host string
229 Mode string // open | invite
230 Error string
231 Username string
232 }{s.siteName(), "", s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
233}
234
235func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
236 username := strings.TrimSpace(r.FormValue("username"))
237 keyText := strings.TrimSpace(r.FormValue("key"))
238 pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
239 if err != nil {
240 s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
241 return
242 }
243 msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
244 strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
245 if code != 0 {
246 s.renderSignup(w, errMsg, username)
247 return
248 }
249 s.render(w, "registered.html", struct {
250 Site string
251 Viewer string
252 Username string
253 Message string
254 Host string
255 }{s.siteName(), "", username, msg, s.cfg.SiteHost()})
256}
257
258// issueCreateForm renders the new-issue form, prefilled from the repo's
259// default issue template when one exists.
260func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
261 p, ok := s.repoFor(w, r, "")
262 if !ok {
263 return
264 }
265 p.Tab = "issues"
266 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
267 body, tplName := "", ""
268 if want := r.URL.Query().Get("template"); want != "" {
269 for _, t := range templates {
270 if t.Name == want {
271 body, tplName = t.Body, t.Name
272 }
273 }
274 } else {
275 for _, t := range templates {
276 if t.Name == "issue-template.md" || body == "" {
277 body, tplName = t.Body, t.Name
278 }
279 if t.Name == "issue-template.md" {
280 break
281 }
282 }
283 }
284 s.render(w, "issuenew.html", struct {
285 repoPage
286 Body string
287 Template string
288 Templates []control.IssueTemplate
289 }{p, body, tplName, templates})
290}
291
292func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
293 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
294 if !ok {
295 return
296 }
297 title := strings.TrimSpace(r.FormValue("title"))
298 if title == "" {
299 http.Error(w, "title required", http.StatusBadRequest)
300 return
301 }
302 n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"))
303 if err != nil {
304 http.Error(w, "internal error", http.StatusInternalServerError)
305 return
306 }
307 // Labels need write access, matching the SSH rule; ignored otherwise.
308 if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 {
309 grant, _ := s.st.AccessRole(repo.ID, u.ID)
310 if policy.CanWrite(u, repo, grant) {
311 if iss, err := s.st.IssueByNumber(repo.ID, n); err == nil {
312 for _, l := range labels {
313 s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
314 }
315 }
316 }
317 }
318 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
319}
320
321// issueEditSubmit edits title/body (author or write) and, with write
322// access, replaces the label set.
323func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
324 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
325 if !ok {
326 return
327 }
328 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
329 iss, err := s.st.IssueByNumber(repo.ID, n)
330 if err != nil {
331 http.NotFound(w, r)
332 return
333 }
334 grant, _ := s.st.AccessRole(repo.ID, u.ID)
335 canWrite := policy.CanWrite(u, repo, grant)
336 if iss.Author != u.Username && !canWrite {
337 http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
338 return
339 }
340 title := strings.TrimSpace(r.FormValue("title"))
341 if title == "" {
342 http.Error(w, "title required", http.StatusBadRequest)
343 return
344 }
345 body := r.FormValue("body")
346 if err := s.st.UpdateIssueText(iss.ID, &title, &body); err != nil {
347 http.Error(w, "internal error", http.StatusInternalServerError)
348 return
349 }
350 if canWrite {
351 want := strings.Fields(r.FormValue("labels"))
352 for _, l := range iss.Labels {
353 if !slices.Contains(want, l) {
354 s.st.SetIssueLabel(repo.ID, iss.ID, l, false)
355 }
356 }
357 for _, l := range want {
358 s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
359 }
360 }
361 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
362}
363
364// mrEditSubmit edits an MR's title/body (author or write).
365func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
366 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
367 if !ok {
368 return
369 }
370 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
371 m, err := s.st.MRByNumber(repo.ID, n)
372 if err != nil {
373 http.NotFound(w, r)
374 return
375 }
376 grant, _ := s.st.AccessRole(repo.ID, u.ID)
377 if m.Author != u.Username && !policy.CanWrite(u, repo, grant) {
378 http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
379 return
380 }
381 title := strings.TrimSpace(r.FormValue("title"))
382 if title == "" {
383 http.Error(w, "title required", http.StatusBadRequest)
384 return
385 }
386 body := r.FormValue("body")
387 if err := s.st.UpdateMRText(m.ID, &title, &body); err != nil {
388 http.Error(w, "internal error", http.StatusInternalServerError)
389 return
390 }
391 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
392}
393
394func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
395 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
396 if !ok {
397 return
398 }
399 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
400 iss, err := s.st.IssueByNumber(repo.ID, n)
401 if err != nil {
402 http.NotFound(w, r)
403 return
404 }
405 body := strings.TrimSpace(r.FormValue("body"))
406 if body == "" {
407 http.Error(w, "empty comment", http.StatusBadRequest)
408 return
409 }
410 if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
411 http.Error(w, "internal error", http.StatusInternalServerError)
412 return
413 }
414 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
415}
416
417func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
418 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
419 if !ok {
420 return
421 }
422 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
423 m, err := s.st.MRByNumber(repo.ID, n)
424 if err != nil {
425 http.NotFound(w, r)
426 return
427 }
428 body := strings.TrimSpace(r.FormValue("body"))
429 if body == "" {
430 http.Error(w, "empty comment", http.StatusBadRequest)
431 return
432 }
433 if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
434 http.Error(w, "internal error", http.StatusInternalServerError)
435 return
436 }
437 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
438}
439
440type editPage struct {
441 Site string
442 Viewer string
443 Repo store.Repo
444 Ref string
445 Path string
446 Content string
447 Error string
448}
449
450func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
451 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
452 if !ok {
453 return
454 }
455 ref := r.PathValue("ref")
456 filePath := strings.Trim(r.PathValue("path"), "/")
457 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
458 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
459 if err != nil {
460 content = nil // new file
461 }
462 if gitutil.IsBinary(content) {
463 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
464 return
465 }
466 s.render(w, "edit.html", editPage{
467 Site: s.siteName(), Viewer: u.Username, Repo: repo,
468 Ref: ref, Path: filePath, Content: string(content),
469 })
470}
471
472func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
473 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
474 if !ok {
475 return
476 }
477 ref := r.PathValue("ref")
478 filePath := strings.Trim(r.PathValue("path"), "/")
479 fail := func(msg string) {
480 s.render(w, "edit.html", editPage{
481 Site: s.siteName(), Viewer: u.Username, Repo: repo,
482 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
483 })
484 }
485 // Web edits produce unsigned commits; a repo that requires signed
486 // commits must refuse them rather than violate its own policy.
487 if repo.Settings.RequireSignedCommits {
488 fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
489 return
490 }
491 email, err := s.st.PrimaryVerifiedEmail(u.ID)
492 if err != nil {
493 fail("internal error")
494 return
495 }
496 if email == "" {
497 fail("commits carry your identity: your account needs a verified primary email")
498 return
499 }
500 message := strings.TrimSpace(r.FormValue("message"))
501 if message == "" {
502 message = "edit " + filePath
503 }
504 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
505 if _, err := gitutil.CommitFileChange(dir, ref, filePath,
506 []byte(r.FormValue("content")), u.Username, email, message); err != nil {
507 fail(err.Error())
508 return
509 }
510 s.st.MarkMirrorsDirty(repo.ID, "push")
511 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
512}