internal/httpd/accounts.go

542b37e7324b23aec449b5fd296735fadffa3cdc
gitbay/internal/httpd/accounts.go history · blame · raw

382 lines · 11358 bytes

  1package httpd
  2
  3import (
  4	"fmt"
  5	"net/http"
  6	"strconv"
  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/store"
 16)
 17
 18const sessionCookie = "gitbay_session"
 19
 20// viewer returns the logged-in user, or a zero User for anonymous visitors.
 21// Only meaningful in accounts mode; in view_only no session route exists so
 22// every request is anonymous.
 23func (s *Server) viewer(r *http.Request) store.User {
 24	ck, err := r.Cookie(sessionCookie)
 25	if err != nil {
 26		return store.User{}
 27	}
 28	u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
 29	if err != nil {
 30		return store.User{}
 31	}
 32	return u
 33}
 34
 35// requireUser wraps a handler that needs a session.
 36func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
 37	return func(w http.ResponseWriter, r *http.Request) {
 38		u := s.viewer(r)
 39		if u.ID == 0 {
 40			http.Redirect(w, r, "/login", http.StatusSeeOther)
 41			return
 42		}
 43		h(w, r, u)
 44	}
 45}
 46
 47// checkOrigin rejects cross-site POSTs. Sessions also use SameSite=Strict;
 48// this is the second layer.
 49func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
 50	return func(w http.ResponseWriter, r *http.Request) {
 51		if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
 52			host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
 53			if host != r.Host {
 54				http.Error(w, "cross-origin request refused", http.StatusForbidden)
 55				return
 56			}
 57		}
 58		h(w, r)
 59	}
 60}
 61
 62func (s *Server) login(w http.ResponseWriter, r *http.Request) {
 63	token := r.URL.Query().Get("token")
 64	if token == "" {
 65		s.render(w, "login.html", struct {
 66			Site   string
 67			Viewer string
 68			Error  string
 69		}{s.siteName(), "", ""})
 70		return
 71	}
 72	userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
 73	if err != nil {
 74		s.render(w, "login.html", struct {
 75			Site   string
 76			Viewer string
 77			Error  string
 78		}{s.siteName(), "", "that login link is invalid, expired, or already used — mint a new one"})
 79		return
 80	}
 81	sessTok, sessHash, err := store.NewToken()
 82	if err != nil {
 83		http.Error(w, "internal error", http.StatusInternalServerError)
 84		return
 85	}
 86	if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
 87		http.Error(w, "internal error", http.StatusInternalServerError)
 88		return
 89	}
 90	http.SetCookie(w, &http.Cookie{
 91		Name: sessionCookie, Value: sessTok, Path: "/",
 92		HttpOnly: true, SameSite: http.SameSiteStrictMode,
 93		Secure: s.cfg.HTTP.TLS != "off",
 94		MaxAge: 7 * 24 * 3600,
 95	})
 96	http.Redirect(w, r, "/", http.StatusSeeOther)
 97}
 98
 99func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
