From c6924ef7c2878f52e996fb188f9a9b3b34489d75 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 3 Aug 2026 13:41:17 +0200 Subject: [PATCH 1/3] content-negotiate by accept quality, not json-only --- advanced-cache.php | 87 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/advanced-cache.php b/advanced-cache.php index 33f4ea1..8515810 100644 --- a/advanced-cache.php +++ b/advanced-cache.php @@ -108,27 +108,92 @@ function is_ssl() { return false; } - function client_accepts_only_json() { + /** + * Whether the client's most-preferred acceptable media type is ActivityPub. + * + * This mirrors Activitypub\accept_prefers_activitypub() in the ActivityPub plugin. Both must + * classify a header the same way, or the representation the plugin serves and the one we key on + * can disagree and a JSON body gets replayed to a browser. + * + * The media types are ranked by quality (`;q=`), highest first, ties broken by the order they + * appear. The winner counts as ActivityPub when it is `application/activity+json`, or + * `application/ld+json` carrying the ActivityStreams 2.0 profile. Plain `application/json` and + * bare `application/ld+json` are not. Mastodon sends ActivityPub at q=1 with `text/html;q=0.1` + * as a fallback and must get JSON; a browser lists `text/html` at q=1 and gets html. + * + * A media type with no `q` defaults to 1.0. A `q=0` refuses the type and is ignored. + */ + function client_prefers_activitypub() { if ( ! isset( $_SERVER['HTTP_ACCEPT'] ) ) return false; - $is_json_only = false; + $winner_quality = 0.0; + $winner_is_ap = false; - foreach ( explode( ',', $_SERVER['HTTP_ACCEPT'] ) as $mime_type ) { - if ( false !== $pos = strpos( $mime_type, ';' ) ) - $mime_type = substr( $mime_type, 0, $pos ); + foreach ( explode( ',', $_SERVER['HTTP_ACCEPT'] ) as $part ) { + $segments = explode( ';', $part ); + $mime_type = strtolower( trim( array_shift( $segments ) ) ); - $mime_type = trim( $mime_type ); + if ( '' === $mime_type ) + continue; - if ( '/json' === substr( $mime_type, -5 ) || '+json' === substr( $mime_type, -5 ) ) { - $is_json_only = true; + // Read the quality and profile parameters, which may come in any order. + $quality = 1.0; + $profile = ''; + + foreach ( $segments as $param ) { + $param = trim( $param ); + + if ( 0 === stripos( $param, 'q=' ) ) { + // Only a valid number sets the quality, a malformed `q=` keeps the 1.0 default. + $q_value = trim( substr( $param, 2 ) ); + if ( is_numeric( $q_value ) ) + $quality = (float) $q_value; + } elseif ( 0 === stripos( $param, 'profile=' ) ) { + $profile = strtolower( trim( substr( $param, 8 ), '"' ) ); + } + } + + // A `q=0` means the client refuses this type, so ignore it entirely. + if ( $quality <= 0 ) continue; + + // Highest quality wins, on a tie the earlier type in the header keeps the lead. + if ( $quality > $winner_quality ) { + $winner_quality = $quality; + + // The profile is matched without the scheme so both the http and https URIs work. + $winner_is_ap = 'application/activity+json' === $mime_type + || ( 'application/ld+json' === $mime_type && false !== strpos( $profile, '://www.w3.org/ns/activitystreams' ) ); } + } - return false; + return $winner_is_ap; + } + + /** + * Whether the response we are about to store has a JSON Content-Type. + * + * A response with no Content-Type at all stays cacheable, only an explicit JSON type counts. + * Unlike client_prefers_activitypub() this matches any JSON type, because here we are not + * negotiating anything, just noticing that the body is not the html we keyed it as. + */ + function response_is_json() { + // Casing on the Content-Type header is inconsistent + foreach ( array( 'Content-Type', 'Content-type' ) as $key ) { + if ( ! isset( $this->cache['headers'][ $key ][0] ) ) + continue; + + $mime_type = strtolower( trim( $this->cache['headers'][ $key ][0] ) ); + + if ( false !== $pos = strpos( $mime_type, ';' ) ) + $mime_type = trim( substr( $mime_type, 0, $pos ) ); + + if ( '/json' === substr( $mime_type, -5 ) || '+json' === substr( $mime_type, -5 ) ) + return true; } - return $is_json_only; + return false; } function is_cacheable_origin( $origin ) { @@ -499,7 +564,7 @@ function set_query( $query_string ) { $batcache->keys['ssl'] = true; # Some plugins return html or json based on the Accept value for the same URL. -if ( $batcache->client_accepts_only_json() ) +if ( $batcache->client_prefers_activitypub() ) $batcache->keys['json'] = true; // Recreate the permalink from the URL From b05c3c61bdf2e0457b5417adf0c453682b8e1c85 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 3 Aug 2026 13:41:35 +0200 Subject: [PATCH 2/3] do not cache a json response under an html key --- advanced-cache.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/advanced-cache.php b/advanced-cache.php index 8515810..bfd5e45 100644 --- a/advanced-cache.php +++ b/advanced-cache.php @@ -327,6 +327,14 @@ function ob($output) { unset( $this->cache['headers'][$header] ); } + // The key was decided on the serve path from the Accept header alone, so a plugin that + // negotiates on something else can still hand back JSON under a key that says html. Send it, + // but do not store it. A miss is cheap, replaying a JSON body to the next browser is not. + if ( ! isset( $this->keys['json'] ) && $this->response_is_json() ) { + wp_cache_delete( "{$this->url_key}_genlock", $this->group ); + return $output; + } + foreach ( $this->cache['headers'] as $header => $values ) { // Do not cache if cookies were set if ( strtolower( $header ) === 'set-cookie' ) { From 1a4f4eb3e66e153cb8d26a1dda7f459130f9df29 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 3 Aug 2026 14:21:56 +0200 Subject: [PATCH 3/3] match the content-type header case-insensitively --- advanced-cache.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/advanced-cache.php b/advanced-cache.php index bfd5e45..ac495c6 100644 --- a/advanced-cache.php +++ b/advanced-cache.php @@ -179,12 +179,16 @@ function client_prefers_activitypub() { * negotiating anything, just noticing that the body is not the html we keyed it as. */ function response_is_json() { - // Casing on the Content-Type header is inconsistent - foreach ( array( 'Content-Type', 'Content-type' ) as $key ) { - if ( ! isset( $this->cache['headers'][ $key ][0] ) ) + if ( empty( $this->cache['headers'] ) ) + return false; + + // headers_list() hands the name back with whatever casing it was set with, so compare + // case-insensitively rather than guessing at the spellings. + foreach ( $this->cache['headers'] as $header => $values ) { + if ( 'content-type' !== strtolower( $header ) || ! isset( $values[0] ) ) continue; - $mime_type = strtolower( trim( $this->cache['headers'][ $key ][0] ) ); + $mime_type = strtolower( trim( $values[0] ) ); if ( false !== $pos = strpos( $mime_type, ';' ) ) $mime_type = trim( substr( $mime_type, 0, $pos ) );