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