- 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>
743 lines
35 KiB
PHP
743 lines
35 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.
|
||
*
|
||
* 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']];
|
||
}
|
||
}
|