Files
KantSites/src/Controller/EditorController.php
T
kantarciandClaude Opus 5 aaea385422 Die Verwaltung kann umziehen: /admin ist frei waehlbar
php bin/console cms:admin:path <neu> legt die Verwaltung auf eine
andere Adresse. Geprueft wird, dass der Name frei ist und keine Seite
verdeckt; der Wert steht in .env.local und fuellt einen Parameter, der
als Praefix in jeder Verwaltungsroute steht, in der Firewall, in den
Schutzkopfzeilen und in der Statistik.

Die robots.txt nennt einen geaenderten Namen nicht - sie ist oeffentlich
und waere sonst ein Wegweiser. Beim Standardnamen bleibt die Zeile,
weil diese Adresse ohnehin jeder kennt.

Die Auffangroute fuer Seiten steht jetzt zuletzt: sie kann einen frei
gewaehlten Namen nicht per Regex ausschliessen, und die Reihenfolge ist
ohnehin die richtige. Der Befehl raeumt den Zwischenspeicher auf, aber
nicht den, in dem er selbst laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:51:55 +02:00

429 lines
20 KiB
PHP

<?php
namespace App\Controller;
use App\Content\CustomCode;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostLayouts;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\StyleCompiler;
use App\Content\StyleSchema;
use App\Content\Theme;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/%kant.admin_path%')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf)
{
}
#[Route('', name: 'admin_pages', methods: ['GET'])]
public function index(): Response
{
$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'], '/');
$page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
}
unset($page);
return $this->render('admin/pages.html.twig', [
'pages' => $pages,
'trash' => $this->pages->trash(),
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
'postLayouts' => PostLayouts::all(),
// Which header and footer areas a page can choose from.
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
]);
}
#[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 {
// Ein Beitrag beginnt mit einem Aufbau, eine Seite leer: bei einer Seite weiß
// niemand vorher, was daraus wird, bei einem Beitrag fast immer.
$layout = $request->request->getString('layout', PostLayouts::DEFAULT);
$document = 'post' === $type && PostLayouts::exists($layout)
? PostLayouts::document($layout, $title)
: DemoContent::starter($title, $type);
$id = $this->pages->createPage($slug, $parentId, $type, $document);
} catch (InvalidPage|InvalidDocument $e) {
return $this->back($e->getMessage());
}
$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]);
}
/** The editor reads the style vocabulary and the palette from here, so both stay in one place. */
#[Route('/api/design', name: 'admin_design', methods: ['GET'])]
public function design(): JsonResponse
{
$theme = $this->theme->load();
return $this->json([
'properties' => StyleSchema::properties(),
'devices' => StyleSchema::DEVICES,
'sides' => StyleSchema::sides(),
'corners' => StyleSchema::corners(),
'hoverable' => StyleSchema::hoverable(),
'colors' => $theme['colors'],
'fonts' => $theme['fonts'],
'presets' => $theme['presets'],
'base' => $theme['base'],
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
]);
}
/** Saves or removes a reusable style, so a look can be applied to other blocks and pages. */
#[Route('/api/style-presets', name: 'admin_style_preset', methods: ['POST'])]
public function stylePreset(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
}
try {
$body = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
$style = $body['style'] ?? null;
$this->theme->savePreset((string) ($body['name'] ?? ''), is_array($style) ? $style : null, $this->styles);
} catch (InvalidPage|InvalidDocument $e) {
return $this->json(['error' => $e->getMessage()], 422);
} catch (\JsonException) {
return $this->json(['error' => 'Die Anfrage ist unvollständig.'], 422);
}
return $this->json(['presets' => $this->theme->load()['presets']]);
}
#[Route('/pages/{id}/meta', name: 'admin_post_meta', methods: ['POST'])]
public function metadata(Request $request, string $id): 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.');
}
$terms = static function (string $raw): array {
$names = array_filter(array_map(trim(...), explode(',', $raw)), static fn (string $n): bool => '' !== $n);
return array_values(array_unique($names));
};
try {
$this->posts->saveMetadata(
$id,
[
'excerpt' => $request->request->getString('excerpt'),
'author' => $request->request->getString('author'),
'publishedAt' => $request->request->getString('publishedAt'),
'featuredMediaId' => $request->request->getString('featuredMediaId'),
'ogMediaId' => $request->request->getString('ogMediaId'),
'seoTitle' => $request->request->getString('seoTitle'),
'robots' => $request->request->getString('robots', 'index'),
],
$terms($request->request->getString('categories')),
$terms($request->request->getString('tags')),
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Beitragsdaten wurden gespeichert.', 'success');
}
/** Which header and footer this page uses, including none at all. */
#[Route('/pages/{id}/bereiche', name: 'admin_page_areas', methods: ['POST'])]
public function areas(Request $request, string $id): 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.');
}
// An empty field means "the standard area"; that is stored as null, not as an address.
$choice = static fn (string $value): ?string => '' === $value ? null : $value;
try {
$this->pages->saveAreas(
$id,
$choice($request->request->getString('headerArea')),
$choice($request->request->getString('footerArea')),
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('Kopf- und Fußbereich dieser Seite wurden gespeichert.', 'success');
}
#[Route('/pages/{id}/move', name: 'admin_move', methods: ['POST'])]
public function move(Request $request, string $id): 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.');
}
try {
$moved = $this->pages->movePage(
$id,
$request->request->getString('slug'),
$request->request->getString('parent_id') ?: null,
$request->request->getInt('expectedVersion', -1),
$this->redirects,
);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back(0 === $moved
? 'Die Adresse ist unverändert.'
: sprintf('%d Adresse(n) geändert. Von den alten Adressen wird dauerhaft weitergeleitet.', $moved), 'success');
}
#[Route('/pages/{id}/schedule', name: 'admin_schedule', methods: ['POST'])]
public function schedule(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
try {
$this->pages->schedule($id, $request->request->getString('publishAt'), $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('' === trim($request->request->getString('publishAt'))
? 'Die geplante Veröffentlichung wurde entfernt.'
: 'Die Veröffentlichung ist geplant. Sie erfolgt, sobald cms:publish-due läuft.', 'success');
}
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
public function restorePage(Request $request, string $id): 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.');
}
try {
$this->pages->restorePage($id);
} catch (InvalidPage $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Seite ist zurück. Sie ist erst nach dem Veröffentlichen wieder öffentlich.', 'success');
}
#[Route('/pages/{id}/revisions/{revision}', name: 'admin_restore_revision', requirements: ['revision' => '[a-f0-9]{32}'], methods: ['POST'])]
public function restoreRevision(Request $request, string $id, string $revision): 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.');
}
try {
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Fassung ist als Entwurf zurück. Der bisherige Stand bleibt als eigene Fassung erhalten.', 'success');
}
#[Route('/pages/{id}/revisions', name: 'admin_revisions', methods: ['GET'])]
public function revisions(Request $request, string $id): Response
{
$page = $this->page($id);
return $this->render('admin/revisions.html.twig', [
'page' => $page,
'title' => $page['document']['title'] ?? $page['path'],
'revisions' => $this->pages->revisions($id, $request->query->getInt('seite', 1)),
]);
}
#[Route('/pages/{id}/trash', name: 'admin_trash', methods: ['POST'])]
public function trash(Request $request, string $id): 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.');
}
try {
$count = $this->pages->trashPage($id, $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back(sprintf('%d Seite(n) in den Papierkorb gelegt. Öffentlich nicht mehr erreichbar, aber wiederherstellbar.', $count), 'success');
}
#[Route('/pages/{id}/delete', name: 'admin_delete', methods: ['POST'])]
public function delete(Request $request, string $id): Response
{
// The final removal deletes every revision, so it stays with the administrator role.
// Everyone with the editor role uses the bin instead.
$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'])]
public function editor(string $id): Response
{
$page = $this->page($id);
return $this->render('admin/editor.html.twig', ['page' => $page]);
}
#[Route('/api/pages/{id}', name: 'admin_document', methods: ['GET'])]
public function document(string $id): JsonResponse
{
return $this->json($this->payload($this->page($id)));
}
#[Route('/api/pages/{id}/draft', name: 'admin_save', methods: ['POST'])]
#[Route('/api/pages/{id}/publish', name: 'admin_publish', methods: ['POST'])]
#[Route('/api/pages/{id}/preview', name: 'admin_preview', methods: ['POST'])]
public function write(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
}
if (strlen($request->getContent()) > 270000) {
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt).'], 413);
}
$page = $this->page($id);
try {
$body = json_decode($request->getContent(), true, 64, JSON_THROW_ON_ERROR);
if (!is_array($body)) {
throw new \InvalidArgumentException();
}
$route = $request->attributes->get('_route');
if ('admin_preview' === $route) {
$document = $body['document'] ?? null;
if (!is_array($document)) {
throw new \InvalidArgumentException();
}
$this->validator->validate($document);
$theme = $this->theme->load();
$areas = [];
foreach (['header', 'footer'] as $kind) {
$area = $this->partials->findByKind($kind);
if (null !== $area) {
$areas[$kind] = $area['document'];
}
}
$sections = [];
foreach ($this->partials->referencedSlugs($document) as $slug) {
$section = $this->partials->resolveSection($slug);
if (null !== $section) {
$sections[$slug] = $section['document'];
}
}
$navigation = [];
foreach ($this->menus->all() as $slot => $menu) {
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
}
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme)]);
}
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
throw new \InvalidArgumentException();
}
if ('admin_publish' === $route) {
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
// The user must publish the exact draft they reviewed, never whichever draft happens to be latest.
if (($body['revisionId'] ?? null) !== $page['draft_revision_id']) {
throw new StaleWrite();
}
$this->pages->publish($id, $body['revisionId'], $body['expectedVersion']);
$page['published_revision_id'] = $body['revisionId'];
} else {
if (!is_array($body['document'] ?? null)) {
throw new \InvalidArgumentException();
}
if (!$this->isGranted('ROLE_ADMIN')) {
$this->code->assertScriptsUnchanged($body['document'], $page['document']);
}
$page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']);
$page['document'] = $body['document'];
}
$page['version'] = $body['expectedVersion'] + 1;
return $this->json($this->payload($page));
} catch (StaleWrite) {
return $this->json(['error' => 'Inzwischen wurde ein neuerer Stand gespeichert. Deine Änderungen bleiben im Editor. Lade sie als JSON herunter, bevor du den aktuellen Serverstand lädst.', 'conflict' => true], 409);
} catch (InvalidDocument $e) {
return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422);
} catch (\JsonException|\InvalidArgumentException) {
return $this->json(['error' => 'Die Anfrage enthält kein gültiges Seitendokument oder keine gültige Version.'], 422);
}
}
private function page(string $id): array
{
return $this->pages->findForEditor($id) ?? throw $this->createNotFoundException('Seite nicht gefunden.');
}
private function payload(array $page): array
{
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue()];
}
}