PluginProbe
ActivityPub / 8.0.2
ActivityPub v8.0.2
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
← All changes | includes/class-router.php +16 -123 9.2.08.0.2 View file →
@@ -19,9 +19,8 @@
19 19 */
20 20 public static function init() {
21 21 \add_action( 'init', array( self::class, 'add_rewrite_rules' ), 11 );
22 22
23 - \add_action( 'send_headers', array( self::class, 'add_headers' ) );
24 23 \add_filter( 'template_include', array( self::class, 'render_activitypub_template' ), 99 );
25 24 \add_action( 'template_redirect', array( self::class, 'template_redirect' ) );
26 25 \add_filter( 'redirect_canonical', array( self::class, 'redirect_canonical' ), 10, 2 );
27 26 \add_filter( 'redirect_canonical', array( self::class, 'no_trailing_redirect' ), 10, 2 );
@@ -63,17 +62,8 @@
63 62 'top'
64 63 );
65 64 }
66 65
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 66 \add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' );
77 67 \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES );
78 68 }
79 69
@@ -88,103 +78,40 @@
88 78 if ( \wp_is_serving_rest_request() || \wp_doing_ajax() ) {
89 79 return $template;
90 80 }
91 81
82 + self::add_headers();
83 +
92 84 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.
85 + if ( \get_query_var( 'p' ) && Outbox::POST_TYPE === \get_post_type( \get_query_var( 'p' ) ) ) {
104 86 \set_query_var( 'is_404', true );
105 87 \status_header( 406 );
106 88 }
107 -
108 89 return $template;
109 90 }
110 91
111 92 $activitypub_object = Query::get_instance()->get_activitypub_object();
112 - $queried_object = Query::get_instance()->get_queried_object();
113 93
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 - ) {
94 + if ( Tombstone::exists_local( Query::get_instance()->get_request_url() ) ) {
137 95 // Set 410 Gone for permanently deleted posts, 200 OK for soft-deleted.
138 96 if ( ! $activitypub_object ) {
139 97 \status_header( 410 );
140 98 }
141 -
142 99 return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php';
143 100 }
144 101
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 102 $activitypub_template = false;
164 103
165 104 if ( $activitypub_object ) {
166 105 if ( \get_query_var( 'preview' ) ) {
167 - \defined( 'ACTIVITYPUB_PREVIEW' ) || \define( 'ACTIVITYPUB_PREVIEW', true );
106 + \define( 'ACTIVITYPUB_PREVIEW', true );
168 107
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 108 /**
182 109 * Filter the template used for the ActivityPub preview.
183 110 *
184 111 * @param string $activitypub_template Absolute path to the template file.
185 112 */
186 - $activitypub_template = \apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
113 + $activitypub_template = apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' );
187 114 } else {
188 115 $activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php';
189 116 }
190 117 }
@@ -195,19 +122,8 @@
195 122 * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
196 123 * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
197 124 */
198 125 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 126 $verification = Signature::verify_http_signature( $_SERVER );
211 127 if ( \is_wp_error( $verification ) ) {
212 128 \status_header( 401 );
213 129
@@ -236,29 +152,14 @@
236 152 */
237 153 public static function add_headers() {
238 154 $id = Query::get_instance()->get_activitypub_object_id();
239 155
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 156 if ( ! $id ) {
256 157 return;
257 158 }
258 159
259 - if ( ! \headers_sent() ) {
260 - \header( 'Link: <' . \esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
160 + if ( ! headers_sent() ) {
161 + \header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false );
261 162
262 163 if ( \get_option( 'activitypub_vary_header', '1' ) ) {
263 164 // Send Vary header for Accept header.
264 165 \header( 'Vary: Accept', false );
@@ -264,12 +165,12 @@
264 165 \header( 'Vary: Accept', false );
265 166 }
266 167 }
267 168
268 - \add_action(
169 + add_action(
269 170 'wp_head',
270 171 static function () use ( $id ) {
271 - echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . \esc_url( $id ) . '" />' . PHP_EOL;
172 + echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . esc_url( $id ) . '" />' . PHP_EOL;
272 173 }
273 174 );
274 175 }
275 176
@@ -281,9 +182,9 @@
281 182 *
282 183 * @return string $redirect_url The possibly-unslashed redirect URL.
283 184 */
284 185 public static function no_trailing_redirect( $redirect_url, $requested_url ) {
285 - if ( \get_query_var( 'actor' ) ) {
186 + if ( get_query_var( 'actor' ) ) {
286 187 return $requested_url;
287 188 }
288 189
289 190 return $redirect_url;
@@ -311,9 +212,9 @@
311 212 $query_params = \wp_parse_args( $query );
312 213 unset( $query_params['activitypub'] );
313 214 unset( $query_params['stamp'] );
314 215
315 - if ( 1 !== \count( $query_params ) ) {
216 + if ( 1 !== count( $query_params ) ) {
316 217 return $redirect_url;
317 218 }
318 219
319 220 if ( isset( $query_params['p'] ) ) {
@@ -351,22 +252,14 @@
351 252 if ( is_activitypub_request() && ! is_local_comment( $comment ) ) {
352 253 return;
353 254 }
354 255
355 - \wp_safe_redirect( \get_comment_link( $comment ) );
256 + \wp_safe_redirect( get_comment_link( $comment ) );
356 257 exit;
357 258 }
358 259
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 ) {
260 + $actor = \get_query_var( 'actor', null );
261 + if ( $actor ) {
369 262 $actor = Actors::get_by_username( $actor );
370 263 if ( ! $actor || \is_wp_error( $actor ) ) {
371 264 $wp_query->set_404();
372 265 return;
@@ -398,9 +291,9 @@
398 291 * @param array $supported_taxonomies Array of taxonomy names. Default array( 'category', 'post_tag' ).
399 292 */
400 293 $supported_taxonomies = \apply_filters( 'activitypub_supported_taxonomies', array( 'category', 'post_tag' ) );
401 294
402 - if ( ! \in_array( $term->taxonomy, $supported_taxonomies, true ) ) {
295 + if ( ! in_array( $term->taxonomy, $supported_taxonomies, true ) ) {
403 296 return;
404 297 }
405 298
406 299 // Don't redirect for ActivityPub requests.