`, the shortcode * `[harvify_cookie_declaration]`, the Action Scheduler group. * * THE RULE, applied to every item: read the legacy value -> write the * replacement -> VERIFY it -> only then remove the legacy value. Nothing is * deleted that has not first been seen stored under its new name, and a newer * value is never overwritten. * * ORDERING. Options, post meta and stored shortcodes move at `plugins_loaded` * 0, before anything reads them. The shortcode in OTHER post meta -- where page * builders such as Elementor keep their content -- is rewritten afterwards in * the background by Action Scheduler, a slice of rows per job (META_HOOK), so * no request ever scans a large meta table; it is queued only on a site that * actually had the legacy plugin's data. Legacy scheduled jobs are retired at `init` * 30, after the Pro scheduler (init 20) has created the ones the migrated * settings ask for under the new names. Activation and the uninstall cleanup * call migrate_options() themselves: neither runs plugins_loaded for us. * * Legacy `hcc_*` strings appear in this file and in the uninstall routine only. * They are migration/cleanup keys for existing installations -- never use them * for new writes. `hcc_` is RETIRED, not free for another plugin. * * @package HarvifyCookieConsent */ namespace HarvifyCookieConsent; if ( ! defined( 'ABSPATH' ) ) { exit; } final class Migrations { /** Per-step completion flags. Autoloaded: the common path is one cached read. */ const STATE_OPTION = 'harvify_cookie_migrations'; /** Anything the migration could not move, kept for support. */ const NOTES_OPTION = 'harvify_cookie_migration_notes'; /** Give up retrying a stubborn phase rather than working on every page load. */ const MAX_ATTEMPTS = 5; /** Action Scheduler group -- already compliant, unchanged. */ const GROUP = 'harvify-cookie-consent'; /** Legacy option key => current option key. */ const OPTION_MAP = array( 'hcc_banner_settings' => 'harvify_cookie_banner_settings', 'hcc_db_version' => 'harvify_cookie_db_version', 'hcc_policy_page_id' => 'harvify_cookie_policy_page_id', 'hcc_pro_settings' => 'harvify_cookie_pro_settings', 'hcc_pro_autoscan_seen' => 'harvify_cookie_pro_autoscan_seen', 'hcc_delete_data_on_uninstall' => 'harvify_cookie_delete_data_on_uninstall', ); /** Legacy post-meta key => current key (the policy page's edit detection). */ const META_MAP = array( '_hcc_policy_hash' => '_harvify_cookie_policy_hash', '_hcc_policy_inputs_hash' => '_harvify_cookie_policy_inputs_hash', ); /** The shortcode the policy generator used to embed, and its replacement. */ const LEGACY_SHORTCODE = '[hcc_cookie_declaration]'; const CURRENT_SHORTCODE = '[harvify_cookie_declaration]'; /** * The legacy tag in every form WordPress's shortcode parser accepts: * `[tag]`, `[tag /]`, `[tag attr]`, and the closing `[/tag]` -- also as JSON * writes a closing tag (`[\/tag]`), which is how Elementor stores it. */ const LEGACY_TAG_PATTERN = '/\[((?:\\\\)?\/)?hcc_cookie_declaration(?=[\s\/\]\\\\])/'; /** Action Scheduler hook for the background post-meta shortcode pass. */ const META_HOOK = 'harvify_cookie_migrate_meta_shortcodes'; /** Legacy Action Scheduler hooks. Nothing listens to them after 1.0.22. */ const LEGACY_HOOKS = array( 'hcc_pro_auto_scan', 'hcc_pro_weekly_summary' ); public static function init() { add_action( 'plugins_loaded', array( __CLASS__, 'migrate_options' ), 0 ); add_action( 'init', array( __CLASS__, 'migrate_scheduled' ), 30 ); add_action( 'init', array( __CLASS__, 'queue_meta_shortcodes' ), 31 ); add_action( self::META_HOOK, array( __CLASS__, 'migrate_meta_shortcodes' ), 10, 1 ); } // ------------------------------------------------------------------------- // State // ------------------------------------------------------------------------- private static function state() { $state = get_option( self::STATE_OPTION, array() ); return is_array( $state ) ? $state : array(); } private static function is_done( $step ) { $state = self::state(); return ! empty( $state[ $step ]['done'] ); } /** * Record the outcome of a phase. * * @param string $step Phase id. * @param bool $complete Everything migrated. */ private static function finish( $step, $complete ) { $state = self::state(); $entry = isset( $state[ $step ] ) && is_array( $state[ $step ] ) ? $state[ $step ] : array(); $attempts = isset( $entry['attempts'] ) ? (int) $entry['attempts'] + 1 : 1; $entry['attempts'] = $attempts; $entry['done'] = ( $complete || $attempts >= self::MAX_ATTEMPTS ); $state[ $step ] = $entry; update_option( self::STATE_OPTION, $state, true ); } /** * Keep a human-readable record of anything left behind. * * @param string $note One line. */ private static function note( $note ) { $notes = get_option( self::NOTES_OPTION, array() ); if ( ! is_array( $notes ) ) { $notes = array(); } $notes[] = gmdate( 'Y-m-d H:i:s' ) . ' ' . $note; update_option( self::NOTES_OPTION, array_slice( $notes, -50 ), false ); } /** * Whether an option row is autoloaded -- so the migrated row keeps it. * * @param string $name Option. * @return bool */ private static function autoloads( $name ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $autoload = $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", $name ) ); return in_array( $autoload, array( 'yes', 'on', 'auto-on', 'auto' ), true ); } // ------------------------------------------------------------------------- // Phase 1 -- options, post meta, the stored shortcode // ------------------------------------------------------------------------- /** * Move persisted values onto the new keys. */ public static function migrate_options() { if ( self::is_done( 'options' ) ) { return; } $complete = true; $sentinel = '__harvify_cookie_absent__'; foreach ( self::OPTION_MAP as $legacy => $current ) { $old = get_option( $legacy, $sentinel ); if ( $sentinel === $old ) { continue; } // This site had the legacy plugin: page-builder content may carry // the old shortcode too, so the background meta pass is wanted. self::want_meta_pass(); if ( get_option( $current, $sentinel ) !== $sentinel ) { // A newer value already exists -- never overwrite it. delete_option( $legacy ); continue; } add_option( $current, $old, '', self::autoloads( $legacy ) ? 'yes' : 'no' ); // VERIFY before destroying anything: re-read, do not trust the write. wp_cache_delete( $current, 'options' ); if ( get_option( $current, $sentinel ) === $sentinel ) { self::note( sprintf( 'Could not write %s; legacy %s left in place.', $current, $legacy ) ); $complete = false; continue; } delete_option( $legacy ); } if ( ! self::migrate_meta() ) { $complete = false; } if ( ! self::migrate_shortcodes() ) { $complete = false; } self::finish( 'options', $complete ); } /** * Rename the policy page's meta keys in place, never over a newer value. * * @return bool True when every key is accounted for. */ private static function migrate_meta() { global $wpdb; $ok = true; $posts = array(); foreach ( self::META_MAP as $legacy => $current ) { // The posts touched, so only THEIR meta cache is cleared afterwards. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $posts = array_merge( $posts, (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", $legacy ) ) ); // Only the rows that do not already carry the new key on the same post. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->postmeta} AS old LEFT JOIN {$wpdb->postmeta} AS cur ON cur.post_id = old.post_id AND cur.meta_key = %s SET old.meta_key = %s WHERE old.meta_key = %s AND cur.meta_id IS NULL", $current, $current, $legacy ) ); // What is left already had a newer value: the legacy copy is stale. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", $legacy ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $left = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s", $legacy ) ); if ( 0 !== $left ) { self::note( sprintf( '%d row(s) of %s could not be renamed to %s.', $left, $legacy, $current ) ); $ok = false; } } // The rows were renamed in SQL, so the object cache still holds the old // keys for those posts. Clear exactly those entries -- not the whole // cache, and not wp_cache_flush_group(), which needs WordPress 6.1. foreach ( array_unique( array_map( 'intval', $posts ) ) as $post_id ) { wp_cache_delete( $post_id, 'post_meta' ); } return $ok; } /** * Rewrite the legacy shortcode tag in stored content. * * The policy generator embedded `[hcc_cookie_declaration]` until 1.0.22, so * every generated policy page carries it. Only that exact tag is replaced, * with the long name that always rendered the same table. A page the owner * never edited keeps reading as unedited: its stored content hash is * refreshed to the rewritten content -- but only if it matched the content * before the rewrite, so an edited page stays edited. * * Written straight to the row: wp_update_post() would add a revision and * change the page's modified date for a change the owner did not make. * * @return bool True when no legacy tag remains. */ private static function migrate_shortcodes() { global $wpdb; // The opening of the tag: catches every form, not only the bare one. $like = '%' . $wpdb->esc_like( '[hcc_cookie_declaration' ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_type <> 'revision' AND post_content LIKE %s", $like ) ); $ok = self::migrate_option_shortcodes( $like ); foreach ( (array) $rows as $row ) { $before = (string) $row->post_content; $after = self::rewrite_tag( $before ); if ( $after === $before ) { continue; // e.g. a longer, unrelated tag that only shares the prefix. } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $written = $wpdb->update( $wpdb->posts, array( 'post_content' => $after ), array( 'ID' => (int) $row->ID ), array( '%s' ), array( '%d' ) ); if ( false === $written ) { self::note( sprintf( 'Post %d still uses the legacy shortcode.', (int) $row->ID ) ); $ok = false; continue; } clean_post_cache( (int) $row->ID ); $hash_key = self::META_MAP['_hcc_policy_hash']; $stored = (string) get_post_meta( (int) $row->ID, $hash_key, true ); if ( '' !== $stored && md5( $before ) === $stored ) { update_post_meta( (int) $row->ID, $hash_key, md5( $after ) ); } } return $ok; } /** * Rewrite the legacy tag wherever an OPTION stores it: block, text and * custom-HTML widgets, Customizer theme mods, a builder's global settings. * A shop that put the declaration table in a footer widget would otherwise * see the bare tag as text after updating. * * Serialized values are unserialized, rewritten and saved again through * update_option(), so their length prefixes stay valid. Transients and this * plugin's own options are skipped. * * Post meta (where some page builders keep their content) is NOT scanned * here: this runs during the first request after the update, and a * LIKE '%...%' over a large shop's post-meta table could outlast the request. * migrate_meta_shortcodes() does it in the background instead (Phase 3). * * @param string $like LIKE pattern for the tag's opening. * @return bool True when no option still carries the legacy tag. */ private static function migrate_option_shortcodes( $like ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_value LIKE %s AND option_name NOT LIKE %s AND option_name NOT LIKE %s AND option_name NOT LIKE %s AND option_name NOT LIKE %s", $like, $wpdb->esc_like( '_transient_' ) . '%', $wpdb->esc_like( '_site_transient_' ) . '%', $wpdb->esc_like( 'harvify_cookie_' ) . '%', $wpdb->esc_like( 'hcc_' ) . '%' ) ); $ok = true; foreach ( (array) $names as $name ) { $before = get_option( $name ); $after = self::rewrite_deep( $before ); if ( $after === $before ) { continue; } update_option( $name, $after ); wp_cache_delete( $name, 'options' ); // VERIFY: re-read; a value that would still change still has the tag. $reread = get_option( $name ); if ( self::rewrite_deep( $reread ) !== $reread ) { self::note( sprintf( 'Option %s still uses the legacy shortcode.', $name ) ); $ok = false; } } return $ok; } /** * Rename the legacy tag in a string. * * @param string $text Text. * @return string */ private static function rewrite_tag( $text ) { return (string) preg_replace( self::LEGACY_TAG_PATTERN, '[$1harvify_cookie_declaration', (string) $text ); } /** * Rename the legacy tag in every string inside a value. * * @param mixed $value Value (string, array, or anything else untouched). * @return mixed */ private static function rewrite_deep( $value ) { if ( is_string( $value ) ) { return self::rewrite_tag( $value ); } if ( is_array( $value ) ) { foreach ( $value as $key => $item ) { $value[ $key ] = self::rewrite_deep( $item ); } } return $value; } // ------------------------------------------------------------------------- // Phase 3 -- the shortcode in page-builder data, in the background // ------------------------------------------------------------------------- /** * Mark the background meta pass as wanted (once). */ private static function want_meta_pass() { $state = self::state(); $entry = isset( $state['meta_shortcodes'] ) && is_array( $state['meta_shortcodes'] ) ? $state['meta_shortcodes'] : array(); if ( ! empty( $entry['done'] ) || ! empty( $entry['wanted'] ) ) { return; } $entry['wanted'] = true; $state['meta_shortcodes'] = $entry; update_option( self::STATE_OPTION, $state, true ); } /** * Queue the next slice of the meta pass, if it is wanted and not queued. * * Runs at `init` 31, after Action Scheduler is ready. A fresh install never * had the legacy data, so nothing is ever queued there. */ public static function queue_meta_shortcodes() { $state = self::state(); $entry = isset( $state['meta_shortcodes'] ) && is_array( $state['meta_shortcodes'] ) ? $state['meta_shortcodes'] : array(); if ( empty( $entry['wanted'] ) || ! empty( $entry['done'] ) ) { return; } if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) { return; // Action Scheduler not loaded yet -- try again next request. } // Pending OR running counts as queued. if ( false !== as_next_scheduled_action( self::META_HOOK, null, self::GROUP ) ) { return; } as_schedule_single_action( time(), self::META_HOOK, array( isset( $entry['from'] ) ? (int) $entry['from'] : 0 ), self::GROUP ); } /** * One background job: rewrite the legacy tag in post meta, a primary-key * range at a time, until a time budget is spent; then queue the next job. * * Each slice is a bounded range scan on meta_id, so the cost per job is the * same on a small blog and on a shop with millions of meta rows. Serialized * values are unserialized, rewritten and serialized again, so their length * prefixes stay valid; JSON (Elementor's `_elementor_data`) is rewritten as * text, which keeps it valid because only the tag name inside a string * changes. Nothing is written when a value would not change. * * Tuning for very large or very slow sites: `harvify_cookie_migration_slice` * (rows per range, default 5000) and `harvify_cookie_migration_seconds` * (time budget per job, default 2). * * @param int $from The last meta_id already examined. */ public static function migrate_meta_shortcodes( $from = 0 ) { global $wpdb; $state = self::state(); $entry = isset( $state['meta_shortcodes'] ) && is_array( $state['meta_shortcodes'] ) ? $state['meta_shortcodes'] : array(); if ( ! empty( $entry['done'] ) ) { return; } // The rows that existed when the pass began. Content written after the // update uses the new shortcode already. if ( empty( $entry['max'] ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $entry['max'] = (int) $wpdb->get_var( "SELECT COALESCE( MAX(meta_id), 0 ) FROM {$wpdb->postmeta}" ); } $max = (int) $entry['max']; $from = max( (int) $from, isset( $entry['from'] ) ? (int) $entry['from'] : 0 ); $slice = max( 100, (int) apply_filters( 'harvify_cookie_migration_slice', 5000 ) ); $budget = (float) apply_filters( 'harvify_cookie_migration_seconds', 2.0 ); $like = '%' . $wpdb->esc_like( '[hcc_cookie_declaration' ) . '%'; $start = microtime( true ); $posts = array(); $ok = true; while ( $from < $max ) { $to = min( $from + $slice, $max ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_id > %d AND meta_id <= %d AND meta_value LIKE %s", $from, $to, $like ) ); foreach ( (array) $rows as $row ) { // This plugin's own keys are migrated by migrate_meta(). if ( 0 === strpos( (string) $row->meta_key, '_harvify_cookie_' ) || 0 === strpos( (string) $row->meta_key, '_hcc_' ) ) { continue; } $before = maybe_unserialize( $row->meta_value ); $after = self::rewrite_deep( $before ); if ( $after === $before ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $written = $wpdb->update( $wpdb->postmeta, array( 'meta_value' => maybe_serialize( $after ) ), array( 'meta_id' => (int) $row->meta_id ), array( '%s' ), array( '%d' ) ); if ( false === $written ) { self::note( sprintf( 'Post meta %d still uses the legacy shortcode.', (int) $row->meta_id ) ); $ok = false; continue; } $posts[] = (int) $row->post_id; } $from = $to; if ( microtime( true ) - $start >= $budget ) { break; } } foreach ( array_unique( $posts ) as $post_id ) { wp_cache_delete( $post_id, 'post_meta' ); } $entry['from'] = $from; if ( ! $ok ) { $entry['failed'] = true; } $state = self::state(); $state['meta_shortcodes'] = $entry; update_option( self::STATE_OPTION, $state, true ); if ( $from >= $max ) { self::finish( 'meta_shortcodes', empty( $entry['failed'] ) ); return; } if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time(), self::META_HOOK, array( $from ), self::GROUP ); } } // ------------------------------------------------------------------------- // Phase 2 -- Action Scheduler jobs // ------------------------------------------------------------------------- /** * Retire every job still queued on a legacy hook. * * The replacements are not made here: the Pro scheduler creates exactly the * jobs the migrated settings ask for, under the new names; on a free build or * a lapsed licence it creates none. */ public static function migrate_scheduled() { if ( self::is_done( 'scheduled' ) ) { return; } if ( ! function_exists( 'as_unschedule_all_actions' ) || ! function_exists( 'as_next_scheduled_action' ) ) { return; // Action Scheduler not loaded yet -- try again next request. } $complete = true; foreach ( self::LEGACY_HOOKS as $legacy ) { if ( false === as_next_scheduled_action( $legacy, null, self::GROUP ) ) { continue; } as_unschedule_all_actions( $legacy, null, self::GROUP ); if ( false !== as_next_scheduled_action( $legacy, null, self::GROUP ) ) { self::note( sprintf( 'Could not retire the scheduled %s job(s).', $legacy ) ); $complete = false; } } self::finish( 'scheduled', $complete ); } } Migrations::init();