97 lines
8.5 KiB
PHP
97 lines
8.5 KiB
PHP
<?php
|
|
|
|
// Delimited recovery requested by Tempo, 23/10/2026. Never import or approve new/edited dossiers.
|
|
declare(strict_types=1);
|
|
|
|
ini_set('zend.exception_ignore_args', '1');
|
|
require getcwd().'/vendor/autoload.php';
|
|
$app = require getcwd().'/bootstrap/app.php';
|
|
(new Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables)->bootstrap($app);
|
|
(new Illuminate\Foundation\Bootstrap\LoadConfiguration)->bootstrap($app);
|
|
$args = getopt('', ['manifest:', 'output:', 'apply', 'preview-sha256:', 'backup:', 'qa']);
|
|
$manifestPath = $args['manifest'] ?? '';
|
|
$manifestHash = '8d07007a2e1e8758d4741b4ac594f78d5943f71ad78b2daae7f85aefa9d2de4a';
|
|
if (!is_file($manifestPath) || !hash_equals($manifestHash, hash_file('sha256', $manifestPath))) {
|
|
throw new RuntimeException('Exact reviewed 29-original recovery manifest required.');
|
|
}
|
|
$manifest = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
|
|
if (count($manifest['items']) !== 29 || count(array_unique(array_column($manifest['items'], 'target_record_id'))) !== 29) {
|
|
throw new RuntimeException('Exact original scope required.');
|
|
}
|
|
$qa = isset($args['qa']);
|
|
$c = config('database.connections.'.config('database.default'));
|
|
$expected = $qa ? 'simulator_fiscal_delta_20261021_test' : 'teste_investidor';
|
|
$port = $qa ? 55441 : 5432;
|
|
if ($c['driver'] !== 'pgsql' || !empty($c['url']) || !in_array($c['host'], ['127.0.0.1', 'localhost'], true) || $c['database'] !== $expected || (int)$c['port'] !== $port) {
|
|
throw new RuntimeException('Named target mismatch.');
|
|
}
|
|
if (!$qa && (!getenv('SSH_CONNECTION') || !function_exists('posix_geteuid') || posix_geteuid() !== 0 || rtrim(config('app.url'), '/') !== 'https://teste-investidor.tempo.co.mz')) {
|
|
throw new RuntimeException('Authenticated destination operator required.');
|
|
}
|
|
$output = $args['output'] ?? '';
|
|
if (!$output || str_contains(str_replace('\\', '/', $output), '/public/')) {
|
|
throw new RuntimeException('Private receipt path required.');
|
|
}
|
|
$pdo = new PDO('pgsql:host='.$c['host'].';port='.$c['port'].';dbname='.$expected, $c['username'], $c['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
$apply = isset($args['apply']);
|
|
$pdo->exec($apply ? 'BEGIN ISOLATION LEVEL SERIALIZABLE' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
try {
|
|
$actual = $pdo->query('select current_database() as name,inet_server_port() as port')->fetch(PDO::FETCH_ASSOC);
|
|
if ($actual['name'] !== $expected || (int)$actual['port'] !== $port) throw new RuntimeException('Actual target mismatch.');
|
|
if ($apply) $pdo->query('select pg_advisory_xact_lock(231026,29)');
|
|
$plan = [];
|
|
$before = [];
|
|
foreach ($manifest['items'] as $item) {
|
|
$query = $pdo->prepare('select * from investment_dossiers where id = ?'.($apply ? ' for update' : ''));
|
|
$query->execute([$item['target_record_id']]);
|
|
$row = $query->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row || $row['deleted_at'] !== null || $row['title'] !== $item['title'] || !hash_equals($item['content_sha256'], hash('sha256', $row['content'])) || !hash_equals($item['source_sha256'], hash('sha256', $row['source']))) {
|
|
throw new RuntimeException('Missing, archived or changed original: '.$item['code']);
|
|
}
|
|
if (!hash_equals($item['approval_sha256'], hash('sha256', $item['approval_evidence']))) throw new RuntimeException('Approval provenance changed.');
|
|
$replayed = $pdo->prepare("select count(*) from audit_events where source_system='portfolio-visibility-recovery-v1' and external_id=? and entity_id=? and action='approved_visibility_restored'");
|
|
$replayed->execute([$manifestHash.':'.$row['id'], $row['id']]);
|
|
$already = (int)$replayed->fetchColumn() === 1;
|
|
if ($already) {
|
|
if ($row['publication_status'] !== 'published' || $row['approval_basis'] !== 'document_owner' || $row['approval_evidence'] !== $item['approval_evidence']) throw new RuntimeException('Recovery subsequently changed: '.$item['code']);
|
|
} elseif ($row['publication_status'] !== 'draft' || $row['editor_id'] !== null || $row['reviewer_id'] !== null || (int)$row['version_no'] !== (int)$item['expected_target_version']) {
|
|
throw new RuntimeException('Preserve subsequent editorial decisions: '.$item['code']);
|
|
}
|
|
$before[] = $row;
|
|
$plan[] = ['id' => $row['id'], 'code' => $item['code'], 'version' => (int)$row['version_no'], 'status' => $row['publication_status'], 'content_sha256' => $item['content_sha256'], 'source_sha256' => $item['source_sha256'], 'already_restored' => $already];
|
|
}
|
|
$previewHash = hash('sha256', json_encode([$expected, $manifestHash, $plan], JSON_THROW_ON_ERROR));
|
|
if ($apply) {
|
|
if (!hash_equals($previewHash, $args['preview-sha256'] ?? '')) throw new RuntimeException('Preview changed; recover current state before writing.');
|
|
if (!$qa) {
|
|
$directory = $args['backup'] ?? '';
|
|
$backup = json_decode(file_get_contents($directory.'/backup-receipt.json'), true, 512, JSON_THROW_ON_ERROR);
|
|
$restore = json_decode(file_get_contents($directory.'/restore-receipt.json'), true, 512, JSON_THROW_ON_ERROR);
|
|
if (!$restore['restore_verified'] || $backup['target']['database'] !== $expected || (int)$backup['target']['port'] !== $port || !hash_equals($backup['sha256'], $restore['source_dump_sha256'])) throw new RuntimeException('Verified matching backup/restore required.');
|
|
}
|
|
$snapshot = $output.'.before.json';
|
|
if (is_file($snapshot)) throw new RuntimeException('Preserve earlier recovery snapshot; use a new receipt path.');
|
|
file_put_contents($snapshot, json_encode(['database' => $expected, 'rows' => $before], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
|
if (!$qa) chmod($snapshot, 0600);
|
|
foreach ($plan as $index => $entry) {
|
|
if ($entry['already_restored']) continue;
|
|
$item = $manifest['items'][$index];
|
|
$update = $pdo->prepare("update investment_dossiers set publication_status='published',approval_basis='document_owner',approval_evidence=?,published_at=now(),version_no=version_no+1,updated_at=now() where id=? and version_no=?");
|
|
$update->execute([$item['approval_evidence'], $entry['id'], $entry['version']]);
|
|
if ($update->rowCount() !== 1) throw new RuntimeException('Concurrent change.');
|
|
$event = $pdo->prepare("insert into audit_events(source_system,external_id,entity_type,entity_id,actor_user_id,action,before_data,after_data) values('portfolio-visibility-recovery-v1',?,'investment_dossiers',?,NULL,'approved_visibility_restored',?,?)");
|
|
$event->execute([$manifestHash.':'.$entry['id'], $entry['id'], json_encode(['version_no' => $entry['version'], 'publication_status' => 'draft', 'approval_basis' => $before[$index]['approval_basis']], JSON_THROW_ON_ERROR), json_encode(['code' => $item['code'], 'version_no' => $entry['version']+1, 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_sha256' => $item['approval_sha256'], 'content_sha256' => $item['content_sha256'], 'source_record_id' => $item['source_record_id'], 'source_published_at' => $item['original_published_at'], 'instruction' => 'Tempo 23/10/2026: preserve and restore completed approved functionality', 'authenticated_transport' => $qa ? 'isolated-QA' : 'SSH', 'not_a_new_editorial_approval' => true], JSON_THROW_ON_ERROR)]);
|
|
}
|
|
}
|
|
$visible = (int)$pdo->query("select count(*) from investment_dossiers d where publication_status='published' and (exists(select 1 from projects p where p.id=d.project_id and p.deleted_at is null) or exists(select 1 from economic_clusters c where c.id=d.cluster_id and c.deleted_at is null) or exists(select 1 from development_corridors r where r.id=d.corridor_id and r.deleted_at is null))")->fetchColumn();
|
|
$result = ['database' => $actual, 'qa' => $qa, 'mode' => $apply ? 'applied' : 'preview', 'manifest_sha256' => $manifestHash, 'preview_sha256' => $previewHash, 'scope' => count($plan), 'restored' => $apply ? count(array_filter($plan, fn($r) => !$r['already_restored'])) : 0, 'visible' => $visible, 'data_content_changed' => false, 'new_dossiers_imported' => 0, 'fake_editor_or_reviewer_assigned' => false, 'plan' => $plan];
|
|
if ($apply) $pdo->commit(); else $pdo->rollBack();
|
|
file_put_contents($output, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
|
if (!$qa) chmod($output, 0600);
|
|
unset($result['plan']);
|
|
echo json_encode($result, JSON_THROW_ON_ERROR);
|
|
} catch (Throwable $error) {
|
|
if ($pdo->inTransaction()) $pdo->rollBack();
|
|
throw $error;
|
|
}
|