Umut asked for a block that takes custom HTML, and for an iframe on a new DEEG offers page. Only one of those was missing. New core block "Eigenes HTML" (document schema v24): one property, the markup, up to 20.000 characters, written to the page unchanged. Changing it is reserved for administrators and compared against what is stored - on saving, on saving an area, and on restoring an older revision - exactly like an own script. <script> and <iframe> are refused with the way to the right place instead of being swallowed by the content security policy: scripts belong in the script field, frames in the "Externer Inhalt" block, because only that one asks for consent and puts the host into frame-src (ADR 106). DEEG: /angebote existed as a draft with placeholder text. It is now built like the legal pages - small caps line, large light heading, lead on the tinted band - with the IS24 result list below it and a line pointing to the portal and to the contact form. Navigation and the offers band on the home page point there. The consent question is switched on, so the frame loads only after the visitor agrees, and both the banner and the placeholder carry the site's own shapes and colours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
548 lines
27 KiB
PHP
548 lines
27 KiB
PHP
<?php
|
||
|
||
namespace App\Controller;
|
||
|
||
use App\Content\CustomCode;
|
||
use App\Content\DemoContent;
|
||
use App\Content\DocumentValidator;
|
||
use App\Content\FontLibrary;
|
||
use App\Content\FormRepository;
|
||
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 App\Plugin\PluginRegistry;
|
||
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 FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview)
|
||
{
|
||
}
|
||
|
||
#[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(),
|
||
'scrollable' => StyleSchema::scrollable(),
|
||
'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 {
|
||
// Dieselbe Regel wie beim Speichern: Skripte aendert nur ein Administrator - auch nicht
|
||
// ueber den Umweg einer alten Fassung.
|
||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||
$this->code->assertScriptsUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||
$this->code->assertHtmlUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||
}
|
||
$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, writing: true);
|
||
|
||
$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'];
|
||
}
|
||
}
|
||
// Wie auf der Website: die zweite Verknüpfung derselben Sektion bekommt eigene Kennungen.
|
||
$seen = [];
|
||
$pristine = $sections;
|
||
foreach (['header', 'footer'] as $kind) {
|
||
if (isset($areas[$kind])) {
|
||
$areas[$kind] = $this->partials->expandRepeatedLinks($areas[$kind], $sections, $seen, $pristine);
|
||
}
|
||
if ('header' === $kind) {
|
||
$document = $this->partials->expandRepeatedLinks($document, $sections, $seen, $pristine);
|
||
}
|
||
}
|
||
$navigation = [];
|
||
foreach ($this->menus->all() as $slot => $menu) {
|
||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||
}
|
||
|
||
// Die Vorschau zeigt dieselbe Ausgabe wie die Website - einschliesslich eigenem
|
||
// CSS, Bereichs- und Sektionsstilen und der Formulare mit ihren Farben. Vorher
|
||
// fehlte all das, und "sieht in der Vorschau anders aus" war immer wahr.
|
||
$documents = [$document, ...array_values($areas), ...array_values($sections)];
|
||
$styles = $this->theme->css($theme);
|
||
foreach ($documents as $doc) {
|
||
$styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc);
|
||
}
|
||
$forms = $this->previewForms($documents, $page);
|
||
foreach ($forms as $state) {
|
||
if (null !== $state['form']) {
|
||
$styles .= $this->forms->css($state['form'], $theme);
|
||
}
|
||
}
|
||
|
||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($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']);
|
||
$this->code->assertHtmlUnchanged($body['document'], $page['document']);
|
||
}
|
||
$page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']);
|
||
// Die gespeicherte Fassung, nicht die geschickte: Rich Text wird beim Speichern
|
||
// bereinigt, und der Editor soll das sofort zeigen statt erst nach dem Neuladen.
|
||
$page['document'] = $this->pages->findForEditor($id)['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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Die Formulare der Vorschau: Definition und Optionsbilder, ohne Zeitstempel - in der Vorschau
|
||
* wird nichts abgeschickt.
|
||
*
|
||
* @param list<array> $documents
|
||
*
|
||
* @return array<string, array>
|
||
*/
|
||
private function previewForms(array $documents, array $page): array
|
||
{
|
||
$states = [];
|
||
$walk = function (array $blocks) use (&$walk, &$states, $page): void {
|
||
foreach ($blocks as $block) {
|
||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||
$states[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
||
}
|
||
$walk($block['children'] ?? []);
|
||
}
|
||
};
|
||
foreach ($documents as $document) {
|
||
$walk($document['blocks'] ?? []);
|
||
}
|
||
|
||
return $states;
|
||
}
|
||
|
||
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(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor()];
|
||
}
|
||
}
|