diff --git a/bskyweb/cmd/bskyweb/embedmeta.go b/bskyweb/cmd/bskyweb/embedmeta.go
new file mode 100644
index 0000000000..84b8f8a4da
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/embedmeta.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+)
+
+// Helpers for extracting Open Graph metadata from post embeds. These feed
+// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
+// jsonld.go. og:video has its own path because JSON-LD does not yet emit
+// VideoObject (deferred — needs `duration` from the appview).
+
+// videoMeta holds og:video meta tag data.
+type videoMeta struct {
+ URL string
+ Type string
+ Width int64
+ Height int64
+ HasSize bool
+}
+
+// extractVideoMeta returns og:video metadata, or the zero value if there's
+// no video embed. Respects embedHidden so og:video and og:image stay in sync.
+func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
+ if pv == nil || pv.Embed == nil || embedHidden {
+ return videoMeta{}
+ }
+ var v *appbsky.EmbedVideo_View
+ if pv.Embed.EmbedVideo_View != nil {
+ v = pv.Embed.EmbedVideo_View
+ } else if pv.Embed.EmbedRecordWithMedia_View != nil &&
+ pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
+ pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil {
+ v = pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View
+ }
+ if v == nil || v.Playlist == "" {
+ return videoMeta{}
+ }
+ out := videoMeta{
+ URL: v.Playlist,
+ Type: "application/vnd.apple.mpegurl",
+ }
+ if v.AspectRatio != nil {
+ out.Width = v.AspectRatio.Width
+ out.Height = v.AspectRatio.Height
+ out.HasSize = true
+ }
+ return out
+}
diff --git a/bskyweb/cmd/bskyweb/embedmeta_test.go b/bskyweb/cmd/bskyweb/embedmeta_test.go
new file mode 100644
index 0000000000..8270520633
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/embedmeta_test.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+ "testing"
+
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+)
+
+func TestExtractVideoMeta(t *testing.T) {
+ // Plain post — no video.
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc", "hi")
+ if vm := extractVideoMeta(pv, false); vm.URL != "" {
+ t.Errorf("non-video post produced video meta: %+v", vm)
+ }
+
+ // Video embed with playlist + aspect ratio.
+ playlist := "https://video.bsky.app/playlist.m3u8"
+ pv = makePostView("alice.bsky.social", "did:plc:alice", "abc", "watch")
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedVideo_View: &appbsky.EmbedVideo_View{
+ Thumbnail: strPtr("https://cdn.bsky.app/thumb@jpg"),
+ Playlist: playlist,
+ AspectRatio: &appbsky.EmbedDefs_AspectRatio{Width: 16, Height: 9},
+ },
+ }
+ vm := extractVideoMeta(pv, false)
+ if vm.URL != playlist {
+ t.Errorf("URL wrong: %v", vm.URL)
+ }
+ if vm.Type != "application/vnd.apple.mpegurl" {
+ t.Errorf("Type wrong: %v", vm.Type)
+ }
+ if !vm.HasSize || vm.Width != 16 || vm.Height != 9 {
+ t.Errorf("aspect ratio not propagated: %+v", vm)
+ }
+
+ // Hidden embed gate suppresses video too.
+ if vm := extractVideoMeta(pv, true); vm.URL != "" {
+ t.Errorf("hidden embed should suppress video meta")
+ }
+
+ // Video inside record-with-media.
+ pv2 := makePostView("alice.bsky.social", "did:plc:alice", "abc", "quote w/ video")
+ pv2.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
+ Media: &appbsky.EmbedRecordWithMedia_View_Media{
+ EmbedVideo_View: &appbsky.EmbedVideo_View{Playlist: playlist},
+ },
+ },
+ }
+ vm2 := extractVideoMeta(pv2, false)
+ if vm2.URL != playlist {
+ t.Errorf("record-with-media video URL not extracted: %+v", vm2)
+ }
+ if vm2.HasSize {
+ t.Errorf("expected HasSize=false when no aspect ratio")
+ }
+
+ // Video without playlist — skip entirely.
+ pv3 := makePostView("alice.bsky.social", "did:plc:alice", "abc", "no playlist")
+ pv3.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedVideo_View: &appbsky.EmbedVideo_View{
+ Thumbnail: strPtr("https://cdn.bsky.app/thumb@jpg"),
+ },
+ }
+ if vm := extractVideoMeta(pv3, false); vm.URL != "" {
+ t.Errorf("video without playlist should produce empty meta, got %+v", vm)
+ }
+}
diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go
new file mode 100644
index 0000000000..7bc44a02f3
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/jsonld.go
@@ -0,0 +1,553 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+// schema.org structured-data types emitted on post and profile pages.
+// Building these as Go structs (rather than inline JSON in templates) gives
+// us correct JSON escaping via encoding/json and a single place to test the
+// schema. Optional fields use omitempty so empty values don't reach Google's
+// validator as null/empty strings.
+
+const schemaOrgContext = "https://schema.org"
+
+type interactionStat struct {
+ Type string `json:"@type"`
+ InteractionType string `json:"interactionType"`
+ UserInteractionCount int64 `json:"userInteractionCount"`
+}
+
+type personOrOrg struct {
+ Type string `json:"@type"`
+ Name string `json:"name,omitempty"`
+ AlternateName string `json:"alternateName,omitempty"`
+ Identifier string `json:"identifier,omitempty"`
+ URL string `json:"url,omitempty"`
+ Description string `json:"description,omitempty"`
+ Image string `json:"image,omitempty"`
+ InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
+ AgentInteractionStat []interactionStat `json:"agentInteractionStatistic,omitempty"`
+ ReviewedBy []*verifier `json:"reviewedBy,omitempty"`
+}
+
+// verifier is the schema.org Person shape emitted under Person.reviewedBy.
+// Narrower than personOrOrg: just enough to identify the verifying entity.
+type verifier struct {
+ Type string `json:"@type"`
+ Name string `json:"name,omitempty"`
+ AlternateName string `json:"alternateName,omitempty"`
+ Identifier string `json:"identifier,omitempty"`
+ URL string `json:"url,omitempty"`
+}
+
+type sharedContent struct {
+ Type string `json:"@type"`
+ URL string `json:"url"`
+}
+
+type discussionForumPosting struct {
+ Context string `json:"@context,omitempty"`
+ Type string `json:"@type"`
+ URL string `json:"url,omitempty"`
+ Identifier string `json:"identifier,omitempty"`
+ Author *personOrOrg `json:"author,omitempty"`
+ Text string `json:"text,omitempty"`
+ Image []string `json:"image,omitempty"`
+ ThumbnailURL string `json:"thumbnailUrl,omitempty"`
+ DatePublished string `json:"datePublished,omitempty"`
+ InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
+ CommentCount *int64 `json:"commentCount,omitempty"`
+ Comment []comment `json:"comment,omitempty"`
+ IsBasedOn string `json:"isBasedOn,omitempty"`
+ SharedContent *sharedContent `json:"sharedContent,omitempty"`
+}
+
+// comment is the schema.org Comment shape used in
+// DiscussionForumPosting.comment[]. The comment property does not accept
+// DiscussionForumPosting, so replies map to Comment.
+type comment struct {
+ Type string `json:"@type"`
+ URL string `json:"url,omitempty"`
+ Identifier string `json:"identifier,omitempty"`
+ Author *personOrOrg `json:"author,omitempty"`
+ Text string `json:"text,omitempty"`
+ Image []string `json:"image,omitempty"`
+ ThumbnailURL string `json:"thumbnailUrl,omitempty"`
+ DatePublished string `json:"datePublished,omitempty"`
+}
+
+type webPage struct {
+ Context string `json:"@context"`
+ Type string `json:"@type"`
+ URL string `json:"url,omitempty"`
+ MainEntity discussionForumPosting `json:"mainEntity"`
+}
+
+type profilePage struct {
+ Context string `json:"@context"`
+ Type string `json:"@type"`
+ DateCreated string `json:"dateCreated,omitempty"`
+ MainEntity *personOrOrg `json:"mainEntity"`
+ HasPart []discussionForumPosting `json:"hasPart,omitempty"`
+}
+
+// maxComments caps DiscussionForumPosting.comment[] to keep SSR HTML small.
+const maxComments = 10
+
+// maxRecentPosts caps ProfilePage.hasPart[].
+const maxRecentPosts = 10
+
+// maxReviewedBy caps Person.reviewedBy entries.
+const maxReviewedBy = 10
+
+// authorFeedFetchLimit oversamples getAuthorFeed because posts_no_replies
+// still returns reposts; we drop those client-side. 3x is a safe margin.
+const authorFeedFetchLimit = 3 * maxRecentPosts
+
+// bskyPostURL returns the canonical handle-form post URL, or "" if handle
+// or rkey is unusable.
+func bskyPostURL(handle, rkey string) string {
+ if handle == "" || handle == "handle.invalid" || rkey == "" {
+ return ""
+ }
+ return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, rkey)
+}
+
+// bskyPostURLFromATURI is bskyPostURL for callers holding an at-uri.
+func bskyPostURLFromATURI(handle, atURI string) string {
+ parsed, err := syntax.ParseATURI(atURI)
+ if err != nil {
+ return ""
+ }
+ return bskyPostURL(handle, parsed.RecordKey().String())
+}
+
+// bskyProfileURL returns the canonical handle-form profile URL, or "" if
+// the handle is unusable.
+func bskyProfileURL(handle string) string {
+ if handle == "" || handle == "handle.invalid" {
+ return ""
+ }
+ 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].
+func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
+ if pv == nil || pv.Embed == nil || embedHidden {
+ return nil
+ }
+
+ if pv.Embed.EmbedImages_View != nil {
+ return imageThumbs(pv.Embed.EmbedImages_View.Images)
+ }
+ if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
+ return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
+ }
+ if pv.Embed.EmbedRecordWithMedia_View != nil && pv.Embed.EmbedRecordWithMedia_View.Media != nil {
+ media := pv.Embed.EmbedRecordWithMedia_View.Media
+ if media.EmbedImages_View != nil {
+ return imageThumbs(media.EmbedImages_View.Images)
+ }
+ if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
+ return []string{*media.EmbedVideo_View.Thumbnail}
+ }
+ }
+ return nil
+}
+
+// imageThumbs returns the thumb URLs, or nil if empty.
+func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
+ if len(images) == 0 {
+ return nil
+ }
+ urls := make([]string, 0, len(images))
+ for _, img := range images {
+ urls = append(urls, img.Thumb)
+ }
+ 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 {
+ if pv == nil || pv.Embed == nil {
+ return ""
+ }
+ var rec *appbsky.EmbedRecord_View
+ if pv.Embed.EmbedRecord_View != nil {
+ rec = pv.Embed.EmbedRecord_View
+ } else if pv.Embed.EmbedRecordWithMedia_View != nil {
+ rec = pv.Embed.EmbedRecordWithMedia_View.Record
+ }
+ if rec == nil || rec.Record == nil {
+ return ""
+ }
+ vr := rec.Record.EmbedRecord_ViewRecord
+ if vr == nil || vr.Author == nil {
+ return ""
+ }
+ return bskyPostURLFromATURI(vr.Author.Handle, vr.Uri)
+}
+
+// extractSharedContentURL returns the URL of an external link embed (also
+// from the media slot of a record-with-media embed).
+func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
+ if pv == nil || pv.Embed == nil {
+ return ""
+ }
+ if pv.Embed.EmbedExternal_View != nil && pv.Embed.EmbedExternal_View.External != nil {
+ return pv.Embed.EmbedExternal_View.External.Uri
+ }
+ if pv.Embed.EmbedRecordWithMedia_View != nil &&
+ pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
+ pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View != nil &&
+ pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External != nil {
+ return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External.Uri
+ }
+ return ""
+}
+
+// buildAuthor constructs a Person. Organization classification for
+// custom-domain accounts is a future enhancement.
+func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
+ if author == nil {
+ return nil
+ }
+ p := &personOrOrg{
+ Type: "Person",
+ }
+ if author.Did != "" {
+ p.Identifier = author.Did
+ }
+ if author.DisplayName != nil && *author.DisplayName != "" {
+ p.Name = *author.DisplayName
+ if author.Handle != "" {
+ p.AlternateName = "@" + author.Handle
+ }
+ } else if author.Handle != "" {
+ p.Name = "@" + author.Handle
+ }
+ if url := bskyProfileURL(author.Handle); url != "" {
+ p.URL = url
+ }
+ return p
+}
+
+// buildReviewedBy maps a profile's verifications to Person entries for
+// Person.reviewedBy. Skips !IsValid and missing Issuer; caps at
+// maxReviewedBy. URL falls back to DID-form when the verifier's handle is
+// missing or "handle.invalid".
+func buildReviewedBy(state *appbsky.ActorDefs_VerificationState) []*verifier {
+ if state == nil || len(state.Verifications) == 0 {
+ return nil
+ }
+ var out []*verifier
+ for _, v := range state.Verifications {
+ if v == nil || !v.IsValid || v.Issuer == "" {
+ continue
+ }
+ if len(out) >= maxReviewedBy {
+ break
+ }
+ entry := &verifier{
+ Type: "Person",
+ Identifier: v.Issuer,
+ }
+ var handle string
+ if v.IssuerHandle != nil {
+ handle = *v.IssuerHandle
+ }
+ if v.IssuerDisplayName != nil && *v.IssuerDisplayName != "" {
+ entry.Name = *v.IssuerDisplayName
+ if handle != "" && handle != "handle.invalid" {
+ entry.AlternateName = "@" + handle
+ }
+ } else if handle != "" && handle != "handle.invalid" {
+ entry.Name = "@" + handle
+ }
+ if url := bskyProfileURL(handle); url != "" {
+ entry.URL = url
+ } else {
+ entry.URL = "https://bsky.app/profile/" + v.Issuer
+ }
+ out = append(out, entry)
+ }
+ return out
+}
+
+// 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 labelSet[label.Val] && !isNeg {
+ return true
+ }
+ }
+ if pv.Record != nil {
+ 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 labelSet[label.Val] {
+ return true
+ }
+ }
+ }
+ }
+ }
+ 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 {
+ if pv == nil || pv.Record == nil {
+ return ""
+ }
+ rec, ok := pv.Record.Val.(*appbsky.FeedPost)
+ if !ok {
+ return ""
+ }
+ return ExpandPostText(rec)
+}
+
+// buildPostStats returns the like / comment / share interaction triple.
+// commentCount is emitted separately on DiscussionForumPosting.
+func buildPostStats(pv *appbsky.FeedDefs_PostView) []interactionStat {
+ if pv == nil {
+ return nil
+ }
+ deref := func(p *int64) int64 {
+ if p == nil {
+ return 0
+ }
+ return *p
+ }
+ return []interactionStat{
+ {Type: "InteractionCounter", InteractionType: "https://schema.org/LikeAction", UserInteractionCount: deref(pv.LikeCount)},
+ {Type: "InteractionCounter", InteractionType: "https://schema.org/CommentAction", UserInteractionCount: deref(pv.ReplyCount)},
+ {Type: "InteractionCounter", InteractionType: "https://schema.org/ShareAction", UserInteractionCount: deref(pv.RepostCount) + deref(pv.QuoteCount)},
+ }
+}
+
+// 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". 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{}
+ }
+ embedHidden := postEmbedHidden(pv, hideLabels)
+ images := extractPostMedia(pv, embedHidden)
+ var thumb string
+ if len(images) > 0 {
+ thumb = images[0]
+ }
+
+ node := discussionForumPosting{
+ Type: "DiscussionForumPosting",
+ URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
+ Identifier: pv.Uri,
+ Author: buildAuthor(pv.Author),
+ Text: postRecordText(pv),
+ Image: images,
+ ThumbnailURL: thumb,
+ DatePublished: pv.IndexedAt,
+ InteractionStat: buildPostStats(pv),
+ }
+
+ // Surface verifications on the post author only; replies do not.
+ if node.Author != nil {
+ if rb := buildReviewedBy(pv.Author.Verification); len(rb) > 0 {
+ node.Author.ReviewedBy = rb
+ }
+ }
+
+ if pv.ReplyCount != nil {
+ node.CommentCount = pv.ReplyCount
+ } else {
+ zero := int64(0)
+ node.CommentCount = &zero
+ }
+
+ if !embedHidden {
+ if quoted := extractQuotedPostURL(pv); quoted != "" {
+ node.IsBasedOn = quoted
+ }
+ if shared := extractSharedContentURL(pv); shared != "" {
+ node.SharedContent = &sharedContent{Type: "WebPage", URL: shared}
+ }
+ }
+
+ for _, r := range replies {
+ if r == nil || r.FeedDefs_ThreadViewPost == nil || r.FeedDefs_ThreadViewPost.Post == nil {
+ continue
+ }
+ if len(node.Comment) >= maxComments {
+ break
+ }
+ 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
+ }
+ node.Comment = append(node.Comment, reply)
+ }
+
+ return node
+}
+
+// buildReplyNode builds a schema.org Comment for a reply. Returns the
+// zero value if pv or pv.Author is nil.
+func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) comment {
+ if pv == nil || pv.Author == nil {
+ return comment{}
+ }
+ embedHidden := postEmbedHidden(pv, hideLabels)
+ images := extractPostMedia(pv, embedHidden)
+ var thumb string
+ if len(images) > 0 {
+ thumb = images[0]
+ }
+ return comment{
+ Type: "Comment",
+ URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
+ Identifier: pv.Uri,
+ Author: buildAuthor(pv.Author),
+ Text: postRecordText(pv),
+ Image: images,
+ ThumbnailURL: thumb,
+ DatePublished: pv.IndexedAt,
+ }
+}
+
+// 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) {
+ if pv == nil || pv.Author == nil {
+ return "", fmt.Errorf("nil post view or author")
+ }
+ 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.
+ if node.URL == "" {
+ node.URL = canonicalURL
+ }
+
+ envelope := webPage{
+ Context: schemaOrgContext,
+ Type: "WebPage",
+ URL: canonicalURL,
+ MainEntity: node,
+ }
+ b, err := json.Marshal(envelope)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+// buildProfileJSONLD marshals a ProfilePage (with hasPart recent posts).
+// Recent posts whose own labels match hideLabels or hideReplyLabels are
+// dropped from hasPart, mirroring the gating applied to comment[].
+func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts []*appbsky.FeedDefs_PostView, hideLabels, hideReplyLabels map[string]bool) (string, error) {
+ if pv == nil {
+ return "", fmt.Errorf("nil profile view")
+ }
+
+ person := &personOrOrg{
+ Type: "Person",
+ Identifier: pv.Did,
+ }
+ if pv.DisplayName != nil && *pv.DisplayName != "" {
+ person.Name = *pv.DisplayName
+ person.AlternateName = "@" + pv.Handle
+ } else {
+ person.Name = "@" + pv.Handle
+ }
+ if pv.Description != nil {
+ person.Description = *pv.Description
+ }
+ if pv.Avatar != nil {
+ person.Image = *pv.Avatar
+ }
+
+ deref := func(p *int64) int64 {
+ if p == nil {
+ return 0
+ }
+ return *p
+ }
+ person.InteractionStat = []interactionStat{
+ {Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowersCount)},
+ }
+ person.AgentInteractionStat = []interactionStat{
+ {Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowsCount)},
+ {Type: "InteractionCounter", InteractionType: "https://schema.org/WriteAction", UserInteractionCount: deref(pv.PostsCount)},
+ }
+ if rb := buildReviewedBy(pv.Verification); len(rb) > 0 {
+ person.ReviewedBy = rb
+ }
+
+ page := profilePage{
+ Context: schemaOrgContext,
+ Type: "ProfilePage",
+ MainEntity: person,
+ }
+ if pv.CreatedAt != nil {
+ page.DateCreated = *pv.CreatedAt
+ }
+
+ for _, rp := range recentPosts {
+ if rp == nil {
+ continue
+ }
+ if len(page.HasPart) >= maxRecentPosts {
+ break
+ }
+ // Drop labeled posts entirely so flagged content isn't surfaced
+ // into the profile's structured data.
+ if postHasHideLabel(rp, hideReplyLabels) || postHasHideLabel(rp, hideLabels) {
+ continue
+ }
+ // 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
+ }
+ page.HasPart = append(page.HasPart, node)
+ }
+
+ b, err := json.Marshal(page)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go
new file mode 100644
index 0000000000..f14cdb6f78
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/jsonld_test.go
@@ -0,0 +1,1051 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+
+ comatprototypes "github.com/bluesky-social/indigo/api/atproto"
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// Pointer helpers for optional appbsky fields.
+func strPtr(s string) *string { return &s }
+func intPtr(i int64) *int64 { return &i }
+
+// newProfileViewDetailed returns a populated profile for tests.
+func newProfileViewDetailed() *appbsky.ActorDefs_ProfileViewDetailed {
+ return &appbsky.ActorDefs_ProfileViewDetailed{
+ Did: "did:plc:alice",
+ Handle: "alice.bsky.social",
+ DisplayName: strPtr("Alice"),
+ Description: strPtr("just a person"),
+ Avatar: strPtr("https://cdn.bsky.app/img/avatar/plain/a@jpeg"),
+ FollowersCount: intPtr(100),
+ FollowsCount: intPtr(50),
+ PostsCount: intPtr(200),
+ CreatedAt: strPtr("2023-01-01T00:00:00Z"),
+ }
+}
+
+// makePostView builds a minimal valid post view for tests.
+func makePostView(handle, did, rkey, text string, opts ...func(*appbsky.FeedDefs_PostView)) *appbsky.FeedDefs_PostView {
+ uri := "at://" + did + "/app.bsky.feed.post/" + rkey
+ pv := &appbsky.FeedDefs_PostView{
+ Uri: uri,
+ Cid: "bafy-test",
+ IndexedAt: "2024-01-02T03:04:05Z",
+ LikeCount: intPtr(7),
+ ReplyCount: intPtr(3),
+ RepostCount: intPtr(2),
+ QuoteCount: intPtr(1),
+ Author: &appbsky.ActorDefs_ProfileViewBasic{
+ Did: did,
+ Handle: handle,
+ DisplayName: strPtr("Test User"),
+ },
+ Record: &lexutil.LexiconTypeDecoder{
+ Val: &appbsky.FeedPost{
+ Text: text,
+ CreatedAt: "2024-01-02T03:04:05Z",
+ },
+ },
+ }
+ for _, opt := range opts {
+ opt(pv)
+ }
+ return pv
+}
+
+// withImages adds a simple images embed.
+func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ var images []*appbsky.EmbedImages_ViewImage
+ for _, t := range thumbs {
+ images = append(images, &appbsky.EmbedImages_ViewImage{Thumb: t, Fullsize: t + "_full"})
+ }
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedImages_View: &appbsky.EmbedImages_View{Images: images},
+ }
+ }
+}
+
+// withVideo adds a video embed with a thumbnail.
+func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedVideo_View: &appbsky.EmbedVideo_View{Thumbnail: strPtr(thumb)},
+ }
+ }
+}
+
+// withExternalEmbed adds an external link embed.
+func withExternalEmbed(uri, title string) func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedExternal_View: &appbsky.EmbedExternal_View{
+ External: &appbsky.EmbedExternal_ViewExternal{Uri: uri, Title: title, Description: "desc"},
+ },
+ }
+ }
+}
+
+// withQuotePost adds a record (quote-post) embed of the given target post.
+func withQuotePost(qHandle, qDid, qRkey string) func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedRecord_View: &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",
+ },
+ },
+ },
+ }
+ }
+}
+
+// withQuotePostBlocked adds a blocked-record embed (should NOT produce isBasedOn).
+func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ pv.Embed = &appbsky.FeedDefs_PostView_Embed{
+ EmbedRecord_View: &appbsky.EmbedRecord_View{
+ Record: &appbsky.EmbedRecord_View_Record{
+ EmbedRecord_ViewBlocked: &appbsky.EmbedRecord_ViewBlocked{
+ Uri: "at://did:plc:blocked/app.bsky.feed.post/x",
+ Blocked: true,
+ },
+ },
+ },
+ }
+ }
+}
+
+// withSelfLabel adds a self-label that should hide embeds.
+func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ rec, _ := pv.Record.Val.(*appbsky.FeedPost)
+ rec.Labels = &appbsky.FeedPost_Labels{
+ LabelDefs_SelfLabels: &comatprototypes.LabelDefs_SelfLabels{
+ Values: []*comatprototypes.LabelDefs_SelfLabel{{Val: val}},
+ },
+ }
+ }
+}
+
+// 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)
+ }
+}
+
+// verifierSpec is a compact specifier for a verification entry in fixtures.
+type verifierSpec struct {
+ issuer, handle, displayName string
+ isValid bool
+}
+
+// makeVerificationState builds a VerificationState from the supplied specs.
+func makeVerificationState(specs ...verifierSpec) *appbsky.ActorDefs_VerificationState {
+ state := &appbsky.ActorDefs_VerificationState{
+ VerifiedStatus: "valid",
+ TrustedVerifierStatus: "none",
+ }
+ for _, s := range specs {
+ v := &appbsky.ActorDefs_VerificationView{
+ Issuer: s.issuer,
+ IsValid: s.isValid,
+ CreatedAt: "2024-01-01T00:00:00Z",
+ Uri: "at://" + s.issuer + "/app.bsky.graph.verification/" + s.issuer,
+ }
+ if s.handle != "" {
+ h := s.handle
+ v.IssuerHandle = &h
+ }
+ if s.displayName != "" {
+ d := s.displayName
+ v.IssuerDisplayName = &d
+ }
+ state.Verifications = append(state.Verifications, v)
+ }
+ return state
+}
+
+// withVerifications sets pv.Author.Verification.
+func withVerifications(state *appbsky.ActorDefs_VerificationState) func(*appbsky.FeedDefs_PostView) {
+ return func(pv *appbsky.FeedDefs_PostView) {
+ if pv.Author == nil {
+ return
+ }
+ pv.Author.Verification = state
+ }
+}
+
+// unmarshalLD parses the JSON-LD blob produced by buildPostJSONLD.
+func unmarshalLD(t *testing.T, s string) map[string]any {
+ t.Helper()
+ var out map[string]any
+ if err := json.Unmarshal([]byte(s), &out); err != nil {
+ t.Fatalf("invalid JSON-LD: %v\n%s", err, s)
+ }
+ return out
+}
+
+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)
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope := unmarshalLD(t, out)
+ if envelope["@context"] != "https://schema.org" {
+ t.Errorf("@context wrong: %v", envelope["@context"])
+ }
+ if envelope["@type"] != "WebPage" {
+ t.Errorf("@type should be WebPage, got %v", envelope["@type"])
+ }
+ if envelope["url"] != canonical {
+ t.Errorf("url should be canonical, got %v", envelope["url"])
+ }
+ main, ok := envelope["mainEntity"].(map[string]any)
+ if !ok {
+ t.Fatalf("mainEntity missing")
+ }
+ if main["@type"] != "DiscussionForumPosting" {
+ t.Errorf("mainEntity @type wrong: %v", main["@type"])
+ }
+ // nested entity should NOT have @context (Google's preferred shape).
+ if _, present := main["@context"]; present {
+ t.Errorf("nested mainEntity should not have @context")
+ }
+ if main["url"] != canonical {
+ t.Errorf("post url wrong: %v", main["url"])
+ }
+ if main["identifier"] != pv.Uri {
+ t.Errorf("identifier should be at-uri: %v", main["identifier"])
+ }
+ if main["text"] != "hello" {
+ t.Errorf("text wrong: %v", main["text"])
+ }
+ if main["datePublished"] != "2024-01-02T03:04:05Z" {
+ t.Errorf("datePublished wrong: %v", main["datePublished"])
+ }
+ // commentCount should always be emitted, even at zero.
+ cc, ok := main["commentCount"].(float64)
+ 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")
+ }
+ if _, present := main["thumbnailUrl"]; present {
+ t.Errorf("bare post should not have thumbnailUrl")
+ }
+ if _, present := main["isBasedOn"]; present {
+ t.Errorf("bare post should not have isBasedOn")
+ }
+ if _, present := main["sharedContent"]; present {
+ t.Errorf("bare post should not have sharedContent")
+ }
+}
+
+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)
+ 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) != 2 {
+ t.Errorf("expected 2 images, got %d", len(imgs))
+ }
+ if imgs[0] != thumb1 {
+ t.Errorf("image[0] should equal first thumb, got %v", imgs[0])
+ }
+ if main["thumbnailUrl"] != thumb1 {
+ t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
+ }
+}
+
+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)
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ if main["thumbnailUrl"] != thumb {
+ t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
+ }
+ imgs := main["image"].([]any)
+ if len(imgs) != 1 || imgs[0] != thumb {
+ t.Errorf("image[0] should be video thumb, got %v", imgs)
+ }
+}
+
+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)
+ 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"])
+ }
+}
+
+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)
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ if _, present := main["isBasedOn"]; present {
+ t.Errorf("blocked quote should not produce isBasedOn")
+ }
+}
+
+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)
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ sc, ok := main["sharedContent"].(map[string]any)
+ if !ok {
+ t.Fatalf("sharedContent missing")
+ }
+ if sc["@type"] != "WebPage" {
+ t.Errorf("sharedContent type wrong: %v", sc["@type"])
+ }
+ if sc["url"] != "https://www.spiegel.de/article" {
+ t.Errorf("sharedContent url wrong: %v", sc["url"])
+ }
+}
+
+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)
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ if _, present := main["image"]; present {
+ t.Errorf("hidden-embed post should not emit image")
+ }
+ if _, present := main["thumbnailUrl"]; present {
+ t.Errorf("hidden-embed post should not emit thumbnailUrl")
+ }
+}
+
+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)
+ if err != nil {
+ t.Fatal(err)
+ }
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ if main["text"] != tricky {
+ t.Errorf("text round-trip failed: got %q want %q", main["text"], tricky)
+ }
+ // Literal would break out of the script tag.
+ if strings.Contains(out, "") {
+ t.Errorf("output contains literal , would break HTML embedding")
+ }
+}
+
+func TestBuildPostJSONLD_Comments(t *testing.T) {
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
+ *pv.ReplyCount = 14
+
+ // 12 valid replies + 1 not-found + 1 blocked. Cap is maxComments=10.
+ const validReplies = 12
+ var replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem
+ for i := 0; i < validReplies; i++ {
+ rkey := fmt.Sprintf("reply%02d", i)
+ reply := makePostView(fmt.Sprintf("rep%02d.bsky.social", i),
+ fmt.Sprintf("did:plc:rep%02d", i), rkey, "reply text")
+ replies = append(replies, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
+ FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply},
+ })
+ }
+ replies = append(replies, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
+ FeedDefs_NotFoundPost: &appbsky.FeedDefs_NotFoundPost{Uri: "at://x/y/z"},
+ })
+ replies = append(replies, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
+ FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
+ })
+
+ out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+
+ if cc := main["commentCount"].(float64); int64(cc) != 14 {
+ t.Errorf("commentCount should reflect ReplyCount, got %v", cc)
+ }
+ comments, ok := main["comment"].([]any)
+ if !ok {
+ t.Fatalf("comment array missing")
+ }
+ if len(comments) != maxComments {
+ t.Errorf("expected %d comments (capped from %d valid), got %d", maxComments, validReplies, len(comments))
+ }
+ // FIFO order: first valid reply, not the not-found at the end.
+ first := comments[0].(map[string]any)
+ if first["identifier"] != "at://did:plc:rep00/app.bsky.feed.post/reply00" {
+ t.Errorf("first comment should be reply00, got %v", first["identifier"])
+ }
+ // Comment type, no nested comment[] / isBasedOn / sharedContent.
+ for i, c := range comments {
+ cm := c.(map[string]any)
+ if cm["@type"] != "Comment" {
+ t.Errorf("reply %d wrong type: got %v, want Comment", i, cm["@type"])
+ }
+ if _, present := cm["comment"]; present {
+ t.Errorf("reply %d should not have nested comment[]", i)
+ }
+ if _, present := cm["isBasedOn"]; present {
+ t.Errorf("reply %d should not have isBasedOn", i)
+ }
+ if _, present := cm["sharedContent"]; present {
+ t.Errorf("reply %d should not have sharedContent", i)
+ }
+ if cm["url"] == nil || cm["identifier"] == nil {
+ t.Errorf("reply %d missing url/identifier", i)
+ }
+ }
+}
+
+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)
+ envelope := unmarshalLD(t, out)
+ main := envelope["mainEntity"].(map[string]any)
+ // mainEntity.url falls back to the caller's canonical URL so envelope
+ // and post URLs always agree.
+ if main["url"] != fallback {
+ t.Errorf("mainEntity.url should fall back to canonical URL, got %v", main["url"])
+ }
+ if envelope["url"] != fallback {
+ t.Errorf("envelope.url should equal canonical URL, got %v", envelope["url"])
+ }
+ if main["url"] != envelope["url"] {
+ t.Errorf("envelope.url and mainEntity.url disagree: %v vs %v",
+ envelope["url"], main["url"])
+ }
+ // identifier (AT-URI) stays stable across handle changes.
+ if main["identifier"] != pv.Uri {
+ t.Errorf("identifier should still be the AT-URI, got %v", main["identifier"])
+ }
+ // Author URL omitted (no usable handle).
+ author := main["author"].(map[string]any)
+ if _, present := author["url"]; present {
+ t.Errorf("handle.invalid author should not produce author.url")
+ }
+}
+
+// envelope.url and mainEntity.url must always agree.
+func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
+ cases := []struct {
+ name, handle, did, rkey, canonical string
+ }{
+ {
+ name: "handle form",
+ handle: "alice.bsky.social",
+ did: "did:plc:alice",
+ rkey: "abc",
+ canonical: "https://bsky.app/profile/alice.bsky.social/post/abc",
+ },
+ {
+ name: "handle.invalid falls back to canonical",
+ handle: "handle.invalid",
+ did: "did:plc:alice",
+ rkey: "abc",
+ canonical: "https://bsky.app/profile/did:plc:alice/post/abc",
+ },
+ }
+ 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)
+ env := unmarshalLD(t, out)
+ main := env["mainEntity"].(map[string]any)
+ if env["url"] != tc.canonical {
+ t.Errorf("envelope.url = %v, want %v", env["url"], tc.canonical)
+ }
+ if main["url"] != tc.canonical {
+ t.Errorf("mainEntity.url = %v, want %v", main["url"], tc.canonical)
+ }
+ })
+ }
+}
+
+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 {
+ t.Errorf("expected error for nil-author post, got nil")
+ }
+}
+
+func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
+ // Reply with nil Author should be dropped, not emitted with empty @type.
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
+ *pv.ReplyCount = 2
+
+ goodReply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good")
+ badReply := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "bad")
+ badReply.Author = nil
+
+ replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
+ {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
+ {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
+ }
+ out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ comments, _ := main["comment"].([]any)
+ if len(comments) != 1 {
+ t.Errorf("expected 1 comment (nil-Author dropped), got %d", len(comments))
+ }
+}
+
+func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
+ // Comments with media embeds should expose image[] and thumbnailUrl.
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
+ *pv.ReplyCount = 1
+ thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:bob/x@jpg"
+ reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "with image",
+ withImages(thumb))
+ replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
+ {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
+ }
+ 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)
+ if !ok || len(imgs) != 1 || imgs[0] != thumb {
+ t.Errorf("comment image[] wrong: %v", c["image"])
+ }
+ if c["thumbnailUrl"] != thumb {
+ t.Errorf("comment thumbnailUrl wrong: %v", c["thumbnailUrl"])
+ }
+}
+
+func TestBuildProfileJSONLD_Basic(t *testing.T) {
+ pv := &appbsky.ActorDefs_ProfileViewDetailed{
+ Did: "did:plc:alice",
+ Handle: "alice.bsky.social",
+ DisplayName: strPtr("Alice"),
+ Description: strPtr("just a person"),
+ Avatar: strPtr("https://cdn.bsky.app/img/avatar/plain/a@jpeg"),
+ FollowersCount: intPtr(100),
+ FollowsCount: intPtr(50),
+ PostsCount: intPtr(200),
+ CreatedAt: strPtr("2023-01-01T00:00:00Z"),
+ }
+ out, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
+ if err != nil {
+ t.Fatal(err)
+ }
+ page := unmarshalLD(t, out)
+ if page["@context"] != "https://schema.org" {
+ t.Errorf("@context wrong")
+ }
+ if page["@type"] != "ProfilePage" {
+ t.Errorf("@type wrong: %v", page["@type"])
+ }
+ main := page["mainEntity"].(map[string]any)
+ if main["@type"] != "Person" {
+ t.Errorf("mainEntity @type wrong: %v", main["@type"])
+ }
+ if main["name"] != "Alice" {
+ t.Errorf("name wrong: %v", main["name"])
+ }
+ if main["alternateName"] != "@alice.bsky.social" {
+ t.Errorf("alternateName wrong: %v", main["alternateName"])
+ }
+ if main["identifier"] != "did:plc:alice" {
+ t.Errorf("identifier wrong: %v", main["identifier"])
+ }
+ if _, present := page["hasPart"]; present {
+ t.Errorf("empty recentPosts should not produce hasPart")
+ }
+}
+
+func TestBuildProfileJSONLD_HasPart(t *testing.T) {
+ pv := &appbsky.ActorDefs_ProfileViewDetailed{
+ Did: "did:plc:alice", Handle: "alice.bsky.social",
+ DisplayName: strPtr("Alice"),
+ CreatedAt: strPtr("2023-01-01T00:00:00Z"),
+ }
+ var posts []*appbsky.FeedDefs_PostView
+ for i := 0; i < 12; i++ {
+ posts = append(posts, makePostView("alice.bsky.social", "did:plc:alice",
+ "r"+string(rune('0'+i)), "post"))
+ }
+ out, _ := buildProfileJSONLD(pv, posts, hideEmbedLabels, hideReplyLabels)
+ page := unmarshalLD(t, out)
+ hp, ok := page["hasPart"].([]any)
+ if !ok {
+ t.Fatalf("hasPart missing")
+ }
+ if len(hp) != maxRecentPosts {
+ t.Errorf("hasPart should be capped at %d, got %d", maxRecentPosts, len(hp))
+ }
+ first := hp[0].(map[string]any)
+ if first["@type"] != "DiscussionForumPosting" {
+ t.Errorf("hasPart entries should be DiscussionForumPosting, got %v", first["@type"])
+ }
+ if _, present := first["@context"]; present {
+ t.Errorf("nested hasPart entries should not have @context")
+ }
+}
+
+func TestBuildProfileJSONLD_HasPartLabelFiltered(t *testing.T) {
+ // Recent posts carrying hideReplyLabels or hideEmbedLabels are dropped
+ // from hasPart entirely. Negation is honored on post-view labels.
+ pv := &appbsky.ActorDefs_ProfileViewDetailed{
+ Did: "did:plc:alice", Handle: "alice.bsky.social",
+ DisplayName: strPtr("Alice"),
+ CreatedAt: strPtr("2023-01-01T00:00:00Z"),
+ }
+ good := makePostView("alice.bsky.social", "did:plc:alice", "good", "ok")
+ hidden := makePostView("alice.bsky.social", "did:plc:alice", "hide", "hidden",
+ withPostLabel("!hide", false))
+ spam := makePostView("alice.bsky.social", "did:plc:alice", "spam", "spam",
+ withSelfLabel("spam"))
+ embedHide := makePostView("alice.bsky.social", "did:plc:alice", "harm", "embed-only",
+ withPostLabel("self-harm", false))
+ negated := makePostView("alice.bsky.social", "did:plc:alice", "neg", "negated",
+ withPostLabel("!hide", true))
+
+ out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{
+ good, hidden, spam, embedHide, negated,
+ }, hideEmbedLabels, hideReplyLabels)
+ page := unmarshalLD(t, out)
+ hp, _ := page["hasPart"].([]any)
+
+ got := make(map[string]bool, len(hp))
+ for _, e := range hp {
+ got[e.(map[string]any)["identifier"].(string)] = true
+ }
+ if !got[good.Uri] {
+ t.Errorf("expected unlabeled post in hasPart")
+ }
+ if !got[negated.Uri] {
+ t.Errorf("negated hide label should not gate; expected post in hasPart")
+ }
+ if got[hidden.Uri] {
+ t.Errorf("post with !hide label should be dropped from hasPart")
+ }
+ if got[spam.Uri] {
+ t.Errorf("self-labeled spam post should be dropped from hasPart")
+ }
+ if got[embedHide.Uri] {
+ t.Errorf("post with hideEmbedLabels label should be dropped from hasPart")
+ }
+}
+
+func TestBskyPostURL(t *testing.T) {
+ tests := []struct {
+ name, handle, rkey, want string
+ }{
+ {"valid", "alice.bsky.social", "abc", "https://bsky.app/profile/alice.bsky.social/post/abc"},
+ {"empty handle", "", "abc", ""},
+ {"handle.invalid", "handle.invalid", "abc", ""},
+ {"empty rkey", "alice.bsky.social", "", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := bskyPostURL(tt.handle, tt.rkey); got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBskyPostURLFromATURI(t *testing.T) {
+ tests := []struct {
+ name, handle, atURI, want string
+ }{
+ {"valid", "alice.bsky.social", "at://did:plc:alice/app.bsky.feed.post/abc", "https://bsky.app/profile/alice.bsky.social/post/abc"},
+ {"empty handle", "", "at://did:plc:alice/app.bsky.feed.post/abc", ""},
+ {"handle.invalid", "handle.invalid", "at://did:plc:alice/app.bsky.feed.post/abc", ""},
+ {"bad uri", "alice.bsky.social", "not-an-aturi", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := bskyPostURLFromATURI(tt.handle, tt.atURI); got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBskyProfileURL(t *testing.T) {
+ if bskyProfileURL("alice.bsky.social") != "https://bsky.app/profile/alice.bsky.social" {
+ t.Errorf("valid handle wrong")
+ }
+ if bskyProfileURL("handle.invalid") != "" {
+ t.Errorf("handle.invalid should produce empty")
+ }
+ if bskyProfileURL("") != "" {
+ 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"])
+ }
+}
+
+func TestBuildReviewedBy_NilAndEmpty(t *testing.T) {
+ if got := buildReviewedBy(nil); got != nil {
+ t.Errorf("nil state should yield nil, got %v", got)
+ }
+ empty := &appbsky.ActorDefs_VerificationState{}
+ if got := buildReviewedBy(empty); got != nil {
+ t.Errorf("empty Verifications should yield nil, got %v", got)
+ }
+ allInvalid := makeVerificationState(
+ verifierSpec{issuer: "did:plc:v1", handle: "v1.example.com", displayName: "V One", isValid: false},
+ verifierSpec{issuer: "did:plc:v2", handle: "v2.example.com", displayName: "V Two", isValid: false},
+ )
+ if got := buildReviewedBy(allInvalid); got != nil {
+ t.Errorf("all-invalid state should yield nil, got %v", got)
+ }
+}
+
+func TestBuildReviewedBy_FiltersInvalid(t *testing.T) {
+ state := makeVerificationState(
+ verifierSpec{issuer: "did:plc:v1", handle: "v1.example.com", displayName: "V One", isValid: true},
+ verifierSpec{issuer: "did:plc:v2", handle: "v2.example.com", displayName: "V Two", isValid: false},
+ verifierSpec{issuer: "did:plc:v3", handle: "v3.example.com", displayName: "V Three", isValid: true},
+ verifierSpec{issuer: "", handle: "noid.example.com", displayName: "No Issuer", isValid: true},
+ )
+ got := buildReviewedBy(state)
+ if len(got) != 2 {
+ t.Fatalf("expected 2 valid verifiers, got %d: %v", len(got), got)
+ }
+ if got[0].Identifier != "did:plc:v1" {
+ t.Errorf("FIFO order broken; first identifier = %q", got[0].Identifier)
+ }
+ if got[1].Identifier != "did:plc:v3" {
+ t.Errorf("expected v3 at index 1, got %q", got[1].Identifier)
+ }
+}
+
+func TestBuildReviewedBy_NameFallbacks(t *testing.T) {
+ cases := []struct {
+ name string
+ spec verifierSpec
+ wantName, wantAlternateName, wantURL, wantIdentif string
+ }{
+ {
+ name: "DisplayName + handle",
+ spec: verifierSpec{issuer: "did:plc:v1", handle: "alice.example.com", displayName: "Alice Verifier", isValid: true},
+ wantName: "Alice Verifier",
+ wantAlternateName: "@alice.example.com",
+ wantURL: "https://bsky.app/profile/alice.example.com",
+ wantIdentif: "did:plc:v1",
+ },
+ {
+ name: "handle only",
+ spec: verifierSpec{issuer: "did:plc:v2", handle: "bob.example.com", isValid: true},
+ wantName: "@bob.example.com",
+ wantAlternateName: "",
+ wantURL: "https://bsky.app/profile/bob.example.com",
+ wantIdentif: "did:plc:v2",
+ },
+ {
+ name: "DisplayName only (no handle)",
+ spec: verifierSpec{issuer: "did:plc:v3", displayName: "Carol Verifier", isValid: true},
+ wantName: "Carol Verifier",
+ wantAlternateName: "",
+ wantURL: "https://bsky.app/profile/did:plc:v3",
+ wantIdentif: "did:plc:v3",
+ },
+ {
+ name: "DisplayName + handle.invalid",
+ spec: verifierSpec{issuer: "did:plc:v4", handle: "handle.invalid", displayName: "Dave Verifier", isValid: true},
+ wantName: "Dave Verifier",
+ wantAlternateName: "",
+ wantURL: "https://bsky.app/profile/did:plc:v4",
+ wantIdentif: "did:plc:v4",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := buildReviewedBy(makeVerificationState(tc.spec))
+ if len(got) != 1 {
+ t.Fatalf("expected 1 entry, got %d", len(got))
+ }
+ v := got[0]
+ if v.Type != "Person" {
+ t.Errorf("@type = %q, want Person", v.Type)
+ }
+ if v.Name != tc.wantName {
+ t.Errorf("name = %q, want %q", v.Name, tc.wantName)
+ }
+ if v.AlternateName != tc.wantAlternateName {
+ t.Errorf("alternateName = %q, want %q", v.AlternateName, tc.wantAlternateName)
+ }
+ if v.URL != tc.wantURL {
+ t.Errorf("url = %q, want %q", v.URL, tc.wantURL)
+ }
+ if v.Identifier != tc.wantIdentif {
+ t.Errorf("identifier = %q, want %q", v.Identifier, tc.wantIdentif)
+ }
+ })
+ }
+}
+
+func TestBuildReviewedBy_Cap(t *testing.T) {
+ specs := make([]verifierSpec, 0, 12)
+ for i := 0; i < 12; i++ {
+ specs = append(specs, verifierSpec{
+ issuer: fmt.Sprintf("did:plc:v%02d", i),
+ handle: fmt.Sprintf("v%02d.example.com", i),
+ displayName: fmt.Sprintf("V%02d", i),
+ isValid: true,
+ })
+ }
+ got := buildReviewedBy(makeVerificationState(specs...))
+ if len(got) != maxReviewedBy {
+ t.Errorf("expected cap at %d, got %d", maxReviewedBy, len(got))
+ }
+ if got[0].Identifier != "did:plc:v00" {
+ t.Errorf("first kept entry should be v00, got %q", got[0].Identifier)
+ }
+}
+
+func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
+ state := makeVerificationState(verifierSpec{
+ issuer: "did:plc:verifier1",
+ handle: "verifier.example.com",
+ displayName: "Trusted Verifier",
+ isValid: true,
+ })
+ pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
+ withVerifications(state))
+ out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
+ if err != nil {
+ t.Fatal(err)
+ }
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ auth := main["author"].(map[string]any)
+ rb, ok := auth["reviewedBy"].([]any)
+ if !ok {
+ t.Fatalf("post author should have reviewedBy, got %v", auth["reviewedBy"])
+ }
+ if len(rb) != 1 {
+ t.Fatalf("expected 1 verifier, got %d", len(rb))
+ }
+ v := rb[0].(map[string]any)
+ if v["@type"] != "Person" {
+ t.Errorf("verifier @type = %v, want Person", v["@type"])
+ }
+ if v["name"] != "Trusted Verifier" {
+ t.Errorf("verifier name = %v", v["name"])
+ }
+ if v["identifier"] != "did:plc:verifier1" {
+ t.Errorf("verifier identifier = %v", v["identifier"])
+ }
+ if v["url"] != "https://bsky.app/profile/verifier.example.com" {
+ t.Errorf("verifier url = %v", v["url"])
+ }
+}
+
+func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
+ // Replies do not surface verifications even when the reply author
+ // carries Verification.
+ state := makeVerificationState(verifierSpec{
+ issuer: "did:plc:verifier1",
+ handle: "verifier.example.com",
+ displayName: "Trusted Verifier",
+ isValid: true,
+ })
+ 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)
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ c := main["comment"].([]any)[0].(map[string]any)
+ auth := c["author"].(map[string]any)
+ if _, present := auth["reviewedBy"]; present {
+ t.Errorf("reply author must not carry reviewedBy, got %v", auth["reviewedBy"])
+ }
+}
+
+func TestBuildProfileJSONLD_MainEntityReviewedBy(t *testing.T) {
+ pv := newProfileViewDetailed()
+ pv.Verification = makeVerificationState(verifierSpec{
+ issuer: "did:plc:verifier1",
+ handle: "verifier.example.com",
+ displayName: "Trusted Verifier",
+ isValid: true,
+ })
+ out, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
+ if err != nil {
+ t.Fatal(err)
+ }
+ main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
+ rb, ok := main["reviewedBy"].([]any)
+ if !ok {
+ t.Fatalf("profile mainEntity should have reviewedBy, got %v", main["reviewedBy"])
+ }
+ v := rb[0].(map[string]any)
+ if v["identifier"] != "did:plc:verifier1" {
+ t.Errorf("verifier identifier = %v", v["identifier"])
+ }
+ if v["url"] != "https://bsky.app/profile/verifier.example.com" {
+ t.Errorf("verifier url = %v", v["url"])
+ }
+}
+
+func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) {
+ // Recent posts inherit verifications via their post-view Author.
+ pv := newProfileViewDetailed()
+ state := makeVerificationState(verifierSpec{
+ issuer: "did:plc:verifier1",
+ handle: "verifier.example.com",
+ displayName: "Trusted Verifier",
+ isValid: true,
+ })
+ post := makePostView("alice.bsky.social", "did:plc:alice", "rp1", "hi",
+ withVerifications(state))
+ out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{post}, hideEmbedLabels, hideReplyLabels)
+ page := unmarshalLD(t, out)
+ hp := page["hasPart"].([]any)
+ if len(hp) != 1 {
+ t.Fatalf("expected 1 hasPart entry, got %d", len(hp))
+ }
+ auth := hp[0].(map[string]any)["author"].(map[string]any)
+ rb, ok := auth["reviewedBy"].([]any)
+ if !ok || len(rb) != 1 {
+ t.Fatalf("hasPart author should carry reviewedBy, got %v", auth["reviewedBy"])
+ }
+ if rb[0].(map[string]any)["identifier"] != "did:plc:verifier1" {
+ t.Errorf("verifier identifier wrong: %v", rb[0])
+ }
+}
diff --git a/bskyweb/cmd/bskyweb/labels.go b/bskyweb/cmd/bskyweb/labels.go
new file mode 100644
index 0000000000..4055d4b9c8
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/labels.go
@@ -0,0 +1,22 @@
+package main
+
+import (
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+)
+
+// Helpers for inspecting profile / post labels.
+
+// profileRequiresAuth reports whether the profile has self-applied the
+// !no-unauthenticated label. SSR responses for these profiles omit post
+// content beyond minimal identity.
+func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
+ if pv == nil {
+ return false
+ }
+ for _, label := range pv.Labels {
+ if label.Src == pv.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
new file mode 100644
index 0000000000..d55e31b207
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/labels_test.go
@@ -0,0 +1,79 @@
+package main
+
+import (
+ "testing"
+
+ comatprototypes "github.com/bluesky-social/indigo/api/atproto"
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+)
+
+func TestProfileRequiresAuth(t *testing.T) {
+ negTrue := true
+
+ tests := []struct {
+ name string
+ pv *appbsky.ActorDefs_ProfileViewDetailed
+ want bool
+ }{
+ {
+ name: "nil profile",
+ pv: nil,
+ want: false,
+ },
+ {
+ name: "no labels",
+ pv: &appbsky.ActorDefs_ProfileViewDetailed{Did: "did:plc:alice"},
+ want: false,
+ },
+ {
+ name: "self-applied !no-unauthenticated",
+ pv: &appbsky.ActorDefs_ProfileViewDetailed{
+ Did: "did:plc:alice",
+ Labels: []*comatprototypes.LabelDefs_Label{
+ {Src: "did:plc:alice", Val: "!no-unauthenticated"},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "label from a different src does not gate",
+ pv: &appbsky.ActorDefs_ProfileViewDetailed{
+ Did: "did:plc:alice",
+ Labels: []*comatprototypes.LabelDefs_Label{
+ {Src: "did:plc:labeler", Val: "!no-unauthenticated"},
+ },
+ },
+ want: false,
+ },
+ {
+ name: "different label value does not gate",
+ pv: &appbsky.ActorDefs_ProfileViewDetailed{
+ Did: "did:plc:alice",
+ Labels: []*comatprototypes.LabelDefs_Label{
+ {Src: "did:plc:alice", Val: "spam"},
+ },
+ },
+ want: false,
+ },
+ {
+ // Negation isn't honored — matches prior inline behavior in
+ // WebPost / WebProfile / WebProfileRSS.
+ name: "negated label still triggers (matches prior behavior)",
+ pv: &appbsky.ActorDefs_ProfileViewDetailed{
+ Did: "did:plc:alice",
+ Labels: []*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 := profileRequiresAuth(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
new file mode 100644
index 0000000000..f7c9edd677
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/render_test.go
@@ -0,0 +1,211 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/bluesky-social/social-app/bskyweb"
+ "github.com/flosch/pongo2/v6"
+)
+
+// renderTemplate executes a template using the live renderer and returns
+// the output. base.html includes templates/scripts.html, which is generated
+// by `yarn build-web`; skip the test if it's missing.
+func renderTemplate(t *testing.T, name string, ctx pongo2.Context) string {
+ t.Helper()
+ if _, err := bskyweb.TemplateFS.ReadFile("templates/scripts.html"); err != nil {
+ t.Skip("templates/scripts.html not present (run yarn build-web first)")
+ }
+ r := NewRenderer("templates/", &bskyweb.TemplateFS, false)
+ tmpl, err := r.TemplateSet.FromCache(name)
+ if err != nil {
+ t.Fatalf("template load %q: %v", name, err)
+ }
+ var buf bytes.Buffer
+ if err := tmpl.ExecuteWriter(ctx, &buf); err != nil {
+ t.Fatalf("template execute %q: %v", name, err)
+ }
+ return buf.String()
+}
+
+var jsonLDRe = regexp.MustCompile(`(?s)`)
+
+// extractJSONLD pulls out the body of the application/ld+json script tag.
+func extractJSONLD(t *testing.T, html string) string {
+ t.Helper()
+ m := jsonLDRe.FindStringSubmatch(html)
+ if m == nil {
+ t.Fatalf("no application/ld+json script tag in rendered HTML:\n%s", html)
+ }
+ return strings.TrimSpace(m[1])
+}
+
+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)
+ if err != nil {
+ t.Fatal(err)
+ }
+ 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,
+ })
+
+ body := extractJSONLD(t, html)
+ var parsed map[string]any
+ if err := json.Unmarshal([]byte(body), &parsed); err != nil {
+ t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
+ }
+ if parsed["@type"] != "WebPage" {
+ t.Errorf("expected WebPage envelope, got %v", parsed["@type"])
+ }
+ // Canonical link should be the handle-form URL.
+ if !strings.Contains(html, ``) {
+ t.Errorf("canonical link missing or wrong:\n%s", html)
+ }
+}
+
+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)
+ 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": []string{thumb1, thumb2},
+ })
+
+ // og:image and JSON-LD image[] must be byte-identical.
+ if !strings.Contains(html, ``) {
+ t.Errorf("og:image[0] not found in rendered HTML")
+ }
+ 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 imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
+ t.Errorf("JSON-LD image strings drifted from og:image; image[0]=%v thumbnailUrl=%v",
+ imgs[0], 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")
+ 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",
+ "postJSONLD": ld,
+ })
+ if !strings.Contains(html, ``) {
+ t.Errorf("expected canonicalize_url filter to strip query; got:\n%s", html)
+ }
+}
+
+func TestRenderProfile_EmitsJSONLD(t *testing.T) {
+ pv := newProfileViewDetailed()
+ ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
+ if err != nil {
+ t.Fatal(err)
+ }
+ html := renderTemplate(t, "profile.html", pongo2.Context{
+ "profileView": pv,
+ "requestURI": "https://bsky.app/profile/alice.bsky.social",
+ "canonicalURL": "https://bsky.app/profile/alice.bsky.social",
+ "profileJSONLD": ld,
+ })
+
+ body := extractJSONLD(t, html)
+ var parsed map[string]any
+ if err := json.Unmarshal([]byte(body), &parsed); err != nil {
+ t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
+ }
+ if parsed["@type"] != "ProfilePage" {
+ t.Errorf("expected ProfilePage, got %v", parsed["@type"])
+ }
+}
+
+// Regression: auth-required profiles must still emit ProfilePage JSON-LD
+// (without hasPart). Previously regressed when WebProfile short-circuited
+// before buildProfileJSONLD.
+func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
+ pv := newProfileViewDetailed()
+ ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels, hideReplyLabels)
+ if err != nil {
+ t.Fatal(err)
+ }
+ html := renderTemplate(t, "profile.html", pongo2.Context{
+ "profileView": pv,
+ "requestURI": "https://bsky.app/profile/alice.bsky.social",
+ "requiresAuth": true,
+ "profileJSONLD": ld,
+ })
+
+ body := extractJSONLD(t, html)
+ var parsed map[string]any
+ if err := json.Unmarshal([]byte(body), &parsed); err != nil {
+ t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
+ }
+ if parsed["@type"] != "ProfilePage" {
+ t.Errorf("expected ProfilePage, got %v", parsed["@type"])
+ }
+ if _, present := parsed["hasPart"]; present {
+ t.Errorf("auth-required profile should not have hasPart")
+ }
+}
+
+// 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)
+ canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
+ html := renderTemplate(t, "post.html", pongo2.Context{
+ "postView": pv,
+ "requestURI": "https://bsky.app/profile/did:plc:alice/post/abc123",
+ "canonicalURL": canonical,
+ "postJSONLD": ld,
+ })
+ if !strings.Contains(html, ``) {
+ t.Errorf("og:url should equal canonical URL when set; got:\n%s", html)
+ }
+ if !strings.Contains(html, ``) {
+ t.Errorf("canonical link missing or wrong:\n%s", html)
+ }
+ // DID-form request URI must not leak into og:url.
+ if strings.Contains(html, ``) {
+ 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/rss.go b/bskyweb/cmd/bskyweb/rss.go
index 368b911eb9..0b47ce548d 100644
--- a/bskyweb/cmd/bskyweb/rss.go
+++ b/bskyweb/cmd/bskyweb/rss.go
@@ -58,10 +58,8 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", handle))
}
- for _, label := range pv.Labels {
- if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
- return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", handle))
- }
+ if profileRequiresAuth(pv) {
+ return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", handle))
}
return c.Redirect(http.StatusFound, fmt.Sprintf("/profile/%s/rss", pv.Did))
}
@@ -76,10 +74,8 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", did))
}
- for _, label := range pv.Labels {
- if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
- return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", did))
- }
+ if profileRequiresAuth(pv) {
+ return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", did))
}
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, did.String(), "", "posts_no_replies", false, 30)
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index fcaef6d740..ec4740c205 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()
@@ -524,17 +538,22 @@ func (srv *Server) WebPost(c echo.Context) error {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "post.html", data)
}
- unauthedViewingOkay := true
- for _, label := range pv.Labels {
- if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
- unauthedViewingOkay = false
- }
- }
+ unauthedViewingOkay := !profileRequiresAuth(pv)
req := c.Request()
+ requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
+
+ // 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())
+
if !unauthedViewingOkay {
// Provide minimal OpenGraph data for auth-required posts
- data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
+ data["requestURI"] = requestURI
+ if canonicalURL != "" {
+ data["canonicalURL"] = canonicalURL
+ }
data["requiresAuth"] = true
data["profileHandle"] = pv.Handle
if pv.DisplayName != nil {
@@ -551,81 +570,44 @@ func (srv *Server) WebPost(c echo.Context) error {
return c.Render(http.StatusOK, "post.html", data)
}
- postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
+ 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"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
+ data["requestURI"] = requestURI
+ if canonicalURL != "" {
+ data["canonicalURL"] = canonicalURL
+ }
- // If any undesirable labels are set, the embed will not be included in
- // metadata
- isEmbedHidden := false
- for _, label := range postView.Labels {
- isNeg := label.Neg != nil && *label.Neg
- if hideEmbedLabels[label.Val] && !isNeg {
- isEmbedHidden = true
- break
+ // Share extraction helpers with jsonld.go so og:image and JSON-LD
+ // image[] are byte-identical (per Google's requirement).
+ isEmbedHidden := postEmbedHidden(postView, hideEmbedLabels)
+ data["postText"] = postRecordText(postView)
+
+ if thumbs := extractPostMedia(postView, isEmbedHidden); len(thumbs) > 0 {
+ data["imgThumbUrls"] = thumbs
+ }
+ if vm := extractVideoMeta(postView, isEmbedHidden); vm.URL != "" {
+ data["videoUrl"] = vm.URL
+ data["videoType"] = vm.Type
+ if vm.HasSize {
+ data["videoWidth"] = vm.Width
+ data["videoHeight"] = vm.Height
}
}
- if postView.Record != nil {
- postRecord, ok := postView.Record.Val.(*appbsky.FeedPost)
- if ok {
- data["postText"] = ExpandPostText(postRecord)
-
- if !isEmbedHidden && postRecord.Labels != nil && postRecord.Labels.LabelDefs_SelfLabels != nil {
- for _, label := range postRecord.Labels.LabelDefs_SelfLabels.Values {
- if hideEmbedLabels[label.Val] {
- isEmbedHidden = true
- break
- }
- }
- }
- }
+ // Build JSON-LD. Fall back to requestURI when handle is unusable so the
+ // envelope url is never empty.
+ jsonldURL := canonicalURL
+ if jsonldURL == "" {
+ jsonldURL = requestURI
}
-
- if postView.Embed != nil && !isEmbedHidden {
- hasImages := postView.Embed.EmbedImages_View != nil
- hasVideo := postView.Embed.EmbedVideo_View != nil
- hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
- hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
- hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
-
- if hasImages {
- var thumbUrls []string
- for i := range postView.Embed.EmbedImages_View.Images {
- thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
- }
- data["imgThumbUrls"] = thumbUrls
- } else if hasVideo {
- if postView.Embed.EmbedVideo_View.Thumbnail != nil {
- data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
- }
- if postView.Embed.EmbedVideo_View.Playlist != "" {
- data["videoUrl"] = postView.Embed.EmbedVideo_View.Playlist
- data["videoType"] = "application/vnd.apple.mpegurl"
- if postView.Embed.EmbedVideo_View.AspectRatio != nil {
- data["videoWidth"] = postView.Embed.EmbedVideo_View.AspectRatio.Width
- data["videoHeight"] = postView.Embed.EmbedVideo_View.AspectRatio.Height
- }
- }
- } else if hasMediaImages {
- var thumbUrls []string
- for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
- thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
- }
- data["imgThumbUrls"] = thumbUrls
- } else if hasMediaVideo {
- if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
- data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
- }
- if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist != "" {
- data["videoUrl"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist
- data["videoType"] = "application/vnd.apple.mpegurl"
- if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio != nil {
- data["videoWidth"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Width
- data["videoHeight"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Height
- }
- }
- }
+ 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)
}
return c.Render(http.StatusOK, "post.html", data)
@@ -687,22 +669,56 @@ func (srv *Server) WebProfile(c echo.Context) error {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "profile.html", data)
}
- unauthedViewingOkay := true
- for _, label := range pv.Labels {
- if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
- unauthedViewingOkay = false
- }
- }
+ unauthedViewingOkay := !profileRequiresAuth(pv)
req := c.Request()
data["profileView"] = pv
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
data["requestHost"] = req.Host
- if !unauthedViewingOkay {
+ // Prefer the handle-form URL so JSON-LD `url` and
+ // match. Template falls back to requestURI
+ // when the handle is unusable.
+ if url := bskyProfileURL(pv.Handle); url != "" {
+ data["canonicalURL"] = url
+ }
+
+ // Fetch recent posts for ProfilePage.hasPart. Skipped for auth-required
+ // profiles (posts aren't publicly indexable anyway). Failures degrade
+ // gracefully — the profile still renders without hasPart.
+ //
+ // NOTE: extra XRPC call on every public profile render; consider
+ // caching per-profile if upstream load becomes a concern.
+ var recentPosts []*appbsky.FeedDefs_PostView
+ if unauthedViewingOkay {
+ af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, pv.Did, "", "posts_no_replies", false, authorFeedFetchLimit)
+ if err != nil {
+ log.Warnf("failed to fetch author feed for: %s\t%v", pv.Did, err)
+ } else {
+ for _, p := range af.Feed {
+ if p == nil || p.Post == nil {
+ continue
+ }
+ // Only the author's own posts (matches RSS handler).
+ if p.Post.Author == nil || p.Post.Author.Did != pv.Did {
+ continue
+ }
+ recentPosts = append(recentPosts, p.Post)
+ if len(recentPosts) >= maxRecentPosts {
+ break
+ }
+ }
+ }
+ } else {
data["requiresAuth"] = true
}
+ if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
+ data["profileJSONLD"] = jsonld
+ } else {
+ log.Warnf("failed to build profile JSON-LD for %s: %v", pv.Did, err)
+ }
+
return c.Render(http.StatusOK, "profile.html", data)
}
@@ -730,12 +746,7 @@ func (srv *Server) WebFeed(c echo.Context) error {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "feed.html", data)
}
- unauthedViewingOkay := true
- for _, label := range pv.Labels {
- if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
- unauthedViewingOkay = false
- }
- }
+ unauthedViewingOkay := !profileRequiresAuth(pv)
if !unauthedViewingOkay {
return c.Render(http.StatusOK, "feed.html", data)
diff --git a/bskyweb/cmd/embedr/handlers.go b/bskyweb/cmd/embedr/handlers.go
index 15e57a4115..5fb4287b5a 100644
--- a/bskyweb/cmd/embedr/handlers.go
+++ b/bskyweb/cmd/embedr/handlers.go
@@ -95,7 +95,7 @@ func (srv *Server) parseBlueskyURL(ctx context.Context, raw string) (*syntax.ATU
}
var did syntax.DID
if atid.IsHandle() {
- ident, err := srv.dir.Lookup(ctx, *atid)
+ ident, err := srv.dir.Lookup(ctx, atid)
if err != nil {
return nil, err
}
diff --git a/bskyweb/go.mod b/bskyweb/go.mod
index 5274340c14..bec34c8871 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-20250729223159-573ae927246a
+ github.com/bluesky-social/indigo v0.0.0-20260528134852-3e368bd18814
github.com/flosch/pongo2/v6 v6.0.0
github.com/ipfs/go-log v1.0.5
github.com/joho/godotenv v1.5.1
@@ -18,11 +18,10 @@ require github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indir
require (
github.com/beorn7/perks v1.0.1 // indirect
- github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
- github.com/carlmjohnson/versioninfo v0.22.5 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
+ github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -32,7 +31,6 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
- github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/go-block-format v0.2.0 // indirect
@@ -82,7 +80,6 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
- github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
diff --git a/bskyweb/go.sum b/bskyweb/go.sum
index ffad3e8825..74a69f6e3a 100644
--- a/bskyweb/go.sum
+++ b/bskyweb/go.sum
@@ -2,12 +2,10 @@ 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-20250729223159-573ae927246a h1:S12KN45uIkRglMHC8PqD/Vsz0+u3KbIaBF/6rit8/Pg=
-github.com/bluesky-social/indigo v0.0.0-20250729223159-573ae927246a/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
-github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
-github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
-github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
-github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
+github.com/bluesky-social/indigo v0.0.0-20260527160159-3b7634c713b8 h1:heYuihJZQZMNij7+dKIEfApG10xrGnDV2rtKpvFyYYU=
+github.com/bluesky-social/indigo v0.0.0-20260527160159-3b7634c713b8/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
+github.com/bluesky-social/indigo v0.0.0-20260528134852-3e368bd18814 h1:UZK0qmB7LAe2IZY6RASlsE0Mm1Rjasddd2T5zWWvT54=
+github.com/bluesky-social/indigo v0.0.0-20260528134852-3e368bd18814/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=
@@ -19,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
+github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
+github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
@@ -48,8 +48,6 @@ github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
-github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg=
-github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
@@ -211,8 +209,6 @@ github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSD
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
-github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
-github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
diff --git a/bskyweb/templates/post.html b/bskyweb/templates/post.html
index 3e9fb59e13..72ac4664e7 100644
--- a/bskyweb/templates/post.html
+++ b/bskyweb/templates/post.html
@@ -15,8 +15,13 @@
{%- if requestURI %}
+ {% if canonicalURL %}
+
+
+ {% else %}
+ {% endif %}
{% endif -%}
{%- if postView.Author.DisplayName %}
@@ -34,6 +39,11 @@
{% endfor %}
+ {% else %}
+
+
+
+ {% endif %}
{%- if videoUrl %}
@@ -42,11 +52,6 @@
{% endif -%}
{% endif -%}
- {% else %}
-
-
-
- {% endif %}
{%- if postView.LikeCount %}
@@ -64,52 +69,20 @@
-
+ {%- if postJSONLD %}
+
+ {% endif -%}
{%- elif requiresAuth and profileHandle -%}
{%- if requestURI %}
+ {% if canonicalURL %}
+
+
+ {% else %}
+ {% endif %}
{% endif -%}
{%- if profileDisplayName %}
diff --git a/bskyweb/templates/profile.html b/bskyweb/templates/profile.html
index b4be429289..804ee7a92b 100644
--- a/bskyweb/templates/profile.html
+++ b/bskyweb/templates/profile.html
@@ -12,8 +12,13 @@
{%- if requestURI %}
+ {% if canonicalURL %}
+
+
+ {% else %}
+ {% endif %}
{% endif -%}
{%- if profileView -%}
@@ -52,44 +57,9 @@
{% endif %}
-
+ {%- if profileJSONLD %}
+
+ {% endif -%}
{% endif -%}
{%- endblock %}