215 lines
17 KiB
PHP
215 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use App\Services\Malware\MalwareScanner;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
|
|
/** Imports only the retained, accepted INE edition. Publication is a separate action. */
|
|
class TerritoryGeometryImport
|
|
{
|
|
public function fingerprint(): string
|
|
{
|
|
return hash('sha256', implode('', array_map(fn ($f) => hash_file('sha256', $f), [__FILE__, config_path('geometry-import.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'), 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(file_get_contents($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.');
|
|
}
|
|
|
|
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 = file_get_contents($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);
|
|
$source = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless(($source['type'] ?? '') === 'FeatureCollection' && count($source['features'] ?? []) === [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']);
|
|
$normalize = fn ($n) => strtolower(trim(Str::ascii($n)));
|
|
$countryNames = array_map($normalize, [$source['features'][0]['properties']['ADM0_PT'], $source['features'][0]['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 ($source['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();
|
|
$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' : (! $parent ? 'parent_unresolved' : 'name_unresolved')];
|
|
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(file_get_contents($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(),
|
|
'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;
|
|
}
|
|
|
|
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.');
|
|
$bytes = file_get_contents(config('geometry-import.directory').'/moz-adm'.$data['level'].'-ine-20190607.geojson');
|
|
abort_unless(hash('sha256', $bytes) === $plan['source_sha256'], 409);
|
|
$source = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
|
$metadata = config('geometry-import.directory').'/moz-adm'.$data['level'].'-ine-metadata.html';
|
|
$metadataScan = app(MalwareScanner::class)->scan($metadata, filesize($metadata), 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 ($plan['rows'] as $i => $row) {
|
|
if ($row['status'] === 'reuse') {
|
|
$reused[] = $row['geometry_id'];
|
|
}
|
|
if ($row['status'] !== 'insert') {
|
|
continue;
|
|
}
|
|
$feature = $source['features'][$i];
|
|
$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' => 'unique_exact_name_and_matched_parent_after_case_and_diacritics'];
|
|
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'], 'metadata_scan' => $metadataScan, 'published' => false, 'reason' => $data['reason']];
|
|
$this->audit($actor, $batch, 'geometry_import_applied', $result);
|
|
|
|
return $result;
|
|
});
|
|
}
|
|
|
|
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, 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) {
|
|
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.');
|
|
// 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');
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
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']);
|
|
}
|
|
}
|