internal/httpd/accounts.go

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

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