internal/httpd/accounts.go

8e64f8208b18dc40fb5c27c873802282e722255e
gitbay/internal/httpd/accounts.go history · blame · raw

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