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' );
}
}