git-preview @ main

static git web frontend but with images and html previews + code truncation

routes/routes.go

  1package routes
  2
  3import (
  4	"fmt"
  5	"html/template"
  6	"log"
  7	"mime"
  8	"net/http"
  9	"os"
 10	"path"
 11	"path/filepath"
 12	"sort"
 13	"strings"
 14	"time"
 15
 16	"github.com/jxc2000-b/git-preview/config"
 17	"github.com/jxc2000-b/git-preview/git"
 18	"github.com/jxc2000-b/git-preview/showcase"
 19	"github.com/bluekeyes/go-gitdiff/gitdiff"
 20	securejoin "github.com/cyphar/filepath-securejoin"
 21	"github.com/dustin/go-humanize"
 22	"github.com/microcosm-cc/bluemonday"
 23	"github.com/russross/blackfriday/v2"
 24)
 25
 26type deps struct {
 27	c *config.Config
 28}
 29
 30func (d *deps) Index(w http.ResponseWriter, r *http.Request) {
 31	// Single-project mode: the site root *is* the repo.
 32	if solo := d.soloRepo(); solo != "" {
 33		r.SetPathValue("name", solo)
 34		d.RepoIndex(w, r)
 35		return
 36	}
 37
 38	dirs, err := os.ReadDir(d.c.Repo.ScanPath)
 39	if err != nil {
 40		d.Write500(w)
 41		log.Printf("reading scan path: %s", err)
 42		return
 43	}
 44
 45	type info struct {
 46		DisplayName, Name, Desc, Idle string
 47		d                             time.Time
 48	}
 49
 50	infos := []info{}
 51
 52	for _, dir := range dirs {
 53		name := dir.Name()
 54		if !dir.IsDir() || d.isIgnored(name) || d.isUnlisted(name) {
 55			continue
 56		}
 57
 58		path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name)
 59		if err != nil {
 60			log.Printf("securejoin error: %v", err)
 61			d.Write404(w)
 62			return
 63		}
 64
 65		gr, err := git.Open(path, "")
 66		if err != nil {
 67			log.Println(err)
 68			continue
 69		}
 70
 71		c, err := gr.LastCommit()
 72		if err != nil {
 73			d.Write500(w)
 74			log.Println(err)
 75			return
 76		}
 77
 78		infos = append(infos, info{
 79			DisplayName: getDisplayName(name),
 80			Name:        name,
 81			Desc:        getDescription(path),
 82			Idle:        humanize.Time(c.Author.When),
 83			d:           c.Author.When,
 84		})
 85	}
 86
 87	sort.Slice(infos, func(i, j int) bool {
 88		return infos[j].d.Before(infos[i].d)
 89	})
 90
 91	tpath := filepath.Join(d.c.Dirs.Templates, "*")
 92	t := template.Must(template.ParseGlob(tpath))
 93
 94	data := make(map[string]interface{})
 95	data["meta"] = d.c.Meta
 96	data["info"] = infos
 97
 98	if err := t.ExecuteTemplate(w, "index", data); err != nil {
 99		log.Println(err)
100		return
101	}
102}
103
104func (d *deps) RepoIndex(w http.ResponseWriter, r *http.Request) {
105	name := r.PathValue("name")
106	if d.isIgnored(name) {
107		d.Write404(w)
108		return
109	}
110	name = filepath.Clean(name)
111	path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name)
112	if err != nil {
113		log.Printf("securejoin error: %v", err)
114		d.Write404(w)
115		return
116	}
117
118	gr, err := git.Open(path, "")
119	if err != nil {
120		d.Write404(w)
121		return
122	}
123
124	commits, err := gr.Commits()
125	if err != nil {
126		d.Write500(w)
127		log.Println(err)
128		return
129	}
130
131	var readmeContent template.HTML
132	for _, readme := range d.c.Repo.Readme {
133		ext := filepath.Ext(readme)
134		content, _ := gr.FileContent(readme)
135		if len(content) > 0 {
136			switch ext {
137			case ".md", ".mkd", ".markdown":
138				unsafe := blackfriday.Run(
139					[]byte(content),
140					blackfriday.WithExtensions(blackfriday.CommonExtensions),
141				)
142				html := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
143				readmeContent = template.HTML(html)
144			default:
145				safe := bluemonday.UGCPolicy().SanitizeBytes([]byte(content))
146				readmeContent = template.HTML(
147					fmt.Sprintf(`<pre>%s</pre>`, safe),
148				)
149			}
150			break
151		}
152	}
153
154	if readmeContent == "" {
155		log.Printf("no readme found for %s", name)
156	}
157
158	mainBranch, err := gr.FindMainBranch(d.c.Repo.MainBranch)
159	if err != nil {
160		d.Write500(w)
161		log.Println(err)
162		return
163	}
164
165	tpath := filepath.Join(d.c.Dirs.Templates, "*")
166	t := template.Must(template.ParseGlob(tpath))
167
168	if len(commits) >= 3 {
169		commits = commits[:3]
170	}
171
172	data := make(map[string]any)
173	data["name"] = name
174	data["displayname"] = getDisplayName(name)
175	data["ref"] = mainBranch
176	data["readme"] = readmeContent
177	data["commits"] = commits
178	data["desc"] = getDescription(path)
179	data["servername"] = d.c.Server.Name
180	data["meta"] = d.c.Meta
181	data["gomod"] = isGoModule(gr)
182	data["single"] = d.soloRepo() != ""
183
184	if sc, err := showcase.Load(gr); err != nil {
185		log.Printf("showcase manifest for %s: %s", name, err)
186	} else if sc != nil && len(sc.Landing) > 0 {
187		data["showcase"] = sc.Landing
188		data["showcaseref"] = mainBranch
189	}
190
191	if err := t.ExecuteTemplate(w, "repo", data); err != nil {
192		log.Println(err)
193		return
194	}
195
196	return
197}
198
199func (d *deps) RepoTree(w http.ResponseWriter, r *http.Request) {
200	name := r.PathValue("name")
201	if d.isIgnored(name) {
202		d.Write404(w)
203		return
204	}
205	treePath := r.PathValue("rest")
206	ref := r.PathValue("ref")
207
208	name = filepath.Clean(name)
209	path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name)
210	if err != nil {
211		log.Printf("securejoin error: %v", err)
212		d.Write404(w)
213		return
214	}
215	gr, err := git.Open(path, ref)
216	if err != nil {
217		d.Write404(w)
218		return
219	}
220
221	files, err := gr.FileTree(treePath)
222	if err != nil {
223		d.Write500(w)
224		log.Println(err)
225		return
226	}
227
228	// The .showcase sidecar dir is plumbing for the showcase galleries;
229	// keep it out of the root tree listing.
230	if treePath == "" {
231		filtered := files[:0]
232		for _, f := range files {
233			if f.Name != showcase.Dir {
234				filtered = append(filtered, f)
235			}
236		}
237		files = filtered
238	}
239
240	data := make(map[string]any)
241	data["name"] = name
242	data["displayname"] = getDisplayName(name)
243	data["ref"] = ref
244	data["parent"] = treePath
245	data["desc"] = getDescription(path)
246	data["dotdot"] = filepath.Dir(treePath)
247	data["single"] = d.soloRepo() != ""
248
249	d.listFiles(files, data, w)
250	return
251}
252
253func (d *deps) FileContent(w http.ResponseWriter, r *http.Request) {
254	name := r.PathValue("name")
255	if d.isIgnored(name) {
256		d.Write404(w)
257		return
258	}
259	treePath := r.PathValue("rest")
260	ref := r.PathValue("ref")
261
262	name = filepath.Clean(name)
263	path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name)
264	if err != nil {
265		log.Printf("securejoin error: %v", err)
266		d.Write404(w)
267		return
268	}
269
270	gr, err := git.Open(path, ref)
271	if err != nil {
272		d.Write404(w)
273		return
274	}
275
276	contents, err := gr.FileContent(treePath)
277	if err != nil {
278		d.Write500(w)
279		return
280	}
281	data := make(map[string]any)
282	data["name"] = name
283	data["displayname"] = getDisplayName(name)
284	data["ref"] = ref
285	data["desc"] = getDescription(path)
286	data["path"] = treePath
287	data["single"] = d.soloRepo() != ""
288
289	sc, err := showcase.Load(gr)
290	if err != nil {
291		log.Printf("showcase manifest for %s: %s", name, err)
292	}
293	if assets := sc.ForFile(treePath); len(assets) > 0 {
294		data["showcase"] = assets
295		data["showcaseref"] = ref
296	}
297
298	// Blob views are previews by default: 20 lines unless the manifest
299	// grants "full" (or forbids with "none"). Enforced server-side, so
300	// hidden lines are never sent.
301	const maxPreviewLines = 20
302
303	switch sc.PreviewMode(treePath) {
304	case "none":
305		data["hidden"] = true
306		d.showFile("", data, w)
307		return
308	case "truncated":
309		if t, cut := truncateLines(contents, maxPreviewLines); cut {
310			contents = t
311			data["truncated"] = true
312		}
313	}
314
315	if d.c.Meta.SyntaxHighlight == "" {
316		d.showFile(contents, data, w)
317	} else {
318		d.showFileWithHighlight(treePath, contents, data, w)
319	}
320}
321
322// truncateLines clips s to at most n lines, reporting whether it cut.
323func truncateLines(s string, n int) (string, bool) {
324	count := 0
325	for i := range s {
326		if s[i] == '\n' {
327			count++
328			if count == n {
329				return s[:i+1], i+1 < len(s)
330			}
331		}
332	}
333	return s, false
334}
335
336// ShowcaseAsset serves raw bytes of a file under the repo's .showcase/
337// directory, for use by the showcase gallery (images, video, iframes).
338func (d *deps) ShowcaseAsset(w http.ResponseWriter, r *http.Request) {
339	name := r.PathValue("name")
340	if d.isIgnored(name) {
341		d.Write404(w)
342		return
343	}
344	ref := r.PathValue("ref")
345	file := path.Clean(r.PathValue("rest"))
346	if file == "." || file == ".." || strings.HasPrefix(file, "../") || strings.HasPrefix(file, "/") {
347		d.Write404(w)
348		return
349	}
350
351	name = filepath.Clean(name)
352	repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name)
353	if err != nil {
354		log.Printf("securejoin error: %v", err)
355		d.Write404(w)
356		return
357	}
358
359	gr, err := git.Open(repoPath, ref)
360	if err != nil {
361		d.Write404(w)
362		return
363	}
364
365	contents, err := gr.FileContentBytes(path.Join(showcase.Dir, file))
366	if err != nil {
367		d.Write404(w)
368		return
369	}
370
371	ctype := mime.TypeByExtension(path.Ext(file))
372	if ctype == "" {
373		ctype = http.DetectContentType(contents)
374	}
375
376	// Interactive HTML runs sandboxed: scripts may run, but the frame
377	// gets a unique origin and no access to the parent site.
378	if strings.HasPrefix(ctype, "text/html") {
379		w.Header().Set("Content-Security-Policy", "sandbox allow-scripts")
380	}
381	w.Header().Set("Content-Type", ctype)
382	w.Header().Set("X-Content-Type-Options", "nosniff")
383	w.Write(contents)
384}
385
386func (d *deps) Log(w http.ResponseWriter, r *http.Request) {
387	name := r.PathValue("name")
388	if d.isIgnored(name) {
389		d.Write404(w)
390		return
391	}
392	ref := r.PathValue("ref")
393
394	path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name)
395	if err != nil {
396		log.Printf("securejoin error: %v", err)
397		d.Write404(w)
398		return
399	}
400
401	gr, err := git.Open(path, ref)
402	if err != nil {
403		d.Write404(w)
404		return
405	}
406
407	commits, err := gr.Commits()
408	if err != nil {
409		d.Write500(w)
410		log.Println(err)
411		return
412	}
413
414	type logEntry struct {
415		Hash    string
416		Message string
417		Age     string
418		Files   int
419		Add     int
420		Del     int
421		Decor   []git.Decoration
422	}
423
424	decorations := gr.Decorations()
425
426	entries := make([]logEntry, 0, len(commits))
427	for _, c := range commits {
428		e := logEntry{
429			Hash:    c.Hash.String(),
430			Message: strings.SplitN(strings.TrimSpace(c.Message), "\n", 2)[0],
431			Age:     c.Author.When.Format("02 Jan 2006"),
432			Decor:   decorations[c.Hash.String()],
433		}
434		if stats, err := c.Stats(); err == nil {
435			e.Files = len(stats)
436			for _, s := range stats {
437				e.Add += s.Addition
438				e.Del += s.Deletion
439			}
440		}
441		entries = append(entries, e)
442	}
443
444	tpath := filepath.Join(d.c.Dirs.Templates, "*")
445	t := template.Must(template.ParseGlob(tpath))
446
447	data := make(map[string]interface{})
448	data["commits"] = entries
449	data["meta"] = d.c.Meta
450	data["name"] = name
451	data["displayname"] = getDisplayName(name)
452	data["ref"] = ref
453	data["desc"] = getDescription(path)
454	data["log"] = true
455	data["single"] = d.soloRepo() != ""
456
457	if err := t.ExecuteTemplate(w, "log", data); err != nil {
458		log.Println(err)
459		return
460	}
461}
462
463func (d *deps) Diff(w http.ResponseWriter, r *http.Request) {
464	name := r.PathValue("name")
465	if d.isIgnored(name) {
466		d.Write404(w)
467		return
468	}
469	ref := r.PathValue("ref")
470
471	path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name)
472	if err != nil {
473		log.Printf("securejoin error: %v", err)
474		d.Write404(w)
475		return
476	}
477	gr, err := git.Open(path, ref)
478	if err != nil {
479		d.Write404(w)
480		return
481	}
482
483	diff, err := gr.Diff()
484	if err != nil {
485		d.Write500(w)
486		log.Println(err)
487		return
488	}
489
490	tpath := filepath.Join(d.c.Dirs.Templates, "*")
491	t := template.Must(template.ParseGlob(tpath))
492
493	// The commit page is a *preview*: cap files and lines server-side so
494	// undisclosed changes are never shipped in the response.
495	const maxPreviewFiles = 3
496	const maxPreviewLines = 10
497
498	type previewFrag struct {
499		Header string
500		Lines  []gitdiff.Line
501	}
502	type previewDiff struct {
503		Name      string
504		IsNew     bool
505		IsDelete  bool
506		IsBinary  bool
507		Fragments []previewFrag
508		Truncated bool
509	}
510
511	shown := diff.Diff
512	hiddenFiles := 0
513	if len(shown) > maxPreviewFiles {
514		hiddenFiles = len(shown) - maxPreviewFiles
515		shown = shown[:maxPreviewFiles]
516	}
517
518	preview := make([]previewDiff, 0, len(shown))
519	for _, fd := range shown {
520		p := previewDiff{
521			Name:     fd.Name.New,
522			IsNew:    fd.IsNew,
523			IsDelete: fd.IsDelete,
524			IsBinary: fd.IsBinary,
525		}
526		if p.Name == "" {
527			p.Name = fd.Name.Old
528		}
529		remaining := maxPreviewLines
530		for _, tf := range fd.TextFragments {
531			if remaining <= 0 {
532				p.Truncated = true
533				break
534			}
535			lines := tf.Lines
536			if len(lines) > remaining {
537				lines = lines[:remaining]
538				p.Truncated = true
539			}
540			remaining -= len(lines)
541			p.Fragments = append(p.Fragments, previewFrag{Header: tf.Header, Lines: lines})
542		}
543		preview = append(preview, p)
544	}
545
546	data := make(map[string]interface{})
547
548	data["commit"] = diff.Commit
549	data["stat"] = diff.Stat
550	data["diff"] = preview
551	data["hiddenfiles"] = hiddenFiles
552	data["meta"] = d.c.Meta
553	data["name"] = name
554	// Don't echo the commit hash back as the displayed ref; the commit
555	// page is a preview and shouldn't surface hashes anywhere.
556	displayRef := ref
557	if headRepo, err := git.Open(path, ""); err == nil {
558		if mainBranch, err := headRepo.FindMainBranch(d.c.Repo.MainBranch); err == nil {
559			displayRef = mainBranch
560		}
561
562		// Load the manifest from HEAD, not the viewed commit, so showcase
563		// entries added later still annotate older commits.
564		if sc, err := showcase.Load(headRepo); err != nil {
565			log.Printf("showcase manifest for %s: %s", name, err)
566		} else if assets := sc.ForCommit(diff.Commit.This); len(assets) > 0 {
567			data["showcase"] = assets
568			data["showcaseref"] = displayRef
569		}
570	}
571	data["displayname"] = getDisplayName(name)
572	data["ref"] = displayRef
573	data["single"] = d.soloRepo() != ""
574	data["desc"] = getDescription(path)
575
576	if err := t.ExecuteTemplate(w, "commit", data); err != nil {
577		log.Println(err)
578		return
579	}
580}
581
582func (d *deps) ServeStatic(w http.ResponseWriter, r *http.Request) {
583	f := r.PathValue("file")
584	f = filepath.Clean(f)
585	f, err := securejoin.SecureJoin(d.c.Dirs.Static, f)
586	if err != nil {
587		d.Write404(w)
588		return
589	}
590
591	http.ServeFile(w, r, f)
592}