Cloudflare Turnstile optional, Formular-Fehlerseite repariert
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

Turnstile laesst sich in den Einstellungen einschalten: aus, bis beide
Schluessel eingetragen sind; die Sicherheitsrichtlinie oeffnet sich nur
auf Seiten mit Formular, und nur fuer challenges.cloudflare.com.
Antwortet Cloudflare nicht, gilt die Absendung als bestanden - ein
Ausfall dort darf keine Kundenanfrage kosten. Die drei eingebauten
Pruefungen ohne Dritte bleiben davon unberuehrt.

Dabei gefunden: wer ein Pflichtfeld vergass, bekam die Seite ohne
Gestaltung zurueck. Der Formularcode baute sie selbst ein zweites Mal
auf und liess Theme, Blockstile und Schriften weg. Jetzt geht auch
dieser Weg durch den Seitencontroller, die Meldungen reisen als
Anfrageattribut mit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 15:39:40 +02:00
1 parent a2dfd0f796
commit e0d06ac44c
11 files changed
+311 -44

No files matched your search

+38 -6
View File
@@ -16,6 +16,7 @@ use App\Content\SiteSettings;
use App\Content\StyleBundle;
use App\Content\StyleCompiler;
use App\Content\Theme;
use App\Content\Turnstile;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -23,7 +24,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 FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private string $secret, private string $siteOrigin)
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin)
{
}
@@ -38,7 +39,9 @@ final class PageController extends AbstractController
$home = $this->settings->homePath();
$path = '' === $path ? $home : $path;
// One canonical address for the root page: its own path redirects to "/".
if ($path === $home && '/' !== $request->getPathInfo()) {
// Ausgenommen der Weg aus dem Formularversand: der zeigt dieselbe Seite mit Meldungen,
// und eine Umleitung wuerde genau die Meldungen verwerfen.
if ($path === $home && '/' !== $request->getPathInfo() && null === $request->attributes->get('kant_form_state')) {
return $this->redirectToRoute('home', status: 301);
}
$page = $this->pages->findPublished($path);
@@ -89,7 +92,7 @@ final class PageController extends AbstractController
// Own scripts arrive as one file per page, never inline, so the policy can stay
// hash based and no page without a script loses its strict setting.
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
'breadcrumbs' => $this->breadcrumbs($page),
'socialImage' => $this->socialImage($page),
'structuredData' => $this->structuredData($page),
@@ -107,8 +110,14 @@ final class PageController extends AbstractController
]);
}
$wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || [] !== $this->code->scriptsOf($page['document']));
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
}
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
}
@@ -145,9 +154,15 @@ final class PageController extends AbstractController
// hier zu sagen kostet nichts und nimmt einem eingeschleusten Skript - oder einer
// eingebetteten fremden Seite - die Möglichkeit, danach zu fragen.
$response->headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
// Steht ein Rahmen von Cloudflare offen, dann gehoert dorthin auch das Skript, das ihn
// oeffnet - sonst laedt die Pruefung nicht und niemand kann das Formular absenden.
$scriptSources = $allowScript ? ["'self'"] : ["'none'"];
if (in_array('challenges.cloudflare.com', $frameHosts, true)) {
$scriptSources = ["'self'", Turnstile::HOST];
}
$response->headers->set('Content-Security-Policy', sprintf(
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; frame-src %s; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
$allowScript ? "'self'" : "'none'",
implode(' ', $scriptSources),
implode(' ', $styleSources),
implode(' ', $fontSources),
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
@@ -162,10 +177,20 @@ final class PageController extends AbstractController
*
* @return array<string, array>
*/
private function formStates(array $page, string $sentSlug): array
/**
* Der Zustand jedes Formulars auf dieser Seite.
*
* Kommt die Anfrage aus dem Formularversand zurueck, weil eine Eingabe fehlte, dann stehen die
* Meldungen und die eingetippten Werte schon fest und werden hier uebernommen. Genau deshalb
* laeuft dieser Fall durch dieselbe Stelle wie jeder andere Seitenaufruf: vorher baute der
* Formularcode die Seite selbst noch einmal auf - ohne Theme, ohne Schriften, ohne
* Stylesheet-Buendel. Wer ein Pflichtfeld vergass, bekam eine Seite ohne Gestaltung zu sehen.
*/
private function formStates(array $page, string $sentSlug, ?Request $request = null): array
{
$issued = time();
$states = [];
$uebergeben = is_array($request?->attributes->get('kant_form_state')) ? $request->attributes->get('kant_form_state') : [];
$walk = function (array $blocks) use (&$walk, &$states, $page, $sentSlug, $issued): void {
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null)) {
@@ -177,12 +202,19 @@ final class PageController extends AbstractController
'blockId' => $block['id'], 'page' => $page,
'issued' => $issued,
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
// Leer, solange die Pruefung aus ist - dann steht auch kein Widget da.
'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '',
];
}
$walk($block['children'] ?? []);
}
};
$walk($page['document']['blocks'] ?? []);
foreach ($uebergeben as $blockId => $zustand) {
if (isset($states[$blockId])) {
$states[$blockId] = [...$states[$blockId], ...$zustand];
}
}
return $states;
}