Files
KantSites/src/Content/FormRepository.php
T
kantarciandClaude Opus 5 269fb223af Messen statt vermuten: 18 Abfragen auf 8, Ladebudget, Blaettern ueberall
Arbeitspaket M sagte "Abfragen je Seite zaehlen. Messen, dann zusammenfassen."
Messen braucht ein Messgeraet, also gibt es jetzt eines: ein Zaehler als
Doctrine-Middleware, dessen Zahl ausserhalb der Produktion im Kopf
X-Kant-Queries steht. Keine Profiler-Maschinerie, eine Zahl.

Die erste Messung: die Startseite brauchte 18 Abfragen. Nicht wegen einer
komplizierten Seite, sondern weil SiteSettings::load() und Theme::load() je
Aufrufer dieselbe Zeile erneut holten - Renderer, strukturierte Daten,
Einwilligungsschalter, Vorlagen. Beide merken sich ihren Stand jetzt fuer die
Dauer einer Anfrage: 18 auf 8.

Dabei ist mir die klassische Falle des Merkens selbst passiert, und ein
bestehender Test hat sie sofort gezeigt: ich leerte den Merker am Anfang des
Speicherns. Das Speichern liest den Stand aber noch einmal und fuellte ihn
damit erneut - der Rest der Anfrage bekam den alten Stand. Geleert wird jetzt
nach dem Schreiben.

Das Ladebudget wird gemessen statt behauptet: die Browserpruefung liest je
Seite HTML-Groesse, CSS-Groesse, Anzahl Anfragen, Anzahl Skripte und
Abfragezahl und prueft Obergrenzen. Startseite: 7,4 KB HTML, 22,7 KB CSS, 2
Anfragen, 8 Abfragen, 0 Skripte. Die Null ist die wichtigste Zahl - sie haelt
als Test fest, was bisher nur Absicht war.

Das erste Bild einer Seite ist nicht mehr lazy. Es ist meistens das groesste
Ding ueber der Falz, und es zu verzoegern verzoegert genau das Pixel, auf das
jemand wartet. Der Renderer bestimmt es in Renderreihenfolge, Kopfbereich
zuerst.

Eine feste Obergrenze ist kein Blaettern: der Fassungsverlauf hoerte bei
dreissig auf, die Einsendungen bei fuenfzig, die Weiterleitungen zeigten bis
zu zweitausend auf einmal. Das Erste sieht aus wie "es gibt dreissig" und ist
das Einzige, was eine Historie nicht tun darf. Alle drei blaettern jetzt, mit
derselben Rechnung und demselben Pager wie Beitragsarchiv und Mediathek.

347 PHP-Tests / 1422 Assertions unter PHP 8.2.12 und 8.4.22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:56:09 +02:00

278 lines
12 KiB
PHP

<?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]);
}
/**
* One page of the submissions, newest first.
*
* @return array{items: list<array>, page: int, pages: int, total: int}
*/
public function submissions(int $number = 1, int $perPage = 25): array
{
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), $perPage);
$items = 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 '.$slice['perPage'].' OFFSET '.$slice['offset'],
));
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
}
}