penguin/flatgit

flatgit showcase

commit 74068cf0454b23a66fe24ee8ca961304abf6d0a9

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-13T13:39:17Z
subjectmarkdown to show image correctly
commit 74068cf0454b23a66fe24ee8ca961304abf6d0a9
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-13T13:39:17Z

    markdown to show image correctly
---
 internal/buildinfo/buildinfo_gen.go |  4 +--
 internal/render/markdown.go         | 37 +++++++++++++++++++++++--
 internal/render/markdown_test.go    | 55 +++++++++++++++++++++++++++++++++++++
 internal/render/render.go           |  2 +-
 internal/render/rtree.go            |  2 +-
 5 files changed, 94 insertions(+), 6 deletions(-)

diff --git a/internal/buildinfo/buildinfo_gen.go b/internal/buildinfo/buildinfo_gen.go
index ab795c1..da365ca 100644
--- a/internal/buildinfo/buildinfo_gen.go
+++ b/internal/buildinfo/buildinfo_gen.go
@@ -2,6 +2,6 @@ package buildinfo
 
 const (
     Version     = "dev"
-    GitRevision = "249de61fd9ac8f7c418b6a45f54f28ddfb220173"
-    Timestamp   = "20261005.051202"
+    GitRevision = "0bbb52a7252c199f9cebc6fb42d219a14c073a3b"
+    Timestamp   = "20261013.132630"
 )
diff --git a/internal/render/markdown.go b/internal/render/markdown.go
index 343eb29..0836d35 100644
--- a/internal/render/markdown.go
+++ b/internal/render/markdown.go
@@ -2,23 +2,38 @@ package render
 
 import (
 	"bytes"
+	"net/url"
+	"path"
 	"path/filepath"
 	"strings"
 
 	"github.com/microcosm-cc/bluemonday"
 	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/ast"
 	"github.com/yuin/goldmark/extension"
 	"github.com/yuin/goldmark/parser"
+	"github.com/yuin/goldmark/text"
 )
 
-func renderMarkdownSafe(src []byte) ([]byte, error) {
+// rawBase is the path from the rendered page to raw/<revision>.
+func renderMarkdownSafe(src []byte, markdownPath, rawBase string) ([]byte, error) {
 	md := goldmark.New(
 		goldmark.WithExtensions(extension.GFM),
 		goldmark.WithParserOptions(parser.WithAutoHeadingID()),
 	)
 
+	doc := md.Parser().Parse(text.NewReader(src))
+	if err := ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
+		if img, ok := node.(*ast.Image); ok && entering {
+			img.Destination = []byte(markdownImageURL(string(img.Destination), markdownPath, rawBase))
+		}
+		return ast.WalkContinue, nil
+	}); err != nil {
+		return nil, err
+	}
+
 	var buf bytes.Buffer
