internal/httpd/accounts.go
382 lines · 11358 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
107func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
108 s.render(w, "new.html", struct {
109 Site string
110 Viewer string
111 Error string
112 }{s.siteName(), u.Username, ""})
113}
114
115func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
116 name := r.FormValue("name")
117 visibility := "public"
118 if r.FormValue("visibility") == "private" {
119 visibility = "private"
120 }
121 fail := func(msg string) {
122 s.render(w, "new.html", struct {
123 Site string
124 Viewer string
125 Error string
126 }{s.siteName(), u.Username, msg})
127 }
128 if err := policy.ValidateName(name); err != nil {
129 fail(err.Error())
130 return
131 }
132 id, err := s.st.CreateRepo("user", u.ID, name, visibility)
133 if err != nil {
134 fail(err.Error())
135 return
136 }
137 dir := control.RepoDir(s.cfg.Server.Root, u.Username, name)
138 if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
139 s.st.DeleteRepo(id)
140 fail("initializing repository failed")
141 return
142 }
143 http.Redirect(w, r, "/"+u.Username+"/"+name, http.StatusSeeOther)
144}
145
146// repoForUser is repoFor with a write/read permission requirement for a
147// logged-in user.
148func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
149 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
150 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
151 if err != nil {
152 http.NotFound(w, r)
153 return store.Repo{}, false
154 }
155 grant, err := s.st.AccessRole(repo.ID, u.ID)
156 if err != nil {
157 http.Error(w, "internal error", http.StatusInternalServerError)
158 return store.Repo{}, false
159 }
160 if !policy.CanRead(u, repo, grant) {
161 http.NotFound(w, r) // invisible: same as nonexistent
162 return store.Repo{}, false
163 }
164 if !perm(u, repo, grant) {
165 http.Error(w, "permission denied", http.StatusForbidden)
166 return store.Repo{}, false
167 }
168 return repo, true
169}
170
171// signupForm and signupSubmit front the SSH registration path for open
172// and invite instances: same store transactions, same rules, a pasted
173// public key instead of the connecting one.
174func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
175 s.renderSignup(w, "", "")
176}
177
178func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
179 s.render(w, "register.html", struct {
180 Site string
181 Viewer string
182 Host string
183 Mode string // open | invite
184 Error string
185 Username string
186 }{s.siteName(), "", s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
187}
188
189func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
190 username := strings.TrimSpace(r.FormValue("username"))
191 keyText := strings.TrimSpace(r.FormValue("key"))
192 pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
193 if err != nil {
194 s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
195 return
196 }
197 msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
198 strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
199 if code != 0 {
200 s.renderSignup(w, errMsg, username)
201 return
202 }
203 s.render(w, "registered.html", struct {
204 Site string
205 Viewer string
206 Username string
207 Message string
208 Host string
209 }{s.siteName(), "", username, msg, s.cfg.SiteHost()})
210}
211
212// issueCreateForm renders the new-issue form, prefilled from the repo's
213// default issue template when one exists.
214func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
215 p, ok := s.repoFor(w, r, "")
216 if !ok {
217 return
218 }
219 p.Tab = "issues"
220 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
221 body, tplName := "", ""
222 if want := r.URL.Query().Get("template"); want != "" {
223 for _, t := range templates {
224 if t.Name == want {
225 body, tplName = t.Body, t.Name
226 }
227 }
228 } else {
229 for _, t := range templates {
230 if t.Name == "issue-template.md" || body == "" {
231 body, tplName = t.Body, t.Name
232 }
233 if t.Name == "issue-template.md" {
234 break
235 }
236 }
237 }
238 s.render(w, "issuenew.html", struct {
239 repoPage
240 Body string
241 Template string
242 Templates []control.IssueTemplate
243 }{p, body, tplName, templates})
244}
245
246func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
247 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
248 if !ok {
249 return
250 }
251 title := strings.TrimSpace(r.FormValue("title"))
252 if title == "" {
253 http.Error(w, "title required", http.StatusBadRequest)
254 return
255 }
256 n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"))
257 if err != nil {
258 http.Error(w, "internal error", http.StatusInternalServerError)
259 return
260 }
261 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
262}
263
264func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
265 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
266 if !ok {
267 return
268 }
269 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
270 iss, err := s.st.IssueByNumber(repo.ID, n)
271 if err != nil {
272 http.NotFound(w, r)
273 return
274 }
275 body := strings.TrimSpace(r.FormValue("body"))
276 if body == "" {
277 http.Error(w, "empty comment", http.StatusBadRequest)
278 return
279 }
280 if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
281 http.Error(w, "internal error", http.StatusInternalServerError)
282 return
283 }
284 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
285}
286
287func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
288 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
289 if !ok {
290 return
291 }
292 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
293 m, err := s.st.MRByNumber(repo.ID, n)
294 if err != nil {
295 http.NotFound(w, r)
296 return
297 }
298 body := strings.TrimSpace(r.FormValue("body"))
299 if body == "" {
300 http.Error(w, "empty comment", http.StatusBadRequest)
301 return
302 }
303 if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
304 http.Error(w, "internal error", http.StatusInternalServerError)
305 return
306 }
307 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
308}
309
310type editPage struct {
311 Site string
312 Viewer string
313 Repo store.Repo
314 Ref string
315 Path string
316 Content string
317 Error string
318}
319
320func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
321 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
322 if !ok {
323 return
324 }
325 ref := r.PathValue("ref")
326 filePath := strings.Trim(r.PathValue("path"), "/")
327 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
328 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
329 if err != nil {
330 content = nil // new file
331 }
332 if gitutil.IsBinary(content) {
333 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
334 return
335 }
336 s.render(w, "edit.html", editPage{
337 Site: s.siteName(), Viewer: u.Username, Repo: repo,
338 Ref: ref, Path: filePath, Content: string(content),
339 })
340}
341
342func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
343 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
344 if !ok {
345 return
346 }
347 ref := r.PathValue("ref")
348 filePath := strings.Trim(r.PathValue("path"), "/")
349 fail := func(msg string) {
350 s.render(w, "edit.html", editPage{
351 Site: s.siteName(), Viewer: u.Username, Repo: repo,
352 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
353 })
354 }
355 // Web edits produce unsigned commits; a repo that requires signed
356 // commits must refuse them rather than violate its own policy.
357 if repo.Settings.RequireSignedCommits {
358 fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
359 return
360 }
361 email, err := s.st.PrimaryVerifiedEmail(u.ID)
362 if err != nil {
363 fail("internal error")
364 return
365 }
366 if email == "" {
367 fail("commits carry your identity: your account needs a verified primary email")
368 return
369 }
370 message := strings.TrimSpace(r.FormValue("message"))
371 if message == "" {
372 message = "edit " + filePath
373 }
374 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
375 if _, err := gitutil.CommitFileChange(dir, ref, filePath,
376 []byte(r.FormValue("content")), u.Username, email, message); err != nil {
377 fail(err.Error())
378 return
379 }
380 s.st.MarkMirrorsDirty(repo.ID, "push")
381 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
382}