diff --git a/.github/changelog/1683-from-description b/.github/changelog/1683-from-description new file mode 100644 index 000000000..1c632eb65 --- /dev/null +++ b/.github/changelog/1683-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add LiteSpeed Cache integration to prevent ActivityPub JSON responses from being cached incorrectly. Includes automatic .htaccess rules and Site Health check to ensure proper configuration. diff --git a/includes/functions.php b/includes/functions.php index a853cccae..c597f0ae0 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1828,3 +1828,23 @@ function get_url_authority( $url ) { return $parsed['scheme'] . '://' . $parsed['host']; } + +/** + * Check if a plugin is active, loading plugin.php if necessary. + * + * This is a wrapper around the core is_plugin_active() function that ensures + * the function is available by loading wp-admin/includes/plugin.php if needed. + * This is useful when checking plugin status outside of the admin context. + * + * @param string $plugin Plugin basename (e.g., 'plugin-folder/plugin-file.php'). + * + * @return bool True if the plugin is active, false otherwise. + */ +function is_plugin_active( $plugin ) { + // Include plugin.php if not already loaded (needed for core is_plugin_active). + if ( ! \function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + return \is_plugin_active( $plugin ); +} diff --git a/integration/class-litespeed-cache.php b/integration/class-litespeed-cache.php new file mode 100644 index 000000000..fb5d52847 --- /dev/null +++ b/integration/class-litespeed-cache.php @@ -0,0 +1,227 @@ + +RewriteEngine On +RewriteCond %{HTTP:Accept} application +RewriteRule ^ - [E=Cache-Control:vary=%{ENV:LSCACHE_VARY_VALUE}+isjson] +'; + + /** + * The option name to store the htaccess rules. + * + * @var string + */ + public static $option_name = 'activitypub_litespeed_cache_setup'; + + /** + * The marker to identify the rules in the htaccess file. + * + * @var string + */ + public static $marker = 'ActivityPub LiteSpeed Cache'; + + /** + * The LiteSpeed Cache plugin slug. + * + * @var string + */ + public static $plugin_slug = 'litespeed-cache/litespeed-cache.php'; + + /** + * Initialize the integration. + */ + public static function init() { + // Add rules if LiteSpeed Cache is active and rules aren't set. + if ( is_plugin_active( self::$plugin_slug ) ) { + if ( ! \get_option( self::$option_name ) ) { + self::add_htaccess_rules(); + } + + \add_filter( 'site_status_tests', array( self::class, 'add_site_health_test' ) ); + + // Remove rules if LiteSpeed Cache is not active but rules were previously set. + } elseif ( \get_option( self::$option_name ) ) { + self::remove_htaccess_rules(); + } + + // Clean up when LiteSpeed Cache plugin is deleted. + \add_action( 'deleted_plugin', array( self::class, 'on_plugin_deleted' ) ); + } + + /** + * Clean up htaccess rules when LiteSpeed Cache plugin is deleted. + * + * @param string $plugin_file Path to the plugin file relative to the plugins directory. + */ + public static function on_plugin_deleted( $plugin_file ) { + if ( self::$plugin_slug === $plugin_file && \get_option( self::$option_name ) ) { + self::remove_htaccess_rules(); + } + } + + /** + * Add the LiteSpeed Cache htaccess rules. + */ + public static function add_htaccess_rules() { + $added_rules = self::append_with_markers( self::$marker, self::$rules ); + + if ( $added_rules ) { + \update_option( self::$option_name, '1' ); + } else { + \update_option( self::$option_name, '0' ); + } + } + + /** + * Remove the LiteSpeed Cache htaccess rules. + */ + public static function remove_htaccess_rules() { + self::append_with_markers( self::$marker, '' ); + + \delete_option( self::$option_name ); + } + + /** + * Add the LiteSpeed Cache config test to site health. + * + * @param array $tests The site health tests. + * + * @return array The site health tests with the LiteSpeed Cache config test. + */ + public static function add_site_health_test( $tests ) { + $tests['direct']['activitypub_test_litespeed_cache_integration'] = array( + 'label' => \__( 'LiteSpeed Cache Test', 'activitypub' ), + 'test' => array( self::class, 'test_litespeed_cache_integration' ), + ); + + return $tests; + } + + /** + * Test the LiteSpeed Cache integration. + * + * @return array The test results. + */ + public static function test_litespeed_cache_integration() { + $result = array( + 'label' => \__( 'Compatibility with LiteSpeed Cache', 'activitypub' ), + 'status' => 'good', + 'badge' => array( + 'label' => \__( 'ActivityPub', 'activitypub' ), + 'color' => 'green', + ), + 'description' => \sprintf( + '
%s
', + \__( 'LiteSpeed Cache is well configured to work with ActivityPub.', 'activitypub' ) + ), + 'actions' => '', + 'test' => 'test_litespeed_cache_integration', + ); + + if ( ! \get_option( self::$option_name ) ) { + $result['status'] = 'critical'; + $result['label'] = \__( 'LiteSpeed Cache might not be properly configured.', 'activitypub' ); + $result['badge']['color'] = 'red'; + $result['description'] = \sprintf( + '%s
', + \__( 'LiteSpeed Cache isn’t currently set up to work with ActivityPub. While this isn’t a major problem, it’s a good idea to enable support. Without it, some technical files (like JSON) might accidentally show up in your website’s cache and be visible to visitors.', 'activitypub' ) + ); + $result['actions'] = \sprintf( + '%s
%s', + \__( 'To enable the ActivityPub integration with LiteSpeed Cache, add the following rules to your
.htaccess file:', 'activitypub' ),
+ \esc_html( self::$rules )
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Prepend rules to the top of a file with markers.
+ *
+ * @param string $marker The marker to identify the rules in the file.
+ * @param string $rules The rules to prepend.
+ *
+ * @return bool True on success, false on failure.
+ */
+ private static function append_with_markers( $marker, $rules ) {
+ $htaccess_file = self::get_htaccess_file_path();
+
+ if ( ! \wp_is_writable( $htaccess_file ) ) {
+ return false;
+ }
+
+ // Ensure get_home_path() is declared.
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+
+ global $wp_filesystem;
+ \WP_Filesystem();
+
+ $htaccess = $wp_filesystem->get_contents( $htaccess_file );
+
+ // If marker exists, remove the old block first.
+ if ( strpos( $htaccess, $marker ) !== false ) {
+ // Remove existing marker block.
+ $pattern = '/# BEGIN ' . preg_quote( $marker, '/' ) . '.*?# END ' . preg_quote( $marker, '/' ) . '\r?\n?/s';
+ $htaccess = preg_replace( $pattern, '', $htaccess );
+ $htaccess = trim( $htaccess );
+ }
+
+ // If rules are empty, just return (for removal case).
+ if ( empty( $rules ) ) {
+ return $wp_filesystem->put_contents( $htaccess_file, $htaccess, FS_CHMOD_FILE );
+ }
+
+ // Prepend new rules to the top of the file.
+ $start_marker = "# BEGIN {$marker}";
+ $end_marker = "# END {$marker}";
+
+ $rules = $start_marker . PHP_EOL . $rules . PHP_EOL . $end_marker;
+ $htaccess = $rules . PHP_EOL . PHP_EOL . $htaccess;
+
+ return $wp_filesystem->put_contents( $htaccess_file, $htaccess, FS_CHMOD_FILE );
+ }
+
+ /**
+ * Get the htaccess file.
+ *
+ * @return string|false The htaccess file or false.
+ */
+ private static function get_htaccess_file_path() {
+ $htaccess_file = false;
+
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ if ( @file_exists( \get_home_path() . '.htaccess' ) ) {
+ /** The htaccess file resides in ABSPATH */
+ $htaccess_file = \get_home_path() . '.htaccess';
+ }
+
+ /**
+ * Filter the htaccess file path.
+ *
+ * @param string|false $htaccess_file The htaccess file path.
+ */
+ return \apply_filters( 'activitypub_litespeed_cache_htaccess_file', $htaccess_file );
+ }
+}
diff --git a/integration/class-surge.php b/integration/class-surge.php
index 49002073e..dbc4f72e1 100644
--- a/integration/class-surge.php
+++ b/integration/class-surge.php
@@ -7,6 +7,8 @@
namespace Activitypub\Integration;
+use function Activitypub\is_plugin_active;
+
/**
* Surge Cache integration.
*
@@ -38,7 +40,7 @@ public static function init() {
*/
public static function add_cache_config() {
// Check if surge is installed and active.
- if ( ! \is_plugin_active( 'surge/surge.php' ) ) {
+ if ( ! is_plugin_active( 'surge/surge.php' ) ) {
return;
}
@@ -139,7 +141,7 @@ public static function get_config_file_path() {
* @return array The site health tests with the Surge cache config test.
*/
public static function maybe_add_site_health( $tests ) {
- if ( ! \is_plugin_active( 'surge/surge.php' ) ) {
+ if ( ! is_plugin_active( 'surge/surge.php' ) ) {
return $tests;
}
diff --git a/integration/load.php b/integration/load.php
index 07759319b..1a3d1fab9 100644
--- a/integration/load.php
+++ b/integration/load.php
@@ -146,6 +146,15 @@ function ( $transformer, $data, $object_class ) {
* @see https://wordpress.org/plugins/surge/
*/
Surge::init();
+
+ /**
+ * Load the LiteSpeed Cache integration.
+ *
+ * The check for whether LiteSpeed Cache is loaded and initialized happens inside Litespeed_Cache::init().
+ *
+ * @see https://wordpress.org/plugins/litespeed-cache/
+ */
+ Litespeed_Cache::init();
}
\add_action( 'plugins_loaded', __NAMESPACE__ . '\plugin_init' );
@@ -153,6 +162,11 @@ function ( $transformer, $data, $object_class ) {
\register_activation_hook( ACTIVITYPUB_PLUGIN_FILE, array( __NAMESPACE__ . '\Surge', 'add_cache_config' ) );
\register_deactivation_hook( ACTIVITYPUB_PLUGIN_FILE, array( __NAMESPACE__ . '\Surge', 'remove_cache_config' ) );
+// Register activation and deactivation hooks for LiteSpeed Cache integration.
+\register_activation_hook( ACTIVITYPUB_PLUGIN_FILE, array( __NAMESPACE__ . '\LiteSpeed_Cache', 'add_htaccess_rules' ) );
+\register_deactivation_hook( ACTIVITYPUB_PLUGIN_FILE, array( __NAMESPACE__ . '\LiteSpeed_Cache', 'remove_htaccess_rules' ) );
+
+
/**
* Register the Stream Connector for ActivityPub.
*
diff --git a/tests/phpunit/tests/integration/class-test-litespeed-cache.php b/tests/phpunit/tests/integration/class-test-litespeed-cache.php
new file mode 100644
index 000000000..b315dea14
--- /dev/null
+++ b/tests/phpunit/tests/integration/class-test-litespeed-cache.php
@@ -0,0 +1,302 @@
+htaccess_file = \sys_get_temp_dir() . '/.htaccess-test';
+ $this->original_htaccess = "# BEGIN WordPress\n# END WordPress";
+ // phpcs:ignore
+ \file_put_contents( $this->htaccess_file, $this->original_htaccess );
+ // Patch htaccess file path to use our temp file.
+ \add_filter( 'activitypub_litespeed_cache_htaccess_file', array( $this, 'get_htaccess_file_path' ) );
+ }
+
+ /**
+ * Tear down the test environment.
+ */
+ public function tear_down() {
+ parent::tear_down();
+ if ( \file_exists( $this->htaccess_file ) ) {
+ \wp_delete_file( $this->htaccess_file );
+ }
+ \remove_all_filters( 'activitypub_litespeed_cache_htaccess_file' );
+ }
+
+ /**
+ * Get the htaccess file path for the test environment.
+ *
+ * @return string The htaccess file path.
+ */
+ public function get_htaccess_file_path() {
+ return $this->htaccess_file;
+ }
+
+ /**
+ * Test adding htaccess rules.
+ *
+ * @covers ::add_htaccess_rules
+ * @covers ::append_with_markers
+ * @covers ::get_htaccess_file_path
+ */
+ public function test_add_htaccess_rules() {
+ Litespeed_Cache::add_htaccess_rules();
+ // phpcs:ignore
+ $contents = \file_get_contents( $this->htaccess_file );
+ $this->assertStringContainsString( Litespeed_Cache::$rules, $contents, 'LiteSpeed rules should be present in htaccess' );
+ }
+
+ /**
+ * Test removing htaccess rules.
+ *
+ * @covers ::remove_htaccess_rules
+ * @covers ::append_with_markers
+ */
+ public function test_remove_htaccess_rules() {
+ // First add, then remove.
+ Litespeed_Cache::add_htaccess_rules();
+ Litespeed_Cache::remove_htaccess_rules();
+ // phpcs:ignore
+ $contents = \file_get_contents( $this->htaccess_file );
+ $this->assertStringNotContainsString( Litespeed_Cache::$rules, $contents, 'LiteSpeed rules should be removed from htaccess' );
+ }
+
+ /**
+ * Test no duplicate rules.
+ *
+ * @covers ::add_htaccess_rules
+ * @covers ::append_with_markers
+ */
+ public function test_no_duplicate_rules() {
+ Litespeed_Cache::add_htaccess_rules();
+ Litespeed_Cache::add_htaccess_rules();
+ // phpcs:ignore
+ $contents = \file_get_contents( $this->htaccess_file );
+ // Count number of rule blocks.
+ $rule_count = substr_count( $contents, Litespeed_Cache::$rules );
+ $this->assertEquals( 1, $rule_count, 'LiteSpeed rules should appear only once' );
+ }
+
+ /**
+ * Test that the option is updated when rules are added.
+ *
+ * @covers ::add_htaccess_rules
+ */
+ public function test_option_updated_on_add() {
+ Litespeed_Cache::add_htaccess_rules();
+ $option = \get_option( Litespeed_Cache::$option_name );
+ $this->assertEquals( '1', $option, 'Option should be set to 1 after adding rules' );
+ }
+
+ /**
+ * Test that the option is deleted when rules are removed.
+ *
+ * @covers ::remove_htaccess_rules
+ */
+ public function test_option_deleted_on_remove() {
+ // First add rules to set the option.
+ Litespeed_Cache::add_htaccess_rules();
+ $this->assertNotFalse( \get_option( Litespeed_Cache::$option_name ), 'Option should exist after adding rules' );
+
+ // Then remove rules.
+ Litespeed_Cache::remove_htaccess_rules();
+ $this->assertFalse( \get_option( Litespeed_Cache::$option_name ), 'Option should be deleted after removing rules' );
+ }
+
+ /**
+ * Test Site Health status when properly configured.
+ *
+ * @covers ::test_litespeed_cache_integration
+ */
+ public function test_site_health_when_configured() {
+ // Set up as if rules were added successfully.
+ \update_option( Litespeed_Cache::$option_name, '1' );
+
+ $result = Litespeed_Cache::test_litespeed_cache_integration();
+
+ $this->assertEquals( 'good', $result['status'], 'Status should be good when configured' );
+ $this->assertEquals( 'green', $result['badge']['color'], 'Badge should be green' );
+ $this->assertStringContainsString( 'well configured', $result['description'] );
+ }
+
+ /**
+ * Test Site Health status when not configured.
+ *
+ * @covers ::test_litespeed_cache_integration
+ */
+ public function test_site_health_when_not_configured() {
+ // Ensure option is false (not configured).
+ \delete_option( Litespeed_Cache::$option_name );
+
+ $result = Litespeed_Cache::test_litespeed_cache_integration();
+
+ $this->assertEquals( 'critical', $result['status'], 'Status should be critical when not configured' );
+ $this->assertEquals( 'red', $result['badge']['color'], 'Badge should be red' );
+ $this->assertStringContainsString( 'not be properly configured', $result['label'] );
+ $this->assertStringContainsString( 'add the following rules', $result['actions'] );
+ $this->assertStringContainsString( \esc_html( Litespeed_Cache::$rules ), $result['actions'], 'Actions should contain HTML-escaped rules' );
+ }
+
+ /**
+ * Test write failure handling when htaccess is not writable.
+ *
+ * @covers ::add_htaccess_rules
+ * @covers ::append_with_markers
+ */
+ public function test_write_failure_handling() {
+ // Create a read-only file.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
+ \chmod( $this->htaccess_file, 0444 );
+
+ Litespeed_Cache::add_htaccess_rules();
+
+ // Option should be set to '0' on failure.
+ $option = \get_option( Litespeed_Cache::$option_name );
+ $this->assertEquals( '0', $option, 'Option should be 0 when write fails' );
+
+ // Restore permissions for cleanup.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
+ \chmod( $this->htaccess_file, 0644 );
+ }
+
+ /**
+ * Test that rules are removed when LiteSpeed Cache is deactivated.
+ *
+ * @covers ::init
+ * @covers ::remove_htaccess_rules
+ */
+ public function test_cleanup_when_litespeed_deactivated() {
+ // Simulate rules being previously added.
+ Litespeed_Cache::add_htaccess_rules();
+ $this->assertEquals( '1', \get_option( Litespeed_Cache::$option_name ) );
+
+ // Mock that LiteSpeed is NOT active.
+ \add_filter(
+ 'activitypub_is_plugin_active',
+ function ( $is_active, $plugin ) {
+ if ( Litespeed_Cache::$plugin_slug === $plugin ) {
+ return false;
+ }
+ return $is_active;
+ },
+ 10,
+ 2
+ );
+
+ // Run init (should detect LiteSpeed is deactivated and clean up).
+ Litespeed_Cache::init();
+
+ // Verify cleanup occurred.
+ $this->assertFalse( \get_option( Litespeed_Cache::$option_name ), 'Option should be deleted when LiteSpeed is deactivated' );
+
+ // phpcs:ignore
+ $contents = \file_get_contents( $this->htaccess_file );
+ $this->assertStringNotContainsString( Litespeed_Cache::$rules, $contents, 'Rules should be removed when LiteSpeed is deactivated' );
+
+ \remove_all_filters( 'activitypub_is_plugin_active' );
+ }
+
+ /**
+ * Test that rules are cleaned up when ActivityPub is deactivated.
+ *
+ * @covers \Activitypub\Activitypub::deactivate
+ */
+ public function test_cleanup_on_activitypub_deactivation() {
+ // Add rules first.
+ Litespeed_Cache::add_htaccess_rules();
+ $this->assertEquals( '1', \get_option( Litespeed_Cache::$option_name ) );
+
+ // phpcs:ignore
+ $contents_before = \file_get_contents( $this->htaccess_file );
+ $this->assertStringContainsString( Litespeed_Cache::$rules, $contents_before );
+
+ // Simulate deactivation.
+ \Activitypub\Activitypub::deactivate( false );
+ \do_action( 'deactivate_' . ACTIVITYPUB_PLUGIN_BASENAME );
+
+ // Verify cleanup.
+ $this->assertFalse( \get_option( Litespeed_Cache::$option_name ), 'Option should be deleted on deactivation' );
+
+ // phpcs:ignore
+ $contents_after = \file_get_contents( $this->htaccess_file );
+ $this->assertStringNotContainsString( Litespeed_Cache::$rules, $contents_after, 'Rules should be removed on deactivation' );
+ }
+
+ /**
+ * Test that rules are cleaned up when LiteSpeed Cache plugin is deleted.
+ *
+ * @covers ::on_plugin_deleted
+ */
+ public function test_cleanup_when_litespeed_deleted() {
+ // Add rules first.
+ Litespeed_Cache::add_htaccess_rules();
+ $this->assertEquals( '1', \get_option( Litespeed_Cache::$option_name ) );
+
+ // phpcs:ignore
+ $contents_before = \file_get_contents( $this->htaccess_file );
+ $this->assertStringContainsString( Litespeed_Cache::$rules, $contents_before );
+
+ // Simulate LiteSpeed Cache plugin deletion.
+ \do_action( 'deleted_plugin', Litespeed_Cache::$plugin_slug, false );
+
+ // Verify cleanup.
+ $this->assertFalse( \get_option( Litespeed_Cache::$option_name ), 'Option should be deleted when plugin is deleted' );
+
+ // phpcs:ignore
+ $contents_after = \file_get_contents( $this->htaccess_file );
+ $this->assertStringNotContainsString( Litespeed_Cache::$rules, $contents_after, 'Rules should be removed when plugin is deleted' );
+ }
+
+ /**
+ * Test that rules are NOT cleaned up when a different plugin is deleted.
+ *
+ * @covers ::on_plugin_deleted
+ */
+ public function test_no_cleanup_when_other_plugin_deleted() {
+ // Add rules first.
+ Litespeed_Cache::add_htaccess_rules();
+ $this->assertEquals( '1', \get_option( Litespeed_Cache::$option_name ) );
+
+ // Simulate a different plugin deletion.
+ \do_action( 'deleted_plugin', 'some-other-plugin/plugin.php', false );
+
+ // Verify rules still exist.
+ $this->assertEquals( '1', \get_option( Litespeed_Cache::$option_name ), 'Option should remain when other plugin is deleted' );
+
+ // phpcs:ignore
+ $contents = \file_get_contents( $this->htaccess_file );
+ $this->assertStringContainsString( Litespeed_Cache::$rules, $contents, 'Rules should remain when other plugin is deleted' );
+ }
+}