'Hauptnavigation', 'secondary' => 'Zweite Navigation', 'footer' => 'Fußzeile', 'legal' => 'Rechtliches', ]; public const MAX_ITEMS = 60; public const MAX_DEPTH = 2; public function __construct(private Connection $db, private SiteSettings $settings) { } /** * @return array */ public function all(string $locale = 'de'): array { $menus = []; foreach ($this->db->fetchAllAssociative('SELECT id, slot, name, items, version FROM menu WHERE website_id = ? AND locale = ?', ['default', $locale]) as $row) { $menus[$row['slot']] = [ 'id' => $row['id'], 'name' => $row['name'], 'version' => (int) $row['version'], 'items' => json_decode($row['items'], true, 512, JSON_THROW_ON_ERROR), ]; } return $menus; } public function save(string $slot, string $name, array $items, int $expectedVersion, string $locale = 'de'): void { if (!isset(self::SLOTS[$slot])) { throw new InvalidMenu('Unbekannter Menüplatz.'); } $name = trim($name); if ('' === $name || mb_strlen($name) > 120) { throw new InvalidMenu('Bitte gib einen Namen mit höchstens 120 Zeichen an.'); } $this->validate($items); $this->db->transactional(function () use ($slot, $name, $items, $expectedVersion, $locale): void { $encoded = json_encode($items, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); $existing = $this->db->fetchAssociative('SELECT id, version FROM menu WHERE website_id = ? AND locale = ? AND slot = ?', ['default', $locale, $slot]); if (false === $existing) { if (0 !== $expectedVersion) { throw new StaleWrite('Das Menü wurde inzwischen angelegt. Lade die Seite neu.'); } $this->db->insert('menu', ['id' => bin2hex(random_bytes(16)), 'website_id' => 'default', 'locale' => $locale, 'slot' => $slot, 'name' => $name, 'items' => $encoded, 'version' => 1]); return; } $changed = $this->db->executeStatement( 'UPDATE menu SET name = ?, items = ?, version = version + 1 WHERE id = ? AND version = ?', [$name, $encoded, $existing['id'], $expectedVersion], ); if (1 !== $changed) { throw new StaleWrite('Das Menü wurde inzwischen geändert. Lade die Seite neu, bevor du speicherst.'); } }); } /** * Turns the stored tree into links. Items whose page is gone or not published are dropped: * a deleted page must not break the navigation or expose a draft. * * @return list}> */ public function resolve(array $items, string $currentPath, string $locale = 'de'): array { $pages = []; foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page WHERE website_id = ? AND locale = ? AND deleted_at IS NULL', ['default', $locale]) as $page) { $pages[$page['id']] = $page; } return $this->resolveLevel($items, $pages, $currentPath, $this->settings->homePath()); } private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array { $links = []; foreach ($items as $item) { $children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath); $label = $item['label'] ?? ''; if ('page' === $item['type']) { $page = $pages[$item['pageId']] ?? null; if (null === $page || null === $page['published_revision_id']) { // Keep any resolvable children rather than dropping a whole branch. array_push($links, ...$children); continue; } $path = $this->settings->publicPath($page['path'], $homePath); $anchor = $item['anchor'] ?? ''; $href = $path.('' === $anchor ? '' : '#'.$anchor); // An anchor link is never the current page on its own; it jumps inside one. $current = '' === $anchor && $page['path'] === $currentPath; } else { $href = $item['url']; $current = false; } if ('' === trim($label)) { continue; } $links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children]; } return $links; } private function validate(array $items, int $depth = 1, int &$count = 0): void { if (!array_is_list($items)) { throw new InvalidMenu('Die Menüstruktur ist ungültig.'); } if ([] !== $items && $depth > self::MAX_DEPTH) { throw new InvalidMenu(sprintf('Mehr als %d Menüebenen sind nicht vorgesehen.', self::MAX_DEPTH)); } foreach ($items as $item) { if (++$count > self::MAX_ITEMS) { throw new InvalidMenu(sprintf('Ein Menü fasst höchstens %d Einträge.', self::MAX_ITEMS)); } if (!is_array($item) || !is_string($item['label'] ?? null) || '' === trim($item['label']) || mb_strlen($item['label']) > 80) { throw new InvalidMenu('Jeder Eintrag braucht eine Beschriftung mit höchstens 80 Zeichen.'); } if ('page' === ($item['type'] ?? null)) { if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) { throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.'); } $anchor = $item['anchor'] ?? ''; if (!is_string($anchor) || ('' !== $anchor && 1 !== preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,64}$/D', $anchor))) { throw new InvalidMenu('Ein Sprungziel besteht aus Buchstaben, Ziffern, Bindestrich und Unterstrich und beginnt mit einem Buchstaben.'); } } elseif ('url' === ($item['type'] ?? null)) { $this->url($item['url'] ?? null); } else { throw new InvalidMenu('Unbekannte Zielart.'); } $this->validate($item['children'] ?? [], $depth + 1, $count); } } private function url(mixed $url): void { if (!is_string($url) || strlen($url) > 2048 || preg_match('/[\x00-\x20\\\\]/', $url)) { throw new InvalidMenu('Die Adresse ist leer, zu lang oder enthält unerlaubte Zeichen.'); } // tel: and mailto: are real navigation targets on the pilot site, so they belong here. $allowed = str_starts_with($url, 'https://') && false !== filter_var($url, FILTER_VALIDATE_URL); $allowed = $allowed || (str_starts_with($url, '/') && !str_starts_with($url, '//')); $allowed = $allowed || 1 === preg_match('/^mailto:[^@\s]+@[^@\s]+$/D', $url); $allowed = $allowed || 1 === preg_match('/^tel:\+?[0-9 ()-]{3,32}$/D', $url); if (!$allowed) { throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade sowie mailto: und tel:.'); } } }