Files
KantSites/migrations/Version20260912000700.php
T
kantarciandClaude Opus 5 d43bd91069 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>
2026-09-12 11:51:07 +02:00

56 lines
2.4 KiB
PHP

<?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.');
}
}