internal/httpd/web.go

a0dd5878fbda93a49fe7d4503e2ac80fc6f6c8f3
gitbay/internal/httpd/web.go history · blame · raw

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}