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 %}