# xspeed/1.4.0/includes/class-purge-ui.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.4.0. 775 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.4.0/code/includes/class-purge-ui.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.4.0/raw/includes/class-purge-ui.php
- Modified: 2026-10-07T09:32:52+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.4.0/code/includes/class-purge-ui.php#L10-L20`.

```php
<?php
/**
 * Single-URL purge surfaces: admin bar, post row actions, edit screen.
 *
 * `Cache::purge_url()` has always been able to clear one page, but the only
 * ways in were WP-CLI and the MCP tool. Someone who had just corrected a typo
 * on one page had to throw away the whole cache to see the fix, which on a
 * large site costs every other page its warm entry too. This class is the
 * missing entry point, in the three places the errand actually starts:
 *
 * - the admin bar, while looking at the page (front end) or editing it,
 * - the Posts/Pages list, in the hover row actions,
 * - the edit screen, in the xSpeed meta box.
 *
 * All three build the same nonce-protected admin-post URL and land in the
 * same handler, so there is one authorization path rather than three.
 *
 * @package XSpeed
 */

declare(strict_types=1);

namespace XSpeed;

defined( 'ABSPATH' ) || exit;

final class Purge_Ui {

	/** admin-post action for purging one URL (the front-end admin bar). */
	public const ACTION = 'xspeed_purge_url';

	/**
	 * admin-post action for purging one POST and the pages that list it.
	 *
	 * Separate from ACTION because the scope genuinely differs, and the nonce
	 * has to be bound to a post id rather than to a URL.
	 */
	public const POST_ACTION = 'xspeed_purge_post';

	/** Per-user transient prefix carrying one purge's result across the redirect. */
	private const NOTICE_KEY = 'xspeed_purge_result_';

	public static function boot(): void {
		add_action( 'admin_post_' . self::ACTION, array( __CLASS__, 'handle' ) );
		add_action( 'admin_post_' . self::POST_ACTION, array( __CLASS__, 'handle_post' ) );
		add_filter( 'post_row_actions', array( __CLASS__, 'row_action' ), 10, 2 );
		add_filter( 'page_row_actions', array( __CLASS__, 'row_action' ), 10, 2 );
		add_action( 'admin_notices', array( __CLASS__, 'render_admin_notice' ) );
		// After Cache::admin_bar_purge() at 100, so the parent node it adds
		// already exists and this call merges into it.
		add_action( 'admin_bar_menu', array( __CLASS__, 'flag_admin_bar_result' ), 110 );
	}

	/**
	 * Can this user purge at all? Same capability the admin-bar menu and the
	 * dashboard purge button use — purging is a site-wide performance action,
	 * not something an author gets over their own posts.
	 */
	public static function user_can_purge(): bool {
		return current_user_can( 'manage_options' );
	}

	/**
	 * The URL the CURRENT screen is about, or '' when the screen isn't about
	 * one page.
	 *
	 * Two contexts resolve, deliberately:
	 *
	 * - Front end: whatever is being viewed. Taken from REQUEST_URI rather
	 *   than the queried object's permalink, because an archive or a paged
	 *   URL has no permalink at all, and the page on screen is the one the
	 *   user means.
	 * - Post edit screen: the edited post's permalink, since the admin URL
	 *   itself is never cached.
	 *
	 * Anywhere else there is no single page in view, so the caller hides the
	 * menu item rather than guessing.
	 */
	public static function current_target(): string {
		if ( ! is_admin() ) {
			if ( ! self::request_is_path_addressable() ) {
				return '';
			}
			$uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- esc_url_raw sanitizes.
			if ( '' === $uri ) {
				return '';
			}
			// Drop the query string, exactly as cache_key() does with
			// strtok( $uri, '?' ) — /post and /post?utm_source=x are one
			// entry, so a link carrying the params would suggest it targets
			// something narrower than it does.
			$uri = (string) strtok( $uri, '?' );

			$root = self::request_root();
			return '' === $root ? '' : $root . $uri;
		}

		$post_id = self::edited_post_id();
		if ( $post_id <= 0 ) {
			return '';
		}
		return self::permalink_of( $post_id );
	}

	/**
	 * Post being edited on the current admin screen, or 0.
	 *
	 * `get_the_ID()` is unreliable this early on post.php, so read the
	 * request directly — post.php uses `post`, and nothing else on the edit
	 * screens carries a post id we should act on.
	 */
	/**
	 * Is the CURRENT front-end request one that `Cache::purge_url()` can
	 * actually reach by path?
	 *
	 * Three request shapes get a cache key that no path can address, because
	 * `cache_key()` builds them from something other than the URI:
	 *
	 * - a cacheable 404 shares one generic `md5( $host . '|404' )` entry per
	 *   host, so every 404 on the site is the same file,
	 * - a cached search folds the term in as `|s=…`, and the query string is
	 *   otherwise stripped,
	 * - a query-form feed (`/?feed=rss2`) folds the type in as `|feed=…`.
	 *
	 * Offering "Purge this URL" on those would purge the bare path instead —
	 * on a search page, the HOME page. Since the redirect carries no success
	 * notice, that lands as a silent wrong answer, so the item is hidden
	 * instead. (Where the matching feature is switched off the page is not
	 * cached at all, and hiding costs nothing.)
	 */
	private static function request_is_path_addressable(): bool {
		if ( function_exists( 'is_404' ) && is_404() ) {
			return false;
		}
		if ( function_exists( 'is_search' ) && is_search() ) {
			return false;
		}
		if ( function_exists( 'is_feed' ) && is_feed() ) {
			// A pretty-permalink feed (/feed/rss/) carries the type in the
			// path and is fine; only the query form is unreachable.
			$uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared, never output or stored.
			if ( false !== strpos( $uri, 'feed=' ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Scheme + host (+ port) the CURRENT request came in on, with no path.
	 *
	 * The host comes from HTTP_HOST rather than home_url() because that is
	 * what `cache_key()` hashed when the entry was written. Where the two
	 * disagree — a proxy forwarding `Host: site.com:8080`, a bare-vs-www
	 * mismatch, a mapped domain — home_url()'s host computes a different md5,
	 * finds no file and reports "already cold" while the page keeps serving
	 * HIT.
	 *
	 * REQUEST_URI is already absolute from the domain root, so it must NOT be
	 * passed through home_url(): on a subdirectory install that prepends the
	 * subdirectory a second time and the link points at `/blog/blog/about/`.
	 */
	private static function request_root(): string {
		$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
		if ( '' === $host ) {
			return self::home_root();
		}
		return ( is_ssl() ? 'https' : 'http' ) . '://' . $host;
	}

	/** Scheme + host (+ port) of home_url(), with no path. */
	private static function home_root(): string {
		$home = wp_parse_url( home_url( '/' ) );
		if ( ! is_array( $home ) || empty( $home['host'] ) ) {
			return '';
		}
		$root = ( $home['scheme'] ?? 'http' ) . '://' . $home['host'];
		if ( ! empty( $home['port'] ) ) {
			$root .= ':' . (int) $home['port'];
		}
		return $root;
	}

	private static function edited_post_id(): int {
		global $pagenow;
		if ( 'post.php' !== $pagenow ) {
			return 0;
		}
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading which post is on screen, no state change.
		return isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
	}

	/**
	 * Permalink of a post, but only when the post is something a visitor can
	 * actually reach — a draft or a non-viewable type has no cached page to
	 * clear, so offering the action would be a button that always reports
	 * "already cold".
	 */
	public static function permalink_of( int $post_id ): string {
		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return '';
		}
		if ( 'publish' !== $post->post_status ) {
			return '';
		}
		if ( ! is_post_type_viewable( $post->post_type ) ) {
			return '';
		}
		$link = get_permalink( $post );
		return is_string( $link ) ? $link : '';
	}

	/**
	 * Nonce-protected admin-post URL that purges one URL.
	 *
	 * The nonce action is bound to the target URL, so a link leaked from one
	 * page can't be replayed to purge a different one. The URL is hashed into
	 * the action rather than concatenated raw to keep the action short and
	 * free of characters `wp_create_nonce` would otherwise carry verbatim.
	 */
	public static function purge_link( string $url ): string {
		return wp_nonce_url(
			add_query_arg(
				array(
					'action' => self::ACTION,
					// add_query_arg() does NOT encode values (build_query()
					// passes $urlencode = false), so a URL carrying its own
					// query string would otherwise swallow the nonce.
					'url'    => rawurlencode( $url ),
				),
				admin_url( 'admin-post.php' )
			),
			self::nonce_action( $url )
		);
	}

	private static function nonce_action( string $url ): string {
		return self::ACTION . '_' . md5( $url );
	}

	/**
	 * "Purge cache" in the Posts/Pages hover row actions.
	 *
	 * @param array<string,string> $actions Existing row actions.
	 * @param \WP_Post             $post    Row's post.
	 * @return array<string,string>
	 */
	public static function row_action( $actions, $post ) {
		if ( ! is_array( $actions ) || ! $post instanceof \WP_Post ) {
			return $actions;
		}
		if ( ! self::user_can_purge() ) {
			return $actions;
		}
		if ( '' === self::permalink_of( (int) $post->ID ) ) {
			return $actions;
		}

		$actions['xspeed_purge'] = sprintf(
			'<a href="%1$s">%2$s</a>',
			esc_url( self::post_purge_link( (int) $post->ID ) ),
			esc_html__( 'Purge cache', 'xspeed' )
		);
		return $actions;
	}

	/**
	 * Purge one URL, then send the user back where they came from.
	 *
	 * The URL is re-validated against this site's home host instead of being
	 * trusted from the query string. `Cache::purge_url()` derives its cache
	 * directory from the host it is given, so an off-site host would have it
	 * walking a bucket that isn't ours.
	 */
	public static function handle(): void {
		if ( ! self::user_can_purge() ) {
			wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
		}

		// PHP has already percent-decoded $_GET once, which undoes the
		// rawurlencode() purge_link() applied. Decoding a second time here
		// would corrupt any URL containing a literal percent sequence, and
		// the nonce below is bound to the value BEFORE that encoding.
		$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.
		check_admin_referer( self::nonce_action( $url ) );

		if ( '' === $url || ! self::is_local_url( $url ) ) {
			wp_die( esc_html__( 'That URL is not on this site.', 'xspeed' ), 400 );
		}

		$purge = Cache::purge_url_reported( $url, __( 'admin', 'xspeed' ) );
		self::record_result( $url, $purge['removed'], 1, false, $purge['forwarded'] );

		wp_safe_redirect( self::redirect_target( wp_get_referer() ) );
		exit;
	}

	/**
	 * Where to send the user back to.
	 *
	 * Cache::safe_purge_redirect() strips `action` from the referer so a
	 * one-shot admin action (a plugin upload) isn't replayed on load. That is
	 * right everywhere except the editor: post.php with no `action` falls
	 * through to its default case and redirects to edit.php, so purging from
	 * the meta box threw the user out of the post they were editing. Rebuild
	 * the edit URL for that one case.
	 *
	 * @param string|false $referer Raw wp_get_referer() value.
	 */
	private static function redirect_target( $referer ): string {
		$referer = is_string( $referer ) ? $referer : '';
		if ( '' !== $referer ) {
			$path = (string) wp_parse_url( $referer, PHP_URL_PATH );
			if ( preg_match( '#/wp-admin/post\.php$#', $path ) ) {
				parse_str( (string) wp_parse_url( $referer, PHP_URL_QUERY ), $query );
				$post_id = isset( $query['post'] ) ? absint( $query['post'] ) : 0;
				if ( $post_id > 0 ) {
					return get_edit_post_link( $post_id, 'raw' ) ?: admin_url();
				}
			}
		}

		return Cache::safe_purge_redirect( $referer );
	}

	/**
	 * Remember what the purge did, for the page the user lands on next.
	 *
	 * A transient rather than a query argument: the front-end redirect goes
	 * back to the page that was just purged, and hanging `?xspeed_purged=1`
	 * off it would leave the marker sitting in the address bar and in
	 * anything the visitor copies out of it.
	 *
	 * Keyed per user, so two admins purging at once don't read each other's
	 * result, and short-lived because it is only ever meant to survive one
	 * redirect.
	 */
	private static function record_result( string $url, int $count, int $urls = 1, bool $whole_site = false, array $forwarded = array() ): void {
		$user_id = get_current_user_id();
		if ( $user_id <= 0 ) {
			return;
		}
		set_transient(
			self::NOTICE_KEY . $user_id,
			array(
				'url'        => $url,
				'count'      => $count,
				'urls'       => $urls,
				'whole_site' => $whole_site,
				'forwarded'  => array_values( array_map( 'strval', $forwarded ) ),
			),
			MINUTE_IN_SECONDS
		);
	}

	/**
	 * Read the pending result and clear it. Consumed once: whichever surface
	 * renders first owns it, and a reload afterwards shows nothing.
	 *
	 * @return array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]}|null
	 */
	private static function take_result(): ?array {
		$result = self::peek_result();
		if ( null === $result ) {
			return null;
		}
		delete_transient( self::NOTICE_KEY . get_current_user_id() );

		return $result;
	}

	/**
	 * Read the pending result WITHOUT clearing it, so a caller that turns out
	 * not to be the right place to show it can leave it for the next screen.
	 *
	 * @return array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]}|null
	 */
	private static function peek_result(): ?array {
		$user_id = get_current_user_id();
		if ( $user_id <= 0 ) {
			return null;
		}
		$result = get_transient( self::NOTICE_KEY . $user_id );
		if ( ! is_array( $result ) || ! isset( $result['url'] ) ) {
			return null;
		}

		return array(
			'url'        => (string) $result['url'],
			'count'      => (int) ( $result['count'] ?? 0 ),
			'urls'       => max( 1, (int) ( $result['urls'] ?? 1 ) ),
			'whole_site' => ! empty( $result['whole_site'] ),
			'forwarded'  => isset( $result['forwarded'] ) && is_array( $result['forwarded'] ) ? array_values( array_map( 'strval', $result['forwarded'] ) ) : array(),
		);
	}

	/**
	 * What to tell the user.
	 *
	 * A count of zero is reported as such rather than as success. The page
	 * having no cached copy is the single most useful thing to know here —
	 * it means either the purge already happened or the page was never
	 * cacheable, and calling that "cleared" sends people looking for a bug
	 * in the wrong place.
	 *
	 * @param array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]} $result
	 */
	private static function message( array $result ): string {
		$path = (string) wp_parse_url( $result['url'], PHP_URL_PATH );
		$path = '' === $path ? '/' : $path;

		if ( ! empty( $result['whole_site'] ) ) {
			return sprintf(
				/* translators: 1: URL path of the post, 2: number of pages that list it, 3: number of files removed. */
				_n(
					'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).',
					'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).',
					$result['count'],
					'xspeed'
				),
				$path,
				$result['urls'],
				$result['count']
			);
		}

		// A post purge also clears the pages that list it, so say so — a user
		// who asked for one page and sees "12 files" should not have to guess
		// whether something over-reached.
		$scope = $result['urls'] > 1
			? sprintf(
				/* translators: 1: URL path of the post, 2: number of OTHER pages also cleared. */
				_n(
					'%1$s and %2$d page that lists it',
					'%1$s and %2$d pages that list it',
					$result['urls'] - 1,
					'xspeed'
				),
				$path,
				$result['urls'] - 1
			)
			: $path;

		$forwarded = self::forwarded_of( $result );
		if ( $result['count'] < 1 && '' !== $forwarded ) {
			return sprintf(
				/* translators: 1: what was purged, 2: caches in front of the site, comma-separated. */
				__( 'xSpeed: sent the purge for %1$s to %2$s. xSpeed\'s own cache held no copy.', 'xspeed' ),
				$scope,
				$forwarded
			);
		}

		if ( $result['count'] < 1 ) {
			return sprintf(
				/* translators: %s: what was purged. */
				__( 'xSpeed: %s was not cached, so there was nothing to clear.', 'xspeed' ),
				$scope
			);
		}

		return sprintf(
			/* translators: 1: what was purged, 2: number of files removed. */
			_n(
				'xSpeed: cleared the cache for %1$s (%2$d file).',
				'xSpeed: cleared the cache for %1$s (%2$d files).',
				$result['count'],
				'xspeed'
			),
			$scope,
			$result['count']
		);
	}

	/** Admin surfaces: the row action and the editor button land here. */
	public static function render_admin_notice(): void {
		if ( ! self::user_can_purge() ) {
			return;
		}
		$result = self::take_result();
		if ( null === $result ) {
			return;
		}
		printf(
			'<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
			self::cleared_anything( $result ) ? 'success' : 'info',
			esc_html( self::message( $result ) )
		);
	}

	/**
	 * Whether the purge cleared anything anywhere: a local file, or a
	 * cache in front of the site that took it.
	 *
	 * @param array<string,mixed> $result
	 */
	private static function cleared_anything( array $result ): bool {
		return $result['count'] > 0 || '' !== self::forwarded_of( $result );
	}

	/**
	 * The caches a purge was sent to, comma-separated, or ''.
	 *
	 * @param array<string,mixed> $result
	 */
	private static function forwarded_of( array $result ): string {
		$forwarded = isset( $result['forwarded'] ) && is_array( $result['forwarded'] ) ? $result['forwarded'] : array();
		return implode( ', ', array_filter( array_map( 'strval', $forwarded ) ) );
	}

	/**
	 * Front end: `admin_notices` never fires there, and the redirect lands on
	 * the purged page itself. Say it in the admin bar instead — the one piece
	 * of our UI already on screen, styled by core, needing no stylesheet and
	 * no script on a front-end page view.
	 *
	 * @param \WP_Admin_Bar $wp_admin_bar
	 */
	public static function flag_admin_bar_result( $wp_admin_bar ): void {
		if ( is_admin() || ! self::user_can_purge() ) {
			return; // In wp-admin the notice above owns the result.
		}
		if ( ! is_object( $wp_admin_bar ) || ! method_exists( $wp_admin_bar, 'get_node' ) ) {
			return;
		}
		$node = $wp_admin_bar->get_node( 'xspeed-purge' );
		if ( ! $node ) {
			return; // Menu not rendered (no capability, or a filter removed it).
		}
		// Peek before consuming. The redirect lands on the page that was
		// purged, but the user may have opened another tab first — burning
		// the confirmation on an unrelated front-end view would leave the
		// purge looking like it did nothing. Anything not aimed at THIS page
		// is left for the screen it belongs to; it expires on its own.
		$result = self::peek_result();
		if ( null === $result || ! self::result_is_about_this_request( $result ) ) {
			return;
		}
		self::take_result();

		$wp_admin_bar->add_node(
			array(
				'id'    => 'xspeed-purge',
				'title' => $node->title . ' · ' . (
					self::cleared_anything( $result )
						? esc_html__( 'cleared', 'xspeed' )
						: esc_html__( 'was not cached', 'xspeed' )
				),
				'meta'  => array( 'title' => self::message( $result ) ),
			)
		);
	}

	/**
	 * The "purge what I'm looking at" admin-bar item for this screen, or null
	 * when the screen isn't about one thing.
	 *
	 * The two contexts want different scopes, which is why this returns a
	 * whole node rather than a URL:
	 *
	 * - On the front end you are looking at ONE rendered page, and that page
	 *   is what you want gone. Anything else would be a surprise.
	 * - On a post edit screen you have just changed a post, and the post's own
	 *   URL is rarely the only page that got stale — the homepage, the archive
	 *   and the neighbouring posts all render its title. Purging just the
	 *   permalink there leaves the visitor's route TO the post showing the old
	 *   version, which reads as "the purge didn't work".
	 *
	 * @return array{title:string,href:string}|null
	 */
	public static function context_node(): ?array {
		if ( ! self::user_can_purge() ) {
			return null;
		}

		if ( is_admin() ) {
			$post_id = self::edited_post_id();
			if ( $post_id <= 0 || '' === self::permalink_of( $post_id ) ) {
				return null;
			}
			return array(
				'title' => __( 'Purge this post', 'xspeed' ),
				'href'  => self::post_purge_link( $post_id ),
			);
		}

		$target = self::current_target();
		if ( '' === $target ) {
			return null;
		}
		return array(
			'title' => __( 'Purge this URL', 'xspeed' ),
			'href'  => self::purge_link( $target ),
		);
	}

	/** Nonce-protected admin-post URL that purges one post and its listings. */
	public static function post_purge_link( int $post_id ): string {
		return wp_nonce_url(
			add_query_arg(
				array(
					'action' => self::POST_ACTION,
					'post'   => $post_id,
				),
				admin_url( 'admin-post.php' )
			),
			self::POST_ACTION . '_' . $post_id
		);
	}

	/**
	 * Every URL that goes stale when one post changes.
	 *
	 * The same list the automatic purge on save clears, from
	 * Affected_Pages, so "Purge this post" and a save never disagree. It
	 * covers the post, the home page and blog page, its post-type archive,
	 * every public term it is in with parent terms, the author and date
	 * archives, every page of each, the feeds, the four adjacent posts and
	 * any ancestors. (An earlier version left terms out on the grounds that
	 * WP Rocket does; current WP Rocket purges terms, parents and their
	 * pagination too.)
	 *
	 * The `xspeed_post_purge_urls` filter is applied inside the builder.
	 *
	 * @return string[] Absolute URLs, de-duplicated.
	 */
	public static function post_purge_urls( \WP_Post $post ): array {
		return Affected_Pages::for_post( $post );
	}

	/**
	 * Clear one post's pages, or the whole site when they are more than a
	 * save would name one by one (Affected_Pages::LIMIT).
	 *
	 * The same limit as the automatic purge on save. Over it, every page
	 * would go to each cache in front one at a time: on a 5,000-post blog
	 * the list was 1,022 URLs, each a blocking request to Nginx Helper.
	 *
	 * @return array{count:int,urls:int,whole_site:bool} Files removed, pages named, and whether the whole site went instead.
	 */
	public static function purge_post( \WP_Post $post ): array {
		// Count only what we actually act on. The set is filterable, so an
		// off-site URL added through xspeed_post_purge_urls is skipped here;
		// reporting it as cleared would inflate the notice.
		$local = array_values( array_filter( self::post_purge_urls( $post ), array( self::class, 'is_local_url' ) ) );
		if ( count( $local ) > Affected_Pages::LIMIT ) {
			$count = Cache::purge_all(
				__( 'admin', 'xspeed' ),
				null,
				array(
					'scope'    => 'site',
					'intent'   => 'content',
					'urls'     => array(),
					'fallback' => Cache::FALLBACK_LIMIT,
				)
			);
			return array(
				'count'      => (int) $count,
				'urls'       => count( $local ),
				'whole_site' => true,
			);
		}
		return array(
			'count'      => array() === $local ? 0 : Cache::purge_urls( $local, __( 'admin', 'xspeed' ) ),
			'urls'       => count( $local ),
			'whole_site' => false,
		);
	}

	/**
	 * Purge one post and everything that lists it.
	 *
	 * Same shape as handle(): the nonce is bound to the post id, the
	 * capability is checked first, and the result is carried to the next
	 * screen so the user is told what happened.
	 */
	public static function handle_post(): void {
		if ( ! self::user_can_purge() ) {
			wp_die( esc_html__( 'Unauthorized.', 'xspeed' ), 403 );
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce is checked on the next line, against this value.
		$post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;
		check_admin_referer( self::POST_ACTION . '_' . $post_id );

		$post = $post_id > 0 ? get_post( $post_id ) : null;
		if ( ! $post instanceof \WP_Post ) {
			wp_die( esc_html__( 'That post does not exist.', 'xspeed' ), 400 );
		}

		$run    = Cache::report_forwarding(
			static function () use ( $post ): array {
				return self::purge_post( $post );
			}
		);
		$result = $run['result'];

		self::record_result( self::permalink_of( $post_id ), $result['count'], $result['urls'], $result['whole_site'], $run['forwarded'] );

		wp_safe_redirect( self::redirect_target( wp_get_referer() ) );
		exit;
	}

	/**
	 * Does a pending result describe the page currently being rendered?
	 *
	 * Compared on path alone: the result was recorded against an absolute URL
	 * built from the request that purged it, and the host on the request
	 * showing the notice is the same one by construction.
	 *
	 * @param array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]} $result
	 */
	private static function result_is_about_this_request( array $result ): bool {
		$uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared, never output or stored.
		if ( '' === $uri ) {
			return false;
		}
		$here   = untrailingslashit( (string) strtok( $uri, '?' ) );
		$purged = untrailingslashit( (string) wp_parse_url( $result['url'], PHP_URL_PATH ) );

		return $here === $purged;
	}

	/**
	 * Is this URL served by this site?
	 *
	 * Compared with port attached, because the cache key hashes the host WITH
	 * its port — `site.test` and `site.test:8080` are separate buckets.
	 *
	 * More than one host can be the right answer: home_url() and site_url()
	 * differ on a WordPress-in-a-subdirectory install, and a proxy or a mapped
	 * domain means the host the page was CACHED under is the one on the
	 * request rather than the one in the option. All three are accepted. The
	 * real gate is the nonce, which is bound to this exact URL and mintable
	 * only by a user who can already purge; this check exists so a
	 * hand-edited URL can't point `purge_url()` at some other site's bucket.
	 */
	public static function is_local_url( string $url ): bool {
		$target = wp_parse_url( $url );
		if ( ! is_array( $target ) || empty( $target['host'] ) ) {
			return false;
		}
		$host = strtolower( (string) $target['host'] );
		if ( ! empty( $target['port'] ) ) {
			$host .= ':' . (int) $target['port'];
		}
		return in_array( $host, self::known_hosts(), true );
	}

	/**
	 * Hosts (with port where non-default) this install answers on.
	 *
	 * @return string[]
	 */
	private static function known_hosts(): array {
		$hosts = array();
		foreach ( array( home_url( '/' ), site_url( '/' ) ) as $known ) {
			$parts = wp_parse_url( (string) $known );
			if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
				continue;
			}
			$host = strtolower( (string) $parts['host'] );
			if ( ! empty( $parts['port'] ) ) {
				$host .= ':' . (int) $parts['port'];
			}
			$hosts[] = $host;
		}
		if ( ! empty( $_SERVER['HTTP_HOST'] ) ) {
			$hosts[] = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) );
		}
		return array_values( array_unique( $hosts ) );
	}
}

```
