delete-upload.php 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. <?php
  2. declare(strict_types=1);
  3. use App\App\Bootstrap;
  4. use App\Security\Csrf;
  5. use App\Security\RateLimiter;
  6. use App\Storage\FileSystem;
  7. use App\Storage\JsonStore;
  8. require dirname(__DIR__) . '/src/autoload.php';
  9. Bootstrap::init();
  10. if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  11. Bootstrap::jsonResponse([
  12. 'ok' => false,
  13. 'message' => Bootstrap::appMessage('common.method_not_allowed'),
  14. ], 405);
  15. }
  16. $csrf = $_POST['csrf'] ?? '';
  17. if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
  18. Bootstrap::jsonResponse([
  19. 'ok' => false,
  20. 'message' => Bootstrap::appMessage('common.invalid_csrf'),
  21. ], 419);
  22. }
  23. if (trim((string) ($_POST['website'] ?? '')) !== '') {
  24. Bootstrap::jsonResponse([
  25. 'ok' => false,
  26. 'message' => Bootstrap::appMessage('common.request_blocked'),
  27. ], 400);
  28. }
  29. $email = strtolower(trim((string) ($_POST['email'] ?? '')));
  30. if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
  31. Bootstrap::jsonResponse([
  32. 'ok' => false,
  33. 'message' => Bootstrap::appMessage('common.invalid_email'),
  34. ], 422);
  35. }
  36. $field = trim((string) ($_POST['field'] ?? ''));
  37. $index = (int) ($_POST['index'] ?? -1);
  38. if ($field === '' || $index < 0) {
  39. Bootstrap::jsonResponse([
  40. 'ok' => false,
  41. 'message' => Bootstrap::appMessage('delete_upload.invalid_upload_entry'),
  42. ], 422);
  43. }
  44. /** @return array{path: string, dir: string}|null */
  45. function resolveStoredUploadPath(array $entry, array $app): ?array
  46. {
  47. $baseDir = rtrim((string) ($app['storage']['uploads'] ?? ''), '/');
  48. if ($baseDir === '') {
  49. return null;
  50. }
  51. $storedDir = trim((string) ($entry['stored_dir'] ?? ''), '/');
  52. $storedFilename = trim((string) ($entry['stored_filename'] ?? ''));
  53. if ($storedDir === '' || $storedFilename === '') {
  54. return null;
  55. }
  56. if (str_contains($storedDir, '..') || str_contains($storedFilename, '..')) {
  57. return null;
  58. }
  59. if (!preg_match('/^[A-Za-z0-9._\/-]+$/', $storedDir)) {
  60. return null;
  61. }
  62. if (!preg_match('/^[A-Za-z0-9._ -]+$/', $storedFilename)) {
  63. return null;
  64. }
  65. $path = $baseDir . '/' . $storedDir . '/' . $storedFilename;
  66. $dir = dirname($path);
  67. $realBase = realpath($baseDir);
  68. if ($realBase !== false) {
  69. $realDir = realpath($dir);
  70. $realPath = realpath($path);
  71. if ($realDir !== false && !str_starts_with($realDir, $realBase)) {
  72. return null;
  73. }
  74. if ($realPath !== false && !str_starts_with($realPath, $realBase)) {
  75. return null;
  76. }
  77. }
  78. return ['path' => $path, 'dir' => $dir];
  79. }
  80. $app = Bootstrap::config('app');
  81. $limiter = new RateLimiter();
  82. $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
  83. $rateKey = sprintf('delete-upload:%s:%s', $ip, $email);
  84. if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
  85. Bootstrap::jsonResponse([
  86. 'ok' => false,
  87. 'message' => Bootstrap::appMessage('delete_upload.rate_limited'),
  88. ], 429);
  89. }
  90. $store = new JsonStore();
  91. try {
  92. $result = $store->withEmailLock($email, static function () use ($store, $app, $email, $field, $index): array {
  93. if ($store->hasSubmission($email)) {
  94. return [
  95. 'ok' => false,
  96. 'status' => 409,
  97. 'message' => Bootstrap::appMessage('delete_upload.already_submitted'),
  98. ];
  99. }
  100. $draft = $store->getDraft($email);
  101. if (!is_array($draft)) {
  102. return [
  103. 'ok' => false,
  104. 'status' => 404,
  105. 'message' => Bootstrap::appMessage('delete_upload.draft_not_found'),
  106. ];
  107. }
  108. $uploads = (array) ($draft['uploads'] ?? []);
  109. $files = $uploads[$field] ?? null;
  110. if (!is_array($files) || !isset($files[$index]) || !is_array($files[$index])) {
  111. return [
  112. 'ok' => false,
  113. 'status' => 404,
  114. 'message' => Bootstrap::appMessage('delete_upload.upload_not_found'),
  115. ];
  116. }
  117. $entry = $files[$index];
  118. unset($files[$index]);
  119. $files = array_values($files);
  120. if ($files === []) {
  121. unset($uploads[$field]);
  122. } else {
  123. $uploads[$field] = $files;
  124. }
  125. $updatedDraft = $store->replaceDraft($email, [
  126. 'step' => $draft['step'] ?? 1,
  127. 'form_data' => (array) ($draft['form_data'] ?? []),
  128. 'uploads' => $uploads,
  129. ]);
  130. $resolved = resolveStoredUploadPath($entry, $app);
  131. if ($resolved !== null) {
  132. $fullPath = $resolved['path'];
  133. if (is_file($fullPath)) {
  134. @unlink($fullPath);
  135. }
  136. $entryDir = $resolved['dir'];
  137. if (is_dir($entryDir)) {
  138. $remaining = scandir($entryDir);
  139. if (is_array($remaining) && count($remaining) <= 2) {
  140. FileSystem::removeTree($entryDir);
  141. }
  142. }
  143. }
  144. return [
  145. 'ok' => true,
  146. 'status' => 200,
  147. 'uploads' => $updatedDraft['uploads'] ?? [],
  148. 'updated_at' => $updatedDraft['updated_at'] ?? null,
  149. ];
  150. });
  151. } catch (Throwable $e) {
  152. Bootstrap::log('app', 'delete-upload error: ' . $e->getMessage());
  153. Bootstrap::jsonResponse([
  154. 'ok' => false,
  155. 'message' => Bootstrap::appMessage('delete_upload.delete_error'),
  156. ], 500);
  157. }
  158. if (($result['ok'] ?? false) !== true) {
  159. Bootstrap::jsonResponse([
  160. 'ok' => false,
  161. 'message' => (string) ($result['message'] ?? Bootstrap::appMessage('delete_upload.delete_error')),
  162. ], (int) ($result['status'] ?? 422));
  163. }
  164. Bootstrap::jsonResponse([
  165. 'ok' => true,
  166. 'message' => Bootstrap::appMessage('delete_upload.success'),
  167. 'uploads' => $result['uploads'] ?? [],
  168. 'updated_at' => $result['updated_at'] ?? null,
  169. ]);