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 ); } self::record_result( $url, Cache::purge_url( $url, __( 'admin', 'xspeed' ) ), 1 ); 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 ): 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, ), 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}|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}|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 ) ), ); } /** * 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} $result */ private static function message( array $result ): string { $path = (string) wp_parse_url( $result['url'], PHP_URL_PATH ); $path = '' === $path ? '/' : $path; // 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; 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

', $result['count'] > 0 ? 'success' : 'info', esc_html( self::message( $result ) ) ); } /** * 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 . ' · ' . ( $result['count'] > 0 ? 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 set follows WP Rocket's `rocket_get_purge_urls()`, which is the * closest thing this problem has to a settled answer: the post itself, * the blog page or the post-type archive it appears on, the four * adjacent posts whose prev/next links now name a different neighbour, * the author archive, every ancestor, and the homepage. * * Term archives are deliberately NOT in the set — Rocket leaves them out * too. A post can carry dozens of terms, and purging every one of them * turns a one-post edit back into the broad sweep this feature exists to * avoid. * * @return string[] Absolute URLs, de-duplicated. */ public static function post_purge_urls( \WP_Post $post ): array { $urls = array(); $permalink = self::permalink_of( (int) $post->ID ); if ( '' !== $permalink ) { $urls[] = $permalink; } // The blog page for posts; the post-type archive for anything else. if ( 'post' === $post->post_type ) { $page_for_posts = (int) get_option( 'page_for_posts' ); if ( $page_for_posts > 0 ) { $urls[] = (string) get_permalink( $page_for_posts ); } } else { $archive = get_post_type_archive_link( $post->post_type ); if ( is_string( $archive ) && '' !== $archive ) { $urls[] = $archive; } } // The neighbours whose own prev/next links now point somewhere else. // Read in the post's own context: get_adjacent_post() works off the // global $post, which on an admin screen is not the one being purged. $urls = array_merge( $urls, self::adjacent_post_urls( $post ) ); $author = get_author_posts_url( (int) $post->post_author ); if ( is_string( $author ) && '' !== $author ) { $urls[] = $author; } foreach ( get_post_ancestors( $post ) as $ancestor_id ) { $link = self::permalink_of( (int) $ancestor_id ); if ( '' !== $link ) { $urls[] = $link; } } $urls[] = home_url( '/' ); /** * Filter the URLs cleared when one post is purged. * * @param string[] $urls Absolute URLs. * @param \WP_Post $post The post being purged. */ $urls = (array) apply_filters( 'xspeed_post_purge_urls', $urls, $post ); $urls = array_filter( $urls, static fn( $url ) => is_string( $url ) && '' !== $url ); return array_values( array_unique( $urls ) ); } /** * Permalinks of the four posts adjacent to this one: previous and next, * each in the whole timeline and within a shared term. * * @return string[] */ private static function adjacent_post_urls( \WP_Post $post ): array { $urls = array(); // get_adjacent_post() reads the global $post. Swap it for the one // being purged and put it back, or on an edit screen we would collect // the neighbours of whatever WordPress happened to have loaded. $previous_global = $GLOBALS['post'] ?? null; $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below. foreach ( array( array( false, true ), array( true, true ), array( false, false ), array( true, false ) ) as $args ) { list( $same_term, $previous ) = $args; $adjacent = get_adjacent_post( $same_term, '', $previous ); if ( $adjacent instanceof \WP_Post ) { $link = self::permalink_of( (int) $adjacent->ID ); if ( '' !== $link ) { $urls[] = $link; } } } if ( null === $previous_global ) { unset( $GLOBALS['post'] ); } else { $GLOBALS['post'] = $previous_global; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring. } return $urls; } /** * 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 ); } $count = 0; $cleared = 0; foreach ( self::post_purge_urls( $post ) as $url ) { // Count only what we actually acted 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. if ( ! self::is_local_url( $url ) ) { continue; } ++$cleared; $count += Cache::purge_url( $url, __( 'admin', 'xspeed' ) ); } self::record_result( self::permalink_of( $post_id ), $count, $cleared ); 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} $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 ) ); } }