Gitfed
bastien-mrq/gitfed/ Commits/ 3d0adc6

Markdown blobs show source; README images render via new raw route

Two related fixes to file viewing: Opening a .md from the file tree now shows its syntax-highlighted source by default, with a Code/Preview toggle in a header above the file (?render=1 opts into the rendered view). The repo page's README preview stays rendered as before. Images referenced in rendered markdown never displayed: relative destinations were rewritten to /repo-blob, which returns an HTML page an <img> can't render. New GET /repo-raw/{repo}?path=&branch= serves the file's actual bytes with a real image content-type (gif/png/svg/ webp/...), same access checks and unknown-branch-404s treatment as the blob page, and markdown image destinations now rewrite to it — an animated gif in a README displays and plays. Clicking an image file in the tree shows the image instead of "Binary file — not shown." Raw responses replace the site-wide CSP with default-src 'none'; sandbox — raw content is whatever a pusher committed, and a hostile SVG navigated to directly would otherwise run scripts on gitfed's own origin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

bastien-mrq 2026-08-13 09:19 commit 3d0adc6a7506684815bd0151d5ad17d4791088f1 parent 217be393c19ea94bcd9201befea24310e833ee31
6 files changed +191 −45
M cmd/gitfed-web/handlers_repo.go +118 −40
M cmd/gitfed-web/render.go +27 −4
M cmd/gitfed-web/render_test.go +41 −1
M cmd/gitfed-web/routes.go +1 −0
M internal/i18n/strings_en.go +2 −0
M internal/i18n/strings_fr.go +2 −0
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index 07a9eca..bca954f 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -5,6 +5,7 @@ import ( "html/template" "net/http" "net/url" + gopath "path" "strconv" "strings" @@ -375,20 +376,20 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { var readmeContent string var readmeFound bool - var readmeFile string if readmeLang != "" { readmeContent, readmeFound, err = s.ops.GetRepoReadmeLang(name, readmeLang) - readmeFile = "README." + readmeLang + ".md" } else { readmeContent, readmeFound, err = s.ops.GetRepoReadme(name) - readmeFile = "README.md" } if err != nil { s.serverError(w, r, err) return } if readmeFound { - readmeHTML, err = renderFileContent(name, "", readmeFile, readmeContent) + // The README preview is always rendered markdown (every + // readmeFile candidate is a .md) — images in it resolve to + // /repo-raw so gifs/screenshots actually display. + readmeHTML, err = renderRepoMarkdown(name, "", readmeContent) if err != nil { s.serverError(w, r, err) return @@ -461,90 +462,166 @@ var blobTpl = newTpl("blob", ` <a href="/r/{{.Repo}}?branch={{.Branch}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo}}?path={{.Path}}&branch={{$.Branch}}">{{.Name}}</a>{{end}}{{end}} </div> <div class="gf-card"> + {{if .IsMarkdown}} + <div class="gf-readme-head"> + {{icon "file-doc"}} {{.FileName}} + <span class="gf-readme-langs"> + {{if .Rendered}}<a href="{{.CodeURL}}" class="badge plain">{{t .Lang "repo.blob_code"}}</a> <span class="badge trusted">{{t .Lang "repo.blob_preview"}}</span>{{else}}<span class="badge trusted">{{t .Lang "repo.blob_code"}}</span> <a href="{{.RenderURL}}" class="badge plain">{{t .Lang "repo.blob_preview"}}</a>{{end}} + </span> + </div> + {{end}} <div class="gf-readme-body markdown-body"> {{.Content}} </div> </div> `) -func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("repo") - path := strings.Trim(r.URL.Query().Get("path"), "/") - lang := s.lang(r) +// repoFileForRequest resolves the ?path= / ?branch= query the blob and raw +// views share: validates access, 404s on an unknown branch or missing file, +// and returns the file's content plus the branch it was read from. ok is +// false for every "show a 404" case; a non-nil err is a real server error. +func (s *server) repoFileForRequest(r *http.Request, name, path string) (content, branch string, ok bool, err error) { if path == "" { - http.NotFound(w, r) - return + return "", "", false, nil } repo, err := s.ops.GetRepo(name) if err != nil || !s.canView(r, repo) { - http.NotFound(w, r) - return + return "", "", false, nil } - branch, _, err := s.ops.GetRepoBranch(name) + branch, _, err = s.ops.GetRepoBranch(name) if err != nil { - s.serverError(w, r, err) - return + return "", "", false, err } onDefaultBranch := true if requested := r.URL.Query().Get("branch"); requested != "" { branches, err := s.ops.ListBranches(name) if err != nil { - s.serverError(w, r, err) - return + return "", "", false, err } if !containsBranch(branches, requested) { - http.NotFound(w, r) - return + return "", "", false, nil } onDefaultBranch = requested == branch branch = requested } - var content string var found bool if onDefaultBranch { content, found, err = s.ops.GetRepoFile(name, path) } else { content, found, err = s.ops.GetRepoFileAtRef(name, branch, path) } + if err != nil { + return "", "", false, err + } + return content, branch, found, nil +} + +func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + path := strings.Trim(r.URL.Query().Get("path"), "/") + lang := s.lang(r) + + content, branch, ok, err := s.repoFileForRequest(r, name, path) if err != nil { s.serverError(w, r, err) return } - if !found { + if !ok { http.NotFound(w, r) return } + fileName, dir := path, "" + if i := strings.LastIndex(fileName, "/"); i >= 0 { + dir, fileName = fileName[:i], fileName[i+1:] + } + lower := strings.ToLower(fileName) + isMarkdown := strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") + isImage := imageExtensions[strings.ToLower(gopath.Ext(fileName))] + showRendered := isMarkdown && r.URL.Query().Get("render") == "1" + + blobURL := "/repo-blob/" + name + "?path=" + path + "&branch=" + branch + var rendered template.HTML - if strings.Contains(content, "\x00") { + switch { + case isImage: + // Images (gif included) display via the raw route instead of the + // "binary file" fallback — /repo-raw serves the actual bytes with + // an image content-type. + rawURL := "/repo-raw/" + name + "?path=" + path + "&branch=" + branch + rendered = template.HTML(`<img src="` + template.HTMLEscapeString(rawURL) + `" alt="` + template.HTMLEscapeString(fileName) + `">`) + case showRendered: + rendered, err = renderRepoMarkdown(name, dir, content) + case strings.Contains(content, "\x00"): rendered = template.HTML(`<p class="muted">` + template.HTMLEscapeString(i18n.T(lang, "repo.binary_file")) + `</p>`) - } else { - fileName, dir := path, "" - if i := strings.LastIndex(fileName, "/"); i >= 0 { - dir, fileName = fileName[:i], fileName[i+1:] - } + default: rendered, err = renderFileContent(name, dir, fileName, content) - if err != nil { - s.serverError(w, r, err) - return - } + } + if err != nil { + s.serverError(w, r, err) + return } var buf bytes.Buffer _ = blobTpl.Execute(&buf, struct { - Repo string - Branch string - Crumbs []crumb - Content template.HTML - Flash template.HTML - }{name, branch, breadcrumbs(path), rendered, flash(r)}) + Repo string + Branch string + Lang string + Crumbs []crumb + Content template.HTML + Flash template.HTML + FileName string + IsMarkdown bool + Rendered bool + CodeURL string + RenderURL string + }{name, branch, string(lang), breadcrumbs(path), rendered, flash(r), + fileName, isMarkdown, showRendered, blobURL, blobURL + "&render=1"}) s.render(w, r, path+" — "+name, "home", template.HTML(buf.String())) } +// handleRepoRaw serves a file's actual bytes — what <img> tags in rendered +// markdown (and the blob view's image display) point at. Same access and +// branch validation as the blob page, but the response is the file itself, +// not an HTML page around it. +func (s *server) handleRepoRaw(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + path := strings.Trim(r.URL.Query().Get("path"), "/") + + content, _, ok, err := s.repoFileForRequest(r, name, path) + if err != nil { + s.serverError(w, r, err) + return + } + if !ok { + http.NotFound(w, r) + return + } + + // Replace the site-wide CSP with a fully inert one: raw content is + // whatever a pusher committed, and an SVG (or HTML served as text/plain + // on a lenient browser) navigated to directly would otherwise run + // scripts on gitfed's own origin. sandbox + default-src 'none' keeps a + // hostile file from executing anything while still rendering as an + // image; style-src lets static SVG styling keep working. + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") + + switch ext := strings.ToLower(gopath.Ext(path)); { + case imageMIME[ext] != "": + w.Header().Set("Content-Type", imageMIME[ext]) + case strings.Contains(content, "\x00"): + w.Header().Set("Content-Type", "application/octet-stream") + default: + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + } + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + _, _ = w.Write([]byte(content)) +} + // handleRepoArchive streams a tar.gz/zip snapshot of a branch or tag — // ?ref= is validated against the repo's real branches and tags before it // ever reaches a git subprocess (see gitexec.Archive's contract), same @@ -607,10 +684,11 @@ func (s *server) handleRepoArchive(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +// renderFileContent renders a file as source code (syntax-highlighted when +// chroma knows the language, plain <pre> otherwise). Markdown deliberately +// goes through here too on the blob page — the source view is the default +// there, with rendering opt-in via ?render=1 (see handleRepoBlob). func renderFileContent(repo, dir, filename, content string) (template.HTML, error) { - if strings.HasSuffix(strings.ToLower(filename), ".md") || strings.HasSuffix(strings.ToLower(filename), ".markdown") { - return renderRepoMarkdown(repo, dir, content) - } if html, ok, err := highlightCode(filename, content); err != nil { return "", err } else if ok {
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index d4d0fe1..598b244 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -82,7 +82,10 @@ func renderRepoMarkdown(repo, dir, src string) (template.HTML, error) { case *ast.Link: link.Destination = []byte(resolveRepoLink(repo, dir, string(link.Destination))) case *ast.Image: - link.Destination = []byte(resolveRepoLink(repo, dir, string(link.Destination))) + // Images go to /repo-raw (actual file bytes with an image + // content-type), not /repo-blob (an HTML page) — an <img> + // pointing at the blob page would never display. + link.Destination = []byte(resolveRepoImage(repo, dir, string(link.Destination))) } return ast.WalkContinue, nil }) @@ -98,6 +101,17 @@ func renderRepoMarkdown(repo, dir, src string) (template.HTML, error) { // of a bare relative path. Absolute URLs, mailto:, protocol-relative and // pure same-page fragments are left untouched. func resolveRepoLink(repo, dir, dest string) string { + return resolveRepoDest("/repo-blob/", repo, dir, dest) +} + +// resolveRepoImage is resolveRepoLink for image destinations — same +// resolution rules, but targeting /repo-raw so the browser gets the file's +// actual bytes instead of a repo-browser HTML page. +func resolveRepoImage(repo, dir, dest string) string { + return resolveRepoDest("/repo-raw/", repo, dir, dest) +} + +func resolveRepoDest(route, repo, dir, dest string) string { if dest == "" || strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "//") || strings.HasPrefix(dest, "mailto:") || strings.Contains(dest, "://") { return dest @@ -124,7 +138,7 @@ func resolveRepoLink(repo, dir, dest string) string { // app builds via templates (e.g. repoTpl's {{.FullPath}}) — goldmark's // own HTML renderer still escapes the destination for the href // attribute, it just doesn't percent-encode "/", which is what we want. - return "/repo-blob/" + repo + "?path=" + target + fragment + return route + repo + "?path=" + target + fragment } // brandMark is the "Branch Blocks" logo — three square-cornered rectangles @@ -199,8 +213,17 @@ var ( ".rs": true, ".php": true, ".sh": true, ".bash": true, ".css": true, ".scss": true, ".html": true, ".htm": true, ".sql": true, ".lua": true, ".swift": true, ".kt": true, ".pl": true, } - docExtensions = map[string]bool{".md": true, ".markdown": true, ".txt": true, ".rst": true, ".adoc": true} - imageExtensions = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".bmp": true, ".ico": true} + docExtensions = map[string]bool{".md": true, ".markdown": true, ".txt": true, ".rst": true, ".adoc": true} + imageExtensions = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".bmp": true, ".ico": true} + + // imageMIME gives /repo-raw a browser-renderable content-type for the + // same set of extensions imageExtensions recognizes — everything else + // that route serves is text/plain or octet-stream, never sniffed + // (X-Content-Type-Options: nosniff is set globally). + imageMIME = map[string]string{ + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", + ".svg": "image/svg+xml", ".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon", + } configExtensions = map[string]bool{".json": true, ".yaml": true, ".yml": true, ".toml": true, ".xml": true, ".ini": true, ".conf": true, ".env": true, ".lock": true} // docFilenames catches conventional extensionless files that are
cmd/gitfed-web/render_test.go
diff --git a/cmd/gitfed-web/render_test.go b/cmd/gitfed-web/render_test.go index 705f002..0b0f4c1 100644 --- a/cmd/gitfed-web/render_test.go +++ b/cmd/gitfed-web/render_test.go @@ -1,6 +1,46 @@ package main -import "testing" +import ( + "strings" + "testing" +) + +func TestRenderRepoMarkdownImageGoesToRaw(t *testing.T) { + html, err := renderRepoMarkdown("alice/demo", "", "![demo](demo.gif)\n\n[docs](docs/USAGE.md)\n") + if err != nil { + t.Fatalf("renderRepoMarkdown: %v", err) + } + s := string(html) + if !strings.Contains(s, `<img src="/repo-raw/alice/demo?path=demo.gif"`) { + t.Errorf("image not rewritten to /repo-raw: %s", s) + } + if !strings.Contains(s, `<a href="/repo-blob/alice/demo?path=docs/USAGE.md"`) { + t.Errorf("link not rewritten to /repo-blob: %s", s) + } +} + +func TestRenderRepoMarkdownAbsoluteImageUntouched(t *testing.T) { + html, err := renderRepoMarkdown("alice/demo", "", "![badge](https://img.example/badge.svg)\n") + if err != nil { + t.Fatalf("renderRepoMarkdown: %v", err) + } + if !strings.Contains(string(html), `src="https://img.example/badge.svg"`) { + t.Errorf("absolute image URL should be left untouched: %s", html) + } +} + +func TestRenderFileContentMarkdownIsSource(t *testing.T) { + html, err := renderFileContent("alice/demo", "", "README.md", "# Title\n") + if err != nil { + t.Fatalf("renderFileContent: %v", err) + } + if strings.Contains(string(html), "<h1") { + t.Errorf("blob view of markdown should show source, not rendered HTML: %s", html) + } + if !strings.Contains(string(html), "<pre>") { + t.Errorf("expected a <pre> source block: %s", html) + } +} func TestFileIcon(t *testing.T) { cases := []struct {
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 5b35388..7c6349f 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -7,6 +7,7 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /{$}", s.handleLanding) mux.HandleFunc("GET /r/{repo...}", s.handleRepoView) mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob) + mux.HandleFunc("GET /repo-raw/{repo...}", s.handleRepoRaw) mux.HandleFunc("GET /repo-archive/{repo...}", s.handleRepoArchive) mux.HandleFunc("GET /repo-commits/{repo...}", s.handleRepoCommits) mux.HandleFunc("GET /repo-commit/{repo...}", s.handleRepoCommitDetail)
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index ca437cf..caa08ce 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -315,6 +315,8 @@ var en = map[string]string{ "repo.license": "License", "repo.view_file": "View file", "repo.binary_file": "Binary file — not shown.", + "repo.blob_code": "Code", + "repo.blob_preview": "Preview", "repo.settings_title": "settings", "repo.visibility_topics": "Visibility & topics", "repo.public_desc": "Public (readable by any authenticated principal)",
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index 3a60574..39ae731 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -315,6 +315,8 @@ var fr = map[string]string{ "repo.license": "Licence", "repo.view_file": "Voir le fichier", "repo.binary_file": "Fichier binaire — non affiché.", + "repo.blob_code": "Code", + "repo.blob_preview": "Aperçu", "repo.settings_title": "paramètres", "repo.visibility_topics": "Visibilité et sujets", "repo.public_desc": "Public (lisible par tout principal authentifié)",