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>
252 lines
11 KiB
PHP
252 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Content\CustomCode;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\FontLibrary;
|
|
use App\Content\FormRepository;
|
|
use App\Content\MenuRepository;
|
|
use App\Content\PartialRepository;
|
|
use App\Content\StyleCompiler;
|
|
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\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
|
|
|
/**
|
|
* Header, footer and reusable sections in the same editor as a page.
|
|
*
|
|
* The editor front end takes its API base from a data attribute, so it works against pages and
|
|
* against areas without a second implementation. Areas have no draft and no publication: they are
|
|
* shared, so a saved change is live. The editor hides the publish button for them.
|
|
*/
|
|
#[Route('/%kant.admin_path%/partials')]
|
|
final class PartialController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private PartialRepository $partials,
|
|
private DocumentValidator $validator,
|
|
private MenuRepository $menus,
|
|
private Theme $theme,
|
|
private StyleCompiler $styles,
|
|
private CustomCode $code,
|
|
private FontLibrary $fonts,
|
|
private CsrfTokenManagerInterface $csrf,
|
|
private PluginRegistry $plugins,
|
|
private FormRepository $forms,
|
|
) {
|
|
}
|
|
|
|
#[Route('', name: 'admin_partials', methods: ['GET'])]
|
|
public function index(): Response
|
|
{
|
|
// Ein Bereich kennt keinen Entwurf: Speichern ist Veroeffentlichen. Deshalb ist der ganze
|
|
// Bildschirm Sache des Veroeffentlichers, nicht erst der Speicherknopf.
|
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
|
$partials = [];
|
|
foreach ($this->partials->all() as $partial) {
|
|
$partial['usedBy'] = 'section' === $partial['kind'] ? $this->partials->usedBy($partial['slug']) : [];
|
|
$partials[] = $partial;
|
|
}
|
|
|
|
return $this->render('admin/partials.html.twig', ['partials' => $partials, 'kinds' => PartialRepository::KINDS]);
|
|
}
|
|
|
|
#[Route('/new', name: 'admin_partial_create', methods: ['POST'])]
|
|
public function create(Request $request): Response
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
|
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
|
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
|
}
|
|
$name = trim($request->request->getString('name'));
|
|
try {
|
|
$id = $this->partials->create(
|
|
$request->request->getString('kind', 'section'),
|
|
$request->request->getString('slug') ?: $this->slugify($name),
|
|
$name,
|
|
// A shared area starts with one empty section, so there is something to click on.
|
|
['schemaVersion' => 11, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
|
|
['id' => 'b_'.bin2hex(random_bytes(8)), 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => []],
|
|
]],
|
|
);
|
|
} catch (InvalidPage|InvalidDocument $e) {
|
|
return $this->back($e->getMessage());
|
|
}
|
|
|
|
return $this->redirectToRoute('admin_partial_editor', ['id' => $id]);
|
|
}
|
|
|
|
#[Route('/{id}/delete', name: 'admin_partial_delete', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
|
public function delete(Request $request, string $id): Response
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
|
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
|
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
|
}
|
|
try {
|
|
$this->partials->delete($id);
|
|
} catch (InvalidPage $e) {
|
|
return $this->back($e->getMessage());
|
|
}
|
|
|
|
return $this->back('Die Sektion wurde entfernt. Seiten, die sie einbinden, zeigen an dieser Stelle nichts mehr.', 'success');
|
|
}
|
|
|
|
#[Route('/{id}', name: 'admin_partial_editor', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
|
public function editor(string $id): Response
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
|
|
|
return $this->render('admin/editor.html.twig', ['partial' => $this->partial($id), 'kinds' => PartialRepository::KINDS]);
|
|
}
|
|
|
|
#[Route('/{id}/document', name: 'admin_partial_document', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
|
public function document(string $id): JsonResponse
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
|
|
|
return $this->json($this->payload($this->partial($id)));
|
|
}
|
|
|
|
#[Route('/{id}/draft', name: 'admin_partial_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
|
#[Route('/{id}/preview', name: 'admin_partial_preview', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
|
public function write(Request $request, string $id): Response
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
|
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' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt).'], 413);
|
|
}
|
|
$partial = $this->partial($id);
|
|
try {
|
|
$body = json_decode($request->getContent(), true, 64, JSON_THROW_ON_ERROR);
|
|
$document = $body['document'] ?? null;
|
|
if (!is_array($body) || !is_array($document)) {
|
|
throw new \InvalidArgumentException();
|
|
}
|
|
if ('admin_partial_preview' === $request->attributes->get('_route')) {
|
|
$this->validator->validate($document, false, true);
|
|
|
|
return $this->renderPreview($partial, $document);
|
|
}
|
|
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) {
|
|
throw new \InvalidArgumentException();
|
|
}
|
|
if (!$this->isGranted('ROLE_ADMIN')) {
|
|
$this->code->assertScriptsUnchanged($document, $partial['document']);
|
|
$this->code->assertHtmlUnchanged($document, $partial['document']);
|
|
}
|
|
$this->partials->save($id, $document, $body['expectedVersion']);
|
|
// Die gespeicherte Fassung zurueck - bereinigter Rich Text steht sofort im Editor.
|
|
$partial['document'] = $this->partials->find($id)['document'] ?? $document;
|
|
$partial['version'] = $body['expectedVersion'] + 1;
|
|
|
|
return $this->json($this->payload($partial));
|
|
} catch (StaleWrite $e) {
|
|
return $this->json(['error' => $e->getMessage(), 'conflict' => true], 409);
|
|
} catch (InvalidDocument|InvalidPage $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 Dokument.'], 422);
|
|
}
|
|
}
|
|
|
|
/** The area on its own, in the shell of a page, so the editor preview looks like the result. */
|
|
private function renderPreview(array $partial, array $document): Response
|
|
{
|
|
$theme = $this->theme->load();
|
|
$navigation = [];
|
|
foreach ($this->menus->all() as $slot => $menu) {
|
|
$navigation[$slot] = $this->menus->resolve($menu['items'], '');
|
|
}
|
|
$sections = [];
|
|
foreach ($this->partials->referencedSlugs($document) as $slug) {
|
|
$section = $this->partials->resolveSection($slug);
|
|
if (null !== $section) {
|
|
$sections[$slug] = $section['document'];
|
|
}
|
|
}
|
|
|
|
// Wie die Seitenvorschau: eigenes CSS, Sektionsstile und Formulare mit ihren Farben.
|
|
$styles = $this->theme->css($theme).$this->styles->compileDocument($document).$this->code->compileDocument($document);
|
|
foreach ($sections as $section) {
|
|
$styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section);
|
|
}
|
|
$forms = [];
|
|
$walk = function (array $blocks) use (&$walk, &$forms): void {
|
|
foreach ($blocks as $block) {
|
|
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
|
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
|
$forms[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => ['path' => ''], 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
|
if (null !== $form) {
|
|
$styles .= $this->forms->css($form, $theme);
|
|
}
|
|
}
|
|
$walk($block['children'] ?? []);
|
|
}
|
|
};
|
|
$walk($document['blocks'] ?? []);
|
|
foreach ($sections as $section) {
|
|
$walk($section['blocks'] ?? []);
|
|
}
|
|
|
|
return $this->render('page.html.twig', [
|
|
'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
|
|
'canonical' => '',
|
|
'editing' => true,
|
|
'navigation' => $navigation,
|
|
'areas' => [],
|
|
'sections' => $sections,
|
|
'postLists' => [],
|
|
'postMeta' => null,
|
|
'needsTabs' => false,
|
|
'forms' => $forms,
|
|
'pageStyles' => $styles,
|
|
'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [],
|
|
]);
|
|
}
|
|
|
|
private function partial(string $id): array
|
|
{
|
|
return $this->partials->find($id) ?? throw $this->createNotFoundException('Bereich nicht gefunden.');
|
|
}
|
|
|
|
private function payload(array $partial): array
|
|
{
|
|
return [
|
|
'document' => $partial['document'],
|
|
'version' => $partial['version'],
|
|
'revisionId' => null,
|
|
'publishedRevisionId' => null,
|
|
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
|
'plugins' => $this->plugins->forEditor(),
|
|
'presets' => $this->plugins->presetsForEditor(),
|
|
];
|
|
}
|
|
|
|
private function slugify(string $name): string
|
|
{
|
|
$slug = strtr(mb_strtolower($name, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
|
|
|
|
return substr(trim(preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '', '-'), 0, 80);
|
|
}
|
|
|
|
private function back(string $message, string $type = 'error'): Response
|
|
{
|
|
$this->addFlash($type, $message);
|
|
|
|
return $this->redirectToRoute('admin_partials');
|
|
}
|
|
}
|