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

+7
View File
@@ -2,3 +2,10 @@ framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file
cache:
pools:
# Login throttling must stay enabled so the tests exercise it, but its counters may not
# survive into the next test run; a persisted pool locked the whole suite out after a
# few repeated runs.
cache.rate_limiter:
adapter: cache.adapter.array
+12
View File
@@ -104,3 +104,15 @@ Schema v3 ergänzt den Container `gallery`, dessen Kinder ausschließlich Bildbl
`layout.effects` erlaubt nur vordefinierte Schatten, Hover-Anheben/Bildzoom sowie Einblenden beim Laden. Effekte gelten geräteübergreifend. Die CSS-Datei wird nur für v3-Seiten eingebunden; es wird kein öffentliches JavaScript ergänzt. Bewegungsregeln gelten ausschließlich bei `prefers-reduced-motion: no-preference`. Inhalte bleiben ohne Animation lesbar; Einblendung ist keine scrollgesteuerte Animation.
Der idempotente Konverter unterstützt nun v1/v2 → v3 und verhindert Downgrades. Erst Einfügen einer Galerie/eines Katalogbildes, eine Bildbeschriftung oder Effektänderung hebt den lokalen Entwurf auf v3. Die Umstellung ist Teil derselben Undo-Transaktion; Speichern erzeugt eine neue Revision. v1/v2 bleiben les- und schreibbar. Kein Massendatenupdate. Rückweg über alte Revisionen oder gemeinsames Backup plus passenden Code; ältere v1/v2-Software darf keine v3-Revisionen laden. Die neue Medientabelle darf beim Rückweg nicht unbedacht gelöscht werden.
## ADR 010 – Seitenerstellung, Hierarchie und Inhaltstypen (12.09.2026)
Seiten konnten bisher nur über die Kommandozeile entstehen, und `page.slug` war websiteweit eindeutig. Damit war weder eine URL-Hierarchie wie `/leistungen/webdesign` noch ein Beitragstyp möglich. Beides gehört in das Datenmodell und wird deshalb jetzt entschieden, solange noch keine Kundeninhalte gespeichert sind; eine spätere Änderung würde eine Migration über echte Kundendaten erzwingen.
Migration `20260912000400` ergänzt `page.path`, `page.parent_id`, `page.type` und `page.created_at`. `slug` bleibt das einzelne Adresssegment, `path` ist die vollständige öffentliche Adresse und trägt die websiteweite Eindeutigkeit über den Index `uniq_page_path (website_id, locale, path)`. Der bisherige eindeutige Index über `slug` entfällt: ein Segment muss nur unter seinen Geschwistern eindeutig sein. Die Tiefe ist auf vier Ebenen begrenzt (`PageRepository::MAX_DEPTH`), Beiträge (`type = post`) nehmen keine Unterseiten auf.
`parent_id` ist bewusst **ohne** Fremdschlüssel angelegt, und die Indexumstellung passiert nicht über die Doctrine-Schemadifferenz, sondern in `postUp()` beziehungsweise `Version20260912000400::applyIndexes()`. Grund: SQLite baut bei Index- oder Fremdschlüsseländerungen die gesamte Tabelle neu auf (`CREATE TEMPORARY TABLE … DROP TABLE page …`), was am Fremdschlüssel `revision.page_id` scheitert. Mit reinen `ADD COLUMN`-Anweisungen bleibt die Migration auf SQLite und MySQL gleichermaßen ein einfacher, nicht tabellenkopierender Schritt. Die Elternbeziehung wird dafür im `PageRepository` innerhalb derselben Transaktion aufgelöst und geprüft. `path` ist auf Datenbankebene `NULL`-fähig, weil der eindeutige Index vor dem Backfill entsteht und wiederholte `NULL`-Werte in SQLite wie in MySQL nicht kollidieren; die Anwendung schreibt niemals eine Seite ohne Pfad, und `postUp()` bricht ab, falls nach dem Backfill noch ein Pfad fehlt.
Anlegen und Löschen laufen über Formulare, nicht über die JSON-API: `POST /admin/pages` benötigt `ROLE_EDITOR`, `POST /admin/pages/{id}/delete` benötigt `ROLE_ADMIN`, beide mit CSRF-Token. Eine neue Seite entsteht als privater Entwurf mit einem gültigen Startdokument (Schema v3, genau eine H1) und wird erst durch bewusstes Veröffentlichen öffentlich. Löschen entfernt alle Revisionen einer Seite, weist aber Seiten mit Unterseiten ab und prüft die erwartete Version, damit keine Inhalte unbemerkt verschwinden. Die reservierten Adressen `admin`, `media`, `assets` und `favicon` sind für Wurzelseiten gesperrt; zusätzlich hält ein Lookahead in der öffentlichen Route die CMS-Pfade aus dem Auffangmuster heraus.
Rückweg: Code-Rollback allein genügt nicht, sobald Unterseiten existieren, weil ältere Software `path` und `parent_id` nicht kennt. Rückweg über das vor der Migration erzeugte Backup (`var/backups/before-hierarchy-*.db`) zusammen mit dem passenden Codestand.
+9
View File
@@ -1,5 +1,14 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: Seitenerstellung und Adresshierarchie, 12.09.2026
- **56 PHP-Tests / 212 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **10 JavaScript-Modelltests**. Neu gegenüber dem vorigen Stand sind 15 Tests zu Seitenerstellung, Adresshierarchie, Löschen und Zugriffsrechten; die Liste steht in STATUS.
- Migration `20260912000400` **zuerst gegen eine Kopie** der lokalen Datenbank ausgeführt und die erzeugten Anweisungen geprüft: vier reine `ALTER TABLE … ADD COLUMN`, kein Tabellenneuaufbau und damit kein Konflikt mit dem Fremdschlüssel `revision.page_id`. Backfill, Wegfall des alten eindeutigen Slug-Index und Anlage von `uniq_page_path` und `idx_page_parent` kontrolliert. Anschließend nach konsistentem Backup (`var/backups/before-hierarchy-20260912-102402.db`) auf die lokale Datenbank angewendet; die drei vorhandenen Seiten und ihre Dokumente sind unverändert.
- `composer lint` (PHP-CS-Fixer), `lint:twig`, `lint:yaml`, `lint:container`, wiederholtes `cms:seed-demo` und der Produktionscacheaufbau sind erfolgreich.
- **Für diesen Schritt wurden auf Wunsch des Nutzers keine Browser-, axe- und Lighthouse-Läufe ausgeführt.** Die neuen Formulare für Anlegen und Löschen sind über Funktionstests geprüft (Formulare werden dort tatsächlich abgeschickt), aber nicht visuell, nicht auf Barrierefreiheit und nicht auf Leistung. Die zuletzt gemessenen Lighthouse- und axe-Ergebnisse gelten für den vorigen Stand und schließen die Verwaltungsoberfläche dieses Schritts nicht ein.
- Beim Testen wurde ein bestehender Fehler in der Testumgebung behoben: die Zähler des Login-Throttlings lagen im dauerhaften Cache und sperrten nach mehreren Testläufen die Anmeldung aus. In der Testumgebung nutzt der Rate-Limiter jetzt einen Array-Cache; die Drosselung selbst bleibt aktiv und wird weiterhin getestet. Zwei aufeinanderfolgende Läufe ohne Cachelöschung waren erfolgreich.
- Weiterhin offen: MySQL/MariaDB, tatsächliches Hosting, Felddaten, vollständige manuelle Barrierefreiheitsprüfung und repräsentative Kundeninhalte.
## Ergänzung: Medienverwaltung, Galerie und Effekte, 12.09.2026
- **41 PHP-Tests / 155 Assertions** unter PHP 8.2.12 und 8.4.22 sowie **10 JavaScript-Modelltests** bestanden. Neu: geschützte Medien-API/CSRF, als JPEG getarnte SVG-Abweisung, tatsächliche Bilddecodierung/Neuencodierung, entfernte angehängte Nutzdaten, drei WebP-Varianten, Metadata-CAS/409, Archivieren/Wiederherstellen und weiter erreichbare Bilddateien. Zusätzlich ungültige Effekt-/Schemaschlüssel, Galerie-Kindtypen und `srcset`-Quellen sowie v3-Undo und Galerie-Verschieberegeln geprüft.
+13 -4
View File
@@ -1,6 +1,8 @@
# Projektstatus
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Editor mit Gestaltung, responsiven Werten, Abschnittsvorlagen, Medienverwaltung, Galerien und Effekten. Die vollständige Phase-1-Abnahme bleibt wegen fehlender Seitenerstellung und Referenzabgleich offen.**
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Seiten und Beiträge lassen sich jetzt im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen. Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.**
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
## Erledigt
@@ -21,11 +23,16 @@ Stand: 12.09.2026. Arbeitstitel Kant CMS. **Editor mit Gestaltung, responsiven W
- Neue Medientabelle durch Migration `20260912000300` lokal angewendet. Vorher konsistentes SQLite-Backup unter `var/backups/before-media-20260912-051258.db`. Bestehende Seiten/Benutzer nicht geändert. Lokal läuft PHP 8.4 mit 8M Upload-, 10M POST- und 256M Speicherlimit; originale Uploads werden nicht gespeichert.
- Speichern/Laden von Entwürfen, separates Veröffentlichen mit Bestätigung, sichtbare Speicher-/Fehler-/Konfliktzustände, JSON-Sicherung und bewusster Server-Neuladedialog. Keine Tests schreiben in den echten Seitenbestand.
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
- Login-Throttling nutzt in der Testumgebung einen Array-Cache. Zuvor überdauerten die Zähler den Testlauf und sperrten die Anmeldung nach wenigen Wiederholungen aus, was als Testfehler erschien.
- Lokale PHP-/Browser-/Lighthouse-Prüfungen und minimale GitHub-Actions-Konfiguration einschließlich vorbereitetem MySQL-Smokejob.
## Geprüft
- 41 PHP-Tests / 155 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 10 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
- 56 PHP-Tests / 212 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 10 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
- Migration `20260912000400` zuerst gegen eine Kopie der lokalen Datenbank geprüft und die erzeugten Anweisungen kontrolliert: vier reine `ALTER TABLE … ADD COLUMN`, kein Tabellenneuaufbau. Danach nach konsistentem Backup unter `var/backups/before-hierarchy-20260912-102402.db` lokal angewendet; Backfill, Indexumstellung und unveränderte Dokumente überprüft. Wiederholtes Seeding und Produktionsbuild erfolgreich.
- Composer-/Format-/Twig-/YAML-/Containerprüfungen, Migration, wiederholtes Seeding und Produktionsbuild erfolgreich.
- Frische Quellkopie ohne bestehende DB/Secrets/Dev-Abhängigkeiten erfolgreich eingerichtet; gespeicherte Seite als HTTP 200 gerendert.
- Browserprüfung aller drei öffentlichen Seiten mobil und Desktop; keine axe-Verstöße, keine Überläufe, funktionierende Sprunglink-/Navigationsabläufe auch ohne JavaScript.
@@ -41,7 +48,9 @@ Stand: 12.09.2026. Arbeitstitel Kant CMS. **Editor mit Gestaltung, responsiven W
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Neue Seiten, Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar. Bestehende Seiten können bearbeitet und veröffentlicht werden.
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
- Seiten lassen sich anlegen und löschen, aber noch nicht umbenennen oder verschieben: eine Adressänderung müsste die Pfade aller Unterseiten mitziehen und Weiterleitungen anlegen. Beiträge werden gespeichert und ausgegeben, haben aber noch keine Übersichtsseite, keine Sortierung nach Datum und keine strukturierten Daten. Die Hauptnavigation ist weiterhin fest im Template; verschachtelte Seiten erscheinen dort nicht automatisch.
- Für diesen Schritt wurden bewusst keine Browser-, Lighthouse- oder axe-Läufe ausgeführt (Nutzerentscheidung). Oberfläche und Ausgabe der neuen Formulare sind nur über die Funktionstests geprüft, nicht visuell.
- SQLite tatsächlich getestet. MySQL-/MariaDB-Kompatibilität und CI erst vorbereitet, nicht nachgewiesen; Anbieter-/Tarifdetails und Livebetrieb offen.
- Plugin-Fallback im Renderer noch nicht durch den strikten Lesevalidator erreichbar; unbekannte Blocktypen werden derzeit abgewiesen, nicht gelöscht. Ausbau in Phase 4.
- Kein Inhaltscache, nur Symfony-Programmcache. Seiten senden `no-store`; Veröffentlichung ist dadurch ohne Cacheinvalidierungsverzögerung sichtbar.
@@ -50,7 +59,7 @@ Stand: 12.09.2026. Arbeitstitel Kant CMS. **Editor mit Gestaltung, responsiven W
## Nächster konkreter Schritt
Neue Seiten über den Browser anlegen und mit einer Startvorlage aufbauen; danach Mediennutzung und Zuschnitte weiter ausbauen. Die wichtigsten Editorabläufe anhand einer konkreten UX-Builder-Referenz abgleichen. Für die Anmeldung eine reguläre Passwortänderungsmöglichkeit ergänzen, ohne Geheimnisse in Projektdateien zu speichern.
Unbekannte Blocktypen im Validator und Renderer erhalten statt abweisen, damit ein deaktiviertes Plugin in Phase 4 keine Seite unlesbar macht. Danach Seiten umbenennen/verschieben samt Pfadaktualisierung und Weiterleitungen, eine aus der Hierarchie erzeugte Navigation sowie Beitragsübersicht und Passwortänderung. Die wichtigsten Editorabläufe anhand einer konkreten UX-Builder-Referenz abgleichen.
Parallel Pilotwebsite, reale UX-Builder-Referenz und Hostingausstattung konkretisieren. Noch keine Pilotwebsite ausgewählt; `kant-it.de` ist allein durch seine Erwähnung im Plan nicht beauftragt. Keine externe Veröffentlichung erfolgt.
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Index;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260912000400 extends AbstractMigration
{
public function getDescription(): string
{
return 'Page hierarchy, public paths and content types without changing any stored document.';
}
public function isTransactional(): bool
{
return false;
}
/**
* Columns only. Changing an index here would make Doctrine rebuild the whole page table on
* SQLite, which the foreign key from revision.page_id rejects. The indexes are swapped in
* applyIndexes() instead, after the backfill has given every row a unique path.
*/
public function up(Schema $schema): void
{
$page = $schema->getTable('page');
// Nullable on purpose: the unique path index is created while existing rows are still
// empty, and repeated NULLs do not collide in either SQLite or MySQL. The repository
// never writes a page without a path.
$page->addColumn('path', 'string', ['length' => 400, 'notnull' => false]);
// No foreign key: adding one would force the same table rebuild described above.
// PageRepository resolves and checks the parent inside the same transaction as every write.
$page->addColumn('parent_id', 'string', ['length' => 64, 'notnull' => false]);
$page->addColumn('type', 'string', ['length' => 16, 'notnull' => true, 'default' => 'page']);
$page->addColumn('created_at', 'datetime_immutable', ['notnull' => false]);
}
public function postUp(Schema $schema): void
{
// Every existing page is a root page, so its public path already equals its slug.
$this->connection->executeStatement('UPDATE page SET path = slug WHERE path IS NULL');
$this->connection->executeStatement('UPDATE page SET created_at = ? WHERE created_at IS NULL', [gmdate('Y-m-d H:i:s')]);
$open = (int) $this->connection->fetchOne('SELECT COUNT(*) FROM page WHERE path IS NULL');
$this->abortIf(0 !== $open, 'Backfill left pages without a public path. Restore the backup taken before this migration.');
self::applyIndexes($this->connection);
}
/**
* A slug only has to be unique among its siblings, so the site-wide unique index moves from
* the slug to the full path. Shared with the test bootstraps so they exercise the same
* constraints as a migrated database.
*/
public static function applyIndexes(Connection $connection): void
{
$platform = $connection->getDatabasePlatform();
$manager = $connection->createSchemaManager();
$existing = $manager->listTableIndexes('page');
foreach ($existing as $name => $index) {
if ($index->isUnique() && ['website_id', 'locale', 'slug'] === $index->getUnquotedColumns()) {
$connection->executeStatement($platform->getDropIndexSQL($name, 'page'));
}
}
foreach ([
new Index('uniq_page_path', ['website_id', 'locale', 'path'], true),
new Index('idx_page_parent', ['parent_id']),
] as $index) {
if (!isset($existing[$index->getName()])) {
$connection->executeStatement($platform->getCreateIndexSQL($index, 'page'));
}
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('Removing the hierarchy would drop parent references of customer pages. Restore a verified database backup with matching code.');
}
}
+3
View File
@@ -10,3 +10,6 @@
@media(prefers-reduced-motion:no-preference){button,a{transition:background-color .12s,border-color .12s}}
.left-panel{display:flex;flex-direction:column;overflow:hidden}.left-panel>.panel-section:first-child{max-height:45%;overflow:auto;flex-shrink:0}.structure-section{display:flex;flex-direction:column;flex:1;min-height:0}.structure-section #structure{overflow:auto;flex:1;min-height:90px;padding:3px}.structure-section .block-actions{flex-shrink:0;background:white}.structure-section .panel-hint{margin:8px 0}.search-label{font-size:11px;margin:12px 0 6px}#tree-search{font-size:11px;padding:9px;min-height:36px}.tree-tools{display:flex;flex-wrap:wrap;gap:3px;margin-top:5px}.tree-tools .text-button{font-size:10px;padding:5px}.structure-list li{position:relative}.tree-item{padding-left:23px}.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px}.preset-heading{font-size:12px;margin:22px 0 8px}.preset-library{display:grid;gap:6px}.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:9px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}.preset-library button:hover:not(:disabled){border-color:var(--accent)}.preset-library span{font-size:18px}.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}.design-reset{margin-top:24px;white-space:normal;width:100%}.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}.breadcrumbs .text-button{font-size:10px;white-space:normal}.breadcrumbs button+button:before{content:'›';color:var(--muted)}.breadcrumbs [aria-current=location]{font-weight:700;background:#e5edda}.canvas-scroll{display:block}.canvas-scroll #preview-stage{position:relative;margin:0 auto}#preview{position:absolute;top:0;left:0;transform-origin:top left;max-width:none;min-height:0}.right-panel select{padding-inline:7px;text-overflow:ellipsis}.right-panel .panel-section{min-width:0}
@media(max-width:850px){.left-panel,.right-panel{height:650px}.left-panel>.panel-section:first-child{max-height:40%}.structure-section{padding:12px 7px}.tree-item{font-size:10px;gap:3px}.tree-icon{min-width:10px}.structure-list .structure-list{padding-left:5px;margin-left:4px}.block-actions button{padding:6px;white-space:normal}.property-tabs button{padding:9px 4px;font-size:11px}.canvas-scroll{padding:12px}.right-panel label{line-height:1.5}.tree-tools .text-button{white-space:normal}}
.notice.ok{background:#edf3dc;color:#315b49;border:1px solid #c5d89a}.notice{margin-top:24px}.create-panel{margin-top:34px;background:white;border:1px solid var(--line);border-radius:10px}.create-panel>summary{padding:20px 24px;font-size:13px;font-weight:600;cursor:pointer;border-radius:10px}.create-panel[open]>summary{border-bottom:1px solid var(--line)}.create-form{padding:6px 24px 26px}.create-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:0 24px}.create-form .primary{margin-top:24px}.create-form code{background:#f0f4e9;padding:1px 5px;border-radius:4px}.delete-panel>summary{font-size:12px;font-weight:600;color:#943a2e;cursor:pointer;padding:11px 13px;border:1px solid var(--line);border-radius:7px;list-style:none}.delete-panel[open]{position:relative}.delete-panel[open]>form{position:absolute;right:0;top:calc(100% + 8px);z-index:2;width:290px;background:white;border:1px solid var(--line);border-radius:10px;padding:18px;box-shadow:0 14px 40px #203b3422}.delete-panel p{font-size:12px;line-height:1.6;color:var(--muted)}
@media (max-width:720px){.page-card{flex-wrap:wrap}.pages-main{margin-block:40px}.delete-panel[open]>form{position:static;width:100%;box-shadow:none;margin-top:10px}}
+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
{
}
+62 -5
View File
@@ -9,17 +9,74 @@
<p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1>
<p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p>
<a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a>
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
{% endfor %}{% endfor %}
{% if is_granted('ROLE_EDITOR') %}
<details class="create-panel"{% if app.flashes is not empty %} open{% endif %}>
<summary>Neue Seite oder neuen Beitrag anlegen</summary>
<form action="{{ path('admin_create') }}" method="post" class="create-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<div class="create-grid">
<div>
<label for="new-title">Titel</label>
<input id="new-title" name="title" maxlength="160" required autocomplete="off">
<p class="field-help">Wird als Überschrift und als Seitentitel übernommen.</p>
</div>
<div>
<label for="new-slug">Adresse</label>
<input id="new-slug" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" autocomplete="off" aria-describedby="new-slug-help">
<p class="field-help" id="new-slug-help">Kleinbuchstaben, Ziffern und Bindestriche, zum Beispiel <code>ueber-uns</code>. Leer lassen, um sie aus dem Titel zu bilden.</p>
</div>
<div>
<label for="new-type">Art</label>
<select id="new-type" name="type">
<option value="page">Seite</option>
<option value="post">Beitrag</option>
</select>
<p class="field-help">Beiträge sind für Blogartikel gedacht und haben keine Unterseiten.</p>
</div>
<div>
<label for="new-parent">Übergeordnete Seite</label>
<select id="new-parent" name="parent_id">
<option value="">— keine, direkt unter der Startseite —</option>
{% for parent in parents %}<option value="{{ parent.id }}">{{ parent.title }} (/{{ parent.path }})</option>{% endfor %}
</select>
<p class="field-help">Bestimmt die Adresse, zum Beispiel <code>/leistungen/webdesign</code>.</p>
</div>
</div>
<button class="primary" type="submit">Anlegen und bearbeiten</button>
</form>
</details>
{% endif %}
<div class="section-heading"><h2>Seiten <span class="count">{{ pages|length }}</span></h2><span class="muted">Entwürfe bleiben bis zur Veröffentlichung privat.</span></div>
<div class="page-list">
{% for page in pages %}
<article class="page-card">
<div class="page-icon" aria-hidden="true">▤</div>
<div class="page-info"><h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3><p>/{{ page.slug == 'home' ? '' : page.slug }} <span>· {{ page.locale|upper }}</span></p></div>
<span class="badge {{ page.published_revision_id == page.draft_revision_id ? 'published' : '' }}">{{ page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert' }}</span>
<article class="page-card" style="margin-left:{{ page.depth * 28 }}px">
<div class="page-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</div>
<div class="page-info">
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
<p>/{{ page.path == 'home' ? '' : page.path }} <span>· {{ page.locale|upper }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }}</span></p>
</div>
<span class="badge {{ page.published_revision_id == page.draft_revision_id ? 'published' : '' }}">{{ page.published_revision_id is null ? 'Nicht veröffentlicht' : (page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert') }}</span>
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
{% if is_granted('ROLE_ADMIN') %}
<details class="delete-panel">
<summary aria-label="{{ page.title }} löschen">Löschen</summary>
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<p>Löscht „{{ page.title }}“ mit allen gespeicherten Ständen. Das lässt sich nicht rückgängig machen.</p>
<button class="secondary danger-text" type="submit">Endgültig löschen</button>
</form>
</details>
{% endif %}
</article>
{% else %}
<div class="empty-state"><h3>Noch keine Seiten vorhanden.</h3><p>Die Beispielseiten können über das lokale Setup angelegt werden.</p></div>
<div class="empty-state"><h3>Noch keine Seiten vorhanden.</h3><p>Lege oben deine erste Seite an.</p></div>
{% endfor %}
</div>
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
+119 -1
View File
@@ -6,12 +6,14 @@ 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 App\Infrastructure\ConnectionFactory;
use App\Kernel;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Schema;
use DoctrineMigrations\Version20260912000100;
use DoctrineMigrations\Version20260912000400;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -30,12 +32,14 @@ final class CmsTest extends TestCase
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
$schema = new Schema();
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
Version20260912000400::applyIndexes($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Kant CMS', 'default_locale' => 'de', 'theme' => '{}']);
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
$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());
}
@@ -219,6 +223,120 @@ final class CmsTest extends TestCase
self::assertSame('/', $response->headers->get('Location'));
}
public function testCreatedPageStaysPrivateUntilPublishedAndKeepsItsAddress(): void
{
$id = $this->pages->createPage('ueber-uns', null, 'page', DemoContent::starter('Über uns'));
self::assertSame(404, $this->request('/ueber-uns')->getStatusCode());
$page = $this->pages->findForEditor($id);
self::assertSame('Über uns', $page['document']['title']);
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
$response = $this->request('/ueber-uns');
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('>Über uns</h1>', $response->getContent());
self::assertStringContainsString('href="https://cms.test/ueber-uns"', $response->getContent());
}
public function testChildPagesBuildNestedPathsAndSiblingSlugsStayIndependent(): void
{
$parent = $this->pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen'));
$child = $this->pages->createPage('webdesign', $parent, 'page', DemoContent::starter('Webdesign'));
$other = $this->pages->createPage('beratung', null, 'page', DemoContent::starter('Beratung'));
// The same slug under a different parent is a different page, not a collision.
$twin = $this->pages->createPage('webdesign', $other, 'page', DemoContent::starter('Webdesign kompakt'));
foreach ([$parent => 'leistungen', $child => 'leistungen/webdesign', $twin => 'beratung/webdesign'] as $id => $path) {
$draft = $this->pages->findForEditor($id);
$this->pages->publish($id, $draft['draft_revision_id'], $draft['version']);
self::assertSame(200, $this->request('/'.$path)->getStatusCode(), $path);
}
self::assertSame('Webdesign', $this->pages->findPublished('leistungen/webdesign')['document']['title']);
self::assertSame('Webdesign kompakt', $this->pages->findPublished('beratung/webdesign')['document']['title']);
}
/**
* @return iterable<string, array{string, string|null, string}>
*/
public static function rejectedPages(): iterable
{
yield 'uppercase slug' => ['Über Uns', null, 'page'];
yield 'path separator in slug' => ['a/b', null, 'page'];
yield 'empty slug' => ['', null, 'page'];
yield 'reserved slug' => ['admin', null, 'page'];
yield 'unknown type' => ['gueltig', null, 'template'];
yield 'missing parent' => ['gueltig', 'does-not-exist', 'page'];
}
#[DataProvider('rejectedPages')]
public function testRejectsInvalidAddressesWithoutWriting(string $slug, ?string $parent, string $type): void
{
$before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM page');
try {
$this->pages->createPage($slug, $parent, $type, DemoContent::starter('Titel'));
self::fail('Expected the page to be rejected.');
} catch (InvalidPage) {
}
self::assertSame($before, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testDuplicateAddressAndExcessiveDepthAreRejected(): void
{
$this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt'));
$this->expectException(InvalidPage::class);
$this->pages->createPage('kontakt', null, 'page', DemoContent::starter('Kontakt erneut'));
}
public function testHierarchyStopsAtTheDocumentedDepthAndPostsTakeNoChildren(): void
{
$id = null;
for ($level = 1; $level <= PageRepository::MAX_DEPTH; ++$level) {
$id = $this->pages->createPage('ebene-'.$level, $id, 'page', DemoContent::starter('Ebene '.$level));
}
try {
$this->pages->createPage('zu-tief', $id, 'page', DemoContent::starter('Zu tief'));
self::fail('Expected the depth limit to apply.');
} catch (InvalidPage) {
}
$post = $this->pages->createPage('erster-beitrag', null, 'post', DemoContent::starter('Erster Beitrag', 'post'));
$this->expectException(InvalidPage::class);
$this->pages->createPage('unter-beitrag', $post, 'page', DemoContent::starter('Unter Beitrag'));
}
public function testDeleteRemovesEveryRevisionButRefusesStaleVersionsAndParents(): void
{
$parent = $this->pages->createPage('eltern', null, 'page', DemoContent::starter('Eltern'));
$child = $this->pages->createPage('kind', $parent, 'page', DemoContent::starter('Kind'));
$version = $this->pages->findForEditor($parent)['version'];
try {
$this->pages->deletePage($parent, $version);
self::fail('Expected the parent to be protected.');
} catch (InvalidPage) {
}
self::assertNotNull($this->pages->findForEditor($parent));
$childVersion = $this->pages->findForEditor($child)['version'];
try {
$this->pages->deletePage($child, $childVersion - 1);
self::fail('Expected a stale delete to be refused.');
} catch (StaleWrite) {
}
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child]));
$this->pages->deletePage($child, $childVersion);
self::assertNull($this->pages->findForEditor($child));
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$child]));
// Deleting the child frees the parent and leaves every other page untouched.
$this->pages->deletePage($parent, $this->pages->findForEditor($parent)['version']);
self::assertSame(['home', 'private'], $this->db->fetchFirstColumn('SELECT slug FROM page ORDER BY slug'));
}
public function testCmsRoutesAreNotSwallowedByThePublicPathRoute(): void
{
// The catch-all would otherwise answer these as a missing public page with 404.
foreach (['/admin', '/admin/media'] as $url) {
self::assertSame(302, $this->request($url)->getStatusCode(), $url);
}
self::assertStringContainsString('name="_username"', $this->request('/admin/login')->getContent());
}
private function request(string $url, string $method = 'GET'): Response
{
$previous = [];
+52 -1
View File
@@ -13,6 +13,7 @@ use Doctrine\DBAL\Schema\Schema;
use DoctrineMigrations\Version20260912000100;
use DoctrineMigrations\Version20260912000200;
use DoctrineMigrations\Version20260912000300;
use DoctrineMigrations\Version20260912000400;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -42,11 +43,13 @@ final class EditorSecurityTest extends WebTestCase
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
(new Version20260912000200($this->db, new NullLogger()))->up($schema);
(new Version20260912000300($this->db, new NullLogger()))->up($schema);
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
Version20260912000400::applyIndexes($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
$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());
$revision = $pages->saveDraft('p1', DemoContent::pages()['home'], 0);
$pages->publish('p1', $revision, 1);
@@ -107,6 +110,54 @@ final class EditorSecurityTest extends WebTestCase
$this->post($client, $action, []);
self::assertResponseRedirects('http://localhost/admin/login');
}
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]]] as [$url, $body]) {
$client->request('POST', $url, $body);
self::assertResponseRedirects('http://localhost/admin/login');
}
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testCreatingAPageNeedsACsrfTokenAndStartsAsAPrivateDraft(): void
{
$client = $this->login('editor');
$client->request('POST', '/admin/pages', ['title' => 'Ohne Token', 'slug' => 'ohne-token', '_csrf_token' => 'wrong']);
self::assertResponseRedirects('/admin');
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
$crawler = $client->request('GET', '/admin');
$client->submit($crawler->filter('.create-form')->form(['title' => 'Über uns', 'slug' => '', 'type' => 'page', 'parent_id' => '']));
self::assertResponseRedirects();
// The address is derived from the title when the field is left empty.
self::assertSame('ueber-uns', $this->db->fetchOne('SELECT path FROM page WHERE slug <> ?', ['home']));
// A new page is a draft, so it must not answer publicly yet.
$client->request('GET', '/ueber-uns');
self::assertResponseStatusCodeSame(404);
}
public function testEditorsAreNeitherOfferedNorAllowedToDelete(): void
{
$client = $this->login('editor');
$crawler = $client->request('GET', '/admin');
self::assertCount(0, $crawler->filter('.delete-panel'), 'An editor must not be offered the delete action.');
$client->request('POST', '/admin/pages/p1/delete', ['_csrf_token' => $crawler->filter('input[name="_csrf_token"]')->last()->attr('value'), 'expectedVersion' => 2]);
self::assertResponseStatusCodeSame(403);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testAdministratorsDeleteAPageWithAllRevisionsOnlyWithACsrfToken(): void
{
$client = $this->login('admin');
$client->request('POST', '/admin/pages/p1/delete', ['_csrf_token' => 'wrong', 'expectedVersion' => 2]);
self::assertResponseRedirects('/admin');
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
$crawler = $client->request('GET', '/admin');
$client->submit($crawler->filter('.delete-panel form')->form());
self::assertResponseRedirects('/admin');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
$client->request('GET', '/');
self::assertResponseStatusCodeSame(404);
}
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void
+4 -1
View File
@@ -11,6 +11,7 @@ use Doctrine\DBAL\Schema\Schema;
use DoctrineMigrations\Version20260912000100;
use DoctrineMigrations\Version20260912000200;
use DoctrineMigrations\Version20260912000300;
use DoctrineMigrations\Version20260912000400;
use Psr\Log\NullLogger;
$directory = dirname(__DIR__).'/var/browser-tests';
@@ -24,14 +25,16 @@ $schema = new Schema();
(new Version20260912000100($db, new NullLogger()))->up($schema);
(new Version20260912000200($db, new NullLogger()))->up($schema);
(new Version20260912000300($db, new NullLogger()))->up($schema);
(new Version20260912000400($db, new NullLogger()))->up($schema);
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
$db->executeStatement($sql);
}
Version20260912000400::applyIndexes($db);
$db->insert('website', ['id' => 'default', 'name' => 'Browser test', 'default_locale' => 'de', 'theme' => '{}']);
$pages = new PageRepository($db, new DocumentValidator());
foreach (DemoContent::pages() as $slug => $document) {
$id = 'test-'.$slug;
$db->insert('page', ['id' => $id, 'slug' => $slug, 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
$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')]);
$revision = $pages->saveDraft($id, $document, 0);
$pages->publish($id, $revision, 1);
}