Skip to content

Commit a8c98ca

Browse files
jehervepfefferle
andauthored
Make the Bluesky appview host filterable (#159)
Co-authored-by: Matthias Pfefferle <pfefferle@users.noreply.github.com>
1 parent 947b466 commit a8c98ca

10 files changed

Lines changed: 569 additions & 13 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Add filters so links to Bluesky can point at an alternative AT Protocol appview, including ones hosted on a subdomain or subpath.

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ Text domain: always `'atmosphere'`.
7272

7373
**MUST** use `use` imports for cross-namespace references — no inline `\Namespace\Class`.
7474

75+
**MUST** build any front-end-displayed Bluesky/appview web link (`profile`, `post`, `hashtag`, `mention`) through `Atmosphere\appview_url( $path, $context )` in `includes/functions.php` — never hardcode `https://bsky.app/...`. The helper centralizes the host and makes it filterable (`atmosphere_appview_host` / `atmosphere_appview_url`). Keep escaping at the call site (`\esc_url()` for HTML, `\esc_url_raw()` for storage); the helper returns an unescaped URL on purpose. This applies to display links only, not to AT Protocol records being published.
76+
7577
## Autoloading
7678

7779
Uses the custom `Atmosphere\Autoloader` in `includes/class-autoloader.php`, which respects WordPress filename conventions (`class-foo.php`, lowercase, hyphenated). `composer.json` declares an empty `autoload` block — Composer is only used for dev tooling (PHPUnit, PHPCS, Changelogger). Helper functions in `includes/functions.php` are loaded via a direct `require_once` from `atmosphere.php`.

docs/developer-docs.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,68 @@ ATmosphere exposes a small set of filters and actions for plugins to extend beha
4040
| `atmosphere_should_sync_reply` | filter | Customise which inbound Bluesky replies become WordPress comments. |
4141
| `atmosphere_transform_bsky_post` | filter | Mutate the Bluesky post record before write. |
4242
| `atmosphere_transform_document` | filter | Mutate the document record before write. |
43+
| `atmosphere_appview_host` | filter | Point Bluesky web links at an alternative AT Protocol appview (host or subpath). |
44+
| `atmosphere_appview_url` | filter | Rewrite the whole assembled appview link, including its route. |
4345
| `atmosphere_publish_post_result` | action | React to a post-publish outcome (success or `WP_Error`). |
4446
| `atmosphere_publish_comment_result` | action | React to a comment-publish outcome. |
4547
| `atmosphere_reaction_synced` | action | React when a Bluesky reaction is stored as a WordPress comment. |
4648

4749
When adding a new public hook, mark its `@since` tag as `unreleased` — the release script rewrites it (see [Release Process → Marking Unreleased Code](release-process.md#marking-unreleased-code)).
4850

51+
### Pointing Bluesky links at another appview
52+
53+
Rendered links to Bluesky (profiles, hashtags, mentions, posts) default to the `bsky.app` web appview. Two filters let you redirect them, depending on how much you need to change.
54+
55+
Both filters pass up to three arguments. As with any WordPress filter, register with `$accepted_args = 3` if your callback needs `$path` and `$context`:
56+
57+
- `$path` — the path being built, e.g. `profile/<did>` or `hashtag/<tag>`.
58+
- `$context` — array with the available parts: `type` (one of `profile`, `post`, `mention`, `hashtag`), `did`, `handle`, `rkey`, `tag`.
59+
60+
#### `atmosphere_appview_host` — swap the host (or subpath)
61+
62+
Use this when the alternative appview mirrors bsky.app's routes (`/profile/...`, `/hashtag/...`) and you only need to change where they live. The first argument is the default host, `'bsky.app'`.
63+
64+
The returned value can be a bare host, a host on a subdomain, or a host with a path prefix, with or without a scheme or trailing slash — it's normalized before use, so an appview hosted on a subpath works cleanly:
65+
66+
```php
67+
// Bare host.
68+
add_filter( 'atmosphere_appview_host', fn() => 'deer.social' );
69+
70+
// Appview living on a subpath: yields https://something.social/atblue/profile/<did>.
71+
add_filter( 'atmosphere_appview_host', fn() => 'something.social/atblue' );
72+
73+
// Route by context: send profiles elsewhere, keep hashtags on bsky.app.
74+
add_filter(
75+
'atmosphere_appview_host',
76+
function ( $host, $path, $context ) {
77+
return 'hashtag' === ( $context['type'] ?? '' ) ? $host : 'deer.social';
78+
},
79+
10,
80+
3
81+
);
82+
```
83+
84+
#### `atmosphere_appview_url` — rewrite the whole link
85+
86+
Use this when the appview's routes differ from bsky.app's — for example `/account/<did>` instead of `/profile/<did>`, or a custom hashtag route. The first argument is the fully assembled URL (after the host filter has run); rebuild it from `$context` and return a complete URL:
87+
88+
```php
89+
// Custom profile route: /account/<did> instead of /profile/<did>.
90+
add_filter(
91+
'atmosphere_appview_url',
92+
function ( $url, $path, $context ) {
93+
if ( 'mention' === ( $context['type'] ?? '' ) || 'profile' === ( $context['type'] ?? '' ) ) {
94+
return 'https://my.appview/account/' . ( $context['did'] ?? $context['handle'] ?? '' );
95+
}
96+
return $url;
97+
},
98+
10,
99+
3
100+
);
101+
```
102+
103+
Of note: links rendered on the fly (facet mentions, hashtags, and the "View on Bluesky" link) pick up the filters on every render, so changing them updates immediately. The author and source links stored on synced reaction comments are resolved once at sync time, so they keep whichever host was in effect when the comment was synced.
104+
49105
## Extending Content Formats
50106

51107
The `site.standard.document` record's `content` field is a singular open union of typed content objects (see [`docs/content-formats.md`](content-formats.md)). ATmosphere ships built-in parsers for HTML, Markpub, Leaflet, and pckt formats, and integrations can register additional parsers.

docs/php-coding-standards.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ use function Atmosphere\is_connected;
223223
\apply_filters( 'atmosphere_backfill_query_chunk_size', 500 );
224224
\apply_filters( 'atmosphere_oauth_redirect_uri', $uri );
225225
\apply_filters( 'atmosphere_client_metadata', $metadata );
226+
\apply_filters( 'atmosphere_appview_host', 'bsky.app', $path, $context ); // Host/subpath for appview web links; normalized; $context keys: type|did|handle|rkey|tag.
227+
\apply_filters( 'atmosphere_appview_url', $url, $path, $context ); // Whole assembled appview link; rewrite the route from $context.
226228
```
227229

228230
**Actions:**

includes/class-atmosphere.php

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,11 +1434,12 @@ public function register_share_status_field(): void {
14341434
}
14351435

14361436
/**
1437-
* Build the bsky.app web URL for one of our own post AT-URIs.
1437+
* Build the appview web URL for one of our own post AT-URIs.
14381438
*
14391439
* `at://<did>/app.bsky.feed.post/<rkey>` →
1440-
* `https://bsky.app/profile/<did>/post/<rkey>`. bsky.app resolves the
1441-
* DID form, so no handle lookup is needed.
1440+
* `https://<appview-host>/profile/<did>/post/<rkey>`. The appview resolves
1441+
* the DID form, so no handle lookup is needed. The host defaults to
1442+
* `bsky.app` and is filterable via `atmosphere_appview_host`.
14421443
*
14431444
* @param string $uri AT-URI from `Post::META_URI`.
14441445
* @return string Web URL, or '' when the URI shape is unexpected.
@@ -1448,7 +1449,16 @@ private static function bsky_web_url_from_uri( string $uri ): string {
14481449
return '';
14491450
}
14501451

1451-
return \esc_url_raw( 'https://bsky.app/profile/' . $matches['did'] . '/post/' . $matches['rkey'] );
1452+
return \esc_url_raw(
1453+
appview_url(
1454+
'profile/' . $matches['did'] . '/post/' . $matches['rkey'],
1455+
array(
1456+
'type' => 'post',
1457+
'did' => $matches['did'],
1458+
'rkey' => $matches['rkey'],
1459+
)
1460+
)
1461+
);
14521462
}
14531463

14541464
/**

includes/class-reaction-sync.php

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,15 @@ private static function insert_reaction(
725725
'comment_post_ID' => $post_id,
726726
'comment_parent' => $comment_parent,
727727
'comment_author' => $author_name,
728-
'comment_author_url' => \esc_url_raw( 'https://bsky.app/profile/' . \rawurlencode( $author_handle ) ),
728+
'comment_author_url' => \esc_url_raw(
729+
appview_url(
730+
'profile/' . \rawurlencode( $author_handle ),
731+
array(
732+
'type' => 'profile',
733+
'handle' => $author_handle,
734+
)
735+
)
736+
),
729737
'comment_author_email' => '',
730738
'comment_author_IP' => '',
731739
'comment_content' => \wp_kses_post( $content ),
@@ -873,10 +881,11 @@ private static function resolve_author( string $did ): array {
873881
}
874882

875883
/**
876-
* Build the https://bsky.app/... web URL for a given AT-URI + handle.
884+
* Build the appview web URL for a given AT-URI + handle.
877885
*
878-
* Only app.bsky.feed.post records have a corresponding bsky.app
879-
* web page; like and repost rkeys don't, so those return ''.
886+
* Only app.bsky.feed.post records have a corresponding appview web
887+
* page; like and repost rkeys don't, so those return ''. The host
888+
* defaults to `bsky.app` and is filterable via `atmosphere_appview_host`.
880889
*
881890
* @param string $at_uri AT-URI.
882891
* @param string $handle Bluesky handle.
@@ -894,7 +903,16 @@ private static function build_bsky_web_url( string $at_uri, string $handle ): st
894903
return '';
895904
}
896905

897-
return \esc_url_raw( 'https://bsky.app/profile/' . \rawurlencode( $handle ) . '/post/' . $rkey );
906+
return \esc_url_raw(
907+
appview_url(
908+
'profile/' . \rawurlencode( $handle ) . '/post/' . $rkey,
909+
array(
910+
'type' => 'post',
911+
'handle' => $handle,
912+
'rkey' => $rkey,
913+
)
914+
)
915+
);
898916
}
899917

900918
/**

includes/functions.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,115 @@ function build_at_uri( string $did, string $collection, string $rkey ): string {
4545
return "at://{$did}/{$collection}/{$rkey}";
4646
}
4747

48+
/**
49+
* Build a web URL pointing at an AT Protocol appview.
50+
*
51+
* Returns an UNESCAPED URL. Callers MUST escape at the point of use
52+
* (\esc_url() for HTML output, \esc_url_raw() for storage/redirects), as
53+
* late as possible and in the right context.
54+
*
55+
* @param string $path Path after the host, with no leading slash, e.g.
56+
* 'profile/<did>/post/<rkey>' or 'hashtag/<tag>'.
57+
* Callers are responsible for encoding path segments.
58+
* @param array $context Optional parts the caller has on hand. Recognised
59+
* keys: 'type' (profile|post|mention|hashtag), 'did',
60+
* 'handle', 'rkey', 'tag'.
61+
* @return string Unescaped URL, e.g. 'https://bsky.app/profile/<did>'.
62+
*/
63+
function appview_url( string $path, array $context = array() ): string {
64+
/**
65+
* Filters the base of AT Protocol appview web links.
66+
*
67+
* The base is everything before the path: scheme, host, and an optional
68+
* path prefix. The returned value may be a bare host ('deer.social'), a
69+
* host with a path prefix ('something.social/atblue'), and may include a
70+
* scheme and/or trailing slash — it is normalized before use, so appviews
71+
* hosted on a subdomain or a subpath work cleanly. Defaults to 'bsky.app',
72+
* the Bluesky appview. To rewrite the path itself (e.g. a custom route),
73+
* use the {@see 'atmosphere_appview_url'} filter instead.
74+
*
75+
* @since unreleased
76+
*
77+
* @param string $host Default appview host ('bsky.app').
78+
* @param string $path Path being built, e.g. 'profile/<did>'.
79+
* @param array $context Available parts: type, did, handle, rkey, tag.
80+
*/
81+
$base = \apply_filters( 'atmosphere_appview_host', 'bsky.app', $path, $context );
82+
83+
$url = appview_base_url( $base ) . '/' . \ltrim( $path, '/' );
84+
85+
/**
86+
* Filters the fully assembled AT Protocol appview web URL.
87+
*
88+
* Use this to rewrite the entire URL — including the route, e.g.
89+
* '/account/<did>' instead of '/profile/<did>', or a custom hashtag
90+
* route — by rebuilding it from the parts in $context. Return a complete
91+
* URL; it is escaped by the caller, not here.
92+
*
93+
* @since unreleased
94+
*
95+
* @param string $url Assembled URL, e.g. 'https://bsky.app/profile/<did>'.
96+
* @param string $path Path that was appended, e.g. 'profile/<did>'.
97+
* @param array $context Available parts: type, did, handle, rkey, tag.
98+
*/
99+
return \apply_filters( 'atmosphere_appview_url', $url, $path, $context );
100+
}
101+
102+
/**
103+
* Normalize a filtered appview base into a clean 'scheme://host[:port][/prefix]'.
104+
*
105+
* Accepts a bare host, a host with a path prefix, with or without a scheme or
106+
* trailing slash, and rebuilds it without empty or doubled segments. The host is
107+
* lower-cased and the scheme is clamped to http/https. An empty value falls back
108+
* to 'https://bsky.app' silently; a non-empty value that yields no host falls
109+
* back too, but flags `_doing_it_wrong` since the callback returned something
110+
* unusable.
111+
*
112+
* These URLs are display links — rendered for users to click, or stored as
113+
* comment author/source links — and are always escaped at the call site. They
114+
* are never fetched server-side, so there is no SSRF surface, and IP-literal or
115+
* localhost hosts are intentionally allowed for self-hosted appviews. The value
116+
* comes from a site-owner filter callback, not from external request input.
117+
*
118+
* @param string $base Filtered base value, e.g. 'something.social/atblue/'.
119+
* @return string Clean base with no trailing slash, e.g. 'https://something.social/atblue'.
120+
*/
121+
function appview_base_url( string $base ): string {
122+
$base = \trim( $base );
123+
124+
// An empty value just means "use the default appview" — nothing to flag.
125+
if ( '' === $base ) {
126+
return 'https://bsky.app';
127+
}
128+
129+
// Ensure a scheme so wp_parse_url splits the host from any path prefix.
130+
if ( ! \preg_match( '#^[a-z][a-z0-9+.-]*://#i', $base ) ) {
131+
$base = 'https://' . \ltrim( $base, '/' );
132+
}
133+
134+
$parts = \wp_parse_url( $base );
135+
$host = \is_array( $parts ) ? \strtolower( $parts['host'] ?? '' ) : '';
136+
137+
if ( '' === $host ) {
138+
\_doing_it_wrong(
139+
__FUNCTION__,
140+
\esc_html__( 'atmosphere_appview_host must return a host (optionally with a scheme and path prefix); falling back to bsky.app.', 'atmosphere' ),
141+
'unreleased'
142+
);
143+
return 'https://bsky.app';
144+
}
145+
146+
$scheme = \strtolower( $parts['scheme'] ?? 'https' );
147+
if ( 'http' !== $scheme && 'https' !== $scheme ) {
148+
$scheme = 'https';
149+
}
150+
151+
$port = isset( $parts['port'] ) ? ':' . $parts['port'] : '';
152+
$prefix = \trim( $parts['path'] ?? '', '/' );
153+
154+
return $scheme . '://' . $host . $port . ( '' !== $prefix ? '/' . $prefix : '' );
155+
}
156+
48157
/**
49158
* Decode entities, strip HTML, normalise whitespace.
50159
*

includes/transformer/class-facet.php

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
\defined( 'ABSPATH' ) || exit;
1414

1515
use function Atmosphere\get_connection;
16+
use function Atmosphere\appview_url;
1617

1718
/**
1819
* Extracts facets from plain text.
@@ -198,16 +199,36 @@ private static function render_feature( array $feature, string $display ): strin
198199
case 'app.bsky.richtext.facet#mention':
199200
/*
200201
* The mention facet only carries the DID, so link by DID.
201-
* bsky.app/profile/{did} resolves the same as the handle
202-
* form used elsewhere in Reaction_Sync.
202+
* The appview's /profile/{did} resolves the same as the
203+
* handle form used elsewhere in Reaction_Sync.
203204
*/
204205
$did = $feature['did'] ?? '';
205-
$href = '' === $did ? '' : \esc_url( 'https://bsky.app/profile/' . $did );
206+
$href = '' === $did
207+
? ''
208+
: \esc_url(
209+
appview_url(
210+
'profile/' . $did,
211+
array(
212+
'type' => 'mention',
213+
'did' => $did,
214+
)
215+
)
216+
);
206217
break;
207218

208219
case 'app.bsky.richtext.facet#tag':
209220
$tag = $feature['tag'] ?? '';
210-
$href = '' === $tag ? '' : \esc_url( 'https://bsky.app/hashtag/' . \rawurlencode( $tag ) );
221+
$href = '' === $tag
222+
? ''
223+
: \esc_url(
224+
appview_url(
225+
'hashtag/' . \rawurlencode( $tag ),
226+
array(
227+
'type' => 'hashtag',
228+
'tag' => $tag,
229+
)
230+
)
231+
);
211232
break;
212233

213234
default:

0 commit comments

Comments
 (0)