* @since 1.18.0 * @return void */ private function __construct() { // The upgrade routine may not have run with this version of the code (automatic updates) if ( get_option( 'affilizz_fragments_table_version' ) !== self::TABLE_VERSION ) { \Affilizz\Install::create_fragments_table(); } add_action( 'admin_post_affilizz_purge_fragments', array( $this, 'purge_all_fragments' ) ); add_action( 'admin_post_affilizz_refresh_post_fragments', array( $this, 'refresh_post_fragments' ) ); add_filter( 'post_row_actions', array( $this, 'add_refresh_row_action' ), 10, 2 ); add_filter( 'page_row_actions', array( $this, 'add_refresh_row_action' ), 10, 2 ); // Fetch the fragments of a post when it is saved, so that its first view already has them add_action( 'save_post', array( $this, 'prefetch_post_fragments' ), 10, 2 ); // Offer to prepare the widgets of the posts published before the hybrid mode add_action( 'admin_init', array( $this, 'update_backfill_state' ) ); add_action( 'admin_post_affilizz_start_fragments_backfill', array( $this, 'start_backfill' ) ); add_action( 'admin_post_affilizz_dismiss_fragments_backfill', array( $this, 'dismiss_backfill' ) ); add_action( self::BACKFILL_HOOK, array( $this, 'run_backfill' ) ); } /** * Returns the name of the fragments table. * @author Affilizz * @since 1.18.0 * @return String The prefixed table name. */ public static function get_table_name() { global $wpdb; return $wpdb->prefix . self::TABLE; } /** * Returns the stored fragments of the contents, even expired ones, and queues the missing or expired ones for a * refresh after the response. Never makes a network call. * @author Affilizz * @since 1.18.0 * @param $components The component ("rendering" or "deal") of each content, keyed by content ID. * @param $post_id The post in which the contents are displayed. * @return Array The HTML of the stored fragments, keyed by content ID. */ public function get_fragments( $components = array(), $post_id = 0 ) { global $wpdb; if ( empty( $components ) ) { return array(); } $table = esc_sql( self::get_table_name() ); $content_ids = array_map( 'strval', array_keys( $components ) ); $placeholders = implode( ', ', array_fill( 0, count( $content_ids ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Custom plugin table, escaped name, one placeholder per content ID. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT content_id, html, fetched_at FROM `{$table}` WHERE post_id = %d AND content_id IN ({$placeholders})", array_merge( array( (int) $post_id ), $content_ids ) ), OBJECT_K ); $oldest_fresh_time = time() - Core::get_fragment_ttl(); $fragments = array(); foreach ( $components as $content_id => $component ) { $row = isset( $rows[ $content_id ] ) ? $rows[ $content_id ] : null; if ( empty( $row ) || (int) $row->fetched_at < $oldest_fresh_time ) { $this->enqueue( $content_id, $component, $post_id ); } if ( ! empty( $row->html ) ) { $fragments[ $content_id ] = $row->html; } } return $fragments; } /** * Queues a fragment for a refresh after the response. * @author Affilizz * @since 1.18.0 * @return void */ private function enqueue( $content_id, $component, $post_id ) { $this->queue[ $component . '|' . $content_id . '|' . $post_id ] = array( (string) $content_id, $component, (int) $post_id ); // After wp_ob_end_flush_all (priority 1), so that the page caches have stored the page before a purge, // and before W3 Total Cache runs its delayed flushes (priority 100000). if ( ! has_action( 'shutdown', array( $this, 'refresh_queue' ) ) ) { add_action( 'shutdown', array( $this, 'refresh_queue' ), 1000 ); } } /** * Returns the component of each content of a publication. * @author Affilizz * @since 1.18.0 * @param $publication_contents The decoded publication contents. * @return Array "rendering" or "deal", keyed by content ID. */ public static function get_components( $publication_contents ) { $components = array(); foreach ( (array) $publication_contents as $publication_content ) { $components[ $publication_content->id ] = ( 'deal' === strtolower( $publication_content->type ) ) ? 'deal' : 'rendering'; } return $components; } /** * Returns the component of each content of the publications of a post (its [affilizz-publication] shortcodes, which * the Gutenberg block also saves). * @author Affilizz * @since 1.18.0 * @param $post The post. * @return Array "rendering" or "deal", keyed by content ID. */ public static function get_post_components( $post ) { $components = array(); if ( empty( $post ) || ! preg_match_all( '/' . get_shortcode_regex( array( 'affilizz-publication' ) ) . '/', $post->post_content, $matches, PREG_SET_ORDER ) ) { return $components; } foreach ( $matches as $match ) { $attributes = shortcode_parse_atts( $match[3] ); if ( ! is_array( $attributes ) || empty( $attributes['id'] ) ) { continue; } $publication = Publications::get( $attributes['id'] ); if ( ! empty( $publication ) && ! empty( $publication->publication_contents ) ) { $components += self::get_components( json_decode( $publication->publication_contents ) ); } } return $components; } /** * Queues the fragments of a published or scheduled post when it is saved, for a refresh after the response : its * first view, and the page cache, then already have the widgets. Fresh fragments are left alone (see lock()). * @author Affilizz * @since 1.18.0 * @return void */ public function prefetch_post_fragments( $post_id, $post ) { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || ! in_array( $post->post_status, array( 'publish', 'future' ), true ) ) { return; } if ( 'hybrid' !== Core::get_current_rendering_mode() || ! has_shortcode( $post->post_content, 'affilizz-publication' ) ) { return; } foreach ( self::get_post_components( $post ) as $content_id => $component ) { $this->enqueue( $content_id, $component, $post_id ); } } /** * Refreshes the queued fragments once the response is sent, then purges the page cache of the posts whose * widgets changed significantly. * @author Affilizz * @since 1.18.0 * @return void */ public function refresh_queue() { // Without these functions (mod_php), the refresh delays the end of the connection : hence the short timeout if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); } elseif ( function_exists( 'litespeed_finish_request' ) ) { litespeed_finish_request(); } ignore_user_abort( true ); $posts_to_purge = array(); foreach ( $this->queue as $queued ) { list( $content_id, $component, $post_id ) = $queued; if ( 'significant' === $this->refresh( $content_id, $component, $post_id ) && $post_id > 0 ) { $posts_to_purge[ $post_id ] = true; } } $this->queue = array(); foreach ( array_keys( $posts_to_purge ) as $post_id ) { self::purge_post_cache( $post_id ); } } /** * Fetches the fragment of a content from the SSR, with a conditional request, and stores it. * @author Affilizz * @since 1.18.0 * @param $content_id The publication content ID, or the deal ID. * @param $component Either "rendering" or "deal". * @param $post_id The post in which the content is displayed. * @param $force Ignore the lock and the ETag (administration actions). * @return String|False False if not refreshed (locked, or SSR error), else "unchanged", "changed" or "significant". */ public function refresh( $content_id, $component, $post_id, $force = false ) { global $wpdb; $table = esc_sql( self::get_table_name() ); $now = time(); if ( ! $force && ! $this->lock( $content_id, $component, $post_id, $now ) ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom plugin table with an escaped name. $row = $wpdb->get_row( $wpdb->prepare( "SELECT html, etag FROM `{$table}` WHERE content_id = %s AND post_id = %d", $content_id, $post_id ) ); $old_html = $row ? (string) $row->html : ''; $headers = ( ! $force && '' !== $old_html && ! empty( $row->etag ) ) ? array( 'If-None-Match' => $row->etag ) : array(); // The fragment pins its locale for hydration: render it in the site's locale, not the SSR's default. $headers['Accept-Language'] = self::get_widget_locale(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get -- Not on VIP ; runs after the response, with a short timeout. $response = wp_remote_get( self::get_fragment_url( $content_id, $component ), array( 'timeout' => Core::get_fragment_http_timeout(), 'headers' => $headers, ) ); $status = (int) wp_remote_retrieve_response_code( $response ); if ( 304 === $status && isset( $headers['If-None-Match'] ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table. $wpdb->update( self::get_table_name(), array( 'fetched_at' => $now, 'locked_until' => 0, ), array( 'content_id' => $content_id, 'post_id' => $post_id, ) ); return 'unchanged'; } // On error, keep the previous fragment : the lock, left to expire, spaces out the retries $html = trim( (string) wp_remote_retrieve_body( $response ) ); if ( 200 !== $status || ! self::is_fragment( $html, $component ) ) { return false; } $etag = wp_remote_retrieve_header( $response, 'etag' ); $etag = is_array( $etag ) ? (string) reset( $etag ) : (string) $etag; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table. $wpdb->replace( self::get_table_name(), array( 'content_id' => $content_id, 'post_id' => $post_id, 'component' => $component, 'html' => $html, 'etag' => substr( $etag, 0, 255 ), 'fetched_at' => $now, 'locked_until' => 0, ), array( '%s', '%d', '%s', '%s', '%s', '%d', '%d' ) ); // Always purge when the widget appears for the first time, else the bare tag stays in the page cache if ( '' === $old_html || self::is_significant_change( $old_html, $html ) ) { return 'significant'; } return ( $old_html === $html ) ? 'unchanged' : 'changed'; } /** * Takes the refresh lock of a fragment, so that simultaneous views do not refresh it more than once. * @author Affilizz * @since 1.18.0 * @return Boolean Did this request take the lock ? */ private function lock( $content_id, $component, $post_id, $now ) { global $wpdb; $table = esc_sql( self::get_table_name() ); // Create the row of a new fragment first (empty, so the bare tag is still rendered), so that there is a row to lock // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom plugin table with an escaped name. $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (content_id, post_id, component, html, etag, fetched_at, locked_until) VALUES (%s, %d, %s, '', '', 0, 0)", $content_id, $post_id, $component ) ); // Only the request whose update changes the row owns the lock ; a fragment refreshed meanwhile is left alone $locked_until = $now + max( self::LOCK_DURATION, 2 * Core::get_fragment_http_timeout() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom plugin table with an escaped name. $updated = $wpdb->query( $wpdb->prepare( "UPDATE `{$table}` SET locked_until = %d WHERE content_id = %s AND post_id = %d AND locked_until < %d AND fetched_at < %d", $locked_until, $content_id, $post_id, $now, $now - Core::get_fragment_ttl() ) ); return 1 === (int) $updated; } /** * Returns the locale the widgets render with, as a BCP 47 tag : the SSR renders the fragments with it, and the * bare tags carry it too, since the first widget of a page sets the locale of all the others. * @author Affilizz * @since 1.18.0 * @return String The locale, e.g. fr-FR. */ public static function get_widget_locale() { return str_replace( '_', '-', get_locale() ); } /** * Returns the URL of the SSR fragment of a content. * @author Affilizz * @since 1.18.0 * @return String The URL. */ public static function get_fragment_url( $content_id, $component ) { return add_query_arg( array( ( 'deal' === $component ? 'dealId' : 'publicationContentId' ) => rawurlencode( $content_id ), 'fragment' => 'true', ), Core::get_ssr_endpoint_root() ); } /** * Checks that a response body is a widget fragment (and not, for example, an error page served with a 200). * @author Affilizz * @since 1.18.0 * @return Boolean Is it a fragment ? */ private static function is_fragment( $html, $component ) { $tag = ( 'deal' === $component ) ? 'affilizz-deal-component' : 'affilizz-rendering-component'; return 0 === strpos( $html, '<' . $tag ); } /** * Tells if a widget changed enough to purge the page cache : best price, best merchant or stock of a product. * @author Affilizz * @since 1.18.0 * @param $old_html The previous fragment. * @param $new_html The new fragment. * @return Boolean Is the change significant ? */ public static function is_significant_change( $old_html, $new_html ) { $old_products = self::get_products_summary( $old_html ); $new_products = self::get_products_summary( $new_html ); if ( count( $old_products ) !== count( $new_products ) ) { return true; } $threshold = (float) apply_filters( 'affilizz_fragment_significant_price_change', 0.05 ); foreach ( $new_products as $index => $new_product ) { $old_product = $old_products[ $index ]; if ( $old_product['seller'] !== $new_product['seller'] || $old_product['availability'] !== $new_product['availability'] ) { return true; } if ( null === $old_product['price'] || null === $new_product['price'] ) { if ( $old_product['price'] !== $new_product['price'] ) { return true; } continue; } if ( $old_product['price'] <= 0 ) { if ( $new_product['price'] != $old_product['price'] ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual -- Float prices. return true; } continue; } if ( abs( $new_product['price'] - $old_product['price'] ) / $old_product['price'] >= $threshold ) { return true; } } return false; } /** * Reads the best offer of each Product of the JSON-LD of a fragment (an AggregateOffer, an Offer, or a list of Offer). * @author Affilizz * @since 1.18.0 * @return Array A list of products, each with its best "price", "seller" and "availability". */ private static function get_products_summary( $html ) { $products = array(); if ( ! preg_match_all( '#]*type=["\']application/ld\+json["\'][^>]*>(.*?)#is', (string) $html, $matches ) ) { return $products; } foreach ( $matches[1] as $json ) { $data = json_decode( $json, true ); if ( ! is_array( $data ) ) { continue; } if ( isset( $data['@graph'] ) ) { $nodes = (array) $data['@graph']; } else { $nodes = wp_is_numeric_array( $data ) ? $data : array( $data ); } foreach ( $nodes as $node ) { if ( ! is_array( $node ) || ! in_array( 'Product', (array) ( $node['@type'] ?? '' ), true ) ) { continue; } $offers = ( isset( $node['offers'] ) && is_array( $node['offers'] ) ) ? $node['offers'] : array(); if ( wp_is_numeric_array( $offers ) ) { $offer_list = $offers; } elseif ( isset( $offers['offers'] ) && is_array( $offers['offers'] ) ) { $offer_list = wp_is_numeric_array( $offers['offers'] ) ? $offers['offers'] : array( $offers['offers'] ); } else { $offer_list = array( $offers ); } $best_offer = null; foreach ( $offer_list as $offer ) { if ( is_array( $offer ) && isset( $offer['price'] ) && is_numeric( $offer['price'] ) && ( null === $best_offer || (float) $offer['price'] < (float) $best_offer['price'] ) ) { $best_offer = $offer; } } if ( isset( $offers['lowPrice'] ) && is_numeric( $offers['lowPrice'] ) ) { $price = (float) $offers['lowPrice']; } else { $price = $best_offer ? (float) $best_offer['price'] : null; } $seller = $best_offer['seller'] ?? ''; $products[] = array( 'price' => $price, 'seller' => is_array( $seller ) ? (string) ( $seller['name'] ?? '' ) : (string) $seller, 'availability' => (string) ( $best_offer['availability'] ?? '' ), ); } } return $products; } /** * Purges the page cache of a post in the detected page cache plugins. Never changes the post modification date. * @author Affilizz * @since 1.18.0 * @param $post_id The post ID. * @return void */ public static function purge_post_cache( $post_id ) { $post_id = (int) $post_id; if ( $post_id <= 0 ) { return; } // WP Rocket if ( function_exists( 'rocket_clean_post' ) ) { rocket_clean_post( $post_id ); } // LiteSpeed Cache sends its purges in a response header : queue them for the next request once the response is sent if ( headers_sent() ) { add_filter( 'litespeed_delay_purge', '__return_true' ); } do_action( 'litespeed_purge_post', $post_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed Cache API. // W3 Total Cache if ( function_exists( 'w3tc_flush_post' ) ) { w3tc_flush_post( $post_id ); } // WP Super Cache if ( function_exists( 'wpsc_delete_post_cache' ) ) { wpsc_delete_post_cache( $post_id ); } // For the other caches (host, CDN) do_action( 'affilizz_purge_post_cache', $post_id ); } /** * Returns the number of stored fragments and the fetch time of the oldest one. * @author Affilizz * @since 1.18.0 * @return Object With "count" and "oldest" (a timestamp, null without fragment). */ public static function get_stats() { global $wpdb; $table = esc_sql( self::get_table_name() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom plugin table with an escaped name. $stats = $wpdb->get_row( "SELECT COUNT(*) AS count, MIN(fetched_at) AS oldest FROM `{$table}` WHERE html <> ''" ); return (object) array( 'count' => $stats ? (int) $stats->count : 0, 'oldest' => ( $stats && $stats->oldest ) ? (int) $stats->oldest : null, ); } /** * Administration action : deletes all the fragments, then purges the page cache of the posts that displayed them. * @author Affilizz * @since 1.18.0 * @return void */ public function purge_all_fragments() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'affilizz' ), 403 ); } check_admin_referer( 'affilizz_purge_fragments' ); global $wpdb; $table = esc_sql( self::get_table_name() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom plugin table with an escaped name. $post_ids = $wpdb->get_col( "SELECT DISTINCT post_id FROM `{$table}`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom plugin table with an escaped name. $deleted = $wpdb->query( "DELETE FROM `{$table}`" ); foreach ( $post_ids as $post_id ) { self::purge_post_cache( $post_id ); } self::redirect_with_result( 'purged', (int) $deleted ); } /** * Administration action : fetches again the fragments of a post, then purges its page cache. * @author Affilizz * @since 1.18.0 * @return void */ public function refresh_post_fragments() { $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The nonce, which depends on the post ID, is checked right below. if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'affilizz' ), 403 ); } check_admin_referer( 'affilizz_refresh_post_fragments_' . $post_id ); global $wpdb; $table = esc_sql( self::get_table_name() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom plugin table with an escaped name. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT content_id, component FROM `{$table}` WHERE post_id = %d", $post_id ) ); $refreshed = 0; foreach ( $rows as $row ) { if ( false !== $this->refresh( $row->content_id, $row->component, $post_id, true ) ) { ++$refreshed; } } self::purge_post_cache( $post_id ); self::redirect_with_result( 'refreshed', $refreshed ); } /** * Adds the "Refresh Affilizz widgets" action to the posts containing a publication, in the posts lists. * @author Affilizz * @since 1.18.0 * @return Array The row actions. */ public function add_refresh_row_action( $actions, $post ) { if ( 'hybrid' !== Core::get_current_rendering_mode() || ! current_user_can( 'edit_post', $post->ID ) || ! has_shortcode( $post->post_content, 'affilizz-publication' ) ) { return $actions; } $url = wp_nonce_url( add_query_arg( array( 'action' => 'affilizz_refresh_post_fragments', 'post' => $post->ID ), admin_url( 'admin-post.php' ) ), 'affilizz_refresh_post_fragments_' . $post->ID ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- Short query arguments. $actions['affilizz_refresh_fragments'] = '' . esc_html__( 'Refresh Affilizz widgets', 'affilizz' ) . ''; return $actions; } /** * Returns the state of the preparation of the widgets of the posts published before the hybrid mode. * @author Affilizz * @since 1.18.0 * @return Array|False With "status" (proposed, running, done or dismissed), "total", "done" and "cursor" (the last * post ID prepared), false outside the hybrid mode. */ public static function get_backfill_state() { return get_option( self::BACKFILL_OPTION ); } /** * When the site switches to the hybrid mode (option, filter or former SSR mode), counts once the published posts * containing a publication, to offer to prepare their widgets ; the posts saved afterwards prefetch their own. * Forgets the state outside the hybrid mode, so that the offer comes back on the next switch. * @author Affilizz * @since 1.18.0 * @return void */ public function update_backfill_state() { $state = self::get_backfill_state(); if ( 'hybrid' !== Core::get_current_rendering_mode() ) { if ( false !== $state ) { delete_option( self::BACKFILL_OPTION ); } return; } if ( false !== $state ) { if ( 'running' === $state['status'] ) { $next = wp_next_scheduled( self::BACKFILL_HOOK ); if ( false === $next ) { // A batch that died (timeout, fatal error) scheduled no next one : resume wp_schedule_single_event( time(), self::BACKFILL_HOOK ); } elseif ( $next < time() - MINUTE_IN_SECONDS ) { // WP-Cron does not run (loopback requests blocked : local Docker setup, protected staging...) : // run the batch after this page instead wp_unschedule_event( $next, self::BACKFILL_HOOK ); add_action( 'shutdown', array( $this, 'run_backfill_after_response' ), 1000 ); } } return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Counted once per switch to the hybrid mode. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_content LIKE %s", '%' . $wpdb->esc_like( '[affilizz-publication' ) . '%' ) ); update_option( self::BACKFILL_OPTION, array( 'status' => $total > 0 ? 'proposed' : 'done', 'total' => $total, 'done' => 0, 'cursor' => 0, ), false ); } /** * Administration action : starts preparing the widgets of the published posts, in batches run by WP-Cron. * @author Affilizz * @since 1.18.0 * @return void */ public function start_backfill() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'affilizz' ), 403 ); } check_admin_referer( 'affilizz_start_fragments_backfill' ); $state = self::get_backfill_state(); if ( is_array( $state ) && 'running' !== $state['status'] ) { $state['status'] = 'running'; $state['done'] = 0; $state['cursor'] = 0; update_option( self::BACKFILL_OPTION, $state, false ); wp_schedule_single_event( time(), self::BACKFILL_HOOK ); } wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() ); exit; } /** * Administration action : declines preparing the widgets of the published posts (each is then prepared on its * first view). * @author Affilizz * @since 1.18.0 * @return void */ public function dismiss_backfill() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'affilizz' ), 403 ); } check_admin_referer( 'affilizz_dismiss_fragments_backfill' ); $state = self::get_backfill_state(); if ( is_array( $state ) ) { $state['status'] = 'dismissed'; update_option( self::BACKFILL_OPTION, $state, false ); } wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() ); exit; } /** * Cron task : fetches the fragments of the next published posts containing a publication, for a limited time, then * schedules the next batch. Fresh fragments are left alone, and the page cache of a post is purged when its * widgets appear or change significantly. * @author Affilizz * @since 1.18.0 * @return void */ public function run_backfill() { $this->run_backfill_batch( self::BACKFILL_TIME_BUDGET ); } /** * Runs a backfill batch after the response of an administration page, when WP-Cron does not run. * @author Affilizz * @since 1.18.0 * @return void */ public function run_backfill_after_response() { $time_budget = self::BACKFILL_TIME_BUDGET; if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); } elseif ( function_exists( 'litespeed_finish_request' ) ) { litespeed_finish_request(); } else { // mod_php : the browser waits for the end of the batch $time_budget = self::BACKFILL_BLOCKING_TIME_BUDGET; } ignore_user_abort( true ); $this->run_backfill_batch( $time_budget ); } /** * Fetches the fragments of the next posts for the given time, then schedules the next batch (see run_backfill). * @author Affilizz * @since 1.18.0 * @param int $time_budget The time after which the batch stops, in seconds. * @return void */ private function run_backfill_batch( $time_budget ) { $state = self::get_backfill_state(); if ( ! is_array( $state ) || 'running' !== $state['status'] || 'hybrid' !== Core::get_current_rendering_mode() ) { return; } global $wpdb; $like = '%' . $wpdb->esc_like( '[affilizz-publication' ) . '%'; $deadline = time() + $time_budget; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Batches of a one-off task. $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_status = 'publish' AND post_content LIKE %s ORDER BY ID LIMIT 10", $state['cursor'], $like ) ); foreach ( $post_ids as $post_id ) { $post_id = (int) $post_id; $significant = false; foreach ( self::get_post_components( get_post( $post_id ) ) as $content_id => $component ) { if ( 'significant' === $this->refresh( (string) $content_id, $component, $post_id ) ) { $significant = true; } } if ( $significant ) { self::purge_post_cache( $post_id ); } $state['cursor'] = $post_id; ++$state['done']; // Posts published since the count are prepared too $state['total'] = max( $state['total'], $state['done'] ); update_option( self::BACKFILL_OPTION, $state, false ); if ( time() >= $deadline ) { wp_schedule_single_event( time() + 10, self::BACKFILL_HOOK ); return; } } } while ( ! empty( $post_ids ) ); $state['status'] = 'done'; update_option( self::BACKFILL_OPTION, $state, false ); } /** * Goes back to the previous administration page, with the result of the action for the notice. * @author Affilizz * @since 1.18.0 * @return void */ private static function redirect_with_result( $result, $count ) { $referer = wp_get_referer(); $url = $referer ? remove_query_arg( array( 'affilizz_fragments', 'affilizz_fragments_count' ), $referer ) : admin_url( 'admin.php?page=affilizz-settings&tab=advanced' ); wp_safe_redirect( add_query_arg( array( 'affilizz_fragments' => $result, 'affilizz_fragments_count' => $count, ), $url ) ); exit; } }