$app */ function resolveSubmitSuccessMessage(array $app): string { $configured = Bootstrap::appMessage('submit.success'); $contactEmail = trim((string) ($app['contact_email'] ?? '')); $message = str_replace( ['%contact_email%', '{{contact_email}}'], $contactEmail !== '' ? $contactEmail : 'uns', $configured ); return trim($message); } function isMinorBirthdate(string $birthdate): bool { $birthdate = trim($birthdate); if ($birthdate === '') { return false; } $date = DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate); if (!$date || $date->format('Y-m-d') !== $birthdate) { return false; } $today = new DateTimeImmutable('today'); if ($date > $today) { return false; } return $date->diff($today)->y < 18; } /** * @param array $errors * @return array */ function buildValidationErrorDetails(array $errors, FormSchema $schema): array { $details = []; $allFields = $schema->getAllFields(); foreach ($errors as $key => $message) { $field = $allFields[$key] ?? []; $label = ''; if (is_array($field)) { $label = trim((string) ($field['label'] ?? '')); } $details[] = [ 'key' => (string) $key, 'label' => $label !== '' ? $label : (string) $key, 'message' => (string) $message, ]; } return $details; } if ($_SERVER['REQUEST_METHOD'] !== 'POST') { Bootstrap::jsonResponse([ 'ok' => false, 'message' => Bootstrap::appMessage('common.method_not_allowed'), ], 405); } $csrf = $_POST['csrf'] ?? ''; if (!Csrf::validate(is_string($csrf) ? $csrf : null)) { Bootstrap::jsonResponse([ 'ok' => false, 'message' => Bootstrap::appMessage('common.invalid_csrf'), ], 419); } if (trim((string) ($_POST['website'] ?? '')) !== '') { Bootstrap::jsonResponse([ 'ok' => false, 'message' => Bootstrap::appMessage('common.request_blocked'), ], 400); } $email = strtolower(trim((string) ($_POST['email'] ?? ''))); if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { Bootstrap::jsonResponse([ 'ok' => false, 'message' => Bootstrap::appMessage('common.invalid_email'), ], 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)); } $app = Bootstrap::config('app'); $formDataRaw = $_POST['form_data'] ?? []; $formData = []; if (is_array($formDataRaw)) { foreach ($formDataRaw as $key => $value) { if (!is_string($key)) { continue; } $formData[$key] = is_array($value) ? '' : trim((string) $value); } } $store = new JsonStore(); $schema = new FormSchema(); $uploadStore = new FileUploadStore(); $validator = new Validator($schema); // Fast fail before upload processing to avoid writing files for known duplicates. if ($store->hasSubmission($email)) { Bootstrap::jsonResponse([ 'ok' => false, 'already_submitted' => true, 'message' => Bootstrap::appMessage('submit.already_submitted'), ], 409); } try { $submitResult = $store->withEmailLock($email, static function () use ($store, $email, $formData, $validator, $uploadStore, $schema): array { if ($store->hasSubmission($email)) { return [ 'ok' => false, 'already_submitted' => true, 'message' => Bootstrap::appMessage('submit.already_submitted'), ]; } $uploadResult = $uploadStore->processUploads($_FILES, $schema->getUploadFields(), $store->emailKey($email)); if (!empty($uploadResult['errors'])) { return [ 'ok' => false, 'already_submitted' => false, 'message' => Bootstrap::appMessage('submit.upload_error'), 'errors' => $uploadResult['errors'], ]; } $draft = $store->getDraft($email) ?? []; $mergedFormData = array_merge((array) ($draft['form_data'] ?? []), $formData); $mergedUploads = (array) ($draft['uploads'] ?? []); foreach ($uploadResult['uploads'] as $field => $items) { $mergedUploads[$field] = array_values(array_merge((array) ($mergedUploads[$field] ?? []), $items)); } $errors = $validator->validateSubmit($mergedFormData, $mergedUploads); if (!empty($errors)) { $store->saveDraft($email, [ 'step' => 4, 'form_data' => $mergedFormData, 'uploads' => $mergedUploads, ]); $errorFields = array_values(array_map(static fn ($key): string => (string) $key, array_keys($errors))); return [ 'ok' => false, 'already_submitted' => false, 'message' => Bootstrap::appMessage('submit.validation_error'), 'errors' => $errors, 'error_fields' => $errorFields, 'error_details' => buildValidationErrorDetails($errors, $schema), ]; } $submission = $store->saveSubmission($email, [ 'step' => 4, 'form_data' => $mergedFormData, 'uploads' => $mergedUploads, 'is_minor_submission' => isMinorBirthdate((string) ($mergedFormData['geburtsdatum'] ?? '')), ]); return [ 'ok' => true, 'submission' => $submission, ]; }); } catch (Throwable $e) { Bootstrap::log('app', 'submit lock error: ' . $e->getMessage()); Bootstrap::jsonResponse([ 'ok' => false, 'message' => Bootstrap::appMessage('submit.lock_error'), ], 500); } if (($submitResult['ok'] ?? false) !== true) { $status = ($submitResult['already_submitted'] ?? false) ? 409 : 422; Bootstrap::jsonResponse([ 'ok' => false, 'already_submitted' => (bool) ($submitResult['already_submitted'] ?? false), 'message' => (string) ($submitResult['message'] ?? Bootstrap::appMessage('submit.failure')), 'errors' => $submitResult['errors'] ?? [], 'error_fields' => $submitResult['error_fields'] ?? [], 'error_details' => $submitResult['error_details'] ?? [], ], $status); } $submission = $submitResult['submission']; $mailer = new Mailer(); $mailer->sendSubmissionMails($submission); Bootstrap::jsonResponse([ 'ok' => true, 'message' => resolveSubmitSuccessMessage($app), 'application_key' => $submission['application_key'] ?? null, ]);