jsonstore.php 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  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. 'is_minor_submission' => (bool) ($submission['is_minor_submission'] ?? false),
  107. 'form_data' => (array) ($submission['form_data'] ?? []),
  108. 'uploads' => (array) ($submission['uploads'] ?? []),
  109. ];
  110. $this->writeJsonFile($this->submissionPath($email), $payload);
  111. $this->deleteDraft($email);
  112. return $payload;
  113. }
  114. public function deleteDraft(string $email): void
  115. {
  116. $path = $this->draftPath($email);
  117. if (is_file($path)) {
  118. unlink($path);
  119. }
  120. }
  121. /** @return array<string, mixed>|null */
  122. public function getSubmissionByKey(string $applicationKey): ?array
  123. {
  124. $safeKey = $this->normalizeApplicationKey($applicationKey);
  125. if ($safeKey === null) {
  126. return null;
  127. }
  128. $path = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json';
  129. if (!is_file($path)) {
  130. return null;
  131. }
  132. return $this->readJsonFile($path);
  133. }
  134. /** @return array<int, array<string, mixed>> */
  135. public function listSubmissions(): array
  136. {
  137. $dir = (string) $this->app['storage']['submissions'];
  138. $files = glob($dir . '/*.json') ?: [];
  139. $list = [];
  140. foreach ($files as $file) {
  141. $item = $this->readJsonFile($file);
  142. if (!is_array($item)) {
  143. continue;
  144. }
  145. $list[] = $item;
  146. }
  147. usort(
  148. $list,
  149. static fn (array $a, array $b): int => strcmp((string) ($b['submitted_at'] ?? ''), (string) ($a['submitted_at'] ?? ''))
  150. );
  151. return $list;
  152. }
  153. public function deleteSubmissionByKey(string $applicationKey): void
  154. {
  155. $safeKey = $this->normalizeApplicationKey($applicationKey);
  156. if ($safeKey === null) {
  157. return;
  158. }
  159. $submissionPath = rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $safeKey . '.json';
  160. if (is_file($submissionPath)) {
  161. $submission = $this->readJsonFile($submissionPath);
  162. if (isset($submission['email']) && is_string($submission['email'])) {
  163. $this->deleteDraft($submission['email']);
  164. }
  165. unlink($submissionPath);
  166. }
  167. }
  168. /**
  169. * @template T
  170. * @param callable():T $callback
  171. * @return T
  172. */
  173. public function withEmailLock(string $email, callable $callback): mixed
  174. {
  175. $locksDir = (string) $this->app['storage']['locks'];
  176. if (!is_dir($locksDir)) {
  177. mkdir($locksDir, 0775, true);
  178. }
  179. $lockFile = $locksDir . '/' . $this->emailKey($email) . '.lock';
  180. $handle = fopen($lockFile, 'c+');
  181. if ($handle === false) {
  182. throw new RuntimeException('Could not open lock file.');
  183. }
  184. try {
  185. if (!flock($handle, LOCK_EX)) {
  186. throw new RuntimeException('Could not acquire lock.');
  187. }
  188. return $callback();
  189. } finally {
  190. flock($handle, LOCK_UN);
  191. fclose($handle);
  192. @unlink($lockFile);
  193. }
  194. }
  195. private function draftPath(string $email): string
  196. {
  197. return rtrim((string) $this->app['storage']['drafts'], '/') . '/' . $this->emailKey($email) . '.json';
  198. }
  199. private function submissionPath(string $email): string
  200. {
  201. return rtrim((string) $this->app['storage']['submissions'], '/') . '/' . $this->emailKey($email) . '.json';
  202. }
  203. /** @return array<string, mixed> */
  204. private function readJsonFile(string $path): array
  205. {
  206. $raw = file_get_contents($path);
  207. if ($raw === false || $raw === '') {
  208. return [];
  209. }
  210. $decoded = json_decode($raw, true);
  211. if (!is_array($decoded)) {
  212. return [];
  213. }
  214. return $decoded;
  215. }
  216. /** @param array<string, mixed> $data */
  217. private function writeJsonFile(string $path, array $data): void
  218. {
  219. $tmpPath = $path . '.tmp';
  220. file_put_contents($tmpPath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
  221. rename($tmpPath, $path);
  222. }
  223. /**
  224. * @param array<string, mixed> $existing
  225. * @param array<string, mixed> $incoming
  226. * @return array<string, mixed>
  227. */
  228. private function mergeUploads(array $existing, array $incoming): array
  229. {
  230. foreach ($incoming as $field => $files) {
  231. if (!is_array($files)) {
  232. continue;
  233. }
  234. $existing[$field] = array_values(array_merge((array) ($existing[$field] ?? []), $files));
  235. }
  236. return $existing;
  237. }
  238. private function normalizeApplicationKey(string $key): ?string
  239. {
  240. $key = strtolower(trim($key));
  241. if (!preg_match('/^[a-f0-9]{64}$/', $key)) {
  242. return null;
  243. }
  244. return $key;
  245. }
  246. }