From 8a19bbd861836d7bba1d2aa6e14f644f90e4eddc Mon Sep 17 00:00:00 2001 From: Wilhelm Berggren Date: Sun, 26 Jul 2026 21:01:04 +0200 Subject: [PATCH] appview/pages/markup: render attached images Signed-off-by: Wilhelm Berggren Signed-off-by: Seongmin Lee --- appview/pages/markup/blob_test.go | 90 +++++++++++++++++++++ appview/pages/markup/markdown.go | 78 +++++++++++++++++- appview/pages/markup/sanitizer/sanitizer.go | 3 + appview/pages/pages.go | 2 +- 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 appview/pages/markup/blob_test.go diff --git a/appview/pages/markup/blob_test.go b/appview/pages/markup/blob_test.go new file mode 100644 index 00000000..2b28af24 --- /dev/null +++ b/appview/pages/markup/blob_test.go @@ -0,0 +1,90 @@ +package markup + +import ( + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func TestParseBlobURI(t *testing.T) { + tests := []struct { + name string + src string + wantDid syntax.DID + wantCid syntax.CID + wantOk bool + }{ + { + name: "valid plc", + src: "blob+at://did:plc:abc123/bafyreiabc", + wantDid: "did:plc:abc123", + wantCid: "bafyreiabc", + wantOk: true, + }, + { + name: "valid web", + src: "blob+at://did:web:example.com/bafyreiabc", + wantDid: "did:web:example.com", + wantCid: "bafyreiabc", + wantOk: true, + }, + { + name: "missing cid", + src: "blob+at://did:plc:abc123", + wantOk: false, + }, + { + name: "empty cid", + src: "blob+at://did:plc:abc123/", + wantOk: false, + }, + { + name: "empty did", + src: "blob+at:///bafyreiabc", + wantOk: false, + }, + { + name: "wrong scheme", + src: "https://example.com/image.png", + wantOk: false, + }, + { + name: "bare at scheme", + src: "at://did:plc:abc123/bafyreiabc", + wantOk: false, + }, + { + name: "empty", + src: "", + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + did, cid, ok := parseBlobURI(tt.src) + if ok != tt.wantOk { + t.Fatalf("parseBlobURI(%q) ok = %v, want %v", tt.src, ok, tt.wantOk) + } + if !tt.wantOk { + return + } + if did != tt.wantDid || cid != tt.wantCid { + t.Fatalf("parseBlobURI(%q) = (%q, %q), want (%q, %q)", tt.src, did, cid, tt.wantDid, tt.wantCid) + } + }) + } +} + +// A nil resolver must never panic and must signal "not rewritten". +func TestBlobToGetBlobURLNilResolver(t *testing.T) { + rctx := &RenderContext{} + src := "blob+at://did:plc:abc123/bafyreiabc" + got, ok := rctx.blobToGetBlobURL(src) + if ok { + t.Fatalf("expected ok=false with nil resolver") + } + if got != src { + t.Fatalf("expected src returned unchanged, got %q", got) + } +} diff --git a/appview/pages/markup/markdown.go b/appview/pages/markup/markdown.go index 6b1ae678..98b22d96 100644 --- a/appview/pages/markup/markdown.go +++ b/appview/pages/markup/markdown.go @@ -3,15 +3,18 @@ package markup import ( "bytes" + "context" "fmt" "io" - "io/fs" "net/url" "path" "strings" + "time" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/styles" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/yuin/goldmark" emoji "github.com/yuin/goldmark-emoji" highlighting "github.com/yuin/goldmark-highlighting/v2" @@ -48,7 +51,7 @@ type RenderContext struct { IsDev bool Hostname string RendererType RendererType - Files fs.FS + Directory identity.Directory } func NewMarkdown(hostname string, extra ...goldmark.Extender) goldmark.Markdown { @@ -179,11 +182,23 @@ func visitNode(ctx *RenderContext, node *htmlparse.Node) { case "a": // TODO: transform `./` or `/` links to tree link case "img", "source": + var blobCid syntax.CID for i, attr := range node.Attr { if attr.Key != "src" { continue } + if strings.HasPrefix(attr.Val, blobURIScheme) { + if _, cid, ok := parseBlobURI(attr.Val); ok { + blobCid = cid + } + if blobUrl, ok := ctx.blobToGetBlobURL(attr.Val); ok { + attr.Val = ctx.camoImageLinkTransformer(blobUrl) + node.Attr[i] = attr + } + continue + } + if isAbsoluteUrl(attr.Val) { // apply camo to external links camoUrl, _ := url.Parse(ctx.CamoUrl) @@ -196,6 +211,11 @@ func visitNode(ctx *RenderContext, node *htmlparse.Node) { } node.Attr[i] = attr } + // tag with the cid so the editor can preview an uncommitted blob + // (appended after the loop to avoid mutating node.Attr mid-range) + if blobCid != "" { + node.Attr = append(node.Attr, htmlparse.Attribute{Key: "data-blob-cid", Val: blobCid.String()}) + } } for n := node.FirstChild; n != nil; n = n.NextSibling { @@ -249,6 +269,60 @@ func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) { link.Destination = []byte(newPath) } +// blobURIScheme embeds a PDS blob in markdown independent of the PDS hostname: +// blob+at:///. +const blobURIScheme = "blob+at://" + +func parseBlobURI(src string) (syntax.DID, syntax.CID, bool) { + rest, ok := strings.CutPrefix(src, blobURIScheme) + if !ok { + return "", "", false + } + rawDid, rawCid, ok := strings.Cut(rest, "/") + if !ok || rawDid == "" || rawCid == "" { + return "", "", false + } + did, err := syntax.ParseDID(rawDid) + if err != nil { + return "", "", false + } + cid, err := syntax.ParseCID(rawCid) + if err != nil { + return "", "", false + } + return did, cid, true +} + +func (rctx *RenderContext) blobToGetBlobURL(src string) (string, bool) { + did, cid, ok := parseBlobURI(src) + if !ok { + return src, false + } + if rctx.Directory == nil { + return src, false + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + ident, err := rctx.Directory.LookupDID(ctx, did) + if err != nil { + return src, false + } + pds := ident.PDSEndpoint() + if pds == "" { + return src, false + } + // TODO: avoid directly fetching from PDS. use services like porxie instead. + u, err := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", pds)) + if err != nil { + return src, false + } + q := u.Query() + q.Set("did", did.String()) + q.Set("cid", cid.String()) + u.RawQuery = q.Encode() + return u.String(), true +} + func (rctx *RenderContext) imageToRawTransformer(dst string) string { if isAbsoluteUrl(dst) { return dst diff --git a/appview/pages/markup/sanitizer/sanitizer.go b/appview/pages/markup/sanitizer/sanitizer.go index b5dd07b3..7af3a784 100644 --- a/appview/pages/markup/sanitizer/sanitizer.go +++ b/appview/pages/markup/sanitizer/sanitizer.go @@ -74,6 +74,9 @@ func buildDefaultPolicy() *bluemonday.Policy { // picture/source for modern image formats (avif, webp, etc.) policy.AllowAttrs("srcset", "type", "media").OnElements("source") + // marker the editor uses to preview an uncommitted blob image; see markdown.go + policy.AllowAttrs("data-blob-cid").OnElements("img", "source") + // checkboxes policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") diff --git a/appview/pages/pages.go b/appview/pages/pages.go index debee4b9..e20d77af 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -92,7 +92,7 @@ func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, Hostname: config.Core.AppviewHost, CamoUrl: config.Camo.Host, CamoSecret: config.Camo.SharedSecret, - Files: Files, + Directory: res.Directory(), } p := &Pages{ -- 2.51.2