diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go
index f73437c47f..a3adaf452f 100644
--- a/bskyweb/cmd/bskyweb/jsonld.go
+++ b/bskyweb/cmd/bskyweb/jsonld.go
@@ -98,21 +98,24 @@ const maxRecentPosts = 10
const authorFeedFetchLimit = 3 * maxRecentPosts
// bskyPostURL returns the canonical handle-form URL for a post, given the
-// post's author handle and at-uri. Returns "" if the URI cannot be parsed or
-// the handle is unusable (handle.invalid).
-func bskyPostURL(handle, atURI string) string {
- if handle == "" || handle == "handle.invalid" {
+// post's author handle and record key. Returns "" if the handle is unusable
+// (empty or handle.invalid) or rkey is empty.
+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 a convenience wrapper for callers that hold an
+// at-uri rather than a record key directly. Returns "" if the URI cannot be
+// parsed.
+func bskyPostURLFromATURI(handle, atURI string) string {
parsed, err := syntax.ParseATURI(atURI)
if err != nil {
return ""
}
- rkey := parsed.RecordKey()
- if rkey == "" {
- return ""
- }
- return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, rkey.String())
+ return bskyPostURL(handle, parsed.RecordKey().String())
}
// bskyProfileURL returns the canonical handle-form URL for a profile.
@@ -185,7 +188,7 @@ func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
// Skip _ViewBlocked, _ViewNotFound, _ViewDetached, and non-post records.
return ""
}
- return bskyPostURL(vr.Author.Handle, vr.Uri)
+ return bskyPostURLFromATURI(vr.Author.Handle, vr.Uri)
}
// extractSharedContentURL returns the URL of an external link embedded in
@@ -308,7 +311,7 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
node := discussionForumPosting{
Type: "DiscussionForumPosting",
- URL: bskyPostURL(pv.Author.Handle, pv.Uri),
+ URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postRecordText(pv),
@@ -370,7 +373,7 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) d
node := discussionForumPosting{
Type: "DiscussionForumPosting",
- URL: bskyPostURL(pv.Author.Handle, pv.Uri),
+ URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
Identifier: pv.Uri,
Author: buildAuthor(pv.Author),
Text: postRecordText(pv),
diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go
index 5e497e6082..e11385772e 100644
--- a/bskyweb/cmd/bskyweb/jsonld_test.go
+++ b/bskyweb/cmd/bskyweb/jsonld_test.go
@@ -503,6 +503,24 @@ func TestBuildProfileJSONLD_HasPart(t *testing.T) {
}
func TestBskyPostURL(t *testing.T) {
+ tests := []struct {
+ name, handle, rkey, want string
+ }{
+ {"valid", "alice.bsky.social", "abc", "https://bsky.app/profile/alice.bsky.social/post/abc"},
+ {"empty handle", "", "abc", ""},
+ {"handle.invalid", "handle.invalid", "abc", ""},
+ {"empty rkey", "alice.bsky.social", "", ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := bskyPostURL(tt.handle, tt.rkey); got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBskyPostURLFromATURI(t *testing.T) {
tests := []struct {
name, handle, atURI, want string
}{
@@ -513,7 +531,7 @@ func TestBskyPostURL(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- if got := bskyPostURL(tt.handle, tt.atURI); got != tt.want {
+ if got := bskyPostURLFromATURI(tt.handle, tt.atURI); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
diff --git a/bskyweb/cmd/bskyweb/labels.go b/bskyweb/cmd/bskyweb/labels.go
new file mode 100644
index 0000000000..53860f88a2
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/labels.go
@@ -0,0 +1,23 @@
+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, indicating the user only wants their content
+// shown to signed-in viewers. SSR responses for these profiles must omit
+// post text, descriptions, and other content beyond minimal identity.
+func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
+ if pv == nil {
+ return false
+ }
+ for _, label := range pv.Labels {
+ if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/bskyweb/cmd/bskyweb/labels_test.go b/bskyweb/cmd/bskyweb/labels_test.go
new file mode 100644
index 0000000000..20d4daaed1
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/labels_test.go
@@ -0,0 +1,81 @@
+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,
+ },
+ {
+ // Helper does not currently honor a Neg flag — matches existing
+ // inline behavior in WebPost / WebProfile / WebProfileRSS. If
+ // negation semantics are needed for self-labels, all four call
+ // sites need to change together.
+ name: "negated label still triggers (matches prior behavior)",
+ pv: &appbsky.ActorDefs_ProfileViewDetailed{
+ Did: "did:plc:alice",
+ Labels: []*comatprototypes.LabelDefs_Label{
+ {Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue},
+ },
+ },
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := profileRequiresAuth(tt.pv); got != tt.want {
+ t.Errorf("got %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/bskyweb/cmd/bskyweb/rss.go b/bskyweb/cmd/bskyweb/rss.go
index 368b911eb9..0b47ce548d 100644
--- a/bskyweb/cmd/bskyweb/rss.go
+++ b/bskyweb/cmd/bskyweb/rss.go
@@ -58,10 +58,8 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", handle))
}
- for _, label := range pv.Labels {
- if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
- return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", handle))
- }
+ if profileRequiresAuth(pv) {
+ return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", handle))
}
return c.Redirect(http.StatusFound, fmt.Sprintf("/profile/%s/rss", pv.Did))
}
@@ -76,10 +74,8 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
if err != nil {
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", did))
}
- for _, label := range pv.Labels {
- if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
- return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", did))
- }
+ if profileRequiresAuth(pv) {
+ return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", did))
}
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, did.String(), "", "posts_no_replies", false, 30)
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index d0d728df09..e7253dd7cd 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -524,12 +524,7 @@ 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)
@@ -538,10 +533,7 @@ func (srv *Server) WebPost(c echo.Context) error {
// This both handles DID-form requests and normalizes handle-form requests against stale handles in the URL,
// guaranteeing JSON-LD `url` and match exactly.
// If the handle is unusable (handle.invalid or empty), fall back to the request URI with query/fragment stripped.
- canonicalURL := ""
- if pv.Handle != "" && pv.Handle != "handle.invalid" {
- canonicalURL = fmt.Sprintf("https://bsky.app/profile/%s/post/%s", pv.Handle, rkey)
- }
+ canonicalURL := bskyPostURL(pv.Handle, rkey.String())
if !unauthedViewingOkay {
// Provide minimal OpenGraph data for auth-required posts
@@ -661,29 +653,27 @@ func (srv *Server) WebProfile(c echo.Context) error {
return c.Render(http.StatusOK, "profile.html", data)
}
identifier := handleOrDID.Normalize().String()
- isDIDInput := handleOrDID.IsDID()
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
if err != nil {
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
- // Canonical URL: when looked up by DID and we have a usable handle,
- // redirect search engines to the handle-form URL.
- if isDIDInput && pv.Handle != "" && pv.Handle != "handle.invalid" {
- data["canonicalURL"] = fmt.Sprintf("https://bsky.app/profile/%s", pv.Handle)
+ // Canonical URL: always prefer the handle-form URL when we have a usable
+ // handle, regardless of how the request was looked up. This handles
+ // DID-form requests and normalizes handle-form requests against stale
+ // handles, guaranteeing JSON-LD `url` and match.
+ // Falls back to requestURI (with query/fragment stripped by the
+ // canonicalize_url filter) when the handle is unusable.
+ if url := bskyProfileURL(pv.Handle); url != "" {
+ data["canonicalURL"] = url
}
// Fetch recent posts to embed as ProfilePage.hasPart so search engines
@@ -754,12 +744,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)