This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/pages/markup: render attached images

Signed-off-by: Wilhelm Berggren <wilhelmberggren@gmail.com>
Signed-off-by: Seongmin Lee <git@boltless.me>

author
Wilhelm Berggren
committer
Seongmin Lee
date (Jul 29, 2026, 1:37 AM +0900) commit 8a19bbd8 parent dd07ef9c change-id otllrvzr
+170 -3
+90
appview/pages/markup/blob_test.go
··· 1 + package markup 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + func TestParseBlobURI(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + src string 13 + wantDid syntax.DID 14 + wantCid syntax.CID 15 + wantOk bool 16 + }{ 17 + { 18 + name: "valid plc", 19 + src: "blob+at://did:plc:abc123/bafyreiabc", 20 + wantDid: "did:plc:abc123", 21 + wantCid: "bafyreiabc", 22 + wantOk: true, 23 + }, 24 + { 25 + name: "valid web", 26 + src: "blob+at://did:web:example.com/bafyreiabc", 27 + wantDid: "did:web:example.com", 28 + wantCid: "bafyreiabc", 29 + wantOk: true, 30 + }, 31 + { 32 + name: "missing cid", 33 + src: "blob+at://did:plc:abc123", 34 + wantOk: false, 35 + }, 36 + { 37 + name: "empty cid", 38 + src: "blob+at://did:plc:abc123/", 39 + wantOk: false, 40 + }, 41 + { 42 + name: "empty did", 43 + src: "blob+at:///bafyreiabc", 44 + wantOk: false, 45 + }, 46 + { 47 + name: "wrong scheme", 48 + src: "https://example.com/image.png", 49 + wantOk: false, 50 + }, 51 + { 52 + name: "bare at scheme", 53 + src: "at://did:plc:abc123/bafyreiabc", 54 + wantOk: false, 55 + }, 56 + { 57 + name: "empty", 58 + src: "", 59 + wantOk: false, 60 + }, 61 + } 62 + 63 + for _, tt := range tests { 64 + t.Run(tt.name, func(t *testing.T) { 65 + did, cid, ok := parseBlobURI(tt.src) 66 + if ok != tt.wantOk { 67 + t.Fatalf("parseBlobURI(%q) ok = %v, want %v", tt.src, ok, tt.wantOk) 68 + } 69 + if !tt.wantOk { 70 + return 71 + } 72 + if did != tt.wantDid || cid != tt.wantCid { 73 + t.Fatalf("parseBlobURI(%q) = (%q, %q), want (%q, %q)", tt.src, did, cid, tt.wantDid, tt.wantCid) 74 + } 75 + }) 76 + } 77 + } 78 + 79 + // A nil resolver must never panic and must signal "not rewritten". 80 + func TestBlobToGetBlobURLNilResolver(t *testing.T) { 81 + rctx := &RenderContext{} 82 + src := "blob+at://did:plc:abc123/bafyreiabc" 83 + got, ok := rctx.blobToGetBlobURL(src) 84 + if ok { 85 + t.Fatalf("expected ok=false with nil resolver") 86 + } 87 + if got != src { 88 + t.Fatalf("expected src returned unchanged, got %q", got) 89 + } 90 + }
+76 -2
appview/pages/markup/markdown.go
··· 3 3 4 4 import ( 5 5 "bytes" 6 + "context" 6 7 "fmt" 7 8 "io" 8 - "io/fs" 9 9 "net/url" 10 10 "path" 11 11 "strings" 12 + "time" 12 13 13 14 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 15 "github.com/alecthomas/chroma/v2/styles" 16 + "github.com/bluesky-social/indigo/atproto/identity" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 15 18 "github.com/yuin/goldmark" 16 19 emoji "github.com/yuin/goldmark-emoji" 17 20 highlighting "github.com/yuin/goldmark-highlighting/v2" ··· 48 51 IsDev bool 49 52 Hostname string 50 53 RendererType RendererType 51 - Files fs.FS 54 + Directory identity.Directory 52 55 } 53 56 54 57 func NewMarkdown(hostname string, extra ...goldmark.Extender) goldmark.Markdown { ··· 179 182 case "a": 180 183 // TODO: transform `./` or `/` links to tree link 181 184 case "img", "source": 185 + var blobCid syntax.CID 182 186 for i, attr := range node.Attr { 183 187 if attr.Key != "src" { 188 + continue 189 + } 190 + 191 + if strings.HasPrefix(attr.Val, blobURIScheme) { 192 + if _, cid, ok := parseBlobURI(attr.Val); ok { 193 + blobCid = cid 194 + } 195 + if blobUrl, ok := ctx.blobToGetBlobURL(attr.Val); ok { 196 + attr.Val = ctx.camoImageLinkTransformer(blobUrl) 197 + node.Attr[i] = attr 198 + } 184 199 continue 185 200 } 186 201 ··· 196 211 } 197 212 node.Attr[i] = attr 198 213 } 214 + // tag with the cid so the editor can preview an uncommitted blob 215 + // (appended after the loop to avoid mutating node.Attr mid-range) 216 + if blobCid != "" { 217 + node.Attr = append(node.Attr, htmlparse.Attribute{Key: "data-blob-cid", Val: blobCid.String()}) 218 + } 199 219 } 200 220 201 221 for n := node.FirstChild; n != nil; n = n.NextSibling { ··· 247 267 248 268 newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath) 249 269 link.Destination = []byte(newPath) 270 + } 271 + 272 + // blobURIScheme embeds a PDS blob in markdown independent of the PDS hostname: 273 + // blob+at://<did>/<cid>. 274 + const blobURIScheme = "blob+at://" 275 + 276 + func parseBlobURI(src string) (syntax.DID, syntax.CID, bool) { 277 + rest, ok := strings.CutPrefix(src, blobURIScheme) 278 + if !ok { 279 + return "", "", false 280 + } 281 + rawDid, rawCid, ok := strings.Cut(rest, "/") 282 + if !ok || rawDid == "" || rawCid == "" { 283 + return "", "", false 284 + } 285 + did, err := syntax.ParseDID(rawDid) 286 + if err != nil { 287 + return "", "", false 288 + } 289 + cid, err := syntax.ParseCID(rawCid) 290 + if err != nil { 291 + return "", "", false 292 + } 293 + return did, cid, true 294 + } 295 + 296 + func (rctx *RenderContext) blobToGetBlobURL(src string) (string, bool) { 297 + did, cid, ok := parseBlobURI(src) 298 + if !ok { 299 + return src, false 300 + } 301 + if rctx.Directory == nil { 302 + return src, false 303 + } 304 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 305 + defer cancel() 306 + ident, err := rctx.Directory.LookupDID(ctx, did) 307 + if err != nil { 308 + return src, false 309 + } 310 + pds := ident.PDSEndpoint() 311 + if pds == "" { 312 + return src, false 313 + } 314 + // TODO: avoid directly fetching from PDS. use services like porxie instead. 315 + u, err := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", pds)) 316 + if err != nil { 317 + return src, false 318 + } 319 + q := u.Query() 320 + q.Set("did", did.String()) 321 + q.Set("cid", cid.String()) 322 + u.RawQuery = q.Encode() 323 + return u.String(), true 250 324 } 251 325 252 326 func (rctx *RenderContext) imageToRawTransformer(dst string) string {
+3
appview/pages/markup/sanitizer/sanitizer.go
··· 74 74 // picture/source for modern image formats (avif, webp, etc.) 75 75 policy.AllowAttrs("srcset", "type", "media").OnElements("source") 76 76 77 + // marker the editor uses to preview an uncommitted blob image; see markdown.go 78 + policy.AllowAttrs("data-blob-cid").OnElements("img", "source") 79 + 77 80 // checkboxes 78 81 policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") 79 82 policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input")
+1 -1
appview/pages/pages.go
··· 92 92 Hostname: config.Core.AppviewHost, 93 93 CamoUrl: config.Camo.Host, 94 94 CamoSecret: config.Camo.SharedSecret, 95 - Files: Files, 95 + Directory: res.Directory(), 96 96 } 97 97 98 98 p := &Pages{