'', 'description' => '', 'phone' => '', 'email' => '', 'street' => '', 'postalCode' => '', 'city' => '', 'country' => 'DE', 'sameAs' => [], // Die sozialen Profile, je Netzwerk eine Adresse (siehe SocialProfiles). Der Baustein // `social` zeigt sie an; die strukturierten Daten führen sie als sameAs mit. 'social' => [], // Öffnungszeiten je Wochentag (opens/closes als HH:MM) und das Einsatzgebiet als Liste // von Ortsnamen. Beides landet in LocalBusiness, weil eine Suchmaschine sonst rät. 'openingHours' => [], 'areaServed' => [], 'localBusiness' => false, 'llmsTxt' => false, // Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und // nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst. 'stickyHeader' => false, // Der Kopfbereich liegt über dem Inhalt statt darüber zu stehen - der Flatsome-Aufbau mit // transparentem Kopf über dem Hero. Welche Farben er beim Scrollen bekommt, bestimmt die // Gestaltung des Kopfbereichs selbst („beim Scrollen" unter Hintergrund). 'headerOverlay' => false, // Einblendungen und Hover-Bewegungen. „always" spielt sie wie Flatsome bei jedem // Besucher ab; „respect" hält sich an die Browservorgabe „Bewegung reduzieren", die // manche Systeme melden, ohne dass jemand sie bewusst eingestellt hat. 'motion' => 'always', // Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen // Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt. 'postMetaHeader' => true, // Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim // Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen. 'faviconMediaId' => '', // Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist // einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung // passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript. 'scrollTop' => false, 'scrollTopPosition' => 'rechts', 'scrollTopShape' => 'rund', 'scrollTopSize' => 48, 'scrollTopColor' => '', 'scrollTopInk' => '', 'scrollTopLabel' => 'Nach oben', 'scrollTopAfter' => 400, // Cloudflare Turnstile fuer Formulare. Aus, weil eine Website ohne Spamwelle keinen // Dritten einbinden soll - und weil die Pruefung JavaScript verlangt. 'turnstile' => false, 'turnstileSiteKey' => '', 'turnstileSecret' => '', 'robotsExtra' => '', // Which stored page answers at the site root. Everything else derives from this. 'homePath' => 'home', 'notFoundPageId' => '', // The consent question. Off by default, because a site that loads nothing from third // parties has nothing to ask about, and a banner without a reason is only in the way. 'consentBanner' => false, 'consentGroups' => ['external'], 'consentText' => '', // Sein Aussehen: Anordnung, Form, Farben, Überschrift. Leer heißt Theme-Standard. 'consentLayout' => 'karte', 'consentShape' => 'weich', 'consentTitle' => '', 'consentColor' => '', 'consentInk' => '', 'consentAccent' => '', 'consentAccentInk' => '', 'privacyPath' => '', 'imprintPath' => '', ]; /** Die Wochentage, wie schema.org sie nennt, mit deutscher Beschriftung für das Formular. */ public const DAYS = ['Monday' => 'Montag', 'Tuesday' => 'Dienstag', 'Wednesday' => 'Mittwoch', 'Thursday' => 'Donnerstag', 'Friday' => 'Freitag', 'Saturday' => 'Samstag', 'Sunday' => 'Sonntag']; public function __construct(private Connection $db, private MediaLibrary $media) { } /** * Die Öffnungszeiten aus dem Formular: je Tag ein Paar aus Öffnen und Schließen; ein Tag ohne * beide Angaben gilt als geschlossen und fehlt in der Liste. * * @return list */ private function openingHours(mixed $input): array { $hours = []; if (is_array($input) && array_is_list($input)) { // Die gespeicherte Form: eine Liste von {day, opens, closes} wird zur Formularform. $map = []; foreach ($input as $row) { if (is_array($row) && isset($row['day'])) { $map[(string) $row['day']] = ['opens' => $row['opens'] ?? '', 'closes' => $row['closes'] ?? '']; } } $input = $map; } foreach (self::DAYS as $day => $label) { $row = is_array($input) && is_array($input[$day] ?? null) ? $input[$day] : []; $opens = trim((string) ($row['opens'] ?? '')); $closes = trim((string) ($row['closes'] ?? '')); if ('' === $opens && '' === $closes) { continue; } foreach ([$opens, $closes] as $time) { if (1 !== preg_match('/^([01]\d|2[0-3]):[0-5]\d$/D', $time)) { throw new InvalidPage(sprintf('Die Öffnungszeit für %s braucht Öffnen und Schließen als Uhrzeit wie 07:30.', $label)); } } if ($opens >= $closes) { throw new InvalidPage(sprintf('%s: die Schließzeit muss nach der Öffnungszeit liegen.', $label)); } $hours[] = ['day' => $day, 'opens' => $opens, 'closes' => $closes]; } return $hours; } /** Das Einsatzgebiet: ein Ortsname je Zeile, höchstens vierzig. */ private function areaServed(string $input): array { $places = []; foreach (preg_split('/[\r\n,]+/', $input) ?: [] as $place) { $place = trim($place); if ('' === $place) { continue; } if (mb_strlen($place) > 80 || 1 === preg_match('/[<>"]/', $place)) { throw new InvalidPage(sprintf('„%s“ ist kein Ortsname.', mb_substr($place, 0, 40))); } $places[] = $place; } if (count($places) > 40) { throw new InvalidPage('Mehr als vierzig Orte im Einsatzgebiet sind nicht vorgesehen.'); } return array_values(array_unique($places)); } /** * Remembered for the length of one request. * * These settings are asked for several times per page - by the renderer, by the structured * data, by the consent gate, by the templates - and each time it was the same row again. The * value is dropped on every save, so nothing stale survives a change. */ private ?array $loaded = null; public function load(): array { if (null !== $this->loaded) { return $this->loaded; } try { $raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']); } catch (TableNotFoundException) { // Vor der Installation gibt es die Tabelle noch nicht. Fragen tun trotzdem schon // Seiten, die es in dem Moment geben kann - der Installer selbst und die // Nicht-gefunden-Seite. Die Standardwerte sind dort die richtige Antwort. return $this->loaded = self::DEFAULTS; } $stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : []; return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])]; } public function save(array $input): void { $values = self::DEFAULTS; // Assigned separately, so saving company data cannot clear the error page. $values['notFoundPageId'] = $this->load()['notFoundPageId']; foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) { $values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max); } if ('' !== $values['email'] && false === filter_var($values['email'], FILTER_VALIDATE_EMAIL)) { throw new InvalidPage('Bitte gib eine gültige E-Mail-Adresse an.'); } if ('' !== $values['phone'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $values['phone'])) { throw new InvalidPage('Bitte gib eine gültige Telefonnummer an.'); } $country = strtoupper(trim((string) ($input['country'] ?? 'DE'))); if (1 !== preg_match('/^[A-Z]{2}$/D', $country)) { throw new InvalidPage('Das Land wird als zweibuchstabiger Code angegeben, zum Beispiel DE.'); } $values['country'] = $country; foreach (preg_split('/[\r\n,]+/', (string) ($input['sameAs'] ?? '')) ?: [] as $profile) { $profile = trim($profile); if ('' === $profile) { continue; } if (!str_starts_with($profile, 'https://') || false === filter_var($profile, FILTER_VALIDATE_URL)) { throw new InvalidPage(sprintf('„%s“ ist keine HTTPS-Adresse.', $profile)); } $values['sameAs'][] = $profile; } $values['sameAs'] = array_slice(array_unique($values['sameAs']), 0, 20); $values['social'] = SocialProfiles::clean($input['social'] ?? []); // Zwei Formen kommen hier an: die des Formulars (hours[Monday][opens], Orte als Text) und // die gespeicherte (openingHours als Liste, areaServed als Liste), wenn ein anderer Bildschirm // die geladenen Werte unverändert zurückgibt. Beide müssen dasselbe ergeben, sonst löscht // das Anlegen der Rechtsseiten die Öffnungszeiten. $values['openingHours'] = $this->openingHours($input['hours'] ?? $input['openingHours'] ?? []); $areaInput = $input['areaServed'] ?? ''; $values['areaServed'] = $this->areaServed(is_array($areaInput) ? implode(' ', array_map('strval', $areaInput)) : (string) $areaInput); $values['localBusiness'] = (bool) ($input['localBusiness'] ?? false); if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) { throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.'); } $values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false); $values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false); $values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false); $favicon = trim((string) ($input['faviconMediaId'] ?? '')); if ('' !== $favicon) { if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) { throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).'); } // Die Dateien entstehen hier und nicht bei jedem Seitenaufruf. Schlägt das fehl, wird // die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol // wäre schlimmer als das alte Symbol. try { $this->media->renderFavicon($favicon); } catch (\RuntimeException $error) { throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage()); } } $values['faviconMediaId'] = $favicon; foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) { $wert = trim((string) ($input[$key] ?? '')); if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) { throw new InvalidPage('Die Turnstile-Schlüssel bestehen aus Buchstaben, Ziffern und den Zeichen _ : . - und sind mindestens acht Zeichen lang.'); } $values[$key] = $wert; } $values['scrollTop'] = (bool) ($input['scrollTop'] ?? false); $values['scrollTopPosition'] = in_array($input['scrollTopPosition'] ?? '', ['rechts', 'links'], true) ? $input['scrollTopPosition'] : 'rechts'; $values['scrollTopShape'] = in_array($input['scrollTopShape'] ?? '', ['rund', 'weich', 'eckig'], true) ? $input['scrollTopShape'] : 'rund'; $values['scrollTopSize'] = max(36, min(72, (int) ($input['scrollTopSize'] ?? 48))); // Ab wie vielen Pixeln er erscheint. Null heisst: sofort. $values['scrollTopAfter'] = max(0, min(4000, (int) ($input['scrollTopAfter'] ?? 400))); $values['scrollTopLabel'] = mb_substr(trim((string) ($input['scrollTopLabel'] ?? '')), 0, 60) ?: 'Nach oben'; foreach (['scrollTopColor', 'scrollTopInk'] as $feld) { $farbe = trim((string) ($input[$feld] ?? '')); if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) { throw new InvalidPage('Die Farben des Knopfes „nach oben" werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.'); } $values[$feld] = $farbe; } $values['turnstile'] = (bool) ($input['turnstile'] ?? false); if ($values['turnstile'] && ('' === $values['turnstileSiteKey'] || '' === $values['turnstileSecret'])) { // Eingeschaltet ohne Schluessel hiesse: jedes Formular lehnt jede Absendung ab. throw new InvalidPage('Für die Turnstile-Prüfung werden beide Schlüssel gebraucht – der öffentliche und der geheime.'); } $values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false); $values['motion'] = in_array($input['motion'] ?? '', ['always', 'respect'], true) ? $input['motion'] : 'always'; // Only comment and simple directive lines; nothing that could smuggle in a second policy. // Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen // Regeln je Bot besteht aus Bloecken, und ohne Trennung liest die niemand mehr. Das Limit // liegt bei 200 Zeilen, weil allein die Liste der KI-Crawler laenger ist als dreissig. $extra = []; foreach (preg_split('/\R/', (string) ($input['robotsExtra'] ?? '')) ?: [] as $line) { $line = trim($line); if ('' === $line) { if ([] !== $extra && '' !== end($extra)) { $extra[] = ''; } continue; } if (1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) { $extra[] = $line; } } $values['robotsExtra'] = trim(implode("\n", array_slice($extra, 0, 200))); $home = trim(trim((string) ($input['homePath'] ?? self::DEFAULTS['homePath'])), '/'); if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $home)) { throw new InvalidPage('Die Startseite braucht eine gültige Adresse.'); } // A root that points nowhere would answer every visit with 404, so it must exist. $homeId = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home]); if (false === $homeId) { throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home)); } if ($homeId === $values['notFoundPageId']) { throw new InvalidPage('Die 404-Seite kann nicht gleichzeitig die Startseite sein.'); } $values['homePath'] = $home; $values['consentBanner'] = (bool) ($input['consentBanner'] ?? false); $values['consentGroups'] = array_values(array_intersect( array_keys(Consent::GROUPS), array_map(static fn (mixed $group): string => (string) $group, (array) ($input['consentGroups'] ?? [])), )); $values['consentText'] = mb_substr(trim((string) ($input['consentText'] ?? '')), 0, 600); $values['consentLayout'] = in_array($input['consentLayout'] ?? '', ['karte', 'leiste', 'ecke'], true) ? $input['consentLayout'] : 'karte'; $values['consentShape'] = in_array($input['consentShape'] ?? '', ['weich', 'rund', 'eckig'], true) ? $input['consentShape'] : 'weich'; $values['consentTitle'] = mb_substr(trim((string) ($input['consentTitle'] ?? '')), 0, 80); foreach (['consentColor', 'consentInk', 'consentAccent', 'consentAccentInk'] as $feld) { $farbe = trim((string) ($input[$feld] ?? '')); if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) { throw new InvalidPage('Die Farben des Einwilligungshinweises werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.'); } $values[$feld] = $farbe; } foreach (['privacyPath', 'imprintPath'] as $key) { $path = trim(trim((string) ($input[$key] ?? '')), '/'); if ('' !== $path && 1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $path)) { throw new InvalidPage('Die Adresse der Datenschutz- beziehungsweise Impressumsseite darf nur Kleinbuchstaben, Ziffern, Bindestriche und Schrägstriche enthalten.'); } $values[$key] = $path; } // Privacy statement and imprint are not a feature that can be switched off: a German // business website needs both, and both have to be reachable from every page. What the // CMS cannot do is write them, so it only insists that the pages exist. foreach (['privacyPath' => 'Datenschutzerklärung', 'imprintPath' => 'Impressum'] as $key => $label) { if ('' !== $values[$key] && false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $values[$key]])) { throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s für die %s.', $values[$key], $label)); } } if ($values['consentBanner']) { if ([] === $values['consentGroups']) { throw new InvalidPage('Ohne eine Gruppe gibt es nichts zu fragen. Wähle mindestens eine, oder schalte den Hinweis ab.'); } // A consent banner without a way to read the details is not consent. if ('' === $values['privacyPath']) { throw new InvalidPage('Ein Einwilligungshinweis braucht die Adresse der Datenschutzseite.'); } } $this->loaded = null; $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [ json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default', ]); } /** Change only the 404 assignment, preserving settings saved in other forms. */ public function assignNotFoundPage(string $id, string $expectedId): void { $this->db->transactional(function () use ($id, $expectedId): void { $lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE'; $raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default']); $stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : []; $stored = is_array($stored) ? $stored : []; $current = $stored['notFoundPageId'] ?? ''; if ($current !== $expectedId) { throw new StaleWrite('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.'); } if ('' !== $id) { $page = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$id, 'default']); if (false === $page || 'page' !== $page['type']) { throw new InvalidPage('Bitte wähle eine vorhandene Seite als 404-Seite.'); } if ($page['path'] === ($stored['homePath'] ?? self::DEFAULTS['homePath'])) { throw new InvalidPage('Die Startseite kann nicht gleichzeitig die 404-Seite sein.'); } } if ($current === $id) { return; } $stored['notFoundPageId'] = $id; $changed = $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [ json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default', ]); if (1 !== $changed) { throw new StaleWrite('Die Einstellungen wurden inzwischen geändert. Lade die Seite neu.'); } }); $this->loaded = null; } /** * Which of the two mandatory pages are missing, by their settings key. * * @return array key => label */ public function missingLegalPages(): array { $settings = $this->load(); $missing = []; foreach (['privacyPath' => 'Datenschutzerklärung', 'imprintPath' => 'Impressum'] as $key => $label) { if ('' === $settings[$key]) { $missing[$key] = $label; } } return $missing; } /** The stored path of the page that answers at the site root. */ public function homePath(): string { return $this->load()['homePath']; } /** * Turns a stored page path into its public address. The root page answers at "/" and must not * also be reachable under its own path, or the same content would live at two addresses. */ public function publicPath(string $storedPath, ?string $homePath = null): string { return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath; } /** * Structured data for the organisation, built from the maintained values only. Returns null * while nothing is maintained: an empty Organization block would be noise, not data. */ public function organisation(array $settings, string $origin, string $siteName): ?array { $hasAddress = '' !== $settings['street'] && '' !== $settings['city']; if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) { return null; } $data = [ '@context' => 'https://schema.org', '@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization', 'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName, 'url' => $origin.'/', ]; foreach (['description' => 'description', 'phone' => 'telephone', 'email' => 'email'] as $key => $property) { if ('' !== $settings[$key]) { $data[$property] = $settings[$key]; } } if ($hasAddress) { $data['address'] = array_filter([ '@type' => 'PostalAddress', 'streetAddress' => $settings['street'], 'postalCode' => $settings['postalCode'], 'addressLocality' => $settings['city'], 'addressCountry' => $settings['country'], ], static fn (string $value): bool => '' !== $value); } // Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch // das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste. $sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])])); if ([] !== $sameAs) { $data['sameAs'] = $sameAs; } // Tage mit denselben Zeiten werden zu einer Angabe: Mo-Fr 07:30-17:00 ist eine Zeile, // nicht fünf. Das ist die Form, die Google für openingHoursSpecification dokumentiert. $gruppen = []; foreach ($settings['openingHours'] ?? [] as $eintrag) { $schluessel = $eintrag['opens'].'-'.$eintrag['closes']; $gruppen[$schluessel] ??= ['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => [], 'opens' => $eintrag['opens'], 'closes' => $eintrag['closes']]; $gruppen[$schluessel]['dayOfWeek'][] = $eintrag['day']; } if ([] !== $gruppen) { $data['openingHoursSpecification'] = array_values($gruppen); } if ([] !== ($settings['areaServed'] ?? [])) { $data['areaServed'] = $settings['areaServed']; } return $data; } }