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-purge-ui.php

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

775 lines 27.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Single-URL purge surfaces: admin bar, post row actions, edit screen.
4 *
5 * `Cache::purge_url()` has always been able to clear one page, but the only
6 * ways in were WP-CLI and the MCP tool. Someone who had just corrected a typo
7 * on one page had to throw away the whole cache to see the fix, which on a
8 * large site costs every other page its warm entry too. This class is the
9 * missing entry point, in the three places the errand actually starts:
10 *
11 * - the admin bar, while looking at the page (front end) or editing it,
12 * - the Posts/Pages list, in the hover row actions,
13 * - the edit screen, in the xSpeed meta box.
14 *
15 * All three build the same nonce-protected admin-post URL and land in the
16 * same handler, so there is one authorization path rather than three.
17 *
18 * @package XSpeed
19 */
20
21 declare(strict_types=1);
22
23 namespace XSpeed;
24
25 defined( 'ABSPATH' ) || exit;
26
27 final class Purge_Ui {
28
29 /** admin-post action for purging one URL (the front-end admin bar). */
30 public const ACTION = 'xspeed_purge_url';
31
32 /**
33 * admin-post action for purging one POST and the pages that list it.
34 *
35 * Separate from ACTION because the scope genuinely differs, and the nonce
36 * has to be bound to a post id rather than to a URL.
37 */
38 public const POST_ACTION = 'xspeed_purge_post';
39
40 /** Per-user transient prefix carrying one purge's result across the redirect. */
41 private const NOTICE_KEY = 'xspeed_purge_result_';
42
43 public static function boot(): void {
44 add_action( 'admin_post_' . self::ACTION, array( __CLASS__, 'handle' ) );
45 add_action( 'admin_post_' . self::POST_ACTION, array( __CLASS__, 'handle_post' ) );
46 add_filter( 'post_row_actions', array( __CLASS__, 'row_action' ), 10, 2 );
47 add_filter( 'page_row_actions', array( __CLASS__, 'row_action' ), 10, 2 );
48 add_action( 'admin_notices', array( __CLASS__, 'render_admin_notice' ) );
49 // After Cache::admin_bar_purge() at 100, so the parent node it adds
50 // already exists and this call merges into it.
51 add_action( 'admin_bar_menu', array( __CLASS__, 'flag_admin_bar_result' ), 110 );
52 }
53
54 /**
55 * Can this user purge at all? Same capability the admin-bar menu and the
56 * dashboard purge button use — purging is a site-wide performance action,
57 * not something an author gets over their own posts.
58 */
59 public static function user_can_purge(): bool {
60 return current_user_can( 'manage_options' );
61 }
62
63 /**
64 * The URL the CURRENT screen is about, or '' when the screen isn't about
65 * one page.
66 *
67 * Two contexts resolve, deliberately:
68 *
69 * - Front end: whatever is being viewed. Taken from REQUEST_URI rather
70 * than the queried object's permalink, because an archive or a paged
71 * URL has no permalink at all, and the page on screen is the one the
72 * user means.
73 * - Post edit screen: the edited post's permalink, since the admin URL
74 * itself is never cached.
75 *
76 * Anywhere else there is no single page in view, so the caller hides the
77 * menu item rather than guessing.
78 */
79 public static function current_target(): string {
80 if ( ! is_admin() ) {
81 if ( ! self::request_is_path_addressable() ) {
82 return '';
83 }
84 $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- esc_url_raw sanitizes.
85 if ( '' === $uri ) {
86 return '';
87 }
88 // Drop the query string, exactly as cache_key() does with
89 // strtok( $uri, '?' ) — /post and /post?utm_source=x are one
90 // entry, so a link carrying the params would suggest it targets
91 // something narrower than it does.
92 $uri = (string) strtok( $uri, '?' );
93
94 $root = self::request_root();
95 return '' === $root ? '' : $root . $uri;
96 }
97
98 $post_id = self::edited_post_id();
99 if ( $post_id <= 0 ) {
100 return '';
101 }
102 return self::permalink_of( $post_id );
103 }
104
105 /**
106 * Post being edited on the current admin screen, or 0.
107 *
108 * `get_the_ID()` is unreliable this early on post.php, so read the
109 * request directly — post.php uses `post`, and nothing else on the edit
110 * screens carries a post id we should act on.
111 */
112 /**
113 * Is the CURRENT front-end request one that `Cache::purge_url()` can
114 * actually reach by path?
115 *
116 * Three request shapes get a cache key that no path can address, because
117 * `cache_key()` builds them from something other than the URI:
118 *
119 * - a cacheable 404 shares one generic `md5( $host . '|404' )` entry per
120 * host, so every 404 on the site is the same file,
121 * - a cached search folds the term in as `|s=…`, and the query string is
122 * otherwise stripped,
123 * - a query-form feed (`/?feed=rss2`) folds the type in as `|feed=…`.
124 *
125 * Offering "Purge this URL" on those would purge the bare path instead —
126 * on a search page, the HOME page. Since the redirect carries no success
127 * notice, that lands as a silent wrong answer, so the item is hidden
128 * instead. (Where the matching feature is switched off the page is not
129 * cached at all, and hiding costs nothing.)
130 */
131 private static function request_is_path_addressable(): bool {
132 if ( function_exists( 'is_404' ) && is_404() ) {
133 return false;
134 }
135 if ( function_exists( 'is_search' ) && is_search() ) {
136 return false;
137 }
138 if ( function_exists( 'is_feed' ) && is_feed() ) {
139 // A pretty-permalink feed (/feed/rss/) carries the type in the
140 // path and is fine; only the query form is unreachable.
141 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared, never output or stored.
142 if ( false !== strpos( $uri, 'feed=' ) ) {
143 return false;
144 }
145 }
146 return true;
147 }
148
149 /**
150 * Scheme + host (+ port) the CURRENT request came in on, with no path.
151 *
152 * The host comes from HTTP_HOST rather than home_url() because that is
153 * what `cache_key()` hashed when the entry was written. Where the two
154 * disagree — a proxy forwarding `Host: site.com:8080`, a bare-vs-www
155 * mismatch, a mapped domain — home_url()'s host computes a different md5,
156 * finds no file and reports "already cold" while the page keeps serving
157 * HIT.
158 *
159 * REQUEST_URI is already absolute from the domain root, so it must NOT be
160 * passed through home_url(): on a subdirectory install that prepends the
161 * subdirectory a second time and the link points at `/blog/blog/about/`.
162 */
163 private static function request_root(): string {
164 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
165 if ( '' === $host ) {
166 return self::home_root();
167 }
168 return ( is_ssl() ? 'https' : 'http' ) . '://' . $host;
169 }
170
171 /** Scheme + host (+ port) of home_url(), with no path. */
172 private static function home_root(): string {
173 $home = wp_parse_url( home_url( '/' ) );
174 if ( ! is_array( $home ) || empty( $home['host'] ) ) {
175 return '';
176 }
177 $root = ( $home['scheme'] ?? 'http' ) . '://' . $home['host'];
178 if ( ! empty( $home['port'] ) ) {
179 $root .= ':' . (int) $home['port'];
180 }
181 return $root;
182 }
183
184 private static function edited_post_id(): int {
185 global $pagenow;
186 if ( 'post.php' !== $pagenow ) {
187 return 0;
188 }
189 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading which post is on screen, no state change.
190 return isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
191 }
192
193 /**
194 * Permalink of a post, but only when the post is something a visitor can
195 * actually reach — a draft or a non-viewable type has no cached page to
196 * clear, so offering the action would be a button that always reports
197 * "already cold".
198 */
199 public static function permalink_of( int $post_id ): string {
200 $post = get_post( $post_id );
201 if ( ! $post instanceof \WP_Post ) {
202 return '';
203 }
204 if ( 'publish' !== $post->post_status ) {
205 return '';
206 }
207 if ( ! is_post_type_viewable( $post->post_type ) ) {
208 return '';
209 }
210 $link = get_permalink( $post );
211 return is_string( $link ) ? $link : '';
212 }
213
214 /**
215 * Nonce-protected admin-post URL that purges one URL.
216 *
217 * The nonce action is bound to the target URL, so a link leaked from one
218 * page can't be replayed to purge a different one. The URL is hashed into
219 * the action rather than concatenated raw to keep the action short and
220 * free of characters `wp_create_nonce` would otherwise carry verbatim.
221 */
222 public static function purge_link( string $url ): string {
223 return wp_nonce_url(
224 add_query_arg(
225 array(
226 'action' => self::ACTION,
227 // add_query_arg() does NOT encode values (build_query()
228 // passes $urlencode = false), so a URL carrying its own
229 // query string would otherwise swallow the nonce.
230 'url' => rawurlencode( $url ),
231 ),
232 admin_url( 'admin-post.php' )
233 ),
234 self::nonce_action( $url )
235 );
236 }
237
238 private static function nonce_action( string $url ): string {
239 return self::ACTION . '_' . md5( $url );
240 }
241
242 /**
243 * "Purge cache" in the Posts/Pages hover row actions.
244 *
245 * @param array<string,string> $actions Existing row actions.
246 * @param \WP_Post $post Row's post.
247 * @return array<string,string>
248 */
249 public static function row_action( $actions, $post ) {
250 if ( ! is_array( $actions ) || ! $post instanceof \WP_Post ) {
251 return $actions;
252 }
253 if ( ! self::user_can_purge() ) {
254 return $actions;
255 }
256 if ( '' === self::permalink_of( (int) $post->ID ) ) {
257 return $actions;
258 }
259
260 $actions['xspeed_purge'] = sprintf(
261 '<a href="%1$s">%2$s</a>',
262 esc_url( self::post_purge_link( (int) $post->ID ) ),
263 esc_html__( 'Purge cache', 'xspeed' )
264 );
265 return $actions;
266 }
267
268 /**
269 * Purge one URL, then send the user back where they came from.
270 *
271 * The URL is re-validated against this site's home host instead of being
272 * trusted from the query string. `Cache::purge_url()` derives its cache
273 * directory from the host it is given, so an off-site host would have it
274 * walking a bucket that isn't ours.
275 */
276 public static function handle(): void {
277 if ( ! self::user_can_purge() ) {
278 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
279 }
280
281 // PHP has already percent-decoded $_GET once, which undoes the
282 // rawurlencode() purge_link() applied. Decoding a second time here
283 // would corrupt any URL containing a literal percent sequence, and
284 // the nonce below is bound to the value BEFORE that encoding.
285 $url = isset( $_GET['url'] ) ? esc_url_raw( wp_unslash( $_GET['url'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- esc_url_raw sanitizes; the nonce below binds this exact value.
286 check_admin_referer( self::nonce_action( $url ) );
287
288 if ( '' === $url || ! self::is_local_url( $url ) ) {
289 wp_die( esc_html__( 'That URL is not on this site.', 'xspeed' ), 400 );
290 }
291
292 $purge = Cache::purge_url_reported( $url, __( 'admin', 'xspeed' ) );
293 self::record_result( $url, $purge['removed'], 1, false, $purge['forwarded'] );
294
295 wp_safe_redirect( self::redirect_target( wp_get_referer() ) );
296 exit;
297 }
298
299 /**
300 * Where to send the user back to.
301 *
302 * Cache::safe_purge_redirect() strips `action` from the referer so a
303 * one-shot admin action (a plugin upload) isn't replayed on load. That is
304 * right everywhere except the editor: post.php with no `action` falls
305 * through to its default case and redirects to edit.php, so purging from
306 * the meta box threw the user out of the post they were editing. Rebuild
307 * the edit URL for that one case.
308 *
309 * @param string|false $referer Raw wp_get_referer() value.
310 */
311 private static function redirect_target( $referer ): string {
312 $referer = is_string( $referer ) ? $referer : '';
313 if ( '' !== $referer ) {
314 $path = (string) wp_parse_url( $referer, PHP_URL_PATH );
315 if ( preg_match( '#/wp-admin/post\.php$#', $path ) ) {
316 parse_str( (string) wp_parse_url( $referer, PHP_URL_QUERY ), $query );
317 $post_id = isset( $query['post'] ) ? absint( $query['post'] ) : 0;
318 if ( $post_id > 0 ) {
319 return get_edit_post_link( $post_id, 'raw' ) ?: admin_url();
320 }
321 }
322 }
323
324 return Cache::safe_purge_redirect( $referer );
325 }
326
327 /**
328 * Remember what the purge did, for the page the user lands on next.
329 *
330 * A transient rather than a query argument: the front-end redirect goes
331 * back to the page that was just purged, and hanging `?xspeed_purged=1`
332 * off it would leave the marker sitting in the address bar and in
333 * anything the visitor copies out of it.
334 *
335 * Keyed per user, so two admins purging at once don't read each other's
336 * result, and short-lived because it is only ever meant to survive one
337 * redirect.
338 */
339 private static function record_result( string $url, int $count, int $urls = 1, bool $whole_site = false, array $forwarded = array() ): void {
340 $user_id = get_current_user_id();
341 if ( $user_id <= 0 ) {
342 return;
343 }
344 set_transient(
345 self::NOTICE_KEY . $user_id,
346 array(
347 'url' => $url,
348 'count' => $count,
349 'urls' => $urls,
350 'whole_site' => $whole_site,
351 'forwarded' => array_values( array_map( 'strval', $forwarded ) ),
352 ),
353 MINUTE_IN_SECONDS
354 );
355 }
356
357 /**
358 * Read the pending result and clear it. Consumed once: whichever surface
359 * renders first owns it, and a reload afterwards shows nothing.
360 *
361 * @return array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]}|null
362 */
363 private static function take_result(): ?array {
364 $result = self::peek_result();
365 if ( null === $result ) {
366 return null;
367 }
368 delete_transient( self::NOTICE_KEY . get_current_user_id() );
369
370 return $result;
371 }
372
373 /**
374 * Read the pending result WITHOUT clearing it, so a caller that turns out
375 * not to be the right place to show it can leave it for the next screen.
376 *
377 * @return array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]}|null
378 */
379 private static function peek_result(): ?array {
380 $user_id = get_current_user_id();
381 if ( $user_id <= 0 ) {
382 return null;
383 }
384 $result = get_transient( self::NOTICE_KEY . $user_id );
385 if ( ! is_array( $result ) || ! isset( $result['url'] ) ) {
386 return null;
387 }
388
389 return array(
390 'url' => (string) $result['url'],
391 'count' => (int) ( $result['count'] ?? 0 ),
392 'urls' => max( 1, (int) ( $result['urls'] ?? 1 ) ),
393 'whole_site' => ! empty( $result['whole_site'] ),
394 'forwarded' => isset( $result['forwarded'] ) && is_array( $result['forwarded'] ) ? array_values( array_map( 'strval', $result['forwarded'] ) ) : array(),
395 );
396 }
397
398 /**
399 * What to tell the user.
400 *
401 * A count of zero is reported as such rather than as success. The page
402 * having no cached copy is the single most useful thing to know here —
403 * it means either the purge already happened or the page was never
404 * cacheable, and calling that "cleared" sends people looking for a bug
405 * in the wrong place.
406 *
407 * @param array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]} $result
408 */
409 private static function message( array $result ): string {
410 $path = (string) wp_parse_url( $result['url'], PHP_URL_PATH );
411 $path = '' === $path ? '/' : $path;
412
413 if ( ! empty( $result['whole_site'] ) ) {
414 return sprintf(
415 /* translators: 1: URL path of the post, 2: number of pages that list it, 3: number of files removed. */
416 _n(
417 'xSpeed: %1$s is listed on %2$d pages, too many to clear one by one, so the whole site cache was cleared (%3$d file).',
418 'xSpeed: %1$s is listed on %2$d pages, too many to clear one by one, so the whole site cache was cleared (%3$d files).',
419 $result['count'],
420 'xspeed'
421 ),
422 $path,
423 $result['urls'],
424 $result['count']
425 );
426 }
427
428 // A post purge also clears the pages that list it, so say so — a user
429 // who asked for one page and sees "12 files" should not have to guess
430 // whether something over-reached.
431 $scope = $result['urls'] > 1
432 ? sprintf(
433 /* translators: 1: URL path of the post, 2: number of OTHER pages also cleared. */
434 _n(
435 '%1$s and %2$d page that lists it',
436 '%1$s and %2$d pages that list it',
437 $result['urls'] - 1,
438 'xspeed'
439 ),
440 $path,
441 $result['urls'] - 1
442 )
443 : $path;
444
445 $forwarded = self::forwarded_of( $result );
446 if ( $result['count'] < 1 && '' !== $forwarded ) {
447 return sprintf(
448 /* translators: 1: what was purged, 2: caches in front of the site, comma-separated. */
449 __( 'xSpeed: sent the purge for %1$s to %2$s. xSpeed\'s own cache held no copy.', 'xspeed' ),
450 $scope,
451 $forwarded
452 );
453 }
454
455 if ( $result['count'] < 1 ) {
456 return sprintf(
457 /* translators: %s: what was purged. */
458 __( 'xSpeed: %s was not cached, so there was nothing to clear.', 'xspeed' ),
459 $scope
460 );
461 }
462
463 return sprintf(
464 /* translators: 1: what was purged, 2: number of files removed. */
465 _n(
466 'xSpeed: cleared the cache for %1$s (%2$d file).',
467 'xSpeed: cleared the cache for %1$s (%2$d files).',
468 $result['count'],
469 'xspeed'
470 ),
471 $scope,
472 $result['count']
473 );
474 }
475
476 /** Admin surfaces: the row action and the editor button land here. */
477 public static function render_admin_notice(): void {
478 if ( ! self::user_can_purge() ) {
479 return;
480 }
481 $result = self::take_result();
482 if ( null === $result ) {
483 return;
484 }
485 printf(
486 '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
487 self::cleared_anything( $result ) ? 'success' : 'info',
488 esc_html( self::message( $result ) )
489 );
490 }
491
492 /**
493 * Whether the purge cleared anything anywhere: a local file, or a
494 * cache in front of the site that took it.
495 *
496 * @param array<string,mixed> $result
497 */
498 private static function cleared_anything( array $result ): bool {
499 return $result['count'] > 0 || '' !== self::forwarded_of( $result );
500 }
501
502 /**
503 * The caches a purge was sent to, comma-separated, or ''.
504 *
505 * @param array<string,mixed> $result
506 */
507 private static function forwarded_of( array $result ): string {
508 $forwarded = isset( $result['forwarded'] ) && is_array( $result['forwarded'] ) ? $result['forwarded'] : array();
509 return implode( ', ', array_filter( array_map( 'strval', $forwarded ) ) );
510 }
511
512 /**
513 * Front end: `admin_notices` never fires there, and the redirect lands on
514 * the purged page itself. Say it in the admin bar instead — the one piece
515 * of our UI already on screen, styled by core, needing no stylesheet and
516 * no script on a front-end page view.
517 *
518 * @param \WP_Admin_Bar $wp_admin_bar
519 */
520 public static function flag_admin_bar_result( $wp_admin_bar ): void {
521 if ( is_admin() || ! self::user_can_purge() ) {
522 return; // In wp-admin the notice above owns the result.
523 }
524 if ( ! is_object( $wp_admin_bar ) || ! method_exists( $wp_admin_bar, 'get_node' ) ) {
525 return;
526 }
527 $node = $wp_admin_bar->get_node( 'xspeed-purge' );
528 if ( ! $node ) {
529 return; // Menu not rendered (no capability, or a filter removed it).
530 }
531 // Peek before consuming. The redirect lands on the page that was
532 // purged, but the user may have opened another tab first — burning
533 // the confirmation on an unrelated front-end view would leave the
534 // purge looking like it did nothing. Anything not aimed at THIS page
535 // is left for the screen it belongs to; it expires on its own.
536 $result = self::peek_result();
537 if ( null === $result || ! self::result_is_about_this_request( $result ) ) {
538 return;
539 }
540 self::take_result();
541
542 $wp_admin_bar->add_node(
543 array(
544 'id' => 'xspeed-purge',
545 'title' => $node->title . ' · ' . (
546 self::cleared_anything( $result )
547 ? esc_html__( 'cleared', 'xspeed' )
548 : esc_html__( 'was not cached', 'xspeed' )
549 ),
550 'meta' => array( 'title' => self::message( $result ) ),
551 )
552 );
553 }
554
555 /**
556 * The "purge what I'm looking at" admin-bar item for this screen, or null
557 * when the screen isn't about one thing.
558 *
559 * The two contexts want different scopes, which is why this returns a
560 * whole node rather than a URL:
561 *
562 * - On the front end you are looking at ONE rendered page, and that page
563 * is what you want gone. Anything else would be a surprise.
564 * - On a post edit screen you have just changed a post, and the post's own
565 * URL is rarely the only page that got stale — the homepage, the archive
566 * and the neighbouring posts all render its title. Purging just the
567 * permalink there leaves the visitor's route TO the post showing the old
568 * version, which reads as "the purge didn't work".
569 *
570 * @return array{title:string,href:string}|null
571 */
572 public static function context_node(): ?array {
573 if ( ! self::user_can_purge() ) {
574 return null;
575 }
576
577 if ( is_admin() ) {
578 $post_id = self::edited_post_id();
579 if ( $post_id <= 0 || '' === self::permalink_of( $post_id ) ) {
580 return null;
581 }
582 return array(
583 'title' => __( 'Purge this post', 'xspeed' ),
584 'href' => self::post_purge_link( $post_id ),
585 );
586 }
587
588 $target = self::current_target();
589 if ( '' === $target ) {
590 return null;
591 }
592 return array(
593 'title' => __( 'Purge this URL', 'xspeed' ),
594 'href' => self::purge_link( $target ),
595 );
596 }
597
598 /** Nonce-protected admin-post URL that purges one post and its listings. */
599 public static function post_purge_link( int $post_id ): string {
600 return wp_nonce_url(
601 add_query_arg(
602 array(
603 'action' => self::POST_ACTION,
604 'post' => $post_id,
605 ),
606 admin_url( 'admin-post.php' )
607 ),
608 self::POST_ACTION . '_' . $post_id
609 );
610 }
611
612 /**
613 * Every URL that goes stale when one post changes.
614 *
615 * The same list the automatic purge on save clears, from
616 * Affected_Pages, so "Purge this post" and a save never disagree. It
617 * covers the post, the home page and blog page, its post-type archive,
618 * every public term it is in with parent terms, the author and date
619 * archives, every page of each, the feeds, the four adjacent posts and
620 * any ancestors. (An earlier version left terms out on the grounds that
621 * WP Rocket does; current WP Rocket purges terms, parents and their
622 * pagination too.)
623 *
624 * The `xspeed_post_purge_urls` filter is applied inside the builder.
625 *
626 * @return string[] Absolute URLs, de-duplicated.
627 */
628 public static function post_purge_urls( \WP_Post $post ): array {
629 return Affected_Pages::for_post( $post );
630 }
631
632 /**
633 * Clear one post's pages, or the whole site when they are more than a
634 * save would name one by one (Affected_Pages::LIMIT).
635 *
636 * The same limit as the automatic purge on save. Over it, every page
637 * would go to each cache in front one at a time: on a 5,000-post blog
638 * the list was 1,022 URLs, each a blocking request to Nginx Helper.
639 *
640 * @return array{count:int,urls:int,whole_site:bool} Files removed, pages named, and whether the whole site went instead.
641 */
642 public static function purge_post( \WP_Post $post ): array {
643 // Count only what we actually act on. The set is filterable, so an
644 // off-site URL added through xspeed_post_purge_urls is skipped here;
645 // reporting it as cleared would inflate the notice.
646 $local = array_values( array_filter( self::post_purge_urls( $post ), array( self::class, 'is_local_url' ) ) );
647 if ( count( $local ) > Affected_Pages::LIMIT ) {
648 $count = Cache::purge_all(
649 __( 'admin', 'xspeed' ),
650 null,
651 array(
652 'scope' => 'site',
653 'intent' => 'content',
654 'urls' => array(),
655 'fallback' => Cache::FALLBACK_LIMIT,
656 )
657 );
658 return array(
659 'count' => (int) $count,
660 'urls' => count( $local ),
661 'whole_site' => true,
662 );
663 }
664 return array(
665 'count' => array() === $local ? 0 : Cache::purge_urls( $local, __( 'admin', 'xspeed' ) ),
666 'urls' => count( $local ),
667 'whole_site' => false,
668 );
669 }
670
671 /**
672 * Purge one post and everything that lists it.
673 *
674 * Same shape as handle(): the nonce is bound to the post id, the
675 * capability is checked first, and the result is carried to the next
676 * screen so the user is told what happened.
677 */
678 public static function handle_post(): void {
679 if ( ! self::user_can_purge() ) {
680 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
681 }
682
683 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce is checked on the next line, against this value.
684 $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
685 check_admin_referer( self::POST_ACTION . '_' . $post_id );
686
687 $post = $post_id > 0 ? get_post( $post_id ) : null;
688 if ( ! $post instanceof \WP_Post ) {
689 wp_die( esc_html__( 'That post does not exist.', 'xspeed' ), 400 );
690 }
691
692 $run = Cache::report_forwarding(
693 static function () use ( $post ): array {
694 return self::purge_post( $post );
695 }
696 );
697 $result = $run['result'];
698
699 self::record_result( self::permalink_of( $post_id ), $result['count'], $result['urls'], $result['whole_site'], $run['forwarded'] );
700
701 wp_safe_redirect( self::redirect_target( wp_get_referer() ) );
702 exit;
703 }
704
705 /**
706 * Does a pending result describe the page currently being rendered?
707 *
708 * Compared on path alone: the result was recorded against an absolute URL
709 * built from the request that purged it, and the host on the request
710 * showing the notice is the same one by construction.
711 *
712 * @param array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]} $result
713 */
714 private static function result_is_about_this_request( array $result ): bool {
715 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared, never output or stored.
716 if ( '' === $uri ) {
717 return false;
718 }
719 $here = untrailingslashit( (string) strtok( $uri, '?' ) );
720 $purged = untrailingslashit( (string) wp_parse_url( $result['url'], PHP_URL_PATH ) );
721
722 return $here === $purged;
723 }
724
725 /**
726 * Is this URL served by this site?
727 *
728 * Compared with port attached, because the cache key hashes the host WITH
729 * its port — `site.test` and `site.test:8080` are separate buckets.
730 *
731 * More than one host can be the right answer: home_url() and site_url()
732 * differ on a WordPress-in-a-subdirectory install, and a proxy or a mapped
733 * domain means the host the page was CACHED under is the one on the
734 * request rather than the one in the option. All three are accepted. The
735 * real gate is the nonce, which is bound to this exact URL and mintable
736 * only by a user who can already purge; this check exists so a
737 * hand-edited URL can't point `purge_url()` at some other site's bucket.
738 */
739 public static function is_local_url( string $url ): bool {
740 $target = wp_parse_url( $url );
741 if ( ! is_array( $target ) || empty( $target['host'] ) ) {
742 return false;
743 }
744 $host = strtolower( (string) $target['host'] );
745 if ( ! empty( $target['port'] ) ) {
746 $host .= ':' . (int) $target['port'];
747 }
748 return in_array( $host, self::known_hosts(), true );
749 }
750
751 /**
752 * Hosts (with port where non-default) this install answers on.
753 *
754 * @return string[]
755 */
756 private static function known_hosts(): array {
757 $hosts = array();
758 foreach ( array( home_url( '/' ), site_url( '/' ) ) as $known ) {
759 $parts = wp_parse_url( (string) $known );
760 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
761 continue;
762 }
763 $host = strtolower( (string) $parts['host'] );
764 if ( ! empty( $parts['port'] ) ) {
765 $host .= ':' . (int) $parts['port'];
766 }
767 $hosts[] = $host;
768 }
769 if ( ! empty( $_SERVER['HTTP_HOST'] ) ) {
770 $hosts[] = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) );
771 }
772 return array_values( array_unique( $hosts ) );
773 }
774 }
775