From 6abbf3cd038828f252890bf4b4e01c6275c302e1 Mon Sep 17 00:00:00 2001 From: Michael Black Date: Tue, 26 May 2026 11:34:00 -0500 Subject: [PATCH] more reusable funcs --- bskyweb/cmd/bskyweb/jsonld.go | 27 +++++----- bskyweb/cmd/bskyweb/jsonld_test.go | 20 +++++++- bskyweb/cmd/bskyweb/labels.go | 23 +++++++++ bskyweb/cmd/bskyweb/labels_test.go | 81 ++++++++++++++++++++++++++++++ bskyweb/cmd/bskyweb/rss.go | 12 ++--- bskyweb/cmd/bskyweb/server.go | 39 +++++--------- 6 files changed, 154 insertions(+), 48 deletions(-) create mode 100644 bskyweb/cmd/bskyweb/labels.go create mode 100644 bskyweb/cmd/bskyweb/labels_test.go 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)