'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 */ 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> $choices Optionen der Auswahlfelder vor diesem Feld * * @return array{field: string, values: list}|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 $options * * @return array */ 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 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, errors: array, 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, 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']]; } }