'[a-z0-9-]{1,80}'], methods: ['POST'])] public function submit(Request $request, string $slug): Response { $form = $this->forms->findBySlug($slug); $path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath(); $page = $this->pages->findPublished($path); if (null === $form || null === $page) { throw $this->createNotFoundException('Formular oder Seite nicht gefunden.'); } $senderHash = hash_hmac('sha256', (string) $request->getClientIp(), $this->secret); // Three quiet checks before the real validation. A caught bot is answered like a success // so it learns nothing, but nothing is stored and nothing is sent. $silentlyDropped = '' !== trim($request->request->getString('website')) || !$this->timingIsPlausible($request) || $this->forms->rateLimited($senderHash) // Die vierte, freiwillige Pruefung. Sie antwortet wie die drei anderen still mit // Erfolg: ein Absender, der erfaehrt, woran er gescheitert ist, versucht es gezielt // noch einmal. || !$this->turnstile->passed($request->request->getString('cf-turnstile-response'), $request->getClientIp()); // Abgeschickt von einer übersetzten Seite (ADR 111): Meldungen in dieser Sprache, und der // Weg zurück führt auf die übersetzte Seite - sofern es sie gibt. $language = $request->request->getString('language'); $translatedPath = null; if ($this->languages->isExtra($language)) { $translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null; } $language = null === $translatedPath ? null : $language; $checked = $this->forms->check($form, $request->request->all(), $language); if (!$silentlyDropped && [] !== $checked['errors']) { // Re-render the page the form sits on, with the entered values and the messages. return $this->renderPage($request, $page, $form, $checked, null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath)); } if (!$silentlyDropped) { $submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash); if ($this->deliver($form, $checked['values'])) { $this->forms->markDelivered($submission); } } // Post/Redirect/Get so a reload does not send the message twice. $back = null === $language ? $this->settings->publicPath($path) : '/'.$language.($page['path'] === $this->settings->homePath() ? '' : '/'.$translatedPath); return $this->redirect($back.'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303); } /** The signed timestamp makes the age of the form checkable without storing anything. */ private function timingIsPlausible(Request $request): bool { $issued = $request->request->getInt('issued'); if (!hash_equals(hash_hmac('sha256', (string) $issued, $this->secret), $request->request->getString('issuedSignature'))) { return false; } $age = time() - $issued; return $age >= FormRepository::MIN_SECONDS && $age <= FormRepository::MAX_SECONDS; } private function deliver(array $form, array $values): bool { $lines = []; foreach ($form['fields'] as $field) { // Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg. if ('step' === $field['type']) { $lines[] = ([] === $lines ? '' : "\n").'== '.$field['label'].' =='; continue; } if (null !== $field['showIf'] && '' === ($values[$field['name']] ?? '')) { continue; } $lines[] = $field['label'].': '.($values[$field['name']] ?? ''); } // Der Absender ist die eigene Adresse des Formulars, sonst der Empfaenger. Viele Hoster // liefern nur aus, was von einer Adresse ihrer eigenen Domain kommt. $email = (new Email()) ->from(($form['settings']['sender'] ?? '') ?: $form['settings']['recipient']) ->to($form['settings']['recipient']) ->subject($form['settings']['subject']) ->text(implode("\n", $lines)."\n\n-- \nGesendet über ".rtrim($this->siteOrigin, '/')); $replyTo = $form['settings']['replyToField']; if ('' !== $replyTo && '' !== ($values[$replyTo] ?? '')) { $email->replyTo($values[$replyTo]); } try { $this->mailer->send($email); return true; } catch (TransportExceptionInterface) { // The submission is already stored, so a broken mail setup loses no enquiry. // It stays marked as undelivered and is visible in the administration. return false; } } /** * Die Seite mit den Fehlermeldungen - gerendert vom Seitencontroller, nicht hier. * * Vorher baute diese Methode die Seite selbst zusammen und liess dabei alles weg, was sie * nicht kannte: Theme, Blockstile, Schriften, das Stylesheet-Buendel. Wer ein Pflichtfeld * vergass, sah eine Seite ohne Gestaltung. Jetzt geht der Weg durch dieselbe Stelle wie jeder * andere Aufruf, und die Formularzustaende werden als Anfrageattribut mitgereicht. */ private function renderPage(Request $request, array $page, array $form, array $checked, ?string $translatedAddress = null): Response { // Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben - wo auch immer // es auf der Seite steht: im Inhalt, im Kopf, im Fuss oder in einer verknuepften Sektion. // Der Seitencontroller ordnet die Meldungen jedem Block mit dieser Adresse zu. $zustaende = ['slug' => $form['slug'], 'errors' => $checked['errors'], 'values' => $checked['values']]; // Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten, // und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung. $adresse = $translatedAddress ?? ($page['path'] === $this->settings->homePath() ? '' : (string) $page['path']); $antwort = $this->forward('App\Controller\PageController::page', [ 'path' => $adresse, 'request' => $request->duplicate(null, null, ['kant_form_state' => $zustaende, '_route' => 'page']), ]); // Die Eingabe war unvollstaendig - die Seite ist dieselbe, die Antwort ist es nicht. $antwort->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); return $antwort; } }