PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 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 All 35 releases
xspeed / includes / class-listing-pages.php

class-listing-pages.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/class-listing-pages.php

1,505 lines 51.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Listing_Pages: which cached pages ran a post list of their own, and which
4 * posts each one showed.
5 *
6 * @package XSpeed
7 */
8
9 namespace XSpeed;
10
11 defined( 'ABSPATH' ) || exit;
12
13 /**
14 * Records, as a page renders, the post lists it ran outside the main loop,
15 * so a narrow purge can clear the pages a save may have changed.
16 *
17 * Affected_Pages names the pages the rules know: archives, feeds,
18 * neighbours. It cannot see a list a page builder, a block plugin, a
19 * related-posts plugin or a theme's own PHP draws with a WP_Query of its
20 * own: an Elementor or Essential Addons post grid, an Essential Blocks,
21 * Spectra or Kadence post block. This class watches `pre_get_posts`, which
22 * fires for every query, even under `suppress_filters` (where `the_posts`
23 * does not), and sorts what it saw at the end of the request.
24 *
25 * - A plain newest-N list (newest first by date, of whole post types, with
26 * no filter but a few excluded posts) shows the same posts on every page.
27 * It is kept once per site as a spec, `{types, n, sticky}`, and
28 * Affected_Pages::lists_changed_by() judges it like a Recent Posts widget:
29 * a change to one of the newest N clears the whole site, any other change
30 * clears nothing for it. A page whose only lists are specs gets no record.
31 * - Any other list gives the page a record, one file per URL: the types it
32 * listed, the IDs it showed, and whether a plain edit of a post it does
33 * not show can change it ("precise").
34 * - A precise record's IDs go into an inverse index, one file per post ID
35 * listing the pages that showed it, so a plain edit reads one file.
36 * - Every record counts toward its types, and the URLs of each type are
37 * kept while there are no more than Affected_Pages::LIMIT of them, with
38 * the URLs of the records that are not precise kept apart. A change that
39 * is not plain, or a list that is not precise, needs every page of the
40 * type: over the limit that is a site-wide purge, read from a count
41 * rather than a scan.
42 *
43 * A save reads a handful of small files and never every record. Records are
44 * written only when they change, never on a cache hit, and never to the
45 * database; a page view costs a file read at most. All writes to the index
46 * happen under one lock per blog (flock on `.lock`), and every file is
47 * written atomically. Purges keep the index: a purge that does not reach a
48 * cache in front would otherwise leave pages there that nothing records.
49 */
50 final class Listing_Pages {
51
52 /** Most non-main queries kept per request. */
53 public const MAX_QUERIES = 200;
54
55 /** Most post IDs kept per page. Past it, the record is not precise. */
56 public const MAX_IDS = 100;
57
58 /** Most page records per blog, for disk use. */
59 public const CAP = 50000;
60
61 /** Most newest-N specs per blog. Past it, a new one is recorded per page. */
62 public const SPEC_CAP = 50;
63
64 /** Largest N kept as a spec. A longer list is recorded per page. */
65 public const MAX_SPEC_N = 100;
66
67 /** Most excluded posts a newest-N list may have. */
68 private const MAX_EXCLUDED = 5;
69
70 /**
71 * Version of the page record format, stored as `v`. Raised whenever the
72 * rules for what a render records change, so a record written by an
73 * earlier build reads as different and the page's next render replaces
74 * it. Without it, a page whose record an older build had marked
75 * imprecise kept that record for as long as nothing else changed.
76 */
77 public const RECORD_VERSION = 2;
78
79 /** Marker file written in the blog's directory when a page was refused. */
80 public const FULL_MARKER = 'full';
81
82 /** Subdirectory of XSPEED_CACHE_DIR. */
83 private const SUBDIR = 'listings';
84
85 /** Longest path recorded. */
86 private const MAX_PATH = 2000;
87
88 /**
89 * Post types that are never a post list a visitor sees: menus,
90 * templates, styles, patterns, attachments, revisions and the like.
91 */
92 private const IGNORED_TYPES = array(
93 'nav_menu_item',
94 'wp_template',
95 'wp_template_part',
96 'wp_global_styles',
97 'wp_navigation',
98 'wp_block',
99 'attachment',
100 'revision',
101 'customize_changeset',
102 'oembed_cache',
103 'user_request',
104 'wp_font_family',
105 'wp_font_face',
106 'elementor_library',
107 );
108
109 /**
110 * Query vars that pick posts by something other than type and date. Any
111 * of them set, and the list is not a plain newest-N list.
112 */
113 private const FILTER_VARS = array(
114 'p', 'page_id', 'name', 'pagename', 'attachment', 'attachment_id', 'subpost', 'subpost_id',
115 'post__in', 'post_name__in', 'post_parent__in', 'post_parent__not_in',
116 'author', 'author_name', 'author__in', 'author__not_in',
117 'cat', 'category_name', 'category__in', 'category__and', 'category__not_in',
118 'tag', 'tag_id', 'tag__in', 'tag__and', 'tag__not_in', 'tag_slug__in', 'tag_slug__and',
119 'tax_query', 'taxonomy', 'term',
120 's', 'meta_key', 'meta_value', 'meta_value_num', 'meta_query',
121 'date_query', 'year', 'monthnum', 'day', 'w', 'm',
122 'title', 'post_mime_type', 'post_password', 'comment_status', 'ping_status', 'comment_count',
123 'nopaging',
124 );
125
126 /** Query vars where 0 is a filter, not "unset". */
127 private const ZERO_IS_SET_VARS = array( 'hour', 'minute', 'second', 'menu_order' );
128
129 /**
130 * Non-main queries seen this request, keyed by object id.
131 *
132 * @var array<int,\WP_Query>
133 */
134 private static $queries = array();
135
136 /** More queries ran than MAX_QUERIES. @var bool */
137 private static $overflow = false;
138
139 /** Per-request answer of enabled(), once a query asked. @var bool|null */
140 private static $active = null;
141
142 /** Per-request answer of ignored_types(). @var string[]|null */
143 private static $ignored = null;
144
145 /** Per-request read of the specs, for a save. @var array<int,array>|null */
146 private static $specs = null;
147
148 public static function boot(): void {
149 if ( ! function_exists( 'add_action' ) || ! self::request_may_record() ) {
150 return;
151 }
152 add_action( 'pre_get_posts', array( __CLASS__, 'note_query' ), PHP_INT_MAX );
153 // After WordPress has flushed the output buffers (priority 1), when
154 // the page and its status are final.
155 add_action( 'shutdown', array( __CLASS__, 'record' ), 1000, 0 );
156 }
157
158 /**
159 * Whether pages may be recorded and read at all: narrow purge is on, and
160 * `xspeed_listing_pages_enabled` agrees.
161 */
162 public static function enabled(): bool {
163 $opts = Settings_Manager::get( 'cache' );
164 if ( empty( $opts['purge_affected_only'] ) ) {
165 return false;
166 }
167 /**
168 * Filter whether pages that run a post list outside the main loop
169 * are recorded and cleared by a narrow purge.
170 *
171 * Only asked while "Clear only the pages a change affects" is on.
172 * Return false to clear only the pages the rules name, as before.
173 *
174 * @param bool $enabled Default true.
175 */
176 return (bool) apply_filters( 'xspeed_listing_pages_enabled', true );
177 }
178
179 // --- Render side ------------------------------------------------------
180
181 /**
182 * Keep a non-main query until the end of the request, when its results
183 * are known.
184 *
185 * @param \WP_Query $query Query about to run.
186 */
187 public static function note_query( $query ): void {
188 if ( ! $query instanceof \WP_Query || $query->is_main_query() ) {
189 return;
190 }
191 if ( null === self::$active ) {
192 self::$active = self::enabled();
193 }
194 if ( ! self::$active ) {
195 return;
196 }
197 // A query for menus or templates only is not a list; skipping it
198 // here keeps them out of the count.
199 $types = $query->get( 'post_type' );
200 if ( '' !== $types && array() !== $types && array() === array_diff( (array) $types, self::ignored_types() ) ) {
201 return;
202 }
203 $id = spl_object_id( $query );
204 if ( ! isset( self::$queries[ $id ] ) && count( self::$queries ) >= self::MAX_QUERIES ) {
205 self::$overflow = true;
206 return;
207 }
208 self::$queries[ $id ] = $query;
209 }
210
211 /**
212 * Write, update or delete this page's record and the specs it ran, once
213 * the response is final.
214 */
215 public static function record(): void {
216 if ( null === self::$active ) {
217 // No query asked yet: there is no list to record, only a stale
218 // record to drop, and that needs the setting too.
219 self::$active = self::enabled();
220 }
221 if ( ! self::$active || ! self::request_may_record() ) {
222 return;
223 }
224 $status = http_response_code();
225 if ( ( 404 === $status || 410 === $status ) && Cache::query_has_only_ignored_params() ) {
226 // The page is gone, and the save that removed it cleared its
227 // cached copies. Without this its record would stay forever.
228 // Not with a query string: `/page/?p=999` is a 404 while
229 // `/page/` is not.
230 $url = self::current_url();
231 if ( '' !== $url ) {
232 foreach ( array( '', '|m', '|d' ) as $device ) {
233 self::forget_page( md5( $url . $device ) );
234 }
235 }
236 return;
237 }
238 if ( ! self::response_may_record() ) {
239 return;
240 }
241 $url = self::current_url();
242 if ( '' === $url ) {
243 return;
244 }
245 // A page that never reached its footer stopped early: what ran is a
246 // part of the page, so it may only add to the record.
247 $complete = function_exists( 'did_action' ) && did_action( 'wp_footer' ) > 0;
248 self::store( $url, self::summarise( self::$queries, self::$overflow ), $complete );
249 }
250
251 /**
252 * What a set of queries listed: the newest-N specs among them, each with
253 * the record it falls back to when the specs are full, and the record
254 * for every other list. Null when none of them is a post list.
255 *
256 * @param array<int,\WP_Query> $queries Non-main queries that ran.
257 * @param bool $overflow More ran than were kept.
258 * @return array{specs:array<int,array{spec:array{types:string[],n:int,sticky:bool},list:array{types:string[],ids:int[],precise:bool}}>,record:array{types:string[],ids:int[],precise:bool}|null}|null
259 */
260 public static function summarise( array $queries, bool $overflow = false ): ?array {
261 $specs = array();
262 $lists = array();
263 $ignored = self::ignored_types();
264
265 foreach ( $queries as $query ) {
266 if ( ! $query instanceof \WP_Query ) {
267 continue;
268 }
269 $types = self::listing_types( $query, $ignored );
270 if ( array() === $types ) {
271 continue;
272 }
273 $shown = self::returned_ids( $query );
274 $list = array(
275 'types' => $types,
276 'ids' => null === $shown ? array() : $shown,
277 'precise' => null !== $shown && self::query_is_precise( $query ),
278 );
279 $spec = self::recent_spec( $query, $types );
280 if ( null !== $spec ) {
281 $specs[] = array(
282 'spec' => $spec,
283 'list' => self::merge( array( $list ) ),
284 );
285 } else {
286 $lists[] = $list;
287 }
288 }
289
290 if ( $overflow ) {
291 // The queries past the limit could list anything.
292 $lists[] = array(
293 'types' => array( 'any' ),
294 'ids' => array(),
295 'precise' => false,
296 );
297 }
298 if ( array() === $specs && array() === $lists ) {
299 return null;
300 }
301 return array(
302 'specs' => $specs,
303 'record' => array() === $lists ? null : self::merge( $lists ),
304 );
305 }
306
307 /**
308 * The spec of a plain newest-N list, or null when the query is anything
309 * else.
310 *
311 * Plain: newest first by date (the default order counts), of whole post
312 * types, published posts only, with no taxonomy, author, search, meta,
313 * date, parent, slug or hand-picked filter. A list that excludes a few
314 * posts (the current one, usually) widens N by that many, as does an
315 * offset or a later page. Such a list holds the newest N of its types on
316 * every page that runs it, so it changes only when the newest N do.
317 *
318 * @param \WP_Query $query Query, after it ran.
319 * @param string[] $types Its listing types.
320 * @return array{types:string[],n:int,sticky:bool}|null
321 */
322 public static function recent_spec( \WP_Query $query, array $types ): ?array {
323 if ( array() === $types ) {
324 return null;
325 }
326 $vars = is_array( $query->query_vars ) ? $query->query_vars : array();
327
328 foreach ( self::FILTER_VARS as $key ) {
329 if ( ! empty( $vars[ $key ] ) ) {
330 return null;
331 }
332 }
333 foreach ( self::ZERO_IS_SET_VARS as $key ) {
334 if ( isset( $vars[ $key ] ) && '' !== $vars[ $key ] && array() !== $vars[ $key ] ) {
335 return null;
336 }
337 }
338 if ( isset( $vars['post_parent'] ) && is_numeric( $vars['post_parent'] ) ) {
339 return null;
340 }
341 if ( isset( $vars['has_password'] ) && null !== $vars['has_password'] ) {
342 return null;
343 }
344 foreach ( array( 'tax_query', 'meta_query' ) as $parsed ) {
345 if ( isset( $query->$parsed ) && is_object( $query->$parsed ) && ! empty( $query->$parsed->queries ) ) {
346 return null;
347 }
348 }
349 if ( ! empty( $query->date_query ) ) {
350 return null;
351 }
352 $status = $vars['post_status'] ?? '';
353 if ( '' !== $status && 'publish' !== $status && array( 'publish' ) !== $status ) {
354 return null;
355 }
356
357 // Newest first by date.
358 $orderby = $vars['orderby'] ?? '';
359 $order = strtoupper( is_scalar( $vars['order'] ?? '' ) ? (string) ( $vars['order'] ?? '' ) : '' );
360 if ( is_array( $orderby ) ) {
361 if ( array() === $orderby ) {
362 $orderby = '';
363 } elseif ( 1 !== count( $orderby ) || ! in_array( strtolower( (string) key( $orderby ) ), array( 'date', 'post_date' ), true ) ) {
364 return null;
365 } else {
366 $order = strtoupper( (string) current( $orderby ) );
367 $orderby = 'date';
368 }
369 }
370 if ( ! is_scalar( $orderby ) || ! in_array( strtolower( trim( (string) $orderby ) ), array( '', 'date', 'post_date' ), true ) ) {
371 return null;
372 }
373 if ( '' !== $order && 'DESC' !== $order ) {
374 return null;
375 }
376
377 $per_page = (int) ( $vars['posts_per_page'] ?? 0 );
378 if ( $per_page < 1 ) {
379 return null;
380 }
381 $offset = isset( $vars['offset'] ) && is_numeric( $vars['offset'] ) ? (int) $vars['offset'] : 0;
382 $paged = max( 1, (int) ( $vars['paged'] ?? 1 ) );
383 $n = $offset > 0 ? $offset + $per_page : $paged * $per_page;
384
385 $excluded = $vars['post__not_in'] ?? array();
386 $excluded = is_array( $excluded ) ? array_filter( $excluded ) : array();
387 if ( count( $excluded ) > self::MAX_EXCLUDED ) {
388 return null;
389 }
390 $n += count( $excluded );
391 if ( $n > self::MAX_SPEC_N ) {
392 return null;
393 }
394
395 sort( $types );
396 return array(
397 'types' => array_values( $types ),
398 'n' => $n,
399 'sticky' => self::puts_sticky_first( $query, $types ),
400 );
401 }
402
403 /**
404 * The post types a query listed, without the ignored and non-viewable
405 * ones: `any`, or a list of type slugs. Empty when it listed none a
406 * visitor sees.
407 *
408 * Read after the query ran. An empty `post_type` resolves the way
409 * WP_Query resolves it: a custom taxonomy query lists every type that
410 * shares the taxonomy (counted as `any`), an attachment or page lookup
411 * lists that type, and anything else lists `post`.
412 *
413 * @param \WP_Query $query Query.
414 * @param string[] $ignored Ignored types.
415 * @return string[]
416 */
417 private static function listing_types( \WP_Query $query, array $ignored ): array {
418 $asked = $query->get( 'post_type' );
419 if ( '' === $asked || array() === $asked || null === $asked ) {
420 if ( ! empty( $query->is_tax ) ) {
421 $asked = 'any';
422 } elseif ( ! empty( $query->is_attachment ) ) {
423 $asked = 'attachment';
424 } elseif ( ! empty( $query->is_page ) ) {
425 $asked = 'page';
426 } else {
427 $asked = 'post';
428 }
429 }
430 $kept = array();
431 foreach ( (array) $asked as $type ) {
432 $type = (string) $type;
433 if ( 'any' === $type ) {
434 return array( 'any' );
435 }
436 if ( '' === $type || in_array( $type, $ignored, true ) || ! is_post_type_viewable( $type ) ) {
437 continue;
438 }
439 $kept[] = $type;
440 }
441 return array_values( array_unique( $kept ) );
442 }
443
444 /**
445 * Whether a plain edit of a post this list does not show can never put
446 * it there: the list is ordered by date or ID (or the default order),
447 * and picks its posts by nothing a plain edit can change.
448 *
449 * Not precise: a search, a meta query or meta order, a title, modified
450 * or comment-count order, a filter on the modified date, a password or
451 * comment-status filter, and a list of posts that puts sticky posts
452 * first (making a post sticky is not a field is_plain_edit() compares).
453 * A random order is precise; see the order check below.
454 *
455 * A lookup by slug (`name`, `pagename`, `post_name__in`) is precise. A
456 * slug change is the one plain edit that can move a post into or out of
457 * it, and pages_for() treats a slug change as not plain, so it reaches
458 * every page of the type. Counting it imprecise made one empty lookup
459 * that a plugin runs on every page (QA on #675, issue 4) turn every page
460 * record imprecise, and every plain edit cleared them all.
461 *
462 * @param \WP_Query $query Query.
463 */
464 public static function query_is_precise( \WP_Query $query ): bool {
465 $vars = is_array( $query->query_vars ) ? $query->query_vars : array();
466
467 $search = $vars['s'] ?? '';
468 if ( ! is_scalar( $search ) || '' !== trim( (string) $search ) ) {
469 return false;
470 }
471 foreach ( array( 'meta_query', 'meta_key', 'meta_value' ) as $key ) {
472 if ( isset( $vars[ $key ] ) && '' !== $vars[ $key ] && array() !== $vars[ $key ] ) {
473 return false;
474 }
475 }
476 foreach ( array( 'post_password', 'comment_status', 'ping_status', 'comment_count' ) as $key ) {
477 if ( ! empty( $vars[ $key ] ) ) {
478 return false;
479 }
480 }
481 if ( isset( $vars['has_password'] ) && null !== $vars['has_password'] ) {
482 return false;
483 }
484 if ( self::puts_sticky_first( $query, self::listing_types( $query, self::ignored_types() ) ) ) {
485 return false;
486 }
487 if ( self::filters_on_modified_date( $query ) ) {
488 return false;
489 }
490
491 $orderby = $vars['orderby'] ?? '';
492 if ( ! is_array( $orderby ) && ! is_scalar( $orderby ) ) {
493 return false;
494 }
495 $keys = is_array( $orderby )
496 ? array_keys( $orderby )
497 : preg_split( '/[\s,]+/', trim( (string) $orderby ), -1, PREG_SPLIT_NO_EMPTY );
498 $allowed = array( 'date', 'post_date', 'id' );
499 if ( self::is_child_page_check( $query ) ) {
500 $allowed = array_merge( $allowed, array( 'title', 'post_title', 'menu_order' ) );
501 }
502 foreach ( (array) $keys as $key ) {
503 $key = strtolower( (string) $key );
504 // A random pick: the cached page holds one draw, and its IDs are
505 // recorded. A plain edit cannot add a post to the set the query
506 // draws from (type, status, terms and date are not plain), so
507 // only an edit of a post the page shows changes it, and that
508 // clears the page through the post's own index file.
509 if ( 1 === preg_match( '/^rand(\(\d*\))?$/', $key ) ) {
510 continue;
511 }
512 if ( ! in_array( $key, $allowed, true ) ) {
513 return false;
514 }
515 }
516 return true;
517 }
518
519 /**
520 * Whether a list puts sticky posts first: WP_Query does that on a
521 * query it marks `is_home` unless told to ignore them, but it fetches
522 * the sticky posts with the query's own post type, and `sticky_posts`
523 * holds posts. So only a list of `post`, or of any type, can show them.
524 * WP_Query marks almost every secondary query that is not singular, an
525 * archive, a search or a feed as `is_home`, which made a lookup of a
526 * plugin's own post type look sticky-first (QA round 3 on #675).
527 *
528 * @param \WP_Query $query Query.
529 * @param string[] $types Its listing types, from listing_types().
530 */
531 private static function puts_sticky_first( \WP_Query $query, array $types ): bool {
532 $vars = is_array( $query->query_vars ) ? $query->query_vars : array();
533 if ( ! empty( $vars['ignore_sticky_posts'] ) || empty( $query->is_home ) ) {
534 return false;
535 }
536 return in_array( 'post', $types, true ) || in_array( 'any', $types, true );
537 }
538
539 /**
540 * Whether a list picks its posts by their modified date, which every
541 * save moves, plain or not ("recently updated").
542 *
543 * @param \WP_Query $query Query.
544 */
545 private static function filters_on_modified_date( \WP_Query $query ): bool {
546 $vars = is_array( $query->query_vars ) ? $query->query_vars : array();
547 $clause = $vars['date_query'] ?? array();
548 if ( empty( $clause ) && isset( $query->date_query ) && is_object( $query->date_query ) && ! empty( $query->date_query->queries ) ) {
549 $clause = $query->date_query->queries;
550 }
551 if ( empty( $clause ) ) {
552 return false;
553 }
554 $encoded = wp_json_encode( $clause );
555 return ! is_string( $encoded ) || false !== stripos( $encoded, 'modified' );
556 }
557
558 /**
559 * Whether a query only asks whether one page has a child page: one
560 * post, picked by its parent, of one post type.
561 *
562 * WordPress core runs this on every page view. body_class() asks
563 * get_pages( array( 'parent' => $id, 'number' => 1 ) ) for its
564 * `page-parent` class, and since 6.3 that is a WP_Query of type `page`
565 * with `post_parent` set, one post per page and ordered by title. The
566 * title order made it imprecise, and with it every page record on every
567 * theme (QA round 2 on #675).
568 *
569 * Its answer cannot move on a plain edit: a post only enters or leaves
570 * a parent's children, or changes its title or order among them,
571 * through a change of parent, status, title or menu order, and none of
572 * those is plain. So title and menu order are precise here. A longer
573 * list of children ordered by title stays imprecise, as does anything
574 * else run with a search, meta or other filter: query_is_precise()
575 * checks those before the order.
576 *
577 * @param \WP_Query $query Query.
578 */
579 private static function is_child_page_check( \WP_Query $query ): bool {
580 $vars = is_array( $query->query_vars ) ? $query->query_vars : array();
581 if ( ! isset( $vars['post_parent'] ) || ! is_numeric( $vars['post_parent'] ) ) {
582 return false;
583 }
584 if ( 1 !== (int) ( $vars['posts_per_page'] ?? 0 ) ) {
585 return false;
586 }
587 $type = $vars['post_type'] ?? '';
588 if ( is_array( $type ) ) {
589 $type = 1 === count( $type ) ? (string) reset( $type ) : '';
590 }
591 if ( ! is_string( $type ) || '' === $type || 'any' === $type ) {
592 return false;
593 }
594 foreach ( self::FILTER_VARS as $key ) {
595 if ( ! empty( $vars[ $key ] ) ) {
596 return false;
597 }
598 }
599 return empty( $vars['offset'] ) && max( 1, (int) ( $vars['paged'] ?? 1 ) ) === 1;
600 }
601
602 /**
603 * The post IDs a query returned, from post objects, `fields => ids` or
604 * `fields => id=>parent`. Null when it never ran to the end.
605 *
606 * @param \WP_Query $query Query.
607 * @return int[]|null
608 */
609 private static function returned_ids( \WP_Query $query ): ?array {
610 if ( ! is_array( $query->posts ) ) {
611 return null;
612 }
613 $ids = array();
614 foreach ( $query->posts as $item ) {
615 if ( is_object( $item ) && isset( $item->ID ) ) {
616 $ids[] = (int) $item->ID;
617 } elseif ( is_numeric( $item ) ) {
618 $ids[] = (int) $item;
619 }
620 }
621 return $ids;
622 }
623
624 /**
625 * One record from several lists: their types, their IDs, and precise
626 * only when all of them are and they showed MAX_IDS posts or fewer.
627 *
628 * @param array<int,array{types:string[],ids:int[],precise:bool}> $lists Lists.
629 * @return array{types:string[],ids:int[],precise:bool}
630 */
631 private static function merge( array $lists ): array {
632 $types = array();
633 $ids = array();
634 $precise = true;
635 foreach ( $lists as $list ) {
636 $types = array_merge( $types, $list['types'] );
637 $ids = array_merge( $ids, $list['ids'] );
638 $precise = $precise && $list['precise'];
639 }
640 $types = in_array( 'any', $types, true ) ? array( 'any' ) : array_values( array_unique( array_map( 'strval', $types ) ) );
641 sort( $types );
642 $ids = array_values( array_unique( array_map( 'intval', $ids ) ) );
643 sort( $ids );
644 if ( count( $ids ) > self::MAX_IDS ) {
645 $precise = false;
646 }
647 return array(
648 'types' => $types,
649 'ids' => $precise ? $ids : array(),
650 'precise' => $precise,
651 );
652 }
653
654 /**
655 * Keep what one render listed: add its specs, and write, update or
656 * delete its record. Nothing is written when nothing changed.
657 *
658 * @param string $url Page URL.
659 * @param array|null $summary summarise() output.
660 * @param bool $complete The page rendered in full.
661 */
662 public static function store( string $url, ?array $summary, bool $complete = true ): void {
663 $key = md5( $url . self::device() );
664 $specs = null === $summary ? array() : $summary['specs'];
665 $record = null === $summary ? null : $summary['record'];
666
667 // Without the lock first: most renders change nothing.
668 list( $pending, $base ) = self::sort_specs( array() === $specs ? array() : self::read_specs(), $specs, $record );
669 $existing = self::read_record( self::page_file( $key ) );
670 if ( array() === $pending && self::same_record( $existing, self::wanted_record( $existing, $base, $complete ), $url ) ) {
671 return;
672 }
673
674 $lock = self::lock();
675 try {
676 // Again under the lock: another render may have got here first.
677 $known = array() === $specs ? array() : self::read_specs();
678 list( $pending, $base ) = self::sort_specs( $known, $specs, $record );
679 if ( array() !== $pending ) {
680 foreach ( $pending as $spec ) {
681 $known[ self::spec_key( $spec ) ] = $spec;
682 }
683 self::write_json( self::specs_file(), array( 'specs' => array_values( $known ) ) );
684 self::$specs = null;
685 }
686
687 $existing = self::read_record( self::page_file( $key ) );
688 $wanted = self::wanted_record( $existing, $base, $complete );
689 if ( self::same_record( $existing, $wanted, $url ) ) {
690 return;
691 }
692 if ( null === $existing && null !== $wanted && self::total() >= self::CAP ) {
693 self::refuse( $wanted['types'] );
694 return;
695 }
696 self::apply( $key, $url, $existing, $wanted );
697 } finally {
698 self::unlock( $lock );
699 }
700 }
701
702 /**
703 * Split a render's specs into the ones to add or widen, and the ones
704 * with no room left, whose lists go into the page's record instead.
705 *
706 * @param array<string,array> $known Stored specs, keyed by spec_key().
707 * @param array<int,array> $specs summarise()'s specs.
708 * @param array|null $record summarise()'s record.
709 * @return array{0:array<int,array{types:string[],n:int,sticky:bool}>,1:array|null}
710 */
711 private static function sort_specs( array $known, array $specs, ?array $record ): array {
712 $pending = array();
713 $room = self::SPEC_CAP - count( $known );
714 foreach ( $specs as $item ) {
715 $spec = $item['spec'];
716 $key = self::spec_key( $spec );
717 $have = $pending[ $key ]['n'] ?? ( $known[ $key ]['n'] ?? null );
718 if ( null !== $have ) {
719 if ( $have < $spec['n'] ) {
720 $pending[ $key ] = $spec;
721 }
722 continue;
723 }
724 if ( $room > 0 ) {
725 --$room;
726 $pending[ $key ] = $spec;
727 continue;
728 }
729 $record = null === $record ? $item['list'] : self::merge( array( $record, $item['list'] ) );
730 }
731 return array( array_values( $pending ), $record );
732 }
733
734 /**
735 * Drop a page's record and its index entries, when it has one.
736 *
737 * @param string $key Record key.
738 */
739 private static function forget_page( string $key ): void {
740 if ( ! is_file( self::page_file( $key ) ) ) {
741 return;
742 }
743 $lock = self::lock();
744 try {
745 $existing = self::read_record( self::page_file( $key ) );
746 if ( null !== $existing ) {
747 self::apply( $key, $existing['url'], $existing, null );
748 } else {
749 wp_delete_file( self::page_file( $key ) );
750 }
751 } finally {
752 self::unlock( $lock );
753 }
754 }
755
756 /**
757 * The record a render leaves: what it listed, or for a render that
758 * stopped early, that added to what was there.
759 *
760 * @param array|null $existing Stored record.
761 * @param array|null $record What this render listed outside specs.
762 * @param bool $complete The page rendered in full.
763 * @return array{types:string[],ids:int[],precise:bool}|null
764 */
765 private static function wanted_record( ?array $existing, ?array $record, bool $complete ): ?array {
766 if ( $complete || null === $existing ) {
767 return $record;
768 }
769 return null === $record ? $existing : self::merge( array( $existing, $record ) );
770 }
771
772 /** Whether a stored record already says what $wanted says. */
773 private static function same_record( ?array $existing, ?array $wanted, string $url ): bool {
774 if ( null === $existing || null === $wanted ) {
775 return null === $existing && null === $wanted;
776 }
777 return $existing['url'] === $url
778 && self::RECORD_VERSION === ( $existing['v'] ?? 0 )
779 && $existing['types'] === $wanted['types']
780 && $existing['ids'] === $wanted['ids']
781 && $existing['precise'] === $wanted['precise'];
782 }
783
784 /**
785 * Change one page's record from $old to $new, and move its URL in the
786 * per-type lists and the inverse index to match. Called under the lock.
787 *
788 * @param string $key Record key.
789 * @param string $url Page URL.
790 * @param array|null $old Stored record.
791 * @param array|null $new Record to store, null to delete.
792 */
793 private static function apply( string $key, string $url, ?array $old, ?array $new ): void {
794 $index = self::read_types();
795 if ( null === $old && null !== $new ) {
796 ++$index['total'];
797 } elseif ( null !== $old && null === $new ) {
798 $index['total'] = max( 0, $index['total'] - 1 );
799 }
800
801 $old_types = null === $old ? array() : $old['types'];
802 $new_types = null === $new ? array() : $new['types'];
803 foreach ( array_unique( array_merge( $old_types, $new_types ) ) as $type ) {
804 $was = in_array( $type, $old_types, true );
805 $is = in_array( $type, $new_types, true );
806 $was_loose = $was && ! $old['precise'];
807 $is_loose = $is && ! $new['precise'];
808 if ( $was === $is && $was_loose === $is_loose ) {
809 continue;
810 }
811 $counts = $index['types'][ $type ] ?? array( 'all' => 0, 'loose' => 0 );
812 $lists = self::read_type( $type );
813 foreach ( array( 'all' => array( $was, $is ), 'loose' => array( $was_loose, $is_loose ) ) as $set => $change ) {
814 if ( $change[0] === $change[1] ) {
815 continue;
816 }
817 $counts[ $set ] = max( 0, $counts[ $set ] + ( $change[1] ? 1 : -1 ) );
818 if ( null === $lists[ $set ] ) {
819 continue;
820 }
821 if ( $change[1] ) {
822 $lists[ $set ][ $key ] = $url;
823 } else {
824 unset( $lists[ $set ][ $key ] );
825 }
826 // Past the limit any change of the type clears the whole
827 // site, so the URLs are no longer needed. Kept, the list
828 // would be rewritten in full by every new page.
829 if ( count( $lists[ $set ] ) > Affected_Pages::LIMIT ) {
830 $lists[ $set ] = null;
831 }
832 }
833 if ( 0 === $counts['all'] && 0 === $counts['loose'] ) {
834 unset( $index['types'][ $type ] );
835 wp_delete_file( self::type_file( $type ) );
836 } else {
837 $index['types'][ $type ] = $counts;
838 self::write_json( self::type_file( $type ), $lists );
839 }
840 }
841 self::write_json( self::types_file(), $index );
842
843 $old_ids = null !== $old && $old['precise'] ? $old['ids'] : array();
844 $new_ids = null !== $new && $new['precise'] ? $new['ids'] : array();
845 foreach ( array_diff( $new_ids, $old_ids ) as $id ) {
846 $pages = self::read_post( (int) $id ) ?? array();
847 $pages[ $key ] = $url;
848 self::write_json( self::post_file( (int) $id ), array( 'urls' => $pages ) );
849 }
850 foreach ( array_diff( $old_ids, $new_ids ) as $id ) {
851 $pages = self::read_post( (int) $id ) ?? array();
852 unset( $pages[ $key ] );
853 if ( array() === $pages ) {
854 wp_delete_file( self::post_file( (int) $id ) );
855 } else {
856 self::write_json( self::post_file( (int) $id ), array( 'urls' => $pages ) );
857 }
858 }
859
860 if ( null === $new ) {
861 wp_delete_file( self::page_file( $key ) );
862 return;
863 }
864 self::write_json(
865 self::page_file( $key ),
866 array(
867 'url' => $url,
868 'types' => $new['types'],
869 'ids' => $new['ids'],
870 'precise' => $new['precise'],
871 'v' => self::RECORD_VERSION,
872 'at' => time(),
873 )
874 );
875 }
876
877 /**
878 * Note a page that was not recorded because the index is at CAP, with
879 * the types it listed. Called under the lock.
880 *
881 * @param string[] $types Types the refused page listed.
882 */
883 private static function refuse( array $types ): void {
884 $marker = self::read_json( self::dir() . '/' . self::FULL_MARKER );
885 $known = is_array( $marker ) && is_array( $marker['types'] ?? null ) ? array_map( 'strval', $marker['types'] ) : array();
886 $merged = array_values( array_unique( array_merge( $known, $types ) ) );
887 sort( $merged );
888 if ( is_array( $marker ) && $merged === $known ) {
889 return;
890 }
891 self::write_json(
892 self::dir() . '/' . self::FULL_MARKER,
893 array(
894 'types' => $merged,
895 'at' => time(),
896 )
897 );
898 }
899
900 // --- Save side --------------------------------------------------------
901
902 /**
903 * Newest-N specs in the shape Affected_Pages::lists_changed_by() reads:
904 * one `recent` spec per type, which is never narrower than the list it
905 * came from (a post among the newest N of several types is among the
906 * newest N of its own).
907 *
908 * @return array<int,array{kind:string,type:string,n:int,sticky:bool}>
909 */
910 public static function recent_specs(): array {
911 if ( null === self::$specs ) {
912 self::$specs = self::read_specs();
913 }
914 $out = array();
915 foreach ( self::$specs as $spec ) {
916 foreach ( $spec['types'] as $type ) {
917 $out[] = array(
918 'kind' => 'recent',
919 'type' => (string) $type,
920 'n' => (int) $spec['n'],
921 'sticky' => (bool) $spec['sticky'],
922 );
923 }
924 }
925 return $out;
926 }
927
928 /**
929 * The recorded pages a change to this post may have altered, or null
930 * when the index cannot answer and the caller has to clear the whole
931 * site.
932 *
933 * A plain edit (Affected_Pages::is_plain_change()) that keeps the slug
934 * needs the pages that showed the post (one inverse-index file) and the
935 * pages of its type whose lists are not precise. Any other change,
936 * including a slug change, needs every page of its type. The types are the post's, the one it had before the save, and
937 * `any`. Null when one of those sets is over Affected_Pages::LIMIT, when
938 * a page that might list the type was refused at CAP, or when the index
939 * does not read back.
940 *
941 * @param \WP_Post $post Post as it is now.
942 * @param \WP_Post|null $before Post before the change.
943 * @return string[]|null Absolute URLs.
944 */
945 public static function pages_for( \WP_Post $post, ?\WP_Post $before ): ?array {
946 $dir = self::dir();
947 if ( ! is_dir( $dir ) ) {
948 return array();
949 }
950 $types = array( (string) $post->post_type, 'any' );
951 if ( $before instanceof \WP_Post ) {
952 $types[] = (string) $before->post_type;
953 }
954 $types = array_values( array_unique( $types ) );
955
956 $marker = $dir . '/' . self::FULL_MARKER;
957 if ( is_file( $marker ) ) {
958 $full = self::read_json( $marker );
959 if ( self::total() < self::CAP ) {
960 // Back under the cap. This save clears the whole site, which
961 // also clears the pages that went unrecorded meanwhile.
962 $lock = self::lock();
963 try {
964 wp_delete_file( $marker );
965 } finally {
966 self::unlock( $lock );
967 }
968 return null;
969 }
970 $refused = is_array( $full ) && is_array( $full['types'] ?? null ) ? array_map( 'strval', $full['types'] ) : array( 'any' );
971 if ( in_array( 'any', $refused, true ) || array() !== array_intersect( $types, $refused ) ) {
972 return null;
973 }
974 }
975
976 // A slug change is plain to Affected_Pages, but it can move a post
977 // into or out of a lookup by slug, which query_is_precise() counts
978 // as precise. So here it needs every page of the type.
979 $plain = Affected_Pages::is_plain_change( $post, $before )
980 && (string) ( $before->post_name ?? '' ) === (string) ( $post->post_name ?? '' );
981 $set = $plain ? 'loose' : 'all';
982 $index = self::read_types_checked();
983 if ( null === $index ) {
984 return null;
985 }
986
987 $urls = array();
988 foreach ( $types as $type ) {
989 $count = (int) ( $index['types'][ $type ][ $set ] ?? 0 );
990 if ( 0 === $count ) {
991 continue;
992 }
993 if ( $count > Affected_Pages::LIMIT ) {
994 return null;
995 }
996 $lists = self::read_type( $type );
997 if ( null === $lists[ $set ] || count( $lists[ $set ] ) !== $count ) {
998 // Dropped when the type went past the limit, or out of step:
999 // rebuild from the records, once.
1000 if ( ! self::rebuild() ) {
1001 return null;
1002 }
1003 $index = self::read_types();
1004 $lists = self::read_type( $type );
1005 if ( null === $lists[ $set ] || (int) ( $index['types'][ $type ][ $set ] ?? 0 ) > Affected_Pages::LIMIT ) {
1006 return null;
1007 }
1008 }
1009 $urls = array_merge( $urls, array_values( $lists[ $set ] ) );
1010 }
1011
1012 if ( $plain ) {
1013 $shown = self::read_post( (int) $post->ID );
1014 if ( null === $shown && is_file( self::post_file( (int) $post->ID ) ) ) {
1015 if ( ! self::rebuild() ) {
1016 return null;
1017 }
1018 $shown = self::read_post( (int) $post->ID );
1019 }
1020 $urls = array_merge( $urls, array_values( (array) $shown ) );
1021 }
1022 return array_values( array_unique( array_map( 'strval', $urls ) ) );
1023 }
1024
1025 /**
1026 * What is recorded for this blog, for `wp xspeed cache listings`.
1027 *
1028 * @param int $samples How many page records to return in full.
1029 * @return array{dir:string,specs:array,pages:int,files:int,types:array,posts:int,full:array|null,samples:array}
1030 */
1031 public static function stats( int $samples = 5 ): array {
1032 $dir = self::dir();
1033 $files = is_dir( $dir . '/pages' ) ? glob( $dir . '/pages/*.json' ) : array();
1034 $files = is_array( $files ) ? $files : array();
1035 $posts = is_dir( $dir . '/post' ) ? glob( $dir . '/post/*.json' ) : array();
1036 $index = self::read_types();
1037 $types = array();
1038 foreach ( $index['types'] as $type => $counts ) {
1039 $lists = self::read_type( (string) $type );
1040 $types[ $type ] = array(
1041 'all' => (int) $counts['all'],
1042 'loose' => (int) $counts['loose'],
1043 'tracked' => null !== $lists['all'],
1044 );
1045 }
1046 uasort( $types, static fn( $a, $b ) => $b['all'] <=> $a['all'] );
1047 $out = array(
1048 'dir' => $dir,
1049 'specs' => array_values( self::read_specs() ),
1050 'pages' => (int) $index['total'],
1051 'files' => count( $files ),
1052 'types' => $types,
1053 'posts' => is_array( $posts ) ? count( $posts ) : 0,
1054 'full' => is_file( $dir . '/' . self::FULL_MARKER ) ? ( self::read_json( $dir . '/' . self::FULL_MARKER ) ?? array( 'types' => array( 'any' ) ) ) : null,
1055 'samples' => array(),
1056 );
1057 foreach ( $files as $file ) {
1058 if ( count( $out['samples'] ) >= $samples ) {
1059 break;
1060 }
1061 $record = self::read_record( $file );
1062 if ( null !== $record ) {
1063 $out['samples'][] = $record;
1064 }
1065 }
1066 return $out;
1067 }
1068
1069 /** Test seam: forget per-request state. */
1070 public static function reset(): void {
1071 self::$queries = array();
1072 self::$overflow = false;
1073 self::$active = null;
1074 self::$ignored = null;
1075 self::$specs = null;
1076 }
1077
1078 /** This blog's index directory. */
1079 public static function dir(): string {
1080 $blog = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 1;
1081 return XSPEED_CACHE_DIR . '/' . self::SUBDIR . '/' . max( 1, $blog );
1082 }
1083
1084 // --- Index files ------------------------------------------------------
1085
1086 private static function page_file( string $key ): string {
1087 return self::dir() . '/pages/' . $key . '.json';
1088 }
1089
1090 private static function post_file( int $id ): string {
1091 return self::dir() . '/post/' . max( 0, $id ) . '.json';
1092 }
1093
1094 private static function type_file( string $type ): string {
1095 $slug = (string) preg_replace( '/[^a-z0-9_\-]/', '', strtolower( $type ) );
1096 return self::dir() . '/type/' . ( '' === $slug ? md5( $type ) : $slug ) . '.json';
1097 }
1098
1099 private static function types_file(): string {
1100 return self::dir() . '/types.json';
1101 }
1102
1103 private static function specs_file(): string {
1104 return self::dir() . '/specs.json';
1105 }
1106
1107 /**
1108 * The specs, keyed by spec_key().
1109 *
1110 * @return array<string,array{types:string[],n:int,sticky:bool}>
1111 */
1112 private static function read_specs(): array {
1113 $data = self::read_json( self::specs_file() );
1114 $out = array();
1115 foreach ( is_array( $data['specs'] ?? null ) ? $data['specs'] : array() as $spec ) {
1116 if ( ! is_array( $spec ) || ! is_array( $spec['types'] ?? null ) || array() === $spec['types'] ) {
1117 continue;
1118 }
1119 $spec = array(
1120 'types' => array_values( array_map( 'strval', $spec['types'] ) ),
1121 'n' => max( 1, (int) ( $spec['n'] ?? 1 ) ),
1122 'sticky' => ! empty( $spec['sticky'] ),
1123 );
1124 $out[ self::spec_key( $spec ) ] = $spec;
1125 }
1126 return $out;
1127 }
1128
1129 /** One spec per set of types and stickiness; the longest N stands for the rest. */
1130 private static function spec_key( array $spec ): string {
1131 return implode( ',', $spec['types'] ) . ( $spec['sticky'] ? '|sticky' : '' );
1132 }
1133
1134 /**
1135 * The per-type counts, as stored; empty when there are none.
1136 *
1137 * @return array{total:int,types:array<string,array{all:int,loose:int}>}
1138 */
1139 private static function read_types(): array {
1140 $data = self::read_json( self::types_file() );
1141 return self::types_shape( $data ) ?? array(
1142 'total' => 0,
1143 'types' => array(),
1144 );
1145 }
1146
1147 /**
1148 * The per-type counts for a save: rebuilt from the records when the
1149 * file is missing or does not parse while records exist. Null when the
1150 * rebuild found a record it could not read.
1151 *
1152 * @return array{total:int,types:array<string,array{all:int,loose:int}>}|null
1153 */
1154 private static function read_types_checked(): ?array {
1155 $index = self::types_shape( self::read_json( self::types_file() ) );
1156 if ( null !== $index ) {
1157 return $index;
1158 }
1159 $pages = glob( self::dir() . '/pages/*.json' );
1160 if ( ! is_array( $pages ) || array() === $pages ) {
1161 return array(
1162 'total' => 0,
1163 'types' => array(),
1164 );
1165 }
1166 return self::rebuild() ? self::read_types() : null;
1167 }
1168
1169 /**
1170 * Validate the per-type counts.
1171 *
1172 * @param mixed $data Decoded file.
1173 * @return array{total:int,types:array<string,array{all:int,loose:int}>}|null
1174 */
1175 private static function types_shape( $data ): ?array {
1176 if ( ! is_array( $data ) || ! isset( $data['total'] ) || ! is_array( $data['types'] ?? null ) ) {
1177 return null;
1178 }
1179 $types = array();
1180 foreach ( $data['types'] as $type => $counts ) {
1181 $types[ (string) $type ] = array(
1182 'all' => max( 0, (int) ( $counts['all'] ?? 0 ) ),
1183 'loose' => max( 0, (int) ( $counts['loose'] ?? 0 ) ),
1184 );
1185 }
1186 return array(
1187 'total' => max( 0, (int) $data['total'] ),
1188 'types' => $types,
1189 );
1190 }
1191
1192 /** Pages recorded, from the per-type counts. */
1193 private static function total(): int {
1194 return self::read_types()['total'];
1195 }
1196
1197 /**
1198 * The URLs of one type's records, all and not precise, keyed by record
1199 * key. A set is null once it went past Affected_Pages::LIMIT.
1200 *
1201 * @return array{all:array<string,string>|null,loose:array<string,string>|null}
1202 */
1203 private static function read_type( string $type ): array {
1204 $data = self::read_json( self::type_file( $type ) );
1205 $out = array(
1206 'all' => array(),
1207 'loose' => array(),
1208 );
1209 if ( ! is_array( $data ) ) {
1210 return $out;
1211 }
1212 foreach ( array( 'all', 'loose' ) as $set ) {
1213 $out[ $set ] = array_key_exists( $set, $data ) && null === $data[ $set ]
1214 ? null
1215 : array_map( 'strval', is_array( $data[ $set ] ?? null ) ? $data[ $set ] : array() );
1216 }
1217 return $out;
1218 }
1219
1220 /**
1221 * The pages that showed a post, keyed by record key, or null when its
1222 * file is missing or does not parse.
1223 *
1224 * @return array<string,string>|null
1225 */
1226 private static function read_post( int $id ): ?array {
1227 $data = self::read_json( self::post_file( $id ) );
1228 return is_array( $data ) && is_array( $data['urls'] ?? null ) ? array_map( 'strval', $data['urls'] ) : null;
1229 }
1230
1231 /**
1232 * One page record, or null when it is missing or does not parse.
1233 *
1234 * @return array{url:string,types:string[],ids:int[],precise:bool,v:int}|null
1235 */
1236 private static function read_record( string $file ): ?array {
1237 $data = self::read_json( $file );
1238 if ( ! is_array( $data ) || ! is_string( $data['url'] ?? null ) || '' === $data['url']
1239 || ! is_array( $data['types'] ?? null ) || ! is_array( $data['ids'] ?? null ) || ! is_bool( $data['precise'] ?? null )
1240 ) {
1241 return null;
1242 }
1243 return array(
1244 'url' => $data['url'],
1245 'types' => array_values( array_map( 'strval', $data['types'] ) ),
1246 'ids' => array_values( array_map( 'intval', $data['ids'] ) ),
1247 'precise' => $data['precise'],
1248 'v' => is_int( $data['v'] ?? null ) ? $data['v'] : 0,
1249 );
1250 }
1251
1252 /**
1253 * Rebuild the per-type counts and lists and the inverse index from the
1254 * page records, under the lock. False when a record does not parse: it
1255 * is deleted, and the caller clears the whole site, which also clears
1256 * that page so its next render records it again.
1257 */
1258 private static function rebuild(): bool {
1259 $lock = self::lock();
1260 try {
1261 $dir = self::dir();
1262 $files = glob( $dir . '/pages/*.json' );
1263 $files = is_array( $files ) ? $files : array();
1264 $index = array(
1265 'total' => 0,
1266 'types' => array(),
1267 );
1268 $lists = array();
1269 $posts = array();
1270 $ok = true;
1271 foreach ( $files as $file ) {
1272 $record = self::read_record( $file );
1273 if ( null === $record ) {
1274 wp_delete_file( $file );
1275 $ok = false;
1276 continue;
1277 }
1278 $key = basename( $file, '.json' );
1279 ++$index['total'];
1280 foreach ( $record['types'] as $type ) {
1281 $index['types'][ $type ] = $index['types'][ $type ] ?? array( 'all' => 0, 'loose' => 0 );
1282 $lists[ $type ] = $lists[ $type ] ?? array( 'all' => array(), 'loose' => array() );
1283 ++$index['types'][ $type ]['all'];
1284 $lists[ $type ]['all'][ $key ] = $record['url'];
1285 if ( ! $record['precise'] ) {
1286 ++$index['types'][ $type ]['loose'];
1287 $lists[ $type ]['loose'][ $key ] = $record['url'];
1288 }
1289 }
1290 if ( $record['precise'] ) {
1291 foreach ( $record['ids'] as $id ) {
1292 $posts[ $id ][ $key ] = $record['url'];
1293 }
1294 }
1295 }
1296
1297 foreach ( array( 'type', 'post' ) as $sub ) {
1298 foreach ( (array) glob( $dir . '/' . $sub . '/*.json' ) as $stale ) {
1299 if ( is_string( $stale ) ) {
1300 wp_delete_file( $stale );
1301 }
1302 }
1303 }
1304 foreach ( $lists as $type => $sets ) {
1305 foreach ( $sets as $set => $urls ) {
1306 if ( count( $urls ) > Affected_Pages::LIMIT ) {
1307 $sets[ $set ] = null;
1308 }
1309 }
1310 self::write_json( self::type_file( (string) $type ), $sets );
1311 }
1312 foreach ( $posts as $id => $urls ) {
1313 self::write_json( self::post_file( (int) $id ), array( 'urls' => $urls ) );
1314 }
1315 self::write_json( self::types_file(), $index );
1316 return $ok;
1317 } finally {
1318 self::unlock( $lock );
1319 }
1320 }
1321
1322 /**
1323 * Decoded JSON file, or null.
1324 *
1325 * @return array|null
1326 */
1327 private static function read_json( string $file ): ?array {
1328 if ( ! is_file( $file ) ) {
1329 return null;
1330 }
1331 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache dir; WP_Filesystem needs admin creds unavailable on a front-end request.
1332 $raw = @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a file removed meanwhile reads as none.
1333 $data = is_string( $raw ) ? json_decode( $raw, true ) : null;
1334 return is_array( $data ) ? $data : null;
1335 }
1336
1337 /**
1338 * Write a JSON file atomically, creating its directory.
1339 *
1340 * @param string $file Path.
1341 * @param array $data Data.
1342 */
1343 private static function write_json( string $file, array $data ): bool {
1344 if ( ! self::ensure_dir( dirname( $file ) ) ) {
1345 return false;
1346 }
1347 $json = wp_json_encode( $data );
1348 return is_string( $json ) && Cache::write_atomic( $file, $json );
1349 }
1350
1351 private static function ensure_dir( string $dir ): bool {
1352 if ( is_dir( $dir ) ) {
1353 return true;
1354 }
1355 $root = XSPEED_CACHE_DIR . '/' . self::SUBDIR;
1356 wp_mkdir_p( $dir );
1357 if ( ! is_dir( $dir ) ) {
1358 return false;
1359 }
1360 for ( $at = $dir; strlen( $at ) >= strlen( $root ); $at = dirname( $at ) ) {
1361 Cache::write_silence( $at );
1362 }
1363 return true;
1364 }
1365
1366 /**
1367 * Take the blog's index lock: flock on `.lock`, as Cache takes its page
1368 * cache lock. Null when it cannot be had (no directory, or a filesystem
1369 * without flock); the caller writes anyway, every file atomically, and a
1370 * race then loses at most one URL, which stays stale until it expires.
1371 *
1372 * @return resource|null
1373 */
1374 private static function lock() {
1375 if ( ! self::ensure_dir( self::dir() ) ) {
1376 return null;
1377 }
1378 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- flock needs a local handle; a failure falls back to unlocked atomic writes.
1379 $handle = @fopen( self::dir() . '/.lock', 'c' );
1380 if ( ! is_resource( $handle ) ) {
1381 return null;
1382 }
1383 if ( ! flock( $handle, LOCK_EX ) ) {
1384 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the fopen above.
1385 return null;
1386 }
1387 return $handle;
1388 }
1389
1390 /** @param resource|null $handle Lock from lock(). */
1391 private static function unlock( $handle ): void {
1392 if ( is_resource( $handle ) ) {
1393 flock( $handle, LOCK_UN );
1394 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the fopen in lock().
1395 }
1396 }
1397
1398 // --- Request checks ---------------------------------------------------
1399
1400 /**
1401 * The device copy this render is, as Cache::cache_key() splits it: ''
1402 * unless phones get a cache of their own. Each copy can list different
1403 * posts, so each keeps its own record.
1404 */
1405 private static function device(): string {
1406 $opts = Settings_Manager::get( 'cache' );
1407 if ( empty( $opts['mobile_separate'] ) ) {
1408 return '';
1409 }
1410 return function_exists( 'wp_is_mobile' ) && wp_is_mobile() ? '|m' : '|d';
1411 }
1412
1413 /**
1414 * The ignored post types, after `xspeed_listing_ignored_post_types`.
1415 *
1416 * @return string[]
1417 */
1418 private static function ignored_types(): array {
1419 if ( null !== self::$ignored ) {
1420 return self::$ignored;
1421 }
1422 /**
1423 * Filter the post types whose queries never make a page a listing
1424 * page: menus, templates, styles, patterns, attachments and the like.
1425 *
1426 * Add a type a plugin queries on every page for its own use, when
1427 * saving a post of it never changes what visitors see.
1428 *
1429 * @param string[] $types Post type slugs.
1430 */
1431 self::$ignored = array_map( 'strval', (array) apply_filters( 'xspeed_listing_ignored_post_types', self::IGNORED_TYPES ) );
1432 return self::$ignored;
1433 }
1434
1435 /**
1436 * Whether this request is one whose pages are cached for visitors: a
1437 * front-end GET, not admin, AJAX, REST, cron, XML-RPC or WP-CLI.
1438 */
1439 private static function request_may_record(): bool {
1440 if ( ( defined( 'WP_CLI' ) && WP_CLI )
1441 || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
1442 || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
1443 || ( defined( 'DOING_AJAX' ) && DOING_AJAX )
1444 || ( defined( 'DOING_CRON' ) && DOING_CRON )
1445 || ( function_exists( 'is_admin' ) && is_admin() )
1446 ) {
1447 return false;
1448 }
1449 $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
1450 return 'GET' === $method;
1451 }
1452
1453 /**
1454 * Whether the finished response is a page to record: a 200 rendered
1455 * here for an anonymous visitor, not a cached copy, not a feed, and
1456 * with no query string other than the ones the cache ignores.
1457 */
1458 private static function response_may_record(): bool {
1459 if ( 200 !== http_response_code() ) {
1460 return false;
1461 }
1462 // A copy served from the cache rendered no lists.
1463 if ( 0 === strpos( Cache::status_header(), 'HIT' ) ) {
1464 return false;
1465 }
1466 if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
1467 return false;
1468 }
1469 if ( function_exists( 'is_feed' ) && is_feed() ) {
1470 return false;
1471 }
1472 return Cache::query_has_only_ignored_params();
1473 }
1474
1475 /**
1476 * The page's URL on the site's own host, or '' for a request made on
1477 * another host name: a forged Host header must not be able to fill the
1478 * index.
1479 */
1480 private static function current_url(): string {
1481 $home = wp_parse_url( home_url( '/' ) );
1482 if ( ! is_array( $home ) || empty( $home['host'] ) ) {
1483 return '';
1484 }
1485 $scheme = ! empty( $home['scheme'] ) ? strtolower( (string) $home['scheme'] ) : 'https';
1486 $host = strtolower( (string) $home['host'] ) . ( isset( $home['port'] ) ? ':' . (int) $home['port'] : '' );
1487
1488 $asked = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ) : '';
1489 $asked = (string) preg_replace( '/:(80|443)$/', '', $asked );
1490 if ( (string) preg_replace( '/:(80|443)$/', '', $host ) !== $asked ) {
1491 return '';
1492 }
1493
1494 // The raw path, as the visitor and a cache in front see it. Printable
1495 // ASCII only: a browser percent-encodes everything else.
1496 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- filtered to printable ASCII below; stored as JSON and only ever handed back to purge_url().
1497 $path = (string) strtok( $uri, '?#' );
1498 $path = (string) preg_replace( '/[^\x21-\x7E]/', '', $path );
1499 if ( '' === $path || '/' !== $path[0] || strlen( $path ) > self::MAX_PATH ) {
1500 return '';
1501 }
1502 return $scheme . '://' . $host . $path;
1503 }
1504 }
1505