PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 All 27 releases
xspeed / includes / class-rest-cache.php

class-rest-cache.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/class-rest-cache.php

269 lines 8.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST response cache.
4 *
5 * The page cache (Cache) bails on REST_REQUEST, so REST responses are
6 * never cached by it. This class adds a separate, opt-in REST cache for
7 * headless / app backends that poll read-only routes:
8 *
9 * - rest_pre_dispatch → serve a fresh cached body (HIT), short-circuit.
10 * - rest_post_dispatch → store a cacheable response (MISS).
11 *
12 * Free never caches REST on its own — it stays inert until an add-on
13 * (xspeed-pro REST cache) flips `xspeed_rest_cache_enabled` and supplies
14 * per-route TTLs via `xspeed_rest_cache_ttl`. The class owns the
15 * safety rules (GET-only, no auth context, 2xx, route allow-list) and
16 * the storage; the add-on owns policy (which routes, how long).
17 *
18 * Storage: one JSON file per entry under XSPEED_CACHE_DIR/rest/, keyed
19 * by md5(route + sorted query params). Purged by Cache::purge_all().
20 *
21 * @package XSpeed
22 */
23
24 namespace XSpeed;
25
26 defined( 'ABSPATH' ) || exit;
27
28 class Rest_Cache {
29
30 /** Subdir of the cache dir holding REST entries. */
31 const SUBDIR = 'rest';
32
33 public function __construct() {
34 // Late on pre_dispatch so permission/auth resolution that other
35 // plugins do on earlier priorities has run; early enough to skip
36 // the actual callback on a HIT. priority 8 < the default 10.
37 add_filter( 'rest_pre_dispatch', array( $this, 'maybe_serve' ), 8, 3 );
38 add_filter( 'rest_post_dispatch', array( $this, 'maybe_store' ), 10, 3 );
39 }
40
41 /**
42 * Master switch. Off by default — Free never caches REST until an
43 * add-on opts in. Also requires the page cache to be enabled (the
44 * REST cache is a facet of caching, not a separate product).
45 */
46 public static function enabled(): bool {
47 $opts = Settings::get();
48 if ( empty( $opts['cache_enabled'] ) ) {
49 return false;
50 }
51 /**
52 * Whether REST response caching is active.
53 *
54 * @param bool $enabled Default false.
55 */
56 return (bool) apply_filters( 'xspeed_rest_cache_enabled', false );
57 }
58
59 /**
60 * Decide whether the current REST request may be cached. Conservative
61 * by design: read-only, anonymous, and not a route an add-on excluded.
62 *
63 * @param \WP_REST_Request $request The REST request.
64 * @return bool
65 */
66 public static function is_cacheable( \WP_REST_Request $request ): bool {
67 if ( ! self::enabled() ) {
68 return false;
69 }
70 if ( 'GET' !== $request->get_method() ) {
71 return false;
72 }
73 // Never cache an authenticated request — the response may be
74 // user-specific. A logged-in cookie, an Authorization header, or
75 // a REST nonce all signal "this could be private".
76 if ( is_user_logged_in() ) {
77 return false;
78 }
79 if ( '' !== (string) $request->get_header( 'authorization' ) ) {
80 return false;
81 }
82 if ( '' !== (string) $request->get_header( 'x_wp_nonce' ) ) {
83 return false;
84 }
85
86 $route = (string) $request->get_route();
87 // Our own admin routes are never cacheable — they're privileged.
88 if ( 0 === strpos( $route, '/xspeed/' ) ) {
89 return false;
90 }
91
92 /**
93 * Final say on whether this REST route is cacheable. An add-on
94 * returning false excludes a route even if the rules above passed.
95 *
96 * @param bool $cacheable Whether to cache this route.
97 * @param string $route The REST route.
98 * @param \WP_REST_Request $request The request.
99 */
100 return (bool) apply_filters( 'xspeed_rest_cache_is_cacheable', true, $route, $request );
101 }
102
103 /**
104 * TTL (seconds) for the current route. Default 0 = don't cache; an
105 * add-on resolves a per-route value via the filter. Without a
106 * listener the REST cache is effectively off even when enabled —
107 * which is the safe default.
108 *
109 * @param \WP_REST_Request $request The request.
110 * @return int Seconds; <= 0 means don't cache.
111 */
112 public static function ttl_for( \WP_REST_Request $request ): int {
113 /**
114 * Resolve the cache TTL in seconds for a REST route.
115 *
116 * @param int $ttl Default 0 (don't cache).
117 * @param string $route The REST route.
118 * @param \WP_REST_Request $request The request.
119 */
120 return (int) apply_filters( 'xspeed_rest_cache_ttl', 0, (string) $request->get_route(), $request );
121 }
122
123 /**
124 * Filter: rest_pre_dispatch. Serve a fresh cached body for a
125 * cacheable request, short-circuiting the dispatch. Returns a
126 * WP_REST_Response on HIT, or the untouched $result on MISS.
127 *
128 * @param mixed $result Dispatch result (null to continue).
129 * @param \WP_REST_Server $server REST server.
130 * @param \WP_REST_Request $request The request.
131 * @return mixed
132 */
133 public function maybe_serve( $result, $server, $request ) {
134 if ( null !== $result || ! ( $request instanceof \WP_REST_Request ) ) {
135 return $result;
136 }
137 if ( ! self::is_cacheable( $request ) ) {
138 return $result;
139 }
140 $ttl = self::ttl_for( $request );
141 if ( $ttl <= 0 ) {
142 return $result;
143 }
144
145 $file = self::file_for( $request );
146 if ( ! file_exists( $file ) ) {
147 return $result;
148 }
149 if ( ( time() - (int) filemtime( $file ) ) > $ttl ) {
150 return $result; // expired — let it re-dispatch + restore.
151 }
152
153 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend REST hit.
154 $data = json_decode( (string) file_get_contents( $file ), true );
155 if ( ! is_array( $data ) || ! array_key_exists( 'body', $data ) ) {
156 return $result;
157 }
158
159 Hit_Counter::record_hit();
160 $response = new \WP_REST_Response( $data['body'], isset( $data['status'] ) ? (int) $data['status'] : 200 );
161 $response->header( 'X-XSpeed-REST-Cache', 'HIT' );
162 return $response;
163 }
164
165 /**
166 * Filter: rest_post_dispatch. Store a cacheable 2xx response.
167 *
168 * @param \WP_REST_Response $response The response.
169 * @param \WP_REST_Server $server REST server.
170 * @param \WP_REST_Request $request The request.
171 * @return \WP_REST_Response
172 */
173 public function maybe_store( $response, $server, $request ) {
174 if ( ! ( $response instanceof \WP_REST_Response ) || ! ( $request instanceof \WP_REST_Request ) ) {
175 return $response;
176 }
177 // Already served from cache → nothing to do.
178 $headers = $response->get_headers();
179 if ( 'HIT' === ( $headers['X-XSpeed-REST-Cache'] ?? '' ) ) {
180 return $response;
181 }
182 if ( ! self::is_cacheable( $request ) ) {
183 return $response;
184 }
185 $ttl = self::ttl_for( $request );
186 if ( $ttl <= 0 ) {
187 return $response;
188 }
189 $status = (int) $response->get_status();
190 if ( $status < 200 || $status >= 300 ) {
191 return $response; // only cache success.
192 }
193
194 $dir = self::dir();
195 if ( ! file_exists( $dir ) ) {
196 wp_mkdir_p( $dir );
197 Cache::write_silence( $dir );
198 }
199
200 $payload = wp_json_encode(
201 array(
202 'body' => $response->get_data(),
203 'status' => $status,
204 )
205 );
206 if ( false !== $payload ) {
207 Hit_Counter::record_miss();
208 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a frontend REST request.
209 file_put_contents( self::file_for( $request ), $payload, LOCK_EX );
210 $response->header( 'X-XSpeed-REST-Cache', 'MISS' );
211 }
212 return $response;
213 }
214
215 /** The REST cache directory. */
216 public static function dir(): string {
217 return XSPEED_CACHE_DIR . '/' . self::SUBDIR;
218 }
219
220 /**
221 * Cache file for a request — md5(route + sorted query params), so
222 * /wp/v2/posts?per_page=5 and ?per_page=10 are distinct but param
223 * order doesn't matter. POST body is irrelevant (GET-only).
224 *
225 * @param \WP_REST_Request $request The request.
226 * @return string
227 */
228 public static function file_for( \WP_REST_Request $request ): string {
229 // Read the query string straight from the URL, not
230 // $request->get_query_params() — WP coerces param types between
231 // rest_pre_dispatch (raw "2") and rest_post_dispatch (sanitized
232 // int 2), which would make the write key differ from the read key
233 // and every HIT miss. The raw query string is identical in both
234 // phases. Parse + sort it so param order doesn't fragment entries.
235 $qs = '';
236 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
237 $pos = strpos( $uri, '?' );
238 if ( false !== $pos ) {
239 parse_str( substr( $uri, $pos + 1 ), $parsed );
240 // Drop WP's own routing param so /wp-json/foo and
241 // /?rest_route=/foo share one entry.
242 unset( $parsed['rest_route'] );
243 ksort( $parsed );
244 $qs = wp_json_encode( $parsed );
245 }
246 $key = md5( (string) $request->get_route() . '?' . $qs );
247 return self::dir() . '/' . $key . '.json';
248 }
249
250 /**
251 * Delete every REST cache entry. Called by Cache::purge_all(). Returns
252 * the count removed.
253 */
254 public static function purge(): int {
255 $dir = self::dir();
256 if ( ! is_dir( $dir ) ) {
257 return 0;
258 }
259 $files = glob( $dir . '/*.json' );
260 if ( ! $files ) {
261 return 0;
262 }
263 foreach ( $files as $f ) {
264 wp_delete_file( $f );
265 }
266 return count( $files );
267 }
268 }
269