163 lines
7.1 KiB
PHP
163 lines
7.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\SimulationFormDraft;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
final class SimulationFormDrafts
|
|
{
|
|
/** @param array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} $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<string,mixed>, economic_snapshot: array<string,mixed>} $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<string,mixed> */
|
|
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;
|
|
}
|
|
}
|