PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta.2
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta.2
16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / vendor / wp-php-toolkit / http-client / Middleware / class-cachemiddleware.php
jetpack / vendor / wp-php-toolkit / http-client / Middleware Last commit date
class-cachemiddleware.php 6 days ago class-httpmiddleware.php 6 days ago class-redirectionmiddleware.php 6 days ago middleware-interface.php 6 days ago
class-cachemiddleware.php
554 lines
1 <?php
2
3 namespace WordPress\HttpClient\Middleware;
4
5 use RuntimeException;
6 use WordPress\HttpClient\Client;
7 use WordPress\HttpClient\Request;
8 use WordPress\HttpClient\Response;
9
10 final class CacheMiddleware implements MiddlewareInterface {
11 /**
12 * @var MiddlewareInterface
13 */
14 private $next_middleware;
15
16 /**
17 * @var ClientState
18 */
19 private $state;
20
21 private $dir;
22
23 /** @var array<string,array{req:Request,meta:array,file:resource|null,headerDone:bool,done:bool}> */
24 private $replay = array();
25
26 /**
27 * Writers keyed by spl_object_hash(req).
28 *
29 * @var array
30 */
31 private $temp_handle = array();
32 private $temp_path = array();
33
34 public function __construct( $client_state, $next_middleware, $options = array() ) {
35 $this->next_middleware = $next_middleware;
36 $this->state = $client_state;
37 $this->dir = rtrim( $options['cache_dir'], '/' );
38
39 if ( ! is_dir( $this->dir ) ) {
40 throw new RuntimeException( sprintf( 'Cache dir %s does not exist or is not a directory', esc_html( $this->dir ) ) );
41 }
42 }
43
44 public function enqueue( Request $request ) {
45 $meth = strtoupper( $request->method );
46 if ( ! in_array( $meth, array( 'GET', 'HEAD' ), true ) ) {
47 $this->invalidateCache( $request );
48 return $this->next_middleware->enqueue( $request );
49 }
50
51 [ $key, $meta ] = $this->lookup( $request );
52 $request->cache_key = $key;
53
54 if ( $meta && $this->fresh( $meta ) ) {
55 $this->startReplay( $request, $meta );
56 return;
57 }
58
59 if ( $meta ) {
60 $this->addValidators( $request, $meta );
61 }
62
63 return $this->next_middleware->enqueue( $request );
64 }
65
66 public function await_next_event( $requests_ids ): bool {
67 /* serve cached replay first. */
68 $id = array_keys( $this->replay )[0] ?? null;
69 if ( null !== $id ) {
70 $this->fromCache( $id );
71
72 // When done, close the file handle and clean up the replay entry.
73 if ( $this->replay[ $id ]['done'] ) {
74 fclose( $this->replay[ $id ]['file'] );
75 unset( $this->replay[ $id ] );
76 }
77 return true;
78 }
79
80 /* drive next middleware. */
81 if ( ! $this->next_middleware->await_next_event( $requests_ids ) ) {
82 return false;
83 }
84
85 return $this->handleNetwork();
86 }
87
88 /**
89 * Cache replay functionality.
90 */
91 private function startReplay( Request $request, array $meta ): void {
92 $id = spl_object_hash( $request );
93 $file_handle = fopen( $this->bodyPath( $request->cache_key, $request->url ), 'rb' );
94 $this->replay[ $id ] = array(
95 'req' => $request,
96 'meta' => $meta,
97 'file' => $file_handle,
98 'headerDone' => false,
99 'done' => false,
100 );
101 }
102
103 private function start304Replay( Request $request, array $meta ): void {
104 $id = spl_object_hash( $request );
105 $file_handle = fopen( $this->bodyPath( $request->cache_key, $request->url ), 'rb' );
106 $this->replay[ $id ] = array(
107 'req' => $request,
108 'meta' => $meta,
109 'file' => $file_handle,
110 'headerDone' => false, // Still emit headers for 304 replay.
111 'done' => false,
112 'is304' => true, // Mark as 304 replay.
113 );
114 }
115
116 private function fromCache( string $id ): void {
117 $context =& $this->replay[ $id ];
118 $request = $context['req'];
119
120 if ( ! $context['headerDone'] ) {
121 $resp = new Response( $request );
122 // For 304 replays, return 200 status with cached headers.
123 if ( isset( $context['is304'] ) && $context['is304'] ) {
124 $resp->status_code = 200; // Convert 304 to 200 for the client.
125 } else {
126 $resp->status_code = $context['meta']['status'];
127 }
128 $resp->headers = $context['meta']['headers'];
129
130 $request->response = $resp;
131 $this->state->event = Client::EVENT_GOT_HEADERS;
132 $this->state->request = $request;
133
134 $context['headerDone'] = true;
135 return;
136 }
137
138 $chunk = fread( $context['file'], 64 * 1024 );
139 if ( '' !== $chunk && false !== $chunk ) {
140 $this->state->event = Client::EVENT_BODY_CHUNK_AVAILABLE;
141 $this->state->request = $request;
142 $this->state->response_body_chunk = $chunk;
143 return;
144 }
145
146 $context['done'] = true;
147 $this->state->event = Client::EVENT_FINISHED;
148 $this->state->request = $request;
149
150 // For 304 replays, we don't want to override the response.
151 if ( ! isset( $context['is304'] ) || ! $context['is304'] ) {
152 $request->response = null;
153 }
154 }
155
156 /**
157 * Network handling functionality.
158 */
159 private function handleNetwork(): bool {
160 $event = $this->state->event;
161 $request = $this->state->request;
162 $response = $request->response;
163
164 /* HEADERS. */
165 if ( Client::EVENT_GOT_HEADERS === $event ) {
166 if ( 304 === $response->status_code && isset( $request->cache_key ) ) {
167 [ , $meta ] = $this->lookup( $request, $request->cache_key );
168 if ( $meta ) {
169 // For 304, start a special replay that serves cached body.
170 $this->start304Replay( $request, $meta );
171 return true;
172 }
173 }
174 if ( $this->cacheable( $response ) ) {
175 // Update cache key based on vary headers if present.
176 $vary = $response->get_header( 'Vary' );
177 if ( $vary ) {
178 $vary_keys = array_map( 'trim', explode( ',', $vary ) );
179 $request->cache_key = $this->varyKey( $request, $vary_keys );
180 }
181
182 $tmp = $this->tempPath( $request->cache_key );
183
184 $this->temp_path[ spl_object_hash( $request ) ] = $tmp;
185 $this->temp_handle[ spl_object_hash( $request ) ] = fopen( $tmp, 'wb' );
186 }
187 return true;
188 }
189 /* BODY. */
190 if ( Client::EVENT_BODY_CHUNK_AVAILABLE === $event ) {
191 $chunk = $this->state->response_body_chunk;
192 $hash = spl_object_hash( $request );
193 if ( isset( $this->temp_handle[ $hash ] ) ) {
194 fwrite( $this->temp_handle[ $hash ], $chunk );
195 }
196 return true;
197 }
198 /* FINISH. */
199 if ( Client::EVENT_FINISHED === $event ) {
200 $hash = spl_object_hash( $request );
201 if ( isset( $this->temp_handle[ $hash ] ) ) {
202 fclose( $this->temp_handle[ $hash ] );
203 $this->commit( $request, $response, $this->temp_path[ $hash ] );
204 unset( $this->temp_handle[ $hash ], $this->temp_path[ $hash ] );
205 }
206 return true;
207 }
208
209 return true;
210 }
211
212 /**
213 * Cache utilities functionality.
214 */
215 private function metaPath( string $key, ?string $url = null ): string {
216 if ( $url ) {
217 $url_hash = sha1( $url );
218 return "$this->dir/{$url_hash}_{$key}.json";
219 }
220 return "$this->dir/{$key}.json";
221 }
222
223 private function bodyPath( string $key, ?string $url = null ): string {
224 if ( $url ) {
225 $url_hash = sha1( $url );
226 return "$this->dir/{$url_hash}_{$key}.body";
227 }
228 return "$this->dir/{$key}.body";
229 }
230
231 private function tempPath( string $key ): string {
232 return "$this->dir/{$key}.tmp";
233 }
234
235 private function varyKey( Request $request, ?array $vary_keys ): string {
236 $parts = array( $request->url );
237 if ( $vary_keys ) {
238 // Build a lowercased map of headers for case-insensitive lookup.
239 $header_map = array();
240 foreach ( $request->headers as $k => $v ) {
241 $header_map[ strtolower( $k ) ] = $v;
242 }
243 foreach ( $vary_keys as $header_name ) {
244 $header_lc = strtolower( $header_name );
245 $header_value = $header_map[ $header_lc ] ?? '';
246 $parts[] = $header_lc . ':' . $header_value;
247 }
248 }
249 return sha1( implode( '|', $parts ) );
250 }
251
252 /** @return array{string,array|null} */
253 public function lookup( Request $request, ?string $forced = null ): array {
254 if ( $forced && is_file( $this->metaPath( $forced, $request->url ) ) ) {
255 return array( $forced, json_decode( file_get_contents( $this->metaPath( $forced, $request->url ) ), true ) );
256 }
257
258 // Look for cache files that match this URL.
259 $url_hash = sha1( $request->url );
260 $glob = glob( $this->dir . '/' . $url_hash . '_*.json' );
261 foreach ( $glob as $meta_path ) {
262 $meta = json_decode( file_get_contents( $meta_path ), true );
263 $expected_key = $this->varyKey( $request, $meta['vary'] ?? array() );
264 $actual_filename = basename( $meta_path, '.json' );
265 $expected_filename = $url_hash . '_' . $expected_key;
266
267 if ( $actual_filename === $expected_filename ) {
268 return array( $expected_key, $meta );
269 }
270 }
271
272 return array( $this->varyKey( $request, null ), null );
273 }
274
275 private function fresh( array $meta ): bool {
276 $now = time();
277
278 // Check for must-revalidate directive - if present, never consider fresh without explicit expiry.
279 $cache_control = $meta['headers']['cache-control'] ?? '';
280 $directives = self::directives( $cache_control );
281 if ( isset( $directives['must-revalidate'] ) ) {
282 // With must-revalidate, only consider fresh if we have explicit expiry info.
283 if ( isset( $meta['max_age'] ) && isset( $meta['stored_at'] ) ) {
284 $fresh = ( $meta['stored_at'] + (int) $meta['max_age'] ) > $now;
285 return $fresh;
286 }
287 if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) {
288 $fresh = ( $meta['stored_at'] + (int) $meta['s_maxage'] ) > $now;
289 return $fresh;
290 }
291 if ( isset( $meta['expires'] ) ) {
292 $expires = is_numeric( $meta['expires'] ) ? (int) $meta['expires'] : strtotime( $meta['expires'] );
293 if ( false !== $expires ) {
294 $fresh = $expires > $now;
295 return $fresh;
296 }
297 }
298 // With must-revalidate, don't use heuristic caching.
299 return false;
300 }
301
302 // If explicit expiry timestamp is set, use it.
303 if ( isset( $meta['expires'] ) ) {
304 $expires = is_numeric( $meta['expires'] ) ? (int) $meta['expires'] : strtotime( $meta['expires'] );
305 if ( false !== $expires ) {
306 $fresh = $expires > $now;
307 return $fresh;
308 }
309 }
310
311 // If explicit TTL (absolute timestamp) is set, use it.
312 if ( isset( $meta['ttl'] ) ) {
313 if ( is_numeric( $meta['ttl'] ) ) {
314 $fresh = (int) $meta['ttl'] > $now;
315 return $fresh;
316 }
317 }
318
319 // If s-maxage is set, check if still valid (takes precedence over max-age).
320 if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) {
321 $fresh = ( $meta['stored_at'] + (int) $meta['s_maxage'] ) > $now;
322 return $fresh;
323 }
324
325 // If max_age is set, check if still valid.
326 if ( isset( $meta['max_age'] ) && isset( $meta['stored_at'] ) ) {
327 $fresh = ( $meta['stored_at'] + (int) $meta['max_age'] ) > $now;
328 return $fresh;
329 }
330
331 // Heuristic: if Last-Modified is present, cache for 10% of its age at storage time.
332 if ( isset( $meta['last_modified'] ) && isset( $meta['stored_at'] ) ) {
333 $lm = is_numeric( $meta['last_modified'] ) ? (int) $meta['last_modified'] : strtotime( $meta['last_modified'] );
334 if ( false !== $lm ) {
335 $age = $meta['stored_at'] - $lm;
336 $heuristic_lifetime = (int) max( 0, $age / 10 );
337 $fresh = ( $meta['stored_at'] + $heuristic_lifetime ) > $now;
338 return $fresh;
339 }
340 }
341
342 // Not fresh by any rule.
343 return false;
344 }
345
346 private function cacheable( Response $response ): bool {
347 return self::response_is_cacheable( $response );
348 }
349
350 private function addValidators( Request $request, array $meta ): void {
351 if ( ! empty( $meta['etag'] ) ) {
352 $request->headers['if-none-match'] = $meta['etag'];
353 }
354 if ( ! empty( $meta['last_modified'] ) ) {
355 $request->headers['if-modified-since'] = $meta['last_modified'];
356 }
357 }
358
359 protected function commit( Request $request, Response $response, string $temp_file ) {
360 $url = $request->url;
361 $meta = array(
362 'url' => $url,
363 'status' => $response->status_code,
364 'headers' => $response->headers,
365 'stored_at' => time(),
366 'etag' => $response->get_header( 'ETag' ),
367 'last_modified' => $response->get_header( 'Last-Modified' ),
368 );
369
370 // Check for Vary header and store vary keys.
371 $vary = $response->get_header( 'Vary' );
372 if ( $vary ) {
373 $meta['vary'] = array_map( 'trim', explode( ',', $vary ) );
374 }
375
376 // Parse Cache-Control for max-age, if present.
377 $cache_control = $response->get_header( 'Cache-Control' );
378 if ( $cache_control ) {
379 $directives = self::directives( $cache_control );
380 if ( isset( $directives['max-age'] ) && is_int( $directives['max-age'] ) ) {
381 $meta['max_age'] = $directives['max-age'];
382 }
383 if ( isset( $directives['s-maxage'] ) && is_int( $directives['s-maxage'] ) ) {
384 $meta['s_maxage'] = $directives['s-maxage'];
385 }
386 }
387
388 // Determine file paths.
389 $key = $request->cache_key;
390 $body_file = $this->bodyPath( $key, $url );
391 $meta_file = $this->metaPath( $key, $url );
392
393 // Atomically replace/rename the temp body file to final cache file.
394 if ( ! rename( $temp_file, $body_file ) ) {
395 // Handle error (e.g., log failure and abort caching).
396 return;
397 }
398
399 // Write metadata with exclusive lock.
400 $fp = fopen( $meta_file, 'c' );
401 if ( $fp ) {
402 flock( $fp, LOCK_EX );
403 ftruncate( $fp, 0 );
404 // Serialize or encode CacheEntry (e.g., JSON).
405 $meta_data = json_encode( $meta );
406 fwrite( $fp, $meta_data );
407 fflush( $fp );
408 flock( $fp, LOCK_UN );
409 fclose( $fp );
410 }
411 }
412
413 public function invalidateCache( Request $request ): void {
414 // Generate cache key if not already set.
415 if ( ! isset( $request->cache_key ) ) {
416 [ $key, ] = $this->lookup( $request );
417 $request->cache_key = $key;
418 }
419
420 $key = $request->cache_key;
421 $body_file = $this->bodyPath( $key, $request->url );
422 $meta_file = $this->metaPath( $key, $request->url );
423
424 // Optionally, acquire lock on meta file to prevent concurrent writes.
425 $fp = @fopen( $meta_file, 'c' );
426 if ( $fp ) {
427 flock( $fp, LOCK_EX );
428 }
429
430 // Delete cache files if they exist.
431 @unlink( $body_file );
432 if ( 'Windows' === PHP_OS_FAMILY && PHP_VERSION_ID < 70300 && isset( $fp ) && $fp ) {
433 // On Windows, PHP 7.2 or earlier can't "unlink()" files with handles in use.
434 // In that case, we need to first release the lock, and then unlink the file.
435 flock( $fp, LOCK_UN );
436 fclose( $fp );
437 unset( $fp );
438 }
439 @unlink( $meta_file );
440
441 // Also remove any temp files for this entry.
442 foreach ( glob( $body_file . '.tmp*' ) as $tmp ) {
443 @unlink( $tmp );
444 }
445 if ( isset( $fp ) && $fp ) {
446 flock( $fp, LOCK_UN );
447 fclose( $fp );
448 }
449 }
450
451
452 /** Return ['no-store'=>true, 'max-age'=>60, …] */
453 public static function directives( ?string $value ): array {
454 if ( null === $value ) {
455 return array();
456 }
457 $out = array();
458
459 // Handle quoted values properly by not splitting on commas inside quotes.
460 $parts = array();
461 $current = '';
462 $in_quotes = false;
463 $quote_char = null;
464
465 for ( $i = 0; $i < strlen( $value ); $i++ ) {
466 $char = $value[ $i ];
467
468 if ( ! $in_quotes && ( '"' === $char || "'" === $char ) ) {
469 $in_quotes = true;
470 $quote_char = $char;
471 $current .= $char;
472 } elseif ( $in_quotes && $char === $quote_char ) {
473 $in_quotes = false;
474 $quote_char = null;
475 $current .= $char;
476 } elseif ( ! $in_quotes && ',' === $char ) {
477 $parts[] = trim( $current );
478 $current = '';
479 } else {
480 $current .= $char;
481 }
482 }
483
484 if ( '' !== $current ) {
485 $parts[] = trim( $current );
486 }
487
488 foreach ( $parts as $part ) {
489 $part = trim( $part );
490 if ( '' === $part ) {
491 continue;
492 }
493 if ( false !== strpos( $part, '=' ) ) {
494 [ $k, $v ] = array_map( 'trim', explode( '=', $part, 2 ) );
495 $out[ strtolower( $k ) ] = ctype_digit( $v ) ? (int) $v : $v;
496 } else {
497 $out[ strtolower( $part ) ] = true;
498 }
499 }
500
501 return $out;
502 }
503
504 public static function response_is_cacheable( Response $r ): bool {
505 $req = $r->request;
506 if ( 'GET' !== $req->method && 'HEAD' !== $req->method ) {
507 return false;
508 }
509
510 // Allow caching of successful responses and redirects.
511 if ( ! ( ( $r->status_code >= 200 && $r->status_code < 300 ) || ( $r->status_code >= 300 && $r->status_code < 400 ) ) ) {
512 return false;
513 }
514
515 $d = self::directives( $r->get_header( 'cache-control' ) );
516 if ( isset( $d['no-store'] ) ) {
517 return false;
518 }
519
520 // Check for explicit freshness indicators, but also validate they're not expired.
521 if ( isset( $d['max-age'] ) ) {
522 // Don't cache responses with max-age=0.
523 if ( is_int( $d['max-age'] ) && $d['max-age'] <= 0 ) {
524 return false;
525 }
526 return true;
527 }
528
529 if ( isset( $d['s-maxage'] ) ) {
530 // Don't cache responses with s-maxage=0.
531 if ( is_int( $d['s-maxage'] ) && $d['s-maxage'] <= 0 ) {
532 return false;
533 }
534 return true;
535 }
536
537 if ( $r->get_header( 'expires' ) ) {
538 // Check if expires header indicates an already expired response.
539 $expires = strtotime( $r->get_header( 'expires' ) );
540 if ( false !== $expires && $expires <= time() ) {
541 return false; // Don't cache already expired responses.
542 }
543 return true;
544 }
545
546 // Cache responses with validation headers (ETag or Last-Modified).
547 if ( $r->get_header( 'etag' ) || $r->get_header( 'last-modified' ) ) {
548 return true;
549 }
550
551 return false;
552 }
553 }
554