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