internal/httpd/accounts.go

9b6ae6d86683b8e4918ba15c08b318a25eea1709
gitbay/internal/httpd/accounts.go history · blame · raw

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}