internal/httpd/web.go

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

448 lines · 11732 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 sigView struct {
248	State       string
249	Signer      string
250	Fingerprint string
251}
252
253func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
254	raw, err := gitutil.ReadCommit(dir, sha)
255	if err != nil {
256		return sigView{State: "unsigned"}, nil
257	}
258	parsed, err := sig.ParseCommit(raw)
259	if err != nil {
260		return sigView{State: "unsigned"}, nil
261	}
262	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
263	if err != nil {
264		return sigView{State: "unsigned"}, parsed
265	}
266	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
267	if res.SignerUserID != 0 {
268		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
269			v.Signer = u.Username
270		}
271	}
272	return v, parsed
273}
274
275func (s *Server) log(w http.ResponseWriter, r *http.Request) {
276	ref := r.PathValue("ref")
277	p, ok := s.repoFor(w, r, ref)
278	if !ok {
279		return
280	}
281	const pageSize = 50
282	shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
283	if err != nil {
284		http.NotFound(w, r)
285		return
286	}
287	next := ""
288	if len(shas) > pageSize {
289		next = shas[pageSize]
290		shas = shas[:pageSize]
291	}
292	type row struct {
293		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
294		Sig                                                   sigView
295	}
296	var rows []row
297	for _, sha := range shas {
298		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
299		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
300		if parsed != nil {
301			rw.Subject = parsed.Subject
302			rw.AuthorName = parsed.AuthorName
303			rw.AuthorEmail = parsed.AuthorEmail
304			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
305		}
306		rows = append(rows, rw)
307	}
308	s.render(w, "log.html", struct {
309		repoPage
310		Commits []row
311		NextSHA string
312	}{p, rows, next})
313}
314
315func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
316	p, ok := s.repoFor(w, r, "")
317	if !ok {
318		return
319	}
320	sha := r.PathValue("sha")
321	full, err := gitutil.ResolveRef(p.Dir, sha)
322	if err != nil {
323		http.NotFound(w, r)
324		return
325	}
326	v, parsed := s.sigFor(p.Repo, p.Dir, full)
327	if parsed == nil {
328		http.NotFound(w, r)
329		return
330	}
331	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
332	type diffLine struct {
333		Class string
334		Text  string
335	}
336	var lines []diffLine
337	for _, l := range strings.Split(patch, "\n") {
338		class := ""
339		switch {
340		case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
341			class = "meta"
342		case strings.HasPrefix(l, "@@"):
343			class = "hunk"
344		case strings.HasPrefix(l, "+"):
345			class = "add"
346		case strings.HasPrefix(l, "-"):
347			class = "del"
348		}
349		lines = append(lines, diffLine{class, l})
350	}
351	committerEmail := ""
352	if parsed.CommitterEmail != parsed.AuthorEmail {
353		committerEmail = parsed.CommitterEmail
354	}
355	msg := ""
356	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
357		msg = string(parsed.Payload[i+2:])
358	}
359	s.render(w, "commit.html", struct {
360		repoPage
361		SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
362		Sig                                                                   sigView
363		DiffLines                                                             []diffLine
364	}{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
365		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
366}
367
368func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
369	p, ok := s.repoFor(w, r, "")
370	if !ok {
371		return
372	}
373	state := r.URL.Query().Get("state")
374	if state != "closed" && state != "all" {
375		state = "open"
376	}
377	issues, err := s.st.ListIssues(p.Repo.ID, state)
378	if err != nil {
379		http.Error(w, "internal error", http.StatusInternalServerError)
380		return
381	}
382	s.render(w, "issues.html", struct {
383		repoPage
384		State  string
385		Issues []store.Issue
386	}{p, state, issues})
387}
388
389func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
390	p, ok := s.repoFor(w, r, "")
391	if !ok {
392		return
393	}
394	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
395	if err != nil {
396		http.NotFound(w, r)
397		return
398	}
399	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
400	if err != nil {
401		http.NotFound(w, r)
402		return
403	}
404	comments, err := s.st.ListIssueComments(iss.ID)
405	if err != nil {
406		http.Error(w, "internal error", http.StatusInternalServerError)
407		return
408	}
409	s.render(w, "issue.html", struct {
410		repoPage
411		Issue    store.Issue
412		Comments []store.IssueComment
413	}{p, iss, comments})
414}
415
416func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
417	p, ok := s.repoFor(w, r, "")
418	if !ok {
419		return
420	}
421	branches, _ := gitutil.Refs(p.Dir, "heads")
422	tags, _ := gitutil.Refs(p.Dir, "tags")
423	s.render(w, "refs.html", struct {
424		repoPage
425		Branches, Tags []gitutil.Ref
426	}{p, branches, tags})
427}
428
429func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
430	p, ok := s.repoFor(w, r, "")
431	if !ok {
432		return
433	}
434	file := r.PathValue("file")
435	ref, ok := strings.CutSuffix(file, ".tar.gz")
436	if !ok {
437		http.NotFound(w, r)
438		return
439	}
440	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
441		http.NotFound(w, r)
442		return
443	}
444	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
445	w.Header().Set("Content-Type", "application/gzip")
446	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
447	gitutil.Archive(p.Dir, ref, prefix, w)
448}