Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/js/hooks/useRestAPI.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -62,6 +68,7 @@ export const WithRestAPIContext: React.FC<PropsWithChildren> = ({ 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
}, [])
Expand Down
15 changes: 15 additions & 0 deletions src/js/utils/restAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = { ...<Record<string, unknown> | undefined>config.params, _: Date.now() }
}

return config
}

export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = {
headers: {
'Access-Control': window.CODE_SNIPPETS?.restAPI.cloud.token
Expand Down
3 changes: 3 additions & 0 deletions src/php/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down
100 changes: 100 additions & 0 deletions src/php/REST_API/REST_Cache_Bypass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace Code_Snippets\REST_API;

use WP_HTTP_Response;
use WP_REST_Request;
use WP_REST_Server;
use const Code_Snippets\REST_API_NAMESPACE;

/**
* Keeps responses from this plugin's REST API routes out of page caches.
*
* Every route reports live state that the admin screens read again after each
* change, so a stored response replayed by a cache shows data that is no longer true.
*/
class REST_Cache_Bypass {

/**
* Headers read by caches that sit in front of WordPress and never run its PHP hooks.
*
* @var array<string, string>
*/
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' );
}
}
122 changes: 122 additions & 0 deletions tests/unit/REST_API/REST_Cache_Bypass_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

namespace Code_Snippets\REST_API;

use Code_Snippets\UnitTestCase;
use WP_REST_Request;
use WP_REST_Response;

/**
* Verify that responses from the plugin's REST routes are marked as uncacheable.
*
* @package Code_Snippets
*/
class REST_Cache_Bypass_Test extends UnitTestCase {

/**
* Run a response through the filters the REST server applies before serving it.
*
* @param string $route Route the request was made to.
* @param string $method HTTP method of the request.
*
* @return WP_REST_Response
*/
private function serve( string $route, string $method = 'GET' ): WP_REST_Response {
// Hooks added by one test are removed before the next, so the server is
// rebuilt to fire rest_api_init again and register the bypass afresh.
$GLOBALS['wp_rest_server'] = null;
$server = rest_get_server();

return apply_filters( 'rest_post_dispatch', new WP_REST_Response( [] ), $server, new WP_REST_Request( $method, $route ) );
}

/**
* Routes of this plugin that the admin screens read repeatedly.
*
* @return array<string, array{0: string}>
*/
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' ) );
}
}
Loading