internal/httpd/accounts.go

6821a6f76082b1e10ff899ff51021b11695c4ad6
gitbay/internal/httpd/accounts.go history · blame · raw

405 lines · 13270 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	code, data, msg := s.dispatchJSON(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body"))
272	if code != protocol.ExitOK {
273		http.Error(w, msg, statusForExit(code))
274		return
275	}
276	n := int64(data["number"].(float64))
277	// Labels need write access, matching the SSH rule; the command refuses
278	// otherwise and the issue stands without them.
279	if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
280		s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
281	}
282	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
283}
284
285// issueEditSubmit edits title/body (author or write) and, with write
286// access, replaces the label set.
287func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
288	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
289	n := r.PathValue("n")
290	title := strings.TrimSpace(r.FormValue("title"))
291	code, _, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
292	if code != protocol.ExitOK {
293		http.Error(w, msg, statusForExit(code))
294		return
295	}
296	var cur struct {
297		Labels []string `json:"labels"`
298	}
299	if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
300		want := strings.Fields(r.FormValue("labels"))
301		var args []string
302		for _, l := range cur.Labels {
303			if !slices.Contains(want, l) {
304				args = append(args, "--remove", l)
305			}
306		}
307		for _, l := range want {
308			if !slices.Contains(cur.Labels, l) {
309				args = append(args, "--add", l)
310			}
311		}
312		if len(args) > 0 {
313			s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
314		}
315	}
316	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
317}
318
319// mrEditSubmit edits an MR's title/body (author or write).
320func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
321	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
322	n := r.PathValue("n")
323	title := strings.TrimSpace(r.FormValue("title"))
324	code, _, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
325	if code != protocol.ExitOK {
326		http.Error(w, msg, statusForExit(code))
327		return
328	}
329	http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
330}
331
332func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
333	s.commentSubmit(w, r, u, "issue", "issues")
334}
335
336func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
337	s.commentSubmit(w, r, u, "mr", "mrs")
338}
339
340func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
341	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
342	n := r.PathValue("n")
343	code, _, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
344	if code != protocol.ExitOK {
345		http.Error(w, msg, statusForExit(code))
346		return
347	}
348	http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
349}
350
351type editPage struct {
352	basePage
353	Repo    store.Repo
354	Ref     string
355	Path    string
356	Content string
357	Error   string
358}
359
360func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
361	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
362	if !ok {
363		return
364	}
365	ref := r.PathValue("ref")
366	filePath := strings.Trim(r.PathValue("path"), "/")
367	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
368	content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
369	if err != nil {
370		content = nil // new file
371	}
372	if gitutil.IsBinary(content) {
373		http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
374		return
375	}
376	s.render(w, "edit.html", editPage{
377		basePage: s.baseFor(u), Repo: repo,
378		Ref: ref, Path: filePath, Content: string(content),
379	})
380}
381
382func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
383	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
384	if !ok {
385		return
386	}
387	ref := r.PathValue("ref")
388	filePath := strings.Trim(r.PathValue("path"), "/")
389
390	// Editing is a control command; the web supplies the form and lets
391	// the registry enforce the rules — signed-commit policy, verified
392	// identity, archived repositories — so every surface agrees on them.
393	argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
394	if message := strings.TrimSpace(r.FormValue("message")); message != "" {
395		argv = append(argv, "--message", message)
396	}
397	if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
398		s.render(w, "edit.html", editPage{
399			basePage: s.baseFor(u), Repo: repo,
400			Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
401		})
402		return
403	}
404	http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
405}