internal/httpd/accounts.go

v1.28.1
gitbay/internal/httpd/accounts.go history · blame · raw

548 lines · 18661 bytes

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