| @@ -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 ); |
| @@ -41,14 +40,8 @@ | ||
| 41 | 40 | if ( ACTIVITYPUB_DISABLE_REWRITES ) { |
| 42 | 41 | return; |
| 43 | 42 | } |
| 44 | 43 | |
| 45 | - \add_rewrite_rule( | |
| 46 | - '^authorize_interaction/?$', | |
| 47 | - 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/interactions', | |
| 48 | - 'top' | |
| 49 | - ); | |
| 50 | - | |
| 51 | 44 | if ( ! \class_exists( 'Webfinger' ) ) { |
| 52 | 45 | \add_rewrite_rule( |
| 53 | 46 | '^.well-known/webfinger', |
| 54 | 47 | 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger', |
| @@ -63,17 +56,8 @@ | ||
| 63 | 56 | 'top' |
| 64 | 57 | ); |
| 65 | 58 | } |
| 66 | 59 | |
| 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 | 60 | \add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' ); |
| 77 | 61 | \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES ); |
| 78 | 62 | } |
| 79 | 63 | |
| @@ -88,103 +72,36 @@ | ||
| 88 | 72 | if ( \wp_is_serving_rest_request() || \wp_doing_ajax() ) { |
| 89 | 73 | return $template; |
| 90 | 74 | } |
| 91 | 75 | |
| 76 | + self::add_headers(); | |
| 77 | + | |
| 92 | 78 | 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. | |
| 79 | + if ( \get_query_var( 'p' ) && Outbox::POST_TYPE === \get_post_type( \get_query_var( 'p' ) ) ) { | |
| 104 | 80 | \set_query_var( 'is_404', true ); |
| 105 | 81 | \status_header( 406 ); |
| 106 | 82 | } |
| 107 | - | |
| 108 | 83 | return $template; |
| 109 | 84 | } |
| 110 | 85 | |
| 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 | - | |
| 86 | + if ( Tombstone::exists_local( Query::get_instance()->get_request_url() ) ) { | |
| 87 | + \status_header( 410 ); | |
| 142 | 88 | return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php'; |
| 143 | 89 | } |
| 144 | 90 | |
| 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 | 91 | $activitypub_template = false; |
| 92 | + $activitypub_object = Query::get_instance()->get_activitypub_object(); | |
| 164 | 93 | |
| 165 | 94 | if ( $activitypub_object ) { |
| 166 | 95 | if ( \get_query_var( 'preview' ) ) { |
| 167 | - \defined( 'ACTIVITYPUB_PREVIEW' ) || \define( 'ACTIVITYPUB_PREVIEW', true ); | |
| 96 | + \define( 'ACTIVITYPUB_PREVIEW', true ); | |
| 168 | 97 | |
| 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 | 98 | /** |
| 182 | 99 | * Filter the template used for the ActivityPub preview. |
| 183 | 100 | * |
| 184 | 101 | * @param string $activitypub_template Absolute path to the template file. |
| 185 | 102 | */ |
| 186 | - $activitypub_template = \apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' ); | |
| 103 | + $activitypub_template = apply_filters( 'activitypub_preview_template', ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php' ); | |
| 187 | 104 | } else { |
| 188 | 105 | $activitypub_template = ACTIVITYPUB_PLUGIN_DIR . 'templates/activitypub-json.php'; |
| 189 | 106 | } |
| 190 | 107 | } |
| @@ -195,19 +112,8 @@ | ||
| 195 | 112 | * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch |
| 196 | 113 | * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch |
| 197 | 114 | */ |
| 198 | 115 | 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 | 116 | $verification = Signature::verify_http_signature( $_SERVER ); |
| 211 | 117 | if ( \is_wp_error( $verification ) ) { |
| 212 | 118 | \status_header( 401 ); |
| 213 | 119 | |
| @@ -236,29 +142,14 @@ | ||
| 236 | 142 | */ |
| 237 | 143 | public static function add_headers() { |
| 238 | 144 | $id = Query::get_instance()->get_activitypub_object_id(); |
| 239 | 145 | |
| 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 | 146 | if ( ! $id ) { |
| 256 | 147 | return; |
| 257 | 148 | } |
| 258 | 149 | |
| 259 | - if ( ! \headers_sent() ) { | |
| 260 | - \header( 'Link: <' . \esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false ); | |
| 150 | + if ( ! headers_sent() ) { | |
| 151 | + \header( 'Link: <' . esc_url( $id ) . '>; title="ActivityPub (JSON)"; rel="alternate"; type="application/activity+json"', false ); | |
| 261 | 152 | |
| 262 | 153 | if ( \get_option( 'activitypub_vary_header', '1' ) ) { |
| 263 | 154 | // Send Vary header for Accept header. |
| 264 | 155 | \header( 'Vary: Accept', false ); |
| @@ -264,12 +155,12 @@ | ||
| 264 | 155 | \header( 'Vary: Accept', false ); |
| 265 | 156 | } |
| 266 | 157 | } |
| 267 | 158 | |
| 268 | - \add_action( | |
| 159 | + add_action( | |
| 269 | 160 | '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; | |
| 161 | + function () use ( $id ) { | |
| 162 | + echo PHP_EOL . '<link rel="alternate" title="ActivityPub (JSON)" type="application/activity+json" href="' . esc_url( $id ) . '" />' . PHP_EOL; | |
| 272 | 163 | } |
| 273 | 164 | ); |
| 274 | 165 | } |
| 275 | 166 | |
| @@ -281,9 +172,9 @@ | ||
| 281 | 172 | * |
| 282 | 173 | * @return string $redirect_url The possibly-unslashed redirect URL. |
| 283 | 174 | */ |
| 284 | 175 | public static function no_trailing_redirect( $redirect_url, $requested_url ) { |
| 285 | - if ( \get_query_var( 'actor' ) ) { | |
| 176 | + if ( get_query_var( 'actor' ) ) { | |
| 286 | 177 | return $requested_url; |
| 287 | 178 | } |
| 288 | 179 | |
| 289 | 180 | return $redirect_url; |
| @@ -311,9 +202,9 @@ | ||
| 311 | 202 | $query_params = \wp_parse_args( $query ); |
| 312 | 203 | unset( $query_params['activitypub'] ); |
| 313 | 204 | unset( $query_params['stamp'] ); |
| 314 | 205 | |
| 315 | - if ( 1 !== \count( $query_params ) ) { | |
| 206 | + if ( 1 !== count( $query_params ) ) { | |
| 316 | 207 | return $redirect_url; |
| 317 | 208 | } |
| 318 | 209 | |
| 319 | 210 | if ( isset( $query_params['p'] ) ) { |
| @@ -351,22 +242,14 @@ | ||
| 351 | 242 | if ( is_activitypub_request() && ! is_local_comment( $comment ) ) { |
| 352 | 243 | return; |
| 353 | 244 | } |
| 354 | 245 | |
| 355 | - \wp_safe_redirect( \get_comment_link( $comment ) ); | |
| 246 | + \wp_safe_redirect( get_comment_link( $comment ) ); | |
| 356 | 247 | exit; |
| 357 | 248 | } |
| 358 | 249 | |
| 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 ) { | |
| 250 | + $actor = \get_query_var( 'actor', null ); | |
| 251 | + if ( $actor ) { | |
| 369 | 252 | $actor = Actors::get_by_username( $actor ); |
| 370 | 253 | if ( ! $actor || \is_wp_error( $actor ) ) { |
| 371 | 254 | $wp_query->set_404(); |
| 372 | 255 | return; |
| @@ -378,43 +261,8 @@ | ||
| 378 | 261 | |
| 379 | 262 | \wp_safe_redirect( $actor->get_url(), 301 ); |
| 380 | 263 | exit; |
| 381 | 264 | } |
| 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 | 265 | } |
| 418 | 266 | |
| 419 | 267 | /** |
| 420 | 268 | * Add the 'activitypub' query variable so WordPress won't mangle it. |
| @@ -431,9 +279,8 @@ | ||
| 431 | 279 | $vars[] = 'stamp'; |
| 432 | 280 | $vars[] = 'type'; |
| 433 | 281 | $vars[] = 'c'; |
| 434 | 282 | $vars[] = 'p'; |
| 435 | - $vars[] = 'term_id'; | |
| 436 | 283 | |
| 437 | 284 | return $vars; |
| 438 | 285 | } |
| 439 | 286 | |