|null */ private static ?array $batch = null; /** * Wire upgrader hooks (always-on; cheap when the feature is off). */ public function hooks(): void { add_filter( 'upgrader_pre_install', array( $this, 'on_pre_install' ), 5, 2 ); add_filter( 'upgrader_pre_download', array( $this, 'on_pre_download' ), 5, 4 ); add_action( 'pre_auto_update', array( $this, 'on_pre_auto_update' ), 5, 3 ); add_action( 'upgrader_process_complete', array( $this, 'on_process_complete' ), 20, 2 ); } /** * Reset per-request state (tests / long-running processes). */ public static function reset_request_state(): void { self::$batch = null; } /** * Current per-request batch (or null). * * @return array|null */ public static function current_batch(): ?array { return self::$batch; } /** * Last stored record. * * @return array */ public static function last_record(): array { $rec = get_option( self::OPTION_LAST, array() ); return is_array( $rec ) ? $rec : array(); } /** * `upgrader_pre_install` — plugin and theme updates (manual single, bulk, auto). * * @param mixed $response Filter value (true or WP_Error). * @param array $hook_extra Upgrader hook_extra. * @return mixed */ public function on_pre_install( $response, $hook_extra = array() ) { try { if ( is_wp_error( $response ) || ! is_array( $hook_extra ) ) { return $response; } if ( isset( $hook_extra['action'] ) && 'update' !== (string) $hook_extra['action'] ) { return $response; // Installs are not updates. } $type = ''; if ( ! empty( $hook_extra['plugin'] ) ) { $type = 'plugin'; } elseif ( ! empty( $hook_extra['theme'] ) ) { $type = 'theme'; } elseif ( isset( $hook_extra['type'] ) && 'core' === (string) $hook_extra['type'] ) { $type = 'core'; } if ( '' === $type ) { return $response; } $outcome = $this->handle( $type, self::describe_item( $type, $hook_extra ), 'upgrader_pre_install' ); return $this->apply_block( $response, $outcome ); } catch ( \Throwable $e ) { self::note_internal_error( $e ); return $response; } } /** * `upgrader_pre_download` — WordPress core updates (Core_Upgrader does not fire pre_install). * * @param mixed $reply False or a short-circuit value. * @param string $package Package URL/path. * @param mixed $upgrader WP_Upgrader. * @param mixed $hook_extra Hook extra. * @return mixed */ public function on_pre_download( $reply, $package = '', $upgrader = null, $hook_extra = array() ) { unset( $hook_extra ); try { if ( false !== $reply || ! is_object( $upgrader ) || ! class_exists( '\Core_Upgrader', false ) || ! ( $upgrader instanceof \Core_Upgrader ) ) { return $reply; } $item = self::describe_item( 'core', array( 'package' => (string) $package ) ); $outcome = $this->handle( 'core', $item, 'upgrader_pre_download' ); return $this->apply_block( $reply, $outcome ); } catch ( \Throwable $e ) { self::note_internal_error( $e ); return $reply; } } /** * `pre_auto_update` — start the backup before the auto-updater downloads anything. * (Blocking, when enabled, is enforced later by pre_install / pre_download.) * * @param string $type plugin|theme|core|translation. * @param object $item Update offer. * @param string $context Filesystem context. */ public function on_pre_auto_update( $type = '', $item = null, $context = '' ): void { unset( $context ); try { $type = (string) $type; if ( ! in_array( $type, array( 'plugin', 'theme', 'core' ), true ) ) { return; } $extra = array(); if ( is_object( $item ) ) { if ( 'plugin' === $type && isset( $item->plugin ) ) { $extra['plugin'] = (string) $item->plugin; $extra['new_version'] = isset( $item->new_version ) ? (string) $item->new_version : ''; } elseif ( 'theme' === $type && isset( $item->theme ) ) { $extra['theme'] = (string) $item->theme; $extra['new_version'] = isset( $item->new_version ) ? (string) $item->new_version : ''; } elseif ( 'core' === $type ) { $extra['new_version'] = isset( $item->version ) ? (string) $item->version : ''; } } $this->handle( $type, self::describe_item( $type, $extra ), 'pre_auto_update' ); } catch ( \Throwable $e ) { self::note_internal_error( $e ); } } /** * `upgrader_process_complete` — stamp the batch as having reached the update stage * and enrich artifact metadata with every item in the batch. * * @param mixed $upgrader Upgrader. * @param array $hook_extra Hook extra. */ public function on_process_complete( $upgrader = null, $hook_extra = array() ): void { unset( $upgrader ); try { if ( null === self::$batch || ! is_array( $hook_extra ) ) { return; } if ( isset( $hook_extra['action'] ) && 'update' !== (string) $hook_extra['action'] ) { return; } $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : ''; if ( 'plugin' === $type && ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) { foreach ( $hook_extra['plugins'] as $file ) { self::add_item_after_update( self::describe_item( 'plugin', array( 'plugin' => (string) $file ) ) ); } } elseif ( 'theme' === $type && ! empty( $hook_extra['themes'] ) && is_array( $hook_extra['themes'] ) ) { foreach ( $hook_extra['themes'] as $slug ) { self::add_item_after_update( self::describe_item( 'theme', array( 'theme' => (string) $slug ) ) ); } } self::$batch['update_finished'] = true; self::persist( true ); } catch ( \Throwable $e ) { self::note_internal_error( $e ); } } /** * Core entry: at most one backup per request; returns the batch outcome. * * @param string $type plugin|theme|core. * @param array $item Item descriptor. * @param string $source Hook that triggered. * @return array{status:string,block:bool,message:string} */ public function handle( string $type, array $item, string $source ): array { $none = array( 'status' => 'disabled', 'block' => false, 'message' => '', ); if ( ! Settings::pre_update_enabled() ) { return $none; } $policy = self::policy( $type, $item ); if ( empty( $policy['enabled'] ) ) { return $none; } // Same request/batch: never run a second backup, just record the item. if ( null !== self::$batch ) { self::add_item( $item ); // Keep the artifact metadata current for every item in the batch (crash-safe). self::persist( 'success' === (string) self::$batch['status'] ); return self::batch_outcome(); } self::$batch = array( 'batch_id' => 'pu-' . gmdate( 'YmdHis' ) . '-' . wp_generate_password( 6, false, false ), 'time' => time(), 'status' => 'running', 'reason' => '', 'message' => '', 'job_id' => 0, 'artifact_id' => 0, 'items' => array(), 'source' => $source, 'trigger' => 'pre_update', 'blocked' => false, 'block_on_failure' => ! empty( $policy['block_on_failure'] ), 'notes' => isset( $policy['notes'] ) && is_array( $policy['notes'] ) ? array_values( $policy['notes'] ) : array(), 'remote_errors' => array(), 'update_finished' => false, 'seen' => false, ); self::add_item( $item ); try { $this->run_backup( $policy ); } catch ( \Throwable $e ) { self::$batch['status'] = 'failed'; self::$batch['reason'] = 'exception'; self::$batch['message'] = $e->getMessage(); } self::persist( true ); return self::batch_outcome(); } /** * Build effective policy (free defaults, extended edition may refine via filter). * * @param string $type plugin|theme|core. * @param array $item Item. * @return array */ public static function policy( string $type, array $item = array() ): array { $policy = array( 'enabled' => true, 'skip_minutes' => self::DEFAULT_SKIP_MINUTES, 'block_on_failure' => false, 'components' => array( 'database' => true, 'plugins' => true, 'themes' => true, 'uploads' => false, // Keeps the pre-update backup fast; full backups still cover uploads. ), 'timeout' => ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) ? 180 : 60, 'destinations' => array( 'local' ), 'notes' => array(), ); /** * Filter the pre-update backup policy for one update item. * * Keys: enabled, skip_minutes, block_on_failure, components, timeout, notes. * The free master toggle always wins: this filter cannot turn the feature on * when the site owner has left it off. * * @param array $policy Policy. * @param string $type plugin|theme|core. * @param array $item Item descriptor. */ $filtered = apply_filters( 'brverify_pre_update_policy', $policy, $type, $item ); if ( ! is_array( $filtered ) ) { $filtered = $policy; } $out = array_merge( $policy, $filtered ); $out['enabled'] = ! empty( $out['enabled'] ); $out['skip_minutes'] = max( 0, min( 1440, (int) $out['skip_minutes'] ) ); $out['block_on_failure'] = ! empty( $out['block_on_failure'] ); $out['timeout'] = max( 5, min( 600, (int) $out['timeout'] ) ); $comp = is_array( $out['components'] ) ? $out['components'] : $policy['components']; $out['components'] = array( 'database' => true, // Always: a code-only backup cannot roll a schema change back. 'plugins' => ! empty( $comp['plugins'] ), 'themes' => ! empty( $comp['themes'] ), 'uploads' => ! empty( $comp['uploads'] ), ); $out['notes'] = is_array( $out['notes'] ) ? array_values( array_map( 'strval', $out['notes'] ) ) : array(); // Destinations: sanitized, and local is always first so the one-click restore has a local copy. $dest = array( 'local' ); if ( is_array( $out['destinations'] ) ) { foreach ( $out['destinations'] as $id ) { $id = sanitize_key( (string) $id ); if ( '' !== $id && ! in_array( $id, $dest, true ) ) { $dest[] = $id; } } } $out['destinations'] = $dest; /** * Filter pre-update backup components (database is always forced on). * * @param array $components Components. * @param string $type plugin|theme|core. */ $components = apply_filters( 'brverify_pre_update_components', $out['components'], $type ); if ( is_array( $components ) ) { $out['components'] = array( 'database' => true, 'plugins' => ! empty( $components['plugins'] ), 'themes' => ! empty( $components['themes'] ), 'uploads' => ! empty( $components['uploads'] ), ); } return $out; } /** * Run (or skip) the backup for the current batch. * * @param array $policy Policy. */ private function run_backup( array $policy ): void { if ( is_multisite() ) { self::$batch['status'] = 'skipped'; self::$batch['reason'] = 'unsupported'; self::$batch['message'] = __( 'Multisite is not supported; no pre-update backup was taken.', '007aj-backup-restore-verifier' ); return; } $repo = new Job_Repository(); if ( ! $repo->tables_exist() ) { self::$batch['status'] = 'failed'; self::$batch['reason'] = 'no_tables'; self::$batch['message'] = __( '007AJ Backup Restore Verifier tables are missing; no pre-update backup was taken.', '007aj-backup-restore-verifier' ); return; } // Skip-if-recent. $recent = self::recent_artifact( (int) $policy['skip_minutes'] ); if ( null !== $recent ) { self::$batch['status'] = 'skipped'; self::$batch['reason'] = 'recent'; self::$batch['artifact_id'] = (int) $recent['id']; self::$batch['job_id'] = (int) $recent['job_id']; self::$batch['message'] = sprintf( /* translators: 1: artifact id, 2: minutes */ __( 'Skipped: backup #%1$d is newer than %2$d minute(s).', '007aj-backup-restore-verifier' ), (int) $recent['id'], (int) $policy['skip_minutes'] ); return; } $runner = new Job_Runner( $repo ); $deadline = microtime( true ) + (int) $policy['timeout']; // Resume-safe: adopt an interrupted pre-update job instead of queueing another. $job_id = self::find_active_pre_update_job( $repo ); if ( 0 === $job_id ) { if ( ! self::acquire_starting() ) { self::mark_busy(); return; } $config = array( 'components' => $policy['components'], 'destinations' => $policy['destinations'], 'trigger' => 'pre_update', 'pre_update' => self::artifact_descriptor(), ); $queued = $runner->queue_backup( $config ); if ( empty( $queued['ok'] ) ) { self::release_starting(); self::mark_busy( (string) ( $queued['error'] ?? '' ) ); return; } $job_id = (int) $queued['job_id']; self::release_starting(); Logger::info( $job_id, 'Pre-update backup queued (' . self::$batch['source'] . ').' ); } else { Logger::info( $job_id, 'Pre-update backup resumed by a new update request.' ); // Adopted job may be owned by another worker: only wait a short while for it. $deadline = min( $deadline, microtime( true ) + 15 ); } self::$batch['job_id'] = $job_id; if ( function_exists( 'wp_raise_memory_limit' ) ) { wp_raise_memory_limit( 'admin' ); } $final = 'running'; $last = array(); while ( microtime( true ) < $deadline ) { $last = $runner->tick( $job_id ); $final = (string) ( $last['status'] ?? '' ); if ( in_array( $final, array( Job_States::STATUS_COMPLETED, Job_States::STATUS_FAILED, Job_States::STATUS_CANCELLED ), true ) ) { break; } if ( 'locked' === $final || 'idle' === $final ) { $job = $repo->get( $job_id ); if ( is_array( $job ) && in_array( (string) $job['status'], array( Job_States::STATUS_COMPLETED, Job_States::STATUS_FAILED, Job_States::STATUS_CANCELLED ), true ) ) { $final = (string) $job['status']; break; } usleep( 250000 ); } } $job = $repo->get( $job_id ); if ( is_array( $job ) ) { $final = in_array( (string) $job['status'], array( Job_States::STATUS_COMPLETED, Job_States::STATUS_FAILED, Job_States::STATUS_CANCELLED ), true ) ? (string) $job['status'] : $final; } if ( Job_States::STATUS_COMPLETED === $final ) { $result = is_array( $job ) && isset( $job['result'] ) && is_array( $job['result'] ) ? $job['result'] : array(); self::$batch['status'] = 'success'; self::$batch['artifact_id'] = (int) ( $result['artifact_id'] ?? 0 ); self::$batch['remote_errors'] = isset( $result['remote_errors'] ) && is_array( $result['remote_errors'] ) ? $result['remote_errors'] : array(); self::$batch['message'] = __( 'Pre-update backup completed.', '007aj-backup-restore-verifier' ); if ( ! empty( self::$batch['remote_errors'] ) ) { self::$batch['notes'][] = __( 'Local backup succeeded, but at least one remote destination failed.', '007aj-backup-restore-verifier' ); } return; } if ( Job_States::STATUS_FAILED === $final || Job_States::STATUS_CANCELLED === $final ) { self::$batch['status'] = 'failed'; self::$batch['reason'] = 'job_failed'; self::$batch['message'] = is_array( $job ) && ! empty( $job['error_message'] ) ? (string) $job['error_message'] : __( 'The pre-update backup failed.', '007aj-backup-restore-verifier' ); return; } // Timed out: the job stays queued/running and the existing cron resume finishes it. self::$batch['status'] = 'timeout'; self::$batch['reason'] = 'timeout'; self::$batch['message'] = __( 'The pre-update backup did not finish in time; it will keep running in the background.', '007aj-backup-restore-verifier' ); } /** * Convert a non-queueable state into a batch "busy" skip. * * @param string $detail Optional detail. */ private static function mark_busy( string $detail = '' ): void { self::$batch['status'] = 'skipped'; self::$batch['reason'] = 'busy'; self::$batch['message'] = '' !== $detail ? $detail : __( 'Another backup or restore is in progress; no pre-update backup was started.', '007aj-backup-restore-verifier' ); } /** * Descriptor stored on the job config (and therefore in manifest/artifact meta). * * @return array */ private static function artifact_descriptor(): array { $items = isset( self::$batch['items'] ) && is_array( self::$batch['items'] ) ? self::$batch['items'] : array(); $first = $items[0] ?? array(); return array( 'batch_id' => (string) ( self::$batch['batch_id'] ?? '' ), 'source' => (string) ( self::$batch['source'] ?? '' ), 'type' => (string) ( $first['type'] ?? '' ), 'slug' => (string) ( $first['slug'] ?? '' ), 'from' => (string) ( $first['from'] ?? '' ), 'to' => (string) ( $first['to'] ?? '' ), 'items' => $items, ); } /** * Find an active (pending/running) pre-update backup job, if any. * * @param Job_Repository $repo Repo. */ private static function find_active_pre_update_job( Job_Repository $repo ): int { foreach ( $repo->list_recent( 20 ) as $job ) { if ( Job_States::TYPE_BACKUP !== (string) $job['type'] ) { continue; } if ( ! in_array( (string) $job['status'], array( Job_States::STATUS_PENDING, Job_States::STATUS_RUNNING ), true ) ) { continue; } $cfg = isset( $job['config'] ) && is_array( $job['config'] ) ? $job['config'] : array(); if ( 'pre_update' === (string) ( $cfg['trigger'] ?? '' ) ) { return (int) $job['id']; } } return 0; } /** * Newest local artifact if it is younger than $minutes (and the file still exists). * * @param int $minutes Window; 0 disables skipping. * @return array|null */ public static function recent_artifact( int $minutes ): ?array { if ( $minutes <= 0 ) { return null; } $repo = new Job_Repository(); foreach ( $repo->list_artifacts( 10 ) as $art ) { if ( 'local' !== (string) ( $art['adapter'] ?? 'local' ) ) { continue; } $path = (string) ( $art['path'] ?? '' ); if ( '' === $path || ! is_file( $path ) ) { continue; } // created_at is stored via current_time('mysql') (site-local); compare on the same basis. $created = strtotime( (string) ( $art['created_at'] ?? '' ) . ' UTC' ); $now = (int) current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested if ( false !== $created && ( $now - $created ) >= 0 && ( $now - $created ) < $minutes * MINUTE_IN_SECONDS ) { return $art; } return null; // Newest usable local artifact is too old. } return null; } /** * Atomic starting mutex. */ private static function acquire_starting(): bool { $now = time(); if ( add_option( self::OPTION_STARTING, $now, '', false ) ) { return true; } $held = (int) get_option( self::OPTION_STARTING, 0 ); if ( $held > 0 && ( $now - $held ) > self::STARTING_TTL ) { update_option( self::OPTION_STARTING, $now, false ); return true; } return false; } /** * Release starting mutex. */ private static function release_starting(): void { delete_option( self::OPTION_STARTING ); } /** * Append an item to the batch (deduped by type+slug). * * @param array $item Item. */ private static function add_item( array $item ): void { if ( null === self::$batch || empty( $item['slug'] ) ) { return; } foreach ( self::$batch['items'] as $existing ) { if ( $existing['type'] === $item['type'] && $existing['slug'] === $item['slug'] ) { return; } } self::$batch['items'][] = $item; } /** * Add an item first seen AFTER the update ran (installed version is already the new one). * * @param array $item Item. */ private static function add_item_after_update( array $item ): void { $item['to'] = $item['from']; $item['from'] = ''; self::add_item( $item ); } /** * Persist the batch record; optionally enrich artifact meta with all items. * * @param bool $touch_artifact Whether to update the artifact meta row. */ private static function persist( bool $touch_artifact ): void { if ( null === self::$batch ) { return; } $rec = self::$batch; update_option( self::OPTION_LAST, $rec, false ); if ( $touch_artifact && 'success' === (string) $rec['status'] && (int) $rec['artifact_id'] > 0 ) { $repo = new Job_Repository(); $repo->update_artifact_meta( (int) $rec['artifact_id'], array( 'trigger' => 'pre_update', 'pre_update' => self::artifact_descriptor(), ) ); } } /** * Outcome for the current batch (status + whether an update must be blocked). * * @return array{status:string,block:bool,message:string} */ private static function batch_outcome(): array { $status = (string) ( self::$batch['status'] ?? 'failed' ); $reason = (string) ( self::$batch['reason'] ?? '' ); $failed = in_array( $status, array( 'failed', 'timeout' ), true ) || ( 'skipped' === $status && 'busy' === $reason ); $block = $failed && ! empty( self::$batch['block_on_failure'] ); if ( $block && isset( self::$batch ) ) { self::$batch['blocked'] = true; update_option( self::OPTION_LAST, self::$batch, false ); } return array( 'status' => $status, 'block' => $block, 'message' => (string) ( self::$batch['message'] ?? '' ), ); } /** * Convert an outcome into the filter return (WP_Error only when blocking). * * @param mixed $original Original filter value. * @param array{status:string,block:bool,message:string} $outcome Outcome. * @return mixed */ private function apply_block( $original, array $outcome ) { if ( empty( $outcome['block'] ) ) { return $original; } return new \WP_Error( 'brverify_pre_update_backup_failed', sprintf( /* translators: %s: failure message */ __( 'Update blocked: the 007AJ Backup Restore Verifier pre-update backup did not complete (%s). Fix the backup or disable "block update if backup fails" and try again.', '007aj-backup-restore-verifier' ), $outcome['message'] ) ); } /** * Build an item descriptor (type/slug/from/to). * * @param string $type plugin|theme|core. * @param array $src hook_extra or offer fields. * @return array */ public static function describe_item( string $type, array $src ): array { $slug = ''; $from = ''; $to = isset( $src['new_version'] ) ? (string) $src['new_version'] : ''; if ( 'plugin' === $type ) { $file = (string) ( $src['plugin'] ?? '' ); $slug = '' !== $file ? ( false !== strpos( $file, '/' ) ? dirname( $file ) : basename( $file, '.php' ) ) : ''; if ( '' !== $file ) { if ( ! function_exists( 'get_plugins' ) && defined( 'ABSPATH' ) && is_readable( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( function_exists( 'get_plugins' ) ) { $all = get_plugins(); $from = isset( $all[ $file ]['Version'] ) ? (string) $all[ $file ]['Version'] : ''; } if ( '' === $to ) { $tr = get_site_transient( 'update_plugins' ); if ( is_object( $tr ) && isset( $tr->response[ $file ]->new_version ) ) { $to = (string) $tr->response[ $file ]->new_version; } } } } elseif ( 'theme' === $type ) { $slug = (string) ( $src['theme'] ?? '' ); if ( '' !== $slug ) { $theme = wp_get_theme( $slug ); $from = $theme->exists() ? (string) $theme->get( 'Version' ) : ''; if ( '' === $to ) { $tr = get_site_transient( 'update_themes' ); if ( is_object( $tr ) && isset( $tr->response[ $slug ]['new_version'] ) ) { $to = (string) $tr->response[ $slug ]['new_version']; } } } } elseif ( 'core' === $type ) { $slug = 'WordPress'; $from = (string) get_bloginfo( 'version' ); // The package name is the most precise source: it is the release actually being installed. if ( '' === $to && ! empty( $src['package'] ) && preg_match( '/wordpress-(\d+(?:\.\d+){1,2})/', (string) $src['package'], $m ) ) { $to = $m[1]; } if ( '' === $to ) { $tr = get_site_transient( 'update_core' ); if ( is_object( $tr ) && ! empty( $tr->updates[0]->version ) ) { $to = (string) $tr->updates[0]->version; } } } return array( 'type' => $type, 'slug' => $slug, 'from' => $from, 'to' => $to, ); } /** * Swallow-and-log helper: never let this feature break an update. * * @param \Throwable $e Error. */ private static function note_internal_error( \Throwable $e ): void { if ( null !== self::$batch ) { self::$batch['status'] = 'failed'; self::$batch['reason'] = 'exception'; self::$batch['message'] = $e->getMessage(); update_option( self::OPTION_LAST, self::$batch, false ); } if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) { error_log( '007AJ Backup Restore Verifier pre-update hook error: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } }