internal/httpd/accounts.go
438 lines · 13001 bytes
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "strconv"
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/store"
16)
17
18const sessionCookie = "gitbay_session"
19
20// viewer returns the logged-in user, or a zero User for anonymous visitors.
21// Only meaningful in accounts mode; in view_only no session route exists so
22// every request is anonymous.
23func (s *Server) viewer(r *http.Request) store.User {
24 ck, err := r.Cookie(sessionCookie)
25 if err != nil {
26 return store.User{}
27 }
28 u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
29 if err != nil {
30 return store.User{}
31 }
32 return u
33}
34
35// requireUser wraps a handler that needs a session.
36func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
37 return func(w http.ResponseWriter, r *http.Request) {
38 u := s.viewer(r)
39 if u.ID == 0 {
40 http.Redirect(w, r, "/login", http.StatusSeeOther)
41 return
42 }
43 h(w, r, u)
44 }
45}
46
47// checkOrigin rejects cross-site POSTs. Sessions also use SameSite=Strict;
48// this is the second layer.
49func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
50 return func(w http.ResponseWriter, r *http.Request) {
51 if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
52 host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
53 if host != r.Host {
54 http.Error(w, "cross-origin request refused", http.StatusForbidden)
55 return
56 }
57 }
58 h(w, r)
59 }
60}
61
62func (s *Server) login(w http.ResponseWriter, r *http.Request) {
63 token := r.URL.Query().Get("token")
64 if token == "" {
65 s.render(w, "login.html", struct {
66 Site string
67 Viewer string
68 Error string
69 }{s.siteName(), "", ""})
70 return
71 }
72 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
73 if err != nil {
74 s.render(w, "login.html", struct {
75 Site string
76 Viewer string
77 Error string
78 }{s.siteName(), "", "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 Site string
123 Viewer string
124 Orgs []string
125 Error string
126 }{s.siteName(), u.Username, s.adminOrgs(u), errMsg})
127}
128
129func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
130 s.renderNewRepo(w, u, "")
131}
132
133func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
134 name := r.FormValue("name")
135 visibility := "public"
136 if r.FormValue("visibility") == "private" {
137 visibility = "private"
138 }
139 fail := func(msg string) { s.renderNewRepo(w, u, msg) }
140 if err := policy.ValidateName(name); err != nil {
141 fail(err.Error())
142 return
143 }
144 // Owner: yourself, or an org you admin — same rule as repo create.
145 owner := r.FormValue("owner")
146 ownerKind, ownerID := "user", u.ID
147 if owner == "" {
148 owner = u.Username
149 }
150 if owner != u.Username {
151 org, err := s.st.OrgByName(owner)
152 if err != nil {
153 fail("no such organization")
154 return
155 }
156 role, _ := s.st.OrgRole(org.ID, u.ID)
157 if role != "admin" {
158 fail("only admins of " + owner + " can create repositories there")
159 return
160 }
161 ownerKind, ownerID = "org", org.ID
162 }
163 id, err := s.st.CreateRepo(ownerKind, ownerID, name, visibility)
164 if err != nil {
165 fail(err.Error())
166 return
167 }
168 dir := control.RepoDir(s.cfg.Server.Root, owner, name)
169 if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
170 s.st.DeleteRepo(id)
171 fail("initializing repository failed")
172 return
173 }
174 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
175}
176
177// pinToggle pins or unpins the repo for the logged-in viewer.
178func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
179 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
180 if !ok {
181 return
182 }
183 if s.st.IsPinned(u.ID, repo.ID) {
184 s.st.UnpinRepo(u.ID, repo.ID)
185 } else {
186 s.st.PinRepo(u.ID, repo.ID)
187 }
188 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
189}
190
191// repoForUser is repoFor with a write/read permission requirement for a
192// logged-in user.
193func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
194 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
195 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
196 if err != nil {
197 http.NotFound(w, r)
198 return store.Repo{}, false
199 }
200 grant, err := s.st.AccessRole(repo.ID, u.ID)
201 if err != nil {
202 http.Error(w, "internal error", http.StatusInternalServerError)
203 return store.Repo{}, false
204 }
205 if !policy.CanRead(u, repo, grant) {
206 http.NotFound(w, r) // invisible: same as nonexistent
207 return store.Repo{}, false
208 }
209 if !perm(u, repo, grant) {
210 http.Error(w, "permission denied", http.StatusForbidden)
211 return store.Repo{}, false
212 }
213 return repo, true
214}
215
216// signupForm and signupSubmit front the SSH registration path for open
217// and invite instances: same store transactions, same rules, a pasted
218// public key instead of the connecting one.
219func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
220 s.renderSignup(w, "", "")
221}
222
223func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
224 s.render(w, "register.html", struct {
225 Site string
226 Viewer string
227 Host string
228 Mode string // open | invite
229 Error string
230 Username string
231 }{s.siteName(), "", s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
232}
233
234func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
235 username := strings.TrimSpace(r.FormValue("username"))
236 keyText := strings.TrimSpace(r.FormValue("key"))
237 pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
238 if err != nil {
239 s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
240 return
241 }
242 msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
243 strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
244 if code != 0 {
245 s.renderSignup(w, errMsg, username)
246 return
247 }
248 s.render(w, "registered.html", struct {
249 Site string
250 Viewer string
251 Username string
252 Message string
253 Host string
254 }{s.siteName(), "", 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"))
302 if err != nil {
303 http.Error(w, "internal error", http.StatusInternalServerError)
304 return
305 }
306 // Labels need write access, matching the SSH rule; ignored otherwise.
307 if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 {
308 grant, _ := s.st.AccessRole(repo.ID, u.ID)
309 if policy.CanWrite(u, repo, grant) {
310 if iss, err := s.st.IssueByNumber(repo.ID, n); err == nil {
311 for _, l := range labels {
312 s.st.SetIssueLabel(repo.ID, iss.ID, l, true)
313 }
314 }
315 }
316 }
317 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
318}
319
320func (s *Server) issueCommentSubmit(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 body := strings.TrimSpace(r.FormValue("body"))
332 if body == "" {
333 http.Error(w, "empty comment", http.StatusBadRequest)
334 return
335 }
336 if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
337 http.Error(w, "internal error", http.StatusInternalServerError)
338 return
339 }
340 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
341}
342
343func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
344 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
345 if !ok {
346 return
347 }
348 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
349 m, err := s.st.MRByNumber(repo.ID, n)
350 if err != nil {
351 http.NotFound(w, r)
352 return
353 }
354 body := strings.TrimSpace(r.FormValue("body"))
355 if body == "" {
356 http.Error(w, "empty comment", http.StatusBadRequest)
357 return
358 }
359 if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
360 http.Error(w, "internal error", http.StatusInternalServerError)
361 return
362 }
363 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
364}
365
366type editPage struct {
367 Site string
368 Viewer string
369 Repo store.Repo
370 Ref string
371 Path string
372 Content string
373 Error string
374}
375
376func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
377 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
378 if !ok {
379 return
380 }
381 ref := r.PathValue("ref")
382 filePath := strings.Trim(r.PathValue("path"), "/")
383 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
384 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
385 if err != nil {
386 content = nil // new file
387 }
388 if gitutil.IsBinary(content) {
389 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
390 return
391 }
392 s.render(w, "edit.html", editPage{
393 Site: s.siteName(), Viewer: u.Username, Repo: repo,
394 Ref: ref, Path: filePath, Content: string(content),
395 })
396}
397
398func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
399 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
400 if !ok {
401 return
402 }
403 ref := r.PathValue("ref")
404 filePath := strings.Trim(r.PathValue("path"), "/")
405 fail := func(msg string) {
406 s.render(w, "edit.html", editPage{
407 Site: s.siteName(), Viewer: u.Username, Repo: repo,
408 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
409 })
410 }
411 // Web edits produce unsigned commits; a repo that requires signed
412 // commits must refuse them rather than violate its own policy.
413 if repo.Settings.RequireSignedCommits {
414 fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
415 return
416 }
417 email, err := s.st.PrimaryVerifiedEmail(u.ID)
418 if err != nil {
419 fail("internal error")
420 return
421 }
422 if email == "" {
423 fail("commits carry your identity: your account needs a verified primary email")
424 return
425 }
426 message := strings.TrimSpace(r.FormValue("message"))
427 if message == "" {
428 message = "edit " + filePath
429 }
430 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
431 if _, err := gitutil.CommitFileChange(dir, ref, filePath,
432 []byte(r.FormValue("content")), u.Username, email, message); err != nil {
433 fail(err.Error())
434 return
435 }
436 s.st.MarkMirrorsDirty(repo.ID, "push")
437 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
438}