internal/httpd/accounts.go

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

496 lines · 15323 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
 63// renderLogin draws the login page. Mode carries the registration mode so
 64// the page can tell a brand-new visitor how to get an account.
 65func (s *Server) renderLogin(w http.ResponseWriter, errMsg string) {
 66	s.render(w, "login.html", struct {
 67		basePage
 68		Mode  string // closed | invite | open
 69		Error string
 70	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.Registration.Mode, errMsg})
 71}
 72
 73func (s *Server) login(w http.ResponseWriter, r *http.Request) {
 74	token := r.URL.Query().Get("token")
 75	if token == "" {
 76		s.renderLogin(w, "")
 77		return
 78	}
 79	userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
 80	if err != nil {
 81		s.renderLogin(w, "that login link is invalid, expired, or already used — mint a new one")
 82		return
 83	}
 84	sessTok, sessHash, err := store.NewToken()
 85	if err != nil {
 86		http.Error(w, "internal error", http.StatusInternalServerError)
 87		return
 88	}
 89	if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
 90		http.Error(w, "internal error", http.StatusInternalServerError)
 91		return
 92	}
 93	http.SetCookie(w, &http.Cookie{
 94		Name: sessionCookie, Value: sessTok, Path: "/",
 95		HttpOnly: true, SameSite: http.SameSiteStrictMode,
 96		Secure: s.cfg.HTTP.TLS != "off",
 97		MaxAge: 7 * 24 * 3600,
 98	})
 99	http.Redirect(w, r, "/", http.StatusSeeOther)
