#!/usr/bin/env php
<?php
/**
 * aisv-extract.txt — standalone extractor for AI-SiteArk `.aisv` archives.
 *
 * Recovers a backup WITHOUT WordPress and WITHOUT the AI-SiteArk plugin. Copy this
 * one file next to your archive and run it from a shell. Nothing else is required:
 * PHP 7.4+, zlib (always present), and openssl only for password-protected archives.
 *
 * Usage:
 *   php aisv-extract.txt --list    backup.aisv [--password=SECRET]
 *   php aisv-extract.txt --extract=OUTDIR backup.aisv [--password=SECRET]
 *   php aisv-extract.txt --extract=OUTDIR backup.aisv --files-only
 *   php aisv-extract.txt --extract=OUTDIR backup.aisv --sql-only
 *
 * Output of --extract:
 *   OUTDIR/database.sql   every table, view, trigger and event, ready for `mysql <`
 *   OUTDIR/files/...      wp-content files, laid out relative to wp-content
 *
 * Multi-volume archives (backup.aisv, backup.aisv.2, …) are picked up automatically;
 * keep every volume in the same directory.
 *
 * Note on incremental backups: an incremental archive only carries the files that
 * changed since its base. Extract the base first, then the incremental over the top.
 * This tool tells you when an archive is an incremental and names the base it needs.
 *
 * @package AI_SiteArk
 * Copyright (c) 2026 AtoZ INFOWAY INC.
 * Licensed under the GNU General Public License, version 2 or later.
 */

if ( PHP_SAPI !== 'cli' ) {
	header( 'Content-Type: text/plain' );
	echo "aisv-extract.txt must be run from the command line.\n";
	exit( 1 );
}

const AISITEARK_MAGIC      = "AISV1\n";
const AISITEARK_PBKDF2_ITER = 100000;
const AISITEARK_CHUNK       = 1048576;

/**
 * Print a message to stderr and exit non-zero.
 *
 * @param string $msg Message.
 * @return void
 */
function aisiteark_fail( $msg ) {
	fwrite( STDERR, "error: {$msg}\n" );
	exit( 1 );
}

/**
 * Print usage and exit.
 *
 * @return void
 */
function aisiteark_usage() {
	fwrite(
		STDOUT,
		"aisv-extract.txt — open an AI-SiteArk .aisv backup without WordPress.\n\n" .
		"  php aisv-extract.txt --list backup.aisv [--password=SECRET]\n" .
		"  php aisv-extract.txt --extract=OUTDIR backup.aisv [--password=SECRET]\n\n" .
		"Options:\n" .
		"  --list             List what the archive contains, extract nothing.\n" .
		"  --extract=OUTDIR   Write database.sql and files/ into OUTDIR.\n" .
		"  --password=SECRET  Required for password-protected archives.\n" .
		"  --files-only       Skip the database.\n" .
		"  --sql-only         Skip the files.\n" .
		"  --help             This text.\n"
	);
	exit( 0 );
}

/**
 * Every volume of an archive, in order (base, base.2, base.3, …).
 *
 * @param string $base Path to the first volume.
 * @return string[]
 */
function aisiteark_volumes( $base ) {
	$vols = array( $base );
	for ( $i = 2; ; $i++ ) {
		$p = $base . '.' . $i;
		if ( ! is_file( $p ) ) {
			break;
		}
		$vols[] = $p;
	}
	return $vols;
}

/**
 * Read exactly $n bytes, or fewer at EOF.
 *
 * @param resource $fh Handle.
 * @param int      $n  Byte count.
 * @return string
 */
function aisiteark_read( $fh, $n ) {
	$out = '';
	while ( $n > 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 );
