A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
8.0 KiB
PHP
164 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Content\FormRepository;
|
|
use App\Content\Languages;
|
|
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,
|
|
private Languages $languages,
|
|
) {
|
|
}
|
|
|
|
#[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());
|
|
|
|
// Abgeschickt von einer übersetzten Seite (ADR 111): Meldungen in dieser Sprache, und der
|
|
// Weg zurück führt auf die übersetzte Seite - sofern es sie gibt.
|
|
$language = $request->request->getString('language');
|
|
$translatedPath = null;
|
|
if ($this->languages->isExtra($language)) {
|
|
$translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null;
|
|
}
|
|
$language = null === $translatedPath ? null : $language;
|
|
|
|
$checked = $this->forms->check($form, $request->request->all(), $language);
|
|
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, null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
|
|
}
|
|
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.
|
|
$back = null === $language ? $this->settings->publicPath($path) : '/'.$language.($page['path'] === $this->settings->homePath() ? '' : '/'.$translatedPath);
|
|
|
|
return $this->redirect($back.'?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, ?string $translatedAddress = null): 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 = $translatedAddress ?? ($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;
|
|
}
|
|
}
|