103 lines
5.9 KiB
PHP
103 lines
5.9 KiB
PHP
<?php
|
|
|
|
use Illuminate\Foundation\Bootstrap\LoadConfiguration;
|
|
use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables;
|
|
|
|
// Active connection is read-only; restore/compatibility writes are restricted to a new QA database.
|
|
require dirname(__DIR__).'/vendor/autoload.php';
|
|
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
|
(new LoadEnvironmentVariables)->bootstrap($app);
|
|
(new LoadConfiguration)->bootstrap($app);
|
|
$c = config('database.connections.pgsql');
|
|
if (($c['url'] ?? null) || $c['database'] !== 'investemoz' || (int) $c['port'] !== 5432) {
|
|
throw new RuntimeException('Active backup identity configuration rejected.');
|
|
}
|
|
$active = new PDO('pgsql:host='.$c['host'].';port=5432;dbname=investemoz', $c['username'], $c['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
$active->exec('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
$target = $active->query('select current_database() as database, inet_server_port() as port')->fetch(PDO::FETCH_ASSOC);
|
|
if ($target['database'] !== 'investemoz' || (int) $target['port'] !== 5432) {
|
|
throw new RuntimeException('Active backup actual identity rejected.');
|
|
}
|
|
$stamp = date('Ymd_His');
|
|
$directory = storage_path('app/private/virtual-desk/activation-backup-'.$stamp);
|
|
mkdir($directory, 0700, true);
|
|
$dump = $directory.'/database.dump';
|
|
$restoreName = 'desk_restore_'.$stamp.'_test';
|
|
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
|
|
$run = function (array $args, string $password): void {
|
|
$process = proc_open($args, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, [...getenv(), 'PGPASSWORD' => $password], ['bypass_shell' => true]);
|
|
if (! is_resource($process)) {
|
|
throw new RuntimeException('Backup process unavailable.');
|
|
}
|
|
fclose($pipes[0]);
|
|
stream_get_contents($pipes[1]);
|
|
fclose($pipes[1]);
|
|
$error = stream_get_contents($pipes[2]);
|
|
fclose($pipes[2]);
|
|
if (proc_close($process) !== 0) {
|
|
throw new RuntimeException('Backup/restore failed: '.$error);
|
|
}
|
|
};
|
|
$snapshot = $active->query('select pg_export_snapshot()')->fetchColumn();
|
|
$run([$bin.'pg_dump.exe', '-h', $c['host'], '-p', '5432', '-U', $c['username'], '-d', 'investemoz', '-Fc', '--snapshot='.$snapshot, '-f', $dump], $c['password']);
|
|
$maintenance = new PDO('pgsql:host=127.0.0.1;port=55441;dbname=postgres', 'postgres', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
if ((int) $maintenance->query('select inet_server_port()')->fetchColumn() !== 55441 || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $restoreName)) {
|
|
throw new RuntimeException('Restore target rejected.');
|
|
}
|
|
$maintenance->exec('CREATE DATABASE "'.$restoreName.'"');
|
|
$run([$bin.'pg_restore.exe', '-h', '127.0.0.1', '-p', '55441', '-U', 'postgres', '-d', $restoreName, '--exit-on-error', '--no-owner', '--no-privileges', $dump], '');
|
|
$qa = new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$restoreName, 'postgres', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
$restored = $qa->query('select current_database() as database, inet_server_port() as port')->fetch(PDO::FETCH_ASSOC);
|
|
if ($restored['database'] !== $restoreName || (int) $restored['port'] !== 55441) {
|
|
throw new RuntimeException('Actual restore target rejected.');
|
|
}
|
|
$tables = $active->query("select schemaname,tablename from pg_tables where schemaname not in ('pg_catalog','information_schema') order by schemaname,tablename")->fetchAll(PDO::FETCH_ASSOC);
|
|
$counts = [];
|
|
$hashes = [];
|
|
foreach ($tables as $t) {
|
|
$name = '"'.str_replace('"', '""', $t['schemaname']).'"."'.str_replace('"', '""', $t['tablename']).'"';
|
|
$sql = 'select row_to_json(t)::text from '.$name.' t order by row_to_json(t)::text';
|
|
$a = $active->query($sql)->fetchAll(PDO::FETCH_COLUMN);
|
|
$b = $qa->query($sql)->fetchAll(PDO::FETCH_COLUMN);
|
|
if ($a !== $b) {
|
|
throw new RuntimeException('Restored contents differ: '.$name);
|
|
}
|
|
$counts[$name] = count($a);
|
|
$hashes[$name] = hash('sha256', implode("\n", $a));
|
|
}
|
|
$auditEventIds = $active->query('SELECT id FROM audit_events ORDER BY id')->fetchAll(PDO::FETCH_COLUMN);
|
|
$active->exec('ROLLBACK');
|
|
// Preserve exactly the private files referenced by restored document records, plus the runtime key.
|
|
$files = $directory.'/private-files.zip';
|
|
$zip = new ZipArchive;
|
|
$zip->open($files, ZipArchive::CREATE | ZipArchive::EXCL);
|
|
$manifest = [];
|
|
foreach ($qa->query('select storage_key from documents where storage_key is not null')->fetchAll(PDO::FETCH_COLUMN) as $relative) {
|
|
$path = realpath(storage_path('app/private/'.$relative));
|
|
$root = realpath(storage_path('app/private'));
|
|
if (! $path || ! str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR))) {
|
|
throw new RuntimeException('Referenced private document missing or outside storage.');
|
|
}
|
|
$name = 'private/'.str_replace('\\', '/', $relative);
|
|
$zip->addFile($path, $name);
|
|
$manifest[$name] = hash_file('sha256', $path);
|
|
}
|
|
$zip->addFile(base_path('.env'), 'runtime/.env');
|
|
$manifest['runtime/.env'] = hash_file('sha256', base_path('.env'));
|
|
$zip->close();
|
|
$read = new ZipArchive;
|
|
$read->open($files);
|
|
foreach ($manifest as $name => $hash) {
|
|
$bytes = $read->getFromName($name);
|
|
if (! is_string($bytes) || hash('sha256', $bytes) !== $hash) {
|
|
throw new RuntimeException('Private file restore verification failed.');
|
|
}
|
|
}
|
|
$read->close();
|
|
$proof = ['restore_verified' => true, 'verified_at' => gmdate('c'), 'target' => $target, 'restored_target' => $restored,
|
|
'dump_path' => $dump, 'sha256' => hash_file('sha256', $dump), 'table_counts' => $counts, 'table_hashes' => $hashes,
|
|
'files_verified' => true, 'files_path' => $files, 'files_sha256' => hash_file('sha256', $files), 'file_hashes' => $manifest,
|
|
'audit_event_ids' => $auditEventIds];
|
|
file_put_contents(storage_path('app/private/virtual-desk/backup-proof.json'), json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
echo json_encode(['restore_verified' => true, 'tables' => count($tables), 'files' => count($manifest), 'restore_target' => $restored], JSON_THROW_ON_ERROR);
|