internal/httpd/web.go

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

399 lines · 10657 bytes

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