From 925eaa9fc850a3c8636e1b496741b90238e958de Mon Sep 17 00:00:00 2001 From: Michael Black Date: Thu, 28 May 2026 11:26:25 -0500 Subject: [PATCH] Refactor and improve seo schema.org data (#10613) (cherry picked from commit 60917d1125a7020a8da222163c5c7fc78f0e032e) --- bskyweb/cmd/bskyweb/embedmeta.go | 48 ++ bskyweb/cmd/bskyweb/embedmeta_test.go | 69 ++ bskyweb/cmd/bskyweb/jsonld.go | 553 +++++++++++++ bskyweb/cmd/bskyweb/jsonld_test.go | 1051 +++++++++++++++++++++++++ bskyweb/cmd/bskyweb/labels.go | 22 + bskyweb/cmd/bskyweb/labels_test.go | 79 ++ bskyweb/cmd/bskyweb/render_test.go | 211 +++++ bskyweb/cmd/bskyweb/rss.go | 12 +- bskyweb/cmd/bskyweb/server.go | 189 ++--- bskyweb/cmd/embedr/handlers.go | 2 +- bskyweb/go.mod | 7 +- bskyweb/go.sum | 16 +- bskyweb/templates/post.html | 63 +- bskyweb/templates/profile.html | 46 +- 14 files changed, 2172 insertions(+), 196 deletions(-) create mode 100644 bskyweb/cmd/bskyweb/embedmeta.go create mode 100644 bskyweb/cmd/bskyweb/embedmeta_test.go create mode 100644 bskyweb/cmd/bskyweb/jsonld.go create mode 100644 bskyweb/cmd/bskyweb/jsonld_test.go create mode 100644 bskyweb/cmd/bskyweb/labels.go create mode 100644 bskyweb/cmd/bskyweb/labels_test.go create mode 100644 bskyweb/cmd/bskyweb/render_test.go 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 %}