diff --git a/src/js/hooks/useRestAPI.tsx b/src/js/hooks/useRestAPI.tsx index 6730bc428..94021be36 100644 --- a/src/js/hooks/useRestAPI.tsx +++ b/src/js/hooks/useRestAPI.tsx @@ -1,7 +1,13 @@ import React, { useMemo } from 'react' import axios from 'axios' import { createContextHook } from '../utils/bootstrap' -import { REST_API_AXIOS_CONFIG, applyMethodOverride, applyRestNonce, listenForNonceRefresh } from '../utils/restAPI' +import { + REST_API_AXIOS_CONFIG, + applyCacheBuster, + applyMethodOverride, + applyRestNonce, + listenForNonceRefresh +} from '../utils/restAPI' import type { PropsWithChildren } from 'react' import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' @@ -62,6 +68,7 @@ export const WithRestAPIContext: React.FC = ({ children }) => const instance = axios.create(REST_API_AXIOS_CONFIG) instance.interceptors.request.use(applyRestNonce) instance.interceptors.request.use(applyMethodOverride) + instance.interceptors.request.use(applyCacheBuster) listenForNonceRefresh() return instance }, []) diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index 4600d679f..e65d54b95 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -107,6 +107,21 @@ export const applyRestNonce = (config: InternalAxiosRequestConfig): InternalAxio return config } +/** + * Give every read a unique address. + * + * Page caches and proxies key a stored response on its URL, and some replay it + * whatever the response headers say. The screens re-read the same route after + * each change, so a replayed response shows state from before the change. + */ +export const applyCacheBuster = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + if ('get' === config.method?.toLowerCase()) { + config.params = { ... | undefined>config.params, _: Date.now() } + } + + return config +} + export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = { headers: { 'Access-Control': window.CODE_SNIPPETS?.restAPI.cloud.token diff --git a/src/php/Plugin.php b/src/php/Plugin.php index 9b6c066f3..fcce8bb8a 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -26,6 +26,7 @@ use Code_Snippets\REST_API\Preferences\Demos_Seen_REST_Controller; use Code_Snippets\REST_API\Preferences\Stats_View_Rest_Controller; use Code_Snippets\REST_API\Preferences\Snippet_View_REST_Controller; +use Code_Snippets\REST_API\REST_Cache_Bypass; use Code_Snippets\REST_API\Snippets\Recently_Active_REST_Controller; use Code_Snippets\REST_API\Snippets\Snippets_REST_Controller; use function Code_Snippets\Settings\are_settings_unified; @@ -155,6 +156,8 @@ public function load_plugin() { public function init_rest_api() { $cloud_search = new Cloud_Search_Controller( $this->cloud_connection ); + new REST_Cache_Bypass(); + new Snippets_REST_Controller(); new Recently_Active_REST_Controller(); diff --git a/src/php/REST_API/REST_Cache_Bypass.php b/src/php/REST_API/REST_Cache_Bypass.php new file mode 100644 index 000000000..f664e9000 --- /dev/null +++ b/src/php/REST_API/REST_Cache_Bypass.php @@ -0,0 +1,100 @@ + + */ + public const HEADERS = [ + // Browsers and any cache that follows the HTTP caching rules. + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0, private', + 'Pragma' => 'no-cache', + 'Expires' => 'Wed, 11 Jan 1984 05:00:00 GMT', + // LiteSpeed and OpenLiteSpeed servers, which ignore Cache-Control. + 'X-LiteSpeed-Cache-Control' => 'no-cache', + // Nginx FastCGI and proxy caches. + 'X-Accel-Expires' => '0', + // Varnish, Fastly and other surrogate caches. + 'Surrogate-Control' => 'no-store', + // Cloudflare and CDNs that follow the targeted cache control standard. + 'CDN-Cache-Control' => 'no-store', + 'Cloudflare-CDN-Cache-Control' => 'no-store', + // SiteGround dynamic cache. + 'X-Cache-Enabled' => 'False', + ]; + + /** + * Class constructor. + */ + public function __construct() { + add_filter( 'rest_post_dispatch', [ $this, 'bypass_cache' ], 10, 3 ); + } + + /** + * Mark the response to a request for one of this plugin's routes as uncacheable. + * + * @param WP_HTTP_Response $response Response about to be served. + * @param WP_REST_Server $server REST server instance. + * @param WP_REST_Request $request Request the response answers. + * + * @return WP_HTTP_Response + */ + public function bypass_cache( $response, $server, $request ) { + if ( ! $response instanceof WP_HTTP_Response || ! $request instanceof WP_REST_Request ) { + return $response; + } + + if ( 0 !== strpos( $request->get_route(), '/' . REST_API_NAMESPACE ) ) { + return $response; + } + + /** + * Filters whether responses from the Code Snippets REST API are kept out of page caches. + * + * @param bool $bypass Whether to mark the response as uncacheable. + * @param WP_REST_Request $request Request the response answers. + */ + if ( ! apply_filters( 'code_snippets/rest_api/bypass_cache', true, $request ) ) { + return $response; + } + + foreach ( self::HEADERS as $name => $value ) { + $response->header( $name, $value ); + } + + $this->signal_cache_plugins(); + + return $response; + } + + /** + * Tell caching plugins running inside WordPress not to store the current response. + * + * @return void + */ + private function signal_cache_plugins(): void { + // Honoured by WP Super Cache, W3 Total Cache, WP Rocket, WP Fastest Cache, + // WP-Optimize, Hummingbird, Cache Enabler, Breeze and LiteSpeed Cache. + if ( ! defined( 'DONOTCACHEPAGE' ) ) { + define( 'DONOTCACHEPAGE', true ); + } + + // LiteSpeed Cache decides from its own control API, and caches REST responses by default. + do_action( 'litespeed_control_set_nocache', 'Code Snippets REST API response' ); + } +} diff --git a/tests/unit/REST_API/REST_Cache_Bypass_Test.php b/tests/unit/REST_API/REST_Cache_Bypass_Test.php new file mode 100644 index 000000000..fbef6afd4 --- /dev/null +++ b/tests/unit/REST_API/REST_Cache_Bypass_Test.php @@ -0,0 +1,122 @@ + + */ + public function plugin_routes(): array { + return [ + 'snippets list' => [ '/code-snippets/v1/snippets' ], + 'single snippet' => [ '/code-snippets/v1/snippets/12' ], + 'AI agent generation' => [ '/code-snippets/v1/cloud/ai-agent/generations/34' ], + 'cloud catalogue' => [ '/code-snippets/v1/cloud/catalogue' ], + 'view preference' => [ '/code-snippets/v1/preferences/snippet-view' ], + ]; + } + + /** + * Every plugin route must carry the full set of cache-bypass headers. + * + * @dataProvider plugin_routes + * + * @param string $route Route to request. + * + * @return void + */ + public function test_plugin_routes_send_bypass_headers( string $route ): void { + $headers = $this->serve( $route )->get_headers(); + + foreach ( REST_Cache_Bypass::HEADERS as $name => $value ) { + $this->assertArrayHasKey( $name, $headers, "missing $name on $route" ); + $this->assertSame( $value, $headers[ $name ] ); + } + } + + /** + * Responses to writes are marked as well, so a cache cannot store them either. + * + * @return void + */ + public function test_write_requests_send_bypass_headers(): void { + $headers = $this->serve( '/code-snippets/v1/snippets/12/activate', 'POST' )->get_headers(); + + $this->assertSame( 'no-cache', $headers['X-LiteSpeed-Cache-Control'] ?? null ); + } + + /** + * Caching plugins that run inside WordPress are told not to store the response. + * + * @return void + */ + public function test_plugin_routes_signal_cache_plugins(): void { + $litespeed_calls = did_action( 'litespeed_control_set_nocache' ); + + $this->serve( '/code-snippets/v1/snippets' ); + + $this->assertTrue( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ); + $this->assertSame( $litespeed_calls + 1, did_action( 'litespeed_control_set_nocache' ) ); + } + + /** + * Routes belonging to WordPress or other plugins are left alone. + * + * @return void + */ + public function test_other_routes_are_untouched(): void { + $litespeed_calls = did_action( 'litespeed_control_set_nocache' ); + + foreach ( [ '/wp/v2/posts', '/', '/code-snippets-extra/v1/items' ] as $route ) { + $this->assertSame( [], $this->serve( $route )->get_headers(), "headers added to $route" ); + } + + $this->assertSame( $litespeed_calls, did_action( 'litespeed_control_set_nocache' ) ); + } + + /** + * The bypass can be switched off with a filter. + * + * @return void + */ + public function test_filter_disables_bypass(): void { + add_filter( 'code_snippets/rest_api/bypass_cache', '__return_false' ); + $litespeed_calls = did_action( 'litespeed_control_set_nocache' ); + + $headers = $this->serve( '/code-snippets/v1/snippets' )->get_headers(); + + remove_filter( 'code_snippets/rest_api/bypass_cache', '__return_false' ); + + $this->assertSame( [], $headers ); + $this->assertSame( $litespeed_calls, did_action( 'litespeed_control_set_nocache' ) ); + } +}