'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; 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), ]; // 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; } /** 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} */ public function check(array $form, array $input): array { $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] = 'Bitte wähle nur aus den angebotenen Optionen.'; $picked = array_values(array_intersect($picked, $field['options'])); } elseif ([] === $picked && $field['required']) { $errors[$name] = '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] = 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[$name] = '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) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.'; } $values[$name] = ''; continue; } if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) { $errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.'; } if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) { $errors[$name] = 'Bitte gib eine gültige Telefonnummer an.'; } if ('number' === $field['type']) { $zahl = str_replace(',', '.', $value); if (!is_numeric($zahl)) { $errors[$name] = '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'] => sprintf('Bitte gib höchstens %s an.', self::zahl($field['max'])), null === $field['max'] => sprintf('Bitte gib mindestens %s an.', self::zahl($field['min'])), default => sprintf('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] = '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']]; } }