Forms gained steps, conditions, image choices, number fields with units and a summary step; the DEEG price finder uses all of it. Plugins are manifest plus Twig: blocks, settings, containers, routes, presets and theme packs, switched per installation. The review backlog is done: archive pages render through the normal page pipeline, forms and post lists work inside areas and sections, repeated sections get their own ids, breakpoints and colours come from the palette, backup and update work on MySQL and lock each other out. The task worker no longer shortens a process that has no time limit: set_time_limit() sets the limit instead of extending it, which turned an unlimited CLI run into eighty seconds and killed the full test suite far away from the scheduler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
7.2 KiB
PHP
151 lines
7.2 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) {
|
|
// 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): 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 = $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;
|
|
}
|
|
}
|