169 lines
16 KiB
PHP
169 lines
16 KiB
PHP
<?php
|
|
|
|
// Active source is READ ONLY; every restore and compatibility mutation is on 55441 *_test.
|
|
|
|
use Illuminate\Foundation\Bootstrap\LoadConfiguration;
|
|
use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
require dirname(__DIR__).'/vendor/autoload.php';
|
|
$root = dirname(__DIR__);
|
|
ini_set('zend.exception_ignore_args', '1');
|
|
set_exception_handler(static function (Throwable $error): void {
|
|
file_put_contents(dirname(__DIR__).'/storage/app/private/investmoz-v2/portal-audit/workflow-upgrade-target-backup-failure-20261011.json',json_encode(['type'=>$error::class,'file'=>basename($error->getFile()),'line'=>$error->getLine(),'message'=>preg_replace('/(?:base64:)?[A-Za-z0-9+\\/=]{40,}/','[REDACTED]',$error->getMessage())],JSON_THROW_ON_ERROR));
|
|
fwrite(STDERR, 'Target preparation not completed; sanitized private diagnostic retained; no active writes.');
|
|
exit(1);
|
|
});
|
|
$app = require $root.'/bootstrap/app.php';
|
|
(new LoadEnvironmentVariables)->bootstrap($app);
|
|
(new LoadConfiguration)->bootstrap($app);
|
|
$c = config('database.connections.'.config('database.default'));
|
|
throw_unless($c['driver'] === 'pgsql' && empty($c['url']) && (int) $c['port'] === 5432 && $c['database'] === 'investemoz', RuntimeException::class, 'Explicit active source required; no provider bootstrap.');
|
|
$source = new PDO('pgsql:host='.$c['host'].';port='.$c['port'].';dbname='.$c['database'], $c['username'], $c['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
$source->exec('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
$identity = $source->query("SELECT current_database() AS database,inet_server_port() AS port,current_setting('transaction_read_only') AS read_only")->fetch(PDO::FETCH_ASSOC);
|
|
throw_unless($identity['database'] === 'investemoz' && (int) $identity['port'] === 5432 && $identity['read_only'] === 'on', RuntimeException::class, 'Active source identity refused.');
|
|
$scope=json_decode(file_get_contents($root.'/storage/app/private/investmoz-v2/portal-audit/workflow-upgrade-code-20261011.json'),true,512,JSON_THROW_ON_ERROR);
|
|
$hash=$scope['code_revision'];
|
|
foreach($scope['active_base_hashes']as$name=>$expected){throw_unless((is_file($root.'/'.$name)?hash_file('sha256',$root.'/'.$name):null)===$expected,RuntimeException::class,'Active baseline changed before snapshot.');}
|
|
$stamp = gmdate('Ymd_His');
|
|
$clone = 'workflow_target_'.$stamp.'_test';
|
|
$dir = $root.'/storage/app/private/investmoz-v2/portal-audit/workflow-target-backups/'.$clone;
|
|
mkdir($dir, 0700, true);
|
|
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
|
|
$run = static function (array $args, string $password) use ($dir): void {
|
|
$p = new Process($args, null, ['PGPASSWORD' => $password]);
|
|
$p->setTimeout(300);
|
|
$p->run();
|
|
if (! $p->isSuccessful()) {
|
|
file_put_contents($dir.'/command-failure.json', json_encode(['tool' => basename($args[0]), 'exit' => $p->getExitCode(), 'diagnostic' => str_replace($password ?: '__NO_PASSWORD__', '[REDACTED]', $p->getErrorOutput())]));
|
|
throw new RuntimeException('Backup/restore failed; private diagnostic retained.');
|
|
}
|
|
};
|
|
$snapshot = $source->query('SELECT pg_export_snapshot()')->fetchColumn();
|
|
$dump = $dir.'/database.dump';
|
|
$run([$bin.'pg_dump.exe', '-h', $c['host'], '-p', '5432', '-U', $c['username'], '-d', $c['database'], '-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]);
|
|
throw_unless((int) $maintenance->query('SELECT inet_server_port()')->fetchColumn() === 55441 && preg_match('/^workflow_target_\d{8}_\d{6}_test$/', $clone), RuntimeException::class, 'Restore target refused.');
|
|
$maintenance->exec('CREATE DATABASE "'.$clone.'"');
|
|
$run([$bin.'pg_restore.exe', '-h', '127.0.0.1', '-p', '55441', '-U', 'postgres', '-d', $clone, '--exit-on-error', '--no-owner', '--no-privileges', $dump], '');
|
|
$restored = new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$clone, 'postgres', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
$restored->exec('BEGIN READ ONLY');
|
|
$tables = [];
|
|
foreach ($source->query("SELECT schemaname,tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,tablename")->fetchAll(PDO::FETCH_ASSOC) 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 = $source->query($sql)->fetchAll(PDO::FETCH_COLUMN);
|
|
$b = $restored->query($sql)->fetchAll(PDO::FETCH_COLUMN);
|
|
throw_unless($a === $b, RuntimeException::class, 'Restored table differs.');
|
|
$tables[$name] = ['count' => count($a), 'sha256' => hash('sha256', implode("\n", $a))];
|
|
}
|
|
$sequenceSql = "SELECT * FROM pg_sequences WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,sequencename";
|
|
$sequences = $source->query($sequenceSql)->fetchAll(PDO::FETCH_ASSOC);
|
|
throw_unless($sequences === $restored->query($sequenceSql)->fetchAll(PDO::FETCH_ASSOC), RuntimeException::class, 'Restored sequences differ.');
|
|
$originalMfaIds = $source->query('SELECT id FROM users WHERE two_factor_secret IS NOT NULL ORDER BY id')->fetchAll(PDO::FETCH_COLUMN);
|
|
$documents = $source->query('SELECT DISTINCT storage_key FROM documents WHERE storage_key IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
|
|
$installationState = ['migrations' => $source->query("SELECT migration FROM migrations WHERE migration IN ('2026_10_09_030000_extend_opportunity_resources','2026_10_10_040000_create_portal_profile_authorization','2026_10_09_190000_prepare_investor_path','2026_10_10_050000_extend_organization_country') ORDER BY migration")->fetchAll(PDO::FETCH_COLUMN),
|
|
'country_columns' => $source->query("SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name='organizations' AND column_name IN ('incorporation_country_code','incorporation_country_provenance') ORDER BY column_name")->fetchAll(PDO::FETCH_COLUMN),
|
|
'organization_count' => (int) $source->query('SELECT count(*) FROM organizations')->fetchColumn(),
|
|
'membership_count' => (int) $source->query('SELECT count(*) FROM memberships WHERE deleted_at IS NULL')->fetchColumn(), 'country_flag' => (bool) config('portal.organization_country_enabled')];
|
|
$eligible = ['verified_managers' => 0, 'verified_managers_with_mfa' => 0];
|
|
$managerEmails = array_map('strtolower', config('portal.managers', []));
|
|
foreach ($source->query('SELECT email,email_verified_at,two_factor_secret,two_factor_confirmed_at FROM users')->fetchAll(PDO::FETCH_ASSOC) as $u) {
|
|
if ($u['email_verified_at'] !== null && in_array(strtolower($u['email']), $managerEmails, true)) {
|
|
$eligible['verified_managers']++;
|
|
if ($u['two_factor_secret'] !== null && $u['two_factor_confirmed_at'] !== null) {
|
|
$eligible['verified_managers_with_mfa']++;
|
|
}
|
|
}
|
|
}
|
|
$encryptionKeys=array_values(array_unique(array_filter([config('app.key'),...config('app.previous_keys',[])])));
|
|
$encrypters=array_map(fn($key)=>new Illuminate\Encryption\Encrypter(str_starts_with($key,'base64:')?base64_decode(substr($key,7),true):$key,config('app.cipher')),$encryptionKeys);
|
|
$mfaReadable=0;
|
|
foreach($restored->query('SELECT two_factor_secret,two_factor_recovery_codes FROM users WHERE two_factor_secret IS NOT NULL')->fetchAll(PDO::FETCH_ASSOC)as$row){
|
|
foreach(['two_factor_secret','two_factor_recovery_codes']as$field){if($row[$field]===null)continue;$readable=false;foreach($encrypters as$crypt){try{$plain=$crypt->decrypt($row[$field],false);$readable=is_string($plain)&&$plain!=='';unset($plain);if($readable)break;}catch(Illuminate\Contracts\Encryption\DecryptException){}}
|
|
throw_unless($readable,RuntimeException::class,'An active MFA envelope is not recoverable with the preserved configured keys.');}$mfaReadable++;
|
|
}
|
|
throw_unless($installationState['country_flag'] && count($installationState['country_columns'])===2 && $installationState['migrations']===['2026_10_10_050000_extend_organization_country'],RuntimeException::class,'Preserved COUNTRY02/dependency baseline differs.');
|
|
$source->exec('ROLLBACK');
|
|
$restored->exec('ROLLBACK');
|
|
$files = $dir.'/private-files.zip';
|
|
$zip = new ZipArchive;
|
|
throw_unless($zip->open($files, ZipArchive::CREATE | ZipArchive::EXCL) === true, RuntimeException::class, 'Archive unavailable.');
|
|
$fileHashes = [];
|
|
$missing = [];
|
|
$privateRoot = realpath($root.'/storage/app/private');
|
|
foreach ($documents as $relative) {
|
|
$path = realpath($privateRoot.'/'.$relative);
|
|
if (! $path || ! str_starts_with(strtolower($path), strtolower($privateRoot.DIRECTORY_SEPARATOR))) {
|
|
$missing[] = $relative;
|
|
|
|
continue;
|
|
}
|
|
$name = 'private/'.str_replace('\\', '/', $relative);
|
|
$zip->addFile($path, $name);
|
|
$fileHashes[$name] = hash_file('sha256', $path);
|
|
}
|
|
// Original stable configuration is kept only in the private restore archive.
|
|
$effectiveRuntime = $dir.'/effective-runtime.json';
|
|
file_put_contents($effectiveRuntime, json_encode(['app_key' => config('app.key'), 'cipher' => config('app.cipher'), 'config_cached' => $app->configurationIsCached(), 'previous_keys'=>config('app.previous_keys',[])], JSON_THROW_ON_ERROR));
|
|
$zip->addFile($effectiveRuntime, 'runtime/effective-runtime.json');
|
|
$fileHashes['runtime/effective-runtime.json'] = hash_file('sha256', $effectiveRuntime);
|
|
$zip->addFile($root.'/.env', 'runtime/.env');
|
|
$fileHashes['runtime/.env'] = hash_file('sha256', $root.'/.env');
|
|
// Preserve the actually loaded active bundle for reversal; no active build is changed.
|
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root.'/public/build', FilesystemIterator::SKIP_DOTS)) as $asset) {
|
|
if (! $asset->isFile()) {
|
|
continue;
|
|
}
|
|
$relative = substr(str_replace('\\', '/', $asset->getPathname()), strlen(str_replace('\\', '/', $root.'/public/build')) + 1);
|
|
$name = 'runtime/active-build/'.$relative;
|
|
$zip->addFile($asset->getPathname(), $name);
|
|
$fileHashes[$name] = hash_file('sha256', $asset->getPathname());
|
|
}
|
|
// Code, configuration and TLS are privately preserved; no active file is replaced.
|
|
foreach(['app','config','routes','bootstrap']as$folder){foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root.'/'.$folder,FilesystemIterator::SKIP_DOTS))as$file){if(!$file->isFile())continue;$relative=str_replace('\\','/',substr($file->getPathname(),strlen($root)+1));$entry='active-code/'.$relative;$zip->addFile($file->getPathname(),$entry);$fileHashes[$entry]=hash_file('sha256',$file->getPathname());}}
|
|
foreach(['composer.json','composer.lock','package.json','package-lock.json']as$relative){$entry='active-code/'.$relative;$zip->addFile($root.'/'.$relative,$entry);$fileHashes[$entry]=hash_file('sha256',$root.'/'.$relative);}
|
|
$tlsBase='C:/Users/macbook/.config/investmoz/tls';
|
|
foreach(['portal-investidor.test-fullchain.pem','portal-investidor.test-key.pem']as$relative){$entry='tls/'.$relative;throw_unless(is_file($tlsBase.'/'.$relative),RuntimeException::class,'Current TLS recovery file missing.');$zip->addFile($tlsBase.'/'.$relative,$entry);$fileHashes[$entry]=hash_file('sha256',$tlsBase.'/'.$relative);}
|
|
$httpConfig=json_decode(file_get_contents($root.'/outputs/investmoz-country02-https-20261011/configuracao-ativa-leitura.json'),true,512,JSON_THROW_ON_ERROR);
|
|
$httpConfig['active_vhost']['path']='C:/laragon/etc/apache2/sites-enabled/portal-investidor.test.conf';
|
|
foreach(['active_vhost','apache_config','ssl_include']as$label){$file=$httpConfig[$label]['path'];$entry='apache/'.$label.'.conf';throw_unless(is_file($file),RuntimeException::class,'Current Apache recovery file missing.');$zip->addFile($file,$entry);$fileHashes[$entry]=hash_file('sha256',$file);}
|
|
foreach(glob('C:/laragon/etc/apache2/sites-enabled/*.conf')as$file){$entry='apache/vhosts/'.basename($file);$zip->addFile($file,$entry);$fileHashes[$entry]=hash_file('sha256',$file);}
|
|
$zip->close();
|
|
$read = new ZipArchive;
|
|
$read->open($files);
|
|
$restoreFiles = $dir.'/files-restored';
|
|
mkdir($restoreFiles, 0700, true);
|
|
foreach ($fileHashes as $name => $expected) {
|
|
throw_unless(! str_contains($name, ':') && ! str_starts_with($name, '/') && ! array_intersect(explode('/', $name), ['.', '..']), RuntimeException::class, 'Member path refused.');
|
|
$bytes = $read->getFromName($name);
|
|
throw_unless(is_string($bytes) && hash('sha256', $bytes) === $expected, RuntimeException::class, 'Archive member differs.');
|
|
$dest = $restoreFiles.'/'.str_replace('/', DIRECTORY_SEPARATOR, $name);
|
|
if (! is_dir(dirname($dest))) {
|
|
mkdir(dirname($dest), 0700, true);
|
|
}
|
|
throw_unless(str_starts_with(strtolower(realpath(dirname($dest))), strtolower(realpath($restoreFiles).DIRECTORY_SEPARATOR)), RuntimeException::class, 'Resolved restore path refused.');
|
|
file_put_contents($dest, $bytes);
|
|
throw_unless(hash_file('sha256', $dest) === $expected, RuntimeException::class, 'Restored file differs.');
|
|
}
|
|
$read->close();
|
|
$proof = ['verified_at' => gmdate('c'), 'code_hash' => $hash, 'target' => $identity, 'restored_target' => ['database' => $clone, 'port' => 55441],
|
|
'dump_path' => $dump, 'dump_sha256' => hash_file('sha256', $dump), 'files_path' => $files, 'files_sha256' => hash_file('sha256', $files),
|
|
'restore_verified' => true, 'files_verified' => $missing === [], 'tables' => $tables, 'sequences' => $sequences, 'sequences_verified' => true,
|
|
'original_mfa_user_ids' => $originalMfaIds, 'file_count' => count($fileHashes), 'missing_files' => $missing,
|
|
'source_installations' => $installationState, 'installer_eligibility_counts' => $eligible,
|
|
'runtime' => ['config_cached' => $app->configurationIsCached(), 'cipher' => config('app.cipher'), 'session_driver' => config('session.driver'), 'app_name' => config('app.name'), 'organization_country_enabled' => config('portal.organization_country_enabled'),
|
|
'opportunity_dashboard_access_enabled' => config('portal.opportunity_dashboard_access_enabled'), 'profile_access_enabled' => config('portal.profile_access_enabled'), 'opportunity_specialized_drafts_enabled' => config('portal.opportunity_specialized_drafts_enabled'),
|
|
'opportunity_modular_profiles_qa_enabled' => config('portal.opportunity_modular_profiles_qa_enabled'), 'legacy_content_qa_enabled' => config('portal.legacy_content_qa_enabled'), 'investor_path_enabled' => config('investor-path.enabled')],
|
|
'candidate_qa_manifest_sha256' => $scope['production_manifest_sha256'], 'active_manifest_sha256' => hash_file('sha256', $root.'/public/build/manifest.json'), 'compatibility_verified' => false, 'reversal_verified' => false, 'qa_passed' => false, 'active_writes' => 0];
|
|
$proof['result']=$missing===[]?'passed':'partial';
|
|
$proof['candidate_code_revision']=$hash;$proof['MFA_envelopes_readable']=$mfaReadable;
|
|
$proof['active_originals_backed_up']=count($documents);$proof['active_files_manifest']=$fileHashes;
|
|
$proof['scope']='Read-only backup of actual active target, restored exclusively to a new 55441 *_test clone; not installation or enablement';
|
|
$proof['activation_ready']=false;$proof['authenticated_installation_preview_still_required']=true;
|
|
file_put_contents($dir.'/backup-proof.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
file_put_contents($root.'/storage/app/private/investmoz-v2/portal-audit/workflow-upgrade-target-backup-link-20261011.json', json_encode(['path' => $dir.'/backup-proof.json', 'directory' => $dir, 'restored_target' => $proof['restored_target']], JSON_THROW_ON_ERROR));
|
|
file_put_contents($root.'/storage/app/private/investmoz-v2/portal-audit/workflow-upgrade-target-backup-20261011.json',json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
|
echo json_encode(['result' => $missing === [] ? 'restored_files_verified' : 'restore_partial_missing_files', 'tables' => count($tables), 'files' => count($fileHashes), 'missing_files' => count($missing), 'clone' => $clone, 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|