From aab6989c3488eb1c74732d40bbb4db53c4d2f868 Mon Sep 17 00:00:00 2001 From: selul Date: Tue, 1 Sep 2026 16:29:56 +0300 Subject: [PATCH] fix: skip temporary Cache-Control header on non-cacheable pages While page profiling is pending, replace_content() sent 'Cache-Control: max-age=300' for every not-logged-in request with headers still unsent. Because PHP's header() replaces same-name headers, it overwrote the no-cache header WooCommerce sets on cart, checkout and account pages, letting proxies cache user-specific pages for five minutes. The header is now sent only when DONOTCACHEPAGE is not set and no Cache-Control header exists yet, so a no-cache or longer-lived policy set by WordPress, WooCommerce or a cache plugin is never overridden. The new optml_send_temporary_cache_header filter lets developers override the decision in both directions. Fixes #1082 Co-Authored-By: Claude Fable 5 --- inc/manager.php | 44 ++++++++++++++++- tests/test-cache-header.php | 98 +++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/test-cache-header.php diff --git a/inc/manager.php b/inc/manager.php index ffb713c7..5c0e345b 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -430,6 +430,48 @@ public function register_after_setup() { public static function should_load_profiler( $default_value = false ) { return ! $default_value && apply_filters( 'optml_page_profiler_disable', false ) === false; } + + /** + * Decide if the temporary Cache-Control header can be sent while page profiling is pending. + * + * The header must never be sent on non-cacheable pages: it would replace a + * Cache-Control header already set by WordPress or another plugin (PHP's + * header() replaces same-name headers by default), e.g. WooCommerce's + * no-cache header on cart and checkout, letting proxies cache user-specific + * pages. We back off when DONOTCACHEPAGE is set or when any Cache-Control + * header exists already, and let developers override the decision. + * + * @param array|null $sent_headers Headers already set for the response; defaults to headers_list(). + * @param bool|null $do_not_cache Whether the page is flagged as non-cacheable; defaults to the DONOTCACHEPAGE constant. + * + * @return bool Whether the header can be sent. + */ + public function should_send_temporary_cache_header( $sent_headers = null, $do_not_cache = null ) { + if ( null === $do_not_cache ) { + $do_not_cache = defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE; + } + $send = ! $do_not_cache; + + if ( $send ) { + if ( null === $sent_headers ) { + $sent_headers = headers_list(); + } + foreach ( $sent_headers as $header ) { + if ( stripos( $header, 'cache-control:' ) === 0 ) { + $send = false; + break; + } + } + } + + /** + * Filters whether the temporary `Cache-Control: max-age=300` header is sent + * while page profiling is pending for the current page. + * + * @param bool $send Computed decision: false when DONOTCACHEPAGE is set or a Cache-Control header exists already. + */ + return apply_filters( 'optml_send_temporary_cache_header', $send ) === true; + } /** * Filter raw HTML content for urls. * @@ -461,7 +503,7 @@ public function replace_content( $html, $partial = false ) { $js_optimizer ); $html = str_replace( Optml_Admin::get_optimizer_script( true ), $js_optimizer, $html ); - if ( ! headers_sent() ) { + if ( ! headers_sent() && $this->should_send_temporary_cache_header() ) { header( 'Cache-Control: max-age=300' ); // Attempt to cache the page just for 5 mins until the optimizer is done. Once the optimizer is done, the page will load optimized. } } else { diff --git a/tests/test-cache-header.php b/tests/test-cache-header.php new file mode 100644 index 00000000..d64e071a --- /dev/null +++ b/tests/test-cache-header.php @@ -0,0 +1,98 @@ +assertTrue( $manager->should_send_temporary_cache_header( [], false ) ); + } + + /** + * Unrelated headers do not block it. + */ + public function test_unrelated_headers_do_not_block() { + $manager = Optml_Manager::instance(); + $headers = [ + 'Content-Type: text/html; charset=UTF-8', + 'X-Pingback: http://example.org/xmlrpc.php', + 'Pragma: no-cache', + ]; + $this->assertTrue( $manager->should_send_temporary_cache_header( $headers, false ) ); + } + + /** + * An existing Cache-Control header is never overridden, e.g. WooCommerce's + * nocache_headers() output on cart and checkout pages. + */ + public function test_existing_cache_control_blocks() { + $manager = Optml_Manager::instance(); + $headers = [ + 'Expires: Wed, 11 Jan 1984 05:00:00 GMT', + 'Cache-Control: no-cache, must-revalidate, max-age=0', + ]; + $this->assertFalse( $manager->should_send_temporary_cache_header( $headers, false ) ); + } + + /** + * The Cache-Control match is case-insensitive and value-agnostic — a longer + * max-age set by a cache plugin must not be downgraded either. + */ + public function test_existing_cache_control_case_and_value_agnostic() { + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [ 'cache-control: public, max-age=31536000' ], false ) ); + } + + /** + * DONOTCACHEPAGE blocks the header. + */ + public function test_donotcachepage_blocks() { + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [], true ) ); + } + + /** + * The filter can force the header off. + */ + public function test_filter_can_disable() { + add_filter( 'optml_send_temporary_cache_header', '__return_false' ); + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [], false ) ); + } + + /** + * The filter can force the header on despite blocking signals. + */ + public function test_filter_can_force_enable() { + add_filter( 'optml_send_temporary_cache_header', '__return_true' ); + $manager = Optml_Manager::instance(); + $this->assertTrue( $manager->should_send_temporary_cache_header( [ 'Cache-Control: no-cache' ], true ) ); + } + + /** + * A non-boolean filter return does not accidentally enable the header. + */ + public function test_non_boolean_filter_return_is_not_true() { + add_filter( + 'optml_send_temporary_cache_header', + function () { + return 'yes'; + } + ); + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [], false ) ); + } +}