191 lines
8.7 KiB
PHP
191 lines
8.7 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\InvalidMenu;
|
|
use App\Domain\StaleWrite;
|
|
use Doctrine\DBAL\Connection;
|
|
|
|
/**
|
|
* Menus are small enough to live as one validated item tree per slot. An item points at a page
|
|
* (optionally at an anchor inside it) or at an external address. Page references are resolved when
|
|
* the menu is rendered, never stored as a path: renaming or deleting a page must not silently
|
|
* produce a dead link.
|
|
*/
|
|
final class MenuRepository
|
|
{
|
|
/**
|
|
* The places a menu can be maintained for. They are not positions on the page - a menu block
|
|
* can stand anywhere - but separate sets of entries: a header needs other links than a
|
|
* footer, and legal links are their own short list.
|
|
*/
|
|
public const SLOTS = [
|
|
'header' => '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<string, array{id: string, name: string, items: array, version: int}>
|
|
*/
|
|
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<array{label: string, href: string, current: bool, children: list<array>}>
|
|
*/
|
|
/**
|
|
* Die Seitenliste, die jedes Menue zum Aufloesen braucht - einmal je Anfrage.
|
|
*
|
|
* Vorher holte jedes Menue sie selbst. Mit zwei Menuen fiel das nicht auf, mit vieren - Kopf,
|
|
* zwei Listen im Fuss und die Rechtszeile - waren es vier gleiche Abfragen je Seitenaufruf.
|
|
*
|
|
* @var array<string, array<string, mixed>>|null
|
|
*/
|
|
private ?array $pages = null;
|
|
|
|
public function resolve(array $items, string $currentPath, string $locale = 'de'): array
|
|
{
|
|
if (null === $this->pages) {
|
|
$this->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) {
|
|
$this->pages[$page['id']] = $page;
|
|
}
|
|
}
|
|
|
|
return $this->resolveLevel($items, $this->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, ...(($item['newTab'] ?? false) ? ['newTab' => true] : [])];
|
|
}
|
|
|
|
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 (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) {
|
|
throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.');
|
|
}
|
|
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:.');
|
|
}
|
|
}
|
|
}
|