Compare commits

...

12 Commits

Author SHA1 Message Date
Michael Black af28680e5d rename new verifier fields 2026-05-28 10:37:07 -05:00
Michael Black b217d9dd5d fix embedr invalid indirection err 2026-05-27 16:23:11 -05:00
Michael Black a99f8bf3d6 add reviewedBy field 2026-05-27 12:44:53 -05:00
Michael Black 6c0b93b527 filter hasPart recent posts on hide labels
Mirrors the gating applied to comment[]: recent posts whose own
labels match hideEmbedLabels or hideReplyLabels are dropped from
hasPart entirely so flagged content isn't surfaced into the
profile's structured data. Negated post-view labels are honored.
2026-05-27 11:18:05 -05:00
Michael Black b170cd45ba address PR review feedback
- gate reply inclusion in JSON-LD comment[] on hideReplyLabels and
  hideEmbedLabels so abusive/spam reply text isn't surfaced into the
  parent post's structured data
- add author identifier (DID) to post/comment author Person nodes so
  identity survives handle changes (mirrors profile Person)
- lift og:video block out of {% if imgThumbUrls %} so videos without
  thumbnails still emit og:video tags
2026-05-27 09:47:00 -05:00
Michael Black e9384aa8fe keep comments concise 2026-05-26 16:10:36 -05:00
Michael Black 20bbceeee9 fix discussionForumPosting.comment field type 2026-05-26 16:07:28 -05:00
Michael Black 0e250e7ddb post jsonld canonical url 2026-05-26 15:22:08 -05:00
Michael Black 6abbf3cd03 more reusable funcs 2026-05-26 11:34:00 -05:00
Michael Black c51164786b cleanup webpost 2026-05-26 11:15:36 -05:00
Michael Black 7f1976ae12 simplify extractPostMedia 2026-05-26 10:13:02 -05:00
Michael Black eddfb9f6cf refactor and improve seo schema.org data 2026-05-26 09:58:27 -05:00
14 changed files with 2172 additions and 196 deletions
+48
View File
@@ -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
}
+69
View File
@@ -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)
}
}
+553
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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
}
+79
View File
@@ -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)
}
})
}
}
+211
View File
@@ -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)<script type="application/ld\+json">(.*?)</script>`)
// 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, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
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, `<meta property="og:image" content="`+thumb1+`">`) {
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, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
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 <link rel="canonical"> 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, `<meta property="og:url" content="`+canonical+`">`) {
t.Errorf("og:url should equal canonical URL when set; got:\n%s", html)
}
if !strings.Contains(html, `<link rel="canonical" href="`+canonical+`" />`) {
t.Errorf("canonical link missing or wrong:\n%s", html)
}
// DID-form request URI must not leak into og:url.
if strings.Contains(html, `<meta property="og:url" content="https://bsky.app/profile/did:plc:alice/post/abc123">`) {
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, `<meta property="og:video" content="`+videoURL+`">`) {
t.Errorf("og:video should emit even without imgThumbUrls; got:\n%s", html)
}
if !strings.Contains(html, `<meta property="og:video:type" content="application/x-mpegURL">`) {
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
}
}
+4 -8
View File
@@ -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)
+100 -89
View File
@@ -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
// <link rel="canonical"> 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
// <link rel="canonical"> 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)
+1 -1
View File
@@ -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
}
+2 -5
View File
@@ -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
+6 -10
View File
@@ -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=
+18 -45
View File
@@ -15,8 +15,13 @@
<meta property="og:type" content="article">
<meta property="profile:username" content="{{ postView.Author.Handle }}">
{%- if requestURI %}
{% if canonicalURL %}
<meta property="og:url" content="{{ canonicalURL }}">
<link rel="canonical" href="{{ canonicalURL }}" />
{% else %}
<meta property="og:url" content="{{ requestURI }}">
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
{% endif %}
{% endif -%}
{%- if postView.Author.DisplayName %}
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
@@ -34,6 +39,11 @@
<meta property="twitter:image" content="{{ imgThumbUrl }}">
{% endfor %}
<meta name="twitter:card" content="summary_large_image">
{% else %}
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta name="twitter:card" content="summary">
{% endif %}
{%- if videoUrl %}
<meta property="og:video" content="{{ videoUrl }}">
<meta property="og:video:type" content="{{ videoType }}">
@@ -42,11 +52,6 @@
<meta property="og:video:height" content="{{ videoHeight }}">
{% endif -%}
{% endif -%}
{% else %}
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta name="twitter:card" content="summary">
{% endif %}
<meta name="twitter:label1" content="Posted At">
<meta name="twitter:value1" content="{{ postView.IndexedAt }}">
{%- if postView.LikeCount %}
@@ -64,52 +69,20 @@
<meta property="article:published_time" content="{{ postView.IndexedAt }}">
<link rel="alternate" type="application/json+oembed" href="https://embed.bsky.app/oembed?format=json&url={{ postView.Uri | urlencode }}" />
<link rel="alternate" href="{{ postView.Uri }}" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
"author": {
"@type": "Person",
{%- if postView.Author.DisplayName %}
"name": "{{ postView.Author.DisplayName }}",
"alternateName": "@{{ postView.Author.Handle }}",
{% else %}
"name": "@{{ postView.Author.Handle }}",
{% endif -%}
"url": "https://bsky.app/profile/{{ postView.Author.Handle }}"
},
{%- if postText %}
"text": "{{ postText }}",
{% endif %}
{%- if imageThumbUrls %}
"image": "{{ imageThumbUrls[0] }}",
{% endif %}
"datePublished": "{{ postView.IndexedAt }}",
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/LikeAction",
"userInteractionCount": {{ postView.LikeCount }}
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/CommentAction",
"userInteractionCount": {{ postView.ReplyCount }}
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/ShareAction",
"userInteractionCount": {{ postView.RepostCount + postView.QuoteCount }}
}
]
}
</script>
{%- if postJSONLD %}
<script type="application/ld+json">{{ postJSONLD|safe }}</script>
{% endif -%}
{%- elif requiresAuth and profileHandle -%}
<meta property="og:type" content="article">
<meta property="profile:username" content="{{ profileHandle }}">
{%- if requestURI %}
{% if canonicalURL %}
<meta property="og:url" content="{{ canonicalURL }}">
<link rel="canonical" href="{{ canonicalURL }}" />
{% else %}
<meta property="og:url" content="{{ requestURI }}">
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
{% endif %}
{% endif -%}
{%- if profileDisplayName %}
<meta property="og:title" content="{{ profileDisplayName }} (@{{ profileHandle }})">
+8 -38
View File
@@ -12,8 +12,13 @@
<meta property="og:site_name" content="Bluesky Social">
<meta property="og:type" content="profile">
{%- if requestURI %}
{% if canonicalURL %}
<meta property="og:url" content="{{ canonicalURL }}">
<link rel="canonical" href="{{ canonicalURL }}" />
{% else %}
<meta property="og:url" content="{{ requestURI }}">
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
{% endif %}
{% endif -%}
{%- if profileView -%}
@@ -52,44 +57,9 @@
<meta property="twitter:description" content="This profile requires authentication to view.">
{% endif %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfilePage",
"dateCreated": "{{ profileView.CreatedAt }}",
"mainEntity": {
"@type": "Person",
{%- if profileView.DisplayName %}
"name": "{{ profileView.DisplayName }}",
"alternateName": "@{{ profileView.Handle }}",
{% else %}
"name": "@{{ profileView.Handle }}",
{% endif -%}
"identifier": "{{ profileView.Did }}",
"description": "{{ profileView.Description }}",
"image": "{{ profileView.Avatar }}",
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/FollowAction",
"userInteractionCount": {{ profileView.FollowersCount }}
}
],
"agentInteractionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/FollowAction",
"userInteractionCount": {{ profileView.FollowsCount }}
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/WriteAction",
"userInteractionCount": {{ profileView.PostsCount }}
}
]
}
}
</script>
{%- if profileJSONLD %}
<script type="application/ld+json">{{ profileJSONLD|safe }}</script>
{% endif -%}
{% endif -%}
{%- endblock %}