100	if ck, err := r.Cookie(sessionCookie); err == nil {
101		s.st.DeleteWebSession(store.HashToken(ck.Value))
102	}
103	http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
104	http.Redirect(w, r, "/", http.StatusSeeOther)
105}
106
107func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
108	s.render(w, "new.html", struct {
109		Site   string
110		Viewer string
111		Error  string
112	}{s.siteName(), u.Username, ""})
113}
114
115func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
116	name := r.FormValue("name")
117	visibility := "public"
118	if r.FormValue("visibility") == "private" {
119		visibility = "private"
120	}
121	fail := func(msg string) {
122		s.render(w, "new.html", struct {
123			Site   string
124			Viewer string
125			Error  string
126		}{s.siteName(), u.Username, msg})
127	}
128	if err := policy.ValidateName(name); err != nil {
129		fail(err.Error())
130		return
131	}
132	id, err := s.st.CreateRepo("user", u.ID, name, visibility)
133	if err != nil {
134		fail(err.Error())
135		return
136	}
137	dir := control.RepoDir(s.cfg.Server.Root, u.Username, name)
138	if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
139		s.st.DeleteRepo(id)
140		fail("initializing repository failed")
141		return
142	}
143	http.Redirect(w, r, "/"+u.Username+"/"+name, http.StatusSeeOther)
144}
145
146// repoForUser is repoFor with a write/read permission requirement for a
147// logged-in user.
148func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
149	perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
150	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
151	if err != nil {
152		http.NotFound(w, r)
153		return store.Repo{}, false
154	}
155	grant, err := s.st.AccessRole(repo.ID, u.ID)
156	if err != nil {
157		http.Error(w, "internal error", http.StatusInternalServerError)
158		return store.Repo{}, false
159	}
160	if !policy.CanRead(u, repo, grant) {
161		http.NotFound(w, r) // invisible: same as nonexistent
162		return store.Repo{}, false
163	}
164	if !perm(u, repo, grant) {
165		http.Error(w, "permission denied", http.StatusForbidden)
166		return store.Repo{}, false
167	}
168	return repo, true
169}
170
171// signupForm and signupSubmit front the SSH registration path for open
172// and invite instances: same store transactions, same rules, a pasted
173// public key instead of the connecting one.
174func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
175	s.renderSignup(w, "", "")
176}
177
178func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
179	s.render(w, "register.html", struct {
180		Site     string
181		Viewer   string
182		Host     string
183		Mode     string // open | invite
184		Error    string
185		Username string
186	}{s.siteName(), "", s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
187}
188
189func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
190	username := strings.TrimSpace(r.FormValue("username"))
191	keyText := strings.TrimSpace(r.FormValue("key"))
192	pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
193	if err != nil {
194		s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
195		return
196	}
197	msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
198		strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
199	if code != 0 {
200		s.renderSignup(w, errMsg, username)
201		return
202	}
203	s.render(w, "registered.html", struct {
204		Site     string
205		Viewer   string
206		Username string
207		Message  string
208		Host     string
209	}{s.siteName(), "", username, msg, s.cfg.SiteHost()})
210}
211
212// issueCreateForm renders the new-issue form, prefilled from the repo's
213// default issue template when one exists.
214func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
215	p, ok := s.repoFor(w, r, "")
216	if !ok {
217		return
218	}
219	p.Tab = "issues"
220	templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
221	body, tplName := "", ""
222	if want := r.URL.Query().Get("template"); want != "" {
223		for _, t := range templates {
224			if t.Name == want {
225				body, tplName = t.Body, t.Name
226			}
227		}
228	} else {
229		for _, t := range templates {
230			if t.Name == "issue-template.md" || body == "" {
231				body, tplName = t.Body, t.Name
232			}
233			if t.Name == "issue-template.md" {
234				break
235			}
236		}
237	}
238	s.render(w, "issuenew.html", struct {
239		repoPage
240		Body      string
241		Template  string
242		Templates []control.IssueTemplate
243	}{p, body, tplName, templates})
244}
245
246func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
247	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
248	if !ok {
249		return
250	}
251	title := strings.TrimSpace(r.FormValue("title"))
252	if title == "" {
253		http.Error(w, "title required", http.StatusBadRequest)
254		return
255	}
256	n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"))
257	if err != nil {
258		http.Error(w, "internal error", http.StatusInternalServerError)
259		return
260	}
261	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
262}
263
264func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
265	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
266	if !ok {
267		return
268	}
269	n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
270	iss, err := s.st.IssueByNumber(repo.ID, n)
271	if err != nil {
272		http.NotFound(w, r)
273		return
274	}
275	body := strings.TrimSpace(r.FormValue("body"))
276	if body == "" {
277		http.Error(w, "empty comment", http.StatusBadRequest)
278		return
279	}
280	if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
281		http.Error(w, "internal error", http.StatusInternalServerError)
282		return
283	}
284	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
285}
286
287func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
288	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
289	if !ok {
290		return
291	}
292	n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
293	m, err := s.st.MRByNumber(repo.ID, n)
294	if err != nil {
295		http.NotFound(w, r)
296		return
297	}
298	body := strings.TrimSpace(r.FormValue("body"))
299	if body == "" {
300		http.Error(w, "empty comment", http.StatusBadRequest)
301		return
302	}
303	if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
304		http.Error(w, "internal error", http.StatusInternalServerError)
305		return
306	}
307	http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
308}
309
310type editPage struct {
311	Site    string
312	Viewer  string
313	Repo    store.Repo
314	Ref     string
315	Path    string
316	Content string
317	Error   string
318}
319
320func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
321	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
322	if !ok {
323		return
324	}
325	ref := r.PathValue("ref")
326	filePath := strings.Trim(r.PathValue("path"), "/")
327	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
328	content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
329	if err != nil {
330		content = nil // new file
331	}
332	if gitutil.IsBinary(content) {
333		http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
334		return
335	}
336	s.render(w, "edit.html", editPage{
337		Site: s.siteName(), Viewer: u.Username, Repo: repo,
338		Ref: ref, Path: filePath, Content: string(content),
339	})
340}
341
342func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
343	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
344	if !ok {
345		return
346	}
347	ref := r.PathValue("ref")
348	filePath := strings.Trim(r.PathValue("path"), "/")
349	fail := func(msg string) {
350		s.render(w, "edit.html", editPage{
351			Site: s.siteName(), Viewer: u.Username, Repo: repo,
352			Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
353		})
354	}
355	// Web edits produce unsigned commits; a repo that requires signed
356	// commits must refuse them rather than violate its own policy.
357	if repo.Settings.RequireSignedCommits {
358		fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
359		return
360	}
361	email, err := s.st.PrimaryVerifiedEmail(u.ID)
362	if err != nil {
363		fail("internal error")
364		return
365	}
366	if email == "" {
367		fail("commits carry your identity: your account needs a verified primary email")
368		return
369	}
370	message := strings.TrimSpace(r.FormValue("message"))
371	if message == "" {
372		message = "edit " + filePath
373	}
374	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
375	if _, err := gitutil.CommitFileChange(dir, ref, filePath,
376		[]byte(r.FormValue("content")), u.Username, email, message); err != nil {
377		fail(err.Error())
378		return
379	}
380	s.st.MarkMirrorsDirty(repo.ID, "push")
381	http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
382}