internal/httpd/accounts.go

13f864d416c4ab328bbb07bdc53867656d3ab678
gitbay/internal/httpd/accounts.go history · blame · raw

336 lines · 9813 bytes

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