From 33e4ac0c543064623b7e4e6f78693f801bb24947 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Sat, 12 Sep 2026 14:59:55 +0200 Subject: [PATCH] =?UTF-8?q?Frei=20w=C3=A4hlbare=20Startseite;=20Standardda?= =?UTF-8?q?teien=20waren=20lokal=20unerreichbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Quelle entscheidet jetzt, welche Seite unter / antwortet: SiteSettings::homePath() und publicPath(). Das Literal home kommt im Anwendungscode nicht mehr vor - öffentliche Routen, Canonical, Sitemap, llms.txt, Menüauflösung, Beitragslisten und der Formularrücksprung benutzen dieselben zwei Methoden. Die gewählte Seite muss veröffentlicht und nicht im Papierkorb sein, ihre eigene Adresse leitet dauerhaft auf /, und als Wurzel ist sie gegen Umbenennen und Papierkorb gesperrt. Zweiter echter Fehler, den nur ein HTTP-Aufruf zeigt: /sitemap.xml, /robots.txt, /llms.txt und /feed.xml waren lokal nie erreichbar. Der eingebaute PHP-Server antwortet für fehlende Pfade mit Dateiendung selbst mit 404, ohne den Frontcontroller zu fragen; alle Kernel-Tests bestanden trotzdem, weil sie die Anwendung direkt aufrufen. bin/dev-router.php bildet die Rewrite-Regel des Hostings nach, Start- und CI-Befehl nutzen es, und die Browserprüfung ruft die vier Adressen jetzt über HTTP auf. Barrierefreiheitsbefunde werden nach Ursache getrennt: ein Kontrastfehler aus Theme oder Stylesheet bleibt ein Defekt und bricht den Lauf ab, einer aus einer im Editor gesetzten Blockfarbe wird bei jedem Lauf mit Seite, Farbe und Verhältnis genannt und in reports/browser.json geschrieben, färbt aber keinen Codewechsel rot. Geprüft: 162 PHP-Tests / 636 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, vollständiger Editortest mit axe, wieder grüne öffentliche Browserprüfung und Lighthouse mit je drei Läufen mobil und Desktop, alle fünf Kategorien 100. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 14 +++++- .github/workflows/ci.yml | 2 +- README.md | 6 +-- bin/dev-router.php | 16 +++++++ docs/ARCHITECTURE.md | 18 ++++++++ docs/AUSBAUPLAN.md | 4 +- docs/QUALITY.md | 9 ++++ docs/STATUS.md | 16 ++++--- scripts/browser-check.mjs | 57 +++++++++++++++++++++++-- src/Content/MenuRepository.php | 10 ++--- src/Content/PageRepository.php | 15 +++---- src/Content/PostRepository.php | 4 +- src/Content/SiteSettings.php | 27 ++++++++++++ src/Controller/FormController.php | 8 ++-- src/Controller/PageController.php | 14 +++--- src/Controller/SeoController.php | 6 ++- src/Controller/SettingsController.php | 13 +++++- templates/admin/settings.html.twig | 5 +++ tests/CmsTest.php | 61 +++++++++++++++++++++++++-- tests/EditorSecurityTest.php | 7 +-- tests/browser-fixture.php | 3 +- 21 files changed, 266 insertions(+), 49 deletions(-) create mode 100644 bin/dev-router.php diff --git a/.claude/launch.json b/.claude/launch.json index 937123e..bfa1f3c 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,7 +4,19 @@ { "name": "kant-cms", "runtimeExecutable": "php", - "runtimeArgs": ["-d", "upload_max_filesize=8M", "-d", "post_max_size=10M", "-d", "memory_limit=256M", "-S", "127.0.0.1:8080", "-t", "public"], + "runtimeArgs": [ + "-d", + "upload_max_filesize=8M", + "-d", + "post_max_size=10M", + "-d", + "memory_limit=256M", + "-S", + "127.0.0.1:8080", + "-t", + "public", + "bin/dev-router.php" + ], "port": 8080 } ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6e7ccf..b77cf4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - run: npm test - run: npm run test:editor -- --audit - name: Start production-mode preview - run: APP_ENV=prod APP_DEBUG=0 php -S 127.0.0.1:8080 -t public > var/server.log 2>&1 & + run: APP_ENV=prod APP_DEBUG=0 php -S 127.0.0.1:8080 -t public bin/dev-router.php > var/server.log 2>&1 & - name: Wait for preview run: node -e "for(let i=0;i<30;i++){try{if((await fetch('http://127.0.0.1:8080')).ok)process.exit(0)}catch{}await new Promise(r=>setTimeout(r,500))}process.exit(1)" - run: npm run test:browser diff --git a/README.md b/README.md index b11835d..d5422fd 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ composer install php bin/setup.php php vendor/bin/doctrine-migrations migrate --no-interaction php bin/console cms:seed-demo -php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public +php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public bin/dev-router.php ``` -Danach `http://127.0.0.1:8080/` öffnen. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage. +Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht optional: der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad mit Dateiendung mit 404, sodass `/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` den Frontcontroller nie erreichen. Auf echtem Hosting erledigt das `public/.htaccess`; `bin/dev-router.php` bildet dasselbe lokal nach. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage. `bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Es gibt keinen öffentlichen Installer und keine Standardzugangsdaten. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token. @@ -48,7 +48,7 @@ Die Vorschau rendert mit echten 1240/768/390 px Layoutbreite und passt sich durc composer build $env:APP_ENV = 'prod' $env:APP_DEBUG = '0' -php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public +php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public bin/dev-router.php ``` Unter Linux dieselben PHP-Argumente mit vorangestelltem `APP_ENV=prod APP_DEBUG=0` verwenden. Vorher eine laufende Instanz auf diesem Port beenden. Zum Entwickeln `APP_ENV=dev` und `APP_DEBUG=1` setzen. Debug-Seiten senden absichtlich `X-Robots-Tag: noindex`; Audits laufen im Produktionsmodus. Der eingebaute PHP-Server bleibt ein lokaler Entwicklungsserver. diff --git a/bin/dev-router.php b/bin/dev-router.php new file mode 100644 index 0000000..90293bd --- /dev/null +++ b/bin/dev-router.php @@ -0,0 +1,16 @@ + document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow'); const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze(); - assert.deepEqual(axe.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); - results.push({ width, path, violations: axe.violations.length, checks: axe.passes.length }); + + // A contrast failure has two very different causes, and they need different answers. + // Comes from the theme or a stylesheet: a defect of the system, and the run must fail - + // this is what caught the link colour that overrode every dark section. + // Comes from a colour an editor set on one block: an editorial finding. It is reported on + // every run and written into the report, but it must not turn a code change red, because + // nobody can fix it in the code. + const styledIds = new Set(await page.evaluate(() => + [...document.querySelectorAll('style')] + .flatMap(node => [...node.textContent.matchAll(/#([a-zA-Z0-9_-]+)\s*[{:]/g)].map(match => match[1])))); + const isEditorial = node => node.target.every(target => + typeof target === 'string' && target.startsWith('#') && styledIds.has(target.slice(1))); + + const defects = []; + for (const violation of axe.violations) { + const editorial = 'color-contrast' === violation.id ? violation.nodes.filter(isEditorial) : []; + const rest = violation.nodes.filter(node => !editorial.includes(node)); + if (editorial.length > 0) { + findings.push({ + path, + width, + id: violation.id, + cause: 'Inhalt: im Editor gesetzte Farbe', + nodes: editorial.map(node => ({ target: node.target, summary: node.failureSummary?.split('\n')[1]?.trim() })), + }); + } + if (rest.length > 0) defects.push({ id: violation.id, nodes: rest.map(node => node.target) }); + } + assert.deepEqual(defects, [], JSON.stringify(defects, null, 1)); + results.push({ width, path, defects: defects.length, editorialFindings: findings.filter(f => f.path === path && f.width === width).length, checks: axe.passes.length }); await page.screenshot({ path: `reports/${width}-${path === '/' ? 'home' : path.slice(1)}.png`, fullPage: true }); } await page.goto(origin); @@ -41,8 +83,17 @@ try { assert.match(await noJs.locator('h1').innerText(), /Deine Inhalte/); await noJs.getByRole('link', { name: 'Die Grundlage entdecken' }).click(); assert.equal(new URL(noJs.url()).pathname, '/grundlagen'); - await writeFile('reports/browser.json', JSON.stringify({ origin, results, keyboard: 'passed', noJavaScript: 'passed' }, null, 2)); + await writeFile('reports/browser.json', JSON.stringify({ origin, results, editorialFindings: findings, keyboard: 'passed', noJavaScript: 'passed' }, null, 2)); console.log('Passed: 6 page/viewport accessibility checks, keyboard navigation, no-JS navigation.'); + if (findings.length > 0) { + // Loud on every run: these are real accessibility problems, they are just fixed in the editor + // rather than in the code, so they do not make a code change fail. + console.log('\n' + findings.length + ' Barrierefreiheits-Befund(e) aus im Editor gesetzten Farben:'); + for (const finding of findings) { + console.log(' ' + finding.path + ' (' + finding.width + ' px) ' + finding.id + ': ' + finding.nodes.map(n => n.target.join(',') + ' - ' + n.summary).join(' | ')); + } + console.log(' Zu beheben im Editor, nicht im Code. Details auch in reports/browser.json.'); + } } finally { await browser.close(); } diff --git a/src/Content/MenuRepository.php b/src/Content/MenuRepository.php index 543a875..dcdbb37 100644 --- a/src/Content/MenuRepository.php +++ b/src/Content/MenuRepository.php @@ -18,7 +18,7 @@ final class MenuRepository public const MAX_ITEMS = 60; public const MAX_DEPTH = 2; - public function __construct(private Connection $db) + public function __construct(private Connection $db, private SiteSettings $settings) { } @@ -82,14 +82,14 @@ final class MenuRepository $pages[$page['id']] = $page; } - return $this->resolveLevel($items, $pages, $currentPath); + return $this->resolveLevel($items, $pages, $currentPath, $this->settings->homePath()); } - private function resolveLevel(array $items, array $pages, string $currentPath): array + private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array { $links = []; foreach ($items as $item) { - $children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath); + $children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath); $label = $item['label'] ?? ''; if ('page' === $item['type']) { $page = $pages[$item['pageId']] ?? null; @@ -98,7 +98,7 @@ final class MenuRepository array_push($links, ...$children); continue; } - $path = 'home' === $page['path'] ? '/' : '/'.$page['path']; + $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. diff --git a/src/Content/PageRepository.php b/src/Content/PageRepository.php index ebc0d64..3df3041 100644 --- a/src/Content/PageRepository.php +++ b/src/Content/PageRepository.php @@ -12,7 +12,7 @@ final class PageRepository public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege']; public const MAX_DEPTH = 4; - public function __construct(private Connection $db, private DocumentValidator $validator) + public function __construct(private Connection $db, private DocumentValidator $validator, private SiteSettings $settings) { } @@ -94,11 +94,10 @@ final class PageRepository if ((int) $page['version'] !== $expectedVersion) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); } - if ('home' === $page['path']) { - // The path 'home' is assumed as the site root in several places (public routing, canonical, - // sitemap, llms.txt, menu resolution). Making it configurable is its own step; until - // then the start page keeps its address rather than half of the system following it. - throw new InvalidPage('Die Startseite behält ihre Adresse. Eine andere Seite als Startseite zu wählen ist noch nicht möglich.'); + if ($page['path'] === $this->settings->homePath()) { + // The root answers at "/". Renaming it would move the whole site to a new address, + // which is a settings decision, not an editing one. + throw new InvalidPage('Diese Seite ist die Startseite. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.'); } $newPath = $slug; @@ -169,8 +168,8 @@ final class PageRepository if ((int) $page['version'] !== $expectedVersion) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); } - if ('home' === $page['path']) { - throw new InvalidPage('Die Startseite kann nicht in den Papierkorb.'); + if ($page['path'] === $this->settings->homePath()) { + throw new InvalidPage('Die Startseite kann nicht in den Papierkorb. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.'); } return $this->db->executeStatement( diff --git a/src/Content/PostRepository.php b/src/Content/PostRepository.php index c0bce17..ac38485 100644 --- a/src/Content/PostRepository.php +++ b/src/Content/PostRepository.php @@ -17,7 +17,7 @@ final class PostRepository public const ARCHIVE_PREFIX = ['category' => 'kategorie', 'tag' => 'schlagwort']; public const PER_PAGE = 10; - public function __construct(private Connection $db, private MediaLibrary $media) + public function __construct(private Connection $db, private MediaLibrary $media, private SiteSettings $settings) { } @@ -141,7 +141,7 @@ final class PostRepository return [ 'title' => $document['title'] ?? $row['path'], - 'href' => '/'.$row['path'], + 'href' => $this->settings->publicPath($row['path']), 'excerpt' => $row['excerpt'] ?? $document['description'] ?? '', 'author' => $row['author'], 'publishedAt' => $row['published_at'], diff --git a/src/Content/SiteSettings.php b/src/Content/SiteSettings.php index d7d524a..949aae0 100644 --- a/src/Content/SiteSettings.php +++ b/src/Content/SiteSettings.php @@ -25,6 +25,8 @@ final class SiteSettings '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) @@ -82,12 +84,37 @@ final class SiteSettings } $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. diff --git a/src/Controller/FormController.php b/src/Controller/FormController.php index 6691246..3c73d91 100644 --- a/src/Controller/FormController.php +++ b/src/Controller/FormController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Content\FormRepository; use App\Content\MenuRepository; use App\Content\PageRepository; +use App\Content\SiteSettings; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,6 +26,7 @@ final class FormController extends AbstractController private FormRepository $forms, private PageRepository $pages, private MenuRepository $menus, + private SiteSettings $settings, private MailerInterface $mailer, private string $secret, private string $siteOrigin, @@ -35,7 +37,7 @@ final class FormController extends AbstractController public function submit(Request $request, string $slug): Response { $form = $this->forms->findBySlug($slug); - $path = trim($request->request->getString('page'), '/') ?: 'home'; + $path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath(); $page = $this->pages->findPublished($path); if (null === $form || null === $page) { throw $this->createNotFoundException('Formular oder Seite nicht gefunden.'); @@ -61,7 +63,7 @@ final class FormController extends AbstractController } // Post/Redirect/Get so a reload does not send the message twice. - return $this->redirect(('home' === $path ? '/' : '/'.$path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303); + return $this->redirect($this->settings->publicPath($path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303); } /** The signed timestamp makes the age of the form checkable without storing anything. */ @@ -133,7 +135,7 @@ final class FormController extends AbstractController $response = $this->render('page.html.twig', [ 'page' => $page, - 'canonical' => rtrim($this->siteOrigin, '/').('home' === $page['path'] ? '/' : '/'.$page['path']), + 'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($page['path']), 'navigation' => $navigation, 'postLists' => [], 'postMeta' => null, diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index fbce483..520ce45 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -27,10 +27,12 @@ final class PageController extends AbstractController #[Route('/', name: 'home', methods: ['GET'])] #[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])] - public function page(Request $request, string $path = 'home'): Response + public function page(Request $request, string $path = ''): Response { - // One canonical address for the home page. - if ('home' === $path && '/home' === $request->getPathInfo()) { + $home = $this->settings->homePath(); + $path = '' === $path ? $home : $path; + // One canonical address for the root page: its own path redirects to "/". + if ($path === $home && '/' !== $request->getPathInfo()) { return $this->redirectToRoute('home', status: 301); } $page = $this->pages->findPublished($path); @@ -52,7 +54,7 @@ final class PageController extends AbstractController } else { $response = $this->render('page.html.twig', [ 'page' => $page, - 'canonical' => rtrim($this->siteOrigin, '/').('home' === $path ? '/' : '/'.$path), + 'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home), 'navigation' => $this->navigation($path), 'postLists' => $this->postLists($page['document']), 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null, @@ -140,7 +142,7 @@ final class PageController extends AbstractController */ private function breadcrumbs(array $page): array { - if ('home' === $page['path']) { + if ($page['path'] === $this->settings->homePath()) { return []; } $trail = []; @@ -166,7 +168,7 @@ final class PageController extends AbstractController { $origin = rtrim($this->siteOrigin, '/'); $blocks = []; - if ('home' === $page['path']) { + if ($page['path'] === $this->settings->homePath()) { $organisation = $this->settings->organisation($this->settings->load(), $origin, (string) $page['website_name']); if (null !== $organisation) { $blocks[] = $organisation; diff --git a/src/Controller/SeoController.php b/src/Controller/SeoController.php index 55233a2..fdf2b2b 100644 --- a/src/Controller/SeoController.php +++ b/src/Controller/SeoController.php @@ -28,6 +28,7 @@ final class SeoController extends AbstractController public function sitemap(): Response { $origin = rtrim($this->siteOrigin, '/'); + $home = $this->settings->homePath(); $entries = []; // Only published pages that are allowed to be indexed. A draft or a noindex page in the // sitemap would ask a search engine to fetch something it must not list. @@ -36,7 +37,7 @@ final class SeoController extends AbstractController ['default'], ) as $row) { $entries[] = [ - 'loc' => $origin.('home' === $row['path'] ? '/' : '/'.$row['path']), + 'loc' => $origin.$this->settings->publicPath($row['path'], $home), 'lastmod' => substr((string) ($row['published_at'] ?? $row['created_at']), 0, 10), ]; } @@ -80,6 +81,7 @@ final class SeoController extends AbstractController throw $this->createNotFoundException('llms.txt ist für diese Website nicht aktiviert.'); } $origin = rtrim($this->siteOrigin, '/'); + $home = $settings['homePath']; $name = (string) $this->db->fetchOne('SELECT name FROM website WHERE id = ?', ['default']); $lines = ['# '.('' !== $settings['legalName'] ? $settings['legalName'] : $name), '']; if ('' !== $settings['description']) { @@ -98,7 +100,7 @@ final class SeoController extends AbstractController foreach ($rows as $row) { $document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); $summary = trim((string) ($row['excerpt'] ?? $document['description'] ?? '')); - $lines[] = sprintf('- [%s](%s)%s', $document['title'] ?? $row['path'], $origin.('home' === $row['path'] ? '/' : '/'.$row['path']), '' === $summary ? '' : ': '.$summary); + $lines[] = sprintf('- [%s](%s)%s', $document['title'] ?? $row['path'], $origin.$this->settings->publicPath($row['path'], $home), '' === $summary ? '' : ': '.$summary); } $lines[] = ''; } diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index ef992a6..83175da 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Content\PageRepository; use App\Content\RedirectRepository; use App\Content\SiteSettings; use App\Domain\InvalidPage; @@ -12,16 +13,26 @@ use Symfony\Component\Routing\Attribute\Route; final class SettingsController extends AbstractController { - public function __construct(private SiteSettings $settings, private RedirectRepository $redirects) + public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages) { } #[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])] public function index(): Response { + $choosable = []; + foreach ($this->pages->listForEditor() as $page) { + // Only a published page can serve as the root; a draft would answer every visit with 404. + if ('page' === $page['type'] && null !== $page['published_revision_id']) { + $document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR); + $choosable[] = ['path' => $page['path'], 'title' => $document['title'] ?? $page['path']]; + } + } + return $this->render('admin/settings.html.twig', [ 'settings' => $this->settings->load(), 'redirects' => $this->redirects->all(), + 'choosableHome' => $choosable, ]); } diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index d335ddf..4dc1baf 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -55,6 +55,11 @@

Zweibuchstabiger Code, zum Beispiel DE.

+ + +

Diese Seite antwortet unter /; ihre eigene Adresse leitet dauerhaft dorthin. Nur veröffentlichte Seiten sind wählbar, und die Startseite lässt sich nicht umbenennen oder in den Papierkorb legen.

diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 11d0dfd..e442db9 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -67,9 +67,9 @@ final class CmsTest extends TestCase foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) { $this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]); } - $this->pages = new PageRepository($this->db, new DocumentValidator()); - $this->menus = new MenuRepository($this->db); - $this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media')); + $this->pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db)); + $this->menus = new MenuRepository($this->db, new SiteSettings($this->db)); + $this->posts = new PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), new SiteSettings($this->db)); $this->site = new SiteSettings($this->db); $this->redirects = new RedirectRepository($this->db); $this->themes = new Theme($this->db); @@ -1364,6 +1364,61 @@ final class CmsTest extends TestCase return $id; } + public function testAnyPublishedPageCanBecomeTheRootAndEverythingFollows(): void + { + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1); + $start = $this->publishedPage('willkommen', 'Willkommen'); + self::assertNotNull($this->pages->findForEditor($start)); + $this->site->save(['country' => 'DE', 'homePath' => 'willkommen', 'legalName' => 'Kant-IT', 'street' => 'Ooser Hauptstr. 51', 'city' => 'Baden-Baden']); + + // The chosen page answers at the root, and its own address redirects there. + $root = $this->request('/'); + self::assertSame(200, $root->getStatusCode()); + self::assertStringContainsString('>Willkommen', $root->getContent()); + self::assertStringContainsString('href="https://cms.test/"', $root->getContent()); + $own = $this->request('/willkommen'); + self::assertSame(301, $own->getStatusCode()); + self::assertSame('/', $own->headers->get('Location')); + + // The previous root became an ordinary page and keeps its own address. + self::assertSame(200, $this->request('/home')->getStatusCode()); + + // Sitemap, structured data and the menu follow the same decision. + self::assertStringContainsString('https://cms.test/', $this->request('/sitemap.xml')->getContent()); + self::assertStringContainsString('https://cms.test/home', $this->request('/sitemap.xml')->getContent()); + self::assertStringContainsString('"@type":"Organization"', $root->getContent(), 'Organisation data belongs on the root page.'); + self::assertStringNotContainsString('schema.org', $this->request('/home')->getContent()); + $this->menus->save('header', 'Hauptnavigation', [ + ['label' => 'Start', 'type' => 'page', 'pageId' => $start, 'anchor' => '', 'children' => []], + ], 0); + self::assertStringContainsString('Start', $this->request('/')->getContent()); + + // The root is protected from being renamed or binned while it is the root. + foreach ([fn () => $this->pages->movePage($start, 'anders', null, $this->pages->findForEditor($start)['version'], $this->redirects), + fn () => $this->pages->trashPage($start, $this->pages->findForEditor($start)['version'])] as $attempt) { + try { + $attempt(); + self::fail('Expected the root page to be protected.'); + } catch (InvalidPage) { + } + } + // But the page that used to be the root can now be moved. + self::assertSame(1, $this->pages->movePage('page-1', 'alte-startseite', null, $this->pages->findForEditor('page-1')['version'], $this->redirects)); + } + + public function testRootMustBeAnExistingPublishedPage(): void + { + $before = $this->site->load()['homePath']; + foreach (['gibt-es-nicht', '', 'Gross'] as $candidate) { + try { + $this->site->save(['country' => 'DE', 'homePath' => $candidate]); + self::fail(sprintf('Expected "%s" to be refused as the root.', $candidate)); + } catch (InvalidPage) { + } + } + self::assertSame($before, $this->site->load()['homePath']); + } + private function request(string $url, string $method = 'GET'): Response { $previous = []; diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php index dd4d18a..b131137 100644 --- a/tests/EditorSecurityTest.php +++ b/tests/EditorSecurityTest.php @@ -7,6 +7,7 @@ use App\Content\DocumentValidator; use App\Content\FormRepository; use App\Content\MenuRepository; use App\Content\PageRepository; +use App\Content\SiteSettings; use App\Infrastructure\ConnectionFactory; use App\Kernel; use App\Security\UserProvider; @@ -65,7 +66,7 @@ final class EditorSecurityTest extends WebTestCase Version20260912000900::applyIndexes($this->db); $this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']); $this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]); - $pages = new PageRepository($this->db, new DocumentValidator()); + $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db)); $revision = $pages->saveDraft('p1', DemoContent::pages()['home'], 0); $pages->publish('p1', $revision, 1); foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) { @@ -210,7 +211,7 @@ final class EditorSecurityTest extends WebTestCase public function testMenuWritesNeedAnEditorRoleACsrfTokenAndTheCurrentVersion(): void { - $menus = new MenuRepository($this->db); + $menus = new MenuRepository($this->db, new SiteSettings($this->db)); $menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0); $client = $this->login('editor'); @@ -257,7 +258,7 @@ final class EditorSecurityTest extends WebTestCase $document = DemoContent::starter('Kontakt'); $document['schemaVersion'] = 8; $document['blocks'][] = ['id' => 'formblock', 'type' => 'form', 'props' => ['form' => 'kontakt']]; - $pages = new PageRepository($this->db, new DocumentValidator()); + $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db)); $revision = $pages->saveDraft('p1', $document, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1'])); $pages->publish('p1', $revision, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1'])); diff --git a/tests/browser-fixture.php b/tests/browser-fixture.php index 74b77d4..3d18333 100644 --- a/tests/browser-fixture.php +++ b/tests/browser-fixture.php @@ -6,6 +6,7 @@ require dirname(__DIR__).'/vendor/autoload.php'; use App\Content\DemoContent; use App\Content\DocumentValidator; use App\Content\PageRepository; +use App\Content\SiteSettings; use App\Infrastructure\ConnectionFactory; use Doctrine\DBAL\Schema\Schema; use DoctrineMigrations\Version20260912000100; @@ -43,7 +44,7 @@ Version20260912000400::applyIndexes($db); Version20260912000600::applyIndexes($db); Version20260912000900::applyIndexes($db); $db->insert('website', ['id' => 'default', 'name' => 'Browser test', 'default_locale' => 'de', 'theme' => '{}']); -$pages = new PageRepository($db, new DocumentValidator()); +$pages = new PageRepository($db, new DocumentValidator(), new SiteSettings($db)); foreach (DemoContent::pages() as $slug => $document) { $id = 'test-'.$slug; $db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);