diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go index 269f5b7047..57cd97f4e6 100644 --- a/bskyweb/cmd/bskyweb/jsonld.go +++ b/bskyweb/cmd/bskyweb/jsonld.go @@ -209,6 +209,9 @@ func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg { p := &personOrOrg{ Type: "Person", } + if author.Did != "" { + p.Identifier = author.Did + } if author.DisplayName != nil && *author.DisplayName != "" { p.Name = *author.DisplayName if author.Handle != "" { @@ -223,16 +226,15 @@ func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg { return p } -// postEmbedHidden reports whether any post-view label or self-label asks -// embeds to be omitted. WebPost uses this so og:image and JSON-LD media -// suppression stay in sync. -func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) bool { +// postHasHideLabel reports whether the post-view or self-labels include +// any non-negated label in labelSet. Self-labels have no negation. +func postHasHideLabel(pv *appbsky.FeedDefs_PostView, labelSet map[string]bool) bool { if pv == nil { return false } for _, label := range pv.Labels { isNeg := label.Neg != nil && *label.Neg - if hideLabels[label.Val] && !isNeg { + if labelSet[label.Val] && !isNeg { return true } } @@ -240,7 +242,7 @@ func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) if rec, ok := pv.Record.Val.(*appbsky.FeedPost); ok { if rec.Labels != nil && rec.Labels.LabelDefs_SelfLabels != nil { for _, label := range rec.Labels.LabelDefs_SelfLabels.Values { - if hideLabels[label.Val] { + if labelSet[label.Val] { return true } } @@ -250,6 +252,13 @@ func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) return false } +// postEmbedHidden reports whether any post-view label or self-label asks +// embeds to be omitted. WebPost uses this so og:image and JSON-LD media +// suppression stay in sync. +func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) bool { + return postHasHideLabel(pv, hideLabels) +} + // postRecordText returns the post's expanded text, or "" if the record is // missing or malformed. func postRecordText(pv *appbsky.FeedDefs_PostView) string { @@ -285,8 +294,9 @@ func buildPostStats(pv *appbsky.FeedDefs_PostView) []interactionStat { // buildPostNode constructs a DiscussionForumPosting in nested form (no // envelope, no @context). Used for top-level posts and for entries in // hasPart / comment arrays. Returns the zero value if pv or pv.Author is -// nil; callers should treat that as "skip". -func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, hideLabels map[string]bool) discussionForumPosting { +// nil; callers should treat that as "skip". Replies whose own labels match +// hideLabels or hideReplyLabels are dropped from comment[]. +func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, hideLabels, hideReplyLabels map[string]bool) discussionForumPosting { if pv == nil || pv.Author == nil { return discussionForumPosting{} } @@ -332,7 +342,13 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th if len(node.Comment) >= maxComments { break } - reply := buildReplyNode(r.FeedDefs_ThreadViewPost.Post, hideLabels) + replyPV := r.FeedDefs_ThreadViewPost.Post + // Drop labeled replies entirely so abusive/spam text isn't surfaced + // into the parent post's structured data. + if postHasHideLabel(replyPV, hideReplyLabels) || postHasHideLabel(replyPV, hideLabels) { + continue + } + reply := buildReplyNode(replyPV, hideLabels) if reply.Type == "" { continue } @@ -369,11 +385,11 @@ 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 map[string]bool) (string, error) { +func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL 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) + node := buildPostNode(pv, replies, hideLabels, hideReplyLabels) // mainEntity.url is empty when the author handle is unusable; fall back // to canonicalURL so it agrees with envelope.url. @@ -447,8 +463,9 @@ func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts [ if len(page.HasPart) >= maxRecentPosts { break } - // Recent posts go in nested form. - node := buildPostNode(rp, nil, hideLabels) + // Recent posts go in nested form. No replies are passed, so the + // reply-label set is irrelevant; pass nil. + node := buildPostNode(rp, nil, hideLabels, nil) if node.Type == "" { continue } diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go index 7d9efc3e09..56cc293d0d 100644 --- a/bskyweb/cmd/bskyweb/jsonld_test.go +++ b/bskyweb/cmd/bskyweb/jsonld_test.go @@ -141,6 +141,19 @@ func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) { } } +// withPostLabel adds a post-view label (as if applied by a labeler) with an +// optional negation flag. +func withPostLabel(val string, neg bool) func(*appbsky.FeedDefs_PostView) { + return func(pv *appbsky.FeedDefs_PostView) { + label := &comatprototypes.LabelDefs_Label{Val: val, Src: "did:plc:labeler"} + if neg { + n := true + label.Neg = &n + } + pv.Labels = append(pv.Labels, label) + } +} + // unmarshalLD parses the JSON-LD blob produced by buildPostJSONLD. func unmarshalLD(t *testing.T, s string) map[string]any { t.Helper() @@ -154,7 +167,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) + out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -196,6 +209,15 @@ func TestBuildPostJSONLD_Bare(t *testing.T) { if !ok || int64(cc) != 3 { t.Errorf("commentCount wrong: %v", main["commentCount"]) } + // Author identifier should be the author's DID so a handle change + // doesn't break identity. + bareAuthor, _ := main["author"].(map[string]any) + if bareAuthor == nil { + t.Fatalf("author missing") + } + if bareAuthor["identifier"] != "did:plc:alice" { + t.Errorf("author identifier should be DID, got %v", bareAuthor["identifier"]) + } // no images on bare post if _, present := main["image"]; present { t.Errorf("bare post should not have image") @@ -215,7 +237,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) + out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -238,7 +260,7 @@ func TestBuildPostJSONLD_WithImages(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) + 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"]) @@ -251,7 +273,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) + 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"]) @@ -260,7 +282,7 @@ 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) + 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") @@ -269,7 +291,7 @@ func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) { 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) + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) sc, ok := main["sharedContent"].(map[string]any) if !ok { @@ -287,7 +309,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) + 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") @@ -301,7 +323,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) + out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -337,7 +359,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) { FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"}, }) - out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels) + out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels) main := unmarshalLD(t, out)["mainEntity"].(map[string]any) if cc := main["commentCount"].(float64); int64(cc) != 14 { @@ -379,7 +401,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) + 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 @@ -428,7 +450,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) + out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels) env := unmarshalLD(t, out) main := env["mainEntity"].(map[string]any) if env["url"] != tc.canonical { @@ -445,7 +467,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); err == nil { + if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil { t.Errorf("expected error for nil-author post, got nil") } } @@ -463,7 +485,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) + out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -484,7 +506,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) + 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) @@ -613,3 +635,99 @@ func TestBskyProfileURL(t *testing.T) { t.Errorf("empty handle should produce empty") } } + +// buildReplies wraps a slice of post views into ThreadViewPost reply elements. +func buildReplies(posts ...*appbsky.FeedDefs_PostView) []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem { + out := make([]*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, 0, len(posts)) + for _, p := range posts { + out = append(out, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{ + FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: p}, + }) + } + return out +} + +// commentIdentifiers extracts the identifier of each entry in mainEntity.comment. +func commentIdentifiers(t *testing.T, out string) []string { + t.Helper() + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + raw, _ := main["comment"].([]any) + ids := make([]string, 0, len(raw)) + for _, c := range raw { + cm := c.(map[string]any) + if id, ok := cm["identifier"].(string); ok { + ids = append(ids, id) + } + } + return ids +} + +func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) { + // A reply carrying a hideReplyLabels post-view label is dropped from comment[]. + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") + 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) + ids := commentIdentifiers(t, out) + if len(ids) != 1 || ids[0] != good.Uri { + t.Errorf("expected only the unlabeled reply to remain, got %v", ids) + } +} + +func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) { + // A reply self-labeling itself with a hideReplyLabels value is dropped. + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") + 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) + ids := commentIdentifiers(t, out) + if len(ids) != 1 || ids[0] != good.Uri { + t.Errorf("expected self-labeled reply dropped, got %v", ids) + } +} + +func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) { + // A reply with a hideEmbedLabels label (e.g., porn) is also dropped. + // hideEmbedLabels is consulted in addition to hideReplyLabels for replies. + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") + good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply") + // "self-harm" is in hideEmbedLabels but not hideReplyLabels — verifies the + // 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) + ids := commentIdentifiers(t, out) + if len(ids) != 1 || ids[0] != good.Uri { + t.Errorf("expected embed-labeled reply dropped, got %v", ids) + } +} + +func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) { + // A negated post-view label should not gate the reply. + 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) + ids := commentIdentifiers(t, out) + if len(ids) != 1 || ids[0] != reply.Uri { + t.Errorf("expected negated-label reply to be kept, got %v", ids) + } +} + +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) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + c := main["comment"].([]any)[0].(map[string]any) + auth, ok := c["author"].(map[string]any) + if !ok { + t.Fatalf("comment author missing") + } + if auth["identifier"] != "did:plc:bob" { + t.Errorf("reply author identifier should be DID, got %v", auth["identifier"]) + } +} diff --git a/bskyweb/cmd/bskyweb/render_test.go b/bskyweb/cmd/bskyweb/render_test.go index 0f9a069d92..8a7109d836 100644 --- a/bskyweb/cmd/bskyweb/render_test.go +++ b/bskyweb/cmd/bskyweb/render_test.go @@ -45,7 +45,7 @@ func extractJSONLD(t *testing.T, html string) string { 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) + ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels) if err != nil { t.Fatal(err) } @@ -74,7 +74,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) + 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", @@ -101,7 +101,7 @@ func TestRenderPost_OGImageMatchesJSONLD(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) + 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", @@ -167,7 +167,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) + 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, @@ -186,3 +186,26 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) { t.Errorf("og:url should not echo DID-form request URI when canonical is set") } } + +// og:video must emit even when there is no thumbnail. Previously the +// {% if videoUrl %} block was nested inside {% if imgThumbUrls %}, so a +// 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) + videoURL := "https://video.bsky.app/v.m3u8" + 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, + "videoUrl": videoURL, + "videoType": "application/x-mpegURL", + }) + if !strings.Contains(html, ``) { + t.Errorf("og:video should emit even without imgThumbUrls; got:\n%s", html) + } + if !strings.Contains(html, ``) { + t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html) + } +} diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index ffa228c29c..bb59c0543a 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -500,6 +500,20 @@ var hideEmbedLabels = map[string]bool{ "sensitive": true, } +// Replies surfaced into a post's JSON-LD comment[] are dropped entirely when +// any of these labels are present (in addition to hideEmbedLabels). Targets +// abuse/spam in third-party reply text, since reply text would otherwise be +// emitted into the parent post's structured data. +var hideReplyLabels = map[string]bool{ + "!hide": true, + "!warn": true, + "porn": true, + "sexual": true, + "nudity": true, + "graphic-media": true, + "spam": true, +} + func (srv *Server) WebPost(c echo.Context) error { ctx := c.Request().Context() data := srv.NewTemplateContext() @@ -590,7 +604,7 @@ func (srv *Server) WebPost(c echo.Context) error { if jsonldURL == "" { jsonldURL = requestURI } - if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels); err == nil { + if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil { data["postJSONLD"] = jsonld } else { log.Warnf("failed to build post JSON-LD for %s: %v", uri, err) diff --git a/bskyweb/templates/post.html b/bskyweb/templates/post.html index 367263aa3c..72ac4664e7 100644 --- a/bskyweb/templates/post.html +++ b/bskyweb/templates/post.html @@ -39,6 +39,11 @@ {% endfor %} + {% else %} + + + + {% endif %} {%- if videoUrl %} @@ -47,11 +52,6 @@ {% endif -%} {% endif -%} - {% else %} - - - - {% endif %} {%- if postView.LikeCount %}