Files
KantSites/src/Content/FormRepository.php
T
kantarciandClaude Opus 5.5 ad7c333215 Harden KantSites after the security review
- The installer only opens with a per-installation key that install.php
  creates and shows; the key is deleted with the first account.
- Lock files keep .env, the database, backups and logs out of reach when a
  domain points at the project folder instead of public/.
- Linked sections render by their own schema version, so an old section
  cannot carry unfiltered HTML into a newer page.
- Editors no longer move, trash or change areas and post data of published
  pages; a scheduled publication goes live with the revision that was
  scheduled (migration 20261012000100).
- Form stamps are bound to their form, IPv6 senders count per /64, and a
  flood per form or overall stops storing with a visible message.
- The consent redirect rejects control characters, update targets must look
  like 1.2.3, and own CSS cannot reach outside its block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:38:25 +02:00

743 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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.
*
* Ein Formular kann in Schritten fragen: ein Feld vom Typ „Schritt“ eröffnet einen Abschnitt, alle
* Felder bis zum nächsten Schritt gehören dazu. Und ein Feld oder ein Schritt kann von einer
* früheren Auswahl abhängen („nur zeigen, wenn Immobilienart = Wohnung“). Beides steht in der
* Definition, nicht im Skript: der Server entscheidet beim Absenden selbst, welche Felder galten,
* und verlangt nichts, was der Besucher gar nicht sehen sollte.
*/
final class FormRepository
{
public const TYPES = [
'text' => 'Einzeiliger Text',
'email' => 'E-Mail-Adresse',
'phone' => 'Telefonnummer',
'number' => 'Zahl',
'textarea' => 'Mehrzeiliger Text',
'select' => 'Auswahl (Liste)',
'radio' => 'Auswahl (Optionsfelder)',
'checkboxes' => 'Mehrfachauswahl',
'consent' => 'Einwilligung',
'step' => 'Neuer Schritt',
];
/** Die Feldtypen mit Optionen; nur von ihnen kann ein anderes Feld abhängen. */
public const CHOICE_TYPES = ['select', 'radio', 'checkboxes'];
/** Ein Preisfinder mit vier Objektarten braucht mehr als ein Kontaktformular. */
public const MAX_FIELDS = 40;
/** Die gestaltbaren Farben eines Formulars, mit ihrer Bezeichnung in der Verwaltung. */
public const COLORS = [
'fieldBorderColor' => 'den Rahmen der Felder',
'fieldBackgroundColor' => 'den Hintergrund der Felder',
'buttonColor' => 'den Button',
'buttonInk' => 'die Buttonschrift',
];
/** 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;
/**
* Gespeicherte Anfragen je Formular und insgesamt je Stunde (ADR 116). Darüber hinaus wird
* nichts mehr gespeichert und nichts verschickt - eine Flut füllt sonst Postfach und
* Datenbank, und die Grenze je Absender hilft nicht gegen viele Absender.
*/
public const FORM_RATE_LIMIT = 30;
public const GLOBAL_RATE_LIMIT = 120;
/**
* Das Siegel eines Formulars: gebunden an das Formular und an den Zweck, damit ein von einer
* Seite geholtes Siegel nicht für jedes Formular gilt (ADR 116).
*/
public static function stamp(string $slug, int $issued, string $secret): string
{
return hash_hmac('sha256', 'formular|'.$slug.'|'.$issued, $secret);
}
/**
* Wer ein Absender ist, für die Grenze je Stunde. Bei IPv6 zählt das /64-Netz: ein einzelner
* Anschluss hat davon Milliarden Adressen, mit der vollen Adresse wäre die Grenze wirkungslos.
*/
public static function senderKey(?string $ip, string $secret): string
{
$ip = (string) $ip;
if (str_contains($ip, ':') && false !== ($binary = @inet_pton($ip)) && 16 === \strlen($binary)) {
$ip = bin2hex(substr($binary, 0, 8)).'::/64';
}
return hash_hmac('sha256', 'absender|'.$ip, $secret);
}
/** Ob für dieses Formular oder insgesamt in der letzten Stunde schon zu viel ankam. */
public function flooded(string $formId): bool
{
$since = gmdate('Y-m-d H:i:s', time() - 3600);
if (self::FORM_RATE_LIMIT <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission WHERE form_id = ? AND created_at >= ?', [$formId, $since])) {
return true;
}
return self::GLOBAL_RATE_LIMIT <= (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission WHERE created_at >= ?', [$since]);
}
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
{
// Ältere Definitionen kennen weder Grenzen noch Bedingungen; die Vorlage und die Prüfung
// verlassen sich darauf, dass jeder Schlüssel da ist.
$fields = array_map(static fn (array $field): array => $field + ['help' => '', 'options' => [], 'images' => [], 'min' => null, 'max' => null, 'unit' => '', 'showIf' => null], json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR));
return [
'id' => $row['id'], 'slug' => $row['slug'], 'name' => $row['name'],
'fields' => $fields,
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR) + ['summary' => false],
'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 = [];
// Die Optionen der bisherigen Auswahlfelder: nur davon darf ein späteres Feld abhängen.
$choices = [];
$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 (in_array($type, self::CHOICE_TYPES, true)) {
foreach ((array) ($field['options'] ?? []) as $option) {
$option = trim((string) $option);
// Kein Komma in einer Option: die Verwaltung trennt Optionen daran, und eine
// Mehrfachauswahl wird mit Komma gespeichert.
if ('' !== $option && mb_strlen($option) <= 120 && !str_contains($option, ',') && !in_array($option, $options, true)) {
$options[] = $option;
}
}
if (count($options) < 2) {
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen ohne Komma.', $label));
}
}
$images = in_array($type, self::CHOICE_TYPES, true) ? $this->validateImages($field['images'] ?? [], $options, $label) : [];
$min = $max = null;
$unit = '';
if ('number' === $type) {
foreach (['min', 'max'] as $grenze) {
$wert = str_replace(',', '.', trim((string) ($field[$grenze] ?? '')));
if ('' === $wert) {
continue;
}
if (!is_numeric($wert)) {
throw new InvalidForm(sprintf('Die Grenze „%s“ von „%s“ ist keine Zahl.', $wert, $label));
}
$$grenze = (float) $wert;
}
if (null !== $min && null !== $max && $min >= $max) {
throw new InvalidForm(sprintf('Bei „%s“ muss die Untergrenze unter der Obergrenze liegen.', $label));
}
$unit = mb_substr(trim((string) ($field['unit'] ?? '')), 0, 12);
}
$showIf = $this->validateCondition($field, $label, $choices);
$clean[] = [
'name' => $name,
'type' => $type,
'label' => $label,
// A consent field is only meaningful when it has to be ticked; a step asks nothing.
'required' => 'consent' === $type ? true : ('step' !== $type && (bool) ($field['required'] ?? false)),
'help' => mb_substr(trim((string) ($field['help'] ?? '')), 0, 200),
'options' => $options,
'images' => $images,
'min' => $min,
'max' => $max,
'unit' => $unit,
'showIf' => $showIf,
];
if (in_array($type, self::CHOICE_TYPES, true)) {
$choices[$name] = $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.');
}
if ([] === array_filter($clean, static fn (array $f): bool => 'step' !== $f['type'])) {
throw new InvalidForm('Ein Formular braucht mindestens ein Feld, das etwas fragt – Schritte allein reichen nicht.');
}
return $clean;
}
/**
* Die Bedingung eines Feldes: es gilt nur, wenn ein früheres Auswahlfeld einen der genannten
* Werte hat. Angenommen wird die gespeicherte Form (showIf: {field, values}) und die aus der
* Verwaltung (showIfField, showIfValues als Kommaliste).
*
* @param array<string, list<string>> $choices Optionen der Auswahlfelder vor diesem Feld
*
* @return array{field: string, values: list<string>}|null
*/
private function validateCondition(array $field, string $label, array $choices): ?array
{
$ref = strtolower(trim((string) ($field['showIfField'] ?? ($field['showIf']['field'] ?? ''))));
if ('' === $ref) {
return null;
}
if (!isset($choices[$ref])) {
throw new InvalidForm(sprintf('„%s“ soll von „%s“ abhängen, aber davor steht kein Auswahlfeld mit diesem Namen.', $label, $ref));
}
$wanted = $field['showIfValues'] ?? ($field['showIf']['values'] ?? []);
if (is_string($wanted)) {
$wanted = explode(',', $wanted);
}
$values = [];
foreach ((array) $wanted as $wert) {
$wert = trim((string) $wert);
if ('' === $wert || in_array($wert, $values, true)) {
continue;
}
if (!in_array($wert, $choices[$ref], true)) {
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ – die Bedingung von „%s“ könnte nie zutreffen.', $wert, $ref, $label));
}
$values[] = $wert;
}
if ([] === $values) {
throw new InvalidForm(sprintf('Für die Bedingung von „%s“ fehlt der Wert, bei dem das Feld gilt.', $label));
}
return ['field' => $ref, 'values' => $values];
}
/**
* Die Bilder einer Auswahl: je Option höchstens ein Bild aus der Mediathek. Sobald eine Option
* eines hat, zeigt die Website die Auswahl als anklickbare Bilder statt als Kästchen.
* Angenommen wird die gespeicherte Form (Option => Kennung) und die aus der Verwaltung
* (eine Zeile je Option: „Option = Kennung“).
*
* @param list<string> $options
*
* @return array<string, string>
*/
private function validateImages(mixed $input, array $options, string $label): array
{
if (is_string($input)) {
$paare = [];
foreach (preg_split('/\\r?\\n/', $input) ?: [] as $zeile) {
if (!str_contains($zeile, '=')) {
continue;
}
[$option, $kennung] = array_map(trim(...), explode('=', $zeile, 2));
$paare[$option] = $kennung;
}
$input = $paare;
}
$images = [];
foreach ((array) $input as $option => $kennung) {
$option = trim((string) $option);
$kennung = strtolower(trim((string) $kennung));
if ('' === $kennung) {
continue;
}
if (!in_array($option, $options, true)) {
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ und kann kein Bild bekommen.', $option, $label));
}
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $kennung)) {
throw new InvalidForm(sprintf('Das Bild für „%s“ ist keine Kennung aus der Mediathek.', $option));
}
$images[$option] = $kennung;
}
return $images;
}
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.');
}
// Der Absender ist freiwillig: leer heisst, die Post kommt von der Empfaengeradresse.
$sender = trim((string) ($settings['sender'] ?? ''));
if ('' !== $sender && false === filter_var($sender, FILTER_VALIDATE_EMAIL)) {
throw new InvalidForm('Bitte gib eine gültige Absenderadresse an oder lass das Feld leer.');
}
$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.');
}
}
$clean = [
'recipient' => $recipient,
'sender' => $sender,
'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,
'buttonLabel' => mb_substr(trim((string) ($settings['buttonLabel'] ?? '')), 0, 40) ?: 'Absenden',
// Die Zusammenfassung vor dem Absenden - ein Schritt, den nur das Skript zeigt; ohne
// Skript steht das ganze Formular ohnehin auf einer Seite.
'summary' => (bool) ($settings['summary'] ?? false),
];
$translations = $this->validateTranslations($settings['translations'] ?? [], $fields);
if ([] !== $translations) {
$clean['translations'] = $translations;
}
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
foreach (self::COLORS as $field => $label) {
$value = trim((string) ($settings[$field] ?? ''));
if ('' !== $value && 1 !== preg_match(StyleSchema::COLOR, $value)) {
throw new InvalidForm(sprintf('„%s“ ist kein Farbwert für %s.', $value, $label));
}
$clean[$field] = $value;
}
return $clean;
}
/**
* Die Übersetzungen eines Formulars (ADR 111): je Sprache Erfolgsmeldung, Button und je Feld
* Beschriftung, Hilfetext, Einheit und die angezeigten Optionen. Übersetzt wird nur, was
* Besucher lesen. Abgeschickt werden weiter die Optionen der Hauptsprache - Bedingungen,
* Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger bleiben dieselben.
*/
private function validateTranslations(mixed $input, array $fields): array
{
if (!is_array($input)) {
throw new InvalidForm('Die Übersetzungen des Formulars sind ungültig.');
}
$byName = array_column($fields, null, 'name');
$clean = [];
foreach ($input as $locale => $translation) {
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($translation)) {
throw new InvalidForm('Eine Übersetzung des Formulars gehört zu einer unbekannten Sprache.');
}
$out = [];
foreach (['success' => 300, 'buttonLabel' => 40] as $key => $max) {
$text = trim((string) ($translation[$key] ?? ''));
if ('' !== $text) {
$out[$key] = mb_substr($text, 0, $max);
}
}
foreach ((is_array($translation['fields'] ?? null) ? $translation['fields'] : []) as $name => $values) {
$field = $byName[$name] ?? null;
if (null === $field || !is_array($values)) {
continue;
}
$entry = [];
foreach (['label' => 120, 'help' => 200, 'unit' => 12] as $key => $max) {
$text = trim((string) ($values[$key] ?? ''));
if ('' !== $text) {
$entry[$key] = mb_substr($text, 0, $max);
}
}
foreach ((is_array($values['options'] ?? null) ? $values['options'] : []) as $original => $text) {
$text = trim((string) $text);
if (in_array((string) $original, $field['options'], true) && '' !== $text) {
$entry['options'][(string) $original] = mb_substr($text, 0, 120);
}
}
if ([] !== $entry) {
$out['fields'][$name] = $entry;
}
}
if ([] !== $out) {
$clean[$locale] = $out;
}
}
return $clean;
}
/**
* Das Formular in einer weiteren Sprache: Beschriftungen, Hilfetexte, Einheiten, Button und
* Erfolgsmeldung übersetzt, wo es Übersetzungen gibt. Optionen behalten ihren Wert; nur die
* Anzeige steht in `optionLabels`.
*/
public static function localise(array $form, ?string $locale): array
{
$translation = null === $locale ? [] : ($form['settings']['translations'][$locale] ?? []);
foreach ($form['fields'] as $index => $field) {
$values = $translation['fields'][$field['name']] ?? [];
foreach (['label', 'help', 'unit'] as $key) {
if (isset($values[$key])) {
$form['fields'][$index][$key] = $values[$key];
}
}
$form['fields'][$index]['optionLabels'] = $values['options'] ?? [];
}
foreach (['success', 'buttonLabel'] as $key) {
if (isset($translation[$key])) {
$form['settings'][$key] = $translation[$key];
}
}
$form['locale'] = $locale;
return $form;
}
/**
* Die Meldungen der Prüfung in weiteren Sprachen. Schlüssel ist die deutsche Vorlage samt
* Platzhaltern; ohne eigene Liste gilt Englisch.
*/
public const MESSAGES = [
'en' => [
'Bitte wähle nur aus den angebotenen Optionen.' => 'Please choose only from the options offered.',
'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.',
'Bitte kürze die Eingabe auf höchstens %d Zeichen.' => 'Please shorten your entry to at most %d characters.',
'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.' => 'We cannot process your request without this consent.',
'Bitte wähle eine der angebotenen Optionen.' => 'Please choose one of the options offered.',
'Dieses Feld wird benötigt.' => 'This field is required.',
'Bitte gib eine gültige E-Mail-Adresse an.' => 'Please enter a valid email address.',
'Bitte gib eine gültige Telefonnummer an.' => 'Please enter a valid phone number.',
'Bitte gib eine Zahl an.' => 'Please enter a number.',
'Bitte gib höchstens %s an.' => 'Please enter at most %s.',
'Bitte gib mindestens %s an.' => 'Please enter at least %s.',
'Bitte gib einen Wert zwischen %s und %s an.' => 'Please enter a value between %s and %s.',
],
'tr' => [
'Bitte wähle nur aus den angebotenen Optionen.' => 'Lütfen yalnızca sunulan seçeneklerden seçin.',
'Bitte wähle mindestens eine Option.' => 'Lütfen en az bir seçenek seçin.',
'Bitte kürze die Eingabe auf höchstens %d Zeichen.' => 'Lütfen girişinizi en fazla %d karaktere kısaltın.',
'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.' => 'Bu onay olmadan talebinizi işleme alamayız.',
'Bitte wähle eine der angebotenen Optionen.' => 'Lütfen sunulan seçeneklerden birini seçin.',
'Dieses Feld wird benötigt.' => 'Bu alan zorunludur.',
'Bitte gib eine gültige E-Mail-Adresse an.' => 'Lütfen geçerli bir e-posta adresi girin.',
'Bitte gib eine gültige Telefonnummer an.' => 'Lütfen geçerli bir telefon numarası girin.',
'Bitte gib eine Zahl an.' => 'Lütfen bir sayı girin.',
'Bitte gib höchstens %s an.' => 'Lütfen en fazla %s girin.',
'Bitte gib mindestens %s an.' => 'Lütfen en az %s girin.',
'Bitte gib einen Wert zwischen %s und %s an.' => 'Lütfen %s ile %s arasında bir değer girin.',
],
];
private static function message(?string $locale, string $text, mixed ...$args): string
{
$template = null === $locale || 'de' === $locale ? $text : (self::MESSAGES[$locale][$text] ?? self::MESSAGES['en'][$text] ?? $text);
return [] === $args ? $template : sprintf($template, ...$args);
}
/**
* Wie viele Einsendungen je Formular eingegangen sind, für die Übersicht.
*
* @return array<string, int> Formular-ID => Anzahl
*/
public function submissionCounts(): array
{
$counts = [];
foreach ($this->db->fetchAllAssociative('SELECT form_id, COUNT(*) AS n FROM form_submission GROUP BY form_id') as $row) {
$counts[(string) $row['form_id']] = (int) $row['n'];
}
return $counts;
}
/** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */
public function needsScript(array $form): bool
{
if ($form['settings']['summary'] ?? false) {
return true;
}
foreach ($form['fields'] as $field) {
if ('step' === $field['type'] || null !== ($field['showIf'] ?? null)) {
return true;
}
}
return false;
}
/**
* Die Gestaltung eines Formulars als Regel fuer die Seite, die es zeigt.
*
* Das Formular bekommt seine Farben als Variablen: Rahmen und Grund der Felder direkt, die
* Buttonfarben ueber dieselben Token, die auch der Standardbutton der Website benutzt - so
* behaelt der Absendeknopf Form und Darstellung aus dem Design und wechselt nur die Farbe.
* Nichts gesetzt, nichts geschrieben: dann gilt weiter die Website.
*/
public function css(array $form, array $theme): string
{
$values = [];
$button = false;
foreach (['fieldBorderColor' => '--form-border', 'fieldBackgroundColor' => '--form-bg', 'buttonColor' => '--kb-fill', 'buttonInk' => '--kb-ink'] as $field => $variable) {
$value = Theme::cssColor((string) ($form['settings'][$field] ?? ''), $theme);
if (null === $value) {
continue;
}
$values[] = $variable.':'.$value;
if ('buttonColor' === $field) {
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,var(--ink))';
$button = true;
}
if ('buttonInk' === $field) {
$values[] = '--kb-hover-ink:'.$value;
$button = true;
}
}
if ($button) {
// Die Darstellung (gefuellt, Rahmen, dezent) leitet Flaeche und Schrift aus Fuellung
// und Tinte ab - am Wurzelelement, mit den Werten von dort. Hier noch einmal, damit
// die Ableitung die Farben dieses Formulars nimmt.
foreach (ButtonDesign::appearance($theme['buttons']['appearance'] ?? 'solid') as $key => $value) {
$values[] = '--kb-'.$key.':'.$value;
}
}
if ([] === $values) {
return '';
}
return sprintf('.block-form[data-form="%s"]{%s}', $form['slug'], implode(';', $values));
}
/**
* Checks one submission against the stored definition.
*
* Ein Feld hinter einer Bedingung zählt nur, wenn sie zutrifft; ein Schritt hinter einer
* Bedingung nimmt seine Felder mit. Was nicht galt, wird weder verlangt noch gespeichert -
* auch wenn ein Skript im Browser es mitgeschickt hat.
*
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
*/
/**
* @param string|null $locale eine weitere Sprache: die Meldungen stehen dann in ihr
*/
public function check(array $form, array $input, ?string $locale = null): array
{
$t = static fn (string $text, mixed ...$args): string => self::message($locale, $text, ...$args);
$values = [];
$errors = [];
$consent = false;
// Die gewählten Optionen der bisherigen Auswahlfelder, für die Bedingungen danach.
$chosen = [];
$stepActive = true;
// Kein Pfeil: der bekäme die leere Liste von oben und sähe keine spätere Wahl.
$holds = static function (?array $showIf) use (&$chosen): bool {
return null === $showIf || [] !== array_intersect($showIf['values'], $chosen[$showIf['field']] ?? []);
};
foreach ($form['fields'] as $field) {
$name = $field['name'];
if ('step' === $field['type']) {
$stepActive = $holds($field['showIf']);
continue;
}
if (!$stepActive || !$holds($field['showIf'])) {
$values[$name] = '';
continue;
}
$raw = $input[$name] ?? '';
if ('checkboxes' === $field['type']) {
$picked = array_values(array_filter(array_map(static fn ($v): string => is_string($v) ? trim($v) : '', is_array($raw) ? $raw : [$raw]), static fn (string $v): bool => '' !== $v));
if ([] !== array_diff($picked, $field['options'])) {
$errors[$name] = $t('Bitte wähle nur aus den angebotenen Optionen.');
$picked = array_values(array_intersect($picked, $field['options']));
} elseif ([] === $picked && $field['required']) {
$errors[$name] = $t('Bitte wähle mindestens eine Option.');
}
$chosen[$name] = $picked;
$values[$name] = implode(', ', $picked);
continue;
}
$value = is_string($raw) ? trim($raw) : '';
$limit = 'textarea' === $field['type'] ? 5000 : 200;
if (mb_strlen($value) > $limit) {
$errors[$name] = $t('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[$name] = $t('Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.');
}
$values[$name] = $consent ? 'ja' : '';
continue;
}
if ('' === $value) {
if ($field['required']) {
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? $t('Bitte wähle eine der angebotenen Optionen.') : $t('Dieses Feld wird benötigt.');
}
$values[$name] = '';
continue;
}
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$name] = $t('Bitte gib eine gültige E-Mail-Adresse an.');
}
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
$errors[$name] = $t('Bitte gib eine gültige Telefonnummer an.');
}
if ('number' === $field['type']) {
$zahl = str_replace(',', '.', $value);
if (!is_numeric($zahl)) {
$errors[$name] = $t('Bitte gib eine Zahl an.');
} elseif ((null !== $field['min'] && (float) $zahl < $field['min']) || (null !== $field['max'] && (float) $zahl > $field['max'])) {
$errors[$name] = match (true) {
null === $field['min'] => $t('Bitte gib höchstens %s an.', self::zahl($field['max'])),
null === $field['max'] => $t('Bitte gib mindestens %s an.', self::zahl($field['min'])),
default => $t('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
};
} else {
// Gespeichert wird die Zahl, wie sie ist - ohne führende Nullen, mit Punkt.
$value = self::zahl((float) $zahl);
}
}
if (in_array($field['type'], ['select', 'radio'], true)) {
if (!in_array($value, $field['options'], true)) {
$errors[$name] = $t('Bitte wähle eine der angebotenen Optionen.');
} else {
$chosen[$name] = [$value];
}
}
$values[$name] = $value;
}
return ['values' => $values, 'errors' => $errors, 'consent' => $consent];
}
/** Eine Zahl ohne unnötige Nachkommastellen, für Meldungen und die gespeicherte Antwort. */
public static function zahl(float $wert): string
{
$text = number_format($wert, 3, '.', '');
return str_contains($text, '.') ? rtrim(rtrim($text, '0'), '.') : $text;
}
/** 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']];
}
}