| @@ -288,9 +288,10 @@ | ||
| 288 | 288 | if ( '' === $url || ! self::is_local_url( $url ) ) { |
| 289 | 289 | wp_die( esc_html__( 'That URL is not on this site.', 'xspeed' ), 400 ); |
| 290 | 290 | } |
| 291 | 291 | |
| 292 | - self::record_result( $url, Cache::purge_url( $url, __( 'admin', 'xspeed' ) ), 1 ); | |
| 292 | + $purge = Cache::purge_url_reported( $url, __( 'admin', 'xspeed' ) ); | |
| 293 | + self::record_result( $url, $purge['removed'], 1, false, $purge['forwarded'] ); | |
| 293 | 294 | |
| 294 | 295 | wp_safe_redirect( self::redirect_target( wp_get_referer() ) ); |
| 295 | 296 | exit; |
| 296 | 297 | } |
| @@ -334,9 +335,9 @@ | ||
| 334 | 335 | * Keyed per user, so two admins purging at once don't read each other's |
| 335 | 336 | * result, and short-lived because it is only ever meant to survive one |
| 336 | 337 | * redirect. |
| 337 | 338 | */ |
| 338 | - private static function record_result( string $url, int $count, int $urls = 1 ): void { | |
| 339 | + private static function record_result( string $url, int $count, int $urls = 1, bool $whole_site = false, array $forwarded = array() ): void { | |
| 339 | 340 | $user_id = get_current_user_id(); |
| 340 | 341 | if ( $user_id <= 0 ) { |
| 341 | 342 | return; |
| 342 | 343 | } |
| @@ -342,11 +343,13 @@ | ||
| 342 | 343 | } |
| 343 | 344 | set_transient( |
| 344 | 345 | self::NOTICE_KEY . $user_id, |
| 345 | 346 | array( |
| 346 | - 'url' => $url, | |
| 347 | - 'count' => $count, | |
| 348 | - 'urls' => $urls, | |
| 347 | + 'url' => $url, | |
| 348 | + 'count' => $count, | |
| 349 | + 'urls' => $urls, | |
| 350 | + 'whole_site' => $whole_site, | |
| 351 | + 'forwarded' => array_values( array_map( 'strval', $forwarded ) ), | |
| 349 | 352 | ), |
| 350 | 353 | MINUTE_IN_SECONDS |
| 351 | 354 | ); |
| 352 | 355 | } |
| @@ -354,9 +357,9 @@ | ||
| 354 | 357 | /** |
| 355 | 358 | * Read the pending result and clear it. Consumed once: whichever surface |
| 356 | 359 | * renders first owns it, and a reload afterwards shows nothing. |
| 357 | 360 | * |
| 358 | - * @return array{url:string,count:int,urls:int}|null | |
| 361 | + * @return array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]}|null | |
| 359 | 362 | */ |
| 360 | 363 | private static function take_result(): ?array { |
| 361 | 364 | $result = self::peek_result(); |
| 362 | 365 | if ( null === $result ) { |
| @@ -370,9 +373,9 @@ | ||
| 370 | 373 | /** |
| 371 | 374 | * Read the pending result WITHOUT clearing it, so a caller that turns out |
| 372 | 375 | * not to be the right place to show it can leave it for the next screen. |
| 373 | 376 | * |
| 374 | - * @return array{url:string,count:int,urls:int}|null | |
| 377 | + * @return array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]}|null | |
| 375 | 378 | */ |
| 376 | 379 | private static function peek_result(): ?array { |
| 377 | 380 | $user_id = get_current_user_id(); |
| 378 | 381 | if ( $user_id <= 0 ) { |
| @@ -383,11 +386,13 @@ | ||
| 383 | 386 | return null; |
| 384 | 387 | } |
| 385 | 388 | |
| 386 | 389 | return array( |
| 387 | - 'url' => (string) $result['url'], | |
| 388 | - 'count' => (int) ( $result['count'] ?? 0 ), | |
| 389 | - 'urls' => max( 1, (int) ( $result['urls'] ?? 1 ) ), | |
| 390 | + 'url' => (string) $result['url'], | |
| 391 | + 'count' => (int) ( $result['count'] ?? 0 ), | |
| 392 | + 'urls' => max( 1, (int) ( $result['urls'] ?? 1 ) ), | |
| 393 | + 'whole_site' => ! empty( $result['whole_site'] ), | |
| 394 | + 'forwarded' => isset( $result['forwarded'] ) && is_array( $result['forwarded'] ) ? array_values( array_map( 'strval', $result['forwarded'] ) ) : array(), | |
| 390 | 395 | ); |
| 391 | 396 | } |
| 392 | 397 | |
| 393 | 398 | /** |
| @@ -398,14 +403,29 @@ | ||
| 398 | 403 | * it means either the purge already happened or the page was never |
| 399 | 404 | * cacheable, and calling that "cleared" sends people looking for a bug |
| 400 | 405 | * in the wrong place. |
| 401 | 406 | * |
| 402 | - * @param array{url:string,count:int,urls:int} $result | |
| 407 | + * @param array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]} $result | |
| 403 | 408 | */ |
| 404 | 409 | private static function message( array $result ): string { |
| 405 | 410 | $path = (string) wp_parse_url( $result['url'], PHP_URL_PATH ); |
| 406 | 411 | $path = '' === $path ? '/' : $path; |
| 407 | 412 | |
| 413 | + if ( ! empty( $result['whole_site'] ) ) { | |
| 414 | + return sprintf( | |
| 415 | + /* translators: 1: URL path of the post, 2: number of pages that list it, 3: number of files removed. */ | |
| 416 | + _n( | |
| 417 | + 'xSpeed: %1$s is listed on %2$d pages, too many to clear one by one, so the whole site cache was cleared (%3$d file).', | |
| 418 | + 'xSpeed: %1$s is listed on %2$d pages, too many to clear one by one, so the whole site cache was cleared (%3$d files).', | |
| 419 | + $result['count'], | |
| 420 | + 'xspeed' | |
| 421 | + ), | |
| 422 | + $path, | |
| 423 | + $result['urls'], | |
| 424 | + $result['count'] | |
| 425 | + ); | |
| 426 | + } | |
| 427 | + | |
| 408 | 428 | // A post purge also clears the pages that list it, so say so — a user |
| 409 | 429 | // who asked for one page and sees "12 files" should not have to guess |
| 410 | 430 | // whether something over-reached. |
| 411 | 431 | $scope = $result['urls'] > 1 |
| @@ -421,8 +441,18 @@ | ||
| 421 | 441 | $result['urls'] - 1 |
| 422 | 442 | ) |
| 423 | 443 | : $path; |
| 424 | 444 | |
| 445 | + $forwarded = self::forwarded_of( $result ); | |
| 446 | + if ( $result['count'] < 1 && '' !== $forwarded ) { | |
| 447 | + return sprintf( | |
| 448 | + /* translators: 1: what was purged, 2: caches in front of the site, comma-separated. */ | |
| 449 | + __( 'xSpeed: sent the purge for %1$s to %2$s. xSpeed\'s own cache held no copy.', 'xspeed' ), | |
| 450 | + $scope, | |
| 451 | + $forwarded | |
| 452 | + ); | |
| 453 | + } | |
| 454 | + | |
| 425 | 455 | if ( $result['count'] < 1 ) { |
| 426 | 456 | return sprintf( |
| 427 | 457 | /* translators: %s: what was purged. */ |
| 428 | 458 | __( 'xSpeed: %s was not cached, so there was nothing to clear.', 'xspeed' ), |
| @@ -453,14 +483,34 @@ | ||
| 453 | 483 | return; |
| 454 | 484 | } |
| 455 | 485 | printf( |
| 456 | 486 | '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>', |
| 457 | - $result['count'] > 0 ? 'success' : 'info', | |
| 487 | + self::cleared_anything( $result ) ? 'success' : 'info', | |
| 458 | 488 | esc_html( self::message( $result ) ) |
| 459 | 489 | ); |
| 460 | 490 | } |
| 461 | 491 | |
| 462 | 492 | /** |
| 493 | + * Whether the purge cleared anything anywhere: a local file, or a | |
| 494 | + * cache in front of the site that took it. | |
| 495 | + * | |
| 496 | + * @param array<string,mixed> $result | |
| 497 | + */ | |
| 498 | + private static function cleared_anything( array $result ): bool { | |
| 499 | + return $result['count'] > 0 || '' !== self::forwarded_of( $result ); | |
| 500 | + } | |
| 501 | + | |
| 502 | + /** | |
| 503 | + * The caches a purge was sent to, comma-separated, or ''. | |
| 504 | + * | |
| 505 | + * @param array<string,mixed> $result | |
| 506 | + */ | |
| 507 | + private static function forwarded_of( array $result ): string { | |
| 508 | + $forwarded = isset( $result['forwarded'] ) && is_array( $result['forwarded'] ) ? $result['forwarded'] : array(); | |
| 509 | + return implode( ', ', array_filter( array_map( 'strval', $forwarded ) ) ); | |
| 510 | + } | |
| 511 | + | |
| 512 | + /** | |
| 463 | 513 | * Front end: `admin_notices` never fires there, and the redirect lands on |
| 464 | 514 | * the purged page itself. Say it in the admin bar instead — the one piece |
| 465 | 515 | * of our UI already on screen, styled by core, needing no stylesheet and |
| 466 | 516 | * no script on a front-end page view. |
| @@ -492,9 +542,9 @@ | ||
| 492 | 542 | $wp_admin_bar->add_node( |
| 493 | 543 | array( |
| 494 | 544 | 'id' => 'xspeed-purge', |
| 495 | 545 | 'title' => $node->title . ' · ' . ( |
| 496 | - $result['count'] > 0 | |
| 546 | + self::cleared_anything( $result ) | |
| 497 | 547 | ? esc_html__( 'cleared', 'xspeed' ) |
| 498 | 548 | : esc_html__( 'was not cached', 'xspeed' ) |
| 499 | 549 | ), |
| 500 | 550 | 'meta' => array( 'title' => self::message( $result ) ), |
| @@ -561,107 +611,62 @@ | ||
| 561 | 611 | |
| 562 | 612 | /** |
| 563 | 613 | * Every URL that goes stale when one post changes. |
| 564 | 614 | * |
| 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. | |
| 615 | + * The same list the automatic purge on save clears, from | |
| 616 | + * Affected_Pages, so "Purge this post" and a save never disagree. It | |
| 617 | + * covers the post, the home page and blog page, its post-type archive, | |
| 618 | + * every public term it is in with parent terms, the author and date | |
| 619 | + * archives, every page of each, the feeds, the four adjacent posts and | |
| 620 | + * any ancestors. (An earlier version left terms out on the grounds that | |
| 621 | + * WP Rocket does; current WP Rocket purges terms, parents and their | |
| 622 | + * pagination too.) | |
| 570 | 623 | * |
| 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. | |
| 624 | + * The `xspeed_post_purge_urls` filter is applied inside the builder. | |
| 575 | 625 | * |
| 576 | 626 | * @return string[] Absolute URLs, de-duplicated. |
| 577 | 627 | */ |
| 578 | 628 | 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 | + return Affected_Pages::for_post( $post ); | |
| 629 | 630 | } |
| 630 | 631 | |
| 631 | 632 | /** |
| 632 | - * Permalinks of the four posts adjacent to this one: previous and next, | |
| 633 | - * each in the whole timeline and within a shared term. | |
| 633 | + * Clear one post's pages, or the whole site when they are more than a | |
| 634 | + * save would name one by one (Affected_Pages::LIMIT). | |
| 634 | 635 | * |
| 635 | - * @return string[] | |
| 636 | + * The same limit as the automatic purge on save. Over it, every page | |
| 637 | + * would go to each cache in front one at a time: on a 5,000-post blog | |
| 638 | + * the list was 1,022 URLs, each a blocking request to Nginx Helper. | |
| 639 | + * | |
| 640 | + * @return array{count:int,urls:int,whole_site:bool} Files removed, pages named, and whether the whole site went instead. | |
| 636 | 641 | */ |
| 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 | - } | |
| 642 | + public static function purge_post( \WP_Post $post ): array { | |
| 643 | + // Count only what we actually act on. The set is filterable, so an | |
| 644 | + // off-site URL added through xspeed_post_purge_urls is skipped here; | |
| 645 | + // reporting it as cleared would inflate the notice. | |
| 646 | + $local = array_values( array_filter( self::post_purge_urls( $post ), array( self::class, 'is_local_url' ) ) ); | |
| 647 | + if ( count( $local ) > Affected_Pages::LIMIT ) { | |
| 648 | + $count = Cache::purge_all( | |
| 649 | + __( 'admin', 'xspeed' ), | |
| 650 | + null, | |
| 651 | + array( | |
| 652 | + 'scope' => 'site', | |
| 653 | + 'intent' => 'content', | |
| 654 | + 'urls' => array(), | |
| 655 | + 'fallback' => Cache::FALLBACK_LIMIT, | |
| 656 | + ) | |
| 657 | + ); | |
| 658 | + return array( | |
| 659 | + 'count' => (int) $count, | |
| 660 | + 'urls' => count( $local ), | |
| 661 | + 'whole_site' => true, | |
| 662 | + ); | |
| 655 | 663 | } |
| 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 | + return array( | |
| 665 | + 'count' => array() === $local ? 0 : Cache::purge_urls( $local, __( 'admin', 'xspeed' ) ), | |
| 666 | + 'urls' => count( $local ), | |
| 667 | + 'whole_site' => false, | |
| 668 | + ); | |
| 664 | 669 | } |
| 665 | 670 | |
| 666 | 671 | /** |
| 667 | 672 | * Purge one post and everything that lists it. |
| @@ -683,22 +688,16 @@ | ||
| 683 | 688 | if ( ! $post instanceof \WP_Post ) { |
| 684 | 689 | wp_die( esc_html__( 'That post does not exist.', 'xspeed' ), 400 ); |
| 685 | 690 | } |
| 686 | 691 | |
| 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; | |
| 692 | + $run = Cache::report_forwarding( | |
| 693 | + static function () use ( $post ): array { | |
| 694 | + return self::purge_post( $post ); | |
| 695 | 695 | } |
| 696 | - ++$cleared; | |
| 697 | - $count += Cache::purge_url( $url, __( 'admin', 'xspeed' ) ); | |
| 698 | - } | |
| 696 | + ); | |
| 697 | + $result = $run['result']; | |
| 699 | 698 | |
| 700 | - self::record_result( self::permalink_of( $post_id ), $count, $cleared ); | |
| 699 | + self::record_result( self::permalink_of( $post_id ), $result['count'], $result['urls'], $result['whole_site'], $run['forwarded'] ); | |
| 701 | 700 | |
| 702 | 701 | wp_safe_redirect( self::redirect_target( wp_get_referer() ) ); |
| 703 | 702 | exit; |
| 704 | 703 | } |
| @@ -709,9 +708,9 @@ | ||
| 709 | 708 | * Compared on path alone: the result was recorded against an absolute URL |
| 710 | 709 | * built from the request that purged it, and the host on the request |
| 711 | 710 | * showing the notice is the same one by construction. |
| 712 | 711 | * |
| 713 | - * @param array{url:string,count:int,urls:int} $result | |
| 712 | + * @param array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]} $result | |
| 714 | 713 | */ |
| 715 | 714 | private static function result_is_about_this_request( array $result ): bool { |
| 716 | 715 | $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared, never output or stored. |
| 717 | 716 | if ( '' === $uri ) { |