PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-purge-ui.php

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

776 lines 26.3 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 self::record_result( $url, Cache::purge_url( $url, __( 'admin', 'xspeed' ) ), 1 );
293
294 wp_safe_redirect( self::redirect_target( wp_get_referer() ) );
295 exit;
296 }
297
298 /**
299 * Where to send the user back to.
300 *
301 * Cache::safe_purge_redirect() strips `action` from the referer so a
302 * one-shot admin action (a plugin upload) isn't replayed on load. That is
303 * right everywhere except the editor: post.php with no `action` falls
304 * through to its default case and redirects to edit.php, so purging from
305 * the meta box threw the user out of the post they were editing. Rebuild
306 * the edit URL for that one case.
307 *
308 * @param string|false $referer Raw wp_get_referer() value.
309 */
310 private static function redirect_target( $referer ): string {
311 $referer = is_string( $referer ) ? $referer : '';
312 if ( '' !== $referer ) {
313 $path = (string) wp_parse_url( $referer, PHP_URL_PATH );
314 if ( preg_match( '#/wp-admin/post\.php$#', $path ) ) {
315 parse_str( (string) wp_parse_url( $referer, PHP_URL_QUERY ), $query );
316 $post_id = isset( $query['post'] ) ? absint( $query['post'] ) : 0;
317 if ( $post_id > 0 ) {
318 return get_edit_post_link( $post_id, 'raw' ) ?: admin_url();
319 }
320 }
321 }
322
323 return Cache::safe_purge_redirect( $referer );
324 }
325
326 /**
327 * Remember what the purge did, for the page the user lands on next.
328 *
329 * A transient rather than a query argument: the front-end redirect goes
330 * back to the page that was just purged, and hanging `?xspeed_purged=1`
331 * off it would leave the marker sitting in the address bar and in
332 * anything the visitor copies out of it.
333 *
334 * Keyed per user, so two admins purging at once don't read each other's
335 * result, and short-lived because it is only ever meant to survive one
336 * redirect.
337 */
338 private static function record_result( string $url, int $count, int $urls = 1 ): void {
339 $user_id = get_current_user_id();
340 if ( $user_id <= 0 ) {
341 return;
342 }
343 set_transient(
344 self::NOTICE_KEY . $user_id,
345 array(
346 'url' => $url,
347 'count' => $count,
348 'urls' => $urls,
349 ),
350 MINUTE_IN_SECONDS
351 );
352 }
353
354 /**
355 * Read the pending result and clear it. Consumed once: whichever surface
356 * renders first owns it, and a reload afterwards shows nothing.
357 *
358 * @return array{url:string,count:int,urls:int}|null
359 */
360 private static function take_result(): ?array {
361 $result = self::peek_result();
362 if ( null === $result ) {
363 return null;
364 }
365 delete_transient( self::NOTICE_KEY . get_current_user_id() );
366
367 return $result;
368 }
369
370 /**
371 * Read the pending result WITHOUT clearing it, so a caller that turns out
372 * not to be the right place to show it can leave it for the next screen.
373 *
374 * @return array{url:string,count:int,urls:int}|null
375 */
376 private static function peek_result(): ?array {
377 $user_id = get_current_user_id();
378 if ( $user_id <= 0 ) {
379 return null;
380 }
381 $result = get_transient( self::NOTICE_KEY . $user_id );
382 if ( ! is_array( $result ) || ! isset( $result['url'] ) ) {
383 return null;
384 }
385
386 return array(
387 'url' => (string) $result['url'],
388 'count' => (int) ( $result['count'] ?? 0 ),
389 'urls' => max( 1, (int) ( $result['urls'] ?? 1 ) ),
390 );
391 }
392
393 /**
394 * What to tell the user.
395 *
396 * A count of zero is reported as such rather than as success. The page
397 * having no cached copy is the single most useful thing to know here —
398 * it means either the purge already happened or the page was never
399 * cacheable, and calling that "cleared" sends people looking for a bug
400 * in the wrong place.
401 *
402 * @param array{url:string,count:int,urls:int} $result
403 */
404 private static function message( array $result ): string {
405 $path = (string) wp_parse_url( $result['url'], PHP_URL_PATH );
406 $path = '' === $path ? '/' : $path;
407
408 // A post purge also clears the pages that list it, so say so — a user
409 // who asked for one page and sees "12 files" should not have to guess
410 // whether something over-reached.
411 $scope = $result['urls'] > 1
412 ? sprintf(
413 /* translators: 1: URL path of the post, 2: number of OTHER pages also cleared. */
414 _n(
415 '%1$s and %2$d page that lists it',
416 '%1$s and %2$d pages that list it',
417 $result['urls'] - 1,
418 'xspeed'
419 ),
420 $path,
421 $result['urls'] - 1
422 )
423 : $path;
424
425 if ( $result['count'] < 1 ) {
426 return sprintf(
427 /* translators: %s: what was purged. */
428 __( 'xSpeed: %s was not cached, so there was nothing to clear.', 'xspeed' ),
429 $scope
430 );
431 }
432
433 return sprintf(
434 /* translators: 1: what was purged, 2: number of files removed. */
435 _n(
436 'xSpeed: cleared the cache for %1$s (%2$d file).',
437 'xSpeed: cleared the cache for %1$s (%2$d files).',
438 $result['count'],
439 'xspeed'
440 ),
441 $scope,
442 $result['count']
443 );
444 }
445
446 /** Admin surfaces: the row action and the editor button land here. */
447 public static function render_admin_notice(): void {
448 if ( ! self::user_can_purge() ) {
449 return;
450 }
451 $result = self::take_result();
452 if ( null === $result ) {
453 return;
454 }
455 printf(
456 '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
457 $result['count'] > 0 ? 'success' : 'info',
458 esc_html( self::message( $result ) )
459 );
460 }
461
462 /**
463 * Front end: `admin_notices` never fires there, and the redirect lands on
464 * the purged page itself. Say it in the admin bar instead — the one piece
465 * of our UI already on screen, styled by core, needing no stylesheet and
466 * no script on a front-end page view.
467 *
468 * @param \WP_Admin_Bar $wp_admin_bar
469 */
470 public static function flag_admin_bar_result( $wp_admin_bar ): void {
471 if ( is_admin() || ! self::user_can_purge() ) {
472 return; // In wp-admin the notice above owns the result.
473 }
474 if ( ! is_object( $wp_admin_bar ) || ! method_exists( $wp_admin_bar, 'get_node' ) ) {
475 return;
476 }
477 $node = $wp_admin_bar->get_node( 'xspeed-purge' );
478 if ( ! $node ) {
479 return; // Menu not rendered (no capability, or a filter removed it).
480 }
481 // Peek before consuming. The redirect lands on the page that was
482 // purged, but the user may have opened another tab first — burning
483 // the confirmation on an unrelated front-end view would leave the
484 // purge looking like it did nothing. Anything not aimed at THIS page
485 // is left for the screen it belongs to; it expires on its own.
486 $result = self::peek_result();
487 if ( null === $result || ! self::result_is_about_this_request( $result ) ) {
488 return;
489 }
490 self::take_result();
491
492 $wp_admin_bar->add_node(
493 array(
494 'id' => 'xspeed-purge',
495 'title' => $node->title . ' · ' . (
496 $result['count'] > 0
497 ? esc_html__( 'cleared', 'xspeed' )
498 : esc_html__( 'was not cached', 'xspeed' )
499 ),
500 'meta' => array( 'title' => self::message( $result ) ),
501 )
502 );
503 }
504
505 /**
506 * The "purge what I'm looking at" admin-bar item for this screen, or null
507 * when the screen isn't about one thing.
508 *
509 * The two contexts want different scopes, which is why this returns a
510 * whole node rather than a URL:
511 *
512 * - On the front end you are looking at ONE rendered page, and that page
513 * is what you want gone. Anything else would be a surprise.
514 * - On a post edit screen you have just changed a post, and the post's own
515 * URL is rarely the only page that got stale — the homepage, the archive
516 * and the neighbouring posts all render its title. Purging just the
517 * permalink there leaves the visitor's route TO the post showing the old
518 * version, which reads as "the purge didn't work".
519 *
520 * @return array{title:string,href:string}|null
521 */
522 public static function context_node(): ?array {
523 if ( ! self::user_can_purge() ) {
524 return null;
525 }
526
527 if ( is_admin() ) {
528 $post_id = self::edited_post_id();
529 if ( $post_id <= 0 || '' === self::permalink_of( $post_id ) ) {
530 return null;
531 }
532 return array(
533 'title' => __( 'Purge this post', 'xspeed' ),
534 'href' => self::post_purge_link( $post_id ),
535 );
536 }
537
538 $target = self::current_target();
539 if ( '' === $target ) {
540 return null;
541 }
542 return array(
543 'title' => __( 'Purge this URL', 'xspeed' ),
544 'href' => self::purge_link( $target ),
545 );
546 }
547
548 /** Nonce-protected admin-post URL that purges one post and its listings. */
549 public static function post_purge_link( int $post_id ): string {
550 return wp_nonce_url(
551 add_query_arg(
552 array(
553 'action' => self::POST_ACTION,
554 'post' => $post_id,
555 ),
556 admin_url( 'admin-post.php' )
557 ),
558 self::POST_ACTION . '_' . $post_id
559 );
560 }
561
562 /**
563 * Every URL that goes stale when one post changes.
564 *
565 * The set follows WP Rocket's `rocket_get_purge_urls()`, which is the
566 * closest thing this problem has to a settled answer: the post itself,
567 * the blog page or the post-type archive it appears on, the four
568 * adjacent posts whose prev/next links now name a different neighbour,
569 * the author archive, every ancestor, and the homepage.
570 *
571 * Term archives are deliberately NOT in the set — Rocket leaves them out
572 * too. A post can carry dozens of terms, and purging every one of them
573 * turns a one-post edit back into the broad sweep this feature exists to
574 * avoid.
575 *
576 * @return string[] Absolute URLs, de-duplicated.
577 */
578 public static function post_purge_urls( \WP_Post $post ): array {
579 $urls = array();
580
581 $permalink = self::permalink_of( (int) $post->ID );
582 if ( '' !== $permalink ) {
583 $urls[] = $permalink;
584 }
585
586 // The blog page for posts; the post-type archive for anything else.
587 if ( 'post' === $post->post_type ) {
588 $page_for_posts = (int) get_option( 'page_for_posts' );
589 if ( $page_for_posts > 0 ) {
590 $urls[] = (string) get_permalink( $page_for_posts );
591 }
592 } else {
593 $archive = get_post_type_archive_link( $post->post_type );
594 if ( is_string( $archive ) && '' !== $archive ) {
595 $urls[] = $archive;
596 }
597 }
598
599 // The neighbours whose own prev/next links now point somewhere else.
600 // Read in the post's own context: get_adjacent_post() works off the
601 // global $post, which on an admin screen is not the one being purged.
602 $urls = array_merge( $urls, self::adjacent_post_urls( $post ) );
603
604 $author = get_author_posts_url( (int) $post->post_author );
605 if ( is_string( $author ) && '' !== $author ) {
606 $urls[] = $author;
607 }
608
609 foreach ( get_post_ancestors( $post ) as $ancestor_id ) {
610 $link = self::permalink_of( (int) $ancestor_id );
611 if ( '' !== $link ) {
612 $urls[] = $link;
613 }
614 }
615
616 $urls[] = home_url( '/' );
617
618 /**
619 * Filter the URLs cleared when one post is purged.
620 *
621 * @param string[] $urls Absolute URLs.
622 * @param \WP_Post $post The post being purged.
623 */
624 $urls = (array) apply_filters( 'xspeed_post_purge_urls', $urls, $post );
625
626 $urls = array_filter( $urls, static fn( $url ) => is_string( $url ) && '' !== $url );
627
628 return array_values( array_unique( $urls ) );
629 }
630
631 /**
632 * Permalinks of the four posts adjacent to this one: previous and next,
633 * each in the whole timeline and within a shared term.
634 *
635 * @return string[]
636 */
637 private static function adjacent_post_urls( \WP_Post $post ): array {
638 $urls = array();
639
640 // get_adjacent_post() reads the global $post. Swap it for the one
641 // being purged and put it back, or on an edit screen we would collect
642 // the neighbours of whatever WordPress happened to have loaded.
643 $previous_global = $GLOBALS['post'] ?? null;
644 $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below.
645
646 foreach ( array( array( false, true ), array( true, true ), array( false, false ), array( true, false ) ) as $args ) {
647 list( $same_term, $previous ) = $args;
648 $adjacent = get_adjacent_post( $same_term, '', $previous );
649 if ( $adjacent instanceof \WP_Post ) {
650 $link = self::permalink_of( (int) $adjacent->ID );
651 if ( '' !== $link ) {
652 $urls[] = $link;
653 }
654 }
655 }
656
657 if ( null === $previous_global ) {
658 unset( $GLOBALS['post'] );
659 } else {
660 $GLOBALS['post'] = $previous_global; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring.
661 }
662
663 return $urls;
664 }
665
666 /**
667 * Purge one post and everything that lists it.
668 *
669 * Same shape as handle(): the nonce is bound to the post id, the
670 * capability is checked first, and the result is carried to the next
671 * screen so the user is told what happened.
672 */
673 public static function handle_post(): void {
674 if ( ! self::user_can_purge() ) {
675 wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
676 }
677
678 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce is checked on the next line, against this value.
679 $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
680 check_admin_referer( self::POST_ACTION . '_' . $post_id );
681
682 $post = $post_id > 0 ? get_post( $post_id ) : null;
683 if ( ! $post instanceof \WP_Post ) {
684 wp_die( esc_html__( 'That post does not exist.', 'xspeed' ), 400 );
685 }
686
687 $count = 0;
688 $cleared = 0;
689 foreach ( self::post_purge_urls( $post ) as $url ) {
690 // Count only what we actually acted on. The set is filterable, so
691 // an off-site URL added through xspeed_post_purge_urls is skipped
692 // here — reporting it as cleared would inflate the notice.
693 if ( ! self::is_local_url( $url ) ) {
694 continue;
695 }
696 ++$cleared;
697 $count += Cache::purge_url( $url, __( 'admin', 'xspeed' ) );
698 }
699
700 self::record_result( self::permalink_of( $post_id ), $count, $cleared );
701
702 wp_safe_redirect( self::redirect_target( wp_get_referer() ) );
703 exit;
704 }
705
706 /**
707 * Does a pending result describe the page currently being rendered?
708 *
709 * Compared on path alone: the result was recorded against an absolute URL
710 * built from the request that purged it, and the host on the request
711 * showing the notice is the same one by construction.
712 *
713 * @param array{url:string,count:int,urls:int} $result
714 */
715 private static function result_is_about_this_request( array $result ): bool {
716 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared, never output or stored.
717 if ( '' === $uri ) {
718 return false;
719 }
720 $here = untrailingslashit( (string) strtok( $uri, '?' ) );
721 $purged = untrailingslashit( (string) wp_parse_url( $result['url'], PHP_URL_PATH ) );
722
723 return $here === $purged;
724 }
725
726 /**
727 * Is this URL served by this site?
728 *
729 * Compared with port attached, because the cache key hashes the host WITH
730 * its port — `site.test` and `site.test:8080` are separate buckets.
731 *
732 * More than one host can be the right answer: home_url() and site_url()
733 * differ on a WordPress-in-a-subdirectory install, and a proxy or a mapped
734 * domain means the host the page was CACHED under is the one on the
735 * request rather than the one in the option. All three are accepted. The
736 * real gate is the nonce, which is bound to this exact URL and mintable
737 * only by a user who can already purge; this check exists so a
738 * hand-edited URL can't point `purge_url()` at some other site's bucket.
739 */
740 public static function is_local_url( string $url ): bool {
741 $target = wp_parse_url( $url );
742 if ( ! is_array( $target ) || empty( $target['host'] ) ) {
743 return false;
744 }
745 $host = strtolower( (string) $target['host'] );
746 if ( ! empty( $target['port'] ) ) {
747 $host .= ':' . (int) $target['port'];
748 }
749 return in_array( $host, self::known_hosts(), true );
750 }
751
752 /**
753 * Hosts (with port where non-default) this install answers on.
754 *
755 * @return string[]
756 */
757 private static function known_hosts(): array {
758 $hosts = array();
759 foreach ( array( home_url( '/' ), site_url( '/' ) ) as $known ) {
760 $parts = wp_parse_url( (string) $known );
761 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
762 continue;
763 }
764 $host = strtolower( (string) $parts['host'] );
765 if ( ! empty( $parts['port'] ) ) {
766 $host .= ':' . (int) $parts['port'];
767 }
768 $hosts[] = $host;
769 }
770 if ( ! empty( $_SERVER['HTTP_HOST'] ) ) {
771 $hosts[] = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) );
772 }
773 return array_values( array_unique( $hosts ) );
774 }
775 }
776