| @@ -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. |