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 $actions Existing row actions. * @param \WP_Post $post Row's post. * @return array */ 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( '%2$s', 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( '

%2$s

', 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 $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 $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 ) ); } }