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
@@ -3,3 +3,4 @@ APP_DEBUG=1
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
|
||||
SITE_ORIGIN=http://127.0.0.1:8080
|
||||
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
|
||||
MAILER_DSN=sendmail://default
|
||||
+2
-1
@@ -6,14 +6,15 @@
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-pdo": "*",
|
||||
"doctrine/dbal": "^4.3",
|
||||
"doctrine/migrations": "^3.9",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/rate-limiter": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/security-csrf": "7.4.*",
|
||||
|
||||
Generated
+406
-2
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "89e340d3971805cede2826b7283eb1f2",
|
||||
"content-hash": "86e20aaf0bceb3b5c42bf6aca03ce8ac",
|
||||
"packages": [
|
||||
{
|
||||
"name": "doctrine/dbal",
|
||||
@@ -251,6 +251,83 @@
|
||||
],
|
||||
"time": "2026-01-29T07:11:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/lexer",
|
||||
"version": "3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/lexer.git",
|
||||
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
|
||||
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^12",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"psalm/plugin-phpunit": "^0.18.3",
|
||||
"vimeo/psalm": "^5.21"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Common\\Lexer\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Guilherme Blanco",
|
||||
"email": "guilhermeblanco@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Roman Borschel",
|
||||
"email": "roman@code-factory.org"
|
||||
},
|
||||
{
|
||||
"name": "Johannes Schmitt",
|
||||
"email": "schmittjoh@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.",
|
||||
"homepage": "https://www.doctrine-project.org/projects/lexer.html",
|
||||
"keywords": [
|
||||
"annotations",
|
||||
"docblock",
|
||||
"lexer",
|
||||
"parser",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/lexer/issues",
|
||||
"source": "https://github.com/doctrine/lexer/tree/3.0.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpdoctrine",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-02-05T11:56:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/migrations",
|
||||
"version": "3.9.7",
|
||||
@@ -354,6 +431,73 @@
|
||||
],
|
||||
"time": "2026-04-23T19:33:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "egulias/email-validator",
|
||||
"version": "4.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/egulias/EmailValidator.git",
|
||||
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
|
||||
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"doctrine/lexer": "^2.0 || ^3.0",
|
||||
"php": ">=8.1",
|
||||
"symfony/polyfill-intl-idn": "^1.26"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.2",
|
||||
"vimeo/psalm": "^5.12"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "4.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Egulias\\EmailValidator\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Eduardo Gulias Davis"
|
||||
}
|
||||
],
|
||||
"description": "A library for validating emails against several RFCs",
|
||||
"homepage": "https://github.com/egulias/EmailValidator",
|
||||
"keywords": [
|
||||
"email",
|
||||
"emailvalidation",
|
||||
"emailvalidator",
|
||||
"validation",
|
||||
"validator"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/egulias/EmailValidator/issues",
|
||||
"source": "https://github.com/egulias/EmailValidator/tree/4.0.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/egulias",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-03-06T22:45:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
@@ -2019,6 +2163,179 @@
|
||||
],
|
||||
"time": "2026-08-30T21:24:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mailer",
|
||||
"version": "v7.4.17",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mailer.git",
|
||||
"reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584",
|
||||
"reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"egulias/email-validator": "^2.1.10|^3|^4",
|
||||
"php": ">=8.2",
|
||||
"psr/event-dispatcher": "^1",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/mime": "^7.2|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/http-client-contracts": "<2.5",
|
||||
"symfony/http-kernel": "<6.4",
|
||||
"symfony/messenger": "<6.4",
|
||||
"symfony/mime": "<6.4",
|
||||
"symfony/twig-bridge": "<6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-client": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/twig-bridge": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Mailer\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Helps sending emails",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mailer/tree/v7.4.17"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-21T17:40:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v7.4.18",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mime.git",
|
||||
"reference": "bf328d82105831db3e409195db0540ff57f27c80"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80",
|
||||
"reference": "bf328d82105831db3e409195db0540ff57f27c80",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-intl-idn": "^1.10",
|
||||
"symfony/polyfill-mbstring": "^1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"egulias/email-validator": "~3.0.0",
|
||||
"phpdocumentor/reflection-docblock": "<5.2|>=7",
|
||||
"phpdocumentor/type-resolver": "<1.5.1",
|
||||
"symfony/mailer": "<6.4",
|
||||
"symfony/serializer": "<6.4.3|>7.0,<7.0.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"egulias/email-validator": "^2.1.10|^3.1|^4",
|
||||
"league/html-to-markdown": "^5.0",
|
||||
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
"symfony/property-info": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4.44|^7.4.17|^8.1.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Mime\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Allows manipulating MIME messages",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"mime",
|
||||
"mime-type"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mime/tree/v7.4.18"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-22T09:04:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/options-resolver",
|
||||
"version": "v7.4.8",
|
||||
@@ -2331,6 +2648,93 @@
|
||||
],
|
||||
"time": "2026-07-28T08:25:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-idn",
|
||||
"version": "v1.42.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-idn.git",
|
||||
"reference": "51b5ff5ba85452b31ec6f55490b08148612339d9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9",
|
||||
"reference": "51b5ff5ba85452b31ec6f55490b08148612339d9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"symfony/polyfill-intl-normalizer": "^1.10"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-intl": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Intl\\Idn\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Laurent Bassin",
|
||||
"email": "laurent@bassin.info"
|
||||
},
|
||||
{
|
||||
"name": "Trevor Rowbotham",
|
||||
"email": "trevor.rowbotham@pm.me"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"idn",
|
||||
"intl",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-24T10:51:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-normalizer",
|
||||
"version": "v1.42.0",
|
||||
@@ -7754,8 +8158,8 @@
|
||||
"platform": {
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-pdo": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
|
||||
@@ -17,3 +17,8 @@ framework:
|
||||
disallow_search_engine_index: '%kernel.debug%'
|
||||
php_errors:
|
||||
log: true
|
||||
mailer:
|
||||
# Default to the host's sendmail, which is what ordinary PHP hosting offers. Override with
|
||||
# MAILER_DSN in .env.local for SMTP. Delivery failures never lose a submission: it is
|
||||
# stored first and stays visible as undelivered in the administration.
|
||||
dsn: '%env(resolve:MAILER_DSN)%'
|
||||
@@ -0,0 +1,4 @@
|
||||
framework:
|
||||
mailer:
|
||||
# Tests never send real mail; the transport collects messages in memory instead.
|
||||
dsn: 'null://null'
|
||||
@@ -10,7 +10,12 @@ services:
|
||||
arguments: ['%env(resolve:DATABASE_URL)%']
|
||||
App\Controller\PageController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
App\Controller\FormController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
@@ -192,3 +192,21 @@ ADR 015 hatte Tabs zurückgestellt, weil es ohne JavaScript keine zugängliche L
|
||||
Die Editorvorschau führt kein JavaScript aus (siehe QUALITY). Tabs erscheinen dort deshalb als untereinanderliegende Abschnitte. Das steht so im Eigenschaftenpanel, damit der Unterschied zur veröffentlichten Seite nicht als Fehler missverstanden wird.
|
||||
|
||||
Ein neuer Tab-Block bringt zwei Tabs mit: ein einzelner Tab ist keine Tabgruppe, und das Skript verbessert erst ab zwei Einträgen. Ein Tab außerhalb einer Tabgruppe und eine Tabgruppe mit anderem Inhalt werden abgewiesen, auch auf oberster Dokumentebene.
|
||||
|
||||
## ADR 017 – Formulare (Dokumentschema v8, 12.09.2026)
|
||||
|
||||
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
|
||||
|
||||
**Kein CSRF-Token und keine Sitzung auf dem öffentlichen Weg.** Symfonys CSRF-Schutz braucht eine Sitzung, und eine Sitzung bedeutet ein Cookie auf jeder öffentlichen Seite; das vermeidet das Projekt bewusst. Die Route ist anonym und ändert nichts, was einem Besucher gehört: eine gefälschte Anfrage könnte höchstens eine Nachricht senden, und genau dagegen wirkt der Spamschutz. Die Verwaltung der Formulardefinitionen bleibt dagegen sitzungsgebunden und CSRF-geschützt.
|
||||
|
||||
**Spamschutz ohne Dienst Dritter**, in drei Stufen: ein Honeypot-Feld außerhalb von Sichtbarkeit, Tabreihenfolge und Hilfstechnik; ein **signierter Zeitstempel** im Formular, der zu schnelle und zu alte Absendungen erkennt, ohne dafür etwas zu speichern; und ein Limit von fünf Absendungen je Absender und Stunde. Ein erkannter Bot bekommt dieselbe Antwort wie ein Erfolg, damit er nichts über die Prüfung lernt – gespeichert und gesendet wird nichts.
|
||||
|
||||
**Die Absenderadresse wird nie gespeichert**, nur ein mit dem Anwendungsgeheimnis gebildeter Hash. Das reicht für das Limit und erlaubt keinen Rückschluss auf die Herkunft. Der Zeitpunkt der Einwilligung wird getrennt festgehalten.
|
||||
|
||||
Ablauf ohne JavaScript: eine fehlerhafte Absendung rendert dieselbe Seite erneut mit HTTP 422, allen Eingaben und einer Meldung je Feld, verbunden über `aria-describedby` und `aria-invalid`; eine gültige Absendung wird gespeichert, versendet und mit HTTP 303 auf die Seite zurückgeleitet, sodass ein Neuladen nichts doppelt sendet.
|
||||
|
||||
**Gespeichert wird vor dem Versand.** Ein kaputter Mailversand verliert damit keine Anfrage: die Einsendung bleibt in der Verwaltung sichtbar und als nicht versendet gekennzeichnet. Als Transport ist `sendmail` voreingestellt, weil das gewöhnliches PHP-Hosting anbietet; per `MAILER_DSN` lässt sich SMTP einstellen.
|
||||
|
||||
**Dateiuploads sind nicht enthalten.** Anhänge brauchen einen eigenen, nicht öffentlichen Ablageort mit Zugriffsschutz, eine Typprüfung und eine Entscheidung über den Versand als Anhang – eine eigene Sicherheitsfläche, die nicht nebenbei entsteht. Das Formular des Piloten hat kein Dateifeld. Der Punkt bleibt im Ausbauplan offen.
|
||||
|
||||
Beim Schreiben der Tests fiel ein echter Fehler auf: das Stundenfenster des Limits wurde mit der lokalen Uhrzeit gebildet, die Zeitstempel der Einsendungen aber in UTC. Bei einer Uhr vor UTC hätte das Limit nie gegriffen. Beide Seiten rechnen jetzt in UTC.
|
||||
+3
-1
@@ -84,10 +84,12 @@ Die oben erhobenen Abschnittsarten als Blöcke: Symbol-und-Text-Karte, Kennzahl,
|
||||
|
||||
Abnahme: Die Startseite des Piloten ist ohne Code nachbaubar. Keine neue öffentliche JavaScript-Abhängigkeit.
|
||||
|
||||
### D – Formulare
|
||||
### D – Formulare — **erledigt am 12.09.2026, ohne Dateiuploads**
|
||||
|
||||
*Braucht: nichts; das größte eigenständige Paket.*
|
||||
|
||||
Umgesetzt: Feldtypen Text, E-Mail, Telefon, Mehrzeiliges, Auswahl und Einwilligung, serverseitige Validierung mit Meldung je Feld, Einwilligungszeitpunkt, E-Mail-Versand mit Antwortadresse, gespeicherte Einsendungen in der Verwaltung und Spamschutz aus Honeypot, signiertem Zeitstempel und Rate-Limit. **Offen geblieben:** Dateiuploads (eigener nicht öffentlicher Ablageort und Typprüfung, Begründung in ADR 017), Löschen und Umbenennen von Formularen, Export und Aufbewahrungsfrist für Einsendungen. Der tatsächliche Versand ist ungeprüft und gehört zu Paket J.
|
||||
|
||||
Formularbaukasten mit Feldtypen Text, E-Mail, Telefon, Auswahl, Mehrzeiliges, Einwilligungskästchen und Datei. Pflichtfelder, serverseitige Validierung mit Fehlermeldungen am Feld, Beschriftungen und Fehlerauszeichnung für Screenreader. Versand per E-Mail mit Antwortadresse, dazu Speicherung der Einsendungen mit Ansicht in der Verwaltung. Spam-Schutz ohne Drittanbieter-CAPTCHA: Honeypot, Zeitschwelle, Rate-Limit pro IP. Einwilligungstext mit Verweis auf die Datenschutzerklärung, Zeitpunkt der Einwilligung wird mitgespeichert.
|
||||
|
||||
Abnahme: Das Kontaktformular des Piloten ist nachgebaut, funktioniert ohne JavaScript, weist ungültige Eingaben serverseitig ab und stellt eine E-Mail zu. Ein abgewiesener Versand verliert keine Eingaben.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Formulare, 12.09.2026
|
||||
|
||||
- **117 PHP-Tests / 450 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **14 JavaScript-Modelltests**. `composer audit` meldet keine bekannten Advisories, auch nach Aufnahme von `symfony/mailer`.
|
||||
- Vollständiger Ablauf geprüft: Ausgabe ohne Skript, gültige Absendung wird gespeichert und mit HTTP 303 zurückgeleitet, damit ein Neuladen nichts doppelt sendet, und die Erfolgsmeldung erscheint auf der Zielseite.
|
||||
- **Datenschutz gezielt geprüft:** in der gespeicherten Zeile steht keine Absenderadresse, sondern ein 64-stelliger Hash; der Zeitpunkt der Einwilligung wird getrennt festgehalten.
|
||||
- Fehlerhafte Absendung: HTTP 422, **nichts gespeichert und nichts gesendet**, vier Meldungen an den richtigen Feldern, verbunden über `aria-describedby` und `aria-invalid`, genau eine Zusammenfassung mit `role=alert`, und sämtliche Eingaben bleiben erhalten.
|
||||
- Vier Spamfälle werden still verworfen, ohne zu speichern, und wie ein Erfolg beantwortet: gefüllter Honeypot, Absendung innerhalb der Mindestzeit, gefälschte Zeitstempelsignatur und ein zu alter Zeitstempel. Das Limit stoppt die sechste Absendung innerhalb einer Stunde.
|
||||
- Die Formularverwaltung verlangt Anmeldung, Editorrolle und CSRF-Token; eine ungültige Empfängeradresse wird mit einer Meldung abgewiesen statt gespeichert.
|
||||
- **Ein echter Fehler wurde dabei gefunden und behoben:** das Stundenfenster des Limits entstand aus der lokalen Uhrzeit, die Zeitstempel der Einsendungen aber aus UTC. Auf einer Uhr vor UTC – etwa in Deutschland – hätte das Limit nie gegriffen und der Spamschutz wäre an dieser Stelle wirkungslos gewesen. Beide Seiten rechnen jetzt in UTC; der Test deckt genau diesen Fall ab.
|
||||
- **Der E-Mail-Versand wurde nie wirklich ausgeführt.** Die Tests nutzen einen Transport, der nichts sendet. Ob `sendmail` auf dem Zielhosting funktioniert, ob Nachrichten zugestellt werden und ob sie im Spamordner landen, ist damit vollständig offen und gehört zu Arbeitspaket J.
|
||||
- **Auch für diesen Schritt wurden auf Wunsch des Nutzers keine Browser-, axe- und Lighthouse-Läufe ausgeführt.** Formularausgabe und Verwaltung sind nicht visuell und nicht mit einem Screenreader geprüft. Gerade bei Formularen ist das ein erheblicher Vorbehalt: die Fehlerauszeichnung ist im Markup korrekt gesetzt, aber nicht in einer echten Hilfstechnik gehört worden.
|
||||
|
||||
## Ergänzung: Tabs, 12.09.2026
|
||||
|
||||
- **109 PHP-Tests / 395 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **14 JavaScript-Modelltests**.
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -25,6 +25,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- Dokumentschema v8: Formulare. Verwaltung unter `/admin/forms` mit Feldtypen Text, E-Mail, Telefon, Mehrzeiliges, Auswahl und Einwilligung, Empfänger, Betreff, Antwortadresse und Erfolgsmeldung. **Der Formularblock im Seiteninhalt enthält nur die Adresse** – Felder und Empfänger liegen in der Datenbank, damit gespeicherter Inhalt nicht ändern kann, wohin eine Anfrage geht. Serverseitige Validierung mit einer Meldung je Feld über `aria-describedby`/`aria-invalid`, Eingaben bleiben bei Ablehnung erhalten (HTTP 422), gültige Absendung per HTTP 303 zurück auf die Seite. Spamschutz ohne Dienst Dritter: Honeypot, signierter Zeitstempel und fünf Absendungen je Absender und Stunde. **Die Absenderadresse wird nie gespeichert**, nur ein Hash; der Zeitpunkt der Einwilligung getrennt. Gespeichert wird vor dem Versand, damit ein kaputter Mailversand keine Anfrage verliert; nicht versendete Einsendungen sind in der Verwaltung gekennzeichnet. Voreingestellter Transport `sendmail`, per `MAILER_DSN` auf SMTP umstellbar. Kein CSRF-Token auf dem öffentlichen Weg, weil dafür eine Sitzung und damit ein Cookie auf jeder öffentlichen Seite nötig wäre; Begründung in ADR 017.
|
||||
- Dokumentschema v7: Tabs. Der Server liefert Ankerliste und alle Panels mit Überschriften, also vollständigen Inhalt ohne JavaScript; `public/assets/tabs.js` macht daraus ein echtes Tab-Widget mit `tablist`/`tab`/`tabpanel` und Pfeiltastenbedienung. **Die strenge Richtlinie bleibt Voreinstellung:** öffentliche Seiten senden weiterhin `script-src 'none'`, nur eine Seite mit Tab-Block sendet `script-src 'self'`. Inline-Skript ist nie erlaubt. In der Editorvorschau erscheinen Tabs untereinander, weil die Vorschau kein JavaScript ausführt; ein Hinweis im Eigenschaftenpanel sagt das.
|
||||
- Dokumentschema v6 mit zehn Blöcken für die Pilotseite: Symbolkarte, Kennzahl, Kundenstimme mit Bewertung, Logoleiste, Aufruf-Band, Kontaktdaten, Trennlinie, Abstand, Akkordeon und Akkordeon-Eintrag. Symbole als Inline-SVG aus einer festen Liste, kein Symbolschriftsatz und keine externe Anfrage. Kontaktdaten werden zu `tel:`- und `mailto:`-Verweisen. Akkordeon über `details`/`summary`, also ohne öffentliches JavaScript. **Tabs sind bewusst nicht umgesetzt** – ohne JavaScript gibt es dafür keine zugängliche Lösung; Begründung in ADR 015.
|
||||
- Beiträge: Datum, Autor, Auszug und Beitragsbild als Seitenspalten, gepflegt über ein Formular in der Seitenübersicht. Kategorien und Schlagwörter als Begriffe mit eigenen Archiven unter `/kategorie/{slug}` und `/schlagwort/{slug}`, Gesamtübersicht unter `/beitraege` mit Paginierung über `?seite=N`, RSS unter `/feed.xml`. Der Block `postlist` (Dokumentschema v5) speichert nur Anzahl und Filter; die Beiträge stellt der Controller beim Ausliefern zusammen, damit ein gespeichertes Dokument keinen unveröffentlichten Beitrag zeigen kann.
|
||||
@@ -35,8 +36,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
|
||||
## Geprüft
|
||||
|
||||
- 109 PHP-Tests / 395 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 14 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- 117 PHP-Tests / 450 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 14 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||
- Formulare geprüft: Ausgabe ohne Skript, Honeypot außerhalb der Tabreihenfolge, gültige Absendung wird gespeichert und per HTTP 303 zurückgeleitet, Einwilligungszeitpunkt festgehalten, **keine Absenderadresse in der Zeile** sondern ein 64-stelliger Hash, Erfolgsmeldung auf der Zielseite. Fehlerhafte Absendung: HTTP 422, nichts gespeichert, vier Meldungen an den richtigen Feldern mit `aria-describedby` und `aria-invalid`, genau eine Zusammenfassung mit `role=alert`, alle Eingaben erhalten. Vier Spamfälle (gefüllter Honeypot, zu schnell, gefälschte Signatur, zu alter Zeitstempel) werden still verworfen und wie ein Erfolg beantwortet. Das Limit stoppt die sechste Absendung innerhalb einer Stunde. Die Formularverwaltung verlangt Anmeldung, Editorrolle und CSRF-Token und weist eine ungültige Empfängeradresse mit Meldung ab.
|
||||
- Tabs geprüft: beide Panels samt Inhalt und Überschrift stehen ohne JavaScript im HTML und sind serverseitig nicht versteckt, die Ankerliste verweist auf die Panels, das Skript wird als externe Datei geladen und nie inline. Die Richtlinie sendet `script-src 'self'` ausschließlich auf der Seite mit Tab-Block; eine Seite ohne Tabs und die 404-Seite behalten `script-src 'none'` und laden kein Skript. Ein Tab außerhalb einer Tabgruppe, eine Tabgruppe mit Textkind und Tabs in Schema v6 werden abgewiesen. Im Modell: eine neue Tabgruppe bringt zwei Tabs mit eigenen IDs mit, nimmt nur Tabs auf, und ein Tab lässt sich weder in eine Sektion noch in ein Akkordeon einfügen.
|
||||
- Neue Blöcke geprüft: eine Seite aus allen zehn Abschnittsarten wird unverändert gespeichert, veröffentlicht und ausgegeben; Kontaktdaten werden zu `tel:`- und `mailto:`-Verweisen mit bereinigter Nummer, das Akkordeon ist ein echtes `details`-Element, Symbole sind Inline-SVG, die Bewertung ist für Screenreader ausgeschrieben, und die Ausgabe enthält kein `script`. Vierzehn abgewiesene Eingaben schreiben nichts: unbekanntes Symbol, fehlender Titel, `javascript:`-Ziel, Bewertung außerhalb 0–5 und als Zeichenkette, leerer Kontaktblock, ungültige Telefonnummer und E-Mail-Adresse, unbekannte Abstandsgröße, unerlaubtes Linkschema, Text in Logoleiste und Akkordeon, Akkordeon-Eintrag ohne Akkordeon sowie Kinder an einer Trennlinie. Zusätzlich geprüft, dass die neuen Blöcke in Schema v5 weiterhin abgewiesen werden.
|
||||
- Beiträge geprüft: Sortierung nach Datum mit Paginierung und Begrenzung zu großer Seitenzahlen, Seiten und unveröffentlichte Beiträge bleiben aus dem Archiv, Kategorie- und Schlagwortarchive einschließlich getrennter Namensräume, erneutes Speichern ersetzt die Zuordnung statt sie zu ergänzen, ein leer gewordenes Archiv antwortet weiter mit HTTP 200 und leerer Liste, Löschen löst die Zuordnungen und lässt die Begriffe bestehen, Veröffentlichen stempelt ein fehlendes Datum und lässt ein gepflegtes unangetastet, Seiten werden nie gestempelt. Sechs abgewiesene Metadateneingaben schreiben nichts und erhöhen die Version nicht. Die Beitragsliste zeigt nur Beiträge des gefilterten Begriffs, und das gespeicherte Dokument enthält weiterhin ausschließlich Anzahl und Filter. Der Feed ist wohlgeformtes XML mit absoluten Adressen und enthält keinen unveröffentlichten Beitrag.
|
||||
@@ -62,6 +64,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- **Dateiuploads in Formularen fehlen** – Anhänge brauchen einen nicht öffentlichen Ablageort mit Zugriffsschutz und eine eigene Typprüfung; Begründung in ADR 017. Ebenfalls offen: Formulare lassen sich nicht löschen oder umbenennen, Einsendungen nicht exportieren oder löschen, und es gibt keine Aufbewahrungsfrist. Der tatsächliche E-Mail-Versand ist nie ausgeführt worden: die Tests nutzen einen Transport, der nichts sendet.
|
||||
- Das Tab-Skript ist nicht im Browser geprüft: Pfeiltastenbedienung, `aria-selected`, das Verstecken der Panels und die Auswahl über ein Sprungziel sind nur im Code umgesetzt, nicht ausgeführt. Geprüft ist bisher ausschließlich die serverseitig erzeugte Auszeichnung und die Sicherheitsrichtlinie.
|
||||
- Die neuen Blöcke lassen sich nicht in einer Vorlage bündeln: die drei Abschnittsvorlagen sind unverändert. Die Logoleiste hat keine Graustufen- oder Größenoption, die Kundenstimme kein Bild, die Symbolkarte keine eigene Farbe.
|
||||
- Beiträge haben noch keine Vorschau des Beitragsbilds in der Verwaltung: das Bild wird über seine Mediathek-ID eingetragen. Es gibt keine Begriffsverwaltung (Umbenennen, Zusammenführen, Löschen ungenutzter Begriffe), keine Beitragssuche und keine Archivbeschreibungen. Die Paginierung der Übersicht ist auf zehn Beiträge je Seite festgelegt.
|
||||
@@ -76,7 +79,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
|
||||
## Nächster konkreter Schritt
|
||||
|
||||
Arbeitspaket D aus `docs/AUSBAUPLAN.md`: das Formularsystem – Feldtypen, serverseitige Validierung mit Fehlermeldungen am Feld, Einwilligung, E-Mail-Versand, gespeicherte Einsendungen und Spam-Schutz ohne Drittanbieter-CAPTCHA. Ohne das ist der Pilot nicht livefähig.
|
||||
Arbeitspaket E aus `docs/AUSBAUPLAN.md`: SEO und maschinelle Zugänglichkeit – SEO-Felder je Seite, `sitemap.xml`, `robots.txt`, Weiterleitungen sowie strukturierte Daten für `LocalBusiness`, `Article` und `BreadcrumbList`.
|
||||
|
||||
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260912000700 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Forms with their field definitions and stored submissions.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$form = $schema->createTable('form');
|
||||
$form->addColumn('id', 'string', ['length' => 64]);
|
||||
$form->addColumn('website_id', 'string', ['length' => 64]);
|
||||
$form->addColumn('locale', 'string', ['length' => 12]);
|
||||
$form->addColumn('slug', 'string', ['length' => 80]);
|
||||
$form->addColumn('name', 'string', ['length' => 120]);
|
||||
$form->addColumn('fields', 'json');
|
||||
// Recipient, subject, consent text and the message shown after a successful send.
|
||||
$form->addColumn('settings', 'json');
|
||||
$form->addColumn('version', 'integer', ['default' => 0]);
|
||||
$form->setPrimaryKey(['id']);
|
||||
$form->addUniqueIndex(['website_id', 'locale', 'slug'], 'uniq_form_slug');
|
||||
|
||||
$submission = $schema->createTable('form_submission');
|
||||
$submission->addColumn('id', 'string', ['length' => 64]);
|
||||
$submission->addColumn('form_id', 'string', ['length' => 64]);
|
||||
$submission->addColumn('data', 'json');
|
||||
// Keyed hash of the sender address, never the address itself: enough to rate limit,
|
||||
// not enough to reconstruct who sent from where.
|
||||
$submission->addColumn('sender_hash', 'string', ['length' => 64]);
|
||||
$submission->addColumn('created_at', 'datetime_immutable');
|
||||
// When the sender ticked the consent box. Null means the form asked for no consent.
|
||||
$submission->addColumn('consent_at', 'datetime_immutable', ['notnull' => false]);
|
||||
$submission->addColumn('delivered', 'boolean', ['default' => false]);
|
||||
$submission->setPrimaryKey(['id']);
|
||||
$submission->addIndex(['form_id', 'created_at'], 'idx_submission_form');
|
||||
$submission->addIndex(['sender_hash', 'created_at'], 'idx_submission_sender');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->throwIrreversibleMigrationException('Contains form definitions and personal data from submissions. Restore a verified database backup with matching code.');
|
||||
}
|
||||
}
|
||||
@@ -18,3 +18,5 @@
|
||||
|
||||
.meta-panel>summary{font-size:12px;font-weight:600;cursor:pointer;padding:11px 13px;border:1px solid var(--line);border-radius:7px;list-style:none}.meta-panel[open]{position:relative}.meta-panel[open]>form{position:absolute;right:0;top:calc(100% + 8px);z-index:3;width:330px;background:white;border:1px solid var(--line);border-radius:10px;padding:6px 20px 22px;box-shadow:0 14px 40px #203b3422}.meta-panel form label{margin-top:16px}.meta-panel .primary{margin-top:20px;width:100%}.meta-panel textarea{min-height:74px;font-size:12px}.meta-panel input{font-size:12px}
|
||||
@media (max-width:720px){.meta-panel[open]>form{position:static;width:100%;box-shadow:none;margin-top:10px}}
|
||||
|
||||
.submission-card{align-items:flex-start}.submission-data{display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:12px 0 0;font-size:12px}.submission-data dt{font-weight:600;color:var(--muted)}.submission-data dd{margin:0;overflow-wrap:anywhere}.menu-table .check-cell{text-align:center}.menu-table .check-cell input{width:18px;min-height:18px}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
||||
@@ -45,6 +45,7 @@ export function createBlock(type) {
|
||||
if (type === 'spacer') block.props = { size: 'medium' };
|
||||
if (type === 'accordionitem') block.props = { title: 'Eine Frage', open: false };
|
||||
if (type === 'tabitem') block.props = { title: 'Neuer Tab' };
|
||||
if (type === 'form') block.props = { form: '' };
|
||||
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||
return block;
|
||||
}
|
||||
@@ -55,6 +56,7 @@ export function insertBlock(document, type, selected) {
|
||||
if (type === 'postlist') upgradeDocument(document, 5);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
let target = locate(document.blocks, selected);
|
||||
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
|
||||
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
|
||||
@@ -96,7 +98,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
|
||||
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
document.schemaVersion = Math.max(document.schemaVersion, version);
|
||||
return document;
|
||||
}
|
||||
@@ -113,7 +115,7 @@ export function effectiveDesign(block, device, key) {
|
||||
}
|
||||
|
||||
export function setDesign(document, id, device, key, value) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
|
||||
@@ -201,6 +201,7 @@ function renderProperties() {
|
||||
field('Anschrift', 'address', block.props.address || '', { multiline: true, maxLength: 200 });
|
||||
}
|
||||
if (block.type === 'spacer') field('Höhe', 'size', block.props.size, { choices: { small: 'Klein', medium: 'Mittel', large: 'Groß', huge: 'Sehr groß' } });
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordionitem') {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
|
||||
@@ -73,3 +73,22 @@ h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||
.block-tabitem{padding-block:4px}
|
||||
.block-tabitem:focus-visible{outline:3px solid #b55b12;outline-offset:4px}
|
||||
.tabs-heading{margin:0 0 12px;font-size:17px}
|
||||
|
||||
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
|
||||
.block-form{display:grid;gap:20px;max-width:640px}
|
||||
.form-field{display:grid;gap:7px}
|
||||
.form-field label{font-weight:600;font-size:14px}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid currentColor;border-radius:8px;background:#fff;color:inherit;font:inherit;min-height:46px}
|
||||
.form-field textarea{min-height:140px;line-height:1.6;resize:vertical}
|
||||
.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:#8b2f22}
|
||||
.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22}
|
||||
.field-note{margin:0;font-size:13px;opacity:.8}
|
||||
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7;font-weight:600}
|
||||
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:#edf3dc;color:#315b49;border:1px solid #c5d89a;font-weight:600}
|
||||
.form-missing{margin:0;padding:14px 18px;border:1px dashed currentColor;border-radius:8px}
|
||||
.check-row{display:flex;align-items:flex-start;gap:11px}
|
||||
.check-row input{width:20px;min-height:20px;flex-shrink:0;margin-top:3px}
|
||||
.check-row label{font-weight:400;line-height:1.55}
|
||||
.required-mark{color:#8b2f22}
|
||||
/* Off screen rather than display:none so a filled value still proves automation. */
|
||||
.form-trap{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
|
||||
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p>
|
||||
<div id="library" class="library">
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
</div>
|
||||
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
||||
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends 'admin/base.html.twig' %}
|
||||
{% block title %}Formulare{% endblock %}
|
||||
{% block body %}
|
||||
<header class="admin-header">
|
||||
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
|
||||
<div class="header-actions"><a href="/">Website ansehen ↗</a><span class="user-name">{{ app.user.userIdentifier }}</span><form action="{{ path('admin_logout') }}" method="post"><input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"><button class="subtle" type="submit">Abmelden</button></form></div>
|
||||
</header>
|
||||
<main id="admin-main" class="pages-main" tabindex="-1">
|
||||
<p class="eyebrow">FORMULARE</p><h1>Formulare</h1>
|
||||
<p class="muted">Felder, Empfänger und Einwilligungstext werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.</p>
|
||||
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a></p>
|
||||
|
||||
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
|
||||
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
|
||||
{% endfor %}{% endfor %}
|
||||
|
||||
{% for form in forms|merge([null]) %}
|
||||
{% set new = form is null %}
|
||||
{% set rows = new ? [] : form.fields %}
|
||||
<section class="menu-section">
|
||||
<div class="section-heading">
|
||||
<h2>{{ new ? 'Neues Formular' : form.name }}{% if not new %} <span class="count">{{ form.fields|length }} Felder</span>{% endif %}</h2>
|
||||
{% if not new %}<span class="muted">Block-Adresse <code>{{ form.slug }}</code></span>{% endif %}
|
||||
</div>
|
||||
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="menu-form">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('forms') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ new ? 0 : form.version }}">
|
||||
<div class="create-grid">
|
||||
<div>
|
||||
<label for="name-{{ loop.index }}">Name</label>
|
||||
<input id="name-{{ loop.index }}" name="name" maxlength="120" required value="{{ new ? '' : form.name }}">
|
||||
</div>
|
||||
{% if new %}
|
||||
<div>
|
||||
<label for="slug-new">Adresse</label>
|
||||
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
|
||||
<p class="field-help">Wird im Formularblock eingetragen.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label for="recipient-{{ loop.index }}">Empfänger</label>
|
||||
<input id="recipient-{{ loop.index }}" name="recipient" type="email" required value="{{ new ? '' : form.settings.recipient }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject-{{ loop.index }}">Betreff</label>
|
||||
<input id="subject-{{ loop.index }}" name="subject" maxlength="160" value="{{ new ? '' : form.settings.subject }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="reply-{{ loop.index }}">Antwortadresse aus Feld</label>
|
||||
<input id="reply-{{ loop.index }}" name="replyToField" maxlength="32" value="{{ new ? '' : form.settings.replyToField }}" placeholder="email">
|
||||
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="success-{{ loop.index }}">Meldung nach dem Absenden</label>
|
||||
<input id="success-{{ loop.index }}" name="success" maxlength="300" value="{{ new ? '' : form.settings.success }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="menu-table">
|
||||
<caption class="visually-hidden">Felder des Formulars. Eine Beschriftung zu leeren entfernt das Feld.</caption>
|
||||
<thead><tr><th scope="col">Technischer Name</th><th scope="col">Typ</th><th scope="col">Beschriftung</th><th scope="col">Pflicht</th><th scope="col">Hilfetext</th><th scope="col">Optionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows|merge([{name: '', type: 'text', label: '', required: false, help: '', options: []}, {name: '', type: 'text', label: '', required: false, help: '', options: []}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.label == '' %} class="menu-row-new"{% endif %}>
|
||||
<td><input name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" aria-label="Technischer Name von Feld {{ n + 1 }}" placeholder="vorname"></td>
|
||||
<td>
|
||||
<select name="fields[{{ n }}][type]" aria-label="Typ von Feld {{ n + 1 }}">
|
||||
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" aria-label="Beschriftung von Feld {{ n + 1 }}"></td>
|
||||
<td class="check-cell"><input type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} aria-label="Feld {{ n + 1 }} ist ein Pflichtfeld"></td>
|
||||
<td><input name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" aria-label="Hilfetext von Feld {{ n + 1 }}"></td>
|
||||
<td><input name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" aria-label="Optionen von Feld {{ n + 1 }}" placeholder="nur bei Auswahl"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert. Optionen einer Auswahl durch Komma trennen. Die letzten beiden Zeilen sind für neue Felder.</p>
|
||||
<button class="primary" type="submit">{{ new ? 'Formular anlegen' : 'Formular speichern' }}</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<div class="section-heading"><h2>Einsendungen <span class="count">{{ submissions|length }}</span></h2><span class="muted">Die neuesten 50.</span></div>
|
||||
{% if submissions is empty %}
|
||||
<div class="empty-state"><h3>Noch keine Einsendungen.</h3><p>Hier erscheinen eingegangene Nachrichten, auch wenn der E-Mail-Versand fehlschlägt.</p></div>
|
||||
{% else %}
|
||||
<div class="page-list">
|
||||
{% for entry in submissions %}
|
||||
<article class="page-card submission-card">
|
||||
<div class="page-info">
|
||||
<h3>{{ entry.form }}</h3>
|
||||
<p>{{ entry.createdAt|date('d.m.Y H:i') }} UTC{% if entry.consentAt %} · Einwilligung {{ entry.consentAt|date('d.m.Y H:i') }}{% endif %}</p>
|
||||
<dl class="submission-data">{% for key, value in entry.data %}<dt>{{ key }}</dt><dd>{{ value }}</dd>{% endfor %}</dl>
|
||||
</div>
|
||||
<span class="badge {{ entry.delivered ? 'published' : '' }}">{{ entry.delivered ? 'Versendet' : 'Nicht versendet' }}</span>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,7 @@
|
||||
<main id="admin-main" class="pages-main" tabindex="-1">
|
||||
<p class="eyebrow">NAVIGATION</p><h1>Menüs</h1>
|
||||
<p class="muted">Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine externe Adresse. Die Reihenfolge ergibt sich aus der Position, die Verschachtelung aus der Ebene.</p>
|
||||
<a class="secondary" href="{{ path('admin_pages') }}">Zurück zu den Seiten</a>
|
||||
<p class="admin-links"><a class="secondary" href="{{ path('admin_pages') }}">Seiten</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a></p>
|
||||
|
||||
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
|
||||
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<main id="admin-main" class="pages-main" tabindex="-1">
|
||||
<p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1>
|
||||
<p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p>
|
||||
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</a></p>
|
||||
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</a></p>
|
||||
|
||||
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
|
||||
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% set tag = type == 'section' ? 'section' : 'div' %}
|
||||
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
|
||||
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
||||
{% if type == 'section' %}</div>{% endif %}
|
||||
</{{ tag }}>
|
||||
{% elseif type == 'heading' %}
|
||||
@@ -39,7 +39,7 @@
|
||||
</figure>
|
||||
{% elseif type == 'logos' %}
|
||||
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
|
||||
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}</li>{% endfor %}
|
||||
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}</li>{% endfor %}
|
||||
</ul>
|
||||
{% elseif type == 'cta' %}
|
||||
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
|
||||
@@ -59,6 +59,10 @@
|
||||
<hr id="{{ block.id }}" class="block-divider{{ designClasses }}">
|
||||
{% elseif type == 'spacer' %}
|
||||
<div id="{{ block.id }}" class="block-spacer spacer-{{ props.size }}{{ designClasses }}" aria-hidden="true"></div>
|
||||
{% elseif type == 'form' %}
|
||||
<div id="{{ block.id }}" class="block-formwrap{{ designClasses }}">
|
||||
{% include 'blocks/form.html.twig' with (forms[block.id]|default({form: null, sent: false, errors: {}, values: {}, blockId: block.id, page: {path: ''}, issued: 0, issuedSignature: ''})) only %}
|
||||
</div>
|
||||
{% elseif type == 'tabs' %}
|
||||
{# Without the script this is an anchor list followed by every panel: complete and readable.
|
||||
tabs.js turns it into a tab widget; it only changes presentation, never content. #}
|
||||
@@ -66,22 +70,22 @@
|
||||
<ul class="tabs-nav">
|
||||
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
|
||||
</ul>
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'tabitem' %}
|
||||
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
|
||||
<h3 class="tabs-heading">{{ props.title }}</h3>
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
||||
</section>
|
||||
{% elseif type == 'accordion' %}
|
||||
<div id="{{ block.id }}" class="block-accordion{{ designClasses }}">
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'accordionitem' %}
|
||||
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
|
||||
<summary>{{ props.title }}</summary>
|
||||
<div class="accordion-body">
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elseif type == 'postlist' %}
|
||||
@@ -90,7 +94,7 @@
|
||||
</div>
|
||||
{% elseif type == 'gallery' %}
|
||||
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{#
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{# Server rendered, no JavaScript. Errors are announced and each message sits at its field. #}
|
||||
{% if form is null %}
|
||||
<p class="form-missing">Dieses Formular ist nicht mehr vorhanden.</p>
|
||||
{% elseif sent %}
|
||||
<p class="form-success" role="status">{{ form.settings.success }}</p>
|
||||
{% else %}
|
||||
<form class="block-form" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate>
|
||||
<input type="hidden" name="page" value="{{ page.path }}">
|
||||
<input type="hidden" name="block" value="{{ blockId }}">
|
||||
<input type="hidden" name="issued" value="{{ issued }}">
|
||||
<input type="hidden" name="issuedSignature" value="{{ issuedSignature }}">
|
||||
{# Bait for automated senders. Hidden from view, from assistive technology and from tab order. #}
|
||||
<div class="form-trap" aria-hidden="true"><label for="website-{{ blockId }}">Website</label><input id="website-{{ blockId }}" name="website" type="text" tabindex="-1" autocomplete="off"></div>
|
||||
|
||||
{% if errors is not empty %}
|
||||
<p class="form-error-summary" role="alert">Bitte prüfe {{ errors|length }} {{ errors|length == 1 ? 'Eingabe' : 'Eingaben' }}. Die betroffenen Felder sind unten gekennzeichnet.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for field in form.fields %}
|
||||
{% set id = blockId ~ '-' ~ field.name %}
|
||||
{% set error = errors[field.name]|default(null) %}
|
||||
{% set value = values[field.name]|default('') %}
|
||||
{% set described = (field.help ? id ~ '-help ' : '') ~ (error ? id ~ '-error' : '') %}
|
||||
<div class="form-field{{ error ? ' has-error' : '' }}">
|
||||
{% if field.type == 'consent' %}
|
||||
<div class="check-row">
|
||||
<input id="{{ id }}" name="{{ field.name }}" type="checkbox" value="ja"{{ value ? ' checked' : '' }} required
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
||||
<label for="{{ id }}">{{ field.label }} <span class="required-mark" aria-hidden="true">*</span></label>
|
||||
</div>
|
||||
{% else %}
|
||||
<label for="{{ id }}">{{ field.label }}{% if field.required %} <span class="required-mark" aria-hidden="true">*</span><span class="visually-hidden"> (Pflichtfeld)</span>{% endif %}</label>
|
||||
{% if field.type == 'textarea' %}
|
||||
<textarea id="{{ id }}" name="{{ field.name }}" rows="6" maxlength="5000"{{ field.required ? ' required' : '' }}
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>{{ value }}</textarea>
|
||||
{% elseif field.type == 'select' %}
|
||||
<select id="{{ id }}" name="{{ field.name }}"{{ field.required ? ' required' : '' }}
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
||||
<option value="">— bitte wählen —</option>
|
||||
{% for option in field.options %}<option value="{{ option }}"{{ value == option ? ' selected' : '' }}>{{ option }}</option>{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input id="{{ id }}" name="{{ field.name }}" value="{{ value }}" maxlength="200"
|
||||
type="{{ {email: 'email', phone: 'tel'}[field.type]|default('text') }}"
|
||||
autocomplete="{{ {email: 'email', phone: 'tel'}[field.type]|default('on') }}"
|
||||
{{ field.required ? ' required' : '' }}
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
|
||||
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button class="button" type="submit">Absenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -30,7 +30,7 @@
|
||||
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({})} only %}{% endfor %}
|
||||
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({})} only %}{% endfor %}
|
||||
</main>
|
||||
<footer class="site-footer shell">
|
||||
<a class="brand" href="/">kant<span class="brand-light">cms</span></a>
|
||||
|
||||
@@ -21,6 +21,7 @@ use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
@@ -45,6 +46,7 @@ final class CmsTest extends TestCase
|
||||
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||
$this->db->executeStatement($sql);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
@@ -17,6 +18,8 @@ use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
@@ -49,6 +52,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||
$this->db->executeStatement($sql);
|
||||
}
|
||||
@@ -107,7 +111,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
public function testAnonymousCannotReadDraftOrWrite(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus'] as $url) {
|
||||
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus', '/admin/forms'] as $url) {
|
||||
$client->request('GET', $url);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
self::assertStringNotContainsString('schemaVersion', $client->getResponse()->getContent());
|
||||
@@ -228,6 +232,175 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame('Neu', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
|
||||
}
|
||||
|
||||
private function contactForm(): FormRepository
|
||||
{
|
||||
$forms = new FormRepository($this->db);
|
||||
$forms->save('kontakt', 'Kontakt', [
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Name', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
|
||||
['name' => 'thema', 'type' => 'select', 'label' => 'Thema', 'required' => true, 'options' => ['Beratung', 'Support']],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Nachricht', 'required' => false],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich willige ein.'],
|
||||
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.'], 0);
|
||||
|
||||
return $forms;
|
||||
}
|
||||
|
||||
/** Publishes a page carrying the contact form block and returns the page path. */
|
||||
private function pageWithForm(): string
|
||||
{
|
||||
$document = DemoContent::starter('Kontakt');
|
||||
$document['schemaVersion'] = 8;
|
||||
$document['blocks'][] = ['id' => 'formblock', 'type' => 'form', 'props' => ['form' => 'kontakt']];
|
||||
$pages = new PageRepository($this->db, new DocumentValidator());
|
||||
$revision = $pages->saveDraft('p1', $document, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1']));
|
||||
$pages->publish('p1', $revision, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1']));
|
||||
|
||||
return 'home';
|
||||
}
|
||||
|
||||
/** @return array<string, string> a body that would pass every check */
|
||||
private function validBody(string $issued): array
|
||||
{
|
||||
return [
|
||||
'page' => 'home', 'block' => 'formblock', 'website' => '',
|
||||
'issued' => $issued, 'issuedSignature' => hash_hmac('sha256', $issued, 'isolated-test-secret'),
|
||||
'name' => 'Umut', 'email' => 'umut@example.com', 'thema' => 'Beratung',
|
||||
'nachricht' => 'Bitte um Rückruf.', 'datenschutz' => 'ja',
|
||||
];
|
||||
}
|
||||
|
||||
public function testFormRendersWithoutScriptAndStoresAValidSubmissionWithConsent(): void
|
||||
{
|
||||
$this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$client = self::createClient();
|
||||
|
||||
$html = $client->request('GET', '/')->html();
|
||||
self::assertStringContainsString('action="/formular/kontakt"', $html);
|
||||
self::assertStringContainsString('Ich willige ein.', $html);
|
||||
// The honeypot exists but is kept out of the tab order and away from assistive technology.
|
||||
self::assertStringContainsString('name="website"', $html);
|
||||
self::assertStringContainsString('tabindex="-1"', $html);
|
||||
self::assertStringNotContainsString('<script', $client->getResponse()->getContent());
|
||||
|
||||
$issued = (string) (time() - 10);
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody($issued));
|
||||
// Post/Redirect/Get, so reloading the result page sends nothing twice.
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertStringContainsString('?gesendet=kontakt#formblock', (string) $client->getResponse()->headers->get('Location'));
|
||||
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM form_submission');
|
||||
self::assertNotFalse($row);
|
||||
$data = json_decode($row['data'], true);
|
||||
self::assertSame('Umut', $data['name']);
|
||||
self::assertSame('ja', $data['datenschutz']);
|
||||
// The moment of consent is stored, the sender address never is.
|
||||
self::assertNotNull($row['consent_at']);
|
||||
self::assertSame(64, strlen($row['sender_hash']));
|
||||
self::assertStringNotContainsString('127.0.0.1', json_encode($row));
|
||||
|
||||
// The success message appears on the page the form sits on.
|
||||
$client->request('GET', '/?gesendet=kontakt');
|
||||
self::assertStringContainsString('Danke.', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testInvalidSubmissionKeepsEveryEntryAndNamesTheOffendingFields(): void
|
||||
{
|
||||
$this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$client = self::createClient();
|
||||
|
||||
$issued = (string) (time() - 10);
|
||||
$body = [...$this->validBody($issued), 'email' => 'keine-adresse', 'thema' => 'Erfunden', 'name' => '', 'datenschutz' => ''];
|
||||
$crawler = $client->request('POST', '/formular/kontakt', $body);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// Nothing is stored and nothing is sent while anything is wrong.
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
|
||||
$html = $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('Bitte gib eine gültige E-Mail-Adresse an.', $html);
|
||||
self::assertStringContainsString('Bitte wähle eine der angebotenen Optionen.', $html);
|
||||
self::assertStringContainsString('Dieses Feld wird benötigt.', $html);
|
||||
self::assertStringContainsString('Ohne diese Einwilligung', $html);
|
||||
// Each message belongs to its field and is announced as such.
|
||||
self::assertSame('formblock-email-error', $crawler->filter('#formblock-email')->attr('aria-describedby'));
|
||||
self::assertSame('true', $crawler->filter('#formblock-email')->attr('aria-invalid'));
|
||||
self::assertCount(1, $crawler->filter('[role=alert]'));
|
||||
// The entries survive the rejection.
|
||||
self::assertSame('keine-adresse', $crawler->filter('#formblock-email')->attr('value'));
|
||||
self::assertStringContainsString('Bitte um Rückruf.', $crawler->filter('#formblock-nachricht')->text());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{callable(string): array}>
|
||||
*/
|
||||
public static function droppedSubmissions(): iterable
|
||||
{
|
||||
$sign = static fn (string $issued): string => hash_hmac('sha256', $issued, 'isolated-test-secret');
|
||||
yield 'honeypot filled' => [static fn (array $body): array => [...$body, 'website' => 'https://spam.example']];
|
||||
yield 'submitted too fast' => [static fn (array $body): array => [...$body, 'issued' => (string) time(), 'issuedSignature' => $sign((string) time())]];
|
||||
yield 'timestamp forged' => [static fn (array $body): array => [...$body, 'issuedSignature' => 'nicht-signiert']];
|
||||
yield 'timestamp far in the past' => [static function (array $body) use ($sign): array {
|
||||
$old = (string) (time() - 200000);
|
||||
|
||||
return [...$body, 'issued' => $old, 'issuedSignature' => $sign($old)];
|
||||
}];
|
||||
}
|
||||
|
||||
#[DataProvider('droppedSubmissions')]
|
||||
public function testSpamProtectionDropsSubmissionsQuietlyWithoutStoringThem(callable $tamper): void
|
||||
{
|
||||
$this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$client = self::createClient();
|
||||
|
||||
$client->request('POST', '/formular/kontakt', $tamper($this->validBody((string) (time() - 10))));
|
||||
// Answered like a success so an automated sender learns nothing from the response.
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
}
|
||||
|
||||
public function testRateLimitStopsTheSixthSubmissionWithinAnHour(): void
|
||||
{
|
||||
$forms = $this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$client = self::createClient();
|
||||
|
||||
for ($i = 0; $i < FormRepository::RATE_LIMIT; ++$i) {
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
}
|
||||
self::assertSame(FormRepository::RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertSame(FormRepository::RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
|
||||
}
|
||||
|
||||
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/forms');
|
||||
self::assertResponseIsSuccessful();
|
||||
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
|
||||
$field = ['fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]];
|
||||
|
||||
$client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => 'wrong', 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 0]);
|
||||
self::assertResponseRedirects('/admin/forms');
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form'));
|
||||
|
||||
// A recipient that is not an address is refused with a message rather than stored.
|
||||
$client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => $token, 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'kein-empfaenger', 'expectedVersion' => 0]);
|
||||
$client->followRedirect();
|
||||
self::assertSelectorExists('.notice.error');
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form'));
|
||||
|
||||
$client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => $token, 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 0]);
|
||||
self::assertSame('Kontakt', $this->db->fetchOne('SELECT name FROM form'));
|
||||
}
|
||||
|
||||
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
|
||||
@@ -14,6 +14,7 @@ use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
$directory = dirname(__DIR__).'/var/browser-tests';
|
||||
@@ -30,6 +31,7 @@ $schema = new Schema();
|
||||
(new Version20260912000400($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000600($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
|
||||
$db->executeStatement($sql);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user