false, 'message' => 'Method not allowed'], 405); } $csrf = $_POST['csrf'] ?? ''; if (!Csrf::validate(is_string($csrf) ? $csrf : null)) { Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419); } if (trim((string) ($_POST['website'] ?? '')) !== '') { Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400); } $email = strtolower(trim((string) ($_POST['email'] ?? ''))); if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422); } $activityRaw = $_POST['last_user_activity_at'] ?? null; $lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null; $formAccess = new FormAccess(); $auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt); if (($auth['ok'] ?? false) !== true) { $reason = (string) ($auth['reason'] ?? ''); Bootstrap::jsonResponse([ 'ok' => false, 'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'), 'auth_required' => $reason === 'auth_required', 'auth_expired' => $reason === 'auth_expired', ], (int) ($auth['status_code'] ?? 401)); } $field = trim((string) ($_POST['field'] ?? '')); $index = (int) ($_POST['index'] ?? -1); if ($field === '' || $index < 0) { Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiger Upload-Eintrag.'], 422); } /** @return array{path: string, dir: string}|null */ function resolveStoredUploadPath(array $entry, array $app): ?array { $baseDir = rtrim((string) ($app['storage']['uploads'] ?? ''), '/'); if ($baseDir === '') { return null; } $storedDir = trim((string) ($entry['stored_dir'] ?? ''), '/'); $storedFilename = trim((string) ($entry['stored_filename'] ?? '')); if ($storedDir === '' || $storedFilename === '') { return null; } if (str_contains($storedDir, '..') || str_contains($storedFilename, '..')) { return null; } if (!preg_match('/^[A-Za-z0-9._\/-]+$/', $storedDir)) { return null; } if (!preg_match('/^[A-Za-z0-9._ -]+$/', $storedFilename)) { return null; } $path = $baseDir . '/' . $storedDir . '/' . $storedFilename; $dir = dirname($path); $realBase = realpath($baseDir); if ($realBase !== false) { $realDir = realpath($dir); $realPath = realpath($path); if ($realDir !== false && !str_starts_with($realDir, $realBase)) { return null; } if ($realPath !== false && !str_starts_with($realPath, $realBase)) { return null; } } return ['path' => $path, 'dir' => $dir]; } $app = Bootstrap::config('app'); $limiter = new RateLimiter(); $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $rateKey = sprintf('delete-upload:%s:%s', $ip, $email); if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) { Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Löschanfragen. Bitte später erneut versuchen.'], 429); } $store = new JsonStore(); try { $result = $store->withEmailLock($email, static function () use ($store, $app, $email, $field, $index): array { if ($store->hasSubmission($email)) { return [ 'ok' => false, 'status' => 409, 'message' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.', ]; } $draft = $store->getDraft($email); if (!is_array($draft)) { return [ 'ok' => false, 'status' => 404, 'message' => 'Kein Entwurf gefunden.', ]; } $uploads = (array) ($draft['uploads'] ?? []); $files = $uploads[$field] ?? null; if (!is_array($files) || !isset($files[$index]) || !is_array($files[$index])) { return [ 'ok' => false, 'status' => 404, 'message' => 'Upload nicht gefunden.', ]; } $entry = $files[$index]; unset($files[$index]); $files = array_values($files); if ($files === []) { unset($uploads[$field]); } else { $uploads[$field] = $files; } $updatedDraft = $store->replaceDraft($email, [ 'step' => $draft['step'] ?? 1, 'form_data' => (array) ($draft['form_data'] ?? []), 'uploads' => $uploads, ]); $resolved = resolveStoredUploadPath($entry, $app); if ($resolved !== null) { $fullPath = $resolved['path']; if (is_file($fullPath)) { @unlink($fullPath); } $entryDir = $resolved['dir']; if (is_dir($entryDir)) { $remaining = scandir($entryDir); if (is_array($remaining) && count($remaining) <= 2) { FileSystem::removeTree($entryDir); } } } return [ 'ok' => true, 'status' => 200, 'uploads' => $updatedDraft['uploads'] ?? [], 'updated_at' => $updatedDraft['updated_at'] ?? null, ]; }); } catch (Throwable $e) { Bootstrap::log('app', 'delete-upload error: ' . $e->getMessage()); Bootstrap::jsonResponse(['ok' => false, 'message' => 'Upload konnte nicht gelöscht werden.'], 500); } if (($result['ok'] ?? false) !== true) { Bootstrap::jsonResponse([ 'ok' => false, 'message' => (string) ($result['message'] ?? 'Upload konnte nicht gelöscht werden.'), ], (int) ($result['status'] ?? 422)); } Bootstrap::jsonResponse([ 'ok' => true, 'message' => 'Upload gelöscht.', 'uploads' => $result['uploads'] ?? [], 'updated_at' => $result['updated_at'] ?? null, ]);