Files
KantSites/src/Content/SiteSettings.php
T
kantarciandClaude Opus 5 de1e66350b
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Knopf nach oben, Bildbetrachter, Rechtszeile als Baustein (Schema v19)
Der Knopf "nach oben" ist ein Anker, kein Skript: er springt an den
Seitenanfang, eingeblendet ueber eine Scroll-Zeitachse. Abschaltbar und
vollstaendig einstellbar - Seite, Form, Groesse, Farben, Schwelle,
Beschriftung.

Bilder lassen sich vergroessern, je Bild waehlbar (nichts / vergroessern
/ Adresse), Galerien mit einem Schalter. Grundlage ist ein gewoehnlicher
Verweis auf die grosse Fassung: ohne JavaScript oeffnet er das Bild, mit
JavaScript einen dialog-Betrachter mit Blaettern, Escape und Pfeiltasten.
Das Skript laedt nur, wo es etwas zu tun hat.

Die Rechtszeile wird ein Baustein und steht damit im Fussbereich statt
als heller Streifen darunter. Die eingebaute Leiste erscheint nur noch,
wenn die Seite den Baustein nicht enthaelt.

Dabei gefunden: zwei Stylesheet-Regeln griffen seit drei
Veroeffentlichungen nicht mehr. Beim Entfernen alter Regeln waren
Selektorreste stehen geblieben, und CSS nimmt dann die naechste Regel
mit - betroffen waren der Einwilligungsdialog und die Rechtszeile. Ein
Test prueft jetzt jede CSS-Datei auf abgebrochene Selektoren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:35:43 +02:00

310 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\TableNotFoundException;
/**
* Company data and site wide switches. The structured data the pages emit is built from exactly
* these values, so what a search engine reads is what an editor maintains here – never a separate,
* invisible copy.
*/
final class SiteSettings
{
public const DEFAULTS = [
'legalName' => '',
'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<string, string> 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;
}
}