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:
1 parent
82b07820bc
commit
a6ffb9bdfd
16 files changed
+555
-23
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user