Form submissions marked as not sent gave no reason: the transport exception was swallowed. Every send now goes through MailCheck, which logs the server's answer and a hint in German to var/log/mail.log (server lines only, no credentials). The system screen shows the mail route, sends a test mail to any address and lists recent attempts (ADR 121). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
8.5 KiB
PHP
163 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Content\FormRepository;
|
|
use App\Content\Languages;
|
|
use App\Content\MenuRepository;
|
|
use App\Content\PageRepository;
|
|
use App\Content\SiteSettings;
|
|
use App\Content\Turnstile;
|
|
use App\Ops\MailCheck;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Mime\Email;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
/**
|
|
* Public form submission. There is no session and no CSRF token here on purpose: the route is
|
|
* anonymous and changes nothing that belongs to a visitor, so a forged request could at worst
|
|
* send a message the spam protection already covers. Starting a session would put a cookie on
|
|
* every public page, which the project avoids.
|
|
*/
|
|
final class FormController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private FormRepository $forms,
|
|
private PageRepository $pages,
|
|
private MenuRepository $menus,
|
|
private SiteSettings $settings,
|
|
private MailCheck $mail,
|
|
private Turnstile $turnstile,
|
|
private string $secret,
|
|
private string $siteOrigin,
|
|
private Languages $languages,
|
|
) {
|
|
}
|
|
|
|
#[Route('/formular/{slug}', name: 'form_submit', requirements: ['slug' => '[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 = FormRepository::senderKey($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, $slug)
|
|
|| $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));
|
|
}
|
|
// Kommt gerade für dieses Formular oder insgesamt zu viel an, wird nichts mehr gespeichert -
|
|
// aber sichtbar, nicht still: ein echter Besucher soll wissen, dass er anrufen muss.
|
|
if (!$silentlyDropped && $this->forms->flooded($form['id'])) {
|
|
return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], 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, string $slug): bool
|
|
{
|
|
$issued = $request->request->getInt('issued');
|
|
if (!hash_equals(FormRepository::stamp($slug, $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]);
|
|
}
|
|
|
|
// The submission is already stored, so a broken mail setup loses no enquiry. It stays
|
|
// marked as undelivered, and var/log/mail.log says why (System -> E-Mail-Versand).
|
|
return $this->mail->send($email, 'Formular '.$form['slug'])['ok'];
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|