428 lines
28 KiB
PHP
428 lines
28 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Jobs\ScanDocumentForMalware;
|
|
use App\Models\User;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
class ImportDocumentBatch extends Command
|
|
{
|
|
protected $signature = 'documents:import-batch {manifest : JSON manifest inside storage/app/private/document-import-staging} {--apply : Register private drafts and queue malware scans} {--actor= : Existing user ID responsible for an applied import} {--pilot-agronegocio : Restrict to the approved two-original pilot} {--sectors-lot20 : Restrict to the named 20-resource sector batch} {--sectors-trio : Restrict to the three named sector articles and reuse the existing image} {--backup-proof= : Fresh verified destination backup receipt for active pilot apply}';
|
|
|
|
protected $description = 'Preview or idempotently import a document batch into the existing private library';
|
|
|
|
private const MAX_BYTES = 10485760;
|
|
|
|
private const PILOT_MANIFEST_SHA256 = '0b720b0a6f7539de1d3eca9e32f89a6b9038848dc0b97ad21ace605154d9ddd8';
|
|
|
|
private const PILOT_HASHES = [
|
|
'legacy-tempo-post-171985-20260420' => '0929184a6d4645b9046512675394ad4f60575273f6dab0955f71cc7474a6a4e2',
|
|
'legacy-tempo-agronegocio-image-5e3840705cf98226' => '5e3840705cf98226d1f189cf665aaa98cb05da96c645159a1332a1d429d642eb',
|
|
];
|
|
|
|
private const SECTORS_MANIFEST_SHA256 = 'ade9a00c031534f4341e8f6bd5e425b502ee03efc9fcfaec4f463a10fb28201b';
|
|
|
|
private const SECTORS_HASHES = [
|
|
'legacy-tempo-post-171981-20260430' => '26a70f16cd511c579f08633655ff312db05122f81e44852dd4b163fa6eef85f1',
|
|
'legacy-tempo-post-172679-20260430' => '2003755b7dac53b43f4608e707321db6d2f29878926a0e74f4cf27627b70a9cb',
|
|
'legacy-tempo-post-172674-20260430' => '3ae780f082a8fa7c767635cce98563f5bc027c9656c9f41f8788843a17fa1f54',
|
|
'legacy-tempo-post-171990-20260616' => '8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72',
|
|
'legacy-tempo-post-172263-20260414' => '538495cd5b31047ac1c5993fdaf298ec1303330368248b251c87d201d31ff211',
|
|
'legacy-tempo-post-172256-20260414' => 'dcc29cbd36cf57e0a5c88dd312c84ee03c32a3d18ae9216d3715dc7b5a6960bf',
|
|
'legacy-tempo-post-172261-20260414' => '88177fccd78d8516dba670c6f71272af062cc981d52d97adefe5bf77c613b16a',
|
|
'legacy-tempo-post-172258-20260417' => '48314a576dea2f2dadf47d8f1eadd7ab58030606b02eebe17aa9bd6badffb7e9',
|
|
'legacy-tempo-post-172253-20260414' => 'e90e39e49ba65bbbcbbc303c6eca456e445238daf9acc935ab03690819281678',
|
|
'legacy-tempo-post-172240-20260414' => '8c5e91c937032680883793a12844f1128f7ac99e97c63b68c23d1396e009ba9d',
|
|
'legacy-tempo-post-172237-20260417' => 'a62a43e31da32795a7286f4c152a93d5dc829233e72023b00ea61fd8e2fcb58c',
|
|
'legacy-tempo-post-172233-20260417' => '345dfd72d8ebf459eae8eb025d699afddbae751df55ffa8d053568cd08096c97',
|
|
'legacy-tempo-post-172230-20260414' => '4f4570f7bbd5599cb925ed996c1734a23582362fe485736931a53b9266565d4e',
|
|
'legacy-tempo-post-172223-20260414' => '514a9d88c097d234038fa6341f5a5b01e8938cb6a9535a807145c84c37010a4d',
|
|
'legacy-tempo-post-172219-20260414' => '0d468ae0de9a76d2b140bc8cb5c555f29626fb6422287c917762bec3e615a70c',
|
|
'legacy-tempo-post-171992-20260430' => '05eb492dd6cfca6a7f305b633658a16723c315168f4b2c75103f22d1fac84032',
|
|
'legacy-tempo-image-0a2c50d91dad3a60' => '0a2c50d91dad3a6007544634540e0f61b11d3506682a5a19066bae03cdcba490',
|
|
'legacy-tempo-image-28f41f12e10ab345' => '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa',
|
|
'legacy-tempo-image-fe9a2e382e385b35' => 'fe9a2e382e385b35f6e61ae320e199a979678677fd327fb7d4b07d8ce0111c11',
|
|
'legacy-tempo-image-8e58544e49e719f9' => '8e58544e49e719f9c865c91e07ef1030155a9e3d59260e6dc93e9c9e25674e39',
|
|
];
|
|
|
|
private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8';
|
|
private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa';
|
|
private const TRIO_HASHES = [
|
|
'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941',
|
|
'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5',
|
|
'legacy-tempo-post-173213-20260616' => '0ba605ae43dec7f685610f828df884bc82cb4da67131fc9783b5cdbe4cc6d9f0',
|
|
];
|
|
|
|
public function handle(): int
|
|
{
|
|
$staging = storage_path('app/private/document-import-staging');
|
|
$manifestPath = realpath($staging.DIRECTORY_SEPARATOR.$this->argument('manifest'));
|
|
$stagingReal = realpath($staging);
|
|
if (! $manifestPath || ! $stagingReal || ! str_starts_with(strtolower($manifestPath), strtolower($stagingReal.DIRECTORY_SEPARATOR)) || ! is_file($manifestPath)) {
|
|
$this->error('Manifest is missing or outside the private document-import staging directory.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
foreach ([
|
|
'documents' => ['id', 'source_system', 'external_id', 'version_no', 'data_classification', 'file_name', 'media_type', 'storage_key', 'sha256', 'status', 'size_bytes', 'issuer', 'issued_on', 'uploaded_by', 'rights_status', 'original_source', 'malware_scan_status'],
|
|
'portal_resources' => ['document_id', 'title', 'category', 'topic', 'published', 'source_reference', 'approved_by', 'description', 'document_type', 'document_number', 'document_date', 'source_url', 'language', 'version_label', 'source_confirmation', 'legal_status', 'rights_status', 'document_identity_key', 'metadata_version', 'created_by'],
|
|
] as $table => $columns) {
|
|
if (! Schema::hasColumns($table, $columns)) {
|
|
$this->error('Document-library schema is incomplete; run the documented schema compatibility check before importing.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
}
|
|
|
|
try {
|
|
$manifestBytes = (string) file_get_contents($manifestPath);
|
|
$manifest = json_decode($manifestBytes, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (Throwable) {
|
|
$this->error('Manifest is not valid JSON.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
if (! is_array($manifest) || ! isset($manifest['documents']) || ! is_array($manifest['documents']) || count($manifest['documents']) > 100) {
|
|
$this->error('Manifest must contain at most 100 documents in a documents array.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$apply = (bool) $this->option('apply');
|
|
$actorId = $this->option('actor');
|
|
$pilot = (bool) $this->option('pilot-agronegocio');
|
|
$sectors = (bool) $this->option('sectors-lot20');
|
|
$trio = (bool) $this->option('sectors-trio');
|
|
if ((int) $pilot + (int) $sectors + (int) $trio > 1) {
|
|
$this->error('Select exactly one named batch; agribusiness scope is unchanged.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
if ($pilot || $sectors || $trio) {
|
|
try {
|
|
$this->validatePilot($manifestPath, $manifestBytes, $manifest, $stagingReal, $apply, $actorId, $sectors, $trio);
|
|
} catch (Throwable $exception) {
|
|
$this->error('Pilot refused before import: '.$exception->getMessage());
|
|
|
|
return self::FAILURE;
|
|
}
|
|
}
|
|
if ($apply && ! $pilot && ! $sectors && ! $trio) {
|
|
// CLI attribution is not an authenticated active installation. Rehearsals stay isolated.
|
|
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
|
if (! app()->environment('testing') || ! str_ends_with($target->database, '_test') || (int) $target->port !== 55441
|
|
|| config('database.connections.pgsql.host') !== '127.0.0.1' || config('database.connections.pgsql.url')) {
|
|
$this->error('CLI apply is restricted to isolated QA. Active imports require the authenticated activation mechanism; --actor is attribution only.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
}
|
|
if ($apply && (! is_numeric($actorId) || ! DB::table('users')->where('id', (int) $actorId)->exists())) {
|
|
$this->error('Apply requires --actor=<existing-user-id>; no author identity will be fabricated.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
if ($apply && ! User::findOrFail((int) $actorId)->can('manage-portal')) {
|
|
$this->error('QA import requires an authorized library manager; no permission is granted by naming an account.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
$disk = Storage::disk('local');
|
|
$seenHashes = [];
|
|
$seenExternalIds = [];
|
|
$dispatch = [];
|
|
$failures = 0;
|
|
|
|
foreach ($manifest['documents'] as $index => $record) {
|
|
$label = is_array($record) ? (string) ($record['external_id'] ?? '#'.($index + 1)) : '#'.($index + 1);
|
|
try {
|
|
$validated = $this->validateRecord($record, $stagingReal);
|
|
if (($pilot || $sectors || $trio) && ! hash_equals(($trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES))[$validated['external_id']] ?? '', $validated['sha256'])) {
|
|
throw new RuntimeException('Approved pilot bytes changed after preview validation.');
|
|
}
|
|
$hash = $validated['sha256'];
|
|
if (isset($seenHashes[$hash])) {
|
|
$this->line($label.' | duplicate bytes within batch; references item '.$seenHashes[$hash]);
|
|
|
|
continue;
|
|
}
|
|
$seenHashes[$hash] = $label;
|
|
if (isset($seenExternalIds[$validated['external_id']])) {
|
|
$this->warn($label.' | external_id already occurs in this batch with different bytes; identity review required');
|
|
|
|
continue;
|
|
}
|
|
$seenExternalIds[$validated['external_id']] = $hash;
|
|
|
|
$existing = DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->first();
|
|
if ($existing) {
|
|
$this->line($label.' | already imported by SHA-256; document='.$existing->id.'; no duplicate');
|
|
|
|
continue;
|
|
}
|
|
$externalMatch = DB::table('documents')->where('source_system', 'document-batch-import')
|
|
->where('external_id', $validated['external_id'])->whereNull('deleted_at')->first();
|
|
if ($externalMatch) {
|
|
$this->warn($label.' | external_id already belongs to document '.$externalMatch->id.' with different bytes; version review required');
|
|
|
|
continue;
|
|
}
|
|
|
|
$identity = $this->identity($validated);
|
|
$identityMatch = DB::table('portal_resources')->where('document_identity_key', $identity)->first();
|
|
if ($identityMatch) {
|
|
$this->warn($label.' | identity matches resource '.$identityMatch->id.' but bytes differ; version review required');
|
|
|
|
continue;
|
|
}
|
|
|
|
$this->line($label.' | '.($apply ? 'will import as private draft' : 'preview: private draft')." | bytes={$validated['size_bytes']} | sha256={$hash}");
|
|
if (! $apply) {
|
|
continue;
|
|
}
|
|
|
|
$documentId = (string) Str::uuid();
|
|
$storageKey = 'portal-documents/'.$documentId;
|
|
$source = fopen($validated['source_path'], 'rb');
|
|
if (! is_resource($source)) {
|
|
throw new RuntimeException('Source could not be opened.');
|
|
}
|
|
try {
|
|
$stored = $disk->put($storageKey, $source);
|
|
} finally {
|
|
fclose($source);
|
|
}
|
|
$storedPath = $disk->path($storageKey);
|
|
$storedHash = $stored && is_file($storedPath) ? hash_file('sha256', $storedPath) : false;
|
|
if (! $stored || ! is_string($storedHash) || ! hash_equals($hash, $storedHash)) {
|
|
$disk->delete($storageKey);
|
|
throw new RuntimeException('Private copy failed the checksum verification.');
|
|
}
|
|
|
|
try {
|
|
$resourceId = DB::transaction(function () use ($validated, $documentId, $storageKey, $hash, $identity, $actorId): int {
|
|
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch:'.$hash]);
|
|
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch-identity:'.$identity]);
|
|
if (DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->exists()) {
|
|
throw new RuntimeException('Matching bytes were registered concurrently; rerun preview.');
|
|
}
|
|
if (DB::table('portal_resources')->where('document_identity_key', $identity)->exists()) {
|
|
throw new RuntimeException('A matching document identity was registered concurrently; version review required.');
|
|
}
|
|
$now = now();
|
|
DB::table('documents')->insert([
|
|
'id' => $documentId, 'source_system' => 'document-batch-import', 'external_id' => $validated['external_id'],
|
|
'version_no' => 1, 'data_classification' => 'internal', 'file_name' => $validated['file_name'],
|
|
'media_type' => $validated['media_type'], 'storage_key' => $storageKey, 'sha256' => $hash,
|
|
'status' => 'quarantined', 'size_bytes' => $validated['size_bytes'], 'issuer' => $validated['issuer'],
|
|
'issued_on' => $validated['document_date'], 'uploaded_by' => (int) $actorId, 'rights_status' => 'unconfirmed',
|
|
'original_source' => $validated['source_url'], 'malware_scan_status' => 'pending',
|
|
'created_at' => $now, 'updated_at' => $now,
|
|
]);
|
|
$resourceId = DB::table('portal_resources')->insertGetId([
|
|
'document_id' => $documentId, 'title' => $validated['title'], 'category' => $validated['category'],
|
|
'topic' => $validated['topic'], 'legislation' => $validated['legislation'], 'essential' => false,
|
|
'published' => false, 'source_reference' => $validated['source_reference'], 'approved_by' => null,
|
|
'description' => $validated['description'], 'document_type' => $validated['document_type'],
|
|
'document_number' => $validated['document_number'], 'document_date' => $validated['document_date'],
|
|
'source_url' => $validated['source_url'], 'language' => $validated['language'],
|
|
'version_label' => $validated['version_label'], 'territorial_scope' => 'unknown',
|
|
'source_confirmation' => 'unconfirmed', 'legal_status' => 'unknown', 'rights_status' => 'unconfirmed',
|
|
'document_identity_key' => $identity, 'metadata_version' => 1, 'created_by' => (int) $actorId,
|
|
'last_reviewed_by' => null, 'independent_review' => false, 'created_at' => $now, 'updated_at' => $now,
|
|
]);
|
|
$resource = DB::table('portal_resources')->where('id', $resourceId)->first();
|
|
DB::table('portal_resource_revisions')->insert([
|
|
'resource_id' => $resourceId, 'revision_no' => 1, 'change_type' => 'created',
|
|
'snapshot' => json_encode(['resource' => $resource, 'associations' => []], JSON_THROW_ON_ERROR),
|
|
'actor_user_id' => (int) $actorId, 'created_at' => $now,
|
|
]);
|
|
DB::table('audit_events')->insert([
|
|
'id' => (string) Str::uuid(), 'source_system' => 'document-batch-import',
|
|
'external_id' => $validated['external_id'], 'data_classification' => 'internal',
|
|
'actor_user_id' => (int) $actorId, 'entity_type' => 'document', 'entity_id' => $documentId,
|
|
'action' => 'private_document_batch_imported',
|
|
'after_data' => json_encode(['sha256' => $hash, 'resource_id' => $resourceId, 'published' => false], JSON_THROW_ON_ERROR),
|
|
'occurred_at' => $now, 'created_at' => $now,
|
|
]);
|
|
|
|
return $resourceId;
|
|
});
|
|
$dispatch[] = $documentId;
|
|
$this->info($label.' | private draft created | resource='.$resourceId.'; pending scan; not published');
|
|
} catch (Throwable $exception) {
|
|
$disk->delete($storageKey);
|
|
throw $exception;
|
|
}
|
|
} catch (Throwable $exception) {
|
|
$this->error($label.' | not imported: '.$exception->getMessage());
|
|
$failures++;
|
|
}
|
|
}
|
|
|
|
foreach ($dispatch as $documentId) {
|
|
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
|
}
|
|
$this->line($apply ? 'New scans queued: '.count($dispatch).'.' : 'Preview only; database and storage unchanged.');
|
|
|
|
return $failures > 0 ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
/** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate. */
|
|
private function validatePilot(string $manifestPath, string $manifestBytes, array $manifest, string $staging, bool $apply, mixed $actorId, bool $sectors = false, bool $trio = false): void
|
|
{
|
|
$allowed = $trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES);
|
|
$manifestName = $trio ? 'legacy-trio-20261015/manifest.json' : ($sectors ? 'legacy-setores-lote20-20261015/manifest.json' : 'legacy-agronegocio/manifest.json');
|
|
$manifestHash = $trio ? self::TRIO_MANIFEST_SHA256 : ($sectors ? self::SECTORS_MANIFEST_SHA256 : self::PILOT_MANIFEST_SHA256);
|
|
if ($manifestPath !== realpath($staging.'/'.$manifestName)
|
|
|| ! hash_equals($manifestHash, hash('sha256', $manifestBytes))
|
|
|| count($manifest['documents']) !== count($allowed)) {
|
|
throw new RuntimeException('Only the exact selected manifest and its fixed resource list are allowed.');
|
|
}
|
|
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
|
$host = config('database.connections.pgsql.host');
|
|
$qa = app()->environment('testing') && str_ends_with($target->database, '_test') && (int) $target->port === 55441;
|
|
$active = ! app()->environment('testing') && $target->database === 'investemoz' && (int) $target->port === 5432;
|
|
if ((! $qa && ! $active) || ! in_array($host, ['127.0.0.1', 'localhost'], true) || config('database.connections.pgsql.url')) {
|
|
throw new RuntimeException('Pilot target identity is not permitted.');
|
|
}
|
|
if ($apply || $actorId !== null) {
|
|
$actor = is_string($actorId) && preg_match('/^[1-9][0-9]*$/D', $actorId) ? User::find((int) $actorId) : null;
|
|
if (! $actor || ! $actor->can('manage-portal')) {
|
|
throw new RuntimeException('A real existing account with effective manage-portal authorization is required.');
|
|
}
|
|
}
|
|
if ($active && $apply) {
|
|
$this->validatePilotBackup($this->option('backup-proof'));
|
|
}
|
|
if ($trio) {
|
|
$images = DB::table('documents as d')->join('portal_resources as r', 'r.document_id', '=', 'd.id')
|
|
->where('d.external_id', 'legacy-tempo-image-28f41f12e10ab345')->where('d.sha256', self::TRIO_REUSED_IMAGE_SHA256)
|
|
->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.data_classification', 'public')
|
|
->where('d.rights_status', 'authorized')->where('r.rights_status', 'authorized')->where('r.published', true)
|
|
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->get(['d.storage_key']);
|
|
if ($images->count() !== 1 || ! Storage::disk('local')->exists($images[0]->storage_key)
|
|
|| ! hash_equals(self::TRIO_REUSED_IMAGE_SHA256, hash_file('sha256', Storage::disk('local')->path($images[0]->storage_key)))) {
|
|
throw new RuntimeException('The shared image must already exist once, published, authorized and clean with the exact original bytes; no image is imported.');
|
|
}
|
|
}
|
|
foreach ($manifest['documents'] as $record) {
|
|
$entry = $this->validateRecord($record, $staging);
|
|
if (! hash_equals($allowed[$entry['external_id']] ?? '', $entry['sha256'])) {
|
|
throw new RuntimeException('Article or image differs from the approved SHA-256.');
|
|
}
|
|
$existing = DB::table('documents')->where('sha256', $entry['sha256'])->whereNull('deleted_at')->first();
|
|
if ($existing && ($existing->external_id !== $entry['external_id']
|
|
|| ! DB::table('portal_resources')->where('document_id', $existing->id)->exists())) {
|
|
throw new RuntimeException('Matching bytes have another identity or no library resource; reconcile explicitly.');
|
|
}
|
|
if (DB::table('documents')->where('external_id', $entry['external_id'])->whereNull('deleted_at')
|
|
->where('sha256', '!=', $entry['sha256'])->exists()
|
|
|| (! $existing && DB::table('portal_resources')->where('document_identity_key', $this->identity($entry))->exists())) {
|
|
throw new RuntimeException('Existing identity/version conflicts with this pilot; no overwrite or partial preflight import.');
|
|
}
|
|
}
|
|
}
|
|
|
|
private function validatePilotBackup(mixed $path): void
|
|
{
|
|
if (! is_string($path) || ! is_file($path)) {
|
|
throw new RuntimeException('Active pilot apply requires a fresh verified backup/restoration receipt.');
|
|
}
|
|
$proof = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
|
$verified = strtotime($proof['verified_at'] ?? '');
|
|
if (! ($proof['restore_verified'] ?? false) || ! ($proof['files_verified'] ?? false)
|
|
|| ($proof['target']['database'] ?? '') !== 'investemoz' || (int) ($proof['target']['port'] ?? 0) !== 5432
|
|
|| ! str_ends_with($proof['restore_target']['database'] ?? '', '_test') || (int) ($proof['restore_target']['port'] ?? 0) !== 55441
|
|
|| $verified === false || $verified > time() || $verified < time() - 86400
|
|
|| ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '')
|
|
|| ! hash_equals($proof['sha256'] ?? '', hash_file('sha256', $proof['dump_path']))
|
|
|| ! hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path']))) {
|
|
throw new RuntimeException('Backup/restoration identity, freshness or integrity does not match the active pilot.');
|
|
}
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function validateRecord(mixed $record, string $stagingRoot): array
|
|
{
|
|
if (! is_array($record)) {
|
|
throw new RuntimeException('Each documents entry must be an object.');
|
|
}
|
|
foreach (['external_id', 'relative_path', 'title', 'issuer'] as $required) {
|
|
if (! isset($record[$required]) || ! is_string($record[$required]) || trim($record[$required]) === '') {
|
|
throw new RuntimeException('Required manifest field is missing: '.$required.'.');
|
|
}
|
|
}
|
|
if (strlen($record['external_id']) > 120 || strlen($record['title']) > 250 || strlen($record['issuer']) > 180) {
|
|
throw new RuntimeException('A metadata value exceeds its permitted length.');
|
|
}
|
|
if (isset($record['file_name']) && (! is_string($record['file_name']) || strlen(basename($record['file_name'])) > 255)) {
|
|
throw new RuntimeException('File name exceeds its permitted length.');
|
|
}
|
|
if (isset($record['document_number']) && (! is_string($record['document_number']) || strlen($record['document_number']) > 120)) {
|
|
throw new RuntimeException('Document number exceeds its permitted length.');
|
|
}
|
|
if (isset($record['document_date'])) {
|
|
if (! is_string($record['document_date']) || ! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $record['document_date'], $dateParts)
|
|
|| ! checkdate((int) $dateParts[2], (int) $dateParts[3], (int) $dateParts[1])) {
|
|
throw new RuntimeException('Document date must use a valid YYYY-MM-DD value.');
|
|
}
|
|
}
|
|
if (isset($record['document_type']) && ! in_array($record['document_type'], ['legislation', 'procedure', 'official_form', 'digital_adaptation', 'guidance_template', 'guide', 'study', 'reference_data', 'unknown'], true)) {
|
|
throw new RuntimeException('Document type is not supported by the catalogue.');
|
|
}
|
|
|
|
$path = realpath($stagingRoot.DIRECTORY_SEPARATOR.$record['relative_path']);
|
|
if (! $path || ! str_starts_with(strtolower($path), strtolower($stagingRoot.DIRECTORY_SEPARATOR)) || ! is_file($path)) {
|
|
throw new RuntimeException('Source file is missing or outside the private staging directory.');
|
|
}
|
|
$size = filesize($path);
|
|
if (! is_int($size) || $size < 1 || $size > self::MAX_BYTES) {
|
|
throw new RuntimeException('File size is outside the 1 byte to 10 MiB import limit.');
|
|
}
|
|
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($path);
|
|
$allowed = ['application/pdf', 'application/json', 'text/plain', 'text/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/png', 'image/jpeg', 'image/webp'];
|
|
if (! is_string($mime) || ! in_array($mime, $allowed, true)) {
|
|
throw new RuntimeException('Detected media type is not supported by the document library.');
|
|
}
|
|
$sha = hash_file('sha256', $path);
|
|
if (! is_string($sha)) {
|
|
throw new RuntimeException('File hash could not be calculated.');
|
|
}
|
|
|
|
$sourceUrl = $record['source_url'] ?? null;
|
|
if ($sourceUrl !== null && (! is_string($sourceUrl) || strlen($sourceUrl) > 2000 || ! filter_var($sourceUrl, FILTER_VALIDATE_URL) || strtolower((string) parse_url($sourceUrl, PHP_URL_SCHEME)) !== 'https')) {
|
|
throw new RuntimeException('Source URL, when supplied, must be a valid HTTPS URL.');
|
|
}
|
|
|
|
return [
|
|
...$record, 'source_path' => $path, 'file_name' => basename((string) ($record['file_name'] ?? $path)),
|
|
'size_bytes' => $size, 'media_type' => $mime, 'sha256' => $sha,
|
|
'category' => (string) ($record['category'] ?? 'general'), 'topic' => (string) ($record['topic'] ?? 'unclassified'),
|
|
'legislation' => (bool) ($record['legislation'] ?? false), 'document_type' => (string) ($record['document_type'] ?? 'unknown'),
|
|
'document_number' => $record['document_number'] ?? null, 'document_date' => $record['document_date'] ?? null,
|
|
'language' => (string) ($record['language'] ?? 'unknown'), 'version_label' => $record['version_label'] ?? null,
|
|
'source_url' => $record['source_url'] ?? null, 'source_reference' => $record['source_reference'] ?? 'Origem por completar.',
|
|
'description' => $record['description'] ?? 'Documento importado para revisão documental.',
|
|
];
|
|
}
|
|
|
|
/** @param array<string, mixed> $record */
|
|
private function identity(array $record): string
|
|
{
|
|
return hash('sha256', mb_strtolower(implode('|', array_map('trim', [
|
|
(string) $record['issuer'], (string) ($record['document_number'] ?? ''), (string) ($record['document_date'] ?? ''),
|
|
(string) $record['language'], (string) ($record['version_label'] ?? $record['title']),
|
|
]))));
|
|
}
|
|
}
|