-	if err := md.Convert(src, &buf); err != nil {
+	if err := md.Renderer().Render(&buf, src, doc); err != nil {
 		return nil, err
 	}
 
@@ -26,6 +41,24 @@ func renderMarkdownSafe(src []byte) ([]byte, error) {
 	return policy.SanitizeBytes(buf.Bytes()), nil
 }
 
+func markdownImageURL(destination, markdownPath, rawBase string) string {
+	u, err := url.Parse(destination)
+	if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(destination, "//") || u.Path == "" {
+		return destination
+	}
+
+	// Resolve within the repository, including repository-root paths. Clamp
+	// parent traversal at its root so images cannot escape the raw revision.
+	imagePath := u.Path
+	if !strings.HasPrefix(imagePath, "/") {
+		imagePath = path.Dir(markdownPath) + "/" + imagePath
+	}
+	imagePath = strings.TrimPrefix(path.Clean("/"+imagePath), "/")
+	u.Path = path.Join(rawBase, imagePath)
+	u.RawPath = ""
+	return u.String()
+}
+
 func isMarkdownPath(path string) bool {
 	name := strings.ToLower(filepath.Base(path))
 	ext := strings.ToLower(filepath.Ext(path))
diff --git a/internal/render/markdown_test.go b/internal/render/markdown_test.go
new file mode 100644
index 0000000..1cf6973
--- /dev/null
+++ b/internal/render/markdown_test.go
@@ -0,0 +1,55 @@
+package render
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestMarkdownImageURL(t *testing.T) {
+	tests := []struct {
+		name, destination, markdownPath, rawBase, want string
+	}{
+		{"summary", "images/logo.png", "README.md", "raw/master", "raw/master/images/logo.png"},
+		{"blob", "images/logo.png", "README.md", "../../raw/master", "../../raw/master/images/logo.png"},
+		{"nested", "./images/logo.png", "docs/guide.md", "../../../raw/master", "../../../raw/master/docs/images/logo.png"},
+		{"parent", "../images/logo.png", "docs/guide.md", "../../../raw/master", "../../../raw/master/images/logo.png"},
+		{"root", "/images/logo.png", "docs/guide.md", "../../../raw/master", "../../../raw/master/images/logo.png"},
+		{"commit", "logo.png", "docs/guide.md", "../../../raw/abc123", "../../../raw/abc123/docs/logo.png"},
+		{"escaping", "images/my%20logo%23%3F.png?v=1#preview", "README.md", "raw/master", "raw/master/images/my%20logo%23%3F.png?v=1#preview"},
+		{"traversal", "../../../logo.png", "docs/guide.md", "../../../raw/master", "../../../raw/master/logo.png"},
+		{"https", "https://example.com/logo.png", "README.md", "raw/master", "https://example.com/logo.png"},
+		{"protocol relative", "//example.com/logo.png", "README.md", "raw/master", "//example.com/logo.png"},
+		{"data", "data:image/png;base64,AAAA", "README.md", "raw/master", "data:image/png;base64,AAAA"},
+		{"fragment", "#logo", "README.md", "raw/master", "#logo"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := markdownImageURL(tt.destination, tt.markdownPath, tt.rawBase); got != tt.want {
+				t.Fatalf("got %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestRenderMarkdownImages(t *testing.T) {
+	src := []byte("![inline](../images/logo.png)\n\n![reference][logo]\n\n[logo]: ./local.png \"Title\"\n\n[link](../images/logo.png)\n\n![external](https://example.com/logo.png)\n\n![unsafe](javascript:alert(1))\n\n`![code](local.png)`\n")
+	html, err := renderMarkdownSafe(src, "docs/guide.md", "../../../raw/master")
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, want := range []string{
+		`src="../../../raw/master/images/logo.png"`,
+		`src="../../../raw/master/docs/local.png"`,
+		`title="Title"`,
+		`href="../images/logo.png"`,
+		`src="https://example.com/logo.png"`,
+		`<code>![code](local.png)</code>`,
+	} {
+		if !strings.Contains(string(html), want) {
+			t.Errorf("missing %q in %s", want, html)
+		}
+	}
+	if strings.Contains(string(html), "javascript:") {
+		t.Errorf("unsafe image URL survived sanitization: %s", html)
+	}
+}
diff --git a/internal/render/render.go b/internal/render/render.go
index dfc95ad..990b957 100644
--- a/internal/render/render.go
+++ b/internal/render/render.go
@@ -228,7 +228,7 @@ func (r *Renderer) renderReadmeHTML(
 		return "", err
 	}
 
-	htmlBytes, err := renderMarkdownSafe(content)
+	htmlBytes, err := renderMarkdownSafe(content, readmePath, "raw/"+refSlug(repo.DefaultBranch))
 	if err != nil {
 		return "", err
 	}
diff --git a/internal/render/rtree.go b/internal/render/rtree.go
index f65074b..24c9647 100644
--- a/internal/render/rtree.go
+++ b/internal/render/rtree.go
@@ -170,7 +170,7 @@ func (r *Renderer) renderBlobEntry(
 	}
 
 	if !blob.Binary && isMarkdownPath(blob.Path) {
-		htmlBytes, err := renderMarkdownSafe(content)
+		htmlBytes, err := renderMarkdownSafe(content, e.Path, relPath(filepath.Dir(blobPath), filepath.Join(next, "raw", revSlug)))
 		if err != nil {
 			return err
 		}