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}