Files
KantSites/src/Controller/PartialController.php
T
kantarciandClaude Opus 5 59a40157a4 Add an own-HTML block, and build the DEEG offers page on the embed block
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>
2026-09-16 08:27:43 +02:00

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');
}
}