PluginProbe
ActivityPub / 9.1.0
ActivityPub v9.1.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.1.0, at includes/class-router.php

435 lines 12.6 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 * Filter the template used for the ActivityPub preview.
171 *
172 * @param string $activitypub_template Absolute path to the template file.
173 */
174 $activitypub_template = \apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
175 } else {
176 $activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php';
177 }
178 }
179
180 /*
181 * Check if the request is authorized.
182 *
183 * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
184 * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
185 */
186 if ( $activitypub_template && use_authorized_fetch() ) {
187 $verification = Signature::verify_http_signature( $_SERVER );
188 if ( \is_wp_error( $verification ) ) {
189 \status_header( 401 );
190
191 // Fallback as template_loader can't return http headers.
192 return $template;
193 }
194 }
195
196 if ( $activitypub_template ) {
197 \set_query_var( 'is_404', false );
198
199 // Check if header already sent.
200 if ( ! \headers_sent() ) {
201 // Send 200 status header.
202 \status_header( 200 );
203 }
204
205 return $activitypub_template;
206 }
207
208 return $template;
209 }
210
211 /**
212 * Add the 'self' link to the header.
213 */
214 public static function add_headers() {
215 $id = Query::get_instance()->get_activitypub_object_id();
216
217 /*
218 * Send CORS headers for resolved ActivityPub objects and outbox
219 * items. Outbox items need CORS even when the object ID doesn't
220 * resolve, because browser preflight requests don't carry the
221 * Authorization header needed to authenticate private items.
222 */
223 $post_id = \get_query_var( 'p' );
224 $is_outbox_url = $post_id && Outbox::POST_TYPE === \get_post_type( $post_id );
225
226 if ( ! \headers_sent() && ( $id || $is_outbox_url ) ) {
227 \header( 'Access-Control-Allow-Origin: *' );
228 \header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
229 \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' );
230 }
231
232 if ( ! $id ) {
233 return;
234 }
235
236 if ( ! \headers_sent() ) {
237 \header( 'Link: <' . \esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
238
239 if ( \get_option( 'activitypub_vary_header', '1' ) ) {
240 // Send Vary header for Accept header.
241 \header( 'Vary: Accept', false );
242 }
243 }
244
245 \add_action(
246 'wp_head',
247 static function () use ( $id ) {
248 echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . \esc_url( $id ) . '" />' . PHP_EOL;
249 }
250 );
251 }
252
253 /**
254 * Remove trailing slash from ActivityPub @username requests.
255 *
256 * @param string $redirect_url The URL to redirect to.
257 * @param string $requested_url The requested URL.
258 *
259 * @return string $redirect_url The possibly-unslashed redirect URL.
260 */
261 public static function no_trailing_redirect( $redirect_url, $requested_url ) {
262 if ( \get_query_var( 'actor' ) ) {
263 return $requested_url;
264 }
265
266 return $redirect_url;
267 }
268
269 /**
270 * Add support for `p` and `author` query vars.
271 *
272 * @param string $redirect_url The URL to redirect to.
273 * @param string $requested_url The requested URL.
274 *
275 * @return string $redirect_url
276 */
277 public static function redirect_canonical( $redirect_url, $requested_url ) {
278 if ( ! is_activitypub_request() ) {
279 return $redirect_url;
280 }
281
282 $query = \wp_parse_url( $requested_url, PHP_URL_QUERY );
283
284 if ( ! $query ) {
285 return $redirect_url;
286 }
287
288 $query_params = \wp_parse_args( $query );
289 unset( $query_params['activitypub'] );
290 unset( $query_params['stamp'] );
291
292 if ( 1 !== \count( $query_params ) ) {
293 return $redirect_url;
294 }
295
296 if ( isset( $query_params['p'] ) ) {
297 return null;
298 }
299
300 if ( isset( $query_params['author'] ) ) {
301 return null;
302 }
303
304 return $requested_url;
305 }
306
307 /**
308 * Custom redirects for ActivityPub requests.
309 *
310 * @return void
311 */
312 public static function template_redirect() {
313 global $wp_query;
314
315 $comment_id = \get_query_var( 'c', null );
316
317 // Check if it seems to be a comment.
318 if ( $comment_id ) {
319 $comment = \get_comment( $comment_id );
320
321 // Load a 404-page if `c` is set but not valid.
322 if ( ! $comment ) {
323 $wp_query->set_404();
324 return;
325 }
326
327 // Stop if it's not an ActivityPub comment.
328 if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
329 return;
330 }
331
332 \wp_safe_redirect( \get_comment_link( $comment ) );
333 exit;
334 }
335
336 /*
337 * Skip the actor branch when this looks like an actor-scoped FEP-7aa9
338 * stamp URL: numeric `actor` paired with a `stamp`. Those resolve to a
339 * FeatureAuthorization via Activitypub\Query, not via the username
340 * lookup which would 404 the numeric ID. Non-numeric actors fall
341 * through to the regular Mastodon-style profile lookup.
342 */
343 $actor = \get_query_var( 'actor', null );
344 $is_stamp_url = $actor && \get_query_var( 'stamp' ) && \ctype_digit( (string) $actor );
345 if ( $actor && ! $is_stamp_url ) {
346 $actor = Actors::get_by_username( $actor );
347 if ( ! $actor || \is_wp_error( $actor ) ) {
348 $wp_query->set_404();
349 return;
350 }
351
352 if ( is_activitypub_request() ) {
353 return;
354 }
355
356 \wp_safe_redirect( $actor->get_url(), 301 );
357 exit;
358 }
359
360 $term_id = \get_query_var( 'term_id', null );
361 if ( $term_id ) {
362 $term = \get_term( $term_id );
363
364 // Load a 404-page if `term_id` is set but not valid.
365 if ( ! $term || \is_wp_error( $term ) ) {
366 $wp_query->set_404();
367 return;
368 }
369
370 /**
371 * Filters the taxonomies supported for term redirects.
372 *
373 * @since 7.8.3
374 *
375 * @param array $supported_taxonomies Array of taxonomy names. Default array( 'category', 'post_tag' ).
376 */
377 $supported_taxonomies = \apply_filters( 'activitypub_supported_taxonomies', array( 'category', 'post_tag' ) );
378
379 if ( ! \in_array( $term->taxonomy, $supported_taxonomies, true ) ) {
380 return;
381 }
382
383 // Don't redirect for ActivityPub requests.
384 if ( is_activitypub_request() ) {
385 return;
386 }
387
388 $term_link = \get_term_link( $term );
389 if ( ! \is_wp_error( $term_link ) ) {
390 \wp_safe_redirect( $term_link, 301 );
391 exit;
392 }
393 }
394 }
395
396 /**
397 * Add the 'activitypub' query variable so WordPress won't mangle it.
398 *
399 * @param array $vars The query variables.
400 *
401 * @return array The query variables.
402 */
403 public static function add_query_vars( $vars ) {
404 $vars[] = 'activitypub';
405 $vars[] = 'preview';
406 $vars[] = 'author';
407 $vars[] = 'actor';
408 $vars[] = 'stamp';
409 $vars[] = 'type';
410 $vars[] = 'c';
411 $vars[] = 'p';
412 $vars[] = 'term_id';
413
414 return $vars;
415 }
416
417 /**
418 * Optimize home page query for ActivityPub requests.
419 *
420 * Skip the database query entirely for ActivityPub requests on the home page
421 * since we only need to return the blog actor, not posts.
422 *
423 * @param \WP_Query $wp_query The WP_Query instance.
424 */
425 public static function fix_is_home_check( $wp_query ) {
426 if (
427 $wp_query->get( 'actor' ) ||
428 $wp_query->get( 'stamp' ) ||
429 $wp_query->get( 'c' )
430 ) {
431 $wp_query->is_home = false;
432 }
433 }
434 }
435