100}
101
102func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
103	if ck, err := r.Cookie(sessionCookie); err == nil {
104		s.st.DeleteWebSession(store.HashToken(ck.Value))
105	}
106	http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
107	http.Redirect(w, r, "/", http.StatusSeeOther)
108}
109
110// adminOrgs lists organizations the user administers, for owner pickers.
111func (s *Server) adminOrgs(u store.User) []string {
112	var out []string
113	if orgs, err := s.st.ListOrgsForUser(u.ID); err == nil {
114		for _, o := range orgs {
115			if o.Role == "admin" {
116				out = append(out, o.Username)
117			}
118		}
119	}
120	return out
121}
122
123func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
124	s.render(w, "new.html", struct {
125		basePage
126		Orgs  []string
127		Error string
128	}{s.baseFor(u), s.adminOrgs(u), errMsg})
129}
130
131func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
132	s.renderNewRepo(w, u, "")
133}
134
135func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
136	name := r.FormValue("name")
137	visibility := "public"
138	if r.FormValue("visibility") == "private" {
139		visibility = "private"
140	}
141	fail := func(msg string) { s.renderNewRepo(w, u, msg) }
142	if err := policy.ValidateName(name); err != nil {
143		fail(err.Error())
144		return
145	}
146	// Owner: yourself, or an org you admin — same rule as repo create.
147	owner := r.FormValue("owner")
148	ownerKind, ownerID := "user", u.ID
149	if owner == "" {
150		owner = u.Username
151	}
152	if owner != u.Username {
153		org, err := s.st.OrgByName(owner)
154		if err != nil {
155			fail("no such organization")
156			return
157		}
158		role, _ := s.st.OrgRole(org.ID, u.ID)
159		if role != "admin" {
160			fail("only admins of " + owner + " can create repositories there")
161			return
162		}
163		ownerKind, ownerID = "org", org.ID
164	}
165	id, err := s.st.CreateRepo(ownerKind, ownerID, name, visibility)
166	if err != nil {
167		fail(err.Error())
168		return
169	}
170	dir := control.RepoDir(s.cfg.Server.Root, owner, name)
171	if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
172		s.st.DeleteRepo(id)
173		fail("initializing repository failed")
174		return
175	}
176	http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
177}
178
179// pinToggle pins or unpins the repo for the logged-in viewer.
180func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
181	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
182	if !ok {
183		return
184	}
185	if s.st.IsPinned(u.ID, repo.ID) {
186		s.st.UnpinRepo(u.ID, repo.ID)
187	} else {
188		s.st.PinRepo(u.ID, repo.ID)
189	}
190	http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
191}
192
193// repoForUser is repoFor with a write/read permission requirement for a
194// logged-in user.
195func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
196	perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
197	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
198	if err != nil {
199		http.NotFound(w, r)
200		return store.Repo{}, false
201	}
202	grant, err := s.st.AccessRole(repo.ID, u.ID)
203	if err != nil {
204		http.Error(w, "internal error", http.StatusInternalServerError)
205		return store.Repo{}, false
206	}
207	if !policy.CanRead(u, repo, grant) {
208		http.NotFound(w, r) // invisible: same as nonexistent
209		return store.Repo{}, false
210	}
211	if !perm(u, repo, grant) {
212		http.Error(w, "permission denied", http.StatusForbidden)
213		return store.Repo{}, false
214	}
215	return repo, true
216}
217
218// signupForm and signupSubmit front the SSH registration path for open
219// and invite instances: same store transactions, same rules, a pasted
220// public key instead of the connecting one.
221func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
222	s.renderSignup(w, "", "")
223}
224
225func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
226	s.render(w, "register.html", struct {
227		basePage
228		Host     string
229		Mode     string // open | invite
230		Error    string
231		Username string
232	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, 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		basePage
251		Username string
252		Message  string
253		Host     string
254	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
255}
256
257// issueCreateForm renders the new-issue form, prefilled from the repo's
258// default issue template when one exists.
259func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
260	p, ok := s.repoFor(w, r, "")
261	if !ok {
262		return
263	}
264	p.Tab = "issues"
265	templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
266	body, tplName := "", ""
267	if want := r.URL.Query().Get("template"); want != "" {
268		for _, t := range templates {
269			if t.Name == want {
270				body, tplName = t.Body, t.Name
271			}
272		}
273	} else {
274		for _, t := range templates {
275			if t.Name == "issue-template.md" || body == "" {
276				body, tplName = t.Body, t.Name
277			}
278			if t.Name == "issue-template.md" {
279				break
280			}
281		}
282	}
283	s.render(w, "issuenew.html", struct {
284		repoPage
285		Body      string
286		Template  string
287		Templates []control.IssueTemplate
288	}{p, body, tplName, templates})
289}
290
291func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
292	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
293	if !ok {
294		return
295	}
296	title := strings.TrimSpace(r.FormValue("title"))
297	if title == "" {
298		http.Error(w, "title required", http.StatusBadRequest)
299		return
300	}
301	n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"), "md")
302	if err != nil {
303		http.Error(w, "internal error", http.StatusInternalServerError)
304		return
305	}
306	s.st.RecordEvent(repo.ID, u.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
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, nil); 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, nil); 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, "md"); err != nil {
411		http.Error(w, "internal error", http.StatusInternalServerError)
412		return
413	}
414	s.st.RecordEvent(repo.ID, u.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, n))
415	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
416}
417
418func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
419	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
420	if !ok {
421		return
422	}
423	n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
424	m, err := s.st.MRByNumber(repo.ID, n)
425	if err != nil {
426		http.NotFound(w, r)
427		return
428	}
429	body := strings.TrimSpace(r.FormValue("body"))
430	if body == "" {
431		http.Error(w, "empty comment", http.StatusBadRequest)
432		return
433	}
434	if err := s.st.AddMRComment(m.ID, u.ID, body, "md"); err != nil {
435		http.Error(w, "internal error", http.StatusInternalServerError)
436		return
437	}
438	s.st.RecordEvent(repo.ID, u.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, n))
439	http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
440}
441
442type editPage struct {
443	basePage
444	Repo    store.Repo
445	Ref     string
446	Path    string
447	Content string
448	Error   string
449}
450
451func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
452	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
453	if !ok {
454		return
455	}
456	ref := r.PathValue("ref")
457	filePath := strings.Trim(r.PathValue("path"), "/")
458	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
459	content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
460	if err != nil {
461		content = nil // new file
462	}
463	if gitutil.IsBinary(content) {
464		http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
465		return
466	}
467	s.render(w, "edit.html", editPage{
468		basePage: s.baseFor(u), Repo: repo,
469		Ref: ref, Path: filePath, Content: string(content),
470	})
471}
472
473func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
474	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
475	if !ok {
476		return
477	}
478	ref := r.PathValue("ref")
479	filePath := strings.Trim(r.PathValue("path"), "/")
480
481	// Editing is a control command; the web supplies the form and lets
482	// the registry enforce the rules — signed-commit policy, verified
483	// identity, archived repositories — so every surface agrees on them.
484	argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
485	if message := strings.TrimSpace(r.FormValue("message")); message != "" {
486		argv = append(argv, "--message", message)
487	}
488	if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
489		s.render(w, "edit.html", editPage{
490			basePage: s.baseFor(u), Repo: repo,
491			Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
492		})
493		return
494	}
495	http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
496}