diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go
index 7bc44a02f3..8d06b71262 100644
--- a/bskyweb/cmd/bskyweb/jsonld.go
+++ b/bskyweb/cmd/bskyweb/jsonld.go
@@ -136,9 +136,9 @@ func bskyProfileURL(handle string) string {
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
}
-// extractPostMedia returns thumbnail URLs for the post's image or video
-// embed, byte-identical to what we put in og:image. Callers derive
-// thumbnailUrl from urls[0].
+// extractPostMedia returns thumbnail URLs for the post's image, gallery,
+// or video embed, byte-identical to what we put in og:image. Callers
+// derive thumbnailUrl from urls[0].
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
if pv == nil || pv.Embed == nil || embedHidden {
return nil
@@ -147,6 +147,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if pv.Embed.EmbedImages_View != nil {
return imageThumbs(pv.Embed.EmbedImages_View.Images)
}
+ if pv.Embed.EmbedGallery_View != nil {
+ return galleryThumbs(pv.Embed.EmbedGallery_View.Items)
+ }
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
}
@@ -155,6 +158,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if media.EmbedImages_View != nil {
return imageThumbs(media.EmbedImages_View.Images)
}
+ if media.EmbedGallery_View != nil {
+ return galleryThumbs(media.EmbedGallery_View.Items)
+ }
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
return []string{*media.EmbedVideo_View.Thumbnail}
}
@@ -174,6 +180,31 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
return urls
}
+// galleryThumbs returns the thumbnail URLs of image items in a gallery
+// embed, or nil if empty. Items_Elem is a union; non-image variants and
+// nil entries are skipped so future gallery item types don't break SEO
+// extraction. Empty Thumbnail strings are also skipped to avoid emitting
+// if the appview ever returns one.
+func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
+ if len(items) == 0 {
+ return nil
+ }
+ urls := make([]string, 0, len(items))
+ for _, item := range items {
+ if item == nil || item.EmbedGallery_ViewImage == nil {
+ continue
+ }
+ if item.EmbedGallery_ViewImage.Thumbnail == "" {
+ continue
+ }
+ urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail)
+ }
+ if len(urls) == 0 {
+ return nil
+ }
+ return urls
+}
+
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
// if the embed is blocked / not-found / detached / a non-post record.
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go
index f14cdb6f78..5c7f1c2bb7 100644
--- a/bskyweb/cmd/bskyweb/jsonld_test.go
+++ b/bskyweb/cmd/bskyweb/jsonld_test.go
@@ -72,6 +72,60 @@ func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
}
}
+// withGallery adds an app.bsky.embed.gallery view with image items.
+func withGallery(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ var items []*appbsky.EmbedGallery_View_Items_Elem
+ for _, t := range thumbs {
+ items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
+ EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
+ Thumbnail: t,
+ Fullsize: t + "_full",
+ },
+ })
+ }
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
+ }
+ }
+}
+
+// withRecordWithMediaGallery adds a record-with-media embed whose media slot
+// is an app.bsky.embed.gallery view.
+func withRecordWithMediaGallery(qHandle, qDid, qRkey string, thumbs ...string) func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ var items []*appbsky.EmbedGallery_View_Items_Elem
+ for _, t := range thumbs {
+ items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
+ EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
+ Thumbnail: t,
+ Fullsize: t + "_full",
+ },
+ })
+ }
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
+ Record: &appbsky.EmbedRecord_View{
+ Record: &appbsky.EmbedRecord_View_Record{
+ EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
+ Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey,
+ Cid: "bafy-quoted",
+ Author: &appbsky.ActorDefs_ProfileViewBasic{
+ Did: qDid,
+ Handle: qHandle,
+ },
+ IndexedAt: "2024-01-01T00:00:00Z",
+ },
+ },
+ },
+ Media: &appbsky.EmbedRecordWithMedia_View_Media{
+ EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
+ },
+ },
+ }
+ }
+}
+
// withVideo adds a video embed with a thumbnail.
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
@@ -299,6 +353,88 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
}
}
+func TestBuildPostJSONLD_WithGallery(t *testing.T) {
+ thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
+ thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
+ thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
+ out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
+ if err != nil {
+ t.Fatal(err)
+ }
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ imgs, ok := main["image"].([]any)
+ if !ok {
+ t.Fatalf("image should be array, got %T", main["image"])
+ }
+ if len(imgs) != 3 {
+ t.Errorf("expected 3 gallery images, got %d", len(imgs))
+ }
+ if imgs[0] != thumb1 || imgs[1] != thumb2 || imgs[2] != thumb3 {
+ t.Errorf("gallery image[] order wrong: %v", imgs)
+ }
+ if main["thumbnailUrl"] != thumb1 {
+ t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
+ }
+}
+
+// Gallery in the media slot of a record-with-media embed should still
+// produce og:image / JSON-LD image[]. Quote-post URL still emits
+// alongside via isBasedOn.
+func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
+ thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
+ withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
+ out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ if err != nil {
+ t.Fatal(err)
+ }
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ imgs, ok := main["image"].([]any)
+ if !ok || len(imgs) != 1 || imgs[0] != thumb {
+ t.Errorf("expected single gallery thumb in image[], got %v", main["image"])
+ }
+ if main["thumbnailUrl"] != thumb {
+ t.Errorf("thumbnailUrl wrong: %v", main["thumbnailUrl"])
+ }
+ if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
+ t.Errorf("isBasedOn should still emit for record-with-media gallery, got %v", main["isBasedOn"])
+ }
+}
+
+// Forward-compat: nil items, unknown-variant union elements, and empty
+// Thumbnail strings must be skipped, not panic or leak as . Unknown variants are dropped silently
+// so older deploys keep working when new gallery item types ship.
+func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
+ thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery")
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedGallery_View: &appbsky.EmbedGallery_View{
+ Items: []*appbsky.EmbedGallery_View_Items_Elem{
+ nil,
+ {}, // empty union, no variant set
+ {EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: ""}}, // empty Thumbnail
+ {EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: thumb}},
+ },
+ },
+ }
+ got := extractPostMedia(pv, false)
+ if len(got) != 1 || got[0] != thumb {
+ t.Errorf("expected single thumb, got %v", got)
+ }
+
+ // All-nil / all-unknown gallery should produce no thumbs (not [""]).
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedGallery_View: &appbsky.EmbedGallery_View{
+ Items: []*appbsky.EmbedGallery_View_Items_Elem{nil, {}},
+ },
+ }
+ if got := extractPostMedia(pv, false); got != nil {
+ t.Errorf("expected nil for empty/unknown-only gallery, got %v", got)
+ }
+}
+
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
@@ -361,6 +497,25 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
}
}
+// Symmetric guard for the gallery extraction path. Functionally redundant
+// with the early-return at the top of extractPostMedia, but exists so the
+// hide-embed contract is asserted directly against the gallery branch -
+// catches anyone who later moves the embedHidden check inside an
+// embed-shape branch.
+func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
+ thumb := "https://cdn.bsky.app/img/g@jpeg"
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
+ withGallery(thumb), withSelfLabel("porn"))
+ out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ if _, present := main["image"]; present {
+ t.Errorf("hidden-embed gallery post should not emit image")
+ }
+ if _, present := main["thumbnailUrl"]; present {
+ t.Errorf("hidden-embed gallery post should not emit thumbnailUrl")
+ }
+}
+
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, , and a unicode char.
tricky := "hello \"world\" \\ <\\>\n 🎉"
diff --git a/bskyweb/cmd/bskyweb/render_test.go b/bskyweb/cmd/bskyweb/render_test.go
index f7c9edd677..30b27a53d5 100644
--- a/bskyweb/cmd/bskyweb/render_test.go
+++ b/bskyweb/cmd/bskyweb/render_test.go
@@ -98,6 +98,40 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
}
}
+// Gallery posts must hit the same og:image / JSON-LD image[] byte-equality
+// contract that legacy images posts do. Regression guard for the
+// app.bsky.embed.gallery extraction path.
+func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
+ thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
+ thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
+ thumbs := extractPostMedia(pv, false)
+ ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
+ html := renderTemplate(t, "post.html", pongo2.Context{
+ "postView": pv,
+ "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
+ "canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
+ "postJSONLD": ld,
+ "imgThumbUrls": thumbs,
+ })
+
+ if !strings.Contains(html, ``) {
+ t.Errorf("og:image[0] not found in rendered HTML for gallery post")
+ }
+ if !strings.Contains(html, ``) {
+ t.Errorf("og:image[1] not found in rendered HTML for gallery post")
+ }
+ body := extractJSONLD(t, html)
+ var parsed map[string]any
+ _ = json.Unmarshal([]byte(body), &parsed)
+ main := parsed["mainEntity"].(map[string]any)
+ imgs := main["image"].([]any)
+ if len(imgs) != 2 || imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
+ t.Errorf("JSON-LD image strings drifted from og:image for gallery; image=%v thumbnailUrl=%v",
+ imgs, main["thumbnailUrl"])
+ }
+}
+
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
diff --git a/bskyweb/go.mod b/bskyweb/go.mod
index 50ab9a162a..e25349e947 100644
--- a/bskyweb/go.mod
+++ b/bskyweb/go.mod
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
go 1.26
require (
- github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0
+ github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
github.com/flosch/pongo2/v6 v6.0.0
github.com/ipfs/go-log v1.0.5
github.com/joho/godotenv v1.5.1
diff --git a/bskyweb/go.sum b/bskyweb/go.sum
index 235e37f521..d9b4b0141a 100644
--- a/bskyweb/go.sum
+++ b/bskyweb/go.sum
@@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY=
-github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
+github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
+github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=