internal/httpd/accounts.go

04832701858d14bea6b62e4bc963f1d1897ca629
gitbay/internal/httpd/accounts.go history · blame · raw

515 lines · 15561 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	s.st.RecordEvent(repo.ID, u.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
308	// Labels need write access, matching the SSH rule; ignored otherwise.
309	if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 {
310		grant, _ := s.st.AccessRole(repo.ID, u.ID)
311		if policy.CanWrite(u, repo, grant) {
312			if iss, err := s.st.IssueByNumber(repo.ID, n); err == nil {
313				for _, l := range labels {
314					s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
315				}
316			}
317		}
318	}
319	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
320}
321
322// issueEditSubmit edits title/body (author or write) and, with write
323// access, replaces the label set.
324func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
325	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
326	if !ok {
327		return
328	}
329	n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
330	iss, err := s.st.IssueByNumber(repo.ID, n)
331	if err != nil {
332		http.NotFound(w, r)
333		return
334	}
335	grant, _ := s.st.AccessRole(repo.ID, u.ID)
336	canWrite := policy.CanWrite(u, repo, grant)
337	if iss.Author != u.Username && !canWrite {
338		http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
339		return
340	}
341	title := strings.TrimSpace(r.FormValue("title"))
342	if title == "" {
343		http.Error(w, "title required", http.StatusBadRequest)
344		return
345	}
346	body := r.FormValue("body")
347	if err := s.st.UpdateIssueText(iss.ID, &title, &body); err != nil {
348		http.Error(w, "internal error", http.StatusInternalServerError)
349		return
350	}
351	if canWrite {
352		want := strings.Fields(r.FormValue("labels"))
353		for _, l := range iss.Labels {
354			if !slices.Contains(want, l) {
355				s.st.SetIssueLabel(repo.ID, iss.ID, l, false)
356			}
357		}
358		for _, l := range want {
359			s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
360		}
361	}
362	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
363}
364
365// mrEditSubmit edits an MR's title/body (author or write).
366func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
367	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
368	if !ok {
369		return
370	}
371	n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
372	m, err := s.st.MRByNumber(repo.ID, n)
373	if err != nil {
374		http.NotFound(w, r)
375		return
376	}
377	grant, _ := s.st.AccessRole(repo.ID, u.ID)
378	if m.Author != u.Username && !policy.CanWrite(u, repo, grant) {
379		http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
380		return
381	}
382	title := strings.TrimSpace(r.FormValue("title"))
383	if title == "" {
384		http.Error(w, "title required", http.StatusBadRequest)
385		return
386	}
387	body := r.FormValue("body")
388	if err := s.st.UpdateMRText(m.ID, &title, &body); err != nil {
389		http.Error(w, "internal error", http.StatusInternalServerError)
390		return
391	}
392	http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
393}
394
395func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
396	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
397	if !ok {
398		return
399	}
400	n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
401	iss, err := s.st.IssueByNumber(repo.ID, n)
402	if err != nil {
403		http.NotFound(w, r)
404		return
405	}
406	body := strings.TrimSpace(r.FormValue("body"))
407	if body == "" {
408		http.Error(w, "empty comment", http.StatusBadRequest)
409		return
410	}
411	if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
412		http.Error(w, "internal error", http.StatusInternalServerError)
413		return
414	}
415	s.st.RecordEvent(repo.ID, u.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, n))
416	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
417}
418
419func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
420	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
421	if !ok {
422		return
423	}
424	n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
425	m, err := s.st.MRByNumber(repo.ID, n)
426	if err != nil {
427		http.NotFound(w, r)
428		return
429	}
430	body := strings.TrimSpace(r.FormValue("body"))
431	if body == "" {
432		http.Error(w, "empty comment", http.StatusBadRequest)
433		return
434	}
435	if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
436		http.Error(w, "internal error", http.StatusInternalServerError)
437		return
438	}
439	s.st.RecordEvent(repo.ID, u.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, n))
440	http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
441}
442
443type editPage struct {
444	Site    string
445	Viewer  string
446	Repo    store.Repo
447	Ref     string
448	Path    string
449	Content string
450	Error   string
451}
452
453func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
454	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
455	if !ok {
456		return
457	}
458	ref := r.PathValue("ref")
459	filePath := strings.Trim(r.PathValue("path"), "/")
460	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
461	content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
462	if err != nil {
463		content = nil // new file
464	}
465	if gitutil.IsBinary(content) {
466		http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
467		return
468	}
469	s.render(w, "edit.html", editPage{
470		Site: s.siteName(), Viewer: u.Username, Repo: repo,
471		Ref: ref, Path: filePath, Content: string(content),
472	})
473}
474
475func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
476	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
477	if !ok {
478		return
479	}
480	ref := r.PathValue("ref")
481	filePath := strings.Trim(r.PathValue("path"), "/")
482	fail := func(msg string) {
483		s.render(w, "edit.html", editPage{
484			Site: s.siteName(), Viewer: u.Username, Repo: repo,
485			Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
486		})
487	}
488	// Web edits produce unsigned commits; a repo that requires signed
489	// commits must refuse them rather than violate its own policy.
490	if repo.Settings.RequireSignedCommits {
491		fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
492		return
493	}
494	email, err := s.st.PrimaryVerifiedEmail(u.ID)
495	if err != nil {
496		fail("internal error")
497		return
498	}
499	if email == "" {
500		fail("commits carry your identity: your account needs a verified primary email")
501		return
502	}
503	message := strings.TrimSpace(r.FormValue("message"))
504	if message == "" {
505		message = "edit " + filePath
506	}
507	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
508	if _, err := gitutil.CommitFileChange(dir, ref, filePath,
509		[]byte(r.FormValue("content")), u.Username, email, message); err != nil {
510		fail(err.Error())
511		return
512	}
513	s.st.MarkMirrorsDirty(repo.ID, "push")
514	http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
515}