PluginProbe
ActivityPub / 8.3.0
ActivityPub v8.3.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 8.3.0, at includes/class-router.php

384 lines 10.2 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
111 if ( Tombstone::exists_local( Query::get_instance()->get_request_url() ) ) {
112 // Set 410 Gone for permanently deleted posts, 200 OK for soft-deleted.
113 if ( ! $activitypub_object ) {
114 \status_header( 410 );
115 }
116
117 return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php';
118 }
119
120 $activitypub_template = false;
121
122 if ( $activitypub_object ) {
123 if ( \get_query_var( 'preview' ) ) {
124 \define( 'ACTIVITYPUB_PREVIEW', true );
125
126 /**
127 * Filter the template used for the ActivityPub preview.
128 *
129 * @param string $activitypub_template Absolute path to the template file.
130 */
131 $activitypub_template = apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
132 } else {
133 $activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php';
134 }
135 }
136
137 /*
138 * Check if the request is authorized.
139 *
140 * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
141 * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
142 */
143 if ( $activitypub_template && use_authorized_fetch() ) {
144 $verification = Signature::verify_http_signature( $_SERVER );
145 if ( \is_wp_error( $verification ) ) {
146 \status_header( 401 );
147
148 // Fallback as template_loader can't return http headers.
149 return $template;
150 }
151 }
152
153 if ( $activitypub_template ) {
154 \set_query_var( 'is_404', false );
155
156 // Check if header already sent.
157 if ( ! \headers_sent() ) {
158 // Send 200 status header.
159 \status_header( 200 );
160 }
161
162 return $activitypub_template;
163 }
164
165 return $template;
166 }
167
168 /**
169 * Add the 'self' link to the header.
170 */
171 public static function add_headers() {
172 $id = Query::get_instance()->get_activitypub_object_id();
173
174 /*
175 * Send CORS headers for resolved ActivityPub objects and outbox
176 * items. Outbox items need CORS even when the object ID doesn't
177 * resolve, because browser preflight requests don't carry the
178 * Authorization header needed to authenticate private items.
179 */
180 $post_id = \get_query_var( 'p' );
181 $is_outbox_url = $post_id && Outbox::POST_TYPE === \get_post_type( $post_id );
182
183 if ( ! \headers_sent() && ( $id || $is_outbox_url ) ) {
184 \header( 'Access-Control-Allow-Origin: *' );
185 \header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
186 \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' );
187 }
188
189 if ( ! $id ) {
190 return;
191 }
192
193 if ( ! \headers_sent() ) {
194 \header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
195
196 if ( \get_option( 'activitypub_vary_header', '1' ) ) {
197 // Send Vary header for Accept header.
198 \header( 'Vary: Accept', false );
199 }
200 }
201
202 \add_action(
203 'wp_head',
204 static function () use ( $id ) {
205 echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . esc_url( $id ) . '" />' . PHP_EOL;
206 }
207 );
208 }
209
210 /**
211 * Remove trailing slash from ActivityPub @username requests.
212 *
213 * @param string $redirect_url The URL to redirect to.
214 * @param string $requested_url The requested URL.
215 *
216 * @return string $redirect_url The possibly-unslashed redirect URL.
217 */
218 public static function no_trailing_redirect( $redirect_url, $requested_url ) {
219 if ( get_query_var( 'actor' ) ) {
220 return $requested_url;
221 }
222
223 return $redirect_url;
224 }
225
226 /**
227 * Add support for `p` and `author` query vars.
228 *
229 * @param string $redirect_url The URL to redirect to.
230 * @param string $requested_url The requested URL.
231 *
232 * @return string $redirect_url
233 */
234 public static function redirect_canonical( $redirect_url, $requested_url ) {
235 if ( ! is_activitypub_request() ) {
236 return $redirect_url;
237 }
238
239 $query = \wp_parse_url( $requested_url, PHP_URL_QUERY );
240
241 if ( ! $query ) {
242 return $redirect_url;
243 }
244
245 $query_params = \wp_parse_args( $query );
246 unset( $query_params['activitypub'] );
247 unset( $query_params['stamp'] );
248
249 if ( 1 !== count( $query_params ) ) {
250 return $redirect_url;
251 }
252
253 if ( isset( $query_params['p'] ) ) {
254 return null;
255 }
256
257 if ( isset( $query_params['author'] ) ) {
258 return null;
259 }
260
261 return $requested_url;
262 }
263
264 /**
265 * Custom redirects for ActivityPub requests.
266 *
267 * @return void
268 */
269 public static function template_redirect() {
270 global $wp_query;
271
272 $comment_id = \get_query_var( 'c', null );
273
274 // Check if it seems to be a comment.
275 if ( $comment_id ) {
276 $comment = \get_comment( $comment_id );
277
278 // Load a 404-page if `c` is set but not valid.
279 if ( ! $comment ) {
280 $wp_query->set_404();
281 return;
282 }
283
284 // Stop if it's not an ActivityPub comment.
285 if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
286 return;
287 }
288
289 \wp_safe_redirect( get_comment_link( $comment ) );
290 exit;
291 }
292
293 $actor = \get_query_var( 'actor', null );
294 if ( $actor ) {
295 $actor = Actors::get_by_username( $actor );
296 if ( ! $actor || \is_wp_error( $actor ) ) {
297 $wp_query->set_404();
298 return;
299 }
300
301 if ( is_activitypub_request() ) {
302 return;
303 }
304
305 \wp_safe_redirect( $actor->get_url(), 301 );
306 exit;
307 }
308
309 $term_id = \get_query_var( 'term_id', null );
310 if ( $term_id ) {
311 $term = \get_term( $term_id );
312
313 // Load a 404-page if `term_id` is set but not valid.
314 if ( ! $term || \is_wp_error( $term ) ) {
315 $wp_query->set_404();
316 return;
317 }
318
319 /**
320 * Filters the taxonomies supported for term redirects.
321 *
322 * @since 7.8.3
323 *
324 * @param array $supported_taxonomies Array of taxonomy names. Default array( 'category', 'post_tag' ).
325 */
326 $supported_taxonomies = \apply_filters( 'activitypub_supported_taxonomies', array( 'category', 'post_tag' ) );
327
328 if ( ! in_array( $term->taxonomy, $supported_taxonomies, true ) ) {
329 return;
330 }
331
332 // Don't redirect for ActivityPub requests.
333 if ( is_activitypub_request() ) {
334 return;
335 }
336
337 $term_link = \get_term_link( $term );
338 if ( ! \is_wp_error( $term_link ) ) {
339 \wp_safe_redirect( $term_link, 301 );
340 exit;
341 }
342 }
343 }
344
345 /**
346 * Add the 'activitypub' query variable so WordPress won't mangle it.
347 *
348 * @param array $vars The query variables.
349 *
350 * @return array The query variables.
351 */
352 public static function add_query_vars( $vars ) {
353 $vars[] = 'activitypub';
354 $vars[] = 'preview';
355 $vars[] = 'author';
356 $vars[] = 'actor';
357 $vars[] = 'stamp';
358 $vars[] = 'type';
359 $vars[] = 'c';
360 $vars[] = 'p';
361 $vars[] = 'term_id';
362
363 return $vars;
364 }
365
366 /**
367 * Optimize home page query for ActivityPub requests.
368 *
369 * Skip the database query entirely for ActivityPub requests on the home page
370 * since we only need to return the blog actor, not posts.
371 *
372 * @param \WP_Query $wp_query The WP_Query instance.
373 */
374 public static function fix_is_home_check( $wp_query ) {
375 if (
376 $wp_query->get( 'actor' ) ||
377 $wp_query->get( 'stamp' ) ||
378 $wp_query->get( 'c' )
379 ) {
380 $wp_query->is_home = false;
381 }
382 }
383 }
384