| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- <?php
- declare(strict_types=1);
- namespace App\Storage;
- use App\App\Bootstrap;
- use RuntimeException;
- final class JsonStore
- {
- /** @var array<string, mixed> */
- private array $app;
- public function __construct()
- {
- $this->app = Bootstrap::config('app');
- }
- public function emailKey(string $email): string
- {
- return hash('sha256', strtolower(trim($email)));
- }
- /** @return array<string, mixed>|null */
- public function getDraft(string $email): ?array
- {
- $path = $this->draftPath($email);
- if (!is_file($path)) {
- return null;
- }
- return $this->readJsonFile($path);
- }
- /** @return array<string, mixed>|null */
- public function getSubmissionByEmail(string $email): ?array
- {
- $path = $this->submissionPath($email);
- if (!is_file($path)) {
- return null;
- }
- return $this->readJsonFile($path);
- }
- public function hasSubmission(string $email): bool
- {
- return is_file($this->submissionPath($email));
- }
- /**
- * @param array<string, mixed> $draft
- * @return array<string, mixed>
- */
- public function saveDraft(string $email, array $draft): array
- {
- $now = date('c');
- $expires = date('c', time() + ((int) ($this->app['retention']['draft_days'] ?? 14) * 86400));
- $current = $this->getDraft($email) ?? [];
- $payload = [
- 'email' => strtolower(trim($email)),
- 'application_key' => $this->emailKey($email),
- 'status' => 'draft',
- 'created_at' => $current['created_at'] ?? $now,
- 'updated_at' => $now,
- 'expires_at' => $expires,
- 'step' => $draft['step'] ?? ($current['step'] ?? 1),
- 'form_data' => array_merge((array) ($current['form_data'] ?? []), (array) ($draft['form_data'] ?? [])),
- 'uploads' => $this->mergeUploads((array) ($current['uploads'] ?? []), (array) ($draft['uploads'] ?? [])),
- ];
- $this->writeJsonFile($this->draftPath($email), $payload);
- return $payload;
- }
- /**
- * Replaces the full draft payload for an email (no upload merge).
- *
- * @param array<string, mixed> $draft
- * @return array<string, mixed>
- */
- public function replaceDraft(string $email, array $draft): array
- {
- $now = date('c');
- $expires = date('c', time() + ((int) ($this->app['retention']['draft_days'] ?? 14) * 86400));
- $current = $this->getDraft($email) ?? [];
- $payload = [
- 'email' => strtolower(trim($email)),
- 'application_key' => $this->emailKey($email),
- 'status' => 'draft',
- 'created_at' => $current['created_at'] ?? $now,
- 'updated_at' => $now,
- 'expires_at' => $expires,
- 'step' => $draft['step'] ?? ($current['step'] ?? 1),
- 'form_data' => (array) ($draft['form_data'] ?? []),
- 'uploads' => (array) ($draft['uploads'] ?? []),
- ];
- $this->writeJsonFile($this->draftPath($email), $payload);
- return $payload;
- }
- /**
- * @param array<string, mixed> $submission
- * @return array<string, mixed>
- */
- public function saveSubmission(string $email, array $submission): array
- {
- $now = date('c');
- $expires = date('c', time() + ((int) ($this->app['retention']['submission_days'] ?? 90) * 86400));
- $draft = $this->getDraft($email) ?? [];
- $payload = [
- 'email' => strtolower(trim($email)),
- 'application_key' => $this->emailKey($email),
- 'status' => 'submitted',
- 'created_at' => $draft['created_at'] ?? $now,
- 'updated_at' => $now,
- 'submitted_at' => $now,
- 'expires_at' => $expires,
- 'step' => $submission['step'] ?? ($draft['step'] ?? null),
- 'form_data' => (array) ($submission['form_data'] ?? []),
- 'uploads' => (array) ($submission['uploads'] ?? []),
- ];
- $this->writeJsonFile($this->submissionPath($email), $payload);
- $this->deleteDraft($email);
- return $payload;
- }
- public function deleteDraft(string $email): void
- {
- $path = $this->draftPath($email);
- if (is_file($path)) {
- unlink($path);
- }
- }
- /** @return array<string, mixed>|null */
- public function getSubmissionByKey(string $applicationKey): ?array
- {
- $safeKey = $this->normalizeApplicationKey($applicationKey);
- if ($safeKey === null) {
- return null;
- }
- $path = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json';
- if (!is_file($path)) {
- return null;
- }
- return $this->readJsonFile($path);
- }
- /** @return array<int, array<string, mixed>> */
- public function listSubmissions(): array
- {
- $dir = (string) $this->app['storage']['submissions'];
- $files = glob($dir . '/*.json') ?: [];
- $list = [];
- foreach ($files as $file) {
- $item = $this->readJsonFile($file);
- if (!is_array($item)) {
- continue;
- }
- $list[] = $item;
- }
- usort(
- $list,
- static fn (array $a, array $b): int => strcmp((string) ($b['submitted_at'] ?? ''), (string) ($a['submitted_at'] ?? ''))
- );
- return $list;
- }
- public function deleteSubmissionByKey(string $applicationKey): void
- {
- $safeKey = $this->normalizeApplicationKey($applicationKey);
- if ($safeKey === null) {
- return;
- }
- $submissionPath = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json';
- if (is_file($submissionPath)) {
- $submission = $this->readJsonFile($submissionPath);
- if (isset($submission['email']) && is_string($submission['email'])) {
- $this->deleteDraft($submission['email']);
- }
- unlink($submissionPath);
- }
- }
- /**
- * @template T
- * @param callable():T $callback
- * @return T
- */
- public function withEmailLock(string $email, callable $callback): mixed
- {
- $locksDir = (string) $this->app['storage']['locks'];
- if (!is_dir($locksDir)) {
- mkdir($locksDir, 0775, true);
- }
- $lockFile = $locksDir . '/' . $this->emailKey($email) . '.lock';
- $handle = fopen($lockFile, 'c+');
- if ($handle === false) {
- throw new RuntimeException('Could not open lock file.');
- }
- try {
- if (!flock($handle, LOCK_EX)) {
- throw new RuntimeException('Could not acquire lock.');
- }
- return $callback();
- } finally {
- flock($handle, LOCK_UN);
- fclose($handle);
- }
- }
- private function draftPath(string $email): string
- {
- return rtrim((string) $this->app['storage']['drafts'], '/') . '/' . $this->emailKey($email) . '.json';
- }
- private function submissionPath(string $email): string
- {
- return rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $this->emailKey($email) . '.json';
- }
- /** @return array<string, mixed> */
- private function readJsonFile(string $path): array
- {
- $raw = file_get_contents($path);
- if ($raw === false || $raw === '') {
- return [];
- }
- $decoded = json_decode($raw, true);
- if (!is_array($decoded)) {
- return [];
- }
- return $decoded;
- }
- /** @param array<string, mixed> $data */
- private function writeJsonFile(string $path, array $data): void
- {
- $tmpPath = $path . '.tmp';
- file_put_contents($tmpPath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
- rename($tmpPath, $path);
- }
- /**
- * @param array<string, mixed> $existing
- * @param array<string, mixed> $incoming
- * @return array<string, mixed>
- */
- private function mergeUploads(array $existing, array $incoming): array
- {
- foreach ($incoming as $field => $files) {
- if (!is_array($files)) {
- continue;
- }
- $existing[$field] = array_values(array_merge((array) ($existing[$field] ?? []), $files));
- }
- return $existing;
- }
- private function normalizeApplicationKey(string $key): ?string
- {
- $key = strtolower(trim($key));
- if (!preg_match('/^[a-f0-9]{64}$/', $key)) {
- return null;
- }
- return $key;
- }
- }
|