internal/httpd/accounts.go
515 lines · 15561 bytes
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "slices"
7 "strconv"
8 "strings"
9 "time"
10
11 gossh "golang.org/x/crypto/ssh"
12
13 "gitbay.org/gitbay/internal/control"
14 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/policy"
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
63func (s *Server) login(w http.ResponseWriter, r *http.Request) {
64 token := r.URL.Query().Get("token")
65 if token == "" {
66 s.render(w, "login.html", struct {
67 Site string
68 Viewer string
69 Error string
70 }{s.siteName(), "", ""})
71 return
72 }
73 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
74 if err != nil {
75 s.render(w, "login.html", struct {
76 Site string
77 Viewer string
78 Error string
79 }{s.siteName(), "", "that login link is invalid, expired, or already used — mint a new one"})
80 return
81 }
82 sessTok, sessHash, err := store.NewToken()
83 if err != nil {
84 http.Error(w, "internal error", http.StatusInternalServerError)
85 return
86 }
87 if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
88 http.Error(w, "internal error", http.StatusInternalServerError)
89 return
90 }
91 http.SetCookie(w, &http.Cookie{
92 Name: sessionCookie, Value: sessTok, Path: "/",
93 HttpOnly: true, SameSite: http.SameSiteStrictMode,
94 Secure: s.cfg.HTTP.TLS != "off",
95 MaxAge: 7 * 24 * 3600,
96 })
97 http.Redirect(w, r, "/", http.StatusSeeOther)
98}
99
100func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
101 if ck, err := r.Cookie(sessionCookie); err == nil {
102 s.st.DeleteWebSession(store.HashToken(ck.Value))
103 }
104 http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
105 http.Redirect(w, r, "/", http.StatusSeeOther)
106}
107
108// adminOrgs lists organizations the user administers, for owner pickers.
109func (s *Server) adminOrgs(u store.User) []string {
110 var out []string
111 if orgs, err := s.st.ListOrgsForUser(u.ID); err == nil {
112 for _, o := range orgs {
113 if o.Role == "admin" {
114 out = append(out, o.Username)
115 }
116 }
117 }
118 return out
119}
120
121func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
122 s.render(w, "new.html", struct {
123 Site string
124 Viewer string
125 Orgs []string
126 Error string
127 }{s.siteName(), u.Username, s.adminOrgs(u), errMsg})
128}
129
130func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
131 s.renderNewRepo(w, u, "")
132}
133
134func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
135 name := r.FormValue("name")
136 visibility := "public"
137 if r.FormValue("visibility") == "private" {
138 visibility = "private"
139 }
140 fail := func(msg string) { s.renderNewRepo(w, u, msg) }
141 if err := policy.ValidateName(name); err != nil {
142 fail(err.Error())
143 return
144 }
145 // Owner: yourself, or an org you admin — same rule as repo create.
146 owner := r.FormValue("owner")
147 ownerKind, ownerID := "user", u.ID
148 if owner == "" {
149 owner = u.Username
150 }
151 if owner != u.Username {
152 org, err := s.st.OrgByName(owner)
153 if err != nil {
154 fail("no such organization")
155 return
156 }
157 role, _ := s.st.OrgRole(org.ID, u.ID)
158 if role != "admin" {
159 fail("only admins of " + owner + " can create repositories there")
160 return
161 }
162 ownerKind, ownerID = "org", org.ID
163 }
164 id, err := s.st.CreateRepo(ownerKind, ownerID, name, visibility)
165 if err != nil {
166 fail(err.Error())
167 return
168 }
169 dir := control.RepoDir(s.cfg.Server.Root, owner, name)
170 if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
171 s.st.DeleteRepo(id)
172 fail("initializing repository failed")
173 return
174 }
175 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
176}
177
178// pinToggle pins or unpins the repo for the logged-in viewer.
179func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
180 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
181 if !ok {
182 return
183 }
184 if s.st.IsPinned(u.ID, repo.ID) {
185 s.st.UnpinRepo(u.ID, repo.ID)
186 } else {
187 s.st.PinRepo(u.ID, repo.ID)
188 }
189 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
190}
191
192// repoForUser is repoFor with a write/read permission requirement for a
193// logged-in user.
194func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
195 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
196 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
197 if err != nil {
198 http.NotFound(w, r)
199 return store.Repo{}, false
200 }
201 grant, err := s.st.AccessRole(repo.ID, u.ID)
202 if err != nil {
203 http.Error(w, "internal error", http.StatusInternalServerError)
204 return store.Repo{}, false
205 }
206 if !policy.CanRead(u, repo, grant) {
207 http.NotFound(w, r) // invisible: same as nonexistent
208 return store.Repo{}, false
209 }
210 if !perm(u, repo, grant) {
211 http.Error(w, "permission denied", http.StatusForbidden)
212 return store.Repo{}, false
213 }
214 return repo, true
215}
216
217// signupForm and signupSubmit front the SSH registration path for open
218// and invite instances: same store transactions, same rules, a pasted
219// public key instead of the connecting one.
220func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
221 s.renderSignup(w, "", "")
222}
223
224func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
225 s.render(w, "register.html", struct {
226 Site string
227 Viewer string
228 Host string
229 Mode string // open | invite
230 Error string
231 Username string
232 }{s.siteName(), "", s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
233}
234
235func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
236 username := strings.TrimSpace(r.FormValue("username"))
237 keyText := strings.TrimSpace(r.FormValue("key"))
238 pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
239 if err != nil {
240 s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
241 return
242 }
243 msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
244 strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
245 if code != 0 {
246 s.renderSignup(w, errMsg, username)
247 return
248 }
249 s.render(w, "registered.html", struct {
250 Site string
251 Viewer string
252 Username string
253 Message string
254 Host string
255 }{s.siteName(), "", username, msg, s.cfg.SiteHost()})
256}
257
258// issueCreateForm renders the new-issue form, prefilled from the repo's
259// default issue template when one exists.
260func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
261 p, ok := s.repoFor(w, r, "")
262 if !ok {
263 return
264 }
265 p.Tab = "issues"
266 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
267 body, tplName := "", ""
268 if want := r.URL.Query().Get("template"); want != "" {
269 for _, t := range templates {
270 if t.Name == want {
271 body, tplName = t.Body, t.Name
272 }
273 }
274 } else {
275 for _, t := range templates {
276 if t.Name == "issue-template.md" || body == "" {
277 body, tplName = t.Body, t.Name
278 }
279 if t.Name == "issue-template.md" {
280 break
281 }
282 }
283 }
284 s.render(w, "issuenew.html", struct {
285 repoPage
286 Body string
287 Template string
288 Templates []control.IssueTemplate
289 }{p, body, tplName, templates})
290}
291
292func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
293 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
294 if !ok {
295 return
296 }
297 title := strings.TrimSpace(r.FormValue("title"))
298 if title == "" {
299 http.Error(w, "title required", http.StatusBadRequest)
300 return
301 }
302 n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"))
303 if err != nil {
304 http.Error(w, "internal error", http.StatusInternalServerError)
305 return
306 }
307 s.st.RecordEvent(repo.ID, u.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
308 // Labels need write access, matching the SSH rule; ignored otherwise.
309 if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 {
310 grant, _ := s.st.AccessRole(repo.ID, u.ID)
311 if policy.CanWrite(u, repo, grant) {
312 if iss, err := s.st.IssueByNumber(repo.ID, n); err == nil {
313 for _, l := range labels {
314 s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
315 }
316 }
317 }
318 }
319 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
320}
321
322// issueEditSubmit edits title/body (author or write) and, with write
323// access, replaces the label set.
324func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
325 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
326 if !ok {
327 return
328 }
329 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
330 iss, err := s.st.IssueByNumber(repo.ID, n)
331 if err != nil {
332 http.NotFound(w, r)
333 return
334 }
335 grant, _ := s.st.AccessRole(repo.ID, u.ID)
336 canWrite := policy.CanWrite(u, repo, grant)
337 if iss.Author != u.Username && !canWrite {
338 http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
339 return
340 }
341 title := strings.TrimSpace(r.FormValue("title"))
342 if title == "" {
343 http.Error(w, "title required", http.StatusBadRequest)
344 return
345 }
346 body := r.FormValue("body")
347 if err := s.st.UpdateIssueText(iss.ID, &title, &body); err != nil {
348 http.Error(w, "internal error", http.StatusInternalServerError)
349 return
350 }
351 if canWrite {
352 want := strings.Fields(r.FormValue("labels"))
353 for _, l := range iss.Labels {
354 if !slices.Contains(want, l) {
355 s.st.SetIssueLabel(repo.ID, iss.ID, l, false)
356 }
357 }
358 for _, l := range want {
359 s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
360 }
361 }
362 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
363}
364
365// mrEditSubmit edits an MR's title/body (author or write).
366func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
367 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
368 if !ok {
369 return
370 }
371 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
372 m, err := s.st.MRByNumber(repo.ID, n)
373 if err != nil {
374 http.NotFound(w, r)
375 return
376 }
377 grant, _ := s.st.AccessRole(repo.ID, u.ID)
378 if m.Author != u.Username && !policy.CanWrite(u, repo, grant) {
379 http.Error(w, "only the author or users with write access can edit", http.StatusForbidden)
380 return
381 }
382 title := strings.TrimSpace(r.FormValue("title"))
383 if title == "" {
384 http.Error(w, "title required", http.StatusBadRequest)
385 return
386 }
387 body := r.FormValue("body")
388 if err := s.st.UpdateMRText(m.ID, &title, &body); err != nil {
389 http.Error(w, "internal error", http.StatusInternalServerError)
390 return
391 }
392 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
393}
394
395func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
396 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
397 if !ok {
398 return
399 }
400 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
401 iss, err := s.st.IssueByNumber(repo.ID, n)
402 if err != nil {
403 http.NotFound(w, r)
404 return
405 }
406 body := strings.TrimSpace(r.FormValue("body"))
407 if body == "" {
408 http.Error(w, "empty comment", http.StatusBadRequest)
409 return
410 }
411 if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
412 http.Error(w, "internal error", http.StatusInternalServerError)
413 return
414 }
415 s.st.RecordEvent(repo.ID, u.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, n))
416 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
417}
418
419func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
420 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
421 if !ok {
422 return
423 }
424 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
425 m, err := s.st.MRByNumber(repo.ID, n)
426 if err != nil {
427 http.NotFound(w, r)
428 return
429 }
430 body := strings.TrimSpace(r.FormValue("body"))
431 if body == "" {
432 http.Error(w, "empty comment", http.StatusBadRequest)
433 return
434 }
435 if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
436 http.Error(w, "internal error", http.StatusInternalServerError)
437 return
438 }
439 s.st.RecordEvent(repo.ID, u.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, n))
440 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
441}
442
443type editPage struct {
444 Site string
445 Viewer string
446 Repo store.Repo
447 Ref string
448 Path string
449 Content string
450 Error string
451}
452
453func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
454 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
455 if !ok {
456 return
457 }
458 ref := r.PathValue("ref")
459 filePath := strings.Trim(r.PathValue("path"), "/")
460 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
461 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
462 if err != nil {
463 content = nil // new file
464 }
465 if gitutil.IsBinary(content) {
466 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
467 return
468 }
469 s.render(w, "edit.html", editPage{
470 Site: s.siteName(), Viewer: u.Username, Repo: repo,
471 Ref: ref, Path: filePath, Content: string(content),
472 })
473}
474
475func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
476 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
477 if !ok {
478 return
479 }
480 ref := r.PathValue("ref")
481 filePath := strings.Trim(r.PathValue("path"), "/")
482 fail := func(msg string) {
483 s.render(w, "edit.html", editPage{
484 Site: s.siteName(), Viewer: u.Username, Repo: repo,
485 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
486 })
487 }
488 // Web edits produce unsigned commits; a repo that requires signed
489 // commits must refuse them rather than violate its own policy.
490 if repo.Settings.RequireSignedCommits {
491 fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
492 return
493 }
494 email, err := s.st.PrimaryVerifiedEmail(u.ID)
495 if err != nil {
496 fail("internal error")
497 return
498 }
499 if email == "" {
500 fail("commits carry your identity: your account needs a verified primary email")
501 return
502 }
503 message := strings.TrimSpace(r.FormValue("message"))
504 if message == "" {
505 message = "edit " + filePath
506 }
507 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
508 if _, err := gitutil.CommitFileChange(dir, ref, filePath,
509 []byte(r.FormValue("content")), u.Username, email, message); err != nil {
510 fail(err.Error())
511 return
512 }
513 s.st.MarkMirrorsDirty(repo.ID, "push")
514 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
515}