PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.11.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.11.0
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
ablocks / includes / classes / page-cache / store.php

store.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.11.0, at includes/classes/page-cache/store.php

637 lines 18.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Classes\PageCache;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 use ABlocks\Classes\FileUpload;
9 use ABlocks\Classes\CacheBackend;
10
11 /**
12 * Page Cache — filesystem layout, reads, writes and purges.
13 *
14 * Files live at:
15 *
16 * uploads/ablocks_uploads/page-cache/{host}/{url-path}/index.html
17 * index.html.gz
18 *
19 * The layout mirrors the URL path rather than hashing it. Hashing would be
20 * simpler here, but nginx cannot compute a hash in a `try_files` rule, so a hash
21 * layout permanently forecloses the server-level tier. Mirroring costs nothing
22 * now and is the only layout all three tiers can share.
23 *
24 * Because nginx matches against its already-decoded `$uri`, the on-disk path
25 * must be the *decoded* URL path — including UTF-8 slugs. That rules out
26 * sanitising by transliteration or hashing unsafe segments; instead we reject
27 * anything that cannot be represented safely. See {@see Store::sanitize_segment()}.
28 *
29 * Writes are direct filesystem calls rather than WP_Filesystem. This is
30 * deliberate: WP_Filesystem cannot do an atomic rename, may fall back to an FTP
31 * transport that prompts for credentials, and requires loading wp-admin includes
32 * on a frontend request. A page cache needs write-then-rename so a concurrent
33 * reader never observes a half-written page. Every path written here is
34 * confined to the plugin's own uploads subdirectory and is validated against it
35 * before use.
36 */
37 class Store {
38
39 const DIR_NAME = 'page-cache';
40 const FILE_NAME = 'index.html';
41
42 /**
43 * Resolved base directory, memoised per request.
44 *
45 * @var string|null
46 */
47 private static $base_dir = null;
48
49 /**
50 * Absolute path to the page-cache root, without a trailing slash.
51 *
52 * @return string
53 */
54 public static function base_dir() {
55 if ( null === self::$base_dir ) {
56 $upload = new FileUpload();
57 self::$base_dir = untrailingslashit( $upload->get_upload_dir() ) . '/' . self::DIR_NAME;
58 }
59 return self::$base_dir;
60 }
61
62 /**
63 * Absolute path to the cache file for a host + URL path, or null when the
64 * request cannot be represented safely on disk.
65 *
66 * @param string $host Host name.
67 * @param string $path URL path (decoded or encoded; decoded here).
68 * @param string $variant Optional variant suffix, e.g. 'mobile'.
69 * @return string|null
70 */
71 public static function file_path( $host, $path, $variant = '' ) {
72 $host = self::sanitize_host( $host );
73 if ( '' === $host ) {
74 return null;
75 }
76
77 $segments = self::sanitize_path( $path );
78 if ( null === $segments ) {
79 return null;
80 }
81
82 $file = self::FILE_NAME;
83 if ( '' !== $variant ) {
84 $variant = preg_replace( '/[^a-z0-9-]/', '', strtolower( $variant ) );
85 if ( '' !== $variant ) {
86 $file = 'index-' . $variant . '.html';
87 }
88 }
89
90 $dir = self::base_dir() . '/' . $host;
91 if ( ! empty( $segments ) ) {
92 $dir .= '/' . implode( '/', $segments );
93 }
94
95 $full = $dir . '/' . $file;
96
97 // Final containment assertion. The segment rules below already make
98 // traversal impossible, but this is the check that actually matters, so
99 // it is enforced independently of them rather than trusted to them.
100 if ( ! self::is_inside_base( $full ) ) {
101 return null;
102 }
103
104 return $full;
105 }
106
107 /**
108 * Cache file path for the current request, or null when not representable.
109 *
110 * @return string|null
111 */
112 public static function current_file_path() {
113 $host = isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '';
114 return self::file_path( is_string( $host ) ? $host : '', Rules::request_path(), self::current_variant() );
115 }
116
117 /**
118 * Variant suffix for the current request ('' or 'mobile').
119 *
120 * @return string
121 */
122 public static function current_variant() {
123 $enabled = (bool) \ABlocks\Helper::get_settings( 'perf_page_cache_mobile_variant', false );
124 if ( ! $enabled ) {
125 return '';
126 }
127 $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) : '';
128 if ( ! is_string( $agent ) || '' === $agent ) {
129 return '';
130 }
131 return preg_match( '/Mobile|Android|iPhone|iPad|iPod|Opera Mini|IEMobile/i', $agent ) ? 'mobile' : '';
132 }
133
134 /**
135 * Write a page to the cache atomically.
136 *
137 * @param string $file Absolute target path from {@see Store::file_path()}.
138 * @param string $html Rendered HTML.
139 * @param bool $gzip Also write a .gz sibling for gzip_static.
140 * @return bool
141 */
142 public static function write( $file, $html, $gzip = true ) {
143 if ( empty( $file ) || ! self::is_inside_base( $file ) ) {
144 return false;
145 }
146
147 // Mirror into a persistent object cache when one exists. On multi-server
148 // setups the filesystem is per-node, so a page written on one server is a
149 // miss on every other; an object cache is shared. Files are still written
150 // regardless, because the nginx tier can only ever serve from disk.
151 self::mirror_to_object_cache( $file, $html );
152
153 $dir = dirname( $file );
154 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
155 return false;
156 }
157
158 self::protect_base_dir();
159
160 if ( ! self::atomic_put( $file, $html ) ) {
161 return false;
162 }
163
164 $gz_file = $file . '.gz';
165 if ( $gzip && function_exists( 'gzencode' ) ) {
166 $encoded = gzencode( $html, 6 );
167 if ( false !== $encoded ) {
168 // A failed .gz write is not fatal — the plain file still serves.
169 self::atomic_put( $gz_file, $encoded );
170 }
171 } elseif ( file_exists( $gz_file ) ) {
172 // Setting turned off after entries were written with a sibling: drop
173 // the stale .gz, or gzip_static would keep serving old content.
174 wp_delete_file( $gz_file );
175 }
176
177 return true;
178 }
179
180 /**
181 * Object-cache key for a cache file path.
182 *
183 * @param string $file Absolute path.
184 * @return string
185 */
186 public static function object_key( $file ) {
187 return 'page_' . md5( wp_normalize_path( (string) $file ) );
188 }
189
190 /**
191 * Copy a rendered page into the object cache, when one is persistent.
192 *
193 * A no-op without Redis/Memcached: storing pages in transients would put
194 * whole HTML documents in `wp_options` and read them back with a database
195 * query, which is slower than the filesystem it is meant to replace.
196 *
197 * @param string $file Absolute path the page was written to.
198 * @param string $html Rendered HTML.
199 * @return bool
200 */
201 private static function mirror_to_object_cache( $file, $html ) {
202 if ( ! CacheBackend::is_persistent() ) {
203 return false;
204 }
205
206 $ttl = (int) \ABlocks\Helper::get_settings( 'perf_page_cache_ttl', 0 );
207 $ttl = $ttl > 0 ? $ttl * HOUR_IN_SECONDS : DAY_IN_SECONDS;
208
209 return CacheBackend::set( self::object_key( $file ), $html, $ttl, false );
210 }
211
212 /**
213 * Atomically write a file anywhere inside the plugin's uploads directory.
214 *
215 * Exposed so sibling features (the style consolidator) share one
216 * write-then-rename implementation rather than each rolling their own. The
217 * containment check is deliberately against the plugin's uploads root rather
218 * than the page-cache subdirectory, since callers legitimately write to
219 * neighbouring directories — but nothing outside that root.
220 *
221 * @param string $file Absolute target path.
222 * @param string $contents Payload.
223 * @return bool
224 */
225 public static function put_file( $file, $contents ) {
226 $upload = new FileUpload();
227 $root = wp_normalize_path( untrailingslashit( $upload->get_upload_dir() ) );
228 $path = wp_normalize_path( (string) $file );
229
230 if ( false !== strpos( $path, '../' ) || 0 !== strpos( $path, $root . '/' ) ) {
231 return false;
232 }
233
234 return self::atomic_put( $file, $contents );
235 }
236
237 /**
238 * Delete one cache entry and its siblings.
239 *
240 * @param string $file Absolute path.
241 * @return bool
242 */
243 public static function delete_file( $file ) {
244 if ( empty( $file ) || ! self::is_inside_base( $file ) ) {
245 return false;
246 }
247 $deleted = false;
248 CacheBackend::delete( self::object_key( $file ) );
249 foreach ( [ $file, $file . '.gz' ] as $target ) {
250 if ( file_exists( $target ) ) {
251 wp_delete_file( $target );
252 $deleted = true;
253 }
254 }
255 return $deleted;
256 }
257
258 /**
259 * Delete every variant cached for a URL.
260 *
261 * @param string $url Absolute or relative URL.
262 * @return bool True when at least one file was removed.
263 */
264 public static function delete_url( $url ) {
265 $parts = wp_parse_url( $url );
266 if ( empty( $parts ) ) {
267 return false;
268 }
269 $host = isset( $parts['host'] ) ? $parts['host'] : ( isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '' );
270 $path = isset( $parts['path'] ) ? $parts['path'] : '/';
271
272 $deleted = false;
273 foreach ( [ '', 'mobile' ] as $variant ) {
274 $file = self::file_path( is_string( $host ) ? $host : '', $path, $variant );
275 if ( $file && self::delete_file( $file ) ) {
276 $deleted = true;
277 }
278 }
279 return $deleted;
280 }
281
282 /**
283 * Remove every cached page.
284 *
285 * @return int Files removed.
286 */
287 public static function flush() {
288 $base = self::base_dir();
289 if ( ! is_dir( $base ) ) {
290 return 0;
291 }
292
293 // Only host directories are removed. Files sitting directly in the base
294 // are the guards (index.php, .htaccess) — and on Apache the .htaccess
295 // carries the tier-3 serve rules, so wiping it on every purge would
296 // quietly demote the site back to PHP-served caching.
297 $removed = 0;
298 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value is checked; a directory vanishing mid-purge is normal and must not warn.
299 $entries = @scandir( $base );
300 if ( false === $entries ) {
301 return 0;
302 }
303
304 foreach ( $entries as $entry ) {
305 if ( '.' === $entry || '..' === $entry ) {
306 continue;
307 }
308 $path = $base . '/' . $entry;
309 if ( is_dir( $path ) && ! is_link( $path ) ) {
310 $removed += self::delete_tree( $path, true );
311 }
312 }
313
314 self::protect_base_dir();
315 return $removed;
316 }
317
318 /**
319 * Delete entries older than the configured TTL.
320 *
321 * @param int $ttl_hours Hours; 0 disables expiry.
322 * @return int Files removed.
323 */
324 public static function prune( $ttl_hours = null ) {
325 if ( null === $ttl_hours ) {
326 $ttl_hours = (int) \ABlocks\Helper::get_settings( 'perf_page_cache_ttl', 0 );
327 }
328 $ttl_hours = (int) $ttl_hours;
329 if ( $ttl_hours <= 0 ) {
330 return 0;
331 }
332
333 $base = self::base_dir();
334 if ( ! is_dir( $base ) ) {
335 return 0;
336 }
337
338 $cutoff = time() - ( $ttl_hours * HOUR_IN_SECONDS );
339 $removed = 0;
340
341 foreach ( self::iterate_files( $base ) as $path ) {
342 if ( '.gz' === substr( $path, -3 ) ) {
343 continue; // Handled with its parent.
344 }
345 if ( filemtime( $path ) < $cutoff ) {
346 $removed += self::delete_file( $path ) ? 1 : 0;
347 }
348 }
349
350 return $removed;
351 }
352
353 /**
354 * Cached-page count and total size on disk.
355 *
356 * @return array{pages:int, files:int, bytes:int}
357 */
358 public static function stats() {
359 $base = self::base_dir();
360 $stats = [
361 'pages' => 0,
362 'files' => 0,
363 'bytes' => 0,
364 ];
365 if ( ! is_dir( $base ) ) {
366 return $stats;
367 }
368 foreach ( self::iterate_files( $base ) as $path ) {
369 $stats['files']++;
370 $stats['bytes'] += (int) filesize( $path );
371 if ( '.html' === substr( $path, -5 ) ) {
372 $stats['pages']++;
373 }
374 }
375 return $stats;
376 }
377
378 /**
379 * Write the directory guards.
380 *
381 * The .htaccess is defence in depth only — nginx ignores it, and nginx is a
382 * first-class target here. The real protection is the rule that we only ever
383 * cache the fully-anonymous view of an already-public URL, so a leaked file
384 * contains exactly what curl on that URL already returns. See Rules.
385 */
386 public static function protect_base_dir() {
387 $base = self::base_dir();
388 if ( ! is_dir( $base ) && ! wp_mkdir_p( $base ) ) {
389 return;
390 }
391
392 $index = $base . '/index.php';
393 if ( ! file_exists( $index ) ) {
394 self::atomic_put( $index, "<?php\n// Silence is golden.\n" );
395 }
396
397 $htaccess = $base . '/.htaccess';
398 if ( ! file_exists( $htaccess ) ) {
399 // Tier 3 on Apache replaces this file with serve rules.
400 self::atomic_put( $htaccess, "# aBlocks page cache — served by PHP.\n<Files \"*.html\">\nRequire all denied\n</Files>\n" );
401 }
402 }
403
404 /**
405 * Write a file atomically: temp file in the same directory, then rename.
406 *
407 * Renaming is atomic within a filesystem, so a concurrent reader sees either
408 * the old file or the new one, never a partial write. The temp file is
409 * created in the destination directory rather than the system temp dir,
410 * because rename() is only atomic when both paths share a filesystem.
411 *
412 * @param string $file Target path.
413 * @param string $contents Payload.
414 * @return bool
415 */
416 private static function atomic_put( $file, $contents ) {
417 $dir = dirname( $file );
418 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
419 return false;
420 }
421
422 $tmp = tempnam( $dir, '.ablocks-cache-' );
423 if ( false === $tmp ) {
424 return false;
425 }
426
427 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents -- WP_Filesystem cannot write-then-rename atomically; see class docblock.
428 $written = file_put_contents( $tmp, $contents, LOCK_EX );
429 if ( false === $written ) {
430 wp_delete_file( $tmp );
431 return false;
432 }
433
434 // tempnam() creates 0600; cache files must be readable by the web server
435 // user when nginx serves them directly (tier 3).
436 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort: a failed chmod is recoverable, and a warning printed here would land inside the HTML being cached.
437 @chmod( $tmp, defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 );
438
439 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value is checked; the warning is suppressed because concurrent writers racing on the same page is normal and must stay silent.
440 if ( ! @rename( $tmp, $file ) ) {
441 wp_delete_file( $tmp );
442 return false;
443 }
444
445 return true;
446 }
447
448 /**
449 * Recursively delete a directory's contents.
450 *
451 * @param string $dir Directory, must be inside the cache base.
452 * @param bool $remove_self Remove $dir itself as well.
453 * @return int Files removed.
454 */
455 private static function delete_tree( $dir, $remove_self = true ) {
456 if ( ! is_dir( $dir ) || ! self::is_inside_base( $dir, true ) ) {
457 return 0;
458 }
459
460 $removed = 0;
461 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value is checked; a directory vanishing mid-purge is normal and must not warn.
462 $entries = @scandir( $dir );
463 if ( false === $entries ) {
464 return 0;
465 }
466
467 foreach ( $entries as $entry ) {
468 if ( '.' === $entry || '..' === $entry ) {
469 continue;
470 }
471 $path = $dir . '/' . $entry;
472 if ( is_link( $path ) ) {
473 // Never follow a symlink out of the cache tree.
474 wp_delete_file( $path );
475 $removed++;
476 continue;
477 }
478 if ( is_dir( $path ) ) {
479 $removed += self::delete_tree( $path, true );
480 continue;
481 }
482 wp_delete_file( $path );
483 $removed++;
484 }
485
486 if ( $remove_self ) {
487 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best effort: a non-empty or already-removed directory is not an error here.
488 @rmdir( $dir );
489 }
490
491 return $removed;
492 }
493
494 /**
495 * Yield every file under a directory.
496 *
497 * @param string $dir Directory.
498 * @return \Generator
499 */
500 private static function iterate_files( $dir ) {
501 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Return value is checked; a directory vanishing mid-purge is normal and must not warn.
502 $entries = @scandir( $dir );
503 if ( false === $entries ) {
504 return;
505 }
506 foreach ( $entries as $entry ) {
507 if ( '.' === $entry || '..' === $entry ) {
508 continue;
509 }
510 $path = $dir . '/' . $entry;
511 if ( is_link( $path ) ) {
512 continue;
513 }
514 if ( is_dir( $path ) ) {
515 yield from self::iterate_files( $path );
516 continue;
517 }
518 if ( '.html' === substr( $path, -5 ) || '.gz' === substr( $path, -3 ) ) {
519 yield $path;
520 }
521 }
522 }
523
524 /**
525 * Normalise a host for use as a directory name.
526 *
527 * HTTP_HOST is attacker-controlled, so this is strict: lowercase, and only
528 * the characters a hostname may legitimately contain. A port is kept (as
529 * `_`-joined) so :8080 dev sites do not collide with production.
530 *
531 * @param string $host Raw host.
532 * @return string Sanitised host, or '' when unusable.
533 */
534 private static function sanitize_host( $host ) {
535 $host = strtolower( trim( (string) $host ) );
536 $host = str_replace( ':', '_', $host );
537 $host = preg_replace( '/[^a-z0-9._\-]/', '', $host );
538 $host = trim( (string) $host, '.-_' );
539 if ( '' === $host || strlen( $host ) > 253 ) {
540 return '';
541 }
542 return $host;
543 }
544
545 /**
546 * Split a URL path into validated directory segments.
547 *
548 * @param string $path URL path.
549 * @return string[]|null Segments, or null when the path is not representable.
550 */
551 private static function sanitize_path( $path ) {
552 $path = (string) $path;
553 $path = (string) strtok( $path, '?' );
554 $path = (string) strtok( $path, '#' );
555
556 $raw = explode( '/', $path );
557 $out = [];
558
559 foreach ( $raw as $segment ) {
560 if ( '' === $segment ) {
561 continue;
562 }
563 $clean = self::sanitize_segment( $segment );
564 if ( null === $clean ) {
565 return null;
566 }
567 $out[] = $clean;
568 }
569
570 // Absurd depth is either an attack or a misconfiguration; either way it
571 // should not create a thousand-deep directory tree.
572 if ( count( $out ) > 20 ) {
573 return null;
574 }
575
576 return $out;
577 }
578
579 /**
580 * Validate one path segment.
581 *
582 * Percent-decoded first, because nginx matches on its decoded $uri and the
583 * on-disk name must agree with it. Anything that could escape the tree or
584 * confuse the filesystem is rejected outright rather than rewritten —
585 * rewriting would silently map two distinct URLs onto one file.
586 *
587 * @param string $segment Raw segment.
588 * @return string|null Validated segment, or null to reject the whole path.
589 */
590 private static function sanitize_segment( $segment ) {
591 $segment = rawurldecode( $segment );
592
593 if ( '' === $segment || '.' === $segment || '..' === $segment ) {
594 return null;
595 }
596 // Null bytes, control characters, separators and Windows-reserved chars.
597 if ( preg_match( '#[\x00-\x1F\x7F/\\\\:*?"<>|]#', $segment ) ) {
598 return null;
599 }
600 // A leading dot would create hidden directories and can shadow guards
601 // such as .htaccess.
602 if ( '.' === $segment[0] ) {
603 return null;
604 }
605 // Individual path components are capped well below the common 255-byte
606 // filesystem limit.
607 if ( strlen( $segment ) > 200 ) {
608 return null;
609 }
610
611 return $segment;
612 }
613
614 /**
615 * Is a path contained by the cache base directory?
616 *
617 * Compares lexically on a normalised path so it works for files that do not
618 * exist yet (realpath() would return false for those).
619 *
620 * @param string $path Candidate path.
621 * @param bool $allow_base Treat the base directory itself as inside.
622 * @return bool
623 */
624 private static function is_inside_base( $path, $allow_base = false ) {
625 $base = wp_normalize_path( self::base_dir() );
626 $path = wp_normalize_path( (string) $path );
627
628 if ( false !== strpos( $path, '../' ) ) {
629 return false;
630 }
631 if ( $allow_base && $path === $base ) {
632 return true;
633 }
634 return 0 === strpos( $path, $base . '/' );
635 }
636 }
637