submit.php 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. <?php
  2. declare(strict_types=1);
  3. use App\App\Bootstrap;
  4. use App\Form\FormSchema;
  5. use App\Form\Validator;
  6. use App\Mail\Mailer;
  7. use App\Security\Csrf;
  8. use App\Security\FormAccess;
  9. use App\Storage\FileUploadStore;
  10. use App\Storage\JsonStore;
  11. require dirname(__DIR__) . '/src/autoload.php';
  12. Bootstrap::init();
  13. /** @param array<string, mixed> $app */
  14. function resolveSubmitSuccessMessage(array $app): string
  15. {
  16. $configured = Bootstrap::appMessage('submit.success');
  17. $contactEmail = trim((string) ($app['contact_email'] ?? ''));
  18. $message = str_replace(
  19. ['%contact_email%', '{{contact_email}}'],
  20. $contactEmail !== '' ? $contactEmail : 'uns',
  21. $configured
  22. );
  23. return trim($message);
  24. }
  25. function isMinorBirthdate(string $birthdate): bool
  26. {
  27. $birthdate = trim($birthdate);
  28. if ($birthdate === '') {
  29. return false;
  30. }
  31. $date = DateTimeImmutable::createFromFormat('!Y-m-d', $birthdate);
  32. if (!$date || $date->format('Y-m-d') !== $birthdate) {
  33. return false;
  34. }
  35. $today = new DateTimeImmutable('today');
  36. if ($date > $today) {
  37. return false;
  38. }
  39. return $date->diff($today)->y < 18;
  40. }
  41. /**
  42. * @param array<string, string> $errors
  43. * @return array<int, array{key: string, label: string, message: string}>
  44. */
  45. function buildValidationErrorDetails(array $errors, FormSchema $schema): array
  46. {
  47. $details = [];
  48. $allFields = $schema->getAllFields();
  49. foreach ($errors as $key => $message) {
  50. $field = $allFields[$key] ?? [];
  51. $label = '';
  52. if (is_array($field)) {
  53. $label = trim((string) ($field['label'] ?? ''));
  54. }
  55. $details[] = [
  56. 'key' => (string) $key,
  57. 'label' => $label !== '' ? $label : (string) $key,
  58. 'message' => (string) $message,
  59. ];
  60. }
  61. return $details;
  62. }
  63. if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  64. Bootstrap::jsonResponse([
  65. 'ok' => false,
  66. 'message' => Bootstrap::appMessage('common.method_not_allowed'),
  67. ], 405);
  68. }
  69. $csrf = $_POST['csrf'] ?? '';
  70. if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
  71. Bootstrap::jsonResponse([
  72. 'ok' => false,
  73. 'message' => Bootstrap::appMessage('common.invalid_csrf'),
  74. ], 419);
  75. }
  76. if (trim((string) ($_POST['website'] ?? '')) !== '') {
  77. Bootstrap::jsonResponse([
  78. 'ok' => false,
  79. 'message' => Bootstrap::appMessage('common.request_blocked'),
  80. ], 400);
  81. }
  82. $email = strtolower(trim((string) ($_POST['email'] ?? '')));
  83. if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
  84. Bootstrap::jsonResponse([
  85. 'ok' => false,
  86. 'message' => Bootstrap::appMessage('common.invalid_email'),
  87. ], 422);
  88. }
  89. $activityRaw = $_POST['last_user_activity_at'] ?? null;
  90. $lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
  91. $formAccess = new FormAccess();
  92. $auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
  93. if (($auth['ok'] ?? false) !== true) {
  94. $reason = (string) ($auth['reason'] ?? '');
  95. Bootstrap::jsonResponse([
  96. 'ok' => false,
  97. 'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
  98. 'auth_required' => $reason === 'auth_required',
  99. 'auth_expired' => $reason === 'auth_expired',
  100. ], (int) ($auth['status_code'] ?? 401));
  101. }
  102. $app = Bootstrap::config('app');
  103. $formDataRaw = $_POST['form_data'] ?? [];
  104. $formData = [];
  105. if (is_array($formDataRaw)) {
  106. foreach ($formDataRaw as $key => $value) {
  107. if (!is_string($key)) {
  108. continue;
  109. }
  110. $formData[$key] = is_array($value) ? '' : trim((string) $value);
  111. }
  112. }
  113. $store = new JsonStore();
  114. $schema = new FormSchema();
  115. $uploadStore = new FileUploadStore();
  116. $validator = new Validator($schema);
  117. // Fast fail before upload processing to avoid writing files for known duplicates.
  118. if ($store->hasSubmission($email)) {
  119. Bootstrap::jsonResponse([
  120. 'ok' => false,
  121. 'already_submitted' => true,
  122. 'message' => Bootstrap::appMessage('submit.already_submitted'),
  123. ], 409);
  124. }
  125. try {
  126. $submitResult = $store->withEmailLock($email, static function () use ($store, $email, $formData, $validator, $uploadStore, $schema): array {
  127. if ($store->hasSubmission($email)) {
  128. return [
  129. 'ok' => false,
  130. 'already_submitted' => true,
  131. 'message' => Bootstrap::appMessage('submit.already_submitted'),
  132. ];
  133. }
  134. $uploadResult = $uploadStore->processUploads($_FILES, $schema->getUploadFields(), $store->emailKey($email));
  135. if (!empty($uploadResult['errors'])) {
  136. return [
  137. 'ok' => false,
  138. 'already_submitted' => false,
  139. 'message' => Bootstrap::appMessage('submit.upload_error'),
  140. 'errors' => $uploadResult['errors'],
  141. ];
  142. }
  143. $draft = $store->getDraft($email) ?? [];
  144. $mergedFormData = array_merge((array) ($draft['form_data'] ?? []), $formData);
  145. $mergedUploads = (array) ($draft['uploads'] ?? []);
  146. foreach ($uploadResult['uploads'] as $field => $items) {
  147. $mergedUploads[$field] = array_values(array_merge((array) ($mergedUploads[$field] ?? []), $items));
  148. }
  149. $errors = $validator->validateSubmit($mergedFormData, $mergedUploads);
  150. if (!empty($errors)) {
  151. $store->saveDraft($email, [
  152. 'step' => 4,
  153. 'form_data' => $mergedFormData,
  154. 'uploads' => $mergedUploads,
  155. ]);
  156. $errorFields = array_values(array_map(static fn ($key): string => (string) $key, array_keys($errors)));
  157. return [
  158. 'ok' => false,
  159. 'already_submitted' => false,
  160. 'message' => Bootstrap::appMessage('submit.validation_error'),
  161. 'errors' => $errors,
  162. 'error_fields' => $errorFields,
  163. 'error_details' => buildValidationErrorDetails($errors, $schema),
  164. ];
  165. }
  166. $submission = $store->saveSubmission($email, [
  167. 'step' => 4,
  168. 'form_data' => $mergedFormData,
  169. 'uploads' => $mergedUploads,
  170. 'is_minor_submission' => isMinorBirthdate((string) ($mergedFormData['geburtsdatum'] ?? '')),
  171. ]);
  172. return [
  173. 'ok' => true,
  174. 'submission' => $submission,
  175. ];
  176. });
  177. } catch (Throwable $e) {
  178. Bootstrap::log('app', 'submit lock error: ' . $e->getMessage());
  179. Bootstrap::jsonResponse([
  180. 'ok' => false,
  181. 'message' => Bootstrap::appMessage('submit.lock_error'),
  182. ], 500);
  183. }
  184. if (($submitResult['ok'] ?? false) !== true) {
  185. $status = ($submitResult['already_submitted'] ?? false) ? 409 : 422;
  186. Bootstrap::jsonResponse([
  187. 'ok' => false,
  188. 'already_submitted' => (bool) ($submitResult['already_submitted'] ?? false),
  189. 'message' => (string) ($submitResult['message'] ?? Bootstrap::appMessage('submit.failure')),
  190. 'errors' => $submitResult['errors'] ?? [],
  191. 'error_fields' => $submitResult['error_fields'] ?? [],
  192. 'error_details' => $submitResult['error_details'] ?? [],
  193. ], $status);
  194. }
  195. $submission = $submitResult['submission'];
  196. $mailer = new Mailer();
  197. $mailer->sendSubmissionMails($submission);
  198. Bootstrap::jsonResponse([
  199. 'ok' => true,
  200. 'message' => resolveSubmitSuccessMessage($app),
  201. 'application_key' => $submission['application_key'] ?? null,
  202. ]);