hash_file('sha256', $f), [__FILE__, app_path('Services/RetainedGeoJsonCollection.php'), config_path('geometry-import.php'), config_path('geometry-correspondences.php'), app_path('Http/Controllers/TerritoryGeometryImportController.php'), base_path('routes/web.php'), resource_path('js/pages/management/territory-geometry-import.tsx'), base_path('bootstrap/app.php'), app_path('Providers/FortifyServiceProvider.php'), config_path('fortify.php'), resource_path('js/pages/auth/login.tsx'), resource_path('js/pages/auth/two-factor-challenge.tsx')]))); } private function guard(User $actor, bool $write = false): void { abort_unless($actor->can('manage-portal') || app(ScopedPortalAdministration::class)->administrative($actor), 403); abort_unless(config('geometry-import.enabled'), 503, 'Importação cartográfica não disponibilizada.'); if (! $write) { return; } abort_unless($actor->two_factor_confirmed_at && $actor->two_factor_secret, 403, 'Confirme a autenticação de dois factores para aplicar ou reverter cartografia.'); $target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); $qa = app()->environment('testing') && str_ends_with($target->database, '_test') && (int) $target->port === 55441 && config('database.connections.pgsql.host') === '127.0.0.1'; if ($qa) { return; } $file = storage_path('app/private/investmoz-v2/geometry-import-activation-proof.json'); $p = is_file($file) ? json_decode($this->readFile($file), true) : []; abort_unless(($p['restore_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['ui_passed'] ?? false) && ($p['code_hash'] ?? '') === $this->fingerprint() && ($p['target']['database'] ?? '') === $target->database && (int) ($p['target']['port'] ?? 0) === (int) $target->port && strtotime($p['verified_at'] ?? '') >= time() - 86400 && is_file($p['dump_path'] ?? '') && hash_file('sha256', $p['dump_path']) === ($p['sha256'] ?? null) && ($p['files_verified'] ?? false) && is_file($p['files_path'] ?? '') && hash_file('sha256', $p['files_path']) === ($p['files_sha256'] ?? null), 422, 'Backup/restauro e provas desta revisão e alvo necessários; preparação não autoriza ativação.'); } /** @return array */ public function preview(User $actor, int $level): array { $this->guard($actor); abort_unless(in_array($level, [1, 2, 3], true), 422); $directory = config('geometry-import.directory'); $file = $directory.'/moz-adm'.$level.'-ine-20190607.geojson'; $metadata = $directory.'/moz-adm'.$level.'-ine-metadata.html'; abort_unless(is_file($file) && is_file($metadata), 422, 'Exemplar privado indisponível.'); $text = $this->readFile($metadata); abort_unless(str_contains($text, 'Mozambique INE - Instituto Nacional de Estatistica') && str_contains($text, '2019-06-07'), 422, 'Metadados do exemplar divergentes.'); $sourceHash = hash_file('sha256', $file); $collection = new RetainedGeoJsonCollection($file); $source = $collection->manifest(); abort_unless(($source['type'] ?? '') === 'FeatureCollection' && $source['feature_count'] === [1 => 11, 2 => 159, 3 => 411][$level] && ! ($source['exceededTransferLimit'] ?? false), 422, 'Exemplar incompleto.'); $locations = DB::table('geo_locations')->where('location_type', [1 => 'province', 2 => 'district', 3 => 'admin_post'][$level])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'source_system', 'source_code', 'external_id']); $crosswalk = config('geometry-correspondences'); $correspondences = $crosswalk['rows'][$level] ?? []; abort_if($correspondences && (($crosswalk['edition'] ?? null) !== '2019-06-07' || ($crosswalk['source_sha256'][$level] ?? null) !== $sourceHash), 422, 'Correspondências não pertencem a este exemplar; nenhuma aproximação aplicada.'); $normalize = fn ($n) => strtolower(trim(Str::ascii($n))); $countryNames = array_map($normalize, [$source['first_properties']['ADM0_PT'], $source['first_properties']['ADM0_EN']]); $countries = DB::table('geo_locations')->where('location_type', 'country')->whereNull('deleted_at')->get(['id', 'name'])->filter(fn ($c) => in_array($normalize($c->name), $countryNames, true))->values(); $countryId = count($countries) === 1 ? $countries[0]->id : null; $parentSourceHash = $level > 1 ? hash_file('sha256', $directory.'/moz-adm'.($level - 1).'-ine-20190607.geojson') : null; $rows = []; foreach ($collection->features() as $feature) { $p = $feature['properties']; abort_unless(($p['ADM0_PCODE'] ?? '') === 'MZ', 422, 'Âmbito territorial do exemplar divergente.'); $external = '2019-06-07:adm'.$level.':'.$p['ADM'.$level.'_PCODE']; $parent = $level > 1 ? DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->where('external_id', '2019-06-07:adm'.($level - 1).':'.$p['ADM'.($level - 1).'_PCODE'])->where('properties->source_sha256', $parentSourceHash)->whereNull('deleted_at')->value('geo_location_id') : $countryId; $matches = $locations->filter(fn ($l) => $normalize($l->name) === $normalize($p['ADM'.$level.'_PT']) && $parent && $l->parent_id === $parent)->values(); $correspondence = $correspondences[$p['ADM'.$level.'_PCODE']] ?? null; $matchBasis = 'unique_exact_name_and_matched_parent_after_case_and_diacritics'; if ($correspondence) { abort_unless($correspondence['source_name'] === $p['ADM'.$level.'_PT'] && $correspondence['parent_source_code'] === $p['ADM'.($level - 1).'_PCODE'], 422, 'Nome/pai do exemplar divergente da correspondência explícita.'); $matches = $locations->filter(fn ($l) => $parent && $l->parent_id === $parent && $normalize($l->name) === $normalize($correspondence['target_name']) && $l->source_code === $correspondence['target_source_code'] && $l->source_system === $crosswalk['catalogue_source_system'] && str_starts_with($l->external_id ?? '', $crosswalk['catalogue_sha256'].':'))->values(); $matchBasis = 'explicit_versioned_correspondence'; } $row = ['external_id' => $external, 'name' => $p['ADM'.$level.'_PT'], 'parent_name' => $level > 1 ? $p['ADM'.($level - 1).'_PT'] : null, 'parent_code' => $level > 1 ? $p['ADM'.($level - 1).'_PCODE'] : null, 'candidates' => $matches->all(), 'location_id' => count($matches) === 1 ? $matches[0]->id : null, 'status' => count($matches) === 1 ? 'insert' : (count($matches) > 1 ? ($correspondence ? 'correspondence_conflict' : 'ambiguous_name') : (! $parent ? 'parent_unresolved' : 'name_unresolved')), 'match_basis' => $matchBasis, 'correspondence' => $correspondence, 'correspondence_version' => $correspondence ? $crosswalk['version'] : null]; if ($row['location_id']) { $geometry = json_encode($feature['geometry'], JSON_THROW_ON_ERROR); $valid = DB::selectOne('SELECT ST_IsValid(g) AND NOT ST_IsEmpty(g) AND ST_NDims(g)=2 AND GeometryType(g) IN (\'POLYGON\',\'MULTIPOLYGON\') AND ST_XMin(Box3D(g))>=-180 AND ST_XMax(Box3D(g))<=180 AND ST_YMin(Box3D(g))>=-90 AND ST_YMax(Box3D(g))<=90 AS valid FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) g) q', [$geometry]); if (! $valid->valid) { $row['status'] = 'invalid_geometry'; } else { $existing = DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->where('external_id', $external)->first(); if ($existing) { $props = json_decode($existing->properties, true); $same = DB::selectOne('SELECT ST_Equals(geom,ST_SetSRID(ST_GeomFromGeoJSON(?),4326)) AS same FROM geo_geometries WHERE id=?', [$geometry, $existing->id])->same; $row['status'] = $same && $existing->geo_location_id === $row['location_id'] && ($props['source_sha256'] ?? null) === $sourceHash && ! $existing->deleted_at ? 'reuse' : 'identity_conflict'; $row['geometry_id'] = $existing->id; $row['version'] = $existing->version_no; $row['classification'] = $existing->data_classification; } elseif (DB::table('geo_geometries')->where('geo_location_id', $row['location_id'])->where('geometry_type', strtolower($feature['geometry']['type']))->whereNull('deleted_at')->exists()) { $row['status'] = 'territory_geometry_conflict'; } } } $rows[] = $row; } $scanFile = $directory.'/geometry-source-scans.json'; $scans = is_file($scanFile) ? json_decode($this->readFile($scanFile), true) : []; $scan = null; foreach ($scans['files'] ?? [] as $s) { if ($s['file'] === basename($file) && $s['sha256'] === $sourceHash) { $scan = ($s['scan']['result'] ?? '') === 'clean' ? $s['scan'] : ($s['gis_scan'] ?? null); } } $scanReady = ($scan['result'] ?? '') === 'clean' && ($scan['engine'] ?? '') === 'ClamAV' && strtotime($scans['captured_at'] ?? '') >= time() - config('geometry-import.scan_max_age_seconds'); $target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); $plan = ['level' => $level, 'target' => $target, 'edition' => '2019-06-07', 'source_sha256' => $sourceHash, 'metadata_sha256' => hash_file('sha256', $metadata), 'code_hash' => $this->fingerprint(), 'correspondence_version' => $crosswalk['version'], 'correspondence_sha256' => hash('sha256', json_encode($crosswalk, JSON_THROW_ON_ERROR)), 'expected' => count($rows), 'resolved' => count(array_filter($rows, fn ($r) => in_array($r['status'], ['insert', 'reuse'], true))), 'rows' => $rows, 'scan_ready' => $scanReady, 'scan' => $scan, 'scan_at' => $scans['captured_at'] ?? null, 'published' => false]; $plan['pending'] = $plan['expected'] - $plan['resolved']; $plan['hash'] = hash_hmac('sha256', json_encode([$actor->id, $plan], JSON_THROW_ON_ERROR), config('app.key')); return $plan; } /** @param array $input * @return array */ public function apply(User $actor, array $input): array { $this->guard($actor, true); $data = Validator::make($input, ['level' => 'required|integer|in:1,2,3', 'hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate(); return $this->receipt($actor, 'geometry-import-apply', $data, function () use ($actor, $data) { $plan = $this->preview($actor, (int) $data['level']); abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Correspondências, fonte ou estado alterados; repita a pré-visualização.'); abort_unless($plan['scan_ready'], 423, 'Scan limpo atual ligado ao original necessário.'); abort_if(count(array_filter($plan['rows'], fn ($r) => str_contains($r['status'], 'conflict') || $r['status'] === 'invalid_geometry')) > 0, 409, 'Conflito de identidade/geometria: nenhuma escrita aplicada.'); $sourcePath = config('geometry-import.directory').'/moz-adm'.$data['level'].'-ine-20190607.geojson'; abort_unless(hash_file('sha256', $sourcePath) === $plan['source_sha256'], 409); $collection = new RetainedGeoJsonCollection($sourcePath); $metadata = config('geometry-import.directory').'/moz-adm'.$data['level'].'-ine-metadata.html'; $metadataSize = filesize($metadata); abort_unless(is_int($metadataSize), 422, 'Metadados indisponíveis durante a aplicação.'); $metadataScan = app(MalwareScanner::class)->scan($metadata, $metadataSize, storage_path('app/private')); abort_unless($metadataScan['result'] === 'clean', 423, 'Scan dos metadados do exemplar pendente ou inconclusivo.'); abort_unless(hash_file('sha256', $metadata) === $plan['metadata_sha256'], 409, 'Metadados alterados durante o scan.'); $metadataScan['signatures_at'] = $metadataScan['signatures_at']?->format(DATE_ATOM); $batch = (string) Str::uuid(); $created = []; $reused = []; foreach ($collection->features() as $i => $feature) { $row = $plan['rows'][$i]; if ($row['status'] === 'reuse') { $reused[] = $row['geometry_id']; } if ($row['status'] !== 'insert') { continue; } $id = (string) Str::uuid(); $trace = ['authority' => 'Mozambique INE - Instituto Nacional de Estatistica', 'provider' => 'UNOCHA; exemplar hosted by World Bank', 'source_version' => $plan['edition'], 'source_sha256' => $plan['source_sha256'], 'metadata_sha256' => $plan['metadata_sha256'], 'source_url' => 'https://services.arcgis.com/iQ1dY19aHwbSDYIF/arcgis/rest/services/Mozambique/FeatureServer/'.($data['level'] + 2), 'source_properties' => $feature['properties'], 'source_scan' => $plan['scan'], 'batch_id' => $batch, 'purpose' => 'administrative_boundary', 'investment_site' => false, 'legal_currentness' => 'not_determined', 'match_basis' => $row['match_basis'], 'correspondence' => $row['correspondence'], 'correspondence_version' => $row['correspondence_version'], 'correspondence_sha256' => $plan['correspondence_sha256']]; DB::insert('INSERT INTO geo_geometries(id,source_system,external_id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,?,?,?,ST_SetSRID(ST_GeomFromGeoJSON(?),4326),?::jsonb,?)', [$id, 'ine-cod-ab', $row['external_id'], $row['location_id'], strtolower($feature['geometry']['type']), json_encode($feature['geometry'], JSON_THROW_ON_ERROR), json_encode($trace, JSON_THROW_ON_ERROR), 'internal']); $created[] = ['id' => $id, 'checksum' => $this->snapshotHash($id)]; } $result = ['batch_id' => $batch, 'level' => $data['level'], 'preview_hash' => $plan['hash'], 'expected' => $plan['expected'], 'resolved' => $plan['resolved'], 'created' => $created, 'inserted' => count($created), 'reused' => count($reused), 'reused_ids' => $reused, 'pending' => $plan['pending'], 'source_sha256' => $plan['source_sha256'], 'metadata_sha256' => $plan['metadata_sha256'], 'correspondence_version' => $plan['correspondence_version'], 'correspondence_sha256' => $plan['correspondence_sha256'], 'metadata_scan' => $metadataScan, 'published' => false, 'reason' => $data['reason']]; $this->audit($actor, $batch, 'geometry_import_applied', $result); return $result; }); } /** @param array $input * @return array */ public function reverse(User $actor, string $batch, array $input): array { $this->guard($actor, true); $data = Validator::make([...$input, 'batch_id' => $batch], ['batch_id' => 'required|uuid', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate(); return $this->receipt($actor, 'geometry-import-reverse', $data, function () use ($actor, $data, $batch) { $event = DB::table('audit_events')->where('source_system', 'geometry-import')->where('entity_id', $batch)->where('action', 'geometry_import_applied')->first(); abort_unless($event !== null, 404); $old = DB::table('audit_events')->where('source_system', 'geometry-import')->where('entity_id', $batch)->where('action', 'geometry_import_reversed')->first(); if ($old) { return json_decode($old->after_data, true); } $manifest = json_decode($event->after_data, true); foreach ($manifest['created'] as $row) { $geometry = DB::table('geo_geometries')->where('id', $row['id'])->lockForUpdate()->first(); abort_unless($this->snapshotHash($row['id']) === $row['checksum'], 409, 'Geometria alterada ou publicada; reversão recusada.'); // Source hierarchy is a real import dependency even though it is not a geometry FK. if (in_array((int) $manifest['level'], [1, 2], true)) { $properties = json_decode($geometry->properties, true, 512, JSON_THROW_ON_ERROR); $parentField = 'ADM'.$manifest['level'].'_PCODE'; $code = $properties['source_properties'][$parentField] ?? null; abort_unless($code, 409, 'Correspondência original sem código territorial; reversão exige reconciliação.'); $descendants = DB::table('geo_geometries')->where('source_system', 'ine-cod-ab')->whereNull('deleted_at') ->where('properties->source_version', $properties['source_version']) ->where('properties->source_properties->'.$parentField, $code) ->where(function ($query) use ($manifest) { foreach (range((int) $manifest['level'] + 1, 3) as $level) { $query->orWhere('external_id', 'like', '2019-06-07:adm'.$level.':%'); } }); abort_if($descendants->exists(), 409, 'Limites descendentes dependem desta correspondência. Reverta primeiro postos, depois distritos e províncias; preserve os lotes anteriores.'); } // Enumerate all real FKs, rather than assuming one consumer table. $references = DB::select("SELECT n.nspname AS schema,t.relname AS table_name,a.attname AS column_name FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid JOIN pg_namespace n ON n.oid=t.relnamespace JOIN pg_attribute a ON a.attrelid=c.conrelid AND a.attnum=c.conkey[1] WHERE c.contype='f' AND c.confrelid='geo_geometries'::regclass"); foreach ($references as $ref) { abort_if(DB::table($ref->schema.'.'.$ref->table_name)->where($ref->column_name, $row['id'])->exists(), 409, 'Geometria associada a um consumidor; preserve a relação.'); } } foreach ($manifest['created'] as $row) { DB::table('geo_geometries')->where('id', $row['id'])->update(['deleted_at' => now(), 'updated_at' => now(), 'version_no' => DB::raw('version_no+1')]); } $result = ['batch_id' => $batch, 'reversed' => count($manifest['created']), 'reused_preserved' => $manifest['reused'], 'published' => false, 'reason' => $data['reason']]; $this->audit($actor, $batch, 'geometry_import_reversed', $result); return $result; }); } private function snapshotHash(string $id): string { $r = DB::selectOne('SELECT row_to_json(q)::text AS value FROM (SELECT *,encode(ST_AsEWKB(geom),\'hex\') AS binary_geometry FROM geo_geometries WHERE id=?) q', [$id]); return hash('sha256', $r->value ?? 'missing'); } /** @param array $data * @param callable():array $operation * @return array */ private function receipt(User $actor, string $scope, array $data, callable $operation): array { return DB::transaction(function () use ($actor, $scope, $data, $operation) { DB::select('SELECT pg_advisory_xact_lock(91026,20190607)'); DB::select('SELECT pg_advisory_xact_lock(280926,150000)'); $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); $receipt = DB::table('geo_write_receipts')->where('actor_id', $actor->id)->where('scope', $scope)->where('idempotency_key', $data['idempotency_key'])->first(); if ($receipt) { abort_unless(hash_equals(trim($receipt->request_hash), $hash), 409, 'Chave reutilizada com outro pedido.'); return json_decode($receipt->response, true); } $result = $operation(); DB::table('geo_write_receipts')->insert(['id' => (string) Str::uuid(), 'actor_id' => $actor->id, 'scope' => $scope, 'idempotency_key' => $data['idempotency_key'], 'request_hash' => $hash, 'response' => json_encode($result, JSON_THROW_ON_ERROR), 'http_status' => 200]); return $result; }); } /** @param array $data */ private function audit(User $actor, string $batch, string $action, array $data): void { DB::table('audit_events')->insert(['source_system' => 'geometry-import', 'entity_type' => 'geo_geometries', 'entity_id' => $batch, 'correlation_id' => $batch, 'actor_user_id' => $actor->id, 'action' => $action, 'after_data' => json_encode($data, JSON_THROW_ON_ERROR), 'data_classification' => 'internal']); } private function readFile(string $path): string { $contents = file_get_contents($path); abort_unless(is_string($contents), 422, 'Exemplar/prova indisponível durante a leitura; operação recusada.'); return $contents; } }