internal/httpd/accounts.go

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

406 lines · 13291 bytes

  1package httpd
  2
  3import (
  4	"fmt"
  5	"net/http"
  6	"slices"
  7	"strings"
  8	"time"
  9
 10	gossh "golang.org/x/crypto/ssh"
 11
 12	"gitbay.org/gitbay/internal/control"
 13	"gitbay.org/gitbay/internal/gitutil"
 14	"gitbay.org/gitbay/internal/policy"
 15	"gitbay.org/gitbay/internal/protocol"
 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	owner := r.FormValue("owner")
137	if owner == "" {
138		owner = u.Username
139	}
140	name := r.FormValue("name")
141	argv := []string{"repo", "create", owner + "/" + name}
142	if r.FormValue("visibility") == "private" {
143		argv = append(argv, "--private")
144	}
145	if _, msg, ok := s.runControl(u, argv); !ok {
146		s.renderNewRepo(w, u, msg)
147		return
148	}
149	http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
150}
151
152// pinToggle pins or unpins the repo for the logged-in viewer.
153func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
154	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
155	if !ok {
156		return
157	}
158	if s.st.IsPinned(u.ID, repo.ID) {
159		s.st.UnpinRepo(u.ID, repo.ID)
160	} else {
161		s.st.PinRepo(u.ID, repo.ID)
162	}
163	http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
164}
165
166// repoForUser is repoFor with a write/read permission requirement for a
167// logged-in user.
168func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
169	perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
170	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
171	if err != nil {
172		http.NotFound(w, r)
173		return store.Repo{}, false
174	}
175	grant, err := s.st.AccessRole(repo.ID, u.ID)
176	if err != nil {
177		http.Error(w, "internal error", http.StatusInternalServerError)
178		return store.Repo{}, false
179	}
180	if !policy.CanRead(u, repo, grant) {
181		http.NotFound(w, r) // invisible: same as nonexistent
182		return store.Repo{}, false
183	}
184	if !perm(u, repo, grant) {
185		http.Error(w, "permission denied", http.StatusForbidden)
186		return store.Repo{}, false
187	}
188	return repo, true
189}
190
191// signupForm and signupSubmit front the SSH registration path for open
192// and invite instances: same store transactions, same rules, a pasted
193// public key instead of the connecting one.
194func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
195	s.renderSignup(w, "", "")
196}
197
198func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
199	s.render(w, "register.html", struct {
200		basePage
201		Host     string
202		Mode     string // open | invite
203		Error    string
204		Username string
205	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
206}
207
208func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
209	username := strings.TrimSpace(r.FormValue("username"))
210	keyText := strings.TrimSpace(r.FormValue("key"))
211	pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
212	if err != nil {
213		s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
214		return
215	}
216	msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
217		strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
218	if code != 0 {
219		s.renderSignup(w, errMsg, username)
220		return
221	}
222	s.render(w, "registered.html", struct {
223		basePage
224		Username string
225		Message  string
226		Host     string
227	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
228}
229
230// issueCreateForm renders the new-issue form, prefilled from the repo's
231// default issue template when one exists.
232func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
233	p, ok := s.repoFor(w, r, "")
234	if !ok {
235		return
236	}
237	p.Tab = "issues"
238	templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
239	body, tplName := "", ""
240	if want := r.URL.Query().Get("template"); want != "" {
241		for _, t := range templates {
242			if t.Name == want {
243				body, tplName = t.Body, t.Name
244			}
245		}
246	} else {
247		for _, t := range templates {
248			if t.Name == "issue-template.md" || body == "" {
249				body, tplName = t.Body, t.Name
250			}
251			if t.Name == "issue-template.md" {
252				break
253			}
254		}
255	}
256	s.render(w, "issuenew.html", struct {
257		repoPage
258		Body      string
259		Template  string
260		Templates []control.IssueTemplate
261	}{p, body, tplName, templates})
262}
263
264// Issue and merge request writes run the command the CLI runs, so the
265// archived check, notifications, body format and the audit entry have one
266// implementation. Bodies travel on stdin, the way --file - does.
267
268func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
269	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
270	title := strings.TrimSpace(r.FormValue("title"))
271	var created control.Created
272	code, msg := s.dispatchIntoStdin(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body"), &created)
273	if code != protocol.ExitOK {
274		http.Error(w, msg, statusForExit(code))
275		return
276	}
277	n := created.Number
278	// Labels need write access, matching the SSH rule; the command refuses
279	// otherwise and the issue stands without them.
280	if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
281		s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
282	}
283	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
284}
285
286// issueEditSubmit edits title/body (author or write) and, with write
287// access, replaces the label set.
288func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
289	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
290	n := r.PathValue("n")
291	title := strings.TrimSpace(r.FormValue("title"))
292	code, _, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
293	if code != protocol.ExitOK {
294		http.Error(w, msg, statusForExit(code))
295		return
296	}
297	var cur struct {
298		Labels []string `json:"labels"`
299	}
300	if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
301		want := strings.Fields(r.FormValue("labels"))
302		var args []string
303		for _, l := range cur.Labels {
304			if !slices.Contains(want, l) {
305				args = append(args, "--remove", l)
306			}
307		}
308		for _, l := range want {
309			if !slices.Contains(cur.Labels, l) {
310				args = append(args, "--add", l)
311			}
312		}
313		if len(args) > 0 {
314			s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
315		}
316	}
317	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
318}
319
320// mrEditSubmit edits an MR's title/body (author or write).
321func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
322	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
323	n := r.PathValue("n")
324	title := strings.TrimSpace(r.FormValue("title"))
325	code, _, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
326	if code != protocol.ExitOK {
327		http.Error(w, msg, statusForExit(code))
328		return
329	}
330	http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
331}
332
333func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
334	s.commentSubmit(w, r, u, "issue", "issues")
335}
336
337func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
338	s.commentSubmit(w, r, u, "mr", "mrs")
339}
340
341func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
342	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
343	n := r.PathValue("n")
344	code, _, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
345	if code != protocol.ExitOK {
346		http.Error(w, msg, statusForExit(code))
347		return
348	}
349	http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
350}
351
352type editPage struct {
353	basePage
354	Repo    store.Repo
355	Ref     string
356	Path    string
357	Content string
358	Error   string
359}
360
361func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
362	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
363	if !ok {
364		return
365	}
366	ref := r.PathValue("ref")
367	filePath := strings.Trim(r.PathValue("path"), "/")
368	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
369	content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
370	if err != nil {
371		content = nil // new file
372	}
373	if gitutil.IsBinary(content) {
374		http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
375		return
376	}
377	s.render(w, "edit.html", editPage{
378		basePage: s.baseFor(u), Repo: repo,
379		Ref: ref, Path: filePath, Content: string(content),
380	})
381}
382
383func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
384	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
385	if !ok {
386		return
387	}
388	ref := r.PathValue("ref")
389	filePath := strings.Trim(r.PathValue("path"), "/")
390
391	// Editing is a control command; the web supplies the form and lets
392	// the registry enforce the rules — signed-commit policy, verified
393	// identity, archived repositories — so every surface agrees on them.
394	argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
395	if message := strings.TrimSpace(r.FormValue("message")); message != "" {
396		argv = append(argv, "--message", message)
397	}
398	if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
399		s.render(w, "edit.html", editPage{
400			basePage: s.baseFor(u), Repo: repo,
401			Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
402		})
403		return
404	}
405	http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
406}