'', 'description' => '', 'phone' => '', 'email' => '', 'street' => '', 'postalCode' => '', 'city' => '', 'country' => 'DE', 'sameAs' => [], '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, // 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', // 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' => '', 'privacyPath' => '', 'imprintPath' => '', ]; public function __construct(private Connection $db, private MediaLibrary $media) { } /** * 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; 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['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); $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); // 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. if (false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home])) { throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home)); } $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); 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', ]); } /** * 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); } if ([] !== $settings['sameAs']) { $data['sameAs'] = $settings['sameAs']; } return $data; } }