Seiten im Browser anlegen und löschen, Adresshierarchie und Beitragstyp

Migration 20260912000400 ergänzt page.path, parent_id, type und
created_at. Die websiteweite Eindeutigkeit wandert vom Slug auf den
vollständigen Pfad, damit gleiche Segmente unter verschiedenen Eltern
möglich sind. Nur ADD COLUMN: Index- oder Fremdschlüsseländerungen
würden SQLite die Tabelle neu aufbauen lassen, was am Fremdschlüssel
revision.page_id scheitert; die Indizes werden in postUp() gesetzt.

POST /admin/pages legt Seiten und Beiträge als privaten Entwurf an
(ROLE_EDITOR), POST /admin/pages/{id}/delete entfernt eine Seite mit
allen Revisionen (ROLE_ADMIN). Beides mit CSRF, ohne JavaScript
bedienbar, mit Schutz für Elternseiten und veraltete Versionen.
Die öffentliche Route bedient verschachtelte Pfade und lässt die
CMS-Routen unberührt.

Testumgebung: Login-Throttling zählt jetzt in einem Array-Cache,
weil die persistierten Zähler die Anmeldung über Testläufe hinweg
aussperrten.

Geprüft: 56 PHP-Tests / 212 Assertions unter PHP 8.2.12 und 8.4.22,
10 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst
gegen eine Datenbankkopie. Keine Browser- oder Lighthouse-Läufe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 10:31:46 +02:00
1 parent 82b07820bc
commit a6ffb9bdfd
16 files changed
+555 -23

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ final class SeedDemoCommand extends Command
continue;
}
$id = bin2hex(random_bytes(16));
$this->db->insert('page', ['id' => $id, 'website_id' => 'default', 'locale' => 'de', 'slug' => $slug, 'version' => 0]);
$this->db->insert('page', ['id' => $id, 'website_id' => 'default', 'locale' => 'de', 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
$revision = $this->pages->saveDraft($id, $document, 0);
$this->pages->publish($id, $revision, 1);
$output->writeln('Created: '.$slug);
+22
View File
@@ -49,6 +49,28 @@ final class DemoContent
];
}
/** The first draft of a newly created page: valid, empty enough to replace, with exactly one H1. */
public static function starter(string $title, string $type = 'page'): array
{
return [
'schemaVersion' => 3,
'title' => $title,
'description' => 'post' === $type
? 'Ein neuer Beitrag. Ersetze diese Beschreibung durch eine kurze Zusammenfassung.'
: 'Eine neue Seite. Ersetze diese Beschreibung durch eine kurze Zusammenfassung fuer Suchmaschinen.',
'blocks' => [
self::section('start-hero', [
self::heading('start-heading', $title, 1),
self::text('start-intro', 'Schreibe hier den ersten Absatz. Waehle links ein Element aus, um es zu bearbeiten, oder fuege ueber die Elementbibliothek weitere Bloecke hinzu.'),
], 'mint'),
self::section('start-body', [
self::heading('start-subheading', 'Ein erster Abschnitt', 2),
self::text('start-text', 'Dieser Text ist ein Platzhalter. Ersetze ihn durch deinen eigenen Inhalt.'),
], 'paper', 'narrow'),
],
];
}
private static function article(string $title, string $description, string $heading, array $sections): array
{
$blocks = [self::section('article-hero', [self::text('article-label', 'KANT CMS / ENTWICKLUNG'), self::heading('article-heading', $heading, 1)], 'mint')];
+86 -4
View File
@@ -2,15 +2,97 @@
namespace App\Content;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use Doctrine\DBAL\Connection;
final class PageRepository
{
/** Reserved because the application already answers under these paths. */
public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon'];
public const MAX_DEPTH = 4;
public function __construct(private Connection $db, private DocumentValidator $validator)
{
}
/**
* Creates a page with its first draft. The draft stays private until it is published.
*
* @return string the new page ID
*/
public function createPage(string $slug, ?string $parentId, string $type, array $document, string $locale = 'de'): string
{
$slug = strtolower(trim($slug));
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
}
if (!in_array($type, ['page', 'post'], true)) {
throw new InvalidPage('Unbekannter Inhaltstyp.');
}
$this->validator->validate($document);
return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string {
$path = $slug;
if (null !== $parentId) {
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ?', [$parentId, 'default', $locale]);
if (false === $parent) {
throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.');
}
if ('page' !== $parent['type']) {
throw new InvalidPage('Beiträge können keine Unterseiten enthalten.');
}
if (substr_count($parent['path'], '/') + 2 > self::MAX_DEPTH) {
throw new InvalidPage(sprintf('Mehr als %d Ebenen sind nicht vorgesehen.', self::MAX_DEPTH));
}
$path = $parent['path'].'/'.$slug;
} elseif (in_array($slug, self::RESERVED_SLUGS, true)) {
throw new InvalidPage('Diese Adresse ist für das CMS reserviert.');
}
if (false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', $locale, $path])) {
throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $path));
}
$pageId = bin2hex(random_bytes(16));
$revisionId = bin2hex(random_bytes(16));
$this->db->insert('page', [
'id' => $pageId, 'website_id' => 'default', 'locale' => $locale,
'slug' => $slug, 'path' => $path, 'parent_id' => $parentId, 'type' => $type,
'draft_revision_id' => $revisionId, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s'),
]);
$this->db->insert('revision', [
'id' => $revisionId, 'page_id' => $pageId,
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'created_at' => gmdate('Y-m-d H:i:s'),
]);
return $pageId;
});
}
/**
* Removes a page and its revisions. Pages with children stay untouched so no content
* disappears silently; the editor has to deal with the children first.
*/
public function deletePage(string $pageId, int $expectedVersion): void
{
$this->db->transactional(function () use ($pageId, $expectedVersion): void {
$page = $this->db->fetchAssociative('SELECT version FROM page WHERE id = ? AND website_id = ?', [$pageId, 'default']);
if (false === $page) {
throw new InvalidPage('Diese Seite gibt es nicht mehr.');
}
if ((int) $page['version'] !== $expectedVersion) {
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.');
}
if (0 !== (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE parent_id = ?', [$pageId])) {
throw new InvalidPage('Diese Seite hat Unterseiten. Verschiebe oder lösche zuerst die Unterseiten.');
}
// Detach both pointers first: the revision rows are referenced by the page row.
$this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]);
$this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]);
$this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]);
});
}
public function saveDraft(string $pageId, array $document, int $expectedVersion): string
{
$this->validator->validate($document);
@@ -52,11 +134,11 @@ final class PageRepository
});
}
public function findPublished(string $slug, string $locale = 'de'): ?array
public function findPublished(string $path, string $locale = 'de'): ?array
{
$page = $this->db->fetchAssociative(
'SELECT p.id, p.slug, p.locale, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.slug = ? AND p.locale = ? AND p.website_id = ?',
[$slug, $locale, 'default'],
'SELECT p.id, p.slug, p.path, p.type, p.locale, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ?',
[$path, $locale, 'default'],
);
if (false === $page) {
return null;
@@ -69,7 +151,7 @@ final class PageRepository
public function listForEditor(): array
{
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.locale, p.version, p.published_revision_id, p.draft_revision_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.slug', ['default']);
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.path', ['default']);
}
public function findForEditor(string $id): ?array
+68 -1
View File
@@ -2,9 +2,11 @@
namespace App\Controller;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\PageRepository;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -26,9 +28,74 @@ final class EditorController extends AbstractController
$pages = $this->pages->listForEditor();
foreach ($pages as &$page) {
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
$page['depth'] = substr_count((string) $page['path'], '/');
}
unset($page);
return $this->render('admin/pages.html.twig', [
'pages' => $pages,
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
]);
}
#[Route('/pages', name: 'admin_create', methods: ['POST'])]
public function create(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$title = trim($request->request->getString('title'));
if ('' === $title || mb_strlen($title) > 160) {
return $this->back('Bitte gib einen Titel mit höchstens 160 Zeichen an.');
}
$type = $request->request->getString('type', 'page');
$parentId = $request->request->getString('parent_id') ?: null;
$slug = trim($request->request->getString('slug')) ?: $this->slugify($title);
try {
$id = $this->pages->createPage($slug, $parentId, $type, DemoContent::starter($title, $type));
} catch (InvalidPage|InvalidDocument $e) {
return $this->back($e->getMessage());
}
return $this->render('admin/pages.html.twig', ['pages' => $pages]);
$this->addFlash('success', sprintf('„%s“ wurde als Entwurf angelegt. Die Seite ist erst nach dem Veröffentlichen öffentlich.', $title));
return $this->redirectToRoute('admin_editor', ['id' => $id]);
}
#[Route('/pages/{id}/delete', name: 'admin_delete', methods: ['POST'])]
public function delete(Request $request, string $id): Response
{
// Deleting removes every revision of a page, so it stays with the administrator role.
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->pages->deletePage($id, $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage $e) {
return $this->back($e->getMessage());
} catch (StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Seite wurde mit allen Ständen gelöscht.', 'success');
}
/** Derives an address from the title so the editor does not have to invent one. */
private function slugify(string $title): string
{
$slug = strtr(mb_strtolower($title, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
return substr(trim($slug, '-'), 0, 80);
}
private function back(string $message, string $type = 'error'): Response
{
$this->addFlash($type, $message);
return $this->redirectToRoute('admin_pages');
}
#[Route('/pages/{id}', name: 'admin_editor', methods: ['GET'])]
+9 -5
View File
@@ -14,21 +14,25 @@ final class PageController extends AbstractController
{
}
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
private const PATH = '(?!admin(?:/|$)|media/|assets/)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
#[Route('/', name: 'home', methods: ['GET'])]
#[Route('/{slug}', name: 'page', requirements: ['slug' => '[a-z0-9]+(?:-[a-z0-9]+)*'], methods: ['GET'])]
public function page(Request $request, string $slug = 'home'): Response
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
public function page(Request $request, string $path = 'home'): Response
{
// One canonical address for the home page.
if ('home' === $slug && '/home' === $request->getPathInfo()) {
if ('home' === $path && '/home' === $request->getPathInfo()) {
return $this->redirectToRoute('home', status: 301);
}
$page = $this->pages->findPublished($slug);
$page = $this->pages->findPublished($path);
if (null === $page) {
$response = $this->render('not-found.html.twig', [], new Response(status: 404));
} else {
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => rtrim($this->siteOrigin, '/').('home' === $slug ? '/' : '/'.$slug),
'canonical' => rtrim($this->siteOrigin, '/').('home' === $path ? '/' : '/'.$path),
]);
}
$response->headers->set('Cache-Control', 'no-store');
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Domain;
/** A page address or hierarchy the CMS refuses to create, with a message meant for the editor. */
final class InvalidPage extends \RuntimeException
{
}