Formulare mit Validierung, Einwilligung und Spamschutz (Arbeitspaket D)
Migration 20260912000700 legt form und form_submission an. Der Formularblock im Seiteninhalt enthält nur die Adresse: Felder, Empfänger und Einwilligungstext liegen in der Datenbank, damit ein gespeichertes Dokument nicht ändern kann, wonach gefragt wird oder wohin eine Anfrage geht. Serverseitige Validierung mit einer Meldung je Feld über aria-describedby und aria-invalid; eine Ablehnung rendert die Seite mit HTTP 422 und allen Eingaben neu, eine gültige Absendung wird mit HTTP 303 zurückgeleitet. Alles ohne JavaScript. Spamschutz ohne Dienst Dritter: Honeypot, signierter Zeitstempel gegen zu schnelle und zu alte Absendungen, fünf Absendungen je Absender und Stunde. Ein erkannter Bot bekommt dieselbe Antwort wie ein Erfolg. Die Absenderadresse wird nie gespeichert, nur ein Hash. Gespeichert wird vor dem Versand, damit ein kaputter Mailversand keine Anfrage verliert. Kein CSRF-Token auf dem öffentlichen Weg, weil das eine Sitzung und damit ein Cookie auf jeder öffentlichen Seite bedeuten würde; Begründung in ADR 017. Dateiuploads fehlen bewusst. Ein echter Fehler von den Tests gefunden: das Stundenfenster des Limits entstand aus lokaler Zeit, die Zeitstempel aber aus UTC - auf einer Uhr vor UTC hätte das Limit nie gegriffen. Geprüft: 117 PHP-Tests / 450 Assertions unter PHP 8.2.12 und 8.4.22, 14 JavaScript-Modelltests, Lint/Format/Container, composer audit. Der E-Mail-Versand wurde nie wirklich ausgeführt; keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c6ec00a906
commit
d43bd91069
31 files changed
+1445
-26
No files matched your search
@@ -13,11 +13,13 @@ final class DocumentValidator
|
||||
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
||||
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
|
||||
public const V7_TYPES = ['tabs', 'tabitem'];
|
||||
/** Schema v8: a form block references a form by its address; the definition lives in the database. */
|
||||
public const V8_TYPES = ['form'];
|
||||
|
||||
public function validate(array $document): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -42,7 +44,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -133,6 +135,11 @@ final class DocumentValidator
|
||||
if ('tabitem' === $type) {
|
||||
$this->text($props['title'] ?? null, 160);
|
||||
}
|
||||
if ('form' === $type) {
|
||||
// Only the address is content. Fields, recipient and consent text stay in the database,
|
||||
// so a stored document can never redirect a submission or change what is asked.
|
||||
$this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.');
|
||||
}
|
||||
if ('image' === $type) {
|
||||
$this->url($props['src'] ?? null);
|
||||
$this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.');
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidForm;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Form definitions and their submissions. A definition is a validated list of fields; a submission
|
||||
* stores the answers, the moment consent was given and a keyed hash of the sender address.
|
||||
*/
|
||||
final class FormRepository
|
||||
{
|
||||
public const TYPES = [
|
||||
'text' => 'Einzeiliger Text',
|
||||
'email' => 'E-Mail-Adresse',
|
||||
'phone' => 'Telefonnummer',
|
||||
'textarea' => 'Mehrzeiliger Text',
|
||||
'select' => 'Auswahl',
|
||||
'consent' => 'Einwilligung',
|
||||
];
|
||||
public const MAX_FIELDS = 25;
|
||||
/** Submissions per sender per hour. Deliberately low: a contact form is not a chat. */
|
||||
public const RATE_LIMIT = 5;
|
||||
/** A human needs longer than this to fill in a form; anything faster is a script. */
|
||||
public const MIN_SECONDS = 3;
|
||||
public const MAX_SECONDS = 86400;
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array{id: string, slug: string, name: string, fields: array, settings: array, version: int}> */
|
||||
public function all(string $locale = 'de'): array
|
||||
{
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM form WHERE website_id = ? AND locale = ? ORDER BY name',
|
||||
['default', $locale],
|
||||
));
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug, string $locale = 'de'): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM form WHERE website_id = ? AND locale = ? AND slug = ?', ['default', $locale, $slug]);
|
||||
|
||||
return false === $row ? null : $this->present($row);
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => $row['id'], 'slug' => $row['slug'], 'name' => $row['name'],
|
||||
'fields' => json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR),
|
||||
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR),
|
||||
'version' => (int) $row['version'],
|
||||
];
|
||||
}
|
||||
|
||||
public function save(string $slug, string $name, array $fields, array $settings, int $expectedVersion, string $locale = 'de'): string
|
||||
{
|
||||
$slug = strtolower(trim($slug));
|
||||
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
|
||||
throw new InvalidForm('Die Adresse des Formulars darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.');
|
||||
}
|
||||
$name = trim($name);
|
||||
if ('' === $name || mb_strlen($name) > 120) {
|
||||
throw new InvalidForm('Bitte gib einen Namen mit höchstens 120 Zeichen an.');
|
||||
}
|
||||
$fields = $this->validateFields($fields);
|
||||
$settings = $this->validateSettings($settings, $fields);
|
||||
|
||||
return $this->db->transactional(function () use ($slug, $name, $fields, $settings, $expectedVersion, $locale): string {
|
||||
$existing = $this->db->fetchAssociative('SELECT id, version FROM form WHERE website_id = ? AND locale = ? AND slug = ?', ['default', $locale, $slug]);
|
||||
$payload = [
|
||||
'name' => $name,
|
||||
'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'settings' => json_encode($settings, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
if (false === $existing) {
|
||||
if (0 !== $expectedVersion) {
|
||||
throw new StaleWrite('Das Formular wurde inzwischen angelegt. Lade die Seite neu.');
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$this->db->insert('form', [...$payload, 'id' => $id, 'website_id' => 'default', 'locale' => $locale, 'slug' => $slug, 'version' => 1]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE form SET name = ?, fields = ?, settings = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[$payload['name'], $payload['fields'], $payload['settings'], $existing['id'], $expectedVersion],
|
||||
);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('Das Formular wurde inzwischen geändert. Lade die Seite neu, bevor du speicherst.');
|
||||
}
|
||||
|
||||
return $existing['id'];
|
||||
});
|
||||
}
|
||||
|
||||
private function validateFields(array $fields): array
|
||||
{
|
||||
if (!array_is_list($fields) || [] === $fields) {
|
||||
throw new InvalidForm('Ein Formular braucht mindestens ein Feld.');
|
||||
}
|
||||
if (count($fields) > self::MAX_FIELDS) {
|
||||
throw new InvalidForm(sprintf('Ein Formular fasst höchstens %d Felder.', self::MAX_FIELDS));
|
||||
}
|
||||
$names = [];
|
||||
$clean = [];
|
||||
foreach ($fields as $field) {
|
||||
$name = strtolower(trim((string) ($field['name'] ?? '')));
|
||||
if (1 !== preg_match('/^[a-z][a-z0-9_]{0,31}$/D', $name) || isset($names[$name])) {
|
||||
throw new InvalidForm('Jedes Feld braucht einen eindeutigen technischen Namen aus Kleinbuchstaben, Ziffern und Unterstrich.');
|
||||
}
|
||||
$names[$name] = true;
|
||||
$type = (string) ($field['type'] ?? '');
|
||||
if (!isset(self::TYPES[$type])) {
|
||||
throw new InvalidForm('Unbekannter Feldtyp.');
|
||||
}
|
||||
$label = trim((string) ($field['label'] ?? ''));
|
||||
if ('' === $label || mb_strlen($label) > 120) {
|
||||
throw new InvalidForm('Jedes Feld braucht eine Beschriftung mit höchstens 120 Zeichen.');
|
||||
}
|
||||
$options = [];
|
||||
if ('select' === $type) {
|
||||
foreach ((array) ($field['options'] ?? []) as $option) {
|
||||
$option = trim((string) $option);
|
||||
if ('' !== $option && mb_strlen($option) <= 120) {
|
||||
$options[] = $option;
|
||||
}
|
||||
}
|
||||
if (count($options) < 2) {
|
||||
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen.', $label));
|
||||
}
|
||||
}
|
||||
$clean[] = [
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'label' => $label,
|
||||
// A consent field is only meaningful when it has to be ticked.
|
||||
'required' => 'consent' === $type ? true : (bool) ($field['required'] ?? false),
|
||||
'help' => mb_substr(trim((string) ($field['help'] ?? '')), 0, 200),
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
if (count(array_filter($clean, static fn (array $f): bool => 'consent' === $f['type'])) > 1) {
|
||||
throw new InvalidForm('Ein Formular hat höchstens ein Einwilligungsfeld.');
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
private function validateSettings(array $settings, array $fields): array
|
||||
{
|
||||
$recipient = trim((string) ($settings['recipient'] ?? ''));
|
||||
if (false === filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidForm('Bitte gib eine gültige Empfängeradresse an.');
|
||||
}
|
||||
$replyTo = trim((string) ($settings['replyToField'] ?? ''));
|
||||
if ('' !== $replyTo) {
|
||||
$match = array_filter($fields, static fn (array $f): bool => $f['name'] === $replyTo && 'email' === $f['type']);
|
||||
if ([] === $match) {
|
||||
throw new InvalidForm('Die Antwortadresse muss auf ein E-Mail-Feld dieses Formulars zeigen.');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'recipient' => $recipient,
|
||||
'subject' => mb_substr(trim((string) ($settings['subject'] ?? '')), 0, 160) ?: 'Neue Formularnachricht',
|
||||
'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.',
|
||||
'replyToField' => $replyTo,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks one submission against the stored definition.
|
||||
*
|
||||
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
|
||||
*/
|
||||
public function check(array $form, array $input): array
|
||||
{
|
||||
$values = [];
|
||||
$errors = [];
|
||||
$consent = false;
|
||||
foreach ($form['fields'] as $field) {
|
||||
$raw = $input[$field['name']] ?? '';
|
||||
$value = is_string($raw) ? trim($raw) : '';
|
||||
$limit = 'textarea' === $field['type'] ? 5000 : 200;
|
||||
if (mb_strlen($value) > $limit) {
|
||||
$errors[$field['name']] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
|
||||
$value = mb_substr($value, 0, $limit);
|
||||
}
|
||||
if ('consent' === $field['type']) {
|
||||
$consent = '' !== $value;
|
||||
if (!$consent) {
|
||||
$errors[$field['name']] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
|
||||
}
|
||||
$values[$field['name']] = $consent ? 'ja' : '';
|
||||
continue;
|
||||
}
|
||||
if ('' === $value) {
|
||||
if ($field['required']) {
|
||||
$errors[$field['name']] = 'Dieses Feld wird benötigt.';
|
||||
}
|
||||
$values[$field['name']] = '';
|
||||
continue;
|
||||
}
|
||||
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[$field['name']] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
}
|
||||
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
|
||||
$errors[$field['name']] = 'Bitte gib eine gültige Telefonnummer an.';
|
||||
}
|
||||
if ('select' === $field['type'] && !in_array($value, $field['options'], true)) {
|
||||
$errors[$field['name']] = 'Bitte wähle eine der angebotenen Optionen.';
|
||||
}
|
||||
$values[$field['name']] = $value;
|
||||
}
|
||||
|
||||
return ['values' => $values, 'errors' => $errors, 'consent' => $consent];
|
||||
}
|
||||
|
||||
/** True when this sender has already sent the allowed number of messages within the hour. */
|
||||
public function rateLimited(string $senderHash): bool
|
||||
{
|
||||
// gmdate, not DateTimeImmutable: created_at is written in UTC, and a local clock ahead of
|
||||
// UTC would push the window past every stored row and disable the limit entirely.
|
||||
$since = gmdate('Y-m-d H:i:s', time() - 3600);
|
||||
|
||||
return self::RATE_LIMIT <= (int) $this->db->fetchOne(
|
||||
'SELECT COUNT(*) FROM form_submission WHERE sender_hash = ? AND created_at >= ?',
|
||||
[$senderHash, $since],
|
||||
);
|
||||
}
|
||||
|
||||
public function store(string $formId, array $values, bool $consent, string $senderHash): string
|
||||
{
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$now = gmdate('Y-m-d H:i:s');
|
||||
$this->db->insert('form_submission', [
|
||||
'id' => $id, 'form_id' => $formId,
|
||||
'data' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'sender_hash' => $senderHash, 'created_at' => $now,
|
||||
'consent_at' => $consent ? $now : null, 'delivered' => false,
|
||||
]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function markDelivered(string $submissionId): void
|
||||
{
|
||||
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]);
|
||||
}
|
||||
|
||||
/** @return list<array{id: string, form: string, data: array, createdAt: string, consentAt: ?string, delivered: bool}> */
|
||||
public function submissions(int $limit = 100): array
|
||||
{
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => $row['id'],
|
||||
'form' => $row['name'] ?? $row['form_id'],
|
||||
'data' => json_decode($row['data'], true, 512, JSON_THROW_ON_ERROR),
|
||||
'createdAt' => $row['created_at'],
|
||||
'consentAt' => $row['consent_at'],
|
||||
'delivered' => (bool) $row['delivered'],
|
||||
], $this->db->fetchAllAssociative(
|
||||
'SELECT s.*, f.name FROM form_submission s LEFT JOIN form f ON f.id = s.form_id ORDER BY s.created_at DESC, s.id DESC LIMIT '.max(1, min($limit, 500)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\FormRepository;
|
||||
use App\Domain\InvalidForm;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class FormAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private FormRepository $forms)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
'forms' => $this->forms->all(),
|
||||
'types' => FormRepository::TYPES,
|
||||
'submissions' => $this->forms->submissions(50),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
|
||||
public function save(Request $request, string $slug): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('forms', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$fields = [];
|
||||
foreach ($request->request->all('fields') as $row) {
|
||||
if (!is_array($row) || '' === trim((string) ($row['label'] ?? ''))) {
|
||||
continue;
|
||||
}
|
||||
$fields[] = [
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'type' => (string) ($row['type'] ?? 'text'),
|
||||
'label' => (string) $row['label'],
|
||||
'required' => '' !== (string) ($row['required'] ?? ''),
|
||||
'help' => (string) ($row['help'] ?? ''),
|
||||
'options' => array_map(trim(...), explode(',', (string) ($row['options'] ?? ''))),
|
||||
];
|
||||
}
|
||||
try {
|
||||
$this->forms->save(
|
||||
'neu' === $slug ? $request->request->getString('slug') : $slug,
|
||||
$request->request->getString('name'),
|
||||
$fields,
|
||||
[
|
||||
'recipient' => $request->request->getString('recipient'),
|
||||
'subject' => $request->request->getString('subject'),
|
||||
'success' => $request->request->getString('success'),
|
||||
'replyToField' => $request->request->getString('replyToField'),
|
||||
],
|
||||
$request->request->getInt('expectedVersion', -1),
|
||||
);
|
||||
} catch (InvalidForm|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Das Formular wurde gespeichert.', 'success');
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_forms');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
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 MailerInterface $mailer,
|
||||
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'), '/') ?: 'home';
|
||||
$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);
|
||||
|
||||
$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($page, $form, $checked, new Response(status: 422));
|
||||
}
|
||||
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(('home' === $path ? '/' : '/'.$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']] ?? '');
|
||||
}
|
||||
$email = (new Email())
|
||||
->from($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;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderPage(array $page, array $form, array $checked, Response $response): Response
|
||||
{
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
$issued = time();
|
||||
$states = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page, $form, $checked, $issued): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
$slug = $block['props']['form'] ?? '';
|
||||
$mine = $slug === $form['slug'];
|
||||
$states[$block['id']] = [
|
||||
'form' => $mine ? $form : $this->forms->findBySlug($slug),
|
||||
'sent' => false,
|
||||
// Only the form that was submitted shows messages and keeps its input.
|
||||
'errors' => $mine ? $checked['errors'] : [],
|
||||
'values' => $mine ? $checked['values'] : [],
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
'issued' => $issued,
|
||||
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
|
||||
];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').('home' === $page['path'] ? '/' : '/'.$page['path']),
|
||||
'navigation' => $navigation,
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
'needsTabs' => false,
|
||||
'forms' => $states,
|
||||
], $response);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
@@ -12,7 +13,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private string $secret, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -39,6 +40,7 @@ final class PageController extends AbstractController
|
||||
'postLists' => $this->postLists($page['document']),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -63,6 +65,37 @@ final class PageController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares one state per form block: the definition, whether this request just sent it, and a
|
||||
* signed timestamp the submission route uses to tell a person from a script.
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function formStates(array $page, string $sentSlug): array
|
||||
{
|
||||
$issued = time();
|
||||
$states = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page, $sentSlug, $issued): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
$slug = $block['props']['form'] ?? '';
|
||||
$states[$block['id']] = [
|
||||
'form' => $this->forms->findBySlug($slug),
|
||||
'sent' => '' !== $sentSlug && $sentSlug === $slug,
|
||||
'errors' => [], 'values' => [],
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
'issued' => $issued,
|
||||
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
|
||||
];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
|
||||
return $states;
|
||||
}
|
||||
|
||||
/** True when the stored document contains at least one block of that type. */
|
||||
private function hasType(array $document, string $type): bool
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
/** A form definition the CMS refuses to store, with a message meant for the editor. */
|
||||
final class InvalidForm extends \RuntimeException
|
||||
{
|
||||
}
|
||||
Reference in new issue
Block a user