'', 'description' => '', 'phone' => '', 'email' => '', 'street' => '', 'postalCode' => '', 'city' => '', 'country' => 'DE', 'sameAs' => [], 'localBusiness' => false, 'llmsTxt' => false, 'robotsExtra' => '', // Which stored page answers at the site root. Everything else derives from this. 'homePath' => 'home', ]; public function __construct(private Connection $db) { } public function load(): array { $raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']); $stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : []; return [...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); // Only comment and simple directive lines; nothing that could smuggle in a second policy. $extra = []; foreach (preg_split('/\R/', (string) ($input['robotsExtra'] ?? '')) ?: [] as $line) { $line = trim($line); if ('' !== $line && 1 === preg_match('/^(#.{0,180}|(?:Disallow|Allow|Crawl-delay|User-agent):[ \t][^\s][^\r\n]{0,180})$/D', $line)) { $extra[] = $line; } } $values['robotsExtra'] = implode("\n", array_slice($extra, 0, 30)); $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; $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [ json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default', ]); } /** 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; } }