Files
KantSites/src/Controller/EditorController.php
T
kantarciandClaude Opus 5 05ea703655
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen,
geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in
der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und
letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan
haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend.

Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher
unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden
bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht.

Vor der Veroeffentlichung geprueft, nicht bloss eingepackt:

- phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig.
- Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt
  sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an.
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr.

Dabei zwei Fehler behoben:

- config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony,
  aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1
  zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt.
- AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von
  php-cs-fixer; composer lint gab 8 zurueck. Behoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:30:13 +02:00

429 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', '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()];
}
}