Files
KantSites/src/Content/MenuRepository.php
T
kantarciandClaude Opus 5 af3589c95c
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
Seitenbreite wirkt, vier Menueplaetze, Seiten ohne Kopfbereich
Drei Fragen, drei unterschiedliche Antworten.

Die globale Seitenbreite war einstellbar und wirkte nur in eine Richtung:
site.css schrieb width:min(1180px,...), also schluckte die 1180 jeden
groesseren Wert aus dem Designbereich. Schmaler ging, breiter nicht, ohne
jede Meldung. Die Zahl kommt jetzt aus dem Theme. Das ist der dritte Fehler
dieser Art an einem Tag - nach der Rahmenstaerke und der Bildrundung -, und
alle drei hatten dieselbe Ursache: CSS, das nie gegen eine Erwartung geprueft
wurde.

Menueplaetze und Menueart waren vermischt. Wo ein Menue steht, bestimmt der
Block und war immer frei. Welche Eintraege es zeigt, bestimmt der Platz -
davon gab es zwei, jetzt vier (Haupt, zweite, Fusszeile, Rechtliches). Wie es
aussieht, war gar nicht einstellbar: jetzt waagerecht, untereinander oder
aufklappbar als Burger. Der Burger ist ein details-Element, denn ein Menue,
das ein Skript zum Oeffnen braucht, geht auf einer Seite ohne Skript nicht auf.

"Seiten ohne Menue" war nicht moeglich - der Kopfbereich war ein Dokument fuer
alle Seiten. Jetzt waehlt jede Seite: Standard, ein anderer Bereich, oder
keiner. Dafuer musste die Regel "es gibt genau einen Kopf- und Fussbereich"
fallen; sie war richtig, solange niemand waehlen konnte, und wurde falsch,
sobald man waehlen kann. Ein bestehender Test, der diese Regel zusicherte, ist
deshalb umgeschrieben und prueft jetzt die neue.

Gespeichert wird die Wahl, nicht das Ergebnis: leer heisst Standard, none
heisst keiner, sonst die Adresse. Ein Verweis auf einen geloeschten Bereich
faellt auf den Standard zurueck, statt die Seite stillschweigend ohne
Navigation auszuliefern.

Neu im Ausbauplan: ein einzelnes install.php, das das Releasearchiv
herunterlaedt und entpackt, damit niemand tausende Dateien per FTP hochladen
muss. Mit den Regeln, die darueber entscheiden, ob das eine Hilfe oder eine
Einladung ist: Adresse und SHA-256 stehen in der Datei und nicht im Formular,
HTTPS mit Zertifikatspruefung, Schutz gegen ZIP-Slip, Selbstloeschung danach.

336 PHP-Tests / 1354 Assertions, 17 JS-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:15:03 +02:00

176 lines
8.0 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>}>
*/
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:.');
}
}
}