internal/httpd/accounts.go

e3632a550366fe23b2619edba30fc58fab19b598
gitbay/internal/httpd/accounts.go history · blame · raw

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