delete-upload.php 6.5 KB

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