internal/httpd/web.go

08b325bb1d221743ebfd2d99502f63b59ff6f346
gitbay/internal/httpd/web.go history · blame · raw

513 lines · 13272 bytes

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