volumes = self::volumes( $base ); $this->open_manifest(); } /** * Every volume for a base path, in order. * * @param string $base Base path. * @return string[] */ public static function volumes( $base ) { $vols = array( $base ); for ( $i = 2; ; $i++ ) { $p = $base . '.' . $i; if ( ! is_file( $p ) ) { break; } $vols[] = $p; } return $vols; } /** * Read + validate the manifest (always the first, unencrypted, entry). * * @return void * @throws RuntimeException On failure. */ private function open_manifest() { $this->rewind(); $h = $this->next_header(); if ( ! $h || ( $h['header']['type'] ?? '' ) !== 'manifest' ) { throw new RuntimeException( 'This file is not an AI-SiteArk backup (no manifest).' ); } $this->manifest = json_decode( $this->read( $h['len'] ), true ); if ( ! is_array( $this->manifest ) ) { throw new RuntimeException( 'The backup manifest could not be read.' ); } } /** @return bool */ public function is_encrypted() { return ! empty( $this->manifest['encrypted'] ); } /** * Verify a password and arm decryption. Returns true on success. * * @param string $password Password. * @return bool */ public function unlock( $password ) { if ( ! $this->is_encrypted() ) { return true; } if ( ! function_exists( 'openssl_decrypt' ) || '' === (string) $password ) { return false; } $key = hash_pbkdf2( 'sha256', (string) $password, base64_decode( $this->manifest['salt'] ?? '' ), AISV_ITER, 32, true ); $ok = openssl_decrypt( base64_decode( $this->manifest['verify'] ?? '' ), 'aes-256-ctr', $key, OPENSSL_RAW_DATA, base64_decode( $this->manifest['verify_iv'] ?? '' ) ); if ( 'AISV-OK' !== $ok ) { return false; } $this->key = $key; return true; } /** * Iterate every non-manifest entry, calling $cb( $header, $reader ) for each. * $reader is $this; use read_payload()/copy_payload_to() inside the callback. * * @param callable $cb Callback. * @return void */ public function each( callable $cb ) { $this->rewind(); while ( null !== ( $h = $this->next_header() ) ) { $start = $this->payload_start; $len = $this->cur_len; if ( ( $h['header']['type'] ?? '' ) !== 'manifest' ) { $cb( $h['header'], $this ); } // Always resynchronise to the exact next entry, no matter how much (or how // little) the callback consumed. Without this, a callback that ignores an // entry's payload leaves the pointer mid-payload and the next header read // interprets random bytes as a gigantic length. fseek( $this->fh, $start + $len, SEEK_SET ); } } /* ---- payload helpers (valid only inside each()'s callback) ---------- */ private $cur_len = 0; private $payload_start = 0; /** * Read + decrypt + gunzip a small payload (manifest/db entries). * * @param array $header Entry header. * @return string */ public function payload( array $header ) { $data = $this->read( $this->cur_len ); if ( null !== $this->key && isset( $header['iv'] ) ) { $data = openssl_decrypt( $data, 'aes-256-ctr', $this->key, OPENSSL_RAW_DATA, base64_decode( $header['iv'] ) ); } if ( 'gzip' === ( $header['enc'] ?? '' ) ) { $plain = @gzdecode( $data ); if ( false === $plain ) { throw new RuntimeException( 'A database entry could not be decompressed (wrong password, or a damaged backup).' ); } $data = $plain; } return $data; } /** * Stream a file payload to disk (decrypting as it goes). * * @param array $header Entry header. * @param string $dest Local path. * @return void */ public function copy_to( array $header, $dest ) { $out = fopen( $dest, 'wb' ); if ( ! $out ) { throw new RuntimeException( 'Cannot write ' . $dest ); } $iv = ( null !== $this->key && isset( $header['iv'] ) ) ? base64_decode( $header['iv'] ) : null; $blocks = 0; $remaining = $this->cur_len; while ( $remaining > 0 ) { $buf = $this->read( (int) min( AISV_CHUNK, $remaining ) ); if ( '' === $buf ) { break; } $read = strlen( $buf ); $remaining -= $read; if ( null !== $iv ) { $buf = openssl_decrypt( $buf, 'aes-256-ctr', $this->key, OPENSSL_RAW_DATA, self::iv_add( $iv, $blocks ) ); $blocks += intdiv( $read, 16 ) + ( ( $read % 16 ) ? 1 : 0 ); } fwrite( $out, $buf ); } fclose( $out ); } /* ---- low-level volume spanning ------------------------------------- */ private function rewind() { $this->close(); $this->vi = 0; $this->open_volume( 0 ); } private function open_volume( $i ) { $this->close(); $this->fh = fopen( $this->volumes[ $i ], 'rb' ); if ( ! $this->fh || fread( $this->fh, strlen( AISV_MAGIC ) ) !== AISV_MAGIC ) { throw new RuntimeException( 'Bad or missing volume: ' . basename( $this->volumes[ $i ] ) ); } $this->vi = $i; } private function close() { if ( $this->fh ) { fclose( $this->fh ); $this->fh = null; } } /** * Next entry header, rolling to the next volume at EOF. Sets $cur_len. * * @return array{header:array,len:int}|null */ private function next_header() { $lb = $this->raw( 4 ); if ( strlen( $lb ) < 4 ) { // Try the next volume. if ( $this->vi + 1 < count( $this->volumes ) ) { $this->open_volume( $this->vi + 1 ); $lb = $this->raw( 4 ); if ( strlen( $lb ) < 4 ) { return null; } } else { return null; } } $hlen = unpack( 'N', $lb )[1]; $hjson = $this->raw( $hlen ); $plb = $this->raw( 8 ); if ( strlen( $plb ) < 8 ) { return null; } $plen = unpack( 'J', $plb )[1]; $this->cur_len = (int) $plen; $this->payload_start = ftell( $this->fh ); $header = json_decode( $hjson, true ); return array( 'header' => is_array( $header ) ? $header : array(), 'len' => (int) $plen ); } /** * Read exactly $n payload bytes (never spans a volume — entries don't). * * @param int $n Bytes. * @return string */ private function read( $n ) { return $this->raw( $n ); } private function raw( $n ) { $out = ''; while ( $n > 0 && $this->fh ) { $buf = fread( $this->fh, $n ); if ( false === $buf || '' === $buf ) { break; } $out .= $buf; $n -= strlen( $buf ); } return $out; } private static function iv_add( $iv, $blocks ) { $b = array_values( unpack( 'C16', $iv ) ); $c = (int) $blocks; for ( $i = 15; $i >= 0 && $c > 0; $i-- ) { $sum = $b[ $i ] + ( $c & 0xFF ); $b[ $i ] = $sum & 0xFF; $c = ( $c >> 8 ) + ( $sum >> 8 ); } return pack( 'C16', ...$b ); } } /* ========================================================================= * Serialization-safe search-replace (ports Restore\Search_Replace) * ====================================================================== */ /** * Replace inside a single value, re-serializing serialized data so string lengths * stay valid. * * @param string $val Value. * @param array $pairs old => new. * @return string */ function aisv_replace_value( $val, array $pairs ) { if ( preg_match( '/^[aOs]:\d+:/', $val ) || 'b:0;' === $val || 'N;' === $val ) { $un = @unserialize( $val ); if ( false !== $un || 'b:0;' === $val ) { return serialize( aisv_recurse( $un, $pairs ) ); } } return strtr( $val, $pairs ); } /** * Recurse through decoded serialized data. * * @param mixed $data Data. * @param array $pairs Map. * @return mixed */ function aisv_recurse( $data, array $pairs ) { if ( is_array( $data ) ) { $out = array(); foreach ( $data as $k => $v ) { $out[ is_string( $k ) ? strtr( $k, $pairs ) : $k ] = aisv_recurse( $v, $pairs ); } return $out; } if ( is_object( $data ) ) { foreach ( $data as $k => $v ) { $data->$k = aisv_recurse( $v, $pairs ); } return $data; } if ( is_string( $data ) ) { return strtr( $data, $pairs ); } return $data; } /* ========================================================================= * Helpers * ====================================================================== */ /** @return string The first-volume .aisv beside this script, or ''. */ function aisv_find_archive() { foreach ( glob( __DIR__ . '/*.aisv' ) as $f ) { return $f; // First-volume names never end in .aisv.N. } return ''; } /** @return string A random 64-char salt line value. */ function aisv_salt() { $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !@#$%^&*()-_ []{}<>~`+=,.;:/?|'; $s = ''; for ( $i = 0; $i < 64; $i++ ) { $s .= $chars[ random_int( 0, strlen( $chars ) - 1 ) ]; } return $s; } /** * Compose a wp-config.php from a template's constants. * * @param array $c { DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, prefix }. * @return string */ function aisv_wp_config( array $c ) { $keys = array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' ); $salt = ''; foreach ( $keys as $k ) { $salt .= "define( '{$k}', '" . str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), aisv_salt() ) . "' );\n"; } $q = static function ( $v ) { return str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), (string) $v ); }; return " $fh, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 300 ) ); $ok = curl_exec( $ch ); curl_close( $ch ); fclose( $fh ); } if ( ! $ok || ! is_file( $zip ) ) { return 'Could not download WordPress core from wordpress.org. Upload the WordPress files manually, then re-run.'; } if ( ! class_exists( 'ZipArchive' ) ) { @unlink( $zip ); return 'PHP has no Zip support to unpack WordPress core. Upload the WordPress files manually.'; } $za = new ZipArchive(); if ( true !== $za->open( $zip ) ) { @unlink( $zip ); return 'The downloaded WordPress core zip could not be opened.'; } // The zip contains a top-level wordpress/ folder — extract, then flatten. $tmp = $dir . '/.aisv-core-tmp'; @mkdir( $tmp, 0755, true ); $za->extractTo( $tmp ); $za->close(); @unlink( $zip ); $src = $tmp . '/wordpress'; if ( is_dir( $src ) ) { aisv_move_tree( $src, $dir ); } aisv_rmtree( $tmp ); return is_file( $dir . '/wp-includes/version.php' ) ? '' : 'WordPress core did not unpack correctly.'; } /** Move a directory tree into $dst without overwriting existing files. */ function aisv_move_tree( $src, $dst ) { foreach ( scandir( $src ) as $e ) { if ( '.' === $e || '..' === $e ) { continue; } $from = $src . '/' . $e; $to = $dst . '/' . $e; if ( is_dir( $from ) ) { @mkdir( $to, 0755, true ); aisv_move_tree( $from, $to ); } elseif ( ! is_file( $to ) ) { @rename( $from, $to ); } } } /** Recursively delete a directory. */ function aisv_rmtree( $dir ) { if ( ! is_dir( $dir ) ) { return; } foreach ( scandir( $dir ) as $e ) { if ( '.' === $e || '..' === $e ) { continue; } $p = $dir . '/' . $e; is_dir( $p ) ? aisv_rmtree( $p ) : @unlink( $p ); } @rmdir( $dir ); } /** Reject path traversal from archive file entries. */ function aisv_safe_rel( $rel ) { $rel = ltrim( str_replace( '\\', '/', (string) $rel ), '/' ); if ( '' === $rel || false !== strpos( $rel, '../' ) || '..' === $rel || preg_match( '#^[a-zA-Z]:/#', $rel ) ) { return false; } return $rel; } /* ========================================================================= * The install run (POST) * ====================================================================== */ /** * Do the whole install and return a log of steps. Throws on a fatal problem. * * @param string $archive_path Archive path. * @param array $in POST fields. * @return string[] Log lines. * @throws RuntimeException On failure. */ function aisv_run_install( $archive_path, array $in ) { $log = array(); $dir = __DIR__; $arc = new AISV_Archive( $archive_path ); if ( $arc->is_encrypted() && ! $arc->unlock( $in['password'] ?? '' ) ) { throw new RuntimeException( 'The backup is encrypted and the password is missing or wrong.' ); } $man = $arc->manifest; $old_url = isset( $man['site_url'] ) ? rtrim( (string) $man['site_url'], '/' ) : ''; $old_path = isset( $man['abspath'] ) ? (string) $man['abspath'] : ''; $old_prefix = isset( $man['table_prefix'] ) ? (string) $man['table_prefix'] : 'wp_'; $new_url = rtrim( (string) $in['new_url'], '/' ); $new_path = rtrim( str_replace( '\\', '/', $dir ), '/' ) . '/'; $prefix = preg_replace( '/[^A-Za-z0-9_$]/', '', (string) ( $in['prefix'] ?: $old_prefix ) ); /* ---- 1. Connect to the (empty) database ---- */ $host = (string) $in['db_host']; $port = 0; $sock = null; if ( false !== strpos( $host, ':' ) ) { list( $host, $tail ) = explode( ':', $host, 2 ); if ( ctype_digit( $tail ) ) { $port = (int) $tail; } else { $sock = $tail; } } mysqli_report( MYSQLI_REPORT_OFF ); $db = @mysqli_connect( $host, $in['db_user'], $in['db_pass'], $in['db_name'], $port ?: 3306, $sock ); if ( ! $db ) { throw new RuntimeException( 'Could not connect to the database: ' . mysqli_connect_error() ); } $db->set_charset( 'utf8mb4' ); $log[] = 'Connected to database "' . $in['db_name'] . '".'; /* ---- 2. Import the database ---- */ $tables = 0; $rows = 0; $arc->each( function ( $header, $reader ) use ( $db, $old_prefix, $prefix, &$tables, &$rows ) { if ( ( $header['type'] ?? '' ) !== 'db' ) { return; } $sql = $reader->payload( $header ); $kind = $header['kind'] ?? 'rows'; // Rewrite the table prefix if the operator chose a new one. if ( $prefix !== $old_prefix && ! empty( $header['table'] ) ) { $oldt = (string) $header['table']; $newt = $prefix . substr( $oldt, strlen( $old_prefix ) ); $sql = str_replace( '`' . $oldt . '`', '`' . $newt . '`', $sql ); } if ( 'create' === $kind ) { foreach ( array_filter( array_map( 'trim', explode( ";\n", $sql ) ) ) as $stmt ) { if ( '' !== $stmt ) { $db->query( $stmt ); } } ++$tables; } elseif ( 'rows' === $kind ) { $stmt = rtrim( trim( $sql ), ';' ); if ( '' !== $stmt && $db->query( $stmt ) ) { $rows += $db->affected_rows; } } else { // view / trigger / event — best effort, DEFINER already stripped at backup. foreach ( array_filter( array_map( 'trim', explode( ";\n", $sql ) ) ) as $stmt ) { if ( '' !== $stmt ) { $db->query( $stmt ); } } } } ); $log[] = "Imported {$tables} tables ({$rows} rows)."; /* ---- 3. Serialization-safe URL / path rewrite ---- */ $pairs = array(); if ( '' !== $old_url && $old_url !== $new_url ) { $pairs[ $old_url ] = $new_url; } if ( '' !== $old_path && rtrim( $old_path, '/' ) !== rtrim( $new_path, '/' ) ) { $pairs[ $old_path ] = $new_path; $pairs[ rtrim( $old_path, '/' ) ] = rtrim( $new_path, '/' ); } if ( $pairs ) { $changed = aisv_db_replace( $db, $prefix, $pairs ); $log[] = "Rewrote the site address for its new location ({$changed} values updated)."; } // Belt-and-braces: make sure the core options point at the new URL. $db->query( "UPDATE `{$prefix}options` SET option_value='" . $db->real_escape_string( $new_url ) . "' WHERE option_name IN ('siteurl','home')" ); /* ---- 4. Extract the files ---- */ $files = 0; $arc->each( function ( $header, $reader ) use ( $dir, &$files ) { if ( ( $header['type'] ?? '' ) !== 'file' ) { return; } $rel = aisv_safe_rel( $header['path'] ?? '' ); if ( false === $rel ) { return; } // Backups store paths relative to wp-content. $dest = $dir . '/wp-content/' . $rel; $sub = dirname( $dest ); if ( ! is_dir( $sub ) ) { @mkdir( $sub, 0755, true ); } $reader->copy_to( $header, $dest ); ++$files; } ); $log[] = "Extracted {$files} files."; /* ---- 5. WordPress core (not in the backup) ---- */ $core_err = aisv_fetch_core( $dir, isset( $man['wp_version'] ) ? preg_replace( '/[^0-9.]/', '', (string) $man['wp_version'] ) : '' ); $log[] = '' === $core_err ? 'WordPress core is in place.' : ( 'NOTE: ' . $core_err ); /* ---- 6. wp-config.php ---- */ $cfg = aisv_wp_config( array( 'DB_NAME' => $in['db_name'], 'DB_USER' => $in['db_user'], 'DB_PASSWORD' => $in['db_pass'], 'DB_HOST' => $in['db_host'], 'prefix' => $prefix, ) ); if ( false === @file_put_contents( $dir . '/wp-config.php', $cfg ) ) { throw new RuntimeException( 'The database and files were restored, but wp-config.php could not be written. Create it by hand from wp-config-sample.php.' ); } $log[] = 'Wrote wp-config.php.'; $db->close(); return $log; } /** * Walk every table's string columns and apply the serialization-safe replace. * * @param mysqli $db Connection. * @param string $prefix Table prefix. * @param array $pairs old => new. * @return int Values changed. */ function aisv_db_replace( $db, $prefix, array $pairs ) { $changed = 0; $res = $db->query( "SHOW TABLES LIKE '" . $db->real_escape_string( $prefix ) . "%'" ); if ( ! $res ) { return 0; } $tables = array(); while ( $row = $res->fetch_row() ) { $tables[] = $row[0]; } $res->free(); foreach ( $tables as $table ) { // Primary key. $pk = ''; $kr = $db->query( "SHOW KEYS FROM `{$table}` WHERE Key_name='PRIMARY'" ); if ( $kr && ( $k = $kr->fetch_assoc() ) ) { $pk = $k['Column_name']; } if ( $kr ) { $kr->free(); } if ( '' === $pk ) { continue; // Can't safely update rows without a key. } $offset = 0; do { $rows = $db->query( "SELECT * FROM `{$table}` LIMIT {$offset}, 200" ); if ( ! $rows ) { break; } $batch = 0; while ( $r = $rows->fetch_assoc() ) { ++$batch; $sets = array(); foreach ( $r as $col => $val ) { if ( ! is_string( $val ) || '' === $val ) { continue; } $new = aisv_replace_value( $val, $pairs ); if ( $new !== $val ) { $sets[] = "`{$col}`='" . $db->real_escape_string( $new ) . "'"; ++$changed; } } if ( $sets ) { $db->query( "UPDATE `{$table}` SET " . implode( ',', $sets ) . " WHERE `{$pk}`='" . $db->real_escape_string( $r[ $pk ] ) . "'" ); } } $rows->free(); $offset += 200; } while ( $batch === 200 ); } return $changed; } /* ========================================================================= * Web wizard * ====================================================================== */ $archive = aisv_find_archive(); $error = ''; $done = null; $manifest = array(); if ( '' !== $archive ) { try { $probe = new AISV_Archive( $archive ); $manifest = $probe->manifest; } catch ( Throwable $e ) { $error = $e->getMessage(); } } if ( 'POST' === ( $_SERVER['REQUEST_METHOD'] ?? '' ) && '' !== $archive && '' === $error ) { $in = array( 'db_host' => trim( $_POST['db_host'] ?? 'localhost' ), 'db_name' => trim( $_POST['db_name'] ?? '' ), 'db_user' => trim( $_POST['db_user'] ?? '' ), 'db_pass' => (string) ( $_POST['db_pass'] ?? '' ), 'prefix' => trim( $_POST['prefix'] ?? '' ), 'new_url' => trim( $_POST['new_url'] ?? '' ), 'password' => (string) ( $_POST['password'] ?? '' ), ); try { if ( '' === $in['db_name'] || '' === $in['db_user'] || '' === $in['new_url'] ) { throw new RuntimeException( 'Please fill in the database name, database user and the new site address.' ); } $done = aisv_run_install( $archive, $in ); } catch ( Throwable $e ) { $error = $e->getMessage(); } } $default_url = ''; if ( isset( $_SERVER['HTTP_HOST'] ) ) { $scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http'; $default_url = $scheme . '://' . $_SERVER['HTTP_HOST'] . rtrim( dirname( $_SERVER['SCRIPT_NAME'] ?? '' ), '/\\' ); } ?>
Rebuild a WordPress site from a .aisv backup — no existing WordPress required.
.aisv file in the same folder as this installer, then reload.| Backup of | |
| Created | |
| Table prefix | |
| Encrypted |