A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 399281c14b

399281c14bab9829ff98203d7ccb5e16d42dc7f3

parent: 794796910f

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-25T15:42:13Z

Render GFM tables, preview images on blob pages, serve raw images typed

- goldmark gains the GFM extension (tables, strikethrough, autolinks,
  task lists) for READMEs and comments alike; raw HTML is still dropped.
- raw serves common image formats (png/jpg/gif/webp/avif/svg/ico) with
  their real content type — nosniff blocked <img> on text/plain, so
  local README images never rendered. SVG script cannot run: the
  instance CSP is script-src 'none'. Everything else stays inert.
- Blob pages preview those formats inline instead of showing source or
  a binary notice.
- .rendered gains table styling.
e2e/design_test.go +29 −1
@@ -2,6 +2,8 @@ package e2e
22
33 import (
44 "encoding/json"
5 "fmt"
6 "net/http"
57 "net/url"
68 "os"
79 "path/filepath"
@@ -24,7 +26,9 @@ func TestReadmeRelativeLinks(t *testing.T) {
2426 os.MkdirAll(filepath.Join(dir, "img"), 0o755)
2527 os.WriteFile(filepath.Join(dir, "README.md"), []byte(
2628 "# site\n\n[guide](docs/guide.md) and [export](docs/paper.html) and "+
27 "[abs](https://example.org/x) here\n\n![logo](img/logo.png)\n"), 0o644)
29 "[abs](https://example.org/x) here\n\n![logo](img/logo.png)\n"+
30 "![ext](https://example.org/pic.png)\n\n"+
31 "| flag | effect |\n|------|--------|\n| `-v` | verbose |\n"), 0o644)
2832 os.WriteFile(filepath.Join(dir, "docs", "guide.md"), []byte("# guide\n"), 0o644)
2933 os.WriteFile(filepath.Join(dir, "docs", "paper.org"), []byte("* paper\n"), 0o644)
3034 os.WriteFile(filepath.Join(dir, "img", "logo.png"), []byte{0x89, 0x50}, 0o644)
@@ -41,7 +45,9 @@ func TestReadmeRelativeLinks(t *testing.T) {
4145 `href="/alice/site/blob/main/docs/guide.md"`, // relative link
4246 `href="/alice/site/blob/main/docs/paper.org"`, // .html mapped to .org source
4347 `src="/alice/site/raw/main/img/logo.png"`, // relative image via raw
48 `src="https://example.org/pic.png"`, // remote image untouched
4449 `href="https://example.org/x"`, // absolute untouched
50 "<table>", "<td>verbose</td>", // GFM table renders
4551 `href="/alice/site/blob/main/README.md">README.md</a>`, // clickable card header
4652 `<th>name</th>`, // file table column headers
4753 } {
@@ -53,6 +59,28 @@ func TestReadmeRelativeLinks(t *testing.T) {
5359 if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">all refs") {
5460 t.Error("branch dropdown missing")
5561 }
62 // Raw serves images with their real type (nosniff otherwise blocks
63 // <img>); everything else stays inert text/plain.
64 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/img/logo.png", inst.httpPort))
65 if err != nil {
66 t.Fatal(err)
67 }
68 resp.Body.Close()
69 if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
70 t.Errorf("raw png content-type = %q", ct)
71 }
72 resp, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/README.md", inst.httpPort))
73 if err != nil {
74 t.Fatal(err)
75 }
76 resp.Body.Close()
77 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
78 t.Errorf("raw md content-type = %q", ct)
79 }
80 // Blob pages preview images inline.
81 if _, body := inst.get(t, "/alice/site/blob/main/img/logo.png"); !strings.Contains(body, `<img src="/alice/site/raw/main/img/logo.png"`) {
82 t.Errorf("blob image preview missing:\n%s", body)
83 }
5684 // Explore rows carry topics, license, and updated date.
5785 inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/site", "web")
5886 // Bare 0BSD grant (no notice-retention clause), wrapped mid-sentence.
internal/httpd/web.go +26 −5
@@ -23,6 +23,7 @@ import (
2323 "github.com/microcosm-cc/bluemonday"
2424 "github.com/niklasfasching/go-org/org"
2525 "github.com/yuin/goldmark"
26 "github.com/yuin/goldmark/extension"
2627
2728 "gitbay.org/gitbay/internal/autolink"
2829 "gitbay.org/gitbay/internal/control"
@@ -462,9 +463,10 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
462463 return
463464 }
464465 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
466 _, image := imageTypes[strings.ToLower(path.Ext(filePath))]
465467
466468 var codeHTML template.HTML
467 if !binary {
469 if !binary && !image {
468470 codeHTML = highlight(filePath, data)
469471 }
470472 cs := crumbs(p, "blob", filePath)
@@ -482,10 +484,11 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
482484 DirPath string
483485 RefKind string
484486 Binary bool
487 Image bool
485488 Size int
486489 Branches []gitutil.Ref
487490 CodeHTML template.HTML
488 }{p, cs, base, filePath, filePath, "blob", binary, len(data), branches, codeHTML})
491 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), branches, codeHTML})
489492 }
490493
491494 // releases lists tag-anchored releases with notes and assets.
@@ -762,11 +765,25 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
762765 return
763766 }
764767 // Serve inert: never let repo content execute in the forge's origin.
765 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
768 // Images get their real type so <img> works under nosniff; SVG script
769 // is dead on arrival because the instance CSP is script-src 'none'.
770 ct := "text/plain; charset=utf-8"
771 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
772 ct = t
773 }
774 w.Header().Set("Content-Type", ct)
766775 w.Header().Set("X-Content-Type-Options", "nosniff")
767776 w.Write(data)
768777 }
769778
779// imageTypes are the formats raw serves with a real content type and blob
780// pages preview inline.
781var imageTypes = map[string]string{
782 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
783 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
784 ".svg": "image/svg+xml", ".ico": "image/x-icon",
785}
786
770787 // readmeRank orders competing README files: richer renderers win.
771788 var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
772789
@@ -794,6 +811,10 @@ func pickReadme(entries []gitutil.TreeEntry) string {
794811 return best
795812 }
796813
814// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
815// task lists) on top of CommonMark. Raw HTML is still dropped.
816var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM))
817
797818 // mdHTML renders user-authored markdown (issue and MR bodies, comments).
798819 // goldmark's default renderer drops raw HTML, so this is safe as-is.
799820 func mdHTML(raw string) template.HTML {
@@ -801,7 +822,7 @@ func mdHTML(raw string) template.HTML {
801822 return ""
802823 }
803824 var buf bytes.Buffer
804 if goldmark.Convert([]byte(raw), &buf) != nil {
825 if markdown.Convert([]byte(raw), &buf) != nil {
805826 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
806827 }
807828 return template.HTML(buf.String())
@@ -899,7 +920,7 @@ func renderReadme(name string, raw []byte) template.HTML {
899920 switch path.Ext(strings.ToLower(name)) {
900921 case ".md", ".markdown":
901922 var buf bytes.Buffer
902 if goldmark.Convert(raw, &buf) != nil {
923 if markdown.Convert(raw, &buf) != nil {
903924 return plain()
904925 }
905926 return template.HTML(buf.String())
internal/web/static/style.css +20
@@ -484,6 +484,26 @@ button.linklike:hover { text-decoration: underline; filter: none; }
484484 }
485485 .rendered img { max-width: 100%; }
486486 .rendered h1, .rendered h2 { border-bottom: 1px solid var(--faint); padding-bottom: var(--sp-1); }
487.rendered table {
488 border-collapse: collapse;
489 margin: var(--sp-3) 0;
490 display: block;
491 max-width: 100%;
492 overflow-x: auto;
493}
494.rendered th, .rendered td {
495 border: 1px solid var(--line);
496 padding: var(--sp-1) var(--sp-3);
497 text-align: left;
498}
499.rendered th { background: var(--surface); font-weight: 600; }
500.blobimage { text-align: center; }
501.blobimage img {
502 max-width: 100%;
503 border: 1px solid var(--faint);
504 border-radius: var(--r-md);
505 background: var(--surface);
506}
487507
488508 pre.message {
489509 background: var(--code-bg);
internal/web/templates/blob.html +2 −1
@@ -7,6 +7,7 @@
77 <span class="spacer"></span>
88 <span class="actions">{{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span>
99 </div>
10{{if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
10{{if .Image}}<div class="blobimage"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}"><img src="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}" alt="{{.Base}}"></a><p class="meta">{{.Size}} bytes</p></div>
11{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
1112 {{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
1213 {{end}}