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", "", "\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", "", "\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é)",