7200 ) { @unlink( __FILE__ ); // The restore has been silent for 2h — assume abandoned and stand down. return; } $aisiteark_guard_self = '%%BASENAME%%'; if ( '' === $aisiteark_guard_self ) { return; } add_filter( 'option_active_plugins', function () use ( $aisiteark_guard_self ) { return array( $aisiteark_guard_self ); }, 99 ); add_filter( 'option_active_sitewide_plugins', '__return_empty_array', 99 ); add_action( 'template_redirect', function () { if ( is_admin() || ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) || ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) { return; } if ( ! headers_sent() ) { status_header( 503 ); header( 'Retry-After: 120' ); nocache_headers(); } echo 'Updating…

This site is being updated

A backup is being restored. Please check back in a few minutes.

'; exit; }, 0 ); GUARD; } /** * A restore whose state file hasn't been touched in this long is treated as * abandoned (a restore only advances via browser AJAX — the tab was closed or it * crashed), so a stuck marker can't block backups forever. */ const STALE_AFTER = 900; // 15 minutes. /** * Whether a restore is in progress. Auto-clears an abandoned (stale) restore. * * @return bool */ public static function in_progress() { $path = self::state_path(); if ( ! $path || ! is_file( $path ) ) { return false; } if ( ( time() - (int) @filemtime( $path ) ) > self::STALE_AFTER ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged self::clear(); // Abandoned restore — release the block. return false; } return null !== self::load(); } /** * The current restore's state (phase + counters), or null. For the tasks view. * * @return array|null */ public static function current() { if ( ! self::in_progress() ) { return null; } return self::load(); } /** Option holding the outcome of the most recent restore. */ const LAST_RESULT = 'aisiteark_last_restore'; /** * Record how the most recent restore ended, so the browser can tell a restore that * genuinely FINISHED (in the background, after its foreground request was interrupted) * from one that truly failed — instead of showing a scary error for a restore that * actually completed. See Controller::ajax_restore_status() and siteark.js. * * @param string $status 'done' | 'failed'. * @param string $message Failure detail, when failed. * @return void */ private static function record_result( $status, $message = '' ) { update_option( self::LAST_RESULT, array( 'status' => (string) $status, 'ts' => time(), 'message' => (string) $message, ), false ); } /** * The outcome of the most recent restore, or null if none recorded. * * @return array|null */ public static function last_result() { $r = get_option( self::LAST_RESULT, null ); return is_array( $r ) ? $r : null; } /** * Cancel / clear a stuck restore (admin action). Drops the state file and lifts * maintenance mode. Any half-written recovery point is left in place. * * @return void */ public static function cancel() { self::clear(); } /** The cron hook that keeps an unattended restore moving. */ const WATCHDOG = 'aisiteark_restore_continue'; /** * P2: does this token belong to the restore currently in progress? * * The token authorises a cookie-less loopback request to advance the restore. It can * never START one, and it dies with the state file. * * @param string $token Candidate token. * @return bool */ public static function verify_token( $token ) { $token = (string) $token; if ( '' === $token ) { return false; } $st = self::load(); if ( ! $st || empty( $st['token'] ) ) { return false; } return hash_equals( (string) $st['token'], $token ); } /** * P2: schedule the watchdog that finishes a restore whose browser tab has gone away. * * Must be called AFTER each step, never before: the step may have imported the source * site's `cron` option, which would wipe any event scheduled earlier in the request. * Re-arming afterwards writes the event into the restored options table. * * @return void */ public static function arm_watchdog() { if ( ! self::in_progress() ) { return; } if ( ! wp_next_scheduled( self::WATCHDOG ) ) { wp_schedule_single_event( time() + 60, self::WATCHDOG ); } } /** * Remove any pending watchdog (the restore ended, one way or another). * * @return void */ private static function disarm_watchdog() { $ts = wp_next_scheduled( self::WATCHDOG ); while ( $ts ) { wp_unschedule_event( $ts, self::WATCHDOG ); $ts = wp_next_scheduled( self::WATCHDOG ); } } /** * P2: kick the next step immediately via a non-blocking loopback request, so an * unattended restore runs at full speed instead of once per cron tick. * * Fire-and-forget: we never wait for, or care about, the response. * * @return void */ public static function spawn_continue() { $st = self::load(); if ( ! $st || empty( $st['token'] ) ) { return; } wp_remote_post( admin_url( 'admin-ajax.php' ), array( 'timeout' => 0.01, 'blocking' => false, 'sslverify' => false, 'body' => array( 'action' => self::WATCHDOG, 'token' => (string) $st['token'], ), ) ); } /** * Validate an archive + start a restore. * * @param string $archive_path Absolute archive path. * @param string $password Archive password (encrypted backups only). * @param array $components Selective-restore components (empty = all). * @param bool $force Skip the pre-restore compatibility / missing-item * gate (the user explicitly chose "restore anyway"). * @return true|\WP_Error */ public static function start( $archive_path, $password = '', $components = array(), $force = false, $replacements = array(), $make_snapshot = null, $replace_all = false ) { // A backup writing the same archive/DB must not overlap a restore. if ( Lock::is_held( Backup_Runner::LOCK ) ) { return new \WP_Error( 'aisiteark_backup_active', __( 'A backup is currently running. Please wait for it to finish before restoring.', 'ai-siteark' ) ); } if ( ! is_file( $archive_path ) ) { return new \WP_Error( 'aisiteark_restore_missing', __( 'Backup file not found.', 'ai-siteark' ) ); } // G4/G2: verify each volume's integrity sidecar when present (older backups // without one simply skip this check). foreach ( Local_Store::volumes_for( $archive_path ) as $vol ) { $sum_path = $vol . '.sha256'; if ( ! is_file( $sum_path ) ) { continue; } $expected = trim( (string) @file_get_contents( $sum_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged if ( '' !== $expected && ! hash_equals( $expected, (string) hash_file( 'sha256', $vol ) ) ) { return new \WP_Error( 'aisiteark_restore_integrity', __( 'This backup failed its integrity check — a volume may be corrupted or was tampered with.', 'ai-siteark' ) ); } } $components = is_array( $components ) ? array_values( array_filter( array_map( 'sanitize_key', $components ) ) ) : array(); if ( class_exists( '\\AtoZ\\SiteArk\\Modules\\Backup\\Controller' ) ) { \AtoZ\SiteArk\Modules\Backup\Controller::log_activity( __( 'Restore started', 'ai-siteark' ) ); } $reader = new Archive_Reader(); $ok = $reader->open( $archive_path ); if ( is_wp_error( $ok ) ) { return $ok; } $header = $reader->next(); if ( ! $header || ( isset( $header['type'] ) ? $header['type'] : '' ) !== 'manifest' ) { $reader->close(); return new \WP_Error( 'aisiteark_restore_manifest', __( 'This archive has no manifest.', 'ai-siteark' ) ); } $manifest = json_decode( $reader->read_payload(), true ); $valid = Manifest::validate( $manifest ); if ( is_wp_error( $valid ) ) { $reader->close(); return $valid; } $pos = $reader->next_entry_pos(); $total_size = $reader->total_size(); // Logical size across all volumes. $reader->close(); // Encrypted backups need the correct password before we touch anything. $enc_key = ''; if ( Manifest::is_encrypted( $manifest ) ) { $key = Manifest::verify_password( $manifest, $password ); if ( false === $key ) { return new \WP_Error( 'aisiteark_restore_password', __( 'This backup is encrypted — the password is missing or wrong.', 'ai-siteark' ) ); } $enc_key = base64_encode( $key ); // Encrypt-then-MAC: authenticate each volume with the password-keyed HMAC (.mac) now // that we hold the key. This catches tampering the unkeyed .sha256 can't (an attacker // who edits a volume could recompute .sha256, but not a key-bound MAC). Backups made // before this feature have no .mac and are covered by the .sha256 check above. foreach ( Local_Store::volumes_for( $archive_path ) as $vol ) { $mac_path = $vol . '.mac'; if ( ! is_file( $mac_path ) ) { continue; } $expected = trim( (string) @file_get_contents( $mac_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged $actual = \AtoZ\SiteArk\Helpers\Crypto::file_mac( $vol, $key ); if ( '' !== $expected && ( '' === $actual || ! hash_equals( $expected, $actual ) ) ) { return new \WP_Error( 'aisiteark_restore_mac', __( 'This encrypted backup failed its authenticity check — a volume was modified after it was created. Restore aborted.', 'ai-siteark' ) ); } } } // Pre-restore gate: environment compatibility + missing table/file detection. // Runs before the site is touched; the user can override with "restore anyway". if ( ! $force ) { $pre = self::preflight( $manifest, $archive_path ); if ( is_wp_error( $pre ) ) { return $pre; } } // Optional user-defined find→replace pairs applied to the DB (like // All-in-One WP Migration's export find/replace). Empty "from" values are // dropped; the map is applied alongside any migration URL/path rewrite. $sr_pairs = array(); foreach ( (array) $replacements as $pair ) { $from = isset( $pair['from'] ) ? (string) $pair['from'] : ''; $to = isset( $pair['to'] ) ? (string) $pair['to'] : ''; if ( '' !== $from ) { $sr_pairs[ $from ] = $to; } } // G1: when the target is an incremental (differential) backup, restore its base // first (files only), then the increment on top, then apply the increment's // deletions. Build the ordered source plan. $seq = isset( $manifest['seq'] ) ? (int) $manifest['seq'] : 0; $base_file = isset( $manifest['base_file'] ) ? (string) $manifest['base_file'] : ''; $deletions = ( isset( $manifest['deletions'] ) && is_array( $manifest['deletions'] ) ) ? array_values( $manifest['deletions'] ) : array(); $user_wants_files = empty( $components ) || (bool) array_intersect( $components, array( 'uploads', 'themes', 'plugins', 'muplugins' ) ); $sources = array(); $del_comps = array(); if ( $seq > 0 && '' !== $base_file && $user_wants_files ) { $base_path = Local_Store::path_for( $base_file ); if ( ! $base_path ) { return new \WP_Error( 'aisiteark_restore_base_missing', /* translators: %s: base backup filename. */ sprintf( __( 'This is an incremental backup, but the full backup it builds on (%s) is missing. Restore that full backup (or a later one) instead.', 'ai-siteark' ), $base_file ) ); } $bmeta = self::archive_meta( $base_path, $password ); if ( is_wp_error( $bmeta ) ) { return $bmeta; } $base_comps = empty( $components ) ? array( 'uploads', 'themes', 'plugins', 'muplugins' ) : array_values( array_diff( $components, array( 'database' ) ) ); $del_comps = $base_comps; // `start` = the first-entry offset, so the DB pass can rewind after the files pass. // `pass` = 'files' first, then 'db' — see do_process() (files-first / DB-last). $sources[] = array( 'archive' => $base_path, 'pos' => $bmeta['pos'], 'start' => $bmeta['pos'], 'pass' => 'files', 'size' => $bmeta['size'], 'enc_key' => $bmeta['enc_key'], 'comps' => $base_comps, 'deletions' => array() ); } $sources[] = array( 'archive' => $archive_path, 'pos' => $pos, 'start' => $pos, 'pass' => 'files', 'size' => (int) $total_size, 'enc_key' => $enc_key, 'comps' => $components, 'deletions' => $deletions ); $total_all = 0; foreach ( $sources as $s ) { $total_all += (int) $s['size']; } // J3: take a database recovery point before touching the site, so a failed or // unwanted restore can be rolled back. Only when the DB is actually being // restored, and skipped for a rollback itself (make_snapshot === false). $wants_db = empty( $components ) || in_array( 'database', $components, true ); $rp_setting = Settings::get(); // Free: no pre-restore recovery point (a Pro feature). $setting = false; $snapshot = ( null === $make_snapshot ? $setting : (bool) $make_snapshot ) && $wants_db; $snap_files = $snapshot && ! empty( $rp_setting['recovery_files'] ); global $wpdb; // Remember which backups directory is OURS before the archive's options table // lands and renames it under us. See pin_store_secret(). Local_Store::dir(); // Ensure the secret exists. $store_secret = (string) get_option( 'aisiteark_backup_secret', '' ); self::save( array( 'archive' => $archive_path, 'store_secret' => $store_secret, // P2: lets a cookie-less loopback request prove it may advance THIS restore. // It lives in the state file, which is deliberately outside the database and // therefore survives the import that replaces every table. 'token' => wp_generate_password( 48, false ), 'phase' => $snapshot ? 'snapshot' : 'process', 'snap' => $snapshot, 'snap_files' => $snap_files, 'sources' => $sources, 'src_i' => 0, 'del_comps' => $del_comps, 'sr_pairs' => $sr_pairs, 'size' => (int) $total_all, 'old_prefix' => isset( $manifest['table_prefix'] ) ? (string) $manifest['table_prefix'] : $wpdb->prefix, 'new_prefix' => $wpdb->prefix, 'components' => $components, // "Replace all files" (opt-in): empty the restored wp-content folders before // extracting, so the site becomes an exact copy of the backup rather than a // merge. Only meaningful when files are being restored. See wipe_for_replace(). 'replace_all' => (bool) $replace_all && $user_wants_files, 'wiped' => false, 'migration' => Manifest::is_migration( $manifest ), 'enc_key' => $enc_key, 'old_url' => isset( $manifest['site_url'] ) ? untrailingslashit( (string) $manifest['site_url'] ) : '', 'new_url' => untrailingslashit( site_url() ), 'old_path' => isset( $manifest['abspath'] ) ? (string) $manifest['abspath'] : '', 'new_path' => ABSPATH, 'sr_ti' => 0, 'sr_off' => 0, 'db' => 0, 'files' => 0, ) ); // Clear any previous restore's recorded outcome, so the browser can't mistake an // older "done"/"failed" for the result of this run while it reconciles. delete_option( self::LAST_RESULT ); // P2: arm the watchdog immediately, so a browser that dies during the very first // step still leaves something behind to finish the job. Every later step re-arms it // after the import, which is what makes it survive the `cron` option being replaced. self::arm_watchdog(); // J4: maintenance mode for the duration of the restore. // // We deliberately do NOT write core's ABSPATH/.maintenance here. wp_maintenance() // runs in wp-settings.php long before plugins load (and core's own docblock notes // the `enable_maintenance_mode` filter "runs before it can be used by plugins"), // and it exempts nothing — not wp-admin, not admin-ajax.php. Writing that file // made every follow-up `aisiteark_restore_step` AJAX call return a 503 maintenance // page instead of JSON, so a browser-driven restore could never get past its // first request, and the admin was locked out for 10 minutes. // // Instead the front end is closed to visitors by Controller::maintenance_gate() // (template_redirect), which runs after plugins load and lets admins + our own // AJAX through. See Controller::maintenance_gate(). // // AND the boot-time guard (below) loads BEFORE any regular plugin, so even if the // imported database activates a plugin whose files aren't restored yet, that plugin // never loads and the site can't white-screen — it shows maintenance and the restore // keeps running. This is what makes an interrupted restore unable to take the site down. self::write_guard(); return true; } /** * Pre-restore gate. Two independent, non-destructive checks run before the site * is touched: * * 1. Compatibility — refuse (overridably) to restore a backup made on a newer * WordPress major, or on a DB server the current one can't safely read * (MySQL 8 → MariaDB, or any major downgrade), whose collations/syntax would * break the import and leave the site half-restored. * 2. Missing-item detection — a header-only pass over the archive verifies it * actually contains every table the manifest's inventory claims, and roughly * the file count it claims; a shortfall means a truncated/corrupt backup. * * Older backups without env fields / an inventory simply skip the affected part. * * @param array $manifest Decoded manifest. * @param string $archive_path Archive path. * @return true|\WP_Error WP_Error 'aisiteark_restore_incompat' lists every problem. */ public static function preflight( array $manifest, $archive_path ) { $issues = array(); /* ---- 1. Environment compatibility ---- */ $src_wp = isset( $manifest['wp_version'] ) ? (string) $manifest['wp_version'] : ''; $cur_wp = (string) get_bloginfo( 'version' ); if ( '' !== $src_wp && '' !== $cur_wp && version_compare( self::major_minor( $src_wp ), self::major_minor( $cur_wp ), '>' ) ) { $issues[] = sprintf( /* translators: 1: backup WordPress version, 2: this site's WordPress version. */ __( 'This backup was made on WordPress %1$s but this site runs %2$s. Restoring a newer database onto older core can break the site — update WordPress here first.', 'ai-siteark' ), $src_wp, $cur_wp ); } if ( self::wants_db_restore( $manifest ) ) { global $wpdb; $src = self::db_profile( isset( $manifest['db_server_info'] ) ? (string) $manifest['db_server_info'] : '', isset( $manifest['db_version'] ) ? (string) $manifest['db_version'] : '' ); $cur = self::db_profile( is_object( $wpdb ) && method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : '', is_object( $wpdb ) ? (string) $wpdb->db_version() : '' ); if ( $src['major'] > 0 && $cur['major'] > 0 ) { if ( ! $src['maria'] && $src['major'] >= 8 && $cur['maria'] ) { $issues[] = sprintf( /* translators: 1: source MySQL version, 2: current MariaDB version. */ __( 'This backup was made on MySQL %1$s but this server is MariaDB %2$s. MySQL 8 collations (e.g. utf8mb4_0900_ai_ci) are not valid on MariaDB and the database import would fail partway.', 'ai-siteark' ), $src['ver'], $cur['ver'] ); } elseif ( $src['maria'] === $cur['maria'] && $src['major'] > $cur['major'] ) { $issues[] = sprintf( /* translators: 1: source DB version, 2: current DB version. */ __( 'This backup was made on a newer database server (%1$s) than this one (%2$s). Restoring it may hit unsupported syntax or collations.', 'ai-siteark' ), $src['ver'], $cur['ver'] ); } } } /* ---- 2. Missing table / file detection ---- */ $inv = Manifest::inventory( $manifest ); if ( is_array( $inv ) ) { $reader = new Archive_Reader(); if ( ! is_wp_error( $reader->open( $archive_path ) ) ) { $seen_tables = array(); $file_seen = 0; $max_file = 0; $reader->next(); // Skip the manifest entry. while ( null !== ( $h = $reader->next() ) ) { $type = isset( $h['type'] ) ? $h['type'] : ''; if ( 'db' === $type && 'create' === ( isset( $h['kind'] ) ? $h['kind'] : '' ) && ! empty( $h['table'] ) ) { $seen_tables[ (string) $h['table'] ] = true; } elseif ( 'file' === $type ) { ++$file_seen; $max_file = max( $max_file, (int) ( $h['size'] ?? 0 ) ); } } $reader->close(); // P6: each file is written whole, so the biggest entry is the real // high-water mark. Plus headroom for the database import's temp usage. $issue = self::disk_issue( $max_file ); if ( '' !== $issue ) { $issues[] = $issue; } $missing = array(); foreach ( (array) ( isset( $inv['tables'] ) ? $inv['tables'] : array() ) as $t ) { if ( empty( $seen_tables[ (string) $t ] ) ) { $missing[] = (string) $t; } } if ( $missing ) { $issues[] = sprintf( /* translators: 1: count, 2: comma-separated table names (first few). */ _n( 'The archive is missing %1$d database table it should contain (%2$s) — the backup looks incomplete or corrupted.', 'The archive is missing %1$d database tables it should contain (%2$s) — the backup looks incomplete or corrupted.', count( $missing ), 'ai-siteark' ), count( $missing ), implode( ', ', array_slice( $missing, 0, 8 ) ) . ( count( $missing ) > 8 ? '…' : '' ) ); } $expected = (int) ( isset( $inv['file_count'] ) ? $inv['file_count'] : 0 ); $shortfall = $expected - $file_seen; // Tolerate a handful of files that vanished between scan and archive; // flag only a real shortfall (truncated archive). if ( $expected > 0 && $shortfall > max( 10, (int) round( $expected * 0.02 ) ) ) { $issues[] = sprintf( /* translators: 1: files found, 2: files expected. */ __( 'The archive contains only %1$d of the %2$d files this backup recorded — it looks truncated or corrupted.', 'ai-siteark' ), $file_seen, $expected ); } } } if ( $issues ) { return new \WP_Error( 'aisiteark_restore_incompat', implode( ' ', $issues ), array( 'incompat' => true ) ); } return true; } /** * Whether the manifest's backup includes the database (so DB compatibility * matters). Defaults to true when scope is absent (older/whole-site backups). * * @param array $manifest Manifest. * @return bool */ private static function wants_db_restore( array $manifest ) { if ( ! isset( $manifest['scope'] ) || ! is_array( $manifest['scope'] ) ) { return true; } return ! empty( $manifest['scope']['database'] ); } /** * P6: describe a disk-space shortfall for the restore, or '' when there is room. * * A restore mostly overwrites files in place, so it does not need the whole archive * again. What it genuinely needs is room for the single largest file it will write, * plus a working margin for the database import. Hosts that disable * `disk_free_space()` skip the check rather than block the restore. * * @param int $max_file Largest file entry in the archive, in bytes. * @return string Human-readable issue, or ''. */ private static function disk_issue( $max_file ) { if ( ! function_exists( 'disk_free_space' ) ) { return ''; } $free = @disk_free_space( WP_CONTENT_DIR ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $free || $free <= 0 ) { return ''; } $margin = 64 * 1024 * 1024; $needed = (int) $max_file + $margin; if ( $free >= $needed ) { return ''; } return sprintf( /* translators: 1: space needed, 2: space free. */ __( 'There may not be enough disk space to restore this backup: it needs at least %1$s free (its largest file, plus room for the database import) but only %2$s is free.', 'ai-siteark' ), size_format( $needed ), size_format( $free ) ); } /** * Reduce a version string to "major.minor" for a coarse compatibility compare. * * @param string $v Version. * @return string */ private static function major_minor( $v ) { if ( preg_match( '/^(\d+)(?:\.(\d+))?/', (string) $v, $m ) ) { return $m[1] . '.' . ( isset( $m[2] ) ? $m[2] : '0' ); } return '0.0'; } /** * Parse a DB server info / version string into { maria, major, ver }. * * @param string $server_info $wpdb->db_server_info() output (may be empty). * @param string $db_version $wpdb->db_version() fallback. * @return array */ private static function db_profile( $server_info, $db_version ) { $info = (string) $server_info; $maria = ( false !== stripos( $info, 'mariadb' ) ); $ver = ''; if ( $maria && preg_match( '/(\d+\.\d+\.\d+)-mariadb/i', $info, $m ) ) { $ver = $m[1]; // e.g. "5.5.5-10.6.12-MariaDB" → "10.6.12". } elseif ( preg_match( '/(\d+\.\d+(?:\.\d+)?)/', $info, $m ) ) { $ver = $m[1]; } else { $ver = (string) $db_version; } $parts = explode( '.', $ver ); return array( 'maria' => $maria, 'major' => (int) ( isset( $parts[0] ) ? $parts[0] : 0 ), 'ver' => ( '' !== $ver ? $ver : (string) $db_version ) ); } /** * Run one restore step. * * @return array Status. */ public static function step() { $st = self::load(); if ( ! $st ) { return array( 'error' => true, 'message' => __( 'No restore in progress.', 'ai-siteark' ) ); } // Never let two steppers (browser loop + cron watchdog) run at once. If another holds // the lock, back off with a benign not-done result and let it carry the restore on. $lock = self::step_lock(); if ( false === $lock ) { return array( 'done' => false, 'progress' => self::progress( $st ), 'phase' => isset( $st['phase'] ) ? $st['phase'] : '', 'message' => self::label( $st ), ); } // Re-load under the lock so we act on the freshest state the previous holder saved. $st = self::load(); if ( ! $st ) { self::step_unlock( $lock ); return array( 'error' => true, 'message' => __( 'No restore in progress.', 'ai-siteark' ) ); } $t0 = microtime( true ); try { if ( 'process' === $st['phase'] ) { self::do_process( $st, $t0 ); } elseif ( 'searchreplace' === $st['phase'] ) { self::do_search_replace( $st, $t0 ); } elseif ( 'finalize' === $st['phase'] ) { self::do_finalize( $st ); } } catch ( \Throwable $e ) { // Put our backups directory back first, or clear() would look in the wrong place. self::pin_store_secret( $st ); // And keep siteurl steady, so a retry/continuation isn't locked out (COOKIEHASH). self::pin_site_url( $st ); // A failure during the snapshot is safe (site untouched); drop the // half-written recovery point. A failure during the actual restore leaves // the recovery point in place so the DB can be rolled back. if ( 'snapshot' === $st['phase'] && ! empty( $st['snap_archive'] ) ) { Local_Store::delete_all_volumes( $st['snap_archive'] ); } self::clear(); self::record_result( 'failed', $e->getMessage() ); self::step_unlock( $lock ); return array( 'error' => true, 'message' => $e->getMessage() ); } // The step above may have imported the archive's options table, which would move // our backups directory (and this restore's own state file) out from under us. self::pin_store_secret( $st ); // …and would flip siteurl to the source's URL, which changes COOKIEHASH and signs // the operator out on the next request (a 400 mid-migration). Pin it back. self::pin_site_url( $st ); // …and would replace active_plugins with the SOURCE site's, which may not list this // plugin under its real folder (e.g. only "ai-siteark-1/"). If we let ourselves // fall out of active_plugins, the very next request — and the background watchdog — // wouldn't load us, and the restore would stall short of finalize. Keep us active // EVERY step, not just at finalize. self::reconcile_self_activation(); $done = ( 'done' === $st['phase'] ); if ( ! $done ) { self::save( $st ); } if ( 'snapshot' === $st['phase'] ) { $cnt = max( 1, (int) ( $st['snap_count'] ?? 1 ) ); $progress = (int) round( ( (int) ( $st['snap_ti'] ?? 0 ) / $cnt ) * 8 ); // 0–8% band. } else { $progress = $done ? 100 : ( 'finalize' === $st['phase'] ? 96 : ( 'searchreplace' === $st['phase'] ? 92 : self::progress( $st ) ) ); } self::step_unlock( $lock ); return array( 'done' => $done, 'progress' => $progress, 'phase' => $st['phase'], 'message' => self::label( $st ), 'log' => sprintf( /* translators: 1: db entries, 2: files restored. */ __( 'Restored %1$d DB chunks, %2$d files', 'ai-siteark' ), (int) $st['db'], (int) $st['files'] ), ); } /** * Keep this install's backups directory stable across a restore. * * Local_Store::dir() is derived from the `aisiteark_backup_secret` option. The instant a * restore imports the archive's options table, that option becomes the SOURCE site's * secret — so the backups folder silently renames itself. The running restore then * loses both its own state file and the archive it is streaming: in_progress() flips * to false, the next AJAX step is treated as a brand-new restore, and (because * continuation steps send no `file` parameter) it dies with "Backup file not found." * * The backups folder is a property of THIS server, not of the restored content, so we * write our secret back after every step. * * The option is compared straight against the database, because the import rewrote * wp_options with direct SQL and the object cache still holds the pre-restore value. * Only this one option's cache entry is corrected — flushing `alloptions` wholesale * would make every later get_option() in this request read the half-restored database. * * @param array $st Restore state. * @return void */ private static function pin_store_secret( array $st ) { $want = isset( $st['store_secret'] ) ? (string) $st['store_secret'] : ''; if ( '' === $want ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $current = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'aisiteark_backup_secret' ) ); if ( $current === $want ) { return; // The database already agrees with us. } // Let the options API see the database's truth for THIS key only, so update_option // actually writes instead of short-circuiting on a stale cached value. wp_cache_delete( 'aisiteark_backup_secret', 'options' ); $alloptions = wp_cache_get( 'alloptions', 'options' ); if ( is_array( $alloptions ) && isset( $alloptions['aisiteark_backup_secret'] ) ) { $alloptions['aisiteark_backup_secret'] = $current; wp_cache_set( 'alloptions', $alloptions, 'options' ); } update_option( 'aisiteark_backup_secret', $want, false ); } /** * Keep this site's URL stable across a restore — the fix for a migration signing the * operator out mid-run with a 400. * * WordPress derives COOKIEHASH — and therefore the auth cookie's NAME * (`wordpress_logged_in_`) — from `md5( siteurl )`, computed fresh at the * start of every request. When a cross-site migration imports the archive's options * table, `siteurl`/`home` briefly become the SOURCE site's URL, so the very next * request computes a different COOKIEHASH and no longer finds the operator's cookie: * the request arrives logged-out, the privileged admin-ajax action never fires, and the * restore dies with a 400 ("0") at the search-replace step. keep_signed_in() can't help * because the request is already logged-out before any plugin code runs. * * So after every step we force `siteurl`/`home` back to this install's real URL. The * migration search-replace still rewrites the old URL everywhere else; only these two * canonical options are pinned, which is enough to hold COOKIEHASH — and the login — * steady for the whole restore. On a same-site restore new_url already matches, so this * is a no-op. * * @param array $st Restore state. * @return void */ private static function pin_site_url( array $st ) { $want = isset( $st['new_url'] ) ? untrailingslashit( (string) $st['new_url'] ) : ''; if ( '' === $want ) { return; } global $wpdb; foreach ( array( 'siteurl', 'home' ) as $key ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $current = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $key ) ); if ( null === $current || untrailingslashit( (string) $current ) === $want ) { continue; // Missing (leave it) or already correct. } // Show the options API the database's truth for THIS key only, so update_option // writes instead of short-circuiting on a stale cached value left by the import. wp_cache_delete( $key, 'options' ); $alloptions = wp_cache_get( 'alloptions', 'options' ); if ( is_array( $alloptions ) && isset( $alloptions[ $key ] ) ) { $alloptions[ $key ] = $current; wp_cache_set( 'alloptions', $alloptions, 'options' ); } update_option( $key, $want ); } } /** * wp-content-relative path prefixes the restore must NOT extract — every copy of * AI-SiteArk itself. * * The running plugin can't be overwritten while it's mid-restore, and a backup taken * on another site must never drop a SECOND copy of the plugin beside the installed one * (which is exactly how a duplicate "ai-siteark-1" plugin appeared after a restore). * So we skip: the folder this plugin is actually installed in (whatever its name), the * canonical `ai-siteark/`, and any suffixed variant such as `ai-siteark-1/`. * * @return string[] */ private static function self_skip_prefixes() { $prefixes = array( 'plugins/ai-siteark/', // canonical folder 'plugins/ai-siteark-', // any variant: ai-siteark-1, ai-siteark-pro, … 'plugins/ai-sitevault/', // the sibling build (same code under the legacy slug) — never 'plugins/ai-sitevault-', // extract it from a backup, or the site gets two copies = fatal. ); // The real installed folder, in case someone renamed it to something unrelated. if ( defined( 'AISITEARK_BASENAME' ) ) { $dir = dirname( (string) AISITEARK_BASENAME ); if ( '' !== $dir && '.' !== $dir ) { $prefixes[] = 'plugins/' . trailingslashit( $dir ); } } return array_values( array_unique( $prefixes ) ); } /** * "Replace all files": empty the wp-content folders that are about to be restored, so * the result is an exact copy of the backup instead of a merge with whatever was here. * * This is the one genuinely destructive part of a restore, so it is fenced hard: * • only the standard file components map to a folder here — uploads, themes, plugins, * mu-plugins. Nothing outside wp-content is ever touched (not core, not wp-config). * • inside plugins/, every AI-SiteArk copy is preserved (self_skip_prefixes) so the * plugin running the restore can't delete itself. * • inside uploads/, the `ai-siteark-*` backups directory is preserved — wiping it * would delete the very archive being restored, and every other backup with it. * * @param string $content wp-content absolute path. * @param array $comps Components being restored (empty = all). * @return void */ private static function wipe_for_replace( $content, array $comps ) { $map = array( 'uploads' => 'uploads', 'themes' => 'themes', 'plugins' => 'plugins', 'muplugins' => 'mu-plugins', ); foreach ( $map as $component => $folder ) { if ( ! self::wants_in( $comps, $component ) ) { continue; } $dir = $content . '/' . $folder; if ( ! is_dir( $dir ) ) { continue; } $keep = array( '.', '..' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readdir foreach ( (array) @scandir( $dir ) as $entry ) { if ( in_array( $entry, $keep, true ) ) { continue; } $rel = $folder . '/' . $entry; // Never delete AI-SiteArk itself, or the folder that holds the backups. if ( self::is_protected_from_wipe( $rel, $entry, $component ) ) { continue; } self::rmrf( $dir . '/' . $entry ); } } } /** * Whether a top-level entry inside a restored folder must survive a "replace all" wipe. * * @param string $rel wp-content-relative path (e.g. plugins/ai-siteark). * @param string $entry The bare entry name. * @param string $component The component being wiped. * @return bool */ private static function is_protected_from_wipe( $rel, $entry, $component ) { // The boot-time restore guard must survive a "replace all" wipe of mu-plugins, or the // site loses its protection mid-restore. if ( 'muplugins' === $component && false !== strpos( (string) $rel, self::GUARD_FILE ) ) { return true; } // Any AI-SiteArk plugin copy (running plugin can't delete itself). foreach ( self::self_skip_prefixes() as $prefix ) { if ( 0 === strpos( $rel . '/', $prefix ) || 0 === strpos( $rel, $prefix ) ) { return true; } } // The backups directory lives at uploads/ai-siteark-/ — deleting it would // destroy the archive being restored and every other stored backup. Protect any // ai-siteark* entry under uploads to be safe. if ( 'uploads' === $component && 0 === strpos( $entry, 'ai-siteark' ) ) { return true; } return false; } /** * Recursively remove a path (file or directory). * * @param string $path Absolute path. * @return void */ private static function rmrf( $path ) { if ( is_link( $path ) || is_file( $path ) ) { wp_delete_file( $path ); return; } if ( ! is_dir( $path ) ) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readdir foreach ( (array) @scandir( $path ) as $entry ) { if ( '.' === $entry || '..' === $entry ) { continue; } self::rmrf( $path . '/' . $entry ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir @rmdir( $path ); } /** * Keep AI-SiteArk active under the folder it is actually installed in — the fix for a * migration leaving it "deactivated: Plugin file does not exist", AND for a restore that * stalls at "Rewriting site URLs…". * * The restored `active_plugins` comes from the source site. If the source had this plugin * in a differently-named folder (a duplicate such as `ai-siteark-1/`, which WordPress * creates on a double upload), that entry points at a folder we never extract. Worse, on a * migration the source's list may not name our real folder at all — and because a restore * is driven by requests that must LOAD this plugin, the moment we fall out of * active_plugins the next request (and the background watchdog) can't load us, so the * restore stalls short of finalize and nothing downstream runs. So this is called after * EVERY step, not just at finalize: it collapses every `ai-siteark` / `ai-siteark-*` * entry to the one real installed basename and, if none is present, adds ours — the * operator is plainly using AI-SiteArk, so keeping it active is correct. * * Reads active_plugins straight from the database: the import rewrites wp_options with raw * SQL, so the object cache is stale and get_option() would return the pre-import list. * * @return void */ private static function reconcile_self_activation() { if ( ! defined( 'AISITEARK_BASENAME' ) ) { return; } $self = (string) AISITEARK_BASENAME; global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'active_plugins' ) ); if ( null === $raw ) { return; // No such option yet (e.g. before the options import) — nothing to do. } // active_plugins comes from the just-imported (untrusted) source database and is only // ever an array of plugin-path strings — unserialize with allowed_classes => false so a // crafted serialized object in that option can't instantiate a class / fire a gadget. $active = is_serialized( $raw ) ? @unserialize( $raw, array( 'allowed_classes' => false ) ) : $raw; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize if ( ! is_array( $active ) ) { $active = array(); } $out = array(); $placed = false; foreach ( $active as $entry ) { $entry = (string) $entry; $dir = strtok( $entry, '/' ); if ( 'ai-siteark' === $dir || 0 === strpos( (string) $dir, 'ai-siteark-' ) ) { if ( ! $placed ) { $out[] = $self; // Keep AI-SiteArk active under its real folder… $placed = true; } continue; // …and drop any other/duplicate AI-SiteArk entry. } $out[] = $entry; } if ( ! $placed ) { $out[] = $self; // Not present at all — add it so the restore can keep running. } $out = array_values( $out ); if ( $out === array_values( $active ) ) { return; // The database already agrees with us. } // Let the options API see the database's truth for THIS key, so update_option writes // instead of short-circuiting on the stale (pre-import) cached list. wp_cache_delete( 'active_plugins', 'options' ); $alloptions = wp_cache_get( 'alloptions', 'options' ); if ( is_array( $alloptions ) && isset( $alloptions['active_plugins'] ) ) { $alloptions['active_plugins'] = $raw; wp_cache_set( 'alloptions', $alloptions, 'options' ); } update_option( 'active_plugins', $out ); } /* ---- phases --------------------------------------------------------- */ /** * Process: import DB entries + extract files until the time budget. * * @param array $st Restore state (by reference). * @param float $t0 Step start time. * @return void */ private static function do_process( array &$st, $t0 ) { $sources = isset( $st['sources'] ) && is_array( $st['sources'] ) ? $st['sources'] : array(); $i = (int) ( $st['src_i'] ?? 0 ); if ( $i >= count( $sources ) ) { $st['phase'] = 'finalize'; return; } $src = $sources[ $i ]; $pass = isset( $src['pass'] ) ? $src['pass'] : 'files'; $reader = new Archive_Reader(); $ok = $reader->open( $src['archive'] ); if ( is_wp_error( $ok ) ) { throw new \RuntimeException( esc_html( $ok->get_error_message() ) ); } if ( ! empty( $src['enc_key'] ) ) { $reader->set_key( base64_decode( $src['enc_key'] ) ); } $reader->seek_to( (int) $src['pos'] ); $content = rtrim( WP_CONTENT_DIR, '/\\' ); $skip = self::self_skip_prefixes(); $comps = isset( $src['comps'] ) && is_array( $src['comps'] ) ? $src['comps'] : array(); // Files-first / DB-last: each source is processed in TWO passes — all FILES first, then // the DATABASE. This keeps the site's EXISTING (working) database live throughout the long // file phase, so if the restore is interrupted there the site is untouched and still runs; // and by the time the database is imported, every plugin/theme it references is already on // disk, so the imported DB can't fatal on a missing file. (The archive stores DB entries // before files, so the files pass simply skips the DB headers, and the DB pass rewinds to // the source start and stops at the first file.) // "Replace all files": empty the restored folders once, at the start of the FILES pass, // before any file is written — never during the DB pass. Only touches the restored // components; never WordPress core, the backups folder, or AI-SiteArk itself. The flag is // persisted immediately so a step that dies after wiping never wipes again on resume. if ( 'files' === $pass && ! empty( $st['replace_all'] ) && empty( $st['wiped'] ) ) { self::wipe_for_replace( $content, $comps ); $st['wiped'] = true; self::save( $st ); } while ( ( microtime( true ) - $t0 ) < self::TIME_BUDGET ) { $header = $reader->next(); if ( null === $header ) { if ( 'files' === $pass ) { // Files pass finished — rewind and import the database last. $st['sources'][ $i ]['pass'] = 'db'; $st['sources'][ $i ]['pos'] = (int) $src['start']; } else { // DB pass finished — this source is done. self::source_done( $st, $sources, $i, $src, $content ); } break; } $type = isset( $header['type'] ) ? $header['type'] : ''; if ( 'files' === $pass ) { if ( 'file' === $type ) { $path = isset( $header['path'] ) ? $header['path'] : ''; if ( self::wants_in( $comps, self::file_component( $path ) ) ) { $ok = File_Extractor::extract( $reader, $path, $content, $skip ); if ( false === $ok ) { // The file genuinely could not be written (permissions / mkdir / a path // colliding with a directory). Don't count it as restored — track it so // the restore reports "with warnings" instead of a false clean success. $st['files_failed'] = (int) ( $st['files_failed'] ?? 0 ) + 1; if ( count( (array) ( $st['file_fail_sample'] ?? array() ) ) < 20 ) { $st['file_fail_sample'][] = $path; } } elseif ( true === $ok ) { $st['files']++; } // null === $ok: deliberately skipped (self-plugin) — neither restored nor failed. } } // DB entries are skipped in the files pass (imported in the DB pass below). } else { // 'db' pass. if ( 'file' === $type ) { // DB entries are contiguous at the start of the archive, so the first file // entry means every DB entry has been imported — this source is done. self::source_done( $st, $sources, $i, $src, $content ); break; } if ( 'db' === $type && self::wants_in( $comps, 'database' ) ) { $sql = gzdecode( $reader->read_payload() ); $kind = isset( $header['kind'] ) ? $header['kind'] : 'rows'; if ( false !== $sql ) { $ident = ( 'view' === $kind || 'event' === $kind ) ? ( isset( $header['name'] ) ? $header['name'] : '' ) : ( isset( $header['table'] ) ? $header['table'] : '' ); DB_Importer::execute_entry( $kind, $ident, $sql, $st['old_prefix'], $st['new_prefix'] ); } $st['db']++; } } $st['sources'][ $i ]['pos'] = $reader->next_entry_pos(); } $reader->close(); } /** * A source archive has been fully processed (files pass + DB pass): advance to the next * source, and when the last one is done apply the increment's deletions and move on to * search-replace / finalize. * * @param array $st Restore state (by reference). * @param array $sources The full source plan. * @param int $i Index of the source just finished. * @param array $src The source just finished. * @param string $content wp-content dir. * @return void */ private static function source_done( array &$st, array $sources, $i, array $src, $content ) { $st['src_i'] = $i + 1; if ( $st['src_i'] >= count( $sources ) ) { // Guard: a restore that read the whole archive yet imported nothing (no DB entries // and no files) means the backup is empty, truncated or unreadable — surface it // instead of silently reporting success. if ( 0 === (int) $st['db'] && 0 === (int) $st['files'] ) { throw new \RuntimeException( esc_html__( 'Nothing was restored — this backup contains no database or files to restore. It may be empty, truncated or from a failed backup. Try a different backup.', 'ai-siteark' ) ); } self::apply_deletions( $src['deletions'], $content, $st['del_comps'] ?? array() ); $last_comps = $sources[ count( $sources ) - 1 ]['comps']; $db_restored = self::wants_in( is_array( $last_comps ) ? $last_comps : array(), 'database' ); $needs_sr = ! empty( $st['migration'] ) || ! empty( $st['sr_pairs'] ); $st['phase'] = ( $needs_sr && $db_restored ) ? 'searchreplace' : 'finalize'; } } /** * Open an archive far enough to read its manifest: returns the first-entry * offset, logical size and (validated) encryption key for the password. * * @param string $path Archive path. * @param string $password Password (encrypted archives). * @return array|\WP_Error */ private static function archive_meta( $path, $password ) { $reader = new Archive_Reader(); $ok = $reader->open( $path ); if ( is_wp_error( $ok ) ) { return $ok; } $h = $reader->next(); if ( ! $h || ( isset( $h['type'] ) ? $h['type'] : '' ) !== 'manifest' ) { $reader->close(); return new \WP_Error( 'aisiteark_restore_manifest', __( 'A backup in this set has no manifest.', 'ai-siteark' ) ); } $m = (array) json_decode( $reader->read_payload(), true ); $pos = $reader->next_entry_pos(); $size = $reader->total_size(); $reader->close(); $enc_key = ''; if ( Manifest::is_encrypted( $m ) ) { $key = Manifest::verify_password( $m, $password ); if ( false === $key ) { return new \WP_Error( 'aisiteark_restore_password', __( 'This backup is encrypted — the password is missing or wrong.', 'ai-siteark' ) ); } $enc_key = base64_encode( $key ); } return array( 'pos' => $pos, 'size' => $size, 'enc_key' => $enc_key, 'manifest' => $m ); } /** * Whether a component is wanted given an explicit component list (empty = all; * "other" — structural root files — is always restored). * * @param array $comps Component list. * @param string $component Component key. * @return bool */ private static function wants_in( array $comps, $component ) { if ( empty( $comps ) || 'other' === $component ) { return true; } return in_array( $component, $comps, true ); } /** * Remove files deleted since the base (G1 differential), traversal-guarded and * limited to the components actually being restored. * * @param array $deletions Content-relative paths. * @param string $content wp-content dir. * @param array $comps Component filter (empty = all). * @return void */ private static function apply_deletions( $deletions, $content, $comps ) { foreach ( (array) $deletions as $rel ) { $rel = ltrim( str_replace( '\\', '/', (string) $rel ), '/' ); if ( '' === $rel || false !== strpos( $rel, '..' ) ) { continue; } if ( ! self::wants_in( is_array( $comps ) ? $comps : array(), self::file_component( $rel ) ) ) { continue; } $abs = $content . '/' . $rel; if ( is_file( $abs ) ) { wp_delete_file( $abs ); } } } /** * Selective restore: is this component wanted? An empty component list means a * full restore. Root wp-content files ("other") are always restored as they are * structural and tiny. * * @param array $st Restore state. * @param string $component database|uploads|themes|plugins|muplugins|other. * @return bool */ private static function wants( array $st, $component ) { $c = isset( $st['components'] ) && is_array( $st['components'] ) ? $st['components'] : array(); if ( empty( $c ) || 'other' === $component ) { return true; } return in_array( $component, $c, true ); } /** * Map a wp-content-relative archive path to a restore component. * * @param string $path Path relative to wp-content. * @return string */ private static function file_component( $path ) { $p = ltrim( str_replace( '\\', '/', (string) $path ), '/' ); if ( 0 === strpos( $p, 'uploads/' ) ) { return 'uploads'; } if ( 0 === strpos( $p, 'themes/' ) ) { return 'themes'; } if ( 0 === strpos( $p, 'mu-plugins/' ) ) { return 'muplugins'; } if ( 0 === strpos( $p, 'plugins/' ) ) { return 'plugins'; } return 'other'; } /** * Migration: rewrite old→new URL/path across all tables (resumable). * * @param array $st Restore state (by reference). * @param float $t0 Step start time. * @return void */ private static function do_search_replace( array &$st, $t0 ) { $pairs = array(); if ( ! empty( $st['old_url'] ) && $st['old_url'] !== $st['new_url'] ) { $pairs[ $st['old_url'] ] = $st['new_url']; } if ( ! empty( $st['old_path'] ) && $st['old_path'] !== $st['new_path'] ) { $pairs[ $st['old_path'] ] = $st['new_path']; } // User-defined find→replace pairs (applied alongside any migration rewrite). if ( ! empty( $st['sr_pairs'] ) && is_array( $st['sr_pairs'] ) ) { $pairs += $st['sr_pairs']; } if ( ! $pairs ) { $st['phase'] = 'finalize'; return; } $ti = (int) $st['sr_ti']; $off = (int) $st['sr_off']; $done = Search_Replace::step( $pairs, $ti, $off, $t0, self::TIME_BUDGET ); $st['sr_ti'] = $ti; $st['sr_off'] = $off; if ( $done ) { $st['phase'] = 'finalize'; } } /** * Finalize: flush caches/rewrites, clear state. * * @param array $st Restore state (by reference). * @return void */ private static function do_finalize( array &$st ) { // A backup archives the options table while holding its backup lock, so the // just-imported database can carry a stale lock row. Clear it — nothing is // running now — or backups on the restored site would be blocked until TTL. Lock::release( Backup_Runner::LOCK ); if ( function_exists( 'wp_cache_flush' ) ) { wp_cache_flush(); } // Keep AI-SiteArk active under its REAL folder after a migration. The imported // active_plugins comes from the source site and may list this plugin under a // different folder name (e.g. "ai-siteark-1/ai-siteark.php" when the source had // a duplicate). We deliberately never extract that duplicate folder, so the entry // dangles and WordPress deactivates it with "Plugin file does not exist" — the plugin // that just ran the restore appears broken. Collapse every AI-SiteArk entry to the // folder we're actually installed in. Runs after the cache flush so it reads the // freshly-imported option. self::reconcile_self_activation(); // Force "Post name" permalinks after every restore. Migrated sites — especially // ones that ran a security / permalink-obfuscation plugin — carry a custom, hashed // structure ("/{3db608…}/") that breaks pretty URLs and can even hide a real tag // inside the hash, so simply "fix it when it looks broken" isn't enough. The site // owner wants the clean, universally understood "/%postname%/" every time, so we set // it unconditionally here, just before rebuilding the rewrite rules below. if ( '/%postname%/' !== (string) get_option( 'permalink_structure', '' ) ) { update_option( 'permalink_structure', '/%postname%/' ); } // Permalinks. $wp_rewrite was built at bootstrap from THIS site's permalink // structure, but the database now holds the restored site's. A plain // flush_rewrite_rules() would therefore persist rules generated from the stale // structure, and every inner page would 404 until permalinks were re-saved by // hand (the step other migration plugins make you do). Re-read the restored // structure first, then hard-flush so `rewrite_rules` — and the WordPress block // in .htaccess / web.config — match what was actually restored. global $wp_rewrite; if ( isset( $wp_rewrite ) && $wp_rewrite instanceof \WP_Rewrite ) { delete_option( 'rewrite_rules' ); $wp_rewrite->init(); // Re-reads permalink_structure from the restored DB. $wp_rewrite->flush_rules( true ); } else { flush_rewrite_rules( true ); } /** * After the DB + files are restored. Migration search-replace hooks here. * * @param array $st Restore state. */ do_action( 'aisiteark_restore_finalize', $st ); if ( class_exists( '\\AtoZ\\SiteArk\\Modules\\Backup\\Controller' ) ) { $ff = (int) ( $st['files_failed'] ?? 0 ); if ( $ff > 0 ) { \AtoZ\SiteArk\Modules\Backup\Controller::log_activity( sprintf( /* translators: %d: number of files that could not be written. */ _n( 'Restore completed WITH WARNINGS — %d file could not be written', 'Restore completed WITH WARNINGS — %d files could not be written', $ff, 'ai-siteark' ), $ff ) . ' (' . implode( '; ', array_slice( (array) ( $st['file_fail_sample'] ?? array() ), 0, 10 ) ) . ')' ); } else { \AtoZ\SiteArk\Modules\Backup\Controller::log_activity( __( 'Restore completed', 'ai-siteark' ) ); } } // J3: keep the recovery point taken before this restore, then prune old ones. $keep = (int) ( Settings::get()['recovery_keep'] ?? 3 ); Local_Store::prune_recovery( max( 1, $keep ) ); self::clear(); self::record_result( 'done' ); $st['phase'] = 'done'; } /* ---- helpers -------------------------------------------------------- */ /** * Byte-based progress through the archive (0–90 during processing). * * @param array $st Restore state. * @return int */ private static function progress( array $st ) { $size = (int) $st['size']; if ( $size <= 0 ) { return 5; } // Bytes consumed across all restore sources (base + increment). $sources = isset( $st['sources'] ) && is_array( $st['sources'] ) ? $st['sources'] : array(); $i = (int) ( $st['src_i'] ?? 0 ); $consumed = 0; for ( $k = 0; $k < $i && $k < count( $sources ); $k++ ) { $consumed += (int) $sources[ $k ]['size']; } if ( isset( $sources[ $i ]['pos'] ) ) { $consumed += (int) $sources[ $i ]['pos']; } return (int) min( 90, round( ( $consumed / $size ) * 90 ) ); } /** * Phase label. * * @param array $st Restore state. * @return string */ private static function label( array $st ) { if ( 'snapshot' === $st['phase'] ) { return __( 'Saving a recovery point…', 'ai-siteark' ); } if ( 'searchreplace' === $st['phase'] ) { return __( 'Rewriting site URLs…', 'ai-siteark' ); } if ( 'finalize' === $st['phase'] || 'done' === $st['phase'] ) { return __( 'Finishing…', 'ai-siteark' ); } return __( 'Restoring database & files…', 'ai-siteark' ); } }