'Einzeiliger Text', 'email' => 'E-Mail-Adresse', 'phone' => 'Telefonnummer', 'textarea' => 'Mehrzeiliger Text', 'select' => 'Auswahl', 'consent' => 'Einwilligung', ]; public const MAX_FIELDS = 25; /** Submissions per sender per hour. Deliberately low: a contact form is not a chat. */ public const RATE_LIMIT = 5; /** A human needs longer than this to fill in a form; anything faster is a script. */ public const MIN_SECONDS = 3; public const MAX_SECONDS = 86400; public function __construct(private Connection $db) { } /** @return list */ public function all(string $locale = 'de'): array { return array_map($this->present(...), $this->db->fetchAllAssociative( 'SELECT * FROM form WHERE website_id = ? AND locale = ? ORDER BY name', ['default', $locale], )); } public function findBySlug(string $slug, string $locale = 'de'): ?array { $row = $this->db->fetchAssociative('SELECT * FROM form WHERE website_id = ? AND locale = ? AND slug = ?', ['default', $locale, $slug]); return false === $row ? null : $this->present($row); } private function present(array $row): array { return [ 'id' => $row['id'], 'slug' => $row['slug'], 'name' => $row['name'], 'fields' => json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR), 'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR), 'version' => (int) $row['version'], ]; } public function save(string $slug, string $name, array $fields, array $settings, int $expectedVersion, string $locale = 'de'): string { $slug = strtolower(trim($slug)); if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) { throw new InvalidForm('Die Adresse des Formulars darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.'); } $name = trim($name); if ('' === $name || mb_strlen($name) > 120) { throw new InvalidForm('Bitte gib einen Namen mit höchstens 120 Zeichen an.'); } $fields = $this->validateFields($fields); $settings = $this->validateSettings($settings, $fields); return $this->db->transactional(function () use ($slug, $name, $fields, $settings, $expectedVersion, $locale): string { $existing = $this->db->fetchAssociative('SELECT id, version FROM form WHERE website_id = ? AND locale = ? AND slug = ?', ['default', $locale, $slug]); $payload = [ 'name' => $name, 'fields' => json_encode($fields, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'settings' => json_encode($settings, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), ]; if (false === $existing) { if (0 !== $expectedVersion) { throw new StaleWrite('Das Formular wurde inzwischen angelegt. Lade die Seite neu.'); } $id = bin2hex(random_bytes(16)); $this->db->insert('form', [...$payload, 'id' => $id, 'website_id' => 'default', 'locale' => $locale, 'slug' => $slug, 'version' => 1]); return $id; } $changed = $this->db->executeStatement( 'UPDATE form SET name = ?, fields = ?, settings = ?, version = version + 1 WHERE id = ? AND version = ?', [$payload['name'], $payload['fields'], $payload['settings'], $existing['id'], $expectedVersion], ); if (1 !== $changed) { throw new StaleWrite('Das Formular wurde inzwischen geändert. Lade die Seite neu, bevor du speicherst.'); } return $existing['id']; }); } private function validateFields(array $fields): array { if (!array_is_list($fields) || [] === $fields) { throw new InvalidForm('Ein Formular braucht mindestens ein Feld.'); } if (count($fields) > self::MAX_FIELDS) { throw new InvalidForm(sprintf('Ein Formular fasst höchstens %d Felder.', self::MAX_FIELDS)); } $names = []; $clean = []; foreach ($fields as $field) { $name = strtolower(trim((string) ($field['name'] ?? ''))); if (1 !== preg_match('/^[a-z][a-z0-9_]{0,31}$/D', $name) || isset($names[$name])) { throw new InvalidForm('Jedes Feld braucht einen eindeutigen technischen Namen aus Kleinbuchstaben, Ziffern und Unterstrich.'); } $names[$name] = true; $type = (string) ($field['type'] ?? ''); if (!isset(self::TYPES[$type])) { throw new InvalidForm('Unbekannter Feldtyp.'); } $label = trim((string) ($field['label'] ?? '')); if ('' === $label || mb_strlen($label) > 120) { throw new InvalidForm('Jedes Feld braucht eine Beschriftung mit höchstens 120 Zeichen.'); } $options = []; if ('select' === $type) { foreach ((array) ($field['options'] ?? []) as $option) { $option = trim((string) $option); if ('' !== $option && mb_strlen($option) <= 120) { $options[] = $option; } } if (count($options) < 2) { throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen.', $label)); } } $clean[] = [ 'name' => $name, 'type' => $type, 'label' => $label, // A consent field is only meaningful when it has to be ticked. 'required' => 'consent' === $type ? true : (bool) ($field['required'] ?? false), 'help' => mb_substr(trim((string) ($field['help'] ?? '')), 0, 200), 'options' => $options, ]; } if (count(array_filter($clean, static fn (array $f): bool => 'consent' === $f['type'])) > 1) { throw new InvalidForm('Ein Formular hat höchstens ein Einwilligungsfeld.'); } return $clean; } private function validateSettings(array $settings, array $fields): array { $recipient = trim((string) ($settings['recipient'] ?? '')); if (false === filter_var($recipient, FILTER_VALIDATE_EMAIL)) { throw new InvalidForm('Bitte gib eine gültige Empfängeradresse an.'); } $replyTo = trim((string) ($settings['replyToField'] ?? '')); if ('' !== $replyTo) { $match = array_filter($fields, static fn (array $f): bool => $f['name'] === $replyTo && 'email' === $f['type']); if ([] === $match) { throw new InvalidForm('Die Antwortadresse muss auf ein E-Mail-Feld dieses Formulars zeigen.'); } } return [ 'recipient' => $recipient, 'subject' => mb_substr(trim((string) ($settings['subject'] ?? '')), 0, 160) ?: 'Neue Formularnachricht', 'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.', 'replyToField' => $replyTo, ]; } /** * Checks one submission against the stored definition. * * @return array{values: array, errors: array, consent: bool} */ public function check(array $form, array $input): array { $values = []; $errors = []; $consent = false; foreach ($form['fields'] as $field) { $raw = $input[$field['name']] ?? ''; $value = is_string($raw) ? trim($raw) : ''; $limit = 'textarea' === $field['type'] ? 5000 : 200; if (mb_strlen($value) > $limit) { $errors[$field['name']] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit); $value = mb_substr($value, 0, $limit); } if ('consent' === $field['type']) { $consent = '' !== $value; if (!$consent) { $errors[$field['name']] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.'; } $values[$field['name']] = $consent ? 'ja' : ''; continue; } if ('' === $value) { if ($field['required']) { $errors[$field['name']] = 'Dieses Feld wird benötigt.'; } $values[$field['name']] = ''; continue; } if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) { $errors[$field['name']] = 'Bitte gib eine gültige E-Mail-Adresse an.'; } if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) { $errors[$field['name']] = 'Bitte gib eine gültige Telefonnummer an.'; } if ('select' === $field['type'] && !in_array($value, $field['options'], true)) { $errors[$field['name']] = 'Bitte wähle eine der angebotenen Optionen.'; } $values[$field['name']] = $value; } return ['values' => $values, 'errors' => $errors, 'consent' => $consent]; } /** True when this sender has already sent the allowed number of messages within the hour. */ public function rateLimited(string $senderHash): bool { // gmdate, not DateTimeImmutable: created_at is written in UTC, and a local clock ahead of // UTC would push the window past every stored row and disable the limit entirely. $since = gmdate('Y-m-d H:i:s', time() - 3600); return self::RATE_LIMIT <= (int) $this->db->fetchOne( 'SELECT COUNT(*) FROM form_submission WHERE sender_hash = ? AND created_at >= ?', [$senderHash, $since], ); } public function store(string $formId, array $values, bool $consent, string $senderHash): string { $id = bin2hex(random_bytes(16)); $now = gmdate('Y-m-d H:i:s'); $this->db->insert('form_submission', [ 'id' => $id, 'form_id' => $formId, 'data' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'sender_hash' => $senderHash, 'created_at' => $now, 'consent_at' => $consent ? $now : null, 'delivered' => false, ]); return $id; } public function markDelivered(string $submissionId): void { $this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]); } /** * One page of the submissions, newest first. * * @return array{items: list, 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']]; } }