Refactor and improve seo schema.org data (#10613)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user