Files
KantSites/src/Controller/EditorController.php
T
kantarci 3391fea58b
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
Add visual button presets, jump targets and menu controls
2026-09-14 13:47:49 +02:00

482 lines
23 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\RequestStack;
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, private RequestStack $requests)
{
}
#[Route('', name: 'admin_pages', methods: ['GET'])]
#[Route('/posts', name: 'admin_posts', methods: ['GET'])]
public function index(Request $request): Response
{
$type = 'admin_posts' === $request->attributes->get('_route') ? 'post' : 'page';
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
$status = $request->query->getString('status', 'all');
$status = in_array($status, ['all', 'draft', 'published', 'changed', 'trash'], true) ? $status : 'all';
$result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status);
$pages = $result['items'];
$parentChoices = [];
foreach ($pages as &$page) {
$page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$page['title'] = $page['document']['title'] ?? $page['slug'];
$page['depth'] = substr_count((string) $page['path'], '/');
$page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) {
$parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null;
}
}
unset($page);
$view = [
'pages' => 'trash' === $status ? [] : $pages,
'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [],
'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(),
'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status,
'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages',
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
];
if ($request->isXmlHttpRequest()) {
return $this->json([
'html' => $this->renderView('admin/content-results.html.twig', $view),
'total' => $result['total'],
'parameters' => ['q' => $query, 'status' => $status, 'limit' => $result['limit'], 'seite' => $result['page']],
]);
}
return $this->render('admin/pages.html.twig', $view);
}
#[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])]
public function contentChoices(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
return $this->json(['items' => $this->pages->choices(
mb_substr($request->query->getString('q'), 0, 160),
$request->query->getString('type'), $request->query->getBoolean('parents'),
)]);
}
#[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'],
'buttons' => $theme['buttons'],
'buttonOptions' => \App\Content\ButtonDesign::OPTIONS,
'buttonPresets' => \App\Content\ButtonDesign::PRESETS,
'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);
$request = $this->requests->getCurrentRequest();
$post = 'post' === $request?->request->getString('_list_type', $request?->request->getString('type', 'page'));
if (!$request?->request->has('_list_type')) {
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages');
}
$params = [
'q' => $request?->request->getString('_list_query', ''),
'status' => $request?->request->getString('_list_status', 'all'),
'seite' => $request?->request->getInt('_list_page', 1),
'limit' => $request?->request->getInt('_list_limit', 20),
];
foreach (['q' => '', 'status' => 'all', 'seite' => 1, 'limit' => 20] as $key => $default) {
if ($params[$key] === $default) {
unset($params[$key]);
}
}
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages', $params);
}
#[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()];
}
}