Files
KantSites/src/Controller/FormController.php
T
kantarciandClaude Fable 5.1 d05dba3042
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Let a form send from its own address in 1.15.1
Umut asked where a contact form's mail is sent from: it was the recipient
address, `From` equal to `To`. That breaks as soon as the recipient is a
mailbox at another provider, because most hosts only deliver mail that
claims an address of their own domain, and the provider's SPF/DMARC then
treats it as forged.

Each form now has an optional `sender` setting, validated like the
recipient; empty keeps the old behaviour, so stored forms need no
migration. Delivery uses `sender ?: recipient`; the reply-to field is
untouched. The administration shows the field under the recipient with a
note on why it matters. The pilot launch config moves to port 8091 because
8087 was held by another session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:16:15 +02:00

150 lines
7.0 KiB
PHP

<?php
namespace App\Controller;
use App\Content\FormRepository;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\SiteSettings;
use App\Content\Turnstile;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
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 MailerInterface $mailer,
private Turnstile $turnstile,
private string $secret,
private string $siteOrigin,
) {
}
#[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 = 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());
$checked = $this->forms->check($form, $request->request->all());
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);
}
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.
return $this->redirect($this->settings->publicPath($path).'?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) {
$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): Response
{
$zustaende = [];
$walk = function (array $blocks) use (&$walk, &$zustaende, $form, $checked): void {
foreach ($blocks as $block) {
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben.
if ('form' === ($block['type'] ?? null) && ($block['props']['form'] ?? '') === $form['slug']) {
$zustaende[$block['id']] = ['errors' => $checked['errors'], 'values' => $checked['values'], 'sent' => false];
}
$walk($block['children'] ?? []);
}
};
$walk($page['document']['blocks'] ?? []);
// Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten,
// und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung.
$adresse = $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;
}
}