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' ); + } +}