JsonStore.php 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Storage;
  4. use App\App\Bootstrap;
  5. use RuntimeException;
  6. final class JsonStore
  7. {
  8. /** @var array<string, mixed> */
  9. private array $app;
  10. public function __construct()
  11. {
  12. $this->app = Bootstrap::config('app');
  13. }
  14. public function emailKey(string $email): string
  15. {
  16. return hash('sha256', strtolower(trim($email)));
  17. }
  18. /** @return array<string, mixed>|null */
  19. public function getDraft(string $email): ?array
  20. {
  21. $path = $this->draftPath($email);
  22. if (!is_file($path)) {
  23. return null;
  24. }
  25. return $this->readJsonFile($path);
  26. }
  27. /** @return array<string, mixed>|null */
  28. public function getSubmissionByEmail(string $email): ?array
  29. {
  30. $path = $this->submissionPath($email);
  31. if (!is_file($path)) {
  32. return null;
  33. }
  34. return $this->readJsonFile($path);
  35. }
  36. public function hasSubmission(string $email): bool
  37. {
  38. return is_file($this->submissionPath($email));
  39. }
  40. /**
  41. * @param array<string, mixed> $draft
  42. * @return array<string, mixed>
  43. */
  44. public function saveDraft(string $email, array $draft): array
  45. {
  46. $now = date('c');
  47. $expires = date('c', time() + ((int) ($this->app['retention']['draft_days'] ?? 14) * 86400));
  48. $current = $this->getDraft($email) ?? [];
  49. $payload = [
  50. 'email' => strtolower(trim($email)),
  51. 'application_key' => $this->emailKey($email),
  52. 'status' => 'draft',
  53. 'created_at' => $current['created_at'] ?? $now,
  54. 'updated_at' => $now,
  55. 'expires_at' => $expires,
  56. 'step' => $draft['step'] ?? ($current['step'] ?? 1),
  57. 'form_data' => array_merge((array) ($current['form_data'] ?? []), (array) ($draft['form_data'] ?? [])),
  58. 'uploads' => $this->mergeUploads((array) ($current['uploads'] ?? []), (array) ($draft['uploads'] ?? [])),
  59. ];
  60. $this->writeJsonFile($this->draftPath($email), $payload);
  61. return $payload;
  62. }
  63. /**
  64. * Replaces the full draft payload for an email (no upload merge).
  65. *
  66. * @param array<string, mixed> $draft
  67. * @return array<string, mixed>
  68. */
  69. public function replaceDraft(string $email, array $draft): array
  70. {
  71. $now = date('c');
  72. $expires = date('c', time() + ((int) ($this->app['retention']['draft_days'] ?? 14) * 86400));
  73. $current = $this->getDraft($email) ?? [];
  74. $payload = [
  75. 'email' => strtolower(trim($email)),
  76. 'application_key' => $this->emailKey($email),
  77. 'status' => 'draft',
  78. 'created_at' => $current['created_at'] ?? $now,
  79. 'updated_at' => $now,
  80. 'expires_at' => $expires,
  81. 'step' => $draft['step'] ?? ($current['step'] ?? 1),
  82. 'form_data' => (array) ($draft['form_data'] ?? []),
  83. 'uploads' => (array) ($draft['uploads'] ?? []),
  84. ];
  85. $this->writeJsonFile($this->draftPath($email), $payload);
  86. return $payload;
  87. }
  88. /**
  89. * @param array<string, mixed> $submission
  90. * @return array<string, mixed>
  91. */
  92. public function saveSubmission(string $email, array $submission): array
  93. {
  94. $now = date('c');
  95. $expires = date('c', time() + ((int) ($this->app['retention']['submission_days'] ?? 90) * 86400));
  96. $draft = $this->getDraft($email) ?? [];
  97. $payload = [
  98. 'email' => strtolower(trim($email)),
  99. 'application_key' => $this->emailKey($email),
  100. 'status' => 'submitted',
  101. 'created_at' => $draft['created_at'] ?? $now,
  102. 'updated_at' => $now,
  103. 'submitted_at' => $now,
  104. 'expires_at' => $expires,
  105. 'step' => $submission['step'] ?? ($draft['step'] ?? null),
  106. 'form_data' => (array) ($submission['form_data'] ?? []),
  107. 'uploads' => (array) ($submission['uploads'] ?? []),
  108. ];
  109. $this->writeJsonFile($this->submissionPath($email), $payload);
  110. $this->deleteDraft($email);
  111. return $payload;
  112. }
  113. public function deleteDraft(string $email): void
  114. {
  115. $path = $this->draftPath($email);
  116. if (is_file($path)) {
  117. unlink($path);
  118. }
  119. }
  120. /** @return array<string, mixed>|null */
  121. public function getSubmissionByKey(string $applicationKey): ?array
  122. {
  123. $safeKey = $this->normalizeApplicationKey($applicationKey);
  124. if ($safeKey === null) {
  125. return null;
  126. }
  127. $path = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json';
  128. if (!is_file($path)) {
  129. return null;
  130. }
  131. return $this->readJsonFile($path);
  132. }
  133. /** @return array<int, array<string, mixed>> */
  134. public function listSubmissions(): array
  135. {
  136. $dir = (string) $this->app['storage']['submissions'];
  137. $files = glob($dir . '/*.json') ?: [];
  138. $list = [];
  139. foreach ($files as $file) {
  140. $item = $this->readJsonFile($file);
  141. if (!is_array($item)) {
  142. continue;
  143. }
  144. $list[] = $item;
  145. }
  146. usort(
  147. $list,
  148. static fn (array $a, array $b): int => strcmp((string) ($b['submitted_at'] ?? ''), (string) ($a['submitted_at'] ?? ''))
  149. );
  150. return $list;
  151. }
  152. public function deleteSubmissionByKey(string $applicationKey): void
  153. {
  154. $safeKey = $this->normalizeApplicationKey($applicationKey);
  155. if ($safeKey === null) {
  156. return;
  157. }
  158. $submissionPath = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json';
  159. if (is_file($submissionPath)) {
  160. $submission = $this->readJsonFile($submissionPath);
  161. if (isset($submission['email']) && is_string($submission['email'])) {
  162. $this->deleteDraft($submission['email']);
  163. }
  164. unlink($submissionPath);
  165. }
  166. }
  167. /**
  168. * @template T
  169. * @param callable():T $callback
  170. * @return T
  171. */
  172. public function withEmailLock(string $email, callable $callback): mixed
  173. {
  174. $locksDir = (string) $this->app['storage']['locks'];
  175. if (!is_dir($locksDir)) {
  176. mkdir($locksDir, 0775, true);
  177. }
  178. $lockFile = $locksDir . '/' . $this->emailKey($email) . '.lock';
  179. $handle = fopen($lockFile, 'c+');
  180. if ($handle === false) {
  181. throw new RuntimeException('Could not open lock file.');
  182. }
  183. try {
  184. if (!flock($handle, LOCK_EX)) {
  185. throw new RuntimeException('Could not acquire lock.');
  186. }
  187. return $callback();
  188. } finally {
  189. flock($handle, LOCK_UN);
  190. fclose($handle);
  191. }
  192. }
  193. private function draftPath(string $email): string
  194. {
  195. return rtrim((string) $this->app['storage']['drafts'], '/') . '/' . $this->emailKey($email) . '.json';
  196. }
  197. private function submissionPath(string $email): string
  198. {
  199. return rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $this->emailKey($email) . '.json';
  200. }
  201. /** @return array<string, mixed> */
  202. private function readJsonFile(string $path): array
  203. {
  204. $raw = file_get_contents($path);
  205. if ($raw === false || $raw === '') {
  206. return [];
  207. }
  208. $decoded = json_decode($raw, true);
  209. if (!is_array($decoded)) {
  210. return [];
  211. }
  212. return $decoded;
  213. }
  214. /** @param array<string, mixed> $data */
  215. private function writeJsonFile(string $path, array $data): void
  216. {
  217. $tmpPath = $path . '.tmp';
  218. file_put_contents($tmpPath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
  219. rename($tmpPath, $path);
  220. }
  221. /**
  222. * @param array<string, mixed> $existing
  223. * @param array<string, mixed> $incoming
  224. * @return array<string, mixed>
  225. */
  226. private function mergeUploads(array $existing, array $incoming): array
  227. {
  228. foreach ($incoming as $field => $files) {
  229. if (!is_array($files)) {
  230. continue;
  231. }
  232. $existing[$field] = array_values(array_merge((array) ($existing[$field] ?? []), $files));
  233. }
  234. return $existing;
  235. }
  236. private function normalizeApplicationKey(string $key): ?string
  237. {
  238. $key = strtolower(trim($key));
  239. if (!preg_match('/^[a-f0-9]{64}$/', $key)) {
  240. return null;
  241. }
  242. return $key;
  243. }
  244. }