diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go index 4178250493..a5ab849c35 100644 --- a/bskyweb/cmd/bskyweb/jsonld.go +++ b/bskyweb/cmd/bskyweb/jsonld.go @@ -65,6 +65,7 @@ type discussionForumPosting struct { CommentCount *int64 `json:"commentCount,omitempty"` Comment []comment `json:"comment,omitempty"` IsBasedOn string `json:"isBasedOn,omitempty"` + IsPartOf string `json:"isPartOf,omitempty"` SharedContent *sharedContent `json:"sharedContent,omitempty"` } @@ -344,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string { return "" } +// threadRootURI returns the AT-URI of the root post of the thread a reply +// belongs to, or "" if the post is not a reply or the record is malformed. +func threadRootURI(pv *appbsky.FeedDefs_PostView) string { + if pv == nil || pv.Record == nil { + return "" + } + rec, ok := pv.Record.Val.(*appbsky.FeedPost) + if !ok || rec.Reply == nil || rec.Reply.Root == nil { + return "" + } + return rec.Reply.Root.Uri +} + +// findRootPostInParents walks tv's parent chain upward and returns the +// PostView whose URI matches rootURI, or nil if the root is not present. +// The root is absent when the chain is truncated by parentHeight (reply +// deeper than the fetched height) or broken by a blocked/not-found parent. +// Avoids a separate FeedGetPosts call when the thread response already +// contains the root. +func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView { + if rootURI == "" { + return nil + } + for node := tv; node != nil; { + if node.Post != nil && node.Post.Uri == rootURI { + return node.Post + } + if node.Parent == nil { + return nil + } + // Only threadViewPost parents continue the chain; a blocked or + // not-found parent breaks it before reaching the root. + node = node.Parent.FeedDefs_ThreadViewPost + } + return nil +} + // buildAuthor constructs a Person. Organization classification for // custom-domain accounts is a future enhancement. func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg { @@ -584,13 +622,21 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c // buildPostJSONLD marshals the WebPage envelope wrapping a // DiscussionForumPosting. canonicalURL is used for both envelope.url and -// (as a fallback) mainEntity.url so they always agree. -func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) { +// (as a fallback) mainEntity.url so they always agree. isPartOfURL, when +// non-empty, is the handle-form canonical URL of the thread root the handler +// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could +// not be resolved). We never emit a DID-form isPartOf because it would not +// match the root page's handle-form canonical. +func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) { if pv == nil || pv.Author == nil { return "", fmt.Errorf("nil post view or author") } node := buildPostNode(pv, replies, hideLabels, hideReplyLabels) + if isPartOfURL != "" { + node.IsPartOf = isPartOfURL + } + // mainEntity.url is empty when the author handle is unusable; fall back // to canonicalURL so it agrees with envelope.url. if node.URL == "" { diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go index ee44008030..d3da1a0570 100644 --- a/bskyweb/cmd/bskyweb/jsonld_test.go +++ b/bskyweb/cmd/bskyweb/jsonld_test.go @@ -183,6 +183,19 @@ func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) { } } +// withReplyRoot marks the post as a reply by setting its record's Reply.Root +// strong-ref to the given thread-root post. +func withReplyRoot(rootDid, rootRkey string) func(*appbsky.FeedDefs_PostView) { + return func(pv *appbsky.FeedDefs_PostView) { + rec, _ := pv.Record.Val.(*appbsky.FeedPost) + uri := "at://" + rootDid + "/app.bsky.feed.post/" + rootRkey + rec.Reply = &appbsky.FeedPost_ReplyRef{ + Root: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"}, + Parent: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"}, + } + } +} + // withSelfLabel adds a self-label that should hide embeds. func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) { return func(pv *appbsky.FeedDefs_PostView) { @@ -263,7 +276,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any { func TestBuildPostJSONLD_Bare(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" - out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) + out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -333,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) { thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg" thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg" pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2)) - out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) + out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -358,7 +371,7 @@ func TestBuildPostJSONLD_WithGallery(t *testing.T) { 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) + out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -385,7 +398,7 @@ 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) + out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -438,7 +451,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) { 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)) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) if main["thumbnailUrl"] != thumb { t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"]) @@ -451,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) { func TestBuildPostJSONLD_QuotePost(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz")) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" { t.Errorf("isBasedOn wrong: %v", main["isBasedOn"]) @@ -460,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) { func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked()) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) if _, present := main["isBasedOn"]; present { t.Errorf("blocked quote should not produce isBasedOn") } } +func TestBuildPostJSONLD_IsPartOf(t *testing.T) { + // isPartOf is sourced solely from the handler-resolved URL. When supplied, + // it is emitted on the main post; when empty, no isPartOf is present. + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply") + isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey" + out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + if main["isPartOf"] != isPartOf { + t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf) + } + + out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) + main = unmarshalLD(t, out)["mainEntity"].(map[string]any) + if _, present := main["isPartOf"]; present { + t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"]) + } +} + +func TestThreadRootURI(t *testing.T) { + // A reply returns its root AT-URI; a non-reply returns "". + reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply", + withReplyRoot("did:plc:root", "rootrkey")) + want := "at://did:plc:root/app.bsky.feed.post/rootrkey" + if got := threadRootURI(reply); got != want { + t.Errorf("threadRootURI = %q, want %q", got, want) + } + + post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply") + if got := threadRootURI(post); got != "" { + t.Errorf("threadRootURI on non-reply = %q, want empty", got) + } +} + +func TestFindRootPostInParents(t *testing.T) { + rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root") + rootURI := rootPost.Uri + + // tvp wraps a PostView, optionally chaining to a parent thread node. + tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost { + node := &appbsky.FeedDefs_ThreadViewPost{Post: pv} + if parent != nil { + node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent} + } + return node + } + + t.Run("direct reply, parent is root", func(t *testing.T) { + leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil)) + if got := findRootPostInParents(leaf, rootURI); got != rootPost { + t.Errorf("expected root post, got %v", got) + } + }) + + t.Run("multi-level chain", func(t *testing.T) { + mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil)) + leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid) + if got := findRootPostInParents(leaf, rootURI); got != rootPost { + t.Errorf("expected root post in chain, got %v", got) + } + }) + + t.Run("root absent, chain truncated", func(t *testing.T) { + // Topmost parent is not the root (e.g. parentHeight cut off the chain). + topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid") + leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil)) + if got := findRootPostInParents(leaf, rootURI); got != nil { + t.Errorf("expected nil when root absent, got %v", got) + } + }) + + t.Run("chain broken by blocked parent", func(t *testing.T) { + // A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost, + // breaking the walk before the root. + leaf := &appbsky.FeedDefs_ThreadViewPost{ + Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), + Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{ + FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI}, + }, + } + if got := findRootPostInParents(leaf, rootURI); got != nil { + t.Errorf("expected nil when chain broken by blocked parent, got %v", got) + } + }) + + t.Run("empty rootURI", func(t *testing.T) { + leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil)) + if got := findRootPostInParents(leaf, ""); got != nil { + t.Errorf("expected nil for empty rootURI, got %v", got) + } + }) +} + +func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) { + // Replies surfaced under the main post as comment[] are Comment nodes and + // never carry isPartOf, even when the main post has one. + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") + reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply") + isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey" + out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + c := main["comment"].([]any)[0].(map[string]any) + if _, present := c["isPartOf"]; present { + t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"]) + } +} + func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title")) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) sc, ok := main["sharedContent"].(map[string]any) if !ok { @@ -487,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) { thumb := "https://cdn.bsky.app/img/x@jpeg" pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw", withImages(thumb), withSelfLabel("porn")) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) if _, present := main["image"]; present { t.Errorf("hidden-embed post should not emit image") @@ -506,7 +625,7 @@ 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) + 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") @@ -520,7 +639,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) { // Includes ", \, newline, , and a unicode char. tricky := "hello \"world\" \\ <\\>\n 🎉" pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky) - out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -556,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) { FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"}, }) - out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) if cc := main["commentCount"].(float64); int64(cc) != 14 { @@ -598,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) { func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) { pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello") fallback := "https://bsky.app/profile/did:plc:alice/post/abc123" - out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels) envelope := unmarshalLD(t, out) main := envelope["mainEntity"].(map[string]any) // mainEntity.url falls back to the caller's canonical URL so envelope @@ -647,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { pv := makePostView(tc.handle, tc.did, tc.rkey, "hi") - out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels) env := unmarshalLD(t, out) main := env["mainEntity"].(map[string]any) if env["url"] != tc.canonical { @@ -664,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) { // Defensive: don't panic if Author is nil. pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") pv.Author = nil - if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil { + if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil { t.Errorf("expected error for nil-author post, got nil") } } @@ -682,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) { {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}}, {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}}, } - out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels) + out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -703,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) { replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{ {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}}, } - out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) c := main["comment"].([]any)[0].(map[string]any) imgs, ok := c["image"].([]any) @@ -910,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) { good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply") bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply", withPostLabel("!hide", false)) - out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels) ids := commentIdentifiers(t, out) if len(ids) != 1 || ids[0] != good.Uri { t.Errorf("expected only the unlabeled reply to remain, got %v", ids) @@ -923,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) { good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply") bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply", withSelfLabel("spam")) - out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels) ids := commentIdentifiers(t, out) if len(ids) != 1 || ids[0] != good.Uri { t.Errorf("expected self-labeled reply dropped, got %v", ids) @@ -939,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) { // union behavior. bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply", withPostLabel("self-harm", false)) - out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels) ids := commentIdentifiers(t, out) if len(ids) != 1 || ids[0] != good.Uri { t.Errorf("expected embed-labeled reply dropped, got %v", ids) @@ -951,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply", withPostLabel("!hide", true)) - out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels) ids := commentIdentifiers(t, out) if len(ids) != 1 || ids[0] != reply.Uri { t.Errorf("expected negated-label reply to be kept, got %v", ids) @@ -962,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) { // Reply author should also carry a DID identifier. pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi") - out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) c := main["comment"].([]any)[0].(map[string]any) auth, ok := c["author"].(map[string]any) @@ -1103,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) { }) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi", withVerifications(state)) - out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -1143,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi", withVerifications(state)) - out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) c := main["comment"].([]any)[0].(map[string]any) auth := c["author"].(map[string]any) @@ -1267,7 +1386,7 @@ func TestBuildPostJSONLD_WithVideoObject(t *testing.T) { hasAspect: true, width: 16, height: 9, })) canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" - out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) + out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -1315,7 +1434,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) { withVideoFull(videoEmbedOpts{ playlist: "https://video.bsky.app/p.m3u8", })) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) video, ok := main["video"].(map[string]any) if !ok { @@ -1332,7 +1451,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) { withVideoFull(videoEmbedOpts{ playlist: "https://video.bsky.app/p.m3u8", })) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) video := main["video"].(map[string]any) if video["name"] != "Video on Bluesky" { @@ -1347,7 +1466,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) { withVideoFull(videoEmbedOpts{ playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip", })) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) video := main["video"].(map[string]any) if video["description"] != "scenic clip" { @@ -1360,7 +1479,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) { withVideoFull(videoEmbedOpts{ playlist: "https://video.bsky.app/p.m3u8", alt: "alt", })) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) video := main["video"].(map[string]any) if _, present := video["width"]; present { @@ -1377,7 +1496,7 @@ func TestBuildPostJSONLD_VideoMissingPlaylist(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", "x", withVideo(thumb)) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) if _, present := main["video"]; present { t.Errorf("video without playlist should not produce VideoObject") @@ -1396,7 +1515,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) { alt: "should be dropped", }), withSelfLabel("porn")) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) if _, present := main["video"]; present { t.Errorf("hidden-embed post should not emit video") @@ -1411,7 +1530,7 @@ func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) { thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true, hasAspect: true, width: 4, height: 3, })) - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) video, ok := main["video"].(map[string]any) if !ok { @@ -1438,7 +1557,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) { withVideoFull(videoEmbedOpts{ thumbnail: thumb, playlist: playlist, alt: "bob's clip", })) - out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) c := main["comment"].([]any)[0].(map[string]any) video, ok := c["video"].(map[string]any) @@ -1461,7 +1580,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) { func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed") - out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) if _, present := main["video"]; present { t.Errorf("post without video should not include video field") @@ -1505,7 +1624,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) { playlist: playlist, alt: "scenic clip", })) canonical := "https://bsky.app/profile/did:plc:alice/post/abc123" - out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) video, ok := main["video"].(map[string]any) if !ok { @@ -1528,7 +1647,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) { withVideoFull(videoEmbedOpts{ playlist: playlist, alt: "bob's clip", })) - out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) + out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) c := main["comment"].([]any)[0].(map[string]any) video, ok := c["video"].(map[string]any) diff --git a/bskyweb/cmd/bskyweb/labels.go b/bskyweb/cmd/bskyweb/labels.go index 4055d4b9c8..2a1c77e3e0 100644 --- a/bskyweb/cmd/bskyweb/labels.go +++ b/bskyweb/cmd/bskyweb/labels.go @@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool { } return false } + +// postAuthorRequiresAuth reports whether the post author self-applied the +// !no-unauthenticated label, read from the author view embedded in a +// getPostThread response. The appview surfaces the account's profile-record +// self-labels on the post author (src == author DID), so this mirrors +// profileRequiresAuth without a separate ActorGetProfile call. +func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool { + if pv == nil || pv.Author == nil { + return false + } + for _, label := range pv.Author.Labels { + if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" { + return true + } + } + return false +} diff --git a/bskyweb/cmd/bskyweb/labels_test.go b/bskyweb/cmd/bskyweb/labels_test.go index d55e31b207..5d385b7ad2 100644 --- a/bskyweb/cmd/bskyweb/labels_test.go +++ b/bskyweb/cmd/bskyweb/labels_test.go @@ -77,3 +77,76 @@ func TestProfileRequiresAuth(t *testing.T) { }) } } + +func TestPostAuthorRequiresAuth(t *testing.T) { + negTrue := true + + authorPV := func(labels []*comatprototypes.LabelDefs_Label) *appbsky.FeedDefs_PostView { + return &appbsky.FeedDefs_PostView{ + Author: &appbsky.ActorDefs_ProfileViewBasic{ + Did: "did:plc:alice", + Handle: "alice.bsky.social", + Labels: labels, + }, + } + } + + tests := []struct { + name string + pv *appbsky.FeedDefs_PostView + want bool + }{ + { + name: "nil post view", + pv: nil, + want: false, + }, + { + name: "nil author", + pv: &appbsky.FeedDefs_PostView{}, + want: false, + }, + { + name: "no labels", + pv: authorPV(nil), + want: false, + }, + { + name: "self-applied !no-unauthenticated", + pv: authorPV([]*comatprototypes.LabelDefs_Label{ + {Src: "did:plc:alice", Val: "!no-unauthenticated"}, + }), + want: true, + }, + { + name: "label from a different src does not gate", + pv: authorPV([]*comatprototypes.LabelDefs_Label{ + {Src: "did:plc:labeler", Val: "!no-unauthenticated"}, + }), + want: false, + }, + { + name: "different label value does not gate", + pv: authorPV([]*comatprototypes.LabelDefs_Label{ + {Src: "did:plc:alice", Val: "spam"}, + }), + want: false, + }, + { + // Negation isn't honored - matches profileRequiresAuth behavior. + name: "negated label still triggers (matches profile behavior)", + pv: authorPV([]*comatprototypes.LabelDefs_Label{ + {Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue}, + }), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := postAuthorRequiresAuth(tt.pv); got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} diff --git a/bskyweb/cmd/bskyweb/render_test.go b/bskyweb/cmd/bskyweb/render_test.go index 902476709f..c4ba22090a 100644 --- a/bskyweb/cmd/bskyweb/render_test.go +++ b/bskyweb/cmd/bskyweb/render_test.go @@ -52,7 +52,7 @@ func TestRenderBase_NoindexMeta(t *testing.T) { func TestRenderPost_EmitsJSONLD(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") - ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) + ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -81,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) { thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg" thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg" pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2)) - ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) + 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", @@ -113,7 +113,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) { 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) + 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", @@ -142,7 +142,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) { 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") - ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) html := renderTemplate(t, "post.html", pongo2.Context{ "postView": pv, "requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo", @@ -208,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) { // og:url and must emit the same URL. func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") - ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" html := renderTemplate(t, "post.html", pongo2.Context{ "postView": pv, @@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) { // video without a thumbnail dropped og:video entirely. func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch") - ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels) videoURL := "https://video.bsky.app/v.m3u8" html := renderTemplate(t, "post.html", pongo2.Context{ "postView": pv, @@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) { // flip of the noindex flag for indexable pages. func TestRenderPost_PublicNoNoindex(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello") - ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) + 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", diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 6f6a5c0804..7373dc5cdc 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -620,23 +620,39 @@ func (srv *Server) WebPost(c echo.Context) error { identifier := handleOrDID.Normalize().String() - // requires two fetches: first fetch profile (!) - pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier) - if err != nil { - log.Warnf("failed to fetch profile for: %s\t%v", identifier, err) - return c.Render(http.StatusOK, "post.html", data) - } - unauthedViewingOkay := !profileRequiresAuth(pv) - req := c.Request() requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) + // Fetch the post thread directly. The AT-URI authority accepts either a + // handle or a DID (the appview resolves it), so we skip the separate + // ActorGetProfile call and source identity, the canonical URL, and the + // auth gate from the thread response's author view instead. + // parentHeight=80 (the lexicon default) pulls the reply's ancestor chain + // up to the root in nearly all threads, letting isPartOf resolve from this + // response without a separate FeedGetPosts call. + uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", identifier, rkey) + tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 80, uri) + if err != nil { + log.Warnf("failed to fetch post: %s\t%v", uri, err) + return c.Render(http.StatusOK, "post.html", data) + } + + threadView := tpv.Thread.FeedDefs_ThreadViewPost + if threadView == nil || threadView.Post == nil || threadView.Post.Author == nil { + return c.Render(http.StatusOK, "post.html", data) + } + postView := threadView.Post + // Always prefer the handle-form URL so JSON-LD `url` and // match. Falls back to requestURI when the // handle is unusable (template strips query/fragment). - canonicalURL := bskyPostURL(pv.Handle, rkey.String()) + canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String()) - if !unauthedViewingOkay { + // Gate before populating any post content into the template so that + // !no-unauthenticated posts never leak text/media. The appview returns the + // post (with the author self-label) to unauthed callers, so we detect the + // label here rather than via a profile fetch. + if postAuthorRequiresAuth(postView) { // Provide minimal OpenGraph data for auth-required posts data["requestURI"] = requestURI if canonicalURL != "" { @@ -645,26 +661,13 @@ func (srv *Server) WebPost(c echo.Context) error { data["requiresAuth"] = true data["noindex"] = true data["nofollow"] = true - data["profileHandle"] = pv.Handle - if pv.DisplayName != nil { - data["profileDisplayName"] = *pv.DisplayName + data["profileHandle"] = postView.Author.Handle + if postView.Author.DisplayName != nil { + data["profileDisplayName"] = *postView.Author.DisplayName } return c.Render(http.StatusOK, "post.html", data) } - // then fetch the post thread (with extra context) - uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey) - tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri) - if err != nil { - log.Warnf("failed to fetch post: %s\t%v", uri, err) - return c.Render(http.StatusOK, "post.html", data) - } - - threadView := tpv.Thread.FeedDefs_ThreadViewPost - if threadView == nil || threadView.Post == nil { - return c.Render(http.StatusOK, "post.html", data) - } - postView := threadView.Post data["postView"] = postView data["requestURI"] = requestURI if canonicalURL != "" { @@ -694,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error { if jsonldURL == "" { jsonldURL = requestURI } - if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil { + + // Best-effort: resolve a reply's thread root to its handle-form canonical + // URL for isPartOf. Prefer the root already present in the thread response + // (parentHeight=80); fall back to a bounded FeedGetPosts only when the + // chain is truncated (very deep thread) or broken by a blocked/not-found + // ancestor. On timeout, error, or an unresolvable root we omit isPartOf + // rather than point at a non-indexable page. + isPartOfURL := "" + if rootURI := threadRootURI(postView); rootURI != "" { + if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil { + isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI) + } else { + pctx, cancel := context.WithTimeout(ctx, 1*time.Second) + if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil { + log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr) + } else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil { + // Handle-form only (no DID fallback): isPartOf must match the + // root page's handle-form canonical, so an unusable handle omits + // isPartOf rather than point at a non-canonical DID-form URL. + isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI) + } + cancel() + } + } + + if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil { data["postJSONLD"] = jsonld } else { log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)