Commit 399281c14b
Verified · cmc
e2e/design_test.go +29 −1
| @@ -2,6 +2,8 @@ package e2e | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "encoding/json" |
| 5 | "fmt" | |
| 6 | "net/http" | |
| 5 | 7 | "net/url" |
| 6 | 8 | "os" |
| 7 | 9 | "path/filepath" |
| @@ -24,7 +26,9 @@ func TestReadmeRelativeLinks(t *testing.T) { | ||
| 24 | 26 | os.MkdirAll(filepath.Join(dir, "img"), 0o755) |
| 25 | 27 | os.WriteFile(filepath.Join(dir, "README.md"), []byte( |
| 26 | 28 | "# site\n\n[guide](docs/guide.md) and [export](docs/paper.html) and "+ |
| 27 | "[abs](https://example.org/x) here\n\n\n"), 0o644) | |
| 29 | "[abs](https://example.org/x) here\n\n\n"+ | |
| 30 | "\n\n"+ | |
| 31 | "| flag | effect |\n|------|--------|\n| `-v` | verbose |\n"), 0o644) | |
| 28 | 32 | os.WriteFile(filepath.Join(dir, "docs", "guide.md"), []byte("# guide\n"), 0o644) |
| 29 | 33 | os.WriteFile(filepath.Join(dir, "docs", "paper.org"), []byte("* paper\n"), 0o644) |
| 30 | 34 | os.WriteFile(filepath.Join(dir, "img", "logo.png"), []byte{0x89, 0x50}, 0o644) |
| @@ -41,7 +45,9 @@ func TestReadmeRelativeLinks(t *testing.T) { | ||
| 41 | 45 | `href="/alice/site/blob/main/docs/guide.md"`, // relative link |
| 42 | 46 | `href="/alice/site/blob/main/docs/paper.org"`, // .html mapped to .org source |
| 43 | 47 | `src="/alice/site/raw/main/img/logo.png"`, // relative image via raw |
| 48 | `src="https://example.org/pic.png"`, // remote image untouched | |
| 44 | 49 | `href="https://example.org/x"`, // absolute untouched |
| 50 | "<table>", "<td>verbose</td>", // GFM table renders | |
| 45 | 51 | `href="/alice/site/blob/main/README.md">README.md</a>`, // clickable card header |
| 46 | 52 | `<th>name</th>`, // file table column headers |
| 47 | 53 | } { |
| @@ -53,6 +59,28 @@ func TestReadmeRelativeLinks(t *testing.T) { | ||
| 53 | 59 | if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">all refs") { |
| 54 | 60 | t.Error("branch dropdown missing") |
| 55 | 61 | } |
| 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 | } | |
| 56 | 84 | // Explore rows carry topics, license, and updated date. |
| 57 | 85 | inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/site", "web") |
| 58 | 86 | // Bare 0BSD grant (no notice-retention clause), wrapped mid-sentence. |
internal/httpd/web.go +26 −5
| @@ -23,6 +23,7 @@ import ( | ||
| 23 | 23 | "github.com/microcosm-cc/bluemonday" |
| 24 | 24 | "github.com/niklasfasching/go-org/org" |
| 25 | 25 | "github.com/yuin/goldmark" |
| 26 | "github.com/yuin/goldmark/extension" | |
| 26 | 27 | |
| 27 | 28 | "gitbay.org/gitbay/internal/autolink" |
| 28 | 29 | "gitbay.org/gitbay/internal/control" |
| @@ -462,9 +463,10 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 462 | 463 | return |
| 463 | 464 | } |
| 464 | 465 | binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes |
| 466 | _, image := imageTypes[strings.ToLower(path.Ext(filePath))] | |
| 465 | 467 | |
| 466 | 468 | var codeHTML template.HTML |
| 467 | if !binary { | |
| 469 | if !binary && !image { | |
| 468 | 470 | codeHTML = highlight(filePath, data) |
| 469 | 471 | } |
| 470 | 472 | cs := crumbs(p, "blob", filePath) |
| @@ -482,10 +484,11 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 482 | 484 | DirPath string |
| 483 | 485 | RefKind string |
| 484 | 486 | Binary bool |
| 487 | Image bool | |
| 485 | 488 | Size int |
| 486 | 489 | Branches []gitutil.Ref |
| 487 | 490 | 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}) | |
| 489 | 492 | } |
| 490 | 493 | |
| 491 | 494 | // releases lists tag-anchored releases with notes and assets. |
| @@ -762,11 +765,25 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | ||
| 762 | 765 | return |
| 763 | 766 | } |
| 764 | 767 | // 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) | |
| 766 | 775 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 767 | 776 | w.Write(data) |
| 768 | 777 | } |
| 769 | 778 | |
| 779 | // imageTypes are the formats raw serves with a real content type and blob | |
| 780 | // pages preview inline. | |
| 781 | var 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 | ||
| 770 | 787 | // readmeRank orders competing README files: richer renderers win. |
| 771 | 788 | var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3} |
| 772 | 789 | |
| @@ -794,6 +811,10 @@ func pickReadme(entries []gitutil.TreeEntry) string { | ||
| 794 | 811 | return best |
| 795 | 812 | } |
| 796 | 813 | |
| 814 | // markdown is the shared renderer: GFM (tables, strikethrough, autolinks, | |
| 815 | // task lists) on top of CommonMark. Raw HTML is still dropped. | |
| 816 | var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM)) | |
| 817 | ||
| 797 | 818 | // mdHTML renders user-authored markdown (issue and MR bodies, comments). |
| 798 | 819 | // goldmark's default renderer drops raw HTML, so this is safe as-is. |
| 799 | 820 | func mdHTML(raw string) template.HTML { |
| @@ -801,7 +822,7 @@ func mdHTML(raw string) template.HTML { | ||
| 801 | 822 | return "" |
| 802 | 823 | } |
| 803 | 824 | var buf bytes.Buffer |
| 804 | if goldmark.Convert([]byte(raw), &buf) != nil { | |
| 825 | if markdown.Convert([]byte(raw), &buf) != nil { | |
| 805 | 826 | return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>") |
| 806 | 827 | } |
| 807 | 828 | return template.HTML(buf.String()) |
| @@ -899,7 +920,7 @@ func renderReadme(name string, raw []byte) template.HTML { | ||
| 899 | 920 | switch path.Ext(strings.ToLower(name)) { |
| 900 | 921 | case ".md", ".markdown": |
| 901 | 922 | var buf bytes.Buffer |
| 902 | if goldmark.Convert(raw, &buf) != nil { | |
| 923 | if markdown.Convert(raw, &buf) != nil { | |
| 903 | 924 | return plain() |
| 904 | 925 | } |
| 905 | 926 | return template.HTML(buf.String()) |
internal/web/static/style.css +20
| @@ -484,6 +484,26 @@ button.linklike:hover { text-decoration: underline; filter: none; } | ||
| 484 | 484 | } |
| 485 | 485 | .rendered img { max-width: 100%; } |
| 486 | 486 | .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 | } | |
| 487 | 507 | |
| 488 | 508 | pre.message { |
| 489 | 509 | background: var(--code-bg); |
internal/web/templates/blob.html +2 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | <span class="spacer"></span> |
| 8 | 8 | <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> |
| 9 | 9 | </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> | |
| 11 | 12 | {{else}}<div class="code">{{.CodeHTML}}</div>{{end}} |
| 12 | 13 | {{end}} |