internal/httpd/accounts.go
405 lines · 13270 bytes
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "slices"
7 "strings"
8 "time"
9
10 gossh "golang.org/x/crypto/ssh"
11
12 "gitbay.org/gitbay/internal/control"
13 "gitbay.org/gitbay/internal/gitutil"
14 "gitbay.org/gitbay/internal/policy"
15 "gitbay.org/gitbay/internal/protocol"
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 owner := r.FormValue("owner")
137 if owner == "" {
138 owner = u.Username
139 }
140 name := r.FormValue("name")
141 argv := []string{"repo", "create", owner + "/" + name}
142 if r.FormValue("visibility") == "private" {
143 argv = append(argv, "--private")
144 }
145 if _, msg, ok := s.runControl(u, argv); !ok {
146 s.renderNewRepo(w, u, msg)
147 return
148 }
149 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
150}
151
152// pinToggle pins or unpins the repo for the logged-in viewer.
153func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
154 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
155 if !ok {
156 return
157 }
158 if s.st.IsPinned(u.ID, repo.ID) {
159 s.st.UnpinRepo(u.ID, repo.ID)
160 } else {
161 s.st.PinRepo(u.ID, repo.ID)
162 }
163 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
164}
165
166// repoForUser is repoFor with a write/read permission requirement for a
167// logged-in user.
168func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
169 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
170 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
171 if err != nil {
172 http.NotFound(w, r)
173 return store.Repo{}, false
174 }
175 grant, err := s.st.AccessRole(repo.ID, u.ID)
176 if err != nil {
177 http.Error(w, "internal error", http.StatusInternalServerError)
178 return store.Repo{}, false
179 }
180 if !policy.CanRead(u, repo, grant) {
181 http.NotFound(w, r) // invisible: same as nonexistent
182 return store.Repo{}, false
183 }
184 if !perm(u, repo, grant) {
185 http.Error(w, "permission denied", http.StatusForbidden)
186 return store.Repo{}, false
187 }
188 return repo, true
189}
190
191// signupForm and signupSubmit front the SSH registration path for open
192// and invite instances: same store transactions, same rules, a pasted
193// public key instead of the connecting one.
194func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
195 s.renderSignup(w, "", "")
196}
197
198func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
199 s.render(w, "register.html", struct {
200 basePage
201 Host string
202 Mode string // open | invite
203 Error string
204 Username string
205 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
206}
207
208func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
209 username := strings.TrimSpace(r.FormValue("username"))
210 keyText := strings.TrimSpace(r.FormValue("key"))
211 pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
212 if err != nil {
213 s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
214 return
215 }
216 msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
217 strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
218 if code != 0 {
219 s.renderSignup(w, errMsg, username)
220 return
221 }
222 s.render(w, "registered.html", struct {
223 basePage
224 Username string
225 Message string
226 Host string
227 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
228}
229
230// issueCreateForm renders the new-issue form, prefilled from the repo's
231// default issue template when one exists.
232func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
233 p, ok := s.repoFor(w, r, "")
234 if !ok {
235 return
236 }
237 p.Tab = "issues"
238 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
239 body, tplName := "", ""
240 if want := r.URL.Query().Get("template"); want != "" {
241 for _, t := range templates {
242 if t.Name == want {
243 body, tplName = t.Body, t.Name
244 }
245 }
246 } else {
247 for _, t := range templates {
248 if t.Name == "issue-template.md" || body == "" {
249 body, tplName = t.Body, t.Name
250 }
251 if t.Name == "issue-template.md" {
252 break
253 }
254 }
255 }
256 s.render(w, "issuenew.html", struct {
257 repoPage
258 Body string
259 Template string
260 Templates []control.IssueTemplate
261 }{p, body, tplName, templates})
262}
263
264// Issue and merge request writes run the command the CLI runs, so the
265// archived check, notifications, body format and the audit entry have one
266// implementation. Bodies travel on stdin, the way --file - does.
267
268func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
269 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
270 title := strings.TrimSpace(r.FormValue("title"))
271 code, data, msg := s.dispatchJSON(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body"))
272 if code != protocol.ExitOK {
273 http.Error(w, msg, statusForExit(code))
274 return
275 }
276 n := int64(data["number"].(float64))
277 // Labels need write access, matching the SSH rule; the command refuses
278 // otherwise and the issue stands without them.
279 if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
280 s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
281 }
282 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
283}
284
285// issueEditSubmit edits title/body (author or write) and, with write
286// access, replaces the label set.
287func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
288 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
289 n := r.PathValue("n")
290 title := strings.TrimSpace(r.FormValue("title"))
291 code, _, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
292 if code != protocol.ExitOK {
293 http.Error(w, msg, statusForExit(code))
294 return
295 }
296 var cur struct {
297 Labels []string `json:"labels"`
298 }
299 if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
300 want := strings.Fields(r.FormValue("labels"))
301 var args []string
302 for _, l := range cur.Labels {
303 if !slices.Contains(want, l) {
304 args = append(args, "--remove", l)
305 }
306 }
307 for _, l := range want {
308 if !slices.Contains(cur.Labels, l) {
309 args = append(args, "--add", l)
310 }
311 }
312 if len(args) > 0 {
313 s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
314 }
315 }
316 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
317}
318
319// mrEditSubmit edits an MR's title/body (author or write).
320func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
321 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
322 n := r.PathValue("n")
323 title := strings.TrimSpace(r.FormValue("title"))
324 code, _, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
325 if code != protocol.ExitOK {
326 http.Error(w, msg, statusForExit(code))
327 return
328 }
329 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
330}
331
332func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
333 s.commentSubmit(w, r, u, "issue", "issues")
334}
335
336func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
337 s.commentSubmit(w, r, u, "mr", "mrs")
338}
339
340func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
341 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
342 n := r.PathValue("n")
343 code, _, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
344 if code != protocol.ExitOK {
345 http.Error(w, msg, statusForExit(code))
346 return
347 }
348 http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
349}
350
351type editPage struct {
352 basePage
353 Repo store.Repo
354 Ref string
355 Path string
356 Content string
357 Error string
358}
359
360func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
361 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
362 if !ok {
363 return
364 }
365 ref := r.PathValue("ref")
366 filePath := strings.Trim(r.PathValue("path"), "/")
367 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
368 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
369 if err != nil {
370 content = nil // new file
371 }
372 if gitutil.IsBinary(content) {
373 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
374 return
375 }
376 s.render(w, "edit.html", editPage{
377 basePage: s.baseFor(u), Repo: repo,
378 Ref: ref, Path: filePath, Content: string(content),
379 })
380}
381
382func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
383 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
384 if !ok {
385 return
386 }
387 ref := r.PathValue("ref")
388 filePath := strings.Trim(r.PathValue("path"), "/")
389
390 // Editing is a control command; the web supplies the form and lets
391 // the registry enforce the rules — signed-commit policy, verified
392 // identity, archived repositories — so every surface agrees on them.
393 argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
394 if message := strings.TrimSpace(r.FormValue("message")); message != "" {
395 argv = append(argv, "--message", message)
396 }
397 if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
398 s.render(w, "edit.html", editPage{
399 basePage: s.baseFor(u), Repo: repo,
400 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
401 })
402 return
403 }
404 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
405}