internal/httpd/accounts.go
464 lines · 15774 bytes
1package httpd
2
3import (
4 "fmt"
5 "log"
6 "net/http"
7 "slices"
8 "strconv"
9 "strings"
10 "time"
11
12 gossh "golang.org/x/crypto/ssh"
13
14 "gitbay.org/gitbay/internal/control"
15 "gitbay.org/gitbay/internal/gitutil"
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "gitbay.org/gitbay/internal/store"
19)
20
21const sessionCookie = "gitbay_session"
22
23// sessionSameSite is Lax so a login link followed from a mail client keeps
24// its session through the redirect. Cross-site POSTs are refused by
25// checkOrigin and carry no Lax cookie anyway.
26const sessionSameSite = http.SameSiteLaxMode
27
28// badLoginToken is what every refused /login?token= gets, whatever the
29// reason. The reasons differ in whether the account exists.
30const badLoginToken = "that login link is invalid, expired, or already used — mint a new one"
31
32// viewer returns the logged-in user, or a zero User for anonymous visitors.
33// Only meaningful in accounts mode; in view_only no session route exists so
34// every request is anonymous.
35func (s *Server) viewer(r *http.Request) store.User {
36 ck, err := r.Cookie(sessionCookie)
37 if err != nil {
38 return store.User{}
39 }
40 u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
41 if err != nil {
42 return store.User{}
43 }
44 return u
45}
46
47// requireUser wraps a handler that needs a session.
48func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
49 return func(w http.ResponseWriter, r *http.Request) {
50 u := s.viewer(r)
51 if u.ID == 0 {
52 http.Redirect(w, r, "/login", http.StatusSeeOther)
53 return
54 }
55 h(w, r, u)
56 }
57}
58
59// checkOrigin rejects cross-site POSTs. It is the primary CSRF defense:
60// sessions use SameSite=Lax, which withholds the cookie from a cross-site
61// POST but not from a cross-site top-level GET.
62func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
63 return func(w http.ResponseWriter, r *http.Request) {
64 if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
65 host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
66 if host != r.Host {
67 http.Error(w, "cross-origin request refused", http.StatusForbidden)
68 return
69 }
70 }
71 h(w, r)
72 }
73}
74
75// renderLogin draws the login page. Mode carries the registration mode so
76// the page can tell a brand-new visitor how to get an account. EmailLogin
77// says whether this instance can mail a link; Sent switches the page to the
78// confirmation that follows a request.
79func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool) {
80 s.render(w, "login.html", struct {
81 basePage
82 Mode string // closed | invite | open
83 Error string
84 EmailLogin bool
85 Sent bool
86 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()},
87 s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent})
88}
89
90// emailLoginEnabled reports whether a link can be mailed at all. There is no
91// separate switch: the capability is exactly the SMTP the instance already
92// configured for verification and notification mail.
93func (s *Server) emailLoginEnabled() bool {
94 return s.cfg.Web.Mode == "accounts" && s.cfg.Mail.SMTPHost != ""
95}
96
97// loginSubmit mails a one-time login link. The response is the same page
98// whatever happened, including when nothing happened.
99func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
100 if !s.emailLoginEnabled() {
101 s.notFound(w, r)
102 return
103 }
104 // The per-account bound lives in the store and survives a restart; this
105 // one stops a single source from spending every account's budget.
106 if allowed, wait := s.apiLimit.allow("login"+s.clientIP(r), true); !allowed {
107 w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
108 http.Error(w, "too many login requests; wait a moment", http.StatusTooManyRequests)
109 return
110 }
111 if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil {
112 log.Printf("login link: %v", err)
113 }
114 s.renderLogin(w, "", true)
115}
116
117func (s *Server) login(w http.ResponseWriter, r *http.Request) {
118 token := r.URL.Query().Get("token")
119 if token == "" {
120 s.renderLogin(w, "", false)
121 return
122 }
123 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
124 if err != nil {
125 s.renderLogin(w, badLoginToken, false)
126 return
127 }
128 // A token minted before the account was suspended is still consumable,
129 // and the session it would create renders every page the account can
130 // read. Checking here covers every mint path. The message is the one a
131 // bad token gets: a distinct one would confirm the account exists.
132 if u, err := s.st.UserByID(userID); err != nil || u.Disabled {
133 s.renderLogin(w, badLoginToken, false)
134 return
135 }
136 sessTok, sessHash, err := store.NewToken()
137 if err != nil {
138 http.Error(w, "internal error", http.StatusInternalServerError)
139 return
140 }
141 if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
142 http.Error(w, "internal error", http.StatusInternalServerError)
143 return
144 }
145 http.SetCookie(w, s.sessionCookieFor(sessTok))
146 http.Redirect(w, r, "/", http.StatusSeeOther)
147}
148
149// sessionCookieFor is the cookie a new session ships in. Secure follows TLS
150// the way clearCookie does, so a plain-HTTP deployment still works.
151func (s *Server) sessionCookieFor(tok string) *http.Cookie {
152 return &http.Cookie{
153 Name: sessionCookie, Value: tok, Path: "/",
154 HttpOnly: true, SameSite: sessionSameSite,
155 Secure: s.cfg.HTTP.TLS != "off",
156 MaxAge: 7 * 24 * 3600,
157 }
158}
159
160func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
161 if ck, err := r.Cookie(sessionCookie); err == nil {
162 s.st.DeleteWebSession(store.HashToken(ck.Value))
163 }
164 http.SetCookie(w, s.clearCookie(sessionCookie, sessionSameSite))
165 http.Redirect(w, r, "/", http.StatusSeeOther)
166}
167
168// adminOrgs lists organizations the user administers, for owner pickers.
169func (s *Server) adminOrgs(u store.User) []string {
170 var out []string
171 if orgs, err := s.st.ListOrgsForUser(u.ID); err == nil {
172 for _, o := range orgs {
173 if o.Role == "admin" {
174 out = append(out, o.Username)
175 }
176 }
177 }
178 return out
179}
180
181func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
182 s.render(w, "new.html", struct {
183 basePage
184 Orgs []string
185 Error string
186 }{s.baseFor(u), s.adminOrgs(u), errMsg})
187}
188
189func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
190 s.renderNewRepo(w, u, "")
191}
192
193func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
194 owner := r.FormValue("owner")
195 if owner == "" {
196 owner = u.Username
197 }
198 name := r.FormValue("name")
199 argv := []string{"repo", "create", owner + "/" + name}
200 if r.FormValue("visibility") == "private" {
201 argv = append(argv, "--private")
202 }
203 if _, msg, ok := s.runControl(u, argv); !ok {
204 s.renderNewRepo(w, u, msg)
205 return
206 }
207 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
208}
209
210// pinToggle pins or unpins the repo for the logged-in viewer.
211func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
212 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
213 if !ok {
214 return
215 }
216 if s.st.IsPinned(u.ID, repo.ID) {
217 s.st.UnpinRepo(u.ID, repo.ID)
218 } else {
219 s.st.PinRepo(u.ID, repo.ID)
220 }
221 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
222}
223
224// repoForUser is repoFor with a write/read permission requirement for a
225// logged-in user.
226func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
227 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
228 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
229 if err != nil {
230 http.NotFound(w, r)
231 return store.Repo{}, false
232 }
233 grant, err := s.st.AccessRole(repo.ID, u.ID)
234 if err != nil {
235 http.Error(w, "internal error", http.StatusInternalServerError)
236 return store.Repo{}, false
237 }
238 if !policy.CanRead(u, repo, grant) {
239 http.NotFound(w, r) // invisible: same as nonexistent
240 return store.Repo{}, false
241 }
242 if !perm(u, repo, grant) {
243 http.Error(w, "permission denied", http.StatusForbidden)
244 return store.Repo{}, false
245 }
246 return repo, true
247}
248
249// signupForm and signupSubmit front the SSH registration path for open
250// and invite instances: same store transactions, same rules, a pasted
251// public key instead of the connecting one.
252func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
253 s.renderSignup(w, "", "")
254}
255
256func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
257 s.render(w, "register.html", struct {
258 basePage
259 Host string
260 Mode string // open | invite
261 Error string
262 Username string
263 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
264}
265
266func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
267 username := strings.TrimSpace(r.FormValue("username"))
268 keyText := strings.TrimSpace(r.FormValue("key"))
269 pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
270 if err != nil {
271 s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
272 return
273 }
274 msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
275 strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
276 if code != 0 {
277 s.renderSignup(w, errMsg, username)
278 return
279 }
280 s.render(w, "registered.html", struct {
281 basePage
282 Username string
283 Message string
284 Host string
285 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
286}
287
288// issueCreateForm renders the new-issue form, prefilled from the repo's
289// default issue template when one exists.
290func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
291 p, ok := s.repoFor(w, r, "")
292 if !ok {
293 return
294 }
295 p.Tab = "issues"
296 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
297 body, tplName := "", ""
298 if want := r.URL.Query().Get("template"); want != "" {
299 for _, t := range templates {
300 if t.Name == want {
301 body, tplName = t.Body, t.Name
302 }
303 }
304 } else {
305 for _, t := range templates {
306 if t.Name == "issue-template.md" || body == "" {
307 body, tplName = t.Body, t.Name
308 }
309 if t.Name == "issue-template.md" {
310 break
311 }
312 }
313 }
314 s.render(w, "issuenew.html", struct {
315 repoPage
316 Body string
317 Template string
318 Templates []control.IssueTemplate
319 }{p, body, tplName, templates})
320}
321
322// Issue and merge request writes run the command the CLI runs, so the
323// archived check, notifications, body format and the audit entry have one
324// implementation. Bodies travel on stdin, the way --file - does.
325
326func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
327 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
328 title := strings.TrimSpace(r.FormValue("title"))
329 var created control.Created
330 code, msg := s.dispatchIntoStdin(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body"), &created)
331 if code != protocol.ExitOK {
332 http.Error(w, msg, statusForExit(code))
333 return
334 }
335 n := created.Number
336 // Labels need write access, matching the SSH rule; the command refuses
337 // otherwise and the issue stands without them.
338 if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
339 s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
340 }
341 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
342}
343
344// issueEditSubmit edits title/body (author or write) and, with write
345// access, replaces the label set.
346func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
347 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
348 n := r.PathValue("n")
349 title := strings.TrimSpace(r.FormValue("title"))
350 code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
351 if code != protocol.ExitOK {
352 http.Error(w, msg, statusForExit(code))
353 return
354 }
355 var cur struct {
356 Labels []string `json:"labels"`
357 }
358 if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
359 want := strings.Fields(r.FormValue("labels"))
360 var args []string
361 for _, l := range cur.Labels {
362 if !slices.Contains(want, l) {
363 args = append(args, "--remove", l)
364 }
365 }
366 for _, l := range want {
367 if !slices.Contains(cur.Labels, l) {
368 args = append(args, "--add", l)
369 }
370 }
371 if len(args) > 0 {
372 s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
373 }
374 }
375 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
376}
377
378// mrEditSubmit edits an MR's title/body (author or write).
379func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
380 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
381 n := r.PathValue("n")
382 title := strings.TrimSpace(r.FormValue("title"))
383 code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
384 if code != protocol.ExitOK {
385 http.Error(w, msg, statusForExit(code))
386 return
387 }
388 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
389}
390
391func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
392 s.commentSubmit(w, r, u, "issue", "issues")
393}
394
395func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
396 s.commentSubmit(w, r, u, "mr", "mrs")
397}
398
399func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
400 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
401 n := r.PathValue("n")
402 code, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
403 if code != protocol.ExitOK {
404 http.Error(w, msg, statusForExit(code))
405 return
406 }
407 http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
408}
409
410type editPage struct {
411 basePage
412 Repo store.Repo
413 Ref string
414 Path string
415 Content string
416 Error string
417}
418
419func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
420 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
421 if !ok {
422 return
423 }
424 ref := r.PathValue("ref")
425 filePath := strings.Trim(r.PathValue("path"), "/")
426 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
427 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
428 if err != nil {
429 content = nil // new file
430 }
431 if gitutil.IsBinary(content) {
432 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
433 return
434 }
435 s.render(w, "edit.html", editPage{
436 basePage: s.baseFor(u), Repo: repo,
437 Ref: ref, Path: filePath, Content: string(content),
438 })
439}
440
441func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
442 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
443 if !ok {
444 return
445 }
446 ref := r.PathValue("ref")
447 filePath := strings.Trim(r.PathValue("path"), "/")
448
449 // Editing is a control command; the web supplies the form and lets
450 // the registry enforce the rules — signed-commit policy, verified
451 // identity, archived repositories — so every surface agrees on them.
452 argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
453 if message := strings.TrimSpace(r.FormValue("message")); message != "" {
454 argv = append(argv, "--message", message)
455 }
456 if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
457 s.render(w, "edit.html", editPage{
458 basePage: s.baseFor(u), Repo: repo,
459 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
460 })
461 return
462 }
463 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
464}