, economic_snapshot: array} $data */ public function create(array $data, Request $request, string $key): array { [$actorId, $tokenHash] = $this->context($request); $scope = $actorId === null ? 'anonymous:'.$tokenHash : 'user:'.$actorId; $keyHash = hash_hmac('sha256', strtolower($key), (string) config('app.key')); $canonicalPayload = json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); $payloadHash = hash('sha256', $canonicalPayload); return DB::transaction(function () use ($actorId, $tokenHash, $scope, $keyHash, $payloadHash, $canonicalPayload, $data): array { $this->setContext($actorId, $tokenHash); DB::select('select pg_advisory_xact_lock(261010, hashtext(?))', [$scope]); $query = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate(); $query = $actorId === null ? $query->where('anonymous_token_hash', $tokenHash) : $query->where('owner_user_id', $actorId); $draft = $query->first(); if ($draft) { if (hash_equals($draft->create_key_hash, $keyHash)) { abort_unless(hash_equals($draft->create_payload_hash, $payloadHash), 409, 'Idempotency-Key já foi utilizada com outros dados.'); return ['draft' => $draft, 'replayed' => true]; } throw new HttpException(409, 'Já existe um rascunho activo. Carregue-o antes de guardar novas alterações.'); } $draft = SimulationFormDraft::query()->create([ 'id' => (string) Str::uuid(), 'owner_user_id' => $actorId, 'anonymous_token_hash' => $tokenHash, 'version_no' => 1, 'create_key_hash' => $keyHash, 'create_payload_hash' => $payloadHash, 'snapshot_sha256' => $payloadHash, 'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']), 'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']), ]); return ['draft' => $draft, 'replayed' => false]; }, 3); } /** @param array{personal_snapshot: array, economic_snapshot: array} $data */ public function update(string $id, array $data, Request $request): SimulationFormDraft { [$actorId, $tokenHash] = $this->context($request); return DB::transaction(function () use ($id, $data, $request, $actorId, $tokenHash): SimulationFormDraft { $this->setContext($actorId, $tokenHash); $draft = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate()->findOrFail($id); $this->requireMatch($request, $draft); $canonicalPayload = json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); $draft->update([ 'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']), 'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']), 'snapshot_sha256' => hash('sha256', $canonicalPayload), 'version_no' => $draft->version_no + 1, ]); return $draft->refresh(); }, 3); } public function current(Request $request): ?SimulationFormDraft { [$actorId, $tokenHash] = $this->context($request); return DB::transaction(function () use ($actorId, $tokenHash): ?SimulationFormDraft { $this->setContext($actorId, $tokenHash); $query = SimulationFormDraft::query()->whereNull('deleted_at'); return ($actorId === null ? $query->where('anonymous_token_hash', $tokenHash) : $query->where('owner_user_id', $actorId))->first(); }); } public function find(string $id, Request $request): SimulationFormDraft { [$actorId, $tokenHash] = $this->context($request); return DB::transaction(function () use ($id, $actorId, $tokenHash): SimulationFormDraft { $this->setContext($actorId, $tokenHash); return SimulationFormDraft::query()->whereNull('deleted_at')->findOrFail($id); }); } public function delete(string $id, Request $request): void { [$actorId, $tokenHash] = $this->context($request); DB::transaction(function () use ($id, $request, $actorId, $tokenHash): void { $this->setContext($actorId, $tokenHash); $draft = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate()->findOrFail($id); $this->requireMatch($request, $draft); $draft->update(['deleted_at' => now()]); }, 3); } /** @return array */ public function present(SimulationFormDraft $draft): array { return [ 'id' => $draft->id, 'version_no' => $draft->version_no, 'snapshot_sha256' => $draft->snapshot_sha256, 'personal_snapshot' => SimulationFormDraft::decryptSnapshot($draft->personal_snapshot), 'economic_snapshot' => SimulationFormDraft::decryptSnapshot($draft->economic_snapshot), 'updated_at' => $draft->updated_at, ]; } public function etag(SimulationFormDraft $draft): string { return '"sfd-'.$draft->id.'-v'.$draft->version_no.'"'; } /** @return array{0:int|null,1:string|null} */ private function context(Request $request): array { $actorId = $request->user()?->id; $token = $request->header('X-Simulation-Token'); abort_unless($actorId !== null || (is_string($token) && strlen($token) >= 32 && strlen($token) <= 256), 422, 'X-Simulation-Token opaco é obrigatório para um rascunho anónimo.'); return [$actorId, $actorId === null ? hash_hmac('sha256', $token, (string) config('app.key')) : null]; } private function setContext(?int $actorId, ?string $tokenHash): void { DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_token_hash', ?, true)", [(string) ($actorId ?? ''), (string) ($tokenHash ?? '')]); } private function requireMatch(Request $request, SimulationFormDraft $draft): void { $provided = $request->header('If-Match'); if (! $provided) { throw new HttpException(428, 'If-Match é obrigatório.'); } if (! hash_equals($this->etag($draft), trim($provided))) { throw new HttpException(412, 'O rascunho foi alterado desde a última leitura.'); } } private function canonical(mixed $value): mixed { if (! is_array($value)) { return $value; } if (! array_is_list($value)) { ksort($value); } foreach ($value as $key => $item) { $value[$key] = $this->canonical($item); } return $value; } }