PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 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 All 33 releases
xspeed / includes / class-rest-cache.php

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

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