krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: internal/httpd/accounts.go · raw
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
167func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
168 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
169 if !ok {
170 return
171 }
172 title := strings.TrimSpace(r.FormValue("title"))
173 if title == "" {
174 http.Error(w, "title required", http.StatusBadRequest)
175 return
176 }
177 n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"))
178 if err != nil {
179 http.Error(w, "internal error", http.StatusInternalServerError)
180 return
181 }
182 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
183}
184
185func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
186 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
187 if !ok {
188 return
189 }
190 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
191 iss, err := s.st.IssueByNumber(repo.ID, n)
192 if err != nil {
193 http.NotFound(w, r)
194 return
195 }
196 body := strings.TrimSpace(r.FormValue("body"))
197 if body == "" {
198 http.Error(w, "empty comment", http.StatusBadRequest)
199 return
200 }
201 if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
202 http.Error(w, "internal error", http.StatusInternalServerError)
203 return
204 }
205 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
206}
207
208func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
209 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
210 if !ok {
211 return
212 }
213 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
214 m, err := s.st.MRByNumber(repo.ID, n)
215 if err != nil {
216 http.NotFound(w, r)
217 return
218 }
219 body := strings.TrimSpace(r.FormValue("body"))
220 if body == "" {
221 http.Error(w, "empty comment", http.StatusBadRequest)
222 return
223 }
224 if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
225 http.Error(w, "internal error", http.StatusInternalServerError)
226 return
227 }
228 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
229}
230
231type editPage struct {
232 Site string
233 Viewer string
234 Repo store.Repo
235 Ref string
236 Path string
237 Content string
238 Error string
239}
240
241func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
242 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
243 if !ok {
244 return
245 }
246 ref := r.PathValue("ref")
247 filePath := strings.Trim(r.PathValue("path"), "/")
248 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
249 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
250 if err != nil {
251 content = nil // new file
252 }
253 if gitutil.IsBinary(content) {
254 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
255 return
256 }
257 s.render(w, "edit.html", editPage{
258 Site: s.siteName(), Viewer: u.Username, Repo: repo,
259 Ref: ref, Path: filePath, Content: string(content),
260 })
261}
262
263func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
264 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
265 if !ok {
266 return
267 }
268 ref := r.PathValue("ref")
269 filePath := strings.Trim(r.PathValue("path"), "/")
270 fail := func(msg string) {
271 s.render(w, "edit.html", editPage{
272 Site: s.siteName(), Viewer: u.Username, Repo: repo,
273 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
274 })
275 }
276 // Web edits produce unsigned commits; a repo that requires signed
277 // commits must refuse them rather than violate its own policy.
278 if repo.Settings.RequireSignedCommits {
279 fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
280 return
281 }
282 email, err := s.st.PrimaryVerifiedEmail(u.ID)
283 if err != nil {
284 fail("internal error")
285 return
286 }
287 if email == "" {
288 fail("commits carry your identity: your account needs a verified primary email")
289 return
290 }
291 message := strings.TrimSpace(r.FormValue("message"))
292 if message == "" {
293 message = "edit " + filePath
294 }
295 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
296 if _, err := gitutil.CommitFileChange(dir, ref, filePath,
297 []byte(r.FormValue("content")), u.Username, email, message); err != nil {
298 fail(err.Error())
299 return
300 }
301 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
302}