File: /wordpress/mu-plugins/edge-cache/shared/class-edge-cache-purge.php
<?php declare( strict_types = 1 );
/**
* Detect events that should cause the Edge Cache to purge, enqueue them for shutdown, and manage purging them.
*
* NOTE:: This class is designed to be shared between the mu-plugin in Simple and Atomic.
* Put platform specific actions and filters in the mu-plugin on each platform.
*/
class Edge_Cache_Purge {
const MAX_URI_COUNT = 3000;
const MAX_TERM_PAGES = 3;
const MAX_COMMENT_PAGES = 3;
const PURGE_NOTHING = 0;
const PURGE_URI = 1;
const PURGE_DOMAIN = 2;
private static $instance = null;
private $purge_mode = self::PURGE_NOTHING; // What kind of thing is setup to purge at shutdown?
private $purging_enabled = true; // Can be set to false on domains we can't purge, to shortcut purge checks. e.g.: public-api.
private $domain_name = null; // The domain which will be purged during shutdown.
private $platform = null; // Is this 'simple' or 'atomic'?
private $extra_tags = []; // Extra tags to include in logstash info.
private $actions = []; // The set of actions which caused the current purges to be enqueued.
private $uris = []; // The list of uris which will be purged during shutdown.
private $posts = []; // Posts enqueued for purging during shutdown.
private $terms = []; // Because we catch term changes during a cache purge event, we enqueue terms for processing during shutdown.
private $uri_count = 0; // The number of uris sent for purging.
public function __construct( $platform ) {
$this->platform = $platform;
}
/**
* Set up hooks to listen for purge events.
*/
public function init() {
// Purge whole cache when major settings (e.g.: theme) are changed.
add_action( 'switch_theme', [ $this, 'handle_site_change' ], 10, 0 );
add_action( 'customize_save_after', [ $this, 'handle_site_change' ], 10, 0 );
// Purge when a post changes.
add_action( 'delete_attachment', [ $this, 'handle_deleted_post' ], 10, 2 );
add_action( 'deleted_post', [ $this, 'handle_deleted_post' ], 10, 2 );
add_action( 'transition_post_status', [ $this, 'handle_transition_post_status' ], PHP_INT_MAX, 3 );
// Purge when a comment changes.
add_action( 'comment_post', [ $this, 'handle_comment_post' ], 10, 3 );
add_action( 'transition_comment_status', [ $this, 'handle_transition_comment_status' ], PHP_INT_MAX, 3 );
// Special case: post_updated allows us to grab old permalinks when the permalink changes.
add_action( 'post_updated', [ $this, 'handle_post_updated' ], 10, 3 );
// Purge when a term cache gets cleared.
add_action( 'clean_term_cache', [ $this, 'handle_clean_term_cache' ], 10, 3 );
// Purge whole cache when the blog's visibility is changed.
add_action( 'update_option_blog_public', [ $this, 'handle_visibility_option_update' ], 10, 3 );
add_action( 'update_option_wpcom_public_coming_soon', [ $this, 'handle_visibility_option_update' ], 10, 3 );
// WooCommerce-specific hooks.
if ( class_exists( 'WC_Product' ) ) {
add_action( 'woocommerce_before_product_object_save', array( $this, 'handle_woo_before_product_object_save' ), 10, 1 );
}
// Allow third parties to trigger purges manually.
add_action( 'edge_cache_purge_domain', [ $this, 'handle_edge_cache_purge_domain' ], 10, 0 );
add_action( 'edge_cache_purge_uris', [ $this, 'handle_edge_cache_purge_uris' ], 10, 1 );
// Execute all purges on shutdown action
add_action( 'shutdown', [ $this, 'handle_shutdown' ], 10, 0 );
}
public function handle_edge_cache_purge_domain() {
$this->log_purge_action();
$this->enqueue_purge_domain();
}
public function handle_edge_cache_purge_uris( $uris ) {
$this->log_purge_action();
$this->enqueue_purge_uri( (array) $uris );
}
/**
* Action handler for posts getting deleted. Does not queue up the post
* for later processing; data around it may be gone by then.
*
* @param $post_id int Post's ID.
* @param $post WP_Post Post object - optional.
*/
public function handle_deleted_post( $post_id, $post = null ) {
if ( ! $post ) {
$post = get_post( $post_id );
}
// Do not act on unpublished posts.
if ( 'publish' !== $post->post_status && 'attachment' !== $post->post_type ) {
return;
}
// If this post is queued for later purging, remove it.
if ( isset( $this->posts[ $post_id ] ) ) {
unset( $this->posts[ $post_id ] );
}
$this->log_purge_action();
$this->process_post( $post_id, $post, true );
}
/**
* Action handler for post_updated. It's the only event that gives us insight into an old permalink URL
* when a user changes permalinks, so this is focused on grabbing old permalink URLs.
*/
public function handle_post_updated( $post_id, $post_after, $post_before ) {
if ( empty( $post_before ) || 'publish' !== $post_before->post_status ) {
return;
}
$old_permalink = get_permalink( $post_before );
$new_permalink = get_permalink( $post_after );
if ( $old_permalink === $new_permalink ) {
return;
}
$this->log_purge_action();
$this->enqueue_purge_uri(
[
$old_permalink,
user_trailingslashit( trailingslashit( $old_permalink ) . 'feed' ),
]
);
}
/**
* Action handler for transition_post_status - called whenever a post changes.
* @param $new_status string New post status.
* @param $old_status string Old post status.
* @param $post WP_Post Post object.
*/
public function handle_transition_post_status( $new_status, $old_status, $post ) {
if ( ! in_array( 'publish', array( $new_status, $old_status ), true ) || empty( $post ) ) {
return;
}
$this->add_extra_tags(
[
'new_status' => $new_status,
'old_status' => $old_status,
]
);
$this->log_purge_action();
$this->enqueue_purge_post( $post->ID, $post );
}
/**
* Action handler for major changes to the site which don't require any args to act on.
* e.g.: switch themes, customizer save.
*/
public function handle_site_change() {
$this->log_purge_action();
$this->enqueue_purge_domain();
}
/**
* Action handler for clean_term_cache - enqueues a term to be purged during shutdown.
*
* We don't get the term pages immediately, as this fires in response to a cache purge action.
* It may cause extra db hits during pageload if we do that - better to fetch during page shutdown.
*
* We do not respect requests to clear caches for the entire taxonomy,
* as this would be potentially hundreds or thousands of PURGE requests.
*
* @param array $ids An array of term IDs.
* @param string $taxonomy Taxonomy slug.
*/
public function handle_clean_term_cache( $ids, $taxonomy, $clean_taxonomy ) {
if ( ! $this->populate_site_info() ) {
return;
}
$taxonomy_object = get_taxonomy( $taxonomy );
if ( ! $taxonomy_object ) {
return;
}
if ( false === $taxonomy_object->public
&& false === $taxonomy_object->publicly_queryable
&& false === $taxonomy_object->show_in_rest ) {
return;
}
// Keep the blog_id these terms were enqueued for; prevent issues for multisite installs.
$blog_id = get_current_blog_id();
$this->terms[ $blog_id ] = $this->terms[ $blog_id ] ?? [];
$this->terms[ $blog_id ][ $taxonomy ] = $this->terms[ $blog_id ][ $taxonomy ] ?? [];
foreach ( $ids as $id ) {
$this->terms[ $blog_id ][ $taxonomy ][ $id ] = 1;
}
$this->log_purge_action();
}
/**
* Action handler for comment_post. Purge the relevant post if the post is published and the
* comment is approved.
*
* @param $comment_id int The comment ID
* @param $comment_status int|str 0=not-approved, 1=approved, 'spam'=spam_comment
* @param $comment_data array Comment data
*/
public function handle_comment_post( $comment_id, $comment_status, $comment_data ) {
if ( $comment_status != 1 || ! is_array( $comment_data ) || empty( $comment_data['comment_post_ID'] ) ) {
return;
}
$post_id = (int) $comment_data['comment_post_ID'];
$post = get_post( $post_id );
if ( empty( $post ) || $post->post_status !== 'publish' ) {
return;
}
$this->add_extra_tags(
[
'new_status' => 'approved',
'old_status' => 'new',
]
);
$this->log_purge_action();
$this->enqueue_purge_post( $post_id, $post, false );
}
/**
* Action handler for transition_comment_status.
* Purge the cache if comment is assigned to a post and the status has
* changed and the comment is approved
*
* @param $new_status int|str The new comment status.
* @param $old_status int|str The old comment status.
* @param $comment WP_Comment Comment object.
*
*/
public function handle_transition_comment_status( $new_status, $old_status, $comment ) {
if ( $old_status == $new_status || empty( $comment->comment_post_ID ) ) {
return;
}
if ( 'approved' === $new_status || 'approved' === $old_status ) {
$this->add_extra_tags(
[
'new_status' => $new_status,
'old_status' => $old_status,
]
);
$post = get_post( (int) $comment->comment_post_ID );
if ( ! $post ) {
return;
}
if ( $post->post_status !== 'publish' ) {
return;
}
$this->log_purge_action();
$this->enqueue_purge_post( $post->ID, $post, false );
}
}
/**
* Action handler for update_option_* which purges the domain cache when:
* - The blog_public option is changed, or
* - the wpcom_public_coming_soon option is changed.
*
* @param $old_value int|null - The previous value of option
* @param $value int|null - The current value of option
*
* @return bool
*/
public function handle_visibility_option_update( $old_value, $value, $option ) {
// Do nothing if a relevant option isn't being updated.
if ( ! in_array( $option, [ 'blog_public', 'wpcom_public_coming_soon' ], true ) ) {
return;
}
$this->log_purge_action();
$this->enqueue_purge_domain();
}
/**
* Action handle for woocommerce_before_product_object_save.
* Enqueue a purge when a WooCommerce product is about to be saved.
*
* @param WC_Product $product The WooCommerce product object being saved.
*/
public function handle_woo_before_product_object_save( $product ) {
if ( ! class_exists( 'WC_Product' ) || ! $product instanceof WC_Product ) {
return;
}
$changes = $product->get_changes();
/**
* Filter the pending changes on a WooCommerce product before deciding whether to ignore this event.
* Use this to ignore metadata changes that don't need a purge. Does not affect modifications to the wp_posts table.
*
* @param array $changes Array of changes detected on the product.
* @param WC_Product $product The WooCommerce product object being saved.
*/
$changes = apply_filters( 'edge_cache_woocommerce_product_changes', $changes, $product );
if ( empty( $changes ) ) {
return;
}
$post = get_post( $product->get_id() );
if ( ! $post || $post->post_status !== 'publish' ) {
return;
}
$this->log_purge_action();
$this->enqueue_purge_post( $post->ID, $post );
}
/**
* Action handler for the 'shutdown' action. Enacts and enqueued purges.
*/
public function handle_shutdown() {
if ( ( defined( 'WP_IMPORTING' ) && true === WP_IMPORTING )
|| ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE )
|| ( defined( 'ISOLATED_TESTING_ENV' ) && ISOLATED_TESTING_ENV ) ) {
return;
}
$this->prepare_enqueued_items();
$this->execute_enqueued_purges();
}
/**
* Gather any URLs that need purging as a result of a term cache clear during the request.
* This is done during shutdown to avoid loading the reloading term unnecessarily during the request.
*/
private function prepare_term_purges() {
// Skip if purging the whole domain; no need to dig up more URLs.
if ( $this->purge_mode === self::PURGE_DOMAIN || empty( $this->terms ) ) {
return;
}
// Only purge terms for the current blog_id at shutdown.
$blog_id = get_current_blog_id();
if ( ! isset( $this->terms[ $blog_id ] ) ) {
return;
}
$uris = [];
foreach ( $this->terms[ $blog_id ] as $taxonomy => $ids ) {
$terms = get_terms(
[
'taxonomy' => $taxonomy,
'include' => array_keys( $ids ),
'hide_empty' => false,
]
);
if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
continue;
}
foreach ( array_filter( $terms ) as $term ) {
$uris = array_merge( $uris, $this->get_purge_uris_for_term( $term ) );
}
}
$this->terms = [];
$this->enqueue_purge_uri( $uris );
}
/**
* Enqueue purging the whole domain during shutdown. Optionally, provide an $action to log.
*
* @param $action string|null - If provided, logs the action that lead to this domain purge.
*/
public function enqueue_purge_domain( $action = null ) {
if ( $action ) {
$this->log_purge_action( $action );
}
return $this->setup_purge_mode( self::PURGE_DOMAIN );
}
/**
* Immediately purge the domain, returning the result.
*/
public function purge_domain_now( $action = null ) {
$this->enqueue_purge_domain( $action );
return $this->execute_enqueued_purges();
}
/**
* Immediately purge the given uris, returning the result.
*/
public function purge_uris_now( $uris, $action = null ) {
if ( ! empty( $action ) ) {
$this->log_purge_action( $action );
}
$this->enqueue_purge_uri( $uris );
return $this->execute_enqueued_purges();
}
/**
* Normalize URIS; ensure there is always a path, strips GET parameters, standardizes schema
* for easy comparison. (Note: schema is irrelevant to actual purges).
*/
public function normalize_uri( $uri ) {
$parts = parse_url( $uri );
if ( empty( $parts['host'] ) ) {
return $uri; // Give up if it's not readable.
}
return 'https://' . $parts['host'] . ( $parts['path'] ?? '/' );
}
/**
* Returns true if there is room in the enqueue buffer to store n URIs, false if not.
*/
private function can_enqueue_uris( $n ) {
if ( $this->purge_mode === self::PURGE_DOMAIN ) {
return false; // No need to enqueue more URIs if we're purging the whole domain.
}
if ( ! $this->purging_enabled ) {
return false; // Purging disabled for this domain.
}
if ( ( $this->uri_count + $n ) > self::MAX_URI_COUNT ) {
return false; // Too many URIs already enqueued.
}
return true;
}
/**
* Enqueue purging a specific uri or set of uris during shutdown.
*
* @param $uris string|array - The URI or set of URIs to purge.
*/
public function enqueue_purge_uri( $uris ) {
if ( empty( $uris ) ) {
return;
}
$uris = (array) $uris;
$this->uri_count += count( $uris );
if ( ! $this->setup_purge_mode( self::PURGE_URI ) ) {
return;
}
foreach ( $uris as $uri ) {
$this->uris[ $this->normalize_uri( $uri ) ] = 1;
}
// If we have too many uris, give up and purge the whole domain.
if ( count( $this->uris ) > self::MAX_URI_COUNT ) {
$this->add_extra_tags( [ 'uri_limit' => 'hit' ] );
$this->enqueue_purge_domain( null ); // null because the wp_action is already counted.
}
}
/**
* Enqueue puring all uris related to a specific post.
*
* Note that the $post argument is optional. If not supplied, get_post will be used to get the post
* object. But this argument is needed, as not all post objects are in the cache ready to be read.
* e.g.: after clean_post_cache, the post has just been removed from cache so an extra call to get_post is costly.
*
* @param $post_id int - the post_id to look at.
* @param $post WP_Post - Optionally, if the caller already has it, the WP_Post object to use.
* @param $include_lists boolean - If set to false, exclude list pages the post appears on (e.g.: tags, categories, archives).
*/
private function enqueue_purge_post( $post_id, $post = null, $include_lists = true ) {
if ( ! $this->setup_purge_mode( self::PURGE_URI ) ) {
return;
}
if ( ! isset( $this->posts[ $post_id ] ) ) {
$this->posts[ $post_id ] = [];
}
if ( $include_lists ) {
$this->posts[ $post_id ]['lists'] = true;
}
if ( $post ) {
$this->posts[ $post_id ]['post'] = $post;
}
}
/**
* Process all enqueued posts, and enqueue their URIs for purging.
*/
private function prepare_post_purges() {
// Only bother if purging enabled and not already purging the whole domain.
if ( ! $this->setup_purge_mode( self::PURGE_URI ) ) {
return;
}
foreach ( $this->posts as $post_id => $target ) {
$include_lists = ! empty( $target['lists'] );
$post = $target['post'] ?? null;
$continue = $this->process_post( $post_id, $post, $include_lists );
if ( ! $continue ) {
return;
}
}
$this->posts = [];
}
/**
* Process a single post, enqueueing its URIs for purging.
* @param $post_id int - The post ID to process.
* @param $post WP_Post - Optionally, if the caller already has it, the WP_Post object to use.
* @param $include_lists boolean - True to include list pages the post appears on (e.g.: tags, categories, archives).
* @return bool - True if processing should continue, false if it should stop.
*/
private function process_post( $post_id, $post = null, $include_lists = true ) {
if ( empty( $post ) ) {
$post = get_post( $post_id );
}
if ( ! $post || wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return true; // Can continue with other posts, even if this one is missing / irrelevant.
}
if ( $this->is_site_editor_post_type( $post->post_type ) ) {
return $this->process_site_editor_post( $post );
}
// Exclude post types that are not viewable
if ( ! is_post_type_viewable( $post->post_type ) ) {
return true;
}
$this->enqueue_purge_uri( $this->get_purge_uris_for_post( $post, $include_lists ) );
return true;
}
/**
* Get the 'front-page' post, if one is set. Returns NULL if no front-page post is set.
*/
private function get_front_page() {
$front_page_id = get_option( 'page_on_front' );
if ( $front_page_id ) {
return get_post( $front_page_id );
}
return null;
}
/**
* Given a URL, the number of items in the set, and the number of items per page, generate /page/x URLs for purging.
*
* @param $base_url string - The base URL to paginate.
* @param $item_count int - The total number of items.
* @param $items_per_page int - The number of items per page.
* @param $page_limit int - Optional limit on the number of pages to generate.
* @return array - An array of paginated URLs.
*/
private function paginate_url( $base_url, $item_count, $items_per_page, $page_limit = null ) {
global $wp_rewrite;
$urls = [ $base_url ];
if ( ! $wp_rewrite->using_permalinks() ) {
return $urls;
}
$paging_suffix = trailingslashit( ltrim( $wp_rewrite->pagination_base, '/' ) );
$page_number_max = min( $page_limit ?? PHP_INT_MAX, ceil( $item_count / max( 1, $items_per_page ) ) );
for ( $page_number = 2; $page_number <= $page_number_max; $page_number++ ) {
$urls[] = user_trailingslashit(
trailingslashit( $base_url ) . $paging_suffix . $page_number,
'paged'
);
}
return $urls;
}
/**
* Given a URL and a number of posts, generate page URLs for purging.
*/
private function get_post_page_count( $post_count ) {
$posts_per_page = (int) get_option( 'posts_per_page' );
if ( $posts_per_page < 1 ) {
$posts_per_page = 10; // Default.
}
return max( 1, (int) ceil( $post_count / $posts_per_page ) );
}
/**
* Return the main posts page url - corresponds to the 'home' template.
*/
private function get_posts_url() {
$front_page_id = get_option( 'page_on_front' );
if ( $front_page_id ) {
$posts_page_id = get_option( 'page_for_posts' );
if ( $posts_page_id ) {
$posts_page = get_post( $posts_page_id );
if ( $posts_page ) {
return get_permalink( $posts_page );
}
}
}
return home_url( '/' );
}
/**
* Process a post from the site editor. Work out whether it's a single-page template edit, or something that
* may affect the whole domain.
* @param $post WP_Post - The post to process.
* @return bool - True if page purge(s) can continue, false if the whole domain is being purged.
*/
private function process_site_editor_post( $post ) {
$this->log_purge_action( 'site_edit' );
$this->add_extra_tags( [ 'target' => $post->post_name ] );
// Check for single-page templates.
if ( 'wp_template' === $post->post_type ) {
if ( $post->post_name === 'home' ) {
// Home template: purge the posts page if possible, otherwise fall back to domain purge.
$post_count = wp_count_posts()->publish;
$page_count = $this->get_post_page_count( $post_count );
if ( $this->can_enqueue_uris( $page_count ) ) {
$home_url = $this->get_posts_url();
$this->enqueue_purge_uri( $this->paginate_url( $home_url, $post_count, get_option( 'posts_per_page' ) ) );
return true;
}
} else if ( $post->post_name === 'front-page' ) {
$target_page = $this->get_front_page();
} else {
$target_page_name = substr( $post->post_name, 5 );
$target_page = get_page_by_path( $target_page_name );
}
// If we can find one single target page for this template, just purge that.
if ( ! empty( $target_page ) && $target_page->post_type === 'page' && $target_page->post_status === 'publish' ) {
$this->enqueue_purge_uri( $this->get_purge_uris_for_post( $target_page, false ) );
return true;
}
}
// Fall back to a domain purge if we can't find a single target page, or if 'home' has too many pages.
$this->enqueue_purge_domain();
return false;
}
/**
* Populate internal structures with site information.
*/
private function populate_site_info() {
if ( empty( $this->domain_name ) ) {
$domain = $this->platform->get_domain_name();
if ( empty( $domain ) || ! $this->platform->can_purge_domain( $domain ) ) {
$this->purging_enabled = false;
return;
}
$this->domain_name = $domain;
}
return true;
}
/**
* Get ready to purge a domain or a set of URIs. Cleans up leftover data and ensures that
* details like domain_name are populated.
*
* @param $purge_type int - PURGE_DOMAIN or PURGE_URI - what are we trying to purge?
*
* @return bool - True if the purge mode is set, false if the current purge should be aborted.
*/
private function setup_purge_mode( $purge_mode ) {
// Nothing to do if we can't purge.
if ( ! $this->purging_enabled ) {
return false;
}
if ( ! $this->populate_site_info() ) {
return false;
}
// Deal with changes in scope of purging (domain vs. uri)
if ( $this->purge_mode !== self::PURGE_NOTHING && $this->purge_mode !== $purge_mode ) {
if ( $this->purge_mode === self::PURGE_URI ) {
$this->uris = [];
} else if ( $this->purge_mode === self::PURGE_DOMAIN ) {
// Any URI purges queued after a DOMAIN purge is queued can be ignored.
return false;
}
}
$this->purge_mode = $purge_mode;
return true;
}
/**
* Track an action that has triggered some kind of purging.
*/
private function log_purge_action( $action = null ) {
if ( empty( $action ) ) {
$action = current_filter();
}
$this->actions[ $action ] = ( $this->actions[ $action ] ?? 0 ) + 1;
}
/**
* Helper method - given a post type, return true if it's from the site editor.
*/
private function is_site_editor_post_type( $post_type ) {
static $fse_post_types = [ 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'wp_block' ];
return in_array( $post_type, $fse_post_types, true );
}
/**
* Given a post permalink and the number of comments on the post, return comment sub-pages
* for the post.
*/
private function get_comment_subpage_uris( $post_permalink, $comment_count ) {
global $wp_rewrite;
if ( ! get_option( 'page_comments' ) ) {
return [];
}
$newest_first = 'newest' === get_option( 'default_comments_page' );
$comments_per_page = max( 1, (int) get_option( 'comments_per_page' ) );
$comment_pages = ceil( $comment_count / $comments_per_page );
if ( $newest_first ) {
// Permalink shows the newest comments.
$to = $comment_pages - 1;
$from = max( 1, $to - self::MAX_COMMENT_PAGES );
} else {
// Permalink shows the oldest comments.
$from = 2;
$to = min( $comment_pages, $from + self::MAX_COMMENT_PAGES );
}
$uris = [];
for ( $i = $from; $i <= $to; $i++ ) {
$uris[] = user_trailingslashit(
trailingslashit( $post_permalink ) . $wp_rewrite->comments_pagination_base . '-' . $i,
'commentpaged'
);
}
return $uris;
}
/**
* Remove the "__trashed" post_name suffix from a list of uris. When a post is trashed,
* "__trashed" is added to its post_name - but we want to purge urls without this suffix.
*
* Pass in a post, so that its name can be used to ensure random other instances of __trashed
* are not removed if not a part of a post_name.
*
* @param $post - WP_Post object to use for the post_name.
* @param $uris - Array of URIs to clean up.
*/
private function get_untrashed_post_links( $post, $uris ) {
if ( substr_compare( $post->post_name, '__trashed', -9 ) === 0 ) {
$trashed = $post->post_name . '/';
$untrashed = substr( $post->post_name, 0, -9 ) . '/';
} else {
$trashed = $post->post_name . '__trashed/';
$untrashed = $post->post_name . '/';
}
return array_map(
function ( $uri ) use ( $trashed, $untrashed ) {
return str_replace( $trashed, $untrashed, $uri );
},
$uris
);
}
/**
* Given a post, return the URLs that could be affected by it changing.
*
* @param $post WP_Post - Post object to inspect.
* @param $include_lists boolean - True (by default) to include all of the list pages the post may appear in. (e.g.: categories).
*/
private function get_purge_uris_for_post( $post, $include_lists = true ) {
$post_type = get_post_type( $post );
$permalink = get_permalink( $post );
$is_product = ( 'product' === $post_type && function_exists( 'wc_get_page_permalink' ) );
$uris = [
home_url( '/' ),
$permalink,
];
if ( ! $is_product ) {
$uris[] = get_post_comments_feed_link( $post->ID );
$uris[] = get_author_posts_url( $post->post_author );
$uris[] = get_author_feed_link( $post->post_author );
$uris[] = get_bloginfo_rss( 'rdf_url' );
$uris[] = get_bloginfo_rss( 'rss_url' );
$uris[] = get_bloginfo_rss( 'rss2_url' );
$uris[] = get_bloginfo_rss( 'atom_url' );
$uris[] = get_bloginfo_rss( 'comments_rss2_url' );
}
if ( $is_product && $include_lists ) {
$shop_page = wc_get_page_permalink( 'shop' );
if ( $shop_page ) {
$uris[] = $shop_page;
}
}
if ( 'attachment' === $post_type ) {
$uris[] = wp_get_attachment_url( $post->ID );
}
// Comment pagination URLs.
$comment_count = get_comments_number( $post );
$comment_uris = $this->get_comment_subpage_uris( $permalink, $comment_count );
$uris = array_merge( $uris, $comment_uris );
// Handle any taxonomies this post is a part of.
if ( $include_lists ) {
$taxonomies = get_object_taxonomies( $post, 'objects' );
foreach ( $taxonomies as $taxonomy ) {
if ( ! $taxonomy instanceof WP_Taxonomy ) {
continue;
}
// Ignore non-visible taxonomies.
if ( false === $taxonomy->public
&& false === $taxonomy->publicly_queryable
&& false === $taxonomy->show_in_rest ) {
continue;
}
// Get applicable terms.
$taxonomy_name = $taxonomy->name;
$terms = get_the_terms( $post->ID, $taxonomy_name );
if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
continue;
}
// Do a purge for each term.
foreach ( array_filter( $terms ) as $term ) {
$uris = array_merge(
$uris,
$this->get_purge_uris_for_term( $term )
);
}
}
$archive_link = get_post_type_archive_link( $post_type );
if ( ! empty( $archive_link ) && $archive_link !== home_url() ) {
$uris[] = $archive_link;
}
}
if ( 'post' === $post_type ) {
$page_for_posts = get_option( 'page_for_posts' );
if ( $page_for_posts ) {
$uris[] = get_permalink( $page_for_posts );
}
}
return $this->get_untrashed_post_links( $post, array_filter( $uris ) );
}
/**
* Get all URIs to be purged for a given term
*
* @param object $term A WP term object
*
* @return array An array of URLs to be purged
*/
private function get_purge_uris_for_term( $term ) {
global $wp_rewrite;
if ( ! ( $term instanceof \WP_Term ) ) {
return [];
}
$term_purge_urls = array();
$taxonomy_name = $term->taxonomy;
$maybe_purge_url = get_term_link( $term, $taxonomy_name );
if ( is_wp_error( $maybe_purge_url ) ) {
return [];
}
if ( $maybe_purge_url && is_string( $maybe_purge_url ) ) {
$term_purge_urls[] = $maybe_purge_url;
// Pagination: but only if using permalinks. ?query-based pagination doesn't need individual purging.
if ( $wp_rewrite->using_permalinks() ) {
$posts_per_page = get_option( 'posts_per_page' );
$total_public_posts_count = intval( $term->count );
$paging_suffix = trailingslashit( ltrim( $wp_rewrite->pagination_base, '/' ) );
// Limit to up to MAX_TERM_PAGES pages
$page_number_max = min( self::MAX_TERM_PAGES, ceil( $total_public_posts_count / max( 1, $posts_per_page ) ) );
for ( $page_number = 2; $page_number <= $page_number_max; $page_number++ ) {
$term_purge_urls[] = user_trailingslashit(
trailingslashit( $maybe_purge_url ) . $paging_suffix . $page_number,
'paged'
);
}
}
}
$maybe_purge_feed_url = get_term_feed_link( $term->term_id, $taxonomy_name );
if ( false !== $maybe_purge_feed_url ) {
$term_purge_urls[] = $maybe_purge_feed_url;
}
return array_filter( $term_purge_urls );
}
/**
* Purge batcache. Returns a string describing the outcome.
*/
public function purge_batcache() {
global $batcache;
if ( isset( $batcache ) && is_object( $batcache ) && method_exists( $batcache, 'flush' ) ) {
if ( ! empty( $this->domain_name ) ) {
if ( $batcache->flush( 'host', $this->domain_name ) ) {
return 'success';
}
}
return 'failure';
}
return 'disabled';
}
/**
* Reset the purge system - useful for tests.
*/
public function clear_enqueued_purges() {
$this->domain_name = null;
$this->purging_enabled = true;
$this->purge_mode = self::PURGE_NOTHING;
$this->extra_tags = [];
$this->actions = [];
$this->uris = [];
$this->terms = [];
$this->posts = [];
}
/**
* Actually execute any purges that are enqueued. Clears the enqueued purge when done.
* @return bool - true on success (or if nothing queued), false on failure.
*/
public function execute_enqueued_purges() {
if ( $this->purge_mode === self::PURGE_NOTHING || empty( $this->domain_name ) ) {
return true;
}
if ( $this->purge_mode === self::PURGE_URI && empty( $this->uris ) ) {
return true;
}
// Invalidate batcache before edge-cache
$batcache_status = $this->purge_batcache();
// Gather logstash tags.
$tags = $this->generate_tags( $batcache_status );
// Perform the edge cache purge.
if ( $this->purge_mode === self::PURGE_DOMAIN ) {
$result = $this->platform->purge_domain( $this->domain_name, $tags );
} else if ( $this->purge_mode === self::PURGE_URI ) {
$result = $this->platform->purge_uris( array_keys( $this->uris ), $tags );
}
$this->clear_enqueued_purges();
return $result;
}
/**
* Generate purge info tags for Logstash.
*/
private function generate_tags( $batcache_status ) {
// Collate actions as action(count),action(count),... and count how many actions total.
$total_actions = 0;
$actions = [];
foreach ( $this->actions as $action => $count ) {
$total_actions += $count;
$actions[] = sprintf( '%s(%s)', $action, $count );
}
$tags = [
'platform' => $this->platform->get_platform_type(),
'wp_domain' => $this->domain_name,
'wp_actions' => implode( ',', $actions ),
'wp_action' => array_keys( $this->actions ),
'domain_purge' => $this->purge_mode === self::PURGE_DOMAIN ? 'yes' : 'no',
'urls_cnt' => $this->uri_count,
'purge_count' => $total_actions,
'batcache' => $batcache_status,
];
$flat_extras = array_map(
function ( $values ) {
return implode( ',', $values );
},
$this->extra_tags
);
$tags = array_merge( $tags, $flat_extras );
return $tags;
}
/**
* Add extra tags to the log when purging.
*/
public function add_extra_tags( $new_tags ) {
foreach ( $new_tags as $key => $value ) {
if ( ! isset( $this->extra_tags[ $key ] ) ) {
$this->extra_tags[ $key ] = [ $value ];
} else {
$this->extra_tags[ $key ][] = $value;
}
}
}
/**
* Returns true if a domain purge is enqueued.
*/
public function is_domain_purge_enqueued() {
return $this->purge_mode === self::PURGE_DOMAIN;
}
/**
* Force processing all enqueued items.
*/
public function prepare_enqueued_items() {
$this->prepare_post_purges();
$this->prepare_term_purges();
}
/**
* Returns a list of URIs enqueued to purge, or false if not purging by URI.
* Note: This will process enqueued terms and posts to ensure the uri list is complete.
*/
public function get_enqueued_uris() {
$this->prepare_enqueued_items();
if ( $this->purge_mode !== self::PURGE_URI ) {
return false;
}
return array_keys( $this->uris );
}
}