From dd8646568b8bfe6e8012d7c27c056f0d09b52a5c Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:12:50 +0200 Subject: [PATCH 1/3] Derive the proxy's cache location from the current uploads directory get_proxy_resources() stored the cache directory's absolute path and URL when the proxy was first used, and never refreshed them (except for a switch to SSL). After moving a site to another host, domain or path, the tracker script was still loaded from the old location: when that server was offline, the (deferred) script held up DOMContentLoaded, so pages never finished loading. Disabling and re-enabling the proxy regenerated the resources, which is why that worked around it. Store only the cache directory's name and derive its path and URL from wp_get_upload_dir() on every request, matching the URL's scheme to the current request (which makes the SSL refresh unnecessary). Existing installs keep their directory (its name is taken from the stored path) and their REST route. When the local file is missing, e.g. after moving the site without its uploads, load the script from Plausible instead of a URL that 404s, and schedule the download right away instead of waiting for the daily run. Also create the cache directory from its path, not its URL, when the cache_url resource is requested. --- readme.txt | 4 ++ src/Helpers.php | 71 ++++++++++++++++++++----------- tests/integration/HelpersTest.php | 52 ++++++++++++++++++++++ 3 files changed, 103 insertions(+), 24 deletions(-) diff --git a/readme.txt b/readme.txt index c5b2d594..634495ec 100644 --- a/readme.txt +++ b/readme.txt @@ -278,6 +278,10 @@ Please make sure you make a backup of your database before updating any version == Changelog == += 2.6.3 = +* Fixed: with the proxy enabled, the locally hosted tracker script kept loading from the site's previous location after moving the site to another host, domain or path. If the old server was offline, pages could hang while waiting for it. The script's location now follows the site automatically. +* Improved: when the locally hosted tracker script is missing (e.g. after moving the site without its uploads), it's loaded from Plausible Analytics until it's downloaded again, which now happens right away instead of on the next daily run. + = 2.6.2 = * *Important!* This release rebuilds the WooCommerce (and EDD) purchase funnel for sites using WPML with a translated product base. If you built your own funnels using the same goals, please rebuild them after installing this update. * Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. diff --git a/src/Helpers.php b/src/Helpers.php index 2b6fcec9..909a3608 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -22,6 +22,11 @@ class Helpers { const MULTILANG_PLUGIN_TRANSLATEPRESS = 'translatepress'; + /** + * @var array|null The proxy resources as stored in the DB, retrieved once per request. @see self::get_proxy_resources() + */ + protected static $stored_proxy_resources; + /** * Returns the language codes of all languages the active multilingual plugin serves to the public. * @@ -401,7 +406,7 @@ public static function get_proxy_resource( $resource_name = '' ) { * Create the cache directory if it doesn't exist. */ if ( ( $resource_name === 'cache_dir' || $resource_name === 'cache_url' ) && ! is_dir( $resources['cache_dir'] ) ) { - wp_mkdir_p( $resources[ $resource_name ] ); + wp_mkdir_p( $resources['cache_dir'] ); } return $resources[ $resource_name ] ?? ''; @@ -410,39 +415,46 @@ public static function get_proxy_resource( $resource_name = '' ) { /** * Get (and generate/store if non-existent) proxy resources. * + * @since 2.6.3 Only the cache directory's name is stored: its path and URL are derived from the current uploads + * directory on every request. They used to be stored as absolute values, so after moving the site to another + * host, domain or path the local tracker script was still loaded from the old location, which stalls every + * page (the script is deferred, so DOMContentLoaded waits for it) when the old server is offline. + * * @return array * @throws Exception * * @codeCoverageIgnore */ public static function get_proxy_resources() { - static $resources; + $stored = &static::$stored_proxy_resources; - if ( $resources === null ) { - $resources = get_option( 'plausible_analytics_proxy_resources', [] ); - } + if ( $stored === null ) { + $stored = get_option( 'plausible_analytics_proxy_resources', [] ); - /** - * Force a refresh of our resources if the user recently switched to SSL and we still have non-SSL resources stored. - */ - if ( ! empty( $resources ) && is_ssl() && isset( $resources['cache_url'] ) && ( strpos( $resources['cache_url'], 'http:' ) !== false ) ) { - $resources = []; - } + // Installs from before 2.6.3 stored the absolute path of the cache directory, of which only the name is kept. + if ( empty( $stored['cache_folder'] ) && ! empty( $stored['cache_dir'] ) ) { + $stored['cache_folder'] = basename( untrailingslashit( $stored['cache_dir'] ) ); + } - if ( empty( $resources ) ) { - $cache_dir = bin2hex( random_bytes( 5 ) ); - $upload_dir = wp_get_upload_dir(); - $resources = [ - 'namespace' => bin2hex( random_bytes( 3 ) ), - 'base' => bin2hex( random_bytes( 2 ) ), - 'endpoint' => bin2hex( random_bytes( 4 ) ), - 'cache_dir' => trailingslashit( $upload_dir['basedir'] ) . trailingslashit( $cache_dir ), - 'cache_url' => trailingslashit( $upload_dir['baseurl'] ) . trailingslashit( $cache_dir ), - ]; + if ( empty( $stored['namespace'] ) || empty( $stored['cache_folder'] ) ) { + $stored = [ + 'namespace' => bin2hex( random_bytes( 3 ) ), + 'base' => bin2hex( random_bytes( 2 ) ), + 'endpoint' => bin2hex( random_bytes( 4 ) ), + 'cache_folder' => bin2hex( random_bytes( 5 ) ), + ]; - update_option( 'plausible_analytics_proxy_resources', $resources ); + update_option( 'plausible_analytics_proxy_resources', $stored ); + } } + $upload_dir = wp_get_upload_dir(); + $resources = $stored; + + $resources['cache_dir'] = trailingslashit( $upload_dir['basedir'] ) . trailingslashit( $stored['cache_folder'] ); + // set_url_scheme() matches the URL to the current request, e.g. after switching the site to SSL. + $resources['cache_url'] = trailingslashit( set_url_scheme( $upload_dir['baseurl'] ) ) . trailingslashit( $stored['cache_folder'] ); + return $resources; } @@ -538,8 +550,19 @@ public static function get_js_url( $local = false ) { */ if ( $local && static::proxy_enabled() ) { /** - * The cache URL is stored as an absolute URL on the main domain, so it needs to be moved to the - * language domain we're currently on. + * The local file may not exist (yet), e.g. right after moving the site to another host without its uploads. + * Load the script from Plausible Analytics until the cron has downloaded it, instead of from a URL that 404s. + */ + if ( ! file_exists( static::get_proxy_resource( 'cache_dir' ) . $file_name . '.js' ) ) { + // Doesn't schedule a duplicate while one is due within 10 minutes. + wp_schedule_single_event( time(), Cron::TASK_NAME ); + + return esc_url( static::get_hosted_domain_url() . "/js/$file_name.js" ); + } + + /** + * The cache URL is built on the main domain, so it needs to be moved to the language domain we're currently + * on. */ return esc_url( static::maybe_use_current_language_domain( static::get_proxy_resource( 'cache_url' ) . $file_name . '.js' ) ); } diff --git a/tests/integration/HelpersTest.php b/tests/integration/HelpersTest.php index 002711e4..468a744d 100644 --- a/tests/integration/HelpersTest.php +++ b/tests/integration/HelpersTest.php @@ -8,6 +8,7 @@ use Exception; use Plausible\Analytics\Tests\TestableHelpers; use Plausible\Analytics\Tests\TestCase; +use Plausible\Analytics\WP\Cron; use Plausible\Analytics\WP\Helpers; use function Brain\Monkey\Functions\when; @@ -237,11 +238,24 @@ public function testGetJsUrl() { try { add_filter( 'plausible_analytics_settings', [ $this, 'enableProxy' ] ); + $local_file = TestableHelpers::get_js_path(); + + // Until the cron has downloaded the local file, the script is loaded from Plausible Analytics. + wp_delete_file( $local_file ); + wp_clear_scheduled_hook( Cron::TASK_NAME ); + + $this->assertEquals( 'https://plausible.io/js/pa-test-tracker-id.js', TestableHelpers::get_js_url( true ) ); + $this->assertNotFalse( wp_next_scheduled( Cron::TASK_NAME ) ); + + file_put_contents( $local_file, '// test' ); + $url = TestableHelpers::get_js_url( true ); $this->assertMatchesRegularExpression( '~http://example.org/wp-content/uploads/.*?/.*?.js~', $url ); } finally { remove_filter( 'plausible_analytics_settings', [ $this, 'enableProxy' ] ); + wp_delete_file( $local_file ?? '' ); + wp_clear_scheduled_hook( Cron::TASK_NAME ); } try { @@ -269,6 +283,44 @@ public function testGetPostSettings() { $this->assertArrayNotHasKey( 'post_test', $settings ); } + /** + * After moving the site to another host, domain or path, the cache directory's path and URL should follow the + * current uploads directory, not the (absolute) ones stored before 2.6.3. + * + * @see Helpers::get_proxy_resources() + * @return void + * @throws Exception + */ + public function testGetProxyResourcesAfterMovingTheSite() { + $stored = new \ReflectionProperty( Helpers::class, 'stored_proxy_resources' ); + $stored->setAccessible( true ); + $backup = get_option( 'plausible_analytics_proxy_resources' ); + + update_option( + 'plausible_analytics_proxy_resources', + [ + 'namespace' => 'abcdef', + 'base' => 'abcd', + 'endpoint' => 'abcdefgh', + 'cache_dir' => '/home/old-host/public_html/wp-content/uploads/0123456789/', + 'cache_url' => 'https://old-host.example/wp-content/uploads/0123456789/', + ] + ); + $stored->setValue( null, null ); + + try { + $upload_dir = wp_get_upload_dir(); + + $this->assertEquals( trailingslashit( $upload_dir['basedir'] ) . '0123456789/', Helpers::get_proxy_resource( 'cache_dir' ) ); + $this->assertEquals( trailingslashit( $upload_dir['baseurl'] ) . '0123456789/', Helpers::get_proxy_resource( 'cache_url' ) ); + // The REST route stays the same. + $this->assertEquals( 'abcdef', Helpers::get_proxy_resource( 'namespace' ) ); + } finally { + update_option( 'plausible_analytics_proxy_resources', $backup ); + $stored->setValue( null, null ); + } + } + /** * @see Helpers::get_proxy_resource() * @return void From 11424e0edb69196b04d3997caba849be85134fef Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:20:14 +0200 Subject: [PATCH 2/3] Read the stored proxy resources without a reference Move reading (and generating) the stored resources into get_stored_proxy_resources() and assign the class property directly, which reads more plainly than a reference to the static property. --- src/Helpers.php | 60 +++++++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/src/Helpers.php b/src/Helpers.php index 909a3608..1559e079 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -426,36 +426,52 @@ public static function get_proxy_resource( $resource_name = '' ) { * @codeCoverageIgnore */ public static function get_proxy_resources() { - $stored = &static::$stored_proxy_resources; + if ( static::$stored_proxy_resources === null ) { + static::$stored_proxy_resources = self::get_stored_proxy_resources(); + } - if ( $stored === null ) { - $stored = get_option( 'plausible_analytics_proxy_resources', [] ); + $resources = static::$stored_proxy_resources; + $upload_dir = wp_get_upload_dir(); - // Installs from before 2.6.3 stored the absolute path of the cache directory, of which only the name is kept. - if ( empty( $stored['cache_folder'] ) && ! empty( $stored['cache_dir'] ) ) { - $stored['cache_folder'] = basename( untrailingslashit( $stored['cache_dir'] ) ); - } + $resources['cache_dir'] = trailingslashit( $upload_dir['basedir'] ) . trailingslashit( $resources['cache_folder'] ); + // set_url_scheme() matches the URL to the current request, e.g. after switching the site to SSL. + $resources['cache_url'] = trailingslashit( set_url_scheme( $upload_dir['baseurl'] ) ) . trailingslashit( $resources['cache_folder'] ); - if ( empty( $stored['namespace'] ) || empty( $stored['cache_folder'] ) ) { - $stored = [ - 'namespace' => bin2hex( random_bytes( 3 ) ), - 'base' => bin2hex( random_bytes( 2 ) ), - 'endpoint' => bin2hex( random_bytes( 4 ) ), - 'cache_folder' => bin2hex( random_bytes( 5 ) ), - ]; + return $resources; + } - update_option( 'plausible_analytics_proxy_resources', $stored ); - } + /** + * Returns the proxy resources stored in the DB, generating (and storing) them when there are none yet. + * + * @since 2.6.3 + * + * @return array + * @throws Exception + * + * @codeCoverageIgnore + */ + private static function get_stored_proxy_resources() { + $stored = get_option( 'plausible_analytics_proxy_resources', [] ); + + // Installs from before 2.6.3 stored the absolute path of the cache directory, of which only the name is kept. + if ( empty( $stored['cache_folder'] ) && ! empty( $stored['cache_dir'] ) ) { + $stored['cache_folder'] = basename( untrailingslashit( $stored['cache_dir'] ) ); } - $upload_dir = wp_get_upload_dir(); - $resources = $stored; + if ( ! empty( $stored['namespace'] ) && ! empty( $stored['cache_folder'] ) ) { + return $stored; + } - $resources['cache_dir'] = trailingslashit( $upload_dir['basedir'] ) . trailingslashit( $stored['cache_folder'] ); - // set_url_scheme() matches the URL to the current request, e.g. after switching the site to SSL. - $resources['cache_url'] = trailingslashit( set_url_scheme( $upload_dir['baseurl'] ) ) . trailingslashit( $stored['cache_folder'] ); + $stored = [ + 'namespace' => bin2hex( random_bytes( 3 ) ), + 'base' => bin2hex( random_bytes( 2 ) ), + 'endpoint' => bin2hex( random_bytes( 4 ) ), + 'cache_folder' => bin2hex( random_bytes( 5 ) ), + ]; - return $resources; + update_option( 'plausible_analytics_proxy_resources', $stored ); + + return $stored; } /** From b2a840fecb43f643e7637f46672e14cb92b67994 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:01:00 +0200 Subject: [PATCH 3/3] Back off the immediate tracker download for 15 minutes While the local tracker file is missing, every request scheduled a download. wp_schedule_single_event() only skips duplicates of a pending event, so if the download keeps failing (e.g. the uploads directory isn't writable), a new attempt was scheduled as soon as the previous one had run. Allow one attempt per 15 minutes. wp_next_scheduled() can't be used, as the daily event uses the same hook. --- src/Helpers.php | 12 ++++++++++-- tests/integration/HelpersTest.php | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Helpers.php b/src/Helpers.php index 1559e079..4e8d3638 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -570,8 +570,16 @@ public static function get_js_url( $local = false ) { * Load the script from Plausible Analytics until the cron has downloaded it, instead of from a URL that 404s. */ if ( ! file_exists( static::get_proxy_resource( 'cache_dir' ) . $file_name . '.js' ) ) { - // Doesn't schedule a duplicate while one is due within 10 minutes. - wp_schedule_single_event( time(), Cron::TASK_NAME ); + /** + * Download it right away, rather than on the next daily run. At most once per 15 minutes: if the + * download keeps failing (e.g. the uploads directory isn't writable), every request would schedule a + * new attempt as soon as the previous one has run. wp_next_scheduled() can't be used for this, as the + * daily event uses the same hook. + */ + if ( ! get_transient( 'plausible_analytics_js_download_attempt' ) ) { + set_transient( 'plausible_analytics_js_download_attempt', 1, 15 * MINUTE_IN_SECONDS ); + wp_schedule_single_event( time(), Cron::TASK_NAME ); + } return esc_url( static::get_hosted_domain_url() . "/js/$file_name.js" ); } diff --git a/tests/integration/HelpersTest.php b/tests/integration/HelpersTest.php index 468a744d..0d439887 100644 --- a/tests/integration/HelpersTest.php +++ b/tests/integration/HelpersTest.php @@ -243,10 +243,17 @@ public function testGetJsUrl() { // Until the cron has downloaded the local file, the script is loaded from Plausible Analytics. wp_delete_file( $local_file ); wp_clear_scheduled_hook( Cron::TASK_NAME ); + delete_transient( 'plausible_analytics_js_download_attempt' ); $this->assertEquals( 'https://plausible.io/js/pa-test-tracker-id.js', TestableHelpers::get_js_url( true ) ); $this->assertNotFalse( wp_next_scheduled( Cron::TASK_NAME ) ); + // Once that attempt has run (and failed), no new one is scheduled until the backoff has expired. + wp_clear_scheduled_hook( Cron::TASK_NAME ); + + $this->assertEquals( 'https://plausible.io/js/pa-test-tracker-id.js', TestableHelpers::get_js_url( true ) ); + $this->assertFalse( wp_next_scheduled( Cron::TASK_NAME ) ); + file_put_contents( $local_file, '// test' ); $url = TestableHelpers::get_js_url( true ); @@ -256,6 +263,7 @@ public function testGetJsUrl() { remove_filter( 'plausible_analytics_settings', [ $this, 'enableProxy' ] ); wp_delete_file( $local_file ?? '' ); wp_clear_scheduled_hook( Cron::TASK_NAME ); + delete_transient( 'plausible_analytics_js_download_attempt' ); } try {