Files
KantSites/src/Controller/FormController.php
T
kantarciandClaude Opus 5.5 d313156c73 Send a test mail from the admin and log why delivery failed
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>
2026-10-12 20:29:36 +02:00

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;
}
}