Der bisherige Editor bot drei Farben, keine Schriftwahl und elf Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit begründet - der Schluss war falsch: die Alternative zu beliebigem CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes Designmodell. StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition, also kann der Editor nichts anbieten, was der Server ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je Gerät und für den Hover-Zustand, mit Vererbung und einzelnem Zurücksetzen. Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text; zusammengesetzte Deklarationen baut der Compiler selbst. Der Server erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline. Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften aus Systemschrift oder Google Fonts, Grundtypografie mit modularer Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung; die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt ist. Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter "Klassische Werte dieser Seite" bearbeitbar. Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand ausgeführt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
11 KiB
PHP
245 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\MenuRepository;
|
|
use App\Content\PageRepository;
|
|
use App\Content\PostRepository;
|
|
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('/admin')]
|
|
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 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,
|
|
'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());
|
|
}
|
|
|
|
$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'],
|
|
]);
|
|
}
|
|
|
|
#[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'),
|
|
'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');
|
|
}
|
|
|
|
#[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'])]
|
|
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);
|
|
|
|
$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, 'needsTabs' => false, 'pageStyles' => $this->theme->css($this->theme->load()).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($this->theme->load())]);
|
|
}
|
|
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();
|
|
}
|
|
$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()];
|
|
}
|
|
}
|