submit.php 6.7 KB

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