delete-upload.php 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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(['ok' => false, 'message' => 'Method not allowed'], 405);
  13. }
  14. $csrf = $_POST['csrf'] ?? '';
  15. if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
  16. Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
  17. }
  18. if (trim((string) ($_POST['website'] ?? '')) !== '') {
  19. Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
  20. }
  21. $email = strtolower(trim((string) ($_POST['email'] ?? '')));
  22. if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
  23. Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
  24. }
  25. $activityRaw = $_POST['last_user_activity_at'] ?? null;
  26. $lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
  27. $formAccess = new FormAccess();
  28. $auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
  29. if (($auth['ok'] ?? false) !== true) {
  30. $reason = (string) ($auth['reason'] ?? '');
  31. Bootstrap::jsonResponse([
  32. 'ok' => false,
  33. 'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
  34. 'auth_required' => $reason === 'auth_required',
  35. 'auth_expired' => $reason === 'auth_expired',
  36. ], (int) ($auth['status_code'] ?? 401));
  37. }
  38. $field = trim((string) ($_POST['field'] ?? ''));
  39. $index = (int) ($_POST['index'] ?? -1);
  40. if ($field === '' || $index < 0) {
  41. Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiger Upload-Eintrag.'], 422);
  42. }
  43. /** @return array{path: string, dir: string}|null */
  44. function resolveStoredUploadPath(array $entry, array $app): ?array
  45. {
  46. $baseDir = rtrim((string) ($app['storage']['uploads'] ?? ''), '/');
  47. if ($baseDir === '') {
  48. return null;
  49. }
  50. $storedDir = trim((string) ($entry['stored_dir'] ?? ''), '/');
  51. $storedFilename = trim((string) ($entry['stored_filename'] ?? ''));
  52. if ($storedDir === '' || $storedFilename === '') {
  53. return null;
  54. }
  55. if (str_contains($storedDir, '..') || str_contains($storedFilename, '..')) {
  56. return null;
  57. }
  58. if (!preg_match('/^[A-Za-z0-9._\/-]+$/', $storedDir)) {
  59. return null;
  60. }
  61. if (!preg_match('/^[A-Za-z0-9._ -]+$/', $storedFilename)) {
  62. return null;
  63. }
  64. $path = $baseDir . '/' . $storedDir . '/' . $storedFilename;
  65. $dir = dirname($path);
  66. $realBase = realpath($baseDir);
  67. if ($realBase !== false) {
  68. $realDir = realpath($dir);
  69. $realPath = realpath($path);
  70. if ($realDir !== false && !str_starts_with($realDir, $realBase)) {
  71. return null;
  72. }
  73. if ($realPath !== false && !str_starts_with($realPath, $realBase)) {
  74. return null;
  75. }
  76. }
  77. return ['path' => $path, 'dir' => $dir];
  78. }
  79. $app = Bootstrap::config('app');
  80. $limiter = new RateLimiter();
  81. $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
  82. $rateKey = sprintf('delete-upload:%s:%s', $ip, $email);
  83. if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
  84. Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Löschanfragen. Bitte später erneut versuchen.'], 429);
  85. }
  86. $store = new JsonStore();
  87. try {
  88. $result = $store->withEmailLock($email, static function () use ($store, $app, $email, $field, $index): array {
  89. if ($store->hasSubmission($email)) {
  90. return [
  91. 'ok' => false,
  92. 'status' => 409,
  93. 'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
  94. ];
  95. }
  96. $draft = $store->getDraft($email);
  97. if (!is_array($draft)) {
  98. return [
  99. 'ok' => false,
  100. 'status' => 404,
  101. 'message' => 'Kein Entwurf gefunden.',
  102. ];
  103. }
  104. $uploads = (array) ($draft['uploads'] ?? []);
  105. $files = $uploads[$field] ?? null;
  106. if (!is_array($files) || !isset($files[$index]) || !is_array($files[$index])) {
  107. return [
  108. 'ok' => false,
  109. 'status' => 404,
  110. 'message' => 'Upload nicht gefunden.',
  111. ];
  112. }
  113. $entry = $files[$index];
  114. unset($files[$index]);
  115. $files = array_values($files);
  116. if ($files === []) {
  117. unset($uploads[$field]);
  118. } else {
  119. $uploads[$field] = $files;
  120. }
  121. $updatedDraft = $store->replaceDraft($email, [
  122. 'step' => $draft['step'] ?? 1,
  123. 'form_data' => (array) ($draft['form_data'] ?? []),
  124. 'uploads' => $uploads,
  125. ]);
  126. $resolved = resolveStoredUploadPath($entry, $app);
  127. if ($resolved !== null) {
  128. $fullPath = $resolved['path'];
  129. if (is_file($fullPath)) {
  130. @unlink($fullPath);
  131. }
  132. $entryDir = $resolved['dir'];
  133. if (is_dir($entryDir)) {
  134. $remaining = scandir($entryDir);
  135. if (is_array($remaining) && count($remaining) <= 2) {
  136. FileSystem::removeTree($entryDir);
  137. }
  138. }
  139. }
  140. return [
  141. 'ok' => true,
  142. 'status' => 200,
  143. 'uploads' => $updatedDraft['uploads'] ?? [],
  144. 'updated_at' => $updatedDraft['updated_at'] ?? null,
  145. ];
  146. });
  147. } catch (Throwable $e) {
  148. Bootstrap::log('app', 'delete-upload error: ' . $e->getMessage());
  149. Bootstrap::jsonResponse(['ok' => false, 'message' => 'Upload konnte nicht gelöscht werden.'], 500);
  150. }
  151. if (($result['ok'] ?? false) !== true) {
  152. Bootstrap::jsonResponse([
  153. 'ok' => false,
  154. 'message' => (string) ($result['message'] ?? 'Upload konnte nicht gelöscht werden.'),
  155. ], (int) ($result['status'] ?? 422));
  156. }
  157. Bootstrap::jsonResponse([
  158. 'ok' => true,
  159. 'message' => 'Upload gelöscht.',
  160. 'uploads' => $result['uploads'] ?? [],
  161. 'updated_at' => $result['updated_at'] ?? null,
  162. ]);