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