internal/httpd/accounts.go
607 lines · 20370 bytes
1package httpd
2
3import (
4 "fmt"
5 "html/template"
6 "log"
7 "net/http"
8 "path"
9 "slices"
10 "strconv"
11 "strings"
12 "time"
13
14 gossh "golang.org/x/crypto/ssh"
15
16 "gitbay.org/gitbay/internal/control"
17 "gitbay.org/gitbay/internal/gitutil"
18 "gitbay.org/gitbay/internal/policy"
19 "gitbay.org/gitbay/internal/protocol"
20 "gitbay.org/gitbay/internal/store"
21)
22
23const sessionCookie = "gitbay_session"
24
25// sessionSameSite is Lax so a login link followed from a mail client keeps
26// its session through the redirect. Cross-site POSTs are refused by
27// checkOrigin and carry no Lax cookie anyway.
28const sessionSameSite = http.SameSiteLaxMode
29
30// badLoginToken is what every refused /login?token= gets, whatever the
31// reason. The reasons differ in whether the account exists.
32const badLoginToken = "that login link is invalid, expired, or already used — mint a new one"
33
34// viewer returns the logged-in user, or a zero User for anonymous visitors.
35// Only meaningful in accounts mode; in view_only no session route exists so
36// every request is anonymous.
37func (s *Server) viewer(r *http.Request) store.User {
38 ck, err := r.Cookie(sessionCookie)
39 if err != nil {
40 return store.User{}
41 }
42 u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
43 if err != nil {
44 return store.User{}
45 }
46 return u
47}
48
49// requireUser wraps a handler that needs a session.
50func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
51 return func(w http.ResponseWriter, r *http.Request) {
52 u := s.viewer(r)
53 if u.ID == 0 {
54 if r.Method == http.MethodGet {
55 s.setNext(w, r.URL.RequestURI())
56 }
57 http.Redirect(w, r, "/login", http.StatusSeeOther)
58 return
59 }
60 h(w, r, u)
61 }
62}
63
64// checkOrigin rejects cross-site POSTs. It is the primary CSRF defense:
65// sessions use SameSite=Lax, which withholds the cookie from a cross-site
66// POST but not from a cross-site top-level GET.
67func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
68 return func(w http.ResponseWriter, r *http.Request) {
69 if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
70 host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
71 if host != r.Host {
72 http.Error(w, "cross-origin request refused", http.StatusForbidden)
73 return
74 }
75 }
76 h(w, r)
77 }
78}
79
80// renderLogin draws the login page. Mode carries the registration mode so
81// the page can tell a brand-new visitor how to get an account. EmailLogin
82// says whether this instance can mail a link; Sent switches the page to the
83// confirmation that follows a request.
84func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool, next string) {
85 s.render(w, "login.html", struct {
86 basePage
87 Mode string // closed | invite | open
88 Error string
89 EmailLogin bool
90 Sent bool
91 Next string
92 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()},
93 s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent, next})
94}
95
96// emailLoginEnabled reports whether a link can be mailed at all. There is no
97// separate switch: the capability is exactly the SMTP the instance already
98// configured for verification and notification mail.
99func (s *Server) emailLoginEnabled() bool {
100 return s.cfg.Web.Mode == "accounts" && s.cfg.Mail.SMTPHost != ""
101}
102
103// loginSubmit mails a one-time login link. The response is the same page
104// whatever happened, including when nothing happened.
105func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
106 if !s.emailLoginEnabled() {
107 s.notFound(w, r)
108 return
109 }
110 // The per-account bound lives in the store and survives a restart; this
111 // one stops a single source from spending every account's budget.
112 if allowed, wait := s.apiLimit.allow("login"+s.clientIP(r), true); !allowed {
113 w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
114 http.Error(w, "too many login requests; wait a moment", http.StatusTooManyRequests)
115 return
116 }
117 if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil {
118 log.Printf("login link: %v", err)
119 }
120 s.renderLogin(w, "", true, "")
121}
122
123func (s *Server) login(w http.ResponseWriter, r *http.Request) {
124 token := r.URL.Query().Get("token")
125 if token == "" {
126 s.renderLogin(w, "", false, s.peekNext(r))
127 return
128 }
129 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
130 if err != nil {
131 s.renderLogin(w, badLoginToken, false, "")
132 return
133 }
134 // A token minted before the account was suspended is still consumable,
135 // and the session it would create renders every page the account can
136 // read. Checking here covers every mint path. The message is the one a
137 // bad token gets: a distinct one would confirm the account exists.
138 if u, err := s.st.UserByID(userID); err != nil || u.Disabled {
139 s.renderLogin(w, badLoginToken, false, "")
140 return
141 }
142 sessTok, sessHash, err := store.NewToken()
143 if err != nil {
144 http.Error(w, "internal error", http.StatusInternalServerError)
145 return
146 }
147 if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
148 http.Error(w, "internal error", http.StatusInternalServerError)
149 return
150 }
151 http.SetCookie(w, s.sessionCookieFor(sessTok))
152 dest := s.takeNext(w, r)
153 if dest == "" {
154 dest = "/"
155 }
156 http.Redirect(w, r, dest, http.StatusSeeOther)
157}
158
159// sessionCookieFor is the cookie a new session ships in. Secure follows TLS
160// the way clearCookie does, so a plain-HTTP deployment still works.
161func (s *Server) sessionCookieFor(tok string) *http.Cookie {
162 return &http.Cookie{
163 Name: sessionCookie, Value: tok, Path: "/",
164 HttpOnly: true, SameSite: sessionSameSite,
165 Secure: s.cfg.HTTP.TLS != "off",
166 MaxAge: 7 * 24 * 3600,
167 }
168}
169
170func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
171 if ck, err := r.Cookie(sessionCookie); err == nil {
172 s.st.DeleteWebSession(store.HashToken(ck.Value))
173 }
174 http.SetCookie(w, s.clearCookie(sessionCookie, sessionSameSite))
175 http.Redirect(w, r, "/", http.StatusSeeOther)
176}
177
178// adminOrgs lists organizations the user administers, for owner pickers.
179func (s *Server) adminOrgs(u store.User) []string {
180 var out []string
181 if orgs, err := s.st.ListOrgsForUser(u.ID); err == nil {
182 for _, o := range orgs {
183 if o.Role == "admin" {
184 out = append(out, o.Username)
185 }
186 }
187 }
188 return out
189}
190
191func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
192 s.render(w, "new.html", struct {
193 basePage
194 Orgs []string
195 Error string
196 }{s.baseFor(u), s.adminOrgs(u), errMsg})
197}
198
199func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
200 s.renderNewRepo(w, u, "")
201}
202
203func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
204 owner := r.FormValue("owner")
205 if owner == "" {
206 owner = u.Username
207 }
208 name := r.FormValue("name")
209 argv := []string{"repo", "create", owner + "/" + name}
210 if r.FormValue("visibility") == "private" {
211 argv = append(argv, "--private")
212 }
213 if _, msg, ok := s.runControl(u, argv); !ok {
214 s.renderNewRepo(w, u, msg)
215 return
216 }
217 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
218}
219
220// pinToggle pins or unpins the repo for the logged-in viewer.
221func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
222 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
223 if !ok {
224 return
225 }
226 if s.st.IsPinned(u.ID, repo.ID) {
227 s.st.UnpinRepo(u.ID, repo.ID)
228 } else {
229 s.st.PinRepo(u.ID, repo.ID)
230 }
231 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
232}
233
234// bookmarkToggle saves or unsaves a repository for the viewer. Read
235// access is all a bookmark needs — it is something you do to someone
236// else's repository — and repoForUser 404s a private one either way.
237func (s *Server) bookmarkToggle(w http.ResponseWriter, r *http.Request, u store.User) {
238 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
239 if !ok {
240 return
241 }
242 verb := "bookmark"
243 if s.st.IsBookmarked(u.ID, repo.ID) {
244 verb = "unbookmark"
245 }
246 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
247 s.setFlash(w, msg)
248 }
249 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
250}
251
252// bookmarksPage lists what the viewer has saved.
253func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) {
254 var rows []control.BookmarkOut
255 s.runControlInto(u, []string{"repo", "bookmarks"}, &rows)
256 s.render(w, "bookmarks.html", struct {
257 basePage
258 Tab string
259 Bookmarks []control.BookmarkOut
260 }{s.baseFor(u), "bookmarks", rows})
261}
262
263// forkSubmit forks the repository under the viewer's account and sends
264// them to it. The command decides everything that matters — read access,
265// quota, name collisions — so a refusal comes back as its own message on
266// the page the button was pressed from (#174).
267func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
268 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
269 if !ok {
270 return
271 }
272 var fork control.ForkOut
273 if msg, ok := s.runControlInto(u, []string{"repo", "fork", repo.Path()}, &fork); !ok {
274 s.setFlash(w, msg)
275 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
276 return
277 }
278 http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther)
279}
280
281// repoForUser is repoFor with a write/read permission requirement for a
282// logged-in user.
283func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
284 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
285 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
286 if err != nil {
287 http.NotFound(w, r)
288 return store.Repo{}, false
289 }
290 grant, err := s.st.AccessRole(repo.ID, u.ID)
291 if err != nil {
292 http.Error(w, "internal error", http.StatusInternalServerError)
293 return store.Repo{}, false
294 }
295 if !policy.CanRead(u, repo, grant) {
296 http.NotFound(w, r) // invisible: same as nonexistent
297 return store.Repo{}, false
298 }
299 if !perm(u, repo, grant) {
300 http.Error(w, "permission denied", http.StatusForbidden)
301 return store.Repo{}, false
302 }
303 return repo, true
304}
305
306// signupForm and signupSubmit front the SSH registration path for open
307// and invite instances: same store transactions, same rules, a pasted
308// public key instead of the connecting one.
309func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
310 s.renderSignup(w, "", "")
311}
312
313func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
314 s.render(w, "register.html", struct {
315 basePage
316 Host string
317 Mode string // open | invite
318 Error string
319 Username string
320 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
321}
322
323func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
324 username := strings.TrimSpace(r.FormValue("username"))
325 keyText := strings.TrimSpace(r.FormValue("key"))
326 pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
327 if err != nil {
328 s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
329 return
330 }
331 msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
332 strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
333 if code != 0 {
334 s.renderSignup(w, errMsg, username)
335 return
336 }
337 s.render(w, "registered.html", struct {
338 basePage
339 Username string
340 Message string
341 Host string
342 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
343}
344
345// issueCreateForm renders the new-issue form, prefilled from the repo's
346// default issue template when one exists. A Preview submit comes back
347// here with the draft in the form, so the page returns with everything
348// still typed and the rendering above the textarea (#235).
349func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
350 p, ok := s.repoFor(w, r, "")
351 if !ok {
352 return
353 }
354 p.Tab = "issues"
355 if wantsPreview(r) {
356 d := s.draftFor(r, p.Repo, "body", "body", bodyFormat(r))
357 s.render(w, "issuenew.html", struct {
358 repoPage
359 Body string
360 Format string
361 Title string
362 Labels string
363 Template string
364 Templates []control.IssueTemplate
365 Draft *draft
366 }{p, d.Body, d.Format, r.FormValue("title"), r.FormValue("labels"),
367 "", control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), d})
368 return
369 }
370 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
371 body, tplName := "", ""
372 if want := r.URL.Query().Get("template"); want != "" {
373 for _, t := range templates {
374 if t.Name == want {
375 body, tplName = t.Body, t.Name
376 }
377 }
378 } else {
379 for _, t := range templates {
380 if t.Name == "issue-template.md" || body == "" {
381 body, tplName = t.Body, t.Name
382 }
383 if t.Name == "issue-template.md" {
384 break
385 }
386 }
387 }
388 format := r.URL.Query().Get("format")
389 if format != "org" {
390 format = "md"
391 }
392 s.render(w, "issuenew.html", struct {
393 repoPage
394 Body string
395 Format string
396 Title string
397 Labels string
398 Template string
399 Templates []control.IssueTemplate
400 Draft *draft
401 }{p, body, format, "", "", tplName, templates, nil})
402}
403
404// Issue and merge request writes run the command the CLI runs, so the
405// archived check, notifications, body format and the audit entry have one
406// implementation. Bodies travel on stdin, the way --file - does.
407
408func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
409 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
410 title := strings.TrimSpace(r.FormValue("title"))
411 format := bodyFormat(r)
412 if wantsPreview(r) {
413 s.issueCreateForm(w, r, u)
414 return
415 }
416 var created control.Created
417 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
418 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
419 if code != protocol.ExitOK {
420 http.Error(w, msg, statusForExit(code))
421 return
422 }
423 n := created.Number
424 // Labels need write access, matching the SSH rule; the command refuses
425 // otherwise and the issue stands without them.
426 if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
427 s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
428 }
429 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
430}
431
432// issueEditSubmit edits title/body (author or write) and, with write
433// access, replaces the label set.
434func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
435 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
436 n := r.PathValue("n")
437 if wantsPreview(r) {
438 s.issuePage(w, r, "edit")
439 return
440 }
441 title := strings.TrimSpace(r.FormValue("title"))
442 code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
443 if code != protocol.ExitOK {
444 http.Error(w, msg, statusForExit(code))
445 return
446 }
447 var cur struct {
448 Labels []string `json:"labels"`
449 }
450 if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
451 want := strings.Fields(r.FormValue("labels"))
452 var args []string
453 for _, l := range cur.Labels {
454 if !slices.Contains(want, l) {
455 args = append(args, "--remove", l)
456 }
457 }
458 for _, l := range want {
459 if !slices.Contains(cur.Labels, l) {
460 args = append(args, "--add", l)
461 }
462 }
463 if len(args) > 0 {
464 s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
465 }
466 }
467 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
468}
469
470// mrEditSubmit edits an MR's title/body (author or write).
471func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
472 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
473 n := r.PathValue("n")
474 if wantsPreview(r) {
475 s.mrPage(w, r, "edit")
476 return
477 }
478 title := strings.TrimSpace(r.FormValue("title"))
479 code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
480 if code != protocol.ExitOK {
481 http.Error(w, msg, statusForExit(code))
482 return
483 }
484 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
485}
486
487func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
488 if wantsPreview(r) {
489 s.issuePage(w, r, "comment")
490 return
491 }
492 s.commentSubmit(w, r, u, "issue", "issues")
493}
494
495func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
496 if wantsPreview(r) {
497 s.mrPage(w, r, "comment")
498 return
499 }
500 s.commentSubmit(w, r, u, "mr", "mrs")
501}
502
503func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
504 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
505 n := r.PathValue("n")
506 code, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
507 if code != protocol.ExitOK {
508 http.Error(w, msg, statusForExit(code))
509 return
510 }
511 http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
512}
513
514type editPage struct {
515 basePage
516 Repo store.Repo
517 Ref string
518 Path string
519 Content string
520 Error string
521 Blocked string
522 // Creating marks a path the branch does not have yet.
523 Creating bool
524 // Markup is set for a path the forge renders, which is where a
525 // Preview button makes sense; Draft holds one when asked for (#235).
526 Markup bool
527 Draft *draft
528}
529
530func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
531 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
532 if !ok {
533 return
534 }
535 ref := r.PathValue("ref")
536 filePath := strings.Trim(r.PathValue("path"), "/")
537
538 blocked := ""
539 switch {
540 case repo.Settings.RequireSignedCommits:
541 blocked = repo.Path() + " requires signed commits and the web editor cannot sign; edit locally and push a signed commit."
542 case repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref):
543 blocked = "branch " + ref + " accepts changes through merge requests only; edit on another branch and open one."
544 }
545
546 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
547 // A branch that does not exist has nothing to edit. A path that does
548 // not exist on a real branch is a new file: commit-file creates it.
549 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+ref); err != nil {
550 s.notFound(w, r)
551 return
552 }
553 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
554 creating := err != nil
555 if creating {
556 content = nil
557 }
558 if gitutil.IsBinary(content) {
559 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
560 return
561 }
562 s.render(w, "edit.html", editPage{
563 basePage: s.baseFor(u), Repo: repo,
564 Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
565 Markup: markupFile(filePath),
566 })
567}
568
569func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
570 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
571 if !ok {
572 return
573 }
574 ref := r.PathValue("ref")
575 filePath := strings.Trim(r.PathValue("path"), "/")
576
577 // Preview: the file as the blob page will render it, above the
578 // editor, with nothing committed. Only for paths the forge renders.
579 if wantsPreview(r) && markupFile(filePath) {
580 content := r.FormValue("content")
581 d := s.draftWith(r, "content", "", content, func(raw, _ string) template.HTML {
582 return renderReadme(path.Base(filePath), []byte(raw))
583 })
584 s.render(w, "edit.html", editPage{
585 basePage: s.baseFor(u), Repo: repo,
586 Ref: ref, Path: filePath, Content: content, Markup: true, Draft: d,
587 })
588 return
589 }
590
591 // Editing is a control command; the web supplies the form and lets
592 // the registry enforce the rules — signed-commit policy, verified
593 // identity, archived repositories — so every surface agrees on them.
594 argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
595 if message := strings.TrimSpace(r.FormValue("message")); message != "" {
596 argv = append(argv, "--message", message)
597 }
598 if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
599 s.render(w, "edit.html", editPage{
600 basePage: s.baseFor(u), Repo: repo,
601 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
602 Markup: markupFile(filePath),
603 })
604 return
605 }
606 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
607}