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,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'])]
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user