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>
150 lines
7.0 KiB
PHP
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;
|
|
}
|
|
}
|