113 lines
7.9 KiB
PHP
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;
|
|
});
|
|
}
|
|
}
|