Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/fix-litespeed-vary
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Fixed LiteSpeed Cache possibly showing visitors the technical ActivityPub version of a page instead of the normal page.
29 changes: 28 additions & 1 deletion integration/class-litespeed-cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace Activitypub\Integration;

use function Activitypub\accept_prefers_activitypub;
use function Activitypub\is_plugin_active;

/**
Expand All @@ -16,14 +17,21 @@
*/
class Litespeed_Cache {

/**
* The `Accept` pattern that puts a request into the JSON cache bucket.
*
* @var string
*/
const ACCEPT_PATTERN = '^[\s,]*(application/activity\+json|application/ld\+json[^,]*activitystreams)';

/**
* The rules to add to the htaccess file.
*
* @var string
*/
public static $rules = '<IfModule LiteSpeed>
RewriteEngine On
RewriteCond %{HTTP:Accept} ^[\s,]*(application/activity\+json|application/ld\+json[^,]*activitystreams) [NC]
RewriteCond %{HTTP:Accept} ' . self::ACCEPT_PATTERN . ' [NC]
RewriteRule ^ - [E=Cache-Control:vary=%{ENV:LSCACHE_VARY_VALUE}+isjson]
</IfModule>';

Expand Down Expand Up @@ -63,6 +71,7 @@ public static function init() {
}

\add_filter( 'site_status_tests', array( self::class, 'add_site_health_test' ) );
\add_action( 'template_redirect', array( self::class, 'maybe_disable_cache' ) );

// Remove rules if LiteSpeed Cache is not active but rules were previously set.
} elseif ( \get_option( self::$option_name ) ) {
Expand All @@ -73,6 +82,24 @@ public static function init() {
\add_action( 'deleted_plugin', array( self::class, 'on_plugin_deleted' ) );
}

/**
* Keep a response out of the cache when its cache bucket and its representation can disagree.
*
* The rewrite rule picks the bucket by whether an ActivityPub type comes first in `Accept`, while
* the plugin picks the representation by quality. When the two disagree, the response could be
* stored in the other representation's bucket, so it is not cached at all.
*
* @return void
*/
public static function maybe_disable_cache() {
$accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? \wp_unslash( $_SERVER['HTTP_ACCEPT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$bucket = (bool) \preg_match( \sprintf( '#%s#i', self::ACCEPT_PATTERN ), $accept );

if ( accept_prefers_activitypub( $accept ) !== $bucket ) {
\do_action( 'litespeed_control_set_nocache', 'ActivityPub: Accept order and quality disagree' );
}
}

/**
* Clean up htaccess rules when LiteSpeed Cache plugin is deleted.
*
Expand Down
36 changes: 36 additions & 0 deletions tests/phpunit/tests/integration/class-test-litespeed-cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,42 @@ public function test_rewrite_condition_matches_accept_prefers_activitypub() {
}
}

/**
* A response is kept out of the cache when the rewrite rule and the plugin disagree on the Accept header.
*
* @covers ::maybe_disable_cache
* @dataProvider accept_cache_provider
*
* @param string $accept The Accept header.
* @param bool $no_cache Whether the response must not be cached.
*/
public function test_maybe_disable_cache( $accept, $no_cache ) {
$_SERVER['HTTP_ACCEPT'] = $accept;
$before = \did_action( 'litespeed_control_set_nocache' );

Litespeed_Cache::maybe_disable_cache();

unset( $_SERVER['HTTP_ACCEPT'] );

$this->assertSame( $no_cache, \did_action( 'litespeed_control_set_nocache' ) > $before );
}

/**
* Accept headers for test_maybe_disable_cache.
*
* @return array[]
*/
public function accept_cache_provider() {
return array(
'activitypub' => array( 'application/activity+json', false ),
'mastodon' => array( 'application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, text/html;q=0.1', false ),
'browser' => array( 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', false ),
'empty' => array( '', false ),
'activitypub wins, listed second' => array( 'text/html;q=0.1, application/activity+json', true ),
'activitypub listed first, loses' => array( 'application/activity+json;q=0.1, text/html', true ),
);
}

/**
* Test that the option is deleted when rules are removed.
*
Expand Down
Loading