internal/httpd/web.go
551 lines · 14224 bytes
1package httpd
2
3import (
4 "bytes"
5 "fmt"
6
7 "github.com/krazywarez/forge/internal/policy"
8 "html/template"
9 "net/http"
10 "path"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/alecthomas/chroma/v2/formatters/html"
16 "github.com/alecthomas/chroma/v2/lexers"
17 "github.com/alecthomas/chroma/v2/styles"
18 "github.com/yuin/goldmark"
19
20 "github.com/krazywarez/forge/internal/control"
21 "github.com/krazywarez/forge/internal/gitutil"
22 "github.com/krazywarez/forge/internal/sig"
23 "github.com/krazywarez/forge/internal/store"
24 "github.com/krazywarez/forge/internal/web"
25)
26
27const maxRenderBytes = 1 << 20 // largest blob rendered inline
28
29func (s *Server) render(w http.ResponseWriter, page string, data any) {
30 var buf bytes.Buffer
31 if err := web.Render(&buf, page, data); err != nil {
32 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
33 return
34 }
35 w.Header().Set("Content-Type", "text/html; charset=utf-8")
36 buf.WriteTo(w)
37}
38
39func (s *Server) siteName() string {
40 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
41 return strings.TrimSuffix(h, "/")
42}
43
44func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
45 w.Header().Set("Content-Type", "text/css; charset=utf-8")
46 w.Write(web.StyleCSS)
47}
48
49func (s *Server) index(w http.ResponseWriter, r *http.Request) {
50 repos, err := s.st.ListPublicRepos()
51 if err != nil {
52 http.Error(w, "internal error", http.StatusInternalServerError)
53 return
54 }
55 var viewer store.User
56 var mine []store.Repo
57 if s.cfg.Web.Mode == "accounts" {
58 if viewer = s.viewer(r); viewer.ID != 0 {
59 all, err := s.st.ListReposForUser(viewer.ID)
60 if err == nil {
61 for _, rp := range all {
62 if rp.Visibility == "private" {
63 mine = append(mine, rp)
64 }
65 }
66 }
67 }
68 }
69 s.render(w, "index.html", struct {
70 Site string
71 Viewer string
72 Repos []store.Repo
73 Mine []store.Repo
74 }{s.siteName(), viewer.Username, repos, mine})
75}
76
77// repoPage is the shared context for repo-scoped pages.
78type repoPage struct {
79 Site string
80 Viewer string
81 Repo store.Repo
82 Ref string
83 CloneURL string
84 Dir string
85}
86
87// repoFor resolves the repo for a web request; false means 404 was sent.
88// Anonymous visitors see public repos only; in accounts mode a logged-in
89// viewer additionally sees repos their grants allow. Private and missing
90// repos are indistinguishable either way.
91func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
92 var repo store.Repo
93 var viewer store.User
94 if s.cfg.Web.Mode == "accounts" {
95 viewer = s.viewer(r)
96 }
97 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
98 ok := err == nil
99 if ok {
100 grant := ""
101 if viewer.ID != 0 {
102 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
103 }
104 ok = policyCanRead(viewer, repo, grant)
105 }
106 if !ok {
107 http.NotFound(w, r)
108 return repoPage{}, false
109 }
110 if ref == "" {
111 ref = repo.DefaultBranch
112 }
113 return repoPage{
114 Site: s.siteName(),
115 Viewer: viewer.Username,
116 Repo: repo,
117 Ref: ref,
118 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
119 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
120 }, true
121}
122
123type crumb struct {
124 Name string
125 URL string
126}
127
128func crumbs(p repoPage, kind, filePath string) []crumb {
129 var cs []crumb
130 base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
131 acc := ""
132 for _, part := range strings.Split(filePath, "/") {
133 if part == "" {
134 continue
135 }
136 acc = path.Join(acc, part)
137 cs = append(cs, crumb{Name: part, URL: base + acc})
138 }
139 return cs
140}
141
142func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
143 p, ok := s.repoFor(w, r, "")
144 if !ok {
145 return
146 }
147 s.renderTree(w, r, p, "")
148}
149
150func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
151 p, ok := s.repoFor(w, r, r.PathValue("ref"))
152 if !ok {
153 return
154 }
155 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
156}
157
158func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
159 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
160 // Empty repo: render the page with no entries rather than 404.
161 s.render(w, "tree.html", struct {
162 repoPage
163 Crumbs []crumb
164 Prefix string
165 Entries []gitutil.TreeEntry
166 ReadmeHTML template.HTML
167 }{repoPage: p})
168 return
169 }
170 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
171 if err != nil {
172 http.NotFound(w, r)
173 return
174 }
175 prefix := ""
176 if dirPath != "" {
177 prefix = dirPath + "/"
178 }
179
180 var readmeHTML template.HTML
181 for _, e := range entries {
182 if e.Type != "blob" {
183 continue
184 }
185 lower := strings.ToLower(e.Name)
186 if lower == "readme" || lower == "readme.md" || lower == "readme.markdown" {
187 raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+e.Name, maxRenderBytes)
188 if err == nil {
189 var buf bytes.Buffer
190 if strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") {
191 // goldmark's default renderer drops raw HTML: safe.
192 if goldmark.Convert(raw, &buf) == nil {
193 readmeHTML = template.HTML(buf.String())
194 }
195 } else {
196 readmeHTML = template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
197 }
198 }
199 break
200 }
201 }
202
203 s.render(w, "tree.html", struct {
204 repoPage
205 Crumbs []crumb
206 Prefix string
207 Entries []gitutil.TreeEntry
208 ReadmeHTML template.HTML
209 }{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML})
210}
211
212func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
213 p, ok := s.repoFor(w, r, r.PathValue("ref"))
214 if !ok {
215 return
216 }
217 filePath := strings.Trim(r.PathValue("path"), "/")
218 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
219 if err != nil {
220 http.NotFound(w, r)
221 return
222 }
223 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
224
225 var codeHTML template.HTML
226 if !binary {
227 codeHTML = highlight(filePath, data)
228 }
229 cs := crumbs(p, "blob", filePath)
230 base := ""
231 if len(cs) > 0 {
232 base = cs[len(cs)-1].Name
233 cs = cs[:len(cs)-1]
234 }
235 s.render(w, "blob.html", struct {
236 repoPage
237 Crumbs []crumb
238 Base string
239 Path string
240 Binary bool
241 Size int
242 CodeHTML template.HTML
243 }{p, cs, base, filePath, binary, len(data), codeHTML})
244}
245
246func highlight(filePath string, data []byte) template.HTML {
247 lexer := lexers.Match(filePath)
248 if lexer == nil {
249 lexer = lexers.Fallback
250 }
251 style := styles.Get("friendly")
252 formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
253 iterator, err := lexer.Tokenise(nil, string(data))
254 if err != nil {
255 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
256 }
257 var buf bytes.Buffer
258 if err := formatter.Format(&buf, style, iterator); err != nil {
259 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
260 }
261 return template.HTML(buf.String())
262}
263
264func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
265 p, ok := s.repoFor(w, r, r.PathValue("ref"))
266 if !ok {
267 return
268 }
269 filePath := strings.Trim(r.PathValue("path"), "/")
270 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
271 if err != nil {
272 http.NotFound(w, r)
273 return
274 }
275 // Serve inert: never let repo content execute in the forge's origin.
276 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
277 w.Header().Set("X-Content-Type-Options", "nosniff")
278 w.Write(data)
279}
280
281type diffLine struct {
282 Class string
283 Text string
284}
285
286func classifyDiff(patch string) []diffLine {
287 var lines []diffLine
288 for _, l := range strings.Split(patch, "\n") {
289 class := ""
290 switch {
291 case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
292 class = "meta"
293 case strings.HasPrefix(l, "@@"):
294 class = "hunk"
295 case strings.HasPrefix(l, "+"):
296 class = "add"
297 case strings.HasPrefix(l, "-"):
298 class = "del"
299 }
300 lines = append(lines, diffLine{class, l})
301 }
302 return lines
303}
304
305type sigView struct {
306 State string
307 Signer string
308 Fingerprint string
309}
310
311func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
312 raw, err := gitutil.ReadCommit(dir, sha)
313 if err != nil {
314 return sigView{State: "unsigned"}, nil
315 }
316 parsed, err := sig.ParseCommit(raw)
317 if err != nil {
318 return sigView{State: "unsigned"}, nil
319 }
320 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
321 if err != nil {
322 return sigView{State: "unsigned"}, parsed
323 }
324 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
325 if res.SignerUserID != 0 {
326 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
327 v.Signer = u.Username
328 }
329 }
330 return v, parsed
331}
332
333func (s *Server) log(w http.ResponseWriter, r *http.Request) {
334 ref := r.PathValue("ref")
335 p, ok := s.repoFor(w, r, ref)
336 if !ok {
337 return
338 }
339 const pageSize = 50
340 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
341 if err != nil {
342 http.NotFound(w, r)
343 return
344 }
345 next := ""
346 if len(shas) > pageSize {
347 next = shas[pageSize]
348 shas = shas[:pageSize]
349 }
350 type row struct {
351 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
352 Sig sigView
353 }
354 var rows []row
355 for _, sha := range shas {
356 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
357 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
358 if parsed != nil {
359 rw.Subject = parsed.Subject
360 rw.AuthorName = parsed.AuthorName
361 rw.AuthorEmail = parsed.AuthorEmail
362 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
363 }
364 rows = append(rows, rw)
365 }
366 s.render(w, "log.html", struct {
367 repoPage
368 Commits []row
369 NextSHA string
370 }{p, rows, next})
371}
372
373func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
374 p, ok := s.repoFor(w, r, "")
375 if !ok {
376 return
377 }
378 sha := r.PathValue("sha")
379 full, err := gitutil.ResolveRef(p.Dir, sha)
380 if err != nil {
381 http.NotFound(w, r)
382 return
383 }
384 v, parsed := s.sigFor(p.Repo, p.Dir, full)
385 if parsed == nil {
386 http.NotFound(w, r)
387 return
388 }
389 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
390 lines := classifyDiff(patch)
391 committerEmail := ""
392 if parsed.CommitterEmail != parsed.AuthorEmail {
393 committerEmail = parsed.CommitterEmail
394 }
395 msg := ""
396 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
397 msg = string(parsed.Payload[i+2:])
398 }
399 s.render(w, "commit.html", struct {
400 repoPage
401 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
402 Sig sigView
403 DiffLines []diffLine
404 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
405 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
406}
407
408func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
409 p, ok := s.repoFor(w, r, "")
410 if !ok {
411 return
412 }
413 state := r.URL.Query().Get("state")
414 if state != "closed" && state != "all" {
415 state = "open"
416 }
417 issues, err := s.st.ListIssues(p.Repo.ID, state)
418 if err != nil {
419 http.Error(w, "internal error", http.StatusInternalServerError)
420 return
421 }
422 s.render(w, "issues.html", struct {
423 repoPage
424 State string
425 Issues []store.Issue
426 }{p, state, issues})
427}
428
429func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
430 p, ok := s.repoFor(w, r, "")
431 if !ok {
432 return
433 }
434 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
435 if err != nil {
436 http.NotFound(w, r)
437 return
438 }
439 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
440 if err != nil {
441 http.NotFound(w, r)
442 return
443 }
444 comments, err := s.st.ListIssueComments(iss.ID)
445 if err != nil {
446 http.Error(w, "internal error", http.StatusInternalServerError)
447 return
448 }
449 s.render(w, "issue.html", struct {
450 repoPage
451 Issue store.Issue
452 Comments []store.IssueComment
453 }{p, iss, comments})
454}
455
456func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
457 p, ok := s.repoFor(w, r, "")
458 if !ok {
459 return
460 }
461 state := r.URL.Query().Get("state")
462 if state == "" {
463 state = "open"
464 }
465 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
466 if !valid[state] {
467 state = "open"
468 }
469 mrs, err := s.st.ListMRs(p.Repo.ID, state)
470 if err != nil {
471 http.Error(w, "internal error", http.StatusInternalServerError)
472 return
473 }
474 s.render(w, "mrs.html", struct {
475 repoPage
476 State string
477 MRs []store.MR
478 }{p, state, mrs})
479}
480
481func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
482 p, ok := s.repoFor(w, r, "")
483 if !ok {
484 return
485 }
486 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
487 if err != nil {
488 http.NotFound(w, r)
489 return
490 }
491 m, err := s.st.MRByNumber(p.Repo.ID, n)
492 if err != nil {
493 http.NotFound(w, r)
494 return
495 }
496 comments, _ := s.st.ListMRComments(m.ID)
497 reviews, _ := s.st.ListMRReviews(m.ID)
498
499 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
500 var lines []diffLine
501 if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
502 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
503 lines = classifyDiff(patch)
504 }
505 }
506 s.render(w, "mr.html", struct {
507 repoPage
508 MR store.MR
509 Comments []store.IssueComment
510 Reviews []store.MRReview
511 DiffLines []diffLine
512 }{p, m, comments, reviews, lines})
513}
514
515func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
516 p, ok := s.repoFor(w, r, "")
517 if !ok {
518 return
519 }
520 branches, _ := gitutil.Refs(p.Dir, "heads")
521 tags, _ := gitutil.Refs(p.Dir, "tags")
522 s.render(w, "refs.html", struct {
523 repoPage
524 Branches, Tags []gitutil.Ref
525 }{p, branches, tags})
526}
527
528func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
529 p, ok := s.repoFor(w, r, "")
530 if !ok {
531 return
532 }
533 file := r.PathValue("file")
534 ref, ok := strings.CutSuffix(file, ".tar.gz")
535 if !ok {
536 http.NotFound(w, r)
537 return
538 }
539 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
540 http.NotFound(w, r)
541 return
542 }
543 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
544 w.Header().Set("Content-Type", "application/gzip")
545 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
546 gitutil.Archive(p.Dir, ref, prefix, w)
547}
548
549func policyCanRead(u store.User, repo store.Repo, grant string) bool {
550 return policy.CanRead(u, repo, grant)
551}