Files
Investidor-V2/app/Services/InvestorContextInstallation.php
T
nala cf003c8623
tests / ci (push) Canceled after 0s
versao nrgocios 4
2026-10-18 15:57:06 +02:00

113 lines
7.9 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Installation adds a navigation preference, never an investment register or permission. */
final class InvestorContextInstallation
{
public const SOURCE = 'investor-context-installation';
public function files(): array
{
return ['app/Http/Controllers/InvestmentSubmissionController.php', 'app/Http/Controllers/InvestorContextController.php', 'app/Http/Controllers/LandAssistanceController.php', 'app/Http/Controllers/PublicPortalController.php', 'app/Http/Controllers/VirtualDeskController.php', 'app/Http/Requests/StoreLandAssistanceCaseRequest.php', 'app/Services/InvestorContext.php', 'app/Services/InvestorContextConsumers.php', 'app/Services/InvestorContextInstallation.php', 'app/Services/InvestorPath.php', 'app/Services/VirtualDesk.php', 'database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php', 'resources/js/app.tsx', 'resources/js/components/catalogue-navigation.tsx', 'resources/js/components/dossier-navigation-context.tsx', 'resources/js/components/dossier-prefill.tsx', 'resources/js/components/navigation-context.tsx', 'resources/js/components/portal-grouped-menu.tsx', 'resources/js/components/simulation-form.tsx', 'resources/js/layouts/app/app-sidebar-layout.tsx', 'resources/js/layouts/portal-navigation.tsx', 'resources/js/lib/investor-context.ts', 'resources/js/lib/investor-path.ts', 'resources/js/lib/portal-navigation.ts', 'resources/js/pages/desk/request.tsx', 'resources/js/pages/investment/business-opportunity.tsx', 'resources/js/pages/investment/catalogue.tsx', 'resources/js/pages/investment/detail.tsx', 'resources/js/pages/investment/submission.tsx', 'resources/js/pages/investment/zone.tsx', 'resources/js/pages/investor-path/context-installation.tsx', 'resources/js/pages/investor-path/context-start.tsx', 'resources/js/pages/investor-path/context.tsx', 'resources/js/pages/investor-path/show.tsx', 'resources/js/pages/land-assistance/form.tsx', 'resources/js/pages/portal/services.tsx', 'resources/js/pages/portal/why-invest.tsx', 'routes/investment.php', 'routes/investor-path.php', 'routes/web.php'];
}
public function fingerprint(): string
{
$files = [];
foreach ($this->files() as $p) {
$files[$p] = hash_file('sha256', base_path($p));
}
foreach (File::allFiles(public_path('build')) as $p) {
$files['public/build/'.$p->getRelativePathname()] = hash_file('sha256', $p->getPathname());
}ksort($files);
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
}
private function authorize(User $actor, bool $write = false): void
{
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'), 403);
if ($write) {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
}
public function preview(User $actor): array
{
$this->authorize($actor);
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$code = $this->fingerprint();
$table = Schema::hasTable('investor_navigation_preferences');
$ledger = DB::table('migrations')->where('migration', InvestorContext::MIGRATION)->count();
$consistent = ($table && $ledger === 1) || (! $table && $ledger === 0);
$reasons = [];
if (! $consistent) {
$reasons[] = 'Esquema e registo da migration divergentes; reconciliar sem recriar dados.';
}
try {
$this->authorize($actor, true);
} catch (HttpException $e) {
$reasons[] = $e->getMessage() ?: 'Elevação existente necessária para a instalação excecional.';
}
$isolated = app()->environment('testing') && config('database.connections.pgsql.host') === '127.0.0.1' && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test') && ! config('database.connections.pgsql.url');
$proofPath = storage_path('app/private/investmoz-v2/investor-context/readiness.json');
$proof = is_file($proofPath) ? json_decode(file_get_contents($proofPath), true) : [];
$fresh = ! app()->environment('testing') && ($proof['code_hash'] ?? '') === $code && ($proof['target'] ?? []) === $target && ($proof['authorization_record'] ?? '') !== '' && ($proof['migration'] ?? '') === InvestorContext::MIGRATION
&& ($proof['restore_verified'] ?? false) && ($proof['restored_boot_verified'] ?? false) && ($proof['compatibility_verified'] ?? false) && strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time();
foreach (['dump', 'files'] as $kind) {
$p = $proof[$kind.'_path'] ?? '';
$fresh = $fresh && is_file($p) && hash_equals($proof[$kind.'_sha256'] ?? '', hash_file('sha256', $p));
}
if (! $isolated && ! $fresh) {
$reasons[] = 'Autorização específica, hashes e backup/restauro/arranque frescos desta candidata necessários.';
}
$state = ['target' => $target, 'migration' => InvestorContext::MIGRATION, 'code_hash' => $code, 'installed' => $table && $ledger === 1, 'consistent' => $consistent, 'ready' => ! $reasons, 'reasons' => $reasons, 'business_records_changed' => 0];
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
}
public function apply(User $actor, array $input): array
{
$this->authorize($actor, true);
abort_if(array_diff(array_keys($input), ['hash', 'operation_key', 'confirm', '_token']), 422, 'Migration, ator ou caminho arbitrário recusado.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $data) {
DB::select('SELECT pg_advisory_xact_lock(161026,150000)');
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['operation_key'])->first();
if ($old) {
$saved = json_decode($old->after_data, true);
abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($saved['request_hash'], $hash), 409);
return [...$saved['result'], 'replayed' => true];
}
$plan = $this->preview($actor);
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada.');
abort_unless($plan['ready'], 422, implode(' ', $plan['reasons']));
if (! $plan['installed']) {
config(['investor-context.authenticated_installation' => true]);
try {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.InvestorContext::MIGRATION.'.php', '--force' => true]) === 0, 500);
} finally {
config(['investor-context.authenticated_installation' => false]);
}
}
abort_unless(Schema::hasTable('investor_navigation_preferences'), 500);
$result = ['migration' => InvestorContext::MIGRATION, 'changed' => ! $plan['installed'], 'business_records_changed' => 0, 'replayed' => false];
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'navigation-preference', 'actor_user_id' => $actor->id, 'correlation_id' => $data['operation_key'], 'action' => 'install', 'after_data' => json_encode(['request_hash' => $hash, 'result' => $result], JSON_THROW_ON_ERROR)]);
return $result;
});
}
}