PluginProbe
ActivityPub / 9.2.0
ActivityPub v9.2.0
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-router.php

class-router.php in ActivityPub 9.2.0, at includes/class-router.php

458 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Router class.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Collection\Actors;
11 use Activitypub\Collection\Outbox;
12
13 /**
14 * Router class.
15 */
16 class Router {
17 /**
18 * Initialize the class, registering WordPress hooks.
19 */
20 public static function init() {
21 \add_action( 'init', array( self::class, 'add_rewrite_rules' ), 11 );
22
23 \add_action( 'send_headers', array( self::class, 'add_headers' ) );
24 \add_filter( 'template_include', array( self::class, 'render_activitypub_template' ), 99 );
25 \add_action( 'template_redirect', array( self::class, 'template_redirect' ) );
26 \add_filter( 'redirect_canonical', array( self::class, 'redirect_canonical' ), 10, 2 );
27 \add_filter( 'redirect_canonical', array( self::class, 'no_trailing_redirect' ), 10, 2 );
28 \add_filter( 'query_vars', array( self::class, 'add_query_vars' ) );
29
30 \add_action( 'parse_query', array( self::class, 'fix_is_home_check' ) );
31 }
32
33 /**
34 * Add rewrite rules.
35 */
36 public static function add_rewrite_rules() {
37 /*
38 * If another system needs to take precedence over the ActivityPub rewrite rules,
39 * they can define their own and will manually call the appropriate functions as required.
40 */
41 if ( ACTIVITYPUB_DISABLE_REWRITES ) {
42 return;
43 }
44
45 \add_rewrite_rule(
46 '^authorize_interaction/?$',
47 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/interactions',
48 'top'
49 );
50
51 if ( ! \class_exists( 'Webfinger' ) ) {
52 \add_rewrite_rule(
53 '^.well-known/webfinger',
54 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger',
55 'top'
56 );
57 }
58
59 if ( ! \class_exists( 'Nodeinfo_Endpoint' ) && true === (bool) \get_option( 'blog_public', 1 ) ) {
60 \add_rewrite_rule(
61 '^.well-known/nodeinfo',
62 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo',
63 'top'
64 );
65 }
66
67 // Authorization Server Metadata (RFC 8414).
68 \add_rewrite_rule(
69 '^.well-known/oauth-authorization-server',
70 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorization-server-metadata',
71 'top'
72 );
73
74 // Must precede the generic @username rule, which would otherwise match "application" as an actor username.
75 \add_rewrite_rule( '^@application\/?$', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/application', 'top' );
76 \add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' );
77 \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES );
78 }
79
80 /**
81 * Return a AS2 JSON version of an author, post or page.
82 *
83 * @param string $template The path to the template object.
84 *
85 * @return string The new path to the JSON template.
86 */
87 public static function render_activitypub_template( $template ) {
88 if ( \wp_is_serving_rest_request() || \wp_doing_ajax() ) {
89 return $template;
90 }
91
92 if ( ! is_activitypub_request() || ! should_negotiate_content() ) {
93 $is_outbox_item = \get_query_var( 'p' ) && Outbox::POST_TYPE === \get_post_type( \get_query_var( 'p' ) );
94 $is_preflight = isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'];
95
96 if ( $is_outbox_item && $is_preflight ) {
97 /*
98 * CORS preflight: override WordPress 404 so the browser
99 * accepts the preflight response (must be 2xx).
100 */
101 \status_header( 200 );
102 } elseif ( $is_outbox_item ) {
103 // Return 406 for non-ActivityPub requests to outbox items since they only support ActivityPub requests.
104 \set_query_var( 'is_404', true );
105 \status_header( 406 );
106 }
107
108 return $template;
109 }
110
111 $activitypub_object = Query::get_instance()->get_activitypub_object();
112 $queried_object = Query::get_instance()->get_queried_object();
113
114 /*
115 * Serve the Tombstone for a deleted object — but not while an authorized
116 * user is previewing it. During an editor `?preview=true` request,
117 * `is_post_publicly_queryable()` treats a draft or pending post as
118 * queryable only for a user who can edit it, so the author can still use
119 * the Fediverse Preview on a post they just soft-deleted (which is
120 * otherwise already in the tombstone registry).
121 *
122 * The bypass is scoped to that preview request on purpose. A normal
123 * ActivityPub fetch (no `preview`) of a tombstoned URL always gets the
124 * Tombstone — even if the URL now resolves to a fresh public post because
125 * its slug was reused — since remote servers were told that id is gone.
126 * The legitimate restore path clears the registry entry itself, via
127 * `Create::maybe_unbury()` when the re-publish Create is queued.
128 */
129 $is_authorized_preview = \get_query_var( 'preview' )
130 && $queried_object instanceof \WP_Post
131 && is_post_publicly_queryable( $queried_object );
132
133 if (
134 Tombstone::exists_local( Query::get_instance()->get_request_url() )
135 && ! $is_authorized_preview
136 ) {
137 // Set 410 Gone for permanently deleted posts, 200 OK for soft-deleted.
138 if ( ! $activitypub_object ) {
139 \status_header( 410 );
140 }
141
142 return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php';
143 }
144
145 /*
146 * Refuse to expose the content-negotiated representation of a post
147 * that is no longer publicly queryable (non-public status, AP
148 * visibility flipped, post-type support removed, etc.). The
149 * lifecycle gate in `is_post_disabled()` intentionally lets such
150 * posts through the federation pipeline so a Delete can fire, but
151 * that escape hatch must not leak into front-end rendering during
152 * the window between status change and Delete delivery.
153 */
154 if (
155 $activitypub_object &&
156 $queried_object instanceof \WP_Post &&
157 'ap_outbox' !== $queried_object->post_type &&
158 ! is_post_publicly_queryable( $queried_object )
159 ) {
160 return $template;
161 }
162
163 $activitypub_template = false;
164
165 if ( $activitypub_object ) {
166 if ( \get_query_var( 'preview' ) ) {
167 \defined( 'ACTIVITYPUB_PREVIEW' ) || \define( 'ACTIVITYPUB_PREVIEW', true );
168
169 /*
170 * A preview is only ever reached by someone allowed to see the unpublished post:
171 * is_post_publicly_queryable() gates draft/pending/future on current_user_can('edit_post'),
172 * and everyone else has already fallen through to the normal template above. The response
173 * therefore varies by caller no matter the Authorized Fetch setting, so it must never be
174 * stored by a shared cache and replayed to someone who cannot edit the post. Independent of
175 * the Authorized Fetch block below, which only guards the signed-fetch path.
176 */
177 if ( ! \headers_sent() ) {
178 \header( 'Cache-Control: private, no-store, max-age=0' );
179 }
180
181 /**
182 * Filter the template used for the ActivityPub preview.
183 *
184 * @param string $activitypub_template Absolute path to the template file.
185 */
186 $activitypub_template = \apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
187 } else {
188 $activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php';
189 }
190 }
191
192 /*
193 * Check if the request is authorized.
194 *
195 * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
196 * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
197 */
198 if ( $activitypub_template && use_authorized_fetch() ) {
199 /*
200 * Under Authorized Fetch the response depends on the caller's signature: an unsigned request
201 * is refused, a signed one gets the document. A shared cache (page cache or CDN) keys the
202 * activity+json variant on the representation, not the signature, so it must store neither
203 * outcome and replay it to the wrong caller. Send this before verifying so it covers the 401
204 * and the 200 alike; `max-age=0` is what page caches such as Surge key on to skip storing.
205 */
206 if ( ! \headers_sent() ) {
207 \header( 'Cache-Control: private, no-store, max-age=0' );
208 }
209
210 $verification = Signature::verify_http_signature( $_SERVER );
211 if ( \is_wp_error( $verification ) ) {
212 \status_header( 401 );
213
214 // Fallback as template_loader can't return http headers.
215 return $template;
216 }
217 }
218
219 if ( $activitypub_template ) {
220 \set_query_var( 'is_404', false );
221
222 // Check if header already sent.
223 if ( ! \headers_sent() ) {
224 // Send 200 status header.
225 \status_header( 200 );
226 }
227
228 return $activitypub_template;
229 }
230
231 return $template;
232 }
233
234 /**
235 * Add the 'self' link to the header.
236 */
237 public static function add_headers() {
238 $id = Query::get_instance()->get_activitypub_object_id();
239
240 /*
241 * Send CORS headers for resolved ActivityPub objects and outbox
242 * items. Outbox items need CORS even when the object ID doesn't
243 * resolve, because browser preflight requests don't carry the
244 * Authorization header needed to authenticate private items.
245 */
246 $post_id = \get_query_var( 'p' );
247 $is_outbox_url = $post_id && Outbox::POST_TYPE === \get_post_type( $post_id );
248
249 if ( ! \headers_sent() && ( $id || $is_outbox_url ) ) {
250 \header( 'Access-Control-Allow-Origin: *' );
251 \header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
252 \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' );
253 }
254
255 if ( ! $id ) {
256 return;
257 }
258
259 if ( ! \headers_sent() ) {
260 \header( 'Link: <' . \esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
261
262 if ( \get_option( 'activitypub_vary_header', '1' ) ) {
263 // Send Vary header for Accept header.
264 \header( 'Vary: Accept', false );
265 }
266 }
267
268 \add_action(
269 'wp_head',
270 static function () use ( $id ) {
271 echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . \esc_url( $id ) . '" />' . PHP_EOL;
272 }
273 );
274 }
275
276 /**
277 * Remove trailing slash from ActivityPub @username requests.
278 *
279 * @param string $redirect_url The URL to redirect to.
280 * @param string $requested_url The requested URL.
281 *
282 * @return string $redirect_url The possibly-unslashed redirect URL.
283 */
284 public static function no_trailing_redirect( $redirect_url, $requested_url ) {
285 if ( \get_query_var( 'actor' ) ) {
286 return $requested_url;
287 }
288
289 return $redirect_url;
290 }
291
292 /**
293 * Add support for `p` and `author` query vars.
294 *
295 * @param string $redirect_url The URL to redirect to.
296 * @param string $requested_url The requested URL.
297 *
298 * @return string $redirect_url
299 */
300 public static function redirect_canonical( $redirect_url, $requested_url ) {
301 if ( ! is_activitypub_request() ) {
302 return $redirect_url;
303 }
304
305 $query = \wp_parse_url( $requested_url, PHP_URL_QUERY );
306
307 if ( ! $query ) {
308 return $redirect_url;
309 }
310
311 $query_params = \wp_parse_args( $query );
312 unset( $query_params['activitypub'] );
313 unset( $query_params['stamp'] );
314
315 if ( 1 !== \count( $query_params ) ) {
316 return $redirect_url;
317 }
318
319 if ( isset( $query_params['p'] ) ) {
320 return null;
321 }
322
323 if ( isset( $query_params['author'] ) ) {
324 return null;
325 }
326
327 return $requested_url;
328 }
329
330 /**
331 * Custom redirects for ActivityPub requests.
332 *
333 * @return void
334 */
335 public static function template_redirect() {
336 global $wp_query;
337
338 $comment_id = \get_query_var( 'c', null );
339
340 // Check if it seems to be a comment.
341 if ( $comment_id ) {
342 $comment = \get_comment( $comment_id );
343
344 // Load a 404-page if `c` is set but not valid.
345 if ( ! $comment ) {
346 $wp_query->set_404();
347 return;
348 }
349
350 // Stop if it's not an ActivityPub comment.
351 if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
352 return;
353 }
354
355 \wp_safe_redirect( \get_comment_link( $comment ) );
356 exit;
357 }
358
359 /*
360 * Skip the actor branch when this looks like an actor-scoped FEP-7aa9
361 * stamp URL: numeric `actor` paired with a `stamp`. Those resolve to a
362 * FeatureAuthorization via Activitypub\Query, not via the username
363 * lookup which would 404 the numeric ID. Non-numeric actors fall
364 * through to the regular Mastodon-style profile lookup.
365 */
366 $actor = \get_query_var( 'actor', null );
367 $is_stamp_url = $actor && \get_query_var( 'stamp' ) && \ctype_digit( (string) $actor );
368 if ( $actor && ! $is_stamp_url ) {
369 $actor = Actors::get_by_username( $actor );
370 if ( ! $actor || \is_wp_error( $actor ) ) {
371 $wp_query->set_404();
372 return;
373 }
374
375 if ( is_activitypub_request() ) {
376 return;
377 }
378
379 \wp_safe_redirect( $actor->get_url(), 301 );
380 exit;
381 }
382
383 $term_id = \get_query_var( 'term_id', null );
384 if ( $term_id ) {
385 $term = \get_term( $term_id );
386
387 // Load a 404-page if `term_id` is set but not valid.
388 if ( ! $term || \is_wp_error( $term ) ) {
389 $wp_query->set_404();
390 return;
391 }
392
393 /**
394 * Filters the taxonomies supported for term redirects.
395 *
396 * @since 7.8.3
397 *
398 * @param array $supported_taxonomies Array of taxonomy names. Default array( 'category', 'post_tag' ).
399 */
400 $supported_taxonomies = \apply_filters( 'activitypub_supported_taxonomies', array( 'category', 'post_tag' ) );
401
402 if ( ! \in_array( $term->taxonomy, $supported_taxonomies, true ) ) {
403 return;
404 }
405
406 // Don't redirect for ActivityPub requests.
407 if ( is_activitypub_request() ) {
408 return;
409 }
410
411 $term_link = \get_term_link( $term );
412 if ( ! \is_wp_error( $term_link ) ) {
413 \wp_safe_redirect( $term_link, 301 );
414 exit;
415 }
416 }
417 }
418
419 /**
420 * Add the 'activitypub' query variable so WordPress won't mangle it.
421 *
422 * @param array $vars The query variables.
423 *
424 * @return array The query variables.
425 */
426 public static function add_query_vars( $vars ) {
427 $vars[] = 'activitypub';
428 $vars[] = 'preview';
429 $vars[] = 'author';
430 $vars[] = 'actor';
431 $vars[] = 'stamp';
432 $vars[] = 'type';
433 $vars[] = 'c';
434 $vars[] = 'p';
435 $vars[] = 'term_id';
436
437 return $vars;
438 }
439
440 /**
441 * Optimize home page query for ActivityPub requests.
442 *
443 * Skip the database query entirely for ActivityPub requests on the home page
444 * since we only need to return the blog actor, not posts.
445 *
446 * @param \WP_Query $wp_query The WP_Query instance.
447 */
448 public static function fix_is_home_check( $wp_query ) {
449 if (
450 $wp_query->get( 'actor' ) ||
451 $wp_query->get( 'stamp' ) ||
452 $wp_query->get( 'c' )
453 ) {
454 $wp_query->is_home = false;
455 }
456 }
457 }
458