154 lines
8.9 KiB
PHP
154 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use App\Support\CheckedFile;
|
|
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;
|
|
|
|
/** Nominal schema installation. No imports, backfill, publication, permissions or flags. */
|
|
class EditorialInstallation
|
|
{
|
|
public const SOURCE = 'editorial-schema-installation';
|
|
|
|
public const MIGRATION = '2026_10_18_160000_add_portal_editorial_content';
|
|
|
|
/**
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function files(): array
|
|
{
|
|
return ['app/Http/Controllers/EditorialArticleController.php', 'app/Http/Controllers/PortalManagementController.php', 'app/Http/Controllers/PortalServicesController.php', 'app/Http/Controllers/PublicPortalController.php', 'app/Services/InvestmentCatalogue.php', 'app/Services/LegacyContent.php', 'app/Services/OpportunityClassification.php', 'routes/web.php', 'database/migrations/2026_10_18_160000_add_portal_editorial_content.php', 'resources/js/pages/management/editor.tsx', 'resources/js/pages/portal/article-editor.tsx', 'resources/js/pages/portal/article.tsx', 'resources/js/pages/portal/document.tsx', 'resources/js/pages/portal/manage.tsx', 'app/Services/EditorialInstallation.php', 'resources/views/management/editorial-installation.blade.php', 'resources/views/management/editorial-mfa.blade.php'];
|
|
}
|
|
|
|
public function fingerprint(): string
|
|
{
|
|
$files = array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files());
|
|
foreach (File::allFiles(public_path('build')) as $file) {
|
|
$files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())];
|
|
}
|
|
usort($files, fn ($a, $b) => strcmp($a[0], $b[0]));
|
|
|
|
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, 'Gestão autorizada necessária.');
|
|
if ($write) {
|
|
app(PortalProfileInstallation::class)->authorize($actor, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $target
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
private function evidence(array $target, string $code): array
|
|
{
|
|
$qa = app()->environment('testing') && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test')
|
|
&& config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
|
|
if ($qa) {
|
|
return ['ready' => true, 'mode' => 'isolated_QA'];
|
|
}
|
|
if (app()->environment('testing')) {
|
|
return ['ready' => false, 'mode' => 'invalid_QA'];
|
|
}
|
|
$path = storage_path('app/private/investmoz-v2/editorial/readiness.json');
|
|
$p = is_file($path) ? json_decode(CheckedFile::read($path), true) : [];
|
|
$date = strtotime($p['verified_at'] ?? '');
|
|
$auth = $p['active_authorization'] ?? [];
|
|
$ready = $target['database'] === 'investemoz' && (int) $target['port'] === 5432 && ! config('database.connections.pgsql.url')
|
|
&& in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true)
|
|
&& ($p['target']['database'] ?? '') === $target['database'] && (int) ($p['target']['port'] ?? 0) === (int) $target['port']
|
|
&& ($p['code_hash'] ?? '') === $code && $date !== false && $date <= time() && $date >= time() - 86400
|
|
&& ($p['restore_verified'] ?? false) && ($p['files_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['compatibility_verified'] ?? false)
|
|
&& ($p['restored_boot_verified'] ?? false) && (int) ($p['restore_target']['port'] ?? 0) === 55441 && str_ends_with($p['restore_target']['database'] ?? '', '_test')
|
|
&& ($auth['decision_record'] ?? '') !== '' && ($auth['code_hash'] ?? '') === $code && ($auth['migration'] ?? '') === self::MIGRATION;
|
|
foreach (['dump', 'files'] as $kind) {
|
|
$file = $p[$kind.'_path'] ?? '';
|
|
$ready = $ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '', CheckedFile::sha256($file));
|
|
}
|
|
|
|
return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null];
|
|
}
|
|
|
|
/**
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
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();
|
|
$ledgerCount = DB::table('migrations')->where('migration', self::MIGRATION)->count();
|
|
$ledger = $ledgerCount === 1;
|
|
$column = Schema::hasColumn('portal_resources', 'editorial_content');
|
|
$shape = DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='portal_resources' AND column_name='editorial_content'");
|
|
$correct = $shape && $shape->data_type === 'jsonb' && $shape->is_nullable === 'YES';
|
|
$consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column);
|
|
$reasons = [];
|
|
if (! $consistent) {
|
|
$reasons[] = 'Ledger e coluna divergentes; reconciliar sem instalação parcial.';
|
|
}
|
|
try {
|
|
$this->authorize($actor, true);
|
|
} catch (HttpException $e) {
|
|
$reasons[] = $e->getMessage();
|
|
}
|
|
$evidence = $this->evidence($target, $code);
|
|
if (! $evidence['ready']) {
|
|
$reasons[] = 'Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';
|
|
}
|
|
$state = ['target' => $target, 'migration' => self::MIGRATION, 'code_hash' => $code, 'installed' => $ledger && $column,
|
|
'consistent' => $consistent, 'ready' => $reasons === [], 'reasons' => $reasons, 'evidence' => $evidence];
|
|
|
|
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $input
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
public function apply(User $actor, array $input): array
|
|
{
|
|
$this->authorize($actor, true);
|
|
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', '_token']) !== [], 422, 'Ator, migration ou caminho arbitrário não permitido.');
|
|
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate();
|
|
|
|
return DB::transaction(function () use ($actor, $data) {
|
|
DB::select('SELECT pg_advisory_xact_lock(181026,160000)');
|
|
$requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
|
$old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
|
|
if ($old) {
|
|
$receipt = json_decode($old->after_data, true);
|
|
abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave já utilizada com outro ator ou conteúdo.');
|
|
|
|
return [...$receipt['result'], 'replayed' => true];
|
|
}
|
|
$plan = $this->preview($actor);
|
|
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.');
|
|
abort_unless($plan['ready'], 422, implode(' ', $plan['reasons']));
|
|
if (! $plan['installed']) {
|
|
config(['editorial.authenticated_installation' => true]);
|
|
try {
|
|
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na instalação nominal.');
|
|
} finally {
|
|
config(['editorial.authenticated_installation' => false]);
|
|
}
|
|
}
|
|
abort_unless($this->preview($actor)['installed'], 500, 'Esquema nominal inconsistente.');
|
|
$result = ['migration' => $plan['migration'], 'code_hash' => $plan['code_hash'], 'target' => $plan['target'], 'changed' => ! $plan['installed'], 'business_records_changed' => 0, 'replayed' => false, 'receipt' => $data['idempotency_key']];
|
|
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'portal-editorial-schema', 'entity_id' => null, 'actor_user_id' => $actor->id,
|
|
'correlation_id' => $data['idempotency_key'], 'action' => 'schema', 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]);
|
|
|
|
return $result;
|
|
});
|
|
}
|
|
}
|