PluginProbe
ActivityPub / 9.0.1
ActivityPub v9.0.1
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.0.1, at includes/class-router.php

433 lines 12.4 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 \add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' );
75 \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES );
76 }
77
78 /**
79 * Return a AS2 JSON version of an author, post or page.
80 *
81 * @param string $template The path to the template object.
82 *
83 * @return string The new path to the JSON template.
84 */
85 public static function render_activitypub_template( $template ) {
86 if ( \wp_is_serving_rest_request() || \wp_doing_ajax() ) {
87 return $template;
88 }
89
90 if ( ! is_activitypub_request() || ! should_negotiate_content() ) {
91 $is_outbox_item = \get_query_var( 'p' ) && Outbox::POST_TYPE === \get_post_type( \get_query_var( 'p' ) );
92 $is_preflight = isset( $_SERVER['REQUEST_METHOD'] ) && 'OPTIONS' === $_SERVER['REQUEST_METHOD'];
93
94 if ( $is_outbox_item && $is_preflight ) {
95 /*
96 * CORS preflight: override WordPress 404 so the browser
97 * accepts the preflight response (must be 2xx).
98 */
99 \status_header( 200 );
100 } elseif ( $is_outbox_item ) {
101 // Return 406 for non-ActivityPub requests to outbox items since they only support ActivityPub requests.
102 \set_query_var( 'is_404', true );
103 \status_header( 406 );
104 }
105
106 return $template;
107 }
108
109 $activitypub_object = Query::get_instance()->get_activitypub_object();
110 $queried_object = Query::get_instance()->get_queried_object();
111
112 /*
113 * Serve the Tombstone for a deleted object — but not while an authorized
114 * user is previewing it. During an editor `?preview=true` request,
115 * `is_post_publicly_queryable()` treats a draft or pending post as
116 * queryable only for a user who can edit it, so the author can still use
117 * the Fediverse Preview on a post they just soft-deleted (which is
118 * otherwise already in the tombstone registry).
119 *
120 * The bypass is scoped to that preview request on purpose. A normal
121 * ActivityPub fetch (no `preview`) of a tombstoned URL always gets the
122 * Tombstone — even if the URL now resolves to a fresh public post because
123 * its slug was reused — since remote servers were told that id is gone.
124 * The legitimate restore path clears the registry entry itself, via
125 * `Create::maybe_unbury()` when the re-publish Create is queued.
126 */
127 $is_authorized_preview = \get_query_var( 'preview' )
128 && $queried_object instanceof \WP_Post
129 && is_post_publicly_queryable( $queried_object );
130
131 if (
132 Tombstone::exists_local( Query::get_instance()->get_request_url() )
133 && ! $is_authorized_preview
134 ) {
135 // Set 410 Gone for permanently deleted posts, 200 OK for soft-deleted.
136 if ( ! $activitypub_object ) {
137 \status_header( 410 );
138 }
139
140 return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php';
141 }
142
143 /*
144 * Refuse to expose the content-negotiated representation of a post
145 * that is no longer publicly queryable (non-public status, AP
146 * visibility flipped, post-type support removed, etc.). The
147 * lifecycle gate in `is_post_disabled()` intentionally lets such
148 * posts through the federation pipeline so a Delete can fire, but
149 * that escape hatch must not leak into front-end rendering during
150 * the window between status change and Delete delivery.
151 */
152 if (
153 $activitypub_object &&
154 $queried_object instanceof \WP_Post &&
155 'ap_outbox' !== $queried_object->post_type &&
156 ! is_post_publicly_queryable( $queried_object )
157 ) {
158 return $template;
159 }
160
161 $activitypub_template = false;
162
163 if ( $activitypub_object ) {
164 if ( \get_query_var( 'preview' ) ) {
165 \defined( 'ACTIVITYPUB_PREVIEW' ) || \define( 'ACTIVITYPUB_PREVIEW', true );
166
167 /**
168 * Filter the template used for the ActivityPub preview.
169 *
170 * @param string $activitypub_template Absolute path to the template file.
171 */
172 $activitypub_template = apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
173 } else {
174 $activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php';
175 }
176 }
177
178 /*
179 * Check if the request is authorized.
180 *
181 * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
182 * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
183 */
184 if ( $activitypub_template && use_authorized_fetch() ) {
185 $verification = Signature::verify_http_signature( $_SERVER );
186 if ( \is_wp_error( $verification ) ) {
187 \status_header( 401 );
188
189 // Fallback as template_loader can't return http headers.
190 return $template;
191 }
192 }
193
194 if ( $activitypub_template ) {
195 \set_query_var( 'is_404', false );
196
197 // Check if header already sent.
198 if ( ! \headers_sent() ) {
199 // Send 200 status header.
200 \status_header( 200 );
201 }
202
203 return $activitypub_template;
204 }
205
206 return $template;
207 }
208
209 /**
210 * Add the 'self' link to the header.
211 */
212 public static function add_headers() {
213 $id = Query::get_instance()->get_activitypub_object_id();
214
215 /*
216 * Send CORS headers for resolved ActivityPub objects and outbox
217 * items. Outbox items need CORS even when the object ID doesn't
218 * resolve, because browser preflight requests don't carry the
219 * Authorization header needed to authenticate private items.
220 */
221 $post_id = \get_query_var( 'p' );
222 $is_outbox_url = $post_id && Outbox::POST_TYPE === \get_post_type( $post_id );
223
224 if ( ! \headers_sent() && ( $id || $is_outbox_url ) ) {
225 \header( 'Access-Control-Allow-Origin: *' );
226 \header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
227 \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' );
228 }
229
230 if ( ! $id ) {
231 return;
232 }
233
234 if ( ! \headers_sent() ) {
235 \header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
236
237 if ( \get_option( 'activitypub_vary_header', '1' ) ) {
238 // Send Vary header for Accept header.
239 \header( 'Vary: Accept', false );
240 }
241 }
242
243 \add_action(
244 'wp_head',
245 static function () use ( $id ) {
246 echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . esc_url( $id ) . '" />' . PHP_EOL;
247 }
248 );
249 }
250
251 /**
252 * Remove trailing slash from ActivityPub @username requests.
253 *
254 * @param string $redirect_url The URL to redirect to.
255 * @param string $requested_url The requested URL.
256 *
257 * @return string $redirect_url The possibly-unslashed redirect URL.
258 */
259 public static function no_trailing_redirect( $redirect_url, $requested_url ) {
260 if ( get_query_var( 'actor' ) ) {
261 return $requested_url;
262 }
263
264 return $redirect_url;
265 }
266
267 /**
268 * Add support for `p` and `author` query vars.
269 *
270 * @param string $redirect_url The URL to redirect to.
271 * @param string $requested_url The requested URL.
272 *
273 * @return string $redirect_url
274 */
275 public static function redirect_canonical( $redirect_url, $requested_url ) {
276 if ( ! is_activitypub_request() ) {
277 return $redirect_url;
278 }
279
280 $query = \wp_parse_url( $requested_url, PHP_URL_QUERY );
281
282 if ( ! $query ) {
283 return $redirect_url;
284 }
285
286 $query_params = \wp_parse_args( $query );
287 unset( $query_params['activitypub'] );
288 unset( $query_params['stamp'] );
289
290 if ( 1 !== count( $query_params ) ) {
291 return $redirect_url;
292 }
293
294 if ( isset( $query_params['p'] ) ) {
295 return null;
296 }
297
298 if ( isset( $query_params['author'] ) ) {
299 return null;
300 }
301
302 return $requested_url;
303 }
304
305 /**
306 * Custom redirects for ActivityPub requests.
307 *
308 * @return void
309 */
310 public static function template_redirect() {
311 global $wp_query;
312
313 $comment_id = \get_query_var( 'c', null );
314
315 // Check if it seems to be a comment.
316 if ( $comment_id ) {
317 $comment = \get_comment( $comment_id );
318
319 // Load a 404-page if `c` is set but not valid.
320 if ( ! $comment ) {
321 $wp_query->set_404();
322 return;
323 }
324
325 // Stop if it's not an ActivityPub comment.
326 if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
327 return;
328 }
329
330 \wp_safe_redirect( get_comment_link( $comment ) );
331 exit;
332 }
333
334 /*
335 * Skip the actor branch when this looks like an actor-scoped FEP-7aa9
336 * stamp URL: numeric `actor` paired with a `stamp`. Those resolve to a
337 * FeatureAuthorization via Activitypub\Query, not via the username
338 * lookup which would 404 the numeric ID. Non-numeric actors fall
339 * through to the regular Mastodon-style profile lookup.
340 */
341 $actor = \get_query_var( 'actor', null );
342 $is_stamp_url = $actor && \get_query_var( 'stamp' ) && \ctype_digit( (string) $actor );
343 if ( $actor && ! $is_stamp_url ) {
344 $actor = Actors::get_by_username( $actor );
345 if ( ! $actor || \is_wp_error( $actor ) ) {
346 $wp_query->set_404();
347 return;
348 }
349
350 if ( is_activitypub_request() ) {
351 return;
352 }
353
354 \wp_safe_redirect( $actor->get_url(), 301 );
355 exit;
356 }
357
358 $term_id = \get_query_var( 'term_id', null );
359 if ( $term_id ) {
360 $term = \get_term( $term_id );
361
362 // Load a 404-page if `term_id` is set but not valid.
363 if ( ! $term || \is_wp_error( $term ) ) {
364 $wp_query->set_404();
365 return;
366 }
367
368 /**
369 * Filters the taxonomies supported for term redirects.
370 *
371 * @since 7.8.3
372 *
373 * @param array $supported_taxonomies Array of taxonomy names. Default array( 'category', 'post_tag' ).
374 */
375 $supported_taxonomies = \apply_filters( 'activitypub_supported_taxonomies', array( 'category', 'post_tag' ) );
376
377 if ( ! in_array( $term->taxonomy, $supported_taxonomies, true ) ) {
378 return;
379 }
380
381 // Don't redirect for ActivityPub requests.
382 if ( is_activitypub_request() ) {
383 return;
384 }
385
386 $term_link = \get_term_link( $term );
387 if ( ! \is_wp_error( $term_link ) ) {
388 \wp_safe_redirect( $term_link, 301 );
389 exit;
390 }
391 }
392 }
393
394 /**
395 * Add the 'activitypub' query variable so WordPress won't mangle it.
396 *
397 * @param array $vars The query variables.
398 *
399 * @return array The query variables.
400 */
401 public static function add_query_vars( $vars ) {
402 $vars[] = 'activitypub';
403 $vars[] = 'preview';
404 $vars[] = 'author';
405 $vars[] = 'actor';
406 $vars[] = 'stamp';
407 $vars[] = 'type';
408 $vars[] = 'c';
409 $vars[] = 'p';
410 $vars[] = 'term_id';
411
412 return $vars;
413 }
414
415 /**
416 * Optimize home page query for ActivityPub requests.
417 *
418 * Skip the database query entirely for ActivityPub requests on the home page
419 * since we only need to return the blog actor, not posts.
420 *
421 * @param \WP_Query $wp_query The WP_Query instance.
422 */
423 public static function fix_is_home_check( $wp_query ) {
424 if (
425 $wp_query->get( 'actor' ) ||
426 $wp_query->get( 'stamp' ) ||
427 $wp_query->get( 'c' )
428 ) {
429 $wp_query->is_home = false;
430 }
431 }
432 }
433