internal/httpd/accounts.go
336 lines · 9813 bytes
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "strconv"
7 "strings"
8 "time"
9
10 "gitbay.org/gitbay/internal/control"
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy"
13 "gitbay.org/gitbay/internal/store"
14)
15
16const sessionCookie = "gitbay_session"
17
18// viewer returns the logged-in user, or a zero User for anonymous visitors.
19// Only meaningful in accounts mode; in view_only no session route exists so
20// every request is anonymous.
21func (s *Server) viewer(r *http.Request) store.User {
22 ck, err := r.Cookie(sessionCookie)
23 if err != nil {
24 return store.User{}
25 }
26 u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
27 if err != nil {
28 return store.User{}
29 }
30 return u
31}
32
33// requireUser wraps a handler that needs a session.
34func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
35 return func(w http.ResponseWriter, r *http.Request) {
36 u := s.viewer(r)
37 if u.ID == 0 {
38 http.Redirect(w, r, "/login", http.StatusSeeOther)
39 return
40 }
41 h(w, r, u)
42 }
43}
44
45// checkOrigin rejects cross-site POSTs. Sessions also use SameSite=Strict;
46// this is the second layer.
47func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
48 return func(w http.ResponseWriter, r *http.Request) {
49 if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
50 host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
51 if host != r.Host {
52 http.Error(w, "cross-origin request refused", http.StatusForbidden)
53 return
54 }
55 }
56 h(w, r)
57 }
58}
59
60func (s *Server) login(w http.ResponseWriter, r *http.Request) {
61 token := r.URL.Query().Get("token")
62 if token == "" {
63 s.render(w, "login.html", struct {
64 Site string
65 Error string
66 }{s.siteName(), ""})
67 return
68 }
69 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
70 if err != nil {
71 s.render(w, "login.html", struct {
72 Site string
73 Error string
74 }{s.siteName(), "that login link is invalid, expired, or already used — mint a new one"})
75 return
76 }
77 sessTok, sessHash, err := store.NewToken()
78 if err != nil {
79 http.Error(w, "internal error", http.StatusInternalServerError)
80 return
81 }
82 if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
83 http.Error(w, "internal error", http.StatusInternalServerError)
84 return
85 }
86 http.SetCookie(w, &http.Cookie{
87 Name: sessionCookie, Value: sessTok, Path: "/",
88 HttpOnly: true, SameSite: http.SameSiteStrictMode,
89 Secure: s.cfg.HTTP.TLS != "off",
90 MaxAge: 7 * 24 * 3600,
91 })
92 http.Redirect(w, r, "/", http.StatusSeeOther)
93}
94
95func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
96 if ck, err := r.Cookie(sessionCookie); err == nil {
97 s.st.DeleteWebSession(store.HashToken(ck.Value))
98 }
99 http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
100 http.Redirect(w, r, "/", http.StatusSeeOther)
101}
102
103func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
104 s.render(w, "new.html", struct {
105 Site string
106 Viewer string
107 Error string
108 }{s.siteName(), u.Username, ""})
109}
110
111func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
112 name := r.FormValue("name")
113 visibility := "public"
114 if r.FormValue("visibility") == "private" {
115 visibility = "private"
116 }
117 fail := func(msg string) {
118 s.render(w, "new.html", struct {
119 Site string
120 Viewer string
121 Error string
122 }{s.siteName(), u.Username, msg})
123 }
124 if err := policy.ValidateName(name); err != nil {
125 fail(err.Error())
126 return
127 }
128 id, err := s.st.CreateRepo("user", u.ID, name, visibility)
129 if err != nil {
130 fail(err.Error())
131 return
132 }
133 dir := control.RepoDir(s.cfg.Server.Root, u.Username, name)
134 if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
135 s.st.DeleteRepo(id)
136 fail("initializing repository failed")
137 return
138 }
139 http.Redirect(w, r, "/"+u.Username+"/"+name, http.StatusSeeOther)
140}
141
142// repoForUser is repoFor with a write/read permission requirement for a
143// logged-in user.
144func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
145 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
146 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
147 if err != nil {
148 http.NotFound(w, r)
149 return store.Repo{}, false
150 }
151 grant, err := s.st.AccessRole(repo.ID, u.ID)
152 if err != nil {
153 http.Error(w, "internal error", http.StatusInternalServerError)
154 return store.Repo{}, false
155 }
156 if !policy.CanRead(u, repo, grant) {
157 http.NotFound(w, r) // invisible: same as nonexistent
158 return store.Repo{}, false
159 }
160 if !perm(u, repo, grant) {
161 http.Error(w, "permission denied", http.StatusForbidden)
162 return store.Repo{}, false
163 }
164 return repo, true
165}
166
167// issueCreateForm renders the new-issue form, prefilled from the repo's
168// default issue template when one exists.
169func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
170 p, ok := s.repoFor(w, r, "")
171 if !ok {
172 return
173 }
174 p.Tab = "issues"
175 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
176 body, tplName := "", ""
177 if want := r.URL.Query().Get("template"); want != "" {
178 for _, t := range templates {
179 if t.Name == want {
180 body, tplName = t.Body, t.Name
181 }
182 }
183 } else {
184 for _, t := range templates {
185 if t.Name == "issue-template.md" || body == "" {
186 body, tplName = t.Body, t.Name
187 }
188 if t.Name == "issue-template.md" {
189 break
190 }
191 }
192 }
193 s.render(w, "issuenew.html", struct {
194 repoPage
195 Body string
196 Template string
197 Templates []control.IssueTemplate
198 }{p, body, tplName, templates})
199}
200
201func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
202 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
203 if !ok {
204 return
205 }
206 title := strings.TrimSpace(r.FormValue("title"))
207 if title == "" {
208 http.Error(w, "title required", http.StatusBadRequest)
209 return
210 }
211 n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"))
212 if err != nil {
213 http.Error(w, "internal error", http.StatusInternalServerError)
214 return
215 }
216 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
217}
218
219func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
220 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
221 if !ok {
222 return
223 }
224 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
225 iss, err := s.st.IssueByNumber(repo.ID, n)
226 if err != nil {
227 http.NotFound(w, r)
228 return
229 }
230 body := strings.TrimSpace(r.FormValue("body"))
231 if body == "" {
232 http.Error(w, "empty comment", http.StatusBadRequest)
233 return
234 }
235 if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
236 http.Error(w, "internal error", http.StatusInternalServerError)
237 return
238 }
239 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
240}
241
242func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
243 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
244 if !ok {
245 return
246 }
247 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
248 m, err := s.st.MRByNumber(repo.ID, n)
249 if err != nil {
250 http.NotFound(w, r)
251 return
252 }
253 body := strings.TrimSpace(r.FormValue("body"))
254 if body == "" {
255 http.Error(w, "empty comment", http.StatusBadRequest)
256 return
257 }
258 if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
259 http.Error(w, "internal error", http.StatusInternalServerError)
260 return
261 }
262 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
263}
264
265type editPage struct {
266 Site string
267 Viewer string
268 Repo store.Repo
269 Ref string
270 Path string
271 Content string
272 Error string
273}
274
275func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
276 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
277 if !ok {
278 return
279 }
280 ref := r.PathValue("ref")
281 filePath := strings.Trim(r.PathValue("path"), "/")
282 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
283 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
284 if err != nil {
285 content = nil // new file
286 }
287 if gitutil.IsBinary(content) {
288 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
289 return
290 }
291 s.render(w, "edit.html", editPage{
292 Site: s.siteName(), Viewer: u.Username, Repo: repo,
293 Ref: ref, Path: filePath, Content: string(content),
294 })
295}
296
297func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
298 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
299 if !ok {
300 return
301 }
302 ref := r.PathValue("ref")
303 filePath := strings.Trim(r.PathValue("path"), "/")
304 fail := func(msg string) {
305 s.render(w, "edit.html", editPage{
306 Site: s.siteName(), Viewer: u.Username, Repo: repo,
307 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
308 })
309 }
310 // Web edits produce unsigned commits; a repo that requires signed
311 // commits must refuse them rather than violate its own policy.
312 if repo.Settings.RequireSignedCommits {
313 fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
314 return
315 }
316 email, err := s.st.PrimaryVerifiedEmail(u.ID)
317 if err != nil {
318 fail("internal error")
319 return
320 }
321 if email == "" {
322 fail("commits carry your identity: your account needs a verified primary email")
323 return
324 }
325 message := strings.TrimSpace(r.FormValue("message"))
326 if message == "" {
327 message = "edit " + filePath
328 }
329 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
330 if _, err := gitutil.CommitFileChange(dir, ref, filePath,
331 []byte(r.FormValue("content")), u.Username, email, message); err != nil {
332 fail(err.Error())
333 return
334 }
335 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
336}