105 lines
6.5 KiB
PHP
105 lines
6.5 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use App\Services\Malware\MalwareScanner;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
// Existing private-library importer, isolated QA only; disclosure is a separate HTTP action.
|
|
require dirname(__DIR__).'/vendor/autoload.php';
|
|
require __DIR__.'/portal-qa-environment.php';
|
|
$target = portalQaEnvironment();
|
|
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
|
$app->make(Kernel::class)->bootstrap();
|
|
$identity = portalQaAssertTarget($target);
|
|
config(['portal.managers' => ['opportunity-manager@example.invalid', 'geo-manager-qa@example.invalid']]);
|
|
$directory = storage_path('app/private/investmoz-v2/portal-audit');
|
|
$stamp = gmdate('Ymd_His');
|
|
set_exception_handler(function (Throwable $error) use ($directory, $stamp): void {
|
|
file_put_contents($directory.'/legacy-sector-import-error-private-'.$stamp.'.json', json_encode(['type' => get_class($error), 'message' => $error->getMessage()], JSON_THROW_ON_ERROR));
|
|
fwrite(STDERR, "QA sector operation failed; inspect private diagnostic.\n");
|
|
exit(1);
|
|
});
|
|
$mode = $argv[1] ?? 'preview';
|
|
throw_unless(in_array($mode, ['preview', 'apply', 'verify', 'replay'], true), RuntimeException::class, 'Unknown operation');
|
|
$manifestName = 'legacy-setores-20261010/manifest.json';
|
|
$staging = storage_path('app/private/document-import-staging');
|
|
$manifest = json_decode(file_get_contents($staging.'/'.$manifestName), true, 512, JSON_THROW_ON_ERROR);
|
|
throw_unless(count($manifest['documents']) === 22, RuntimeException::class, 'Expected original 22-item sector lot');
|
|
$actorFile = json_decode(file_get_contents($directory.'/resource-http-actors-private.json'), true, 512, JSON_THROW_ON_ERROR);
|
|
$actor = User::findOrFail($actorFile['manager']['id']);
|
|
throw_unless($actor->can('manage-portal'), RuntimeException::class, 'Authorized existing library manager required');
|
|
function sectorCounts(): array
|
|
{
|
|
$result = [];
|
|
foreach (['documents', 'portal_resources', 'audit_events', 'document_malware_scans', 'opportunities', 'desk_cases', 'users', 'jobs'] as $table) {
|
|
if (Schema::hasTable($table)) {
|
|
$result[$table] = DB::table($table)->count();
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
$before = sectorCounts();
|
|
$exit = null;
|
|
if (in_array($mode, ['preview', 'apply', 'replay'], true)) {
|
|
$args = ['manifest' => $manifestName];
|
|
if ($mode !== 'preview') {
|
|
$health = app(MalwareScanner::class)->health();
|
|
throw_unless($health['available'] ?? false, RuntimeException::class, 'Real ClamD not healthy; import refused');
|
|
// A fresh read-only preview is mandatory before each application, including repetition.
|
|
$previewExit = Artisan::call('documents:import-batch', $args);
|
|
$preview = Artisan::output();
|
|
file_put_contents($directory.'/legacy-sectors-pre-apply-'.$stamp.'.txt', $preview);
|
|
throw_unless($previewExit === 0 && ! str_contains($preview, 'review required') && ! str_contains($preview, 'not imported'), RuntimeException::class, 'Preview has unresolved conflicts');
|
|
$args += ['--apply' => true, '--actor' => (string) $actor->id];
|
|
config(['queue.default' => 'sync']); // Actual configured ClamD scanner; no fake scan result.
|
|
}
|
|
$exit = Artisan::call('documents:import-batch', $args);
|
|
$output = Artisan::output();
|
|
file_put_contents($directory.'/legacy-sectors-import-'.$mode.'-'.$stamp.'.txt', $output);
|
|
throw_unless($exit === 0 && ! str_contains($output, 'review required') && ! str_contains($output, 'not imported'), RuntimeException::class, 'Import/preview failed');
|
|
}
|
|
$rows = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
|
->whereIn('d.external_id', array_column($manifest['documents'], 'external_id'))
|
|
->orderBy('r.id')->get(['r.id', 'r.document_id', 'r.title', 'r.source_url', 'r.metadata_version', 'r.published', 'd.external_id', 'd.sha256',
|
|
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.status', 'd.file_name', 'd.storage_key', 'd.storage_encryption', 'd.data_classification']);
|
|
$checks = [];
|
|
foreach ($rows as $row) {
|
|
$record = collect($manifest['documents'])->firstWhere('external_id', $row->external_id);
|
|
$path = $staging.'/'.$record['relative_path'];
|
|
$bytes = Storage::disk('local')->get($row->storage_key);
|
|
if ($row->storage_encryption === 'laravel') {
|
|
$bytes = Crypt::decryptString($bytes);
|
|
}
|
|
$checks[] = ['resource_id' => $row->id, 'source_hash_equal' => hash_equals(trim($row->sha256), hash_file('sha256', $path)),
|
|
'stored_original_equal' => hash_equals(trim($row->sha256), hash('sha256', $bytes)), 'clean_scan_for_hash' => $row->malware_scan_status === 'clean'
|
|
&& hash_equals(trim($row->sha256), (string) $row->malware_scanned_sha256)];
|
|
unset($bytes);
|
|
}
|
|
$after = sectorCounts();
|
|
if ($mode === 'preview') {
|
|
throw_unless($before === $after, RuntimeException::class, 'Preview unexpectedly changed database');
|
|
}
|
|
if ($mode !== 'preview') {
|
|
throw_unless($rows->count() === 22 && collect($checks)->every(fn ($c) => $c['source_hash_equal'] && $c['stored_original_equal'] && $c['clean_scan_for_hash']), RuntimeException::class, 'Original/hash/scan checks incomplete');
|
|
if ($mode === 'apply') {
|
|
throw_unless($rows->every(fn ($r) => ! $r->published && $r->data_classification === 'internal'), RuntimeException::class, 'Importer must not publish');
|
|
}
|
|
if ($mode === 'replay') {
|
|
throw_unless($before === $after, RuntimeException::class, 'Repeat import is not idempotent');
|
|
}
|
|
}
|
|
$proof = ['mode' => $mode, 'captured_at' => gmdate('c'), 'target' => $identity, 'manifest_sha256' => hash_file('sha256', $staging.'/'.$manifestName),
|
|
'expected_items' => 22, 'resources' => $rows, 'checks' => $checks, 'before' => $before, 'after' => $after, 'command_exit' => $exit,
|
|
'scanner_class' => get_class(app(MalwareScanner::class)), 'active_writes' => 0, 'publication_is_separate' => true];
|
|
$path = $directory.'/legacy-sectors-'.$mode.'-'.$stamp.'.json';
|
|
file_put_contents($path, json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
|
file_put_contents($directory.'/legacy-sectors-'.$mode.'-qa.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
|
echo json_encode(['mode' => $mode, 'expected_items' => 22, 'resources_found' => $rows->count(), 'before' => $before, 'after' => $after, 'command_exit' => $exit,
|
|
'target' => $identity, 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|