#!/usr/bin/env php 0 ) { $buf = fread( $fh, $n ); if ( false === $buf || '' === $buf ) { break; } $out .= $buf; $n -= strlen( $buf ); } return $out; } /** * Derive the archive key from a password + salt (must match Helpers\Crypto). * * @param string $password Password. * @param string $salt Raw salt bytes. * @return string 32 raw key bytes. */ function aisiteark_key( $password, $salt ) { return hash_pbkdf2( 'sha256', (string) $password, $salt, AISITEARK_PBKDF2_ITER, 32, true ); } /** * Decrypt one buffer (AES-256-CTR). * * @param string $cipher Ciphertext. * @param string $key Key. * @param string $iv IV. * @return string */ function aisiteark_dec( $cipher, $key, $iv ) { return openssl_decrypt( $cipher, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv ); } /** * Advance a CTR IV by N 16-byte blocks (mirrors Helpers\Crypto::iv_add). * * @param string $iv 16 raw bytes. * @param int $blocks Block count. * @return string */ function aisiteark_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 ); } /** * Blocks occupied by N bytes. * * @param int $bytes Bytes. * @return int */ function aisiteark_blocks( $bytes ) { return intdiv( $bytes, 16 ) + ( ( $bytes % 16 ) ? 1 : 0 ); } /** * Reject path traversal and absolute paths coming out of the archive. * * @param string $rel Content-relative path. * @return string|false Safe relative path, or false to skip. */ function aisiteark_safe_rel( $rel ) { $rel = str_replace( '\\', '/', (string) $rel ); $rel = ltrim( $rel, '/' ); if ( '' === $rel || false !== strpos( $rel, '../' ) || '..' === $rel ) { return false; } if ( preg_match( '#^[a-zA-Z]:/#', $rel ) ) { return false; } return $rel; } /** * Read the next entry header from an open volume. * * @param resource $fh Handle. * @return array{header:array,len:int}|null Null at EOF. */ function aisiteark_next_entry( $fh ) { $lb = aisiteark_read( $fh, 4 ); if ( strlen( $lb ) < 4 ) { return null; } $hlen = unpack( 'N', $lb )[1]; $hjson = aisiteark_read( $fh, $hlen ); $hdr = json_decode( (string) $hjson, true ); $pb = aisiteark_read( $fh, 8 ); if ( strlen( $pb ) < 8 ) { return null; } $plen = unpack( 'J', $pb )[1]; return array( 'header' => is_array( $hdr ) ? $hdr : array(), 'len' => (int) $plen, ); } /* ------------------------------------------------------------------------- * Arguments * ---------------------------------------------------------------------- */ $archive = ''; $outdir = ''; $password = ''; $do_list = false; $skip_db = false; $skip_file = false; foreach ( array_slice( $argv, 1 ) as $arg ) { if ( '--help' === $arg || '-h' === $arg ) { aisiteark_usage(); } elseif ( '--list' === $arg ) { $do_list = true; } elseif ( '--files-only' === $arg ) { $skip_db = true; } elseif ( '--sql-only' === $arg ) { $skip_file = true; } elseif ( 0 === strpos( $arg, '--extract=' ) ) { $outdir = substr( $arg, 10 ); } elseif ( 0 === strpos( $arg, '--password=' ) ) { $password = substr( $arg, 11 ); } elseif ( 0 === strpos( $arg, '--' ) ) { aisiteark_fail( "unknown option {$arg} (try --help)" ); } else { $archive = $arg; } } if ( '' === $archive ) { aisiteark_usage(); } if ( ! is_file( $archive ) ) { aisiteark_fail( "no such archive: {$archive}" ); } if ( ! $do_list && '' === $outdir ) { aisiteark_fail( 'give either --list or --extract=OUTDIR (try --help)' ); } $volumes = aisiteark_volumes( $archive ); /* ------------------------------------------------------------------------- * Manifest — always the first entry of volume 1, always unencrypted. * ---------------------------------------------------------------------- */ $fh = fopen( $volumes[0], 'rb' ); if ( ! $fh ) { aisiteark_fail( "cannot read {$volumes[0]}" ); } if ( aisiteark_read( $fh, strlen( AISITEARK_MAGIC ) ) !== AISITEARK_MAGIC ) { aisiteark_fail( 'not an AI-SiteArk archive (bad magic header)' ); } $first = aisiteark_next_entry( $fh ); if ( ! $first || ( $first['header']['type'] ?? '' ) !== 'manifest' ) { aisiteark_fail( 'archive has no manifest' ); } $manifest = json_decode( aisiteark_read( $fh, $first['len'] ), true ); fclose( $fh ); if ( ! is_array( $manifest ) ) { aisiteark_fail( 'manifest is unreadable' ); } $encrypted = ! empty( $manifest['encrypted'] ); $key = ''; if ( $encrypted ) { if ( ! function_exists( 'openssl_decrypt' ) ) { aisiteark_fail( 'this archive is encrypted but PHP has no openssl extension' ); } if ( '' === $password ) { aisiteark_fail( 'this archive is password-protected — pass --password=SECRET' ); } $key = aisiteark_key( $password, base64_decode( (string) ( $manifest['salt'] ?? '' ) ) ); $ok = aisiteark_dec( base64_decode( (string) ( $manifest['verify'] ?? '' ) ), $key, base64_decode( (string) ( $manifest['verify_iv'] ?? '' ) ) ); if ( 'AISV-OK' !== $ok ) { aisiteark_fail( 'wrong password' ); } } $seq = (int) ( $manifest['seq'] ?? 0 ); $base = (string) ( $manifest['base_file'] ?? '' ); fwrite( STDOUT, "archive : " . basename( $archive ) . "\n" ); fwrite( STDOUT, "volumes : " . count( $volumes ) . "\n" ); fwrite( STDOUT, "created : " . ( $manifest['created_at'] ?? $manifest['created'] ?? '?' ) . "\n" ); fwrite( STDOUT, "site : " . ( $manifest['site_url'] ?? '?' ) . "\n" ); fwrite( STDOUT, "prefix : " . ( $manifest['table_prefix'] ?? '?' ) . "\n" ); fwrite( STDOUT, "encrypted: " . ( $encrypted ? 'yes' : 'no' ) . "\n" ); if ( $seq > 0 ) { fwrite( STDOUT, "\nNOTE: this is an INCREMENTAL backup (seq {$seq}).\n" ); fwrite( STDOUT, " It contains only files changed since its base: {$base}\n" ); fwrite( STDOUT, " Extract that base first, then extract this one over the top.\n" ); } fwrite( STDOUT, "\n" ); /* ------------------------------------------------------------------------- * Walk every entry of every volume. * ---------------------------------------------------------------------- */ if ( ! $do_list ) { if ( ! is_dir( $outdir ) && ! mkdir( $outdir, 0755, true ) && ! is_dir( $outdir ) ) { aisiteark_fail( "cannot create {$outdir}" ); } } $sql_path = rtrim( $outdir, '/\\' ) . '/database.sql'; $sql_fh = null; if ( ! $do_list && ! $skip_db ) { $sql_fh = fopen( $sql_path, 'wb' ); if ( ! $sql_fh ) { aisiteark_fail( "cannot write {$sql_path}" ); } fwrite( $sql_fh, "-- Extracted by aisv-extract.txt from " . basename( $archive ) . "\n" ); fwrite( $sql_fh, "-- Source site: " . ( $manifest['site_url'] ?? '?' ) . "\n" ); fwrite( $sql_fh, "-- Table prefix: " . ( $manifest['table_prefix'] ?? '?' ) . "\n\n" ); fwrite( $sql_fh, "SET FOREIGN_KEY_CHECKS=0;\n\n" ); } $n_db = 0; $n_files = 0; $n_bytes = 0; $tables = array(); $listing = array(); foreach ( $volumes as $vi => $vol ) { $fh = fopen( $vol, 'rb' ); if ( ! $fh ) { aisiteark_fail( "cannot read {$vol}" ); } if ( aisiteark_read( $fh, strlen( AISITEARK_MAGIC ) ) !== AISITEARK_MAGIC ) { aisiteark_fail( 'bad magic in ' . basename( $vol ) ); } while ( true ) { $e = aisiteark_next_entry( $fh ); if ( null === $e ) { break; } $h = $e['header']; $len = $e['len']; $type = $h['type'] ?? ''; // Manifest entry (only on volume 1) — already parsed. if ( 'manifest' === $type ) { fseek( $fh, $len, SEEK_CUR ); continue; } if ( 'db' === $type ) { if ( $skip_db || $do_list ) { if ( $do_list && 'create' === ( $h['kind'] ?? '' ) && ! empty( $h['table'] ) ) { $tables[] = (string) $h['table']; } ++$n_db; fseek( $fh, $len, SEEK_CUR ); continue; } $payload = aisiteark_read( $fh, $len ); if ( $encrypted && isset( $h['iv'] ) ) { $payload = aisiteark_dec( $payload, $key, base64_decode( $h['iv'] ) ); } if ( 'gzip' === ( $h['enc'] ?? '' ) ) { $plain = @gzdecode( $payload ); if ( false === $plain ) { aisiteark_fail( 'could not decompress a database entry (wrong password, or the archive is damaged)' ); } $payload = $plain; } $label = $h['table'] ?? ( $h['name'] ?? '' ); fwrite( $sql_fh, "\n-- [" . ( $h['kind'] ?? 'rows' ) . "] {$label}\n" ); fwrite( $sql_fh, rtrim( $payload, "\n" ) . "\n" ); ++$n_db; continue; } if ( 'file' === $type ) { $rel = aisiteark_safe_rel( $h['path'] ?? '' ); if ( $do_list || $skip_file || false === $rel ) { if ( $do_list && false !== $rel && count( $listing ) < 25 ) { $listing[] = $rel . ' (' . number_format( $len ) . " bytes)"; } ++$n_files; $n_bytes += $len; fseek( $fh, $len, SEEK_CUR ); continue; } $dest = rtrim( $outdir, '/\\' ) . '/files/' . $rel; $dir = dirname( $dest ); if ( ! is_dir( $dir ) && ! mkdir( $dir, 0755, true ) && ! is_dir( $dir ) ) { aisiteark_fail( "cannot create {$dir}" ); } $out = fopen( $dest, 'wb' ); if ( ! $out ) { aisiteark_fail( "cannot write {$dest}" ); } $iv = ( $encrypted && isset( $h['iv'] ) ) ? base64_decode( $h['iv'] ) : null; $blocks = 0; $remaining = $len; while ( $remaining > 0 ) { $buf = aisiteark_read( $fh, (int) min( AISITEARK_CHUNK, $remaining ) ); if ( '' === $buf ) { break; } $read = strlen( $buf ); $remaining -= $read; if ( null !== $iv ) { $buf = aisiteark_dec( $buf, $key, aisiteark_iv_add( $iv, $blocks ) ); $blocks += aisiteark_blocks( $read ); } fwrite( $out, $buf ); } fclose( $out ); ++$n_files; $n_bytes += $len; continue; } // Unknown entry type — skip its payload rather than desync. fseek( $fh, $len, SEEK_CUR ); } fclose( $fh ); } if ( $sql_fh ) { fwrite( $sql_fh, "\nSET FOREIGN_KEY_CHECKS=1;\n" ); fclose( $sql_fh ); } /* ------------------------------------------------------------------------- * Report * ---------------------------------------------------------------------- */ if ( $do_list ) { fwrite( STDOUT, "database entries : {$n_db}\n" ); if ( $tables ) { fwrite( STDOUT, "tables : " . count( $tables ) . ' (' . implode( ', ', array_slice( $tables, 0, 8 ) ) . ( count( $tables ) > 8 ? ', …' : '' ) . ")\n" ); } fwrite( STDOUT, "files : {$n_files} (" . number_format( $n_bytes ) . " bytes)\n" ); if ( $listing ) { fwrite( STDOUT, "\nfirst files:\n" ); foreach ( $listing as $l ) { fwrite( STDOUT, " {$l}\n" ); } if ( $n_files > count( $listing ) ) { fwrite( STDOUT, " … and " . ( $n_files - count( $listing ) ) . " more\n" ); } } exit( 0 ); } fwrite( STDOUT, "extracted to: {$outdir}\n" ); if ( ! $skip_db ) { fwrite( STDOUT, " database.sql ({$n_db} entries)\n" ); fwrite( STDOUT, "\nRestore it with:\n mysql -u USER -p DBNAME < " . $sql_path . "\n" ); } if ( ! $skip_file ) { fwrite( STDOUT, " files/ ({$n_files} files, " . number_format( $n_bytes ) . " bytes)\n" ); fwrite( STDOUT, "\nThese sit relative to wp-content/ — copy them over your wp-content directory.\n" ); } exit( 0 );