php bin/console cms:admin:path <neu> legt die Verwaltung auf eine andere Adresse. Geprueft wird, dass der Name frei ist und keine Seite verdeckt; der Wert steht in .env.local und fuellt einen Parameter, der als Praefix in jeder Verwaltungsroute steht, in der Firewall, in den Schutzkopfzeilen und in der Statistik. Die robots.txt nennt einen geaenderten Namen nicht - sie ist oeffentlich und waere sonst ein Wegweiser. Beim Standardnamen bleibt die Zeile, weil diese Adresse ohnehin jeder kennt. Die Auffangroute fuer Seiten steht jetzt zuletzt: sie kann einen frei gewaehlten Namen nicht per Regex ausschliessen, und die Reihenfolge ist ohnehin die richtige. Der Befehl raeumt den Zwischenspeicher auf, aber nicht den, in dem er selbst laeuft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
8.9 KiB
PHP
212 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Content\CustomCode;
|
|
use App\Content\DocumentValidator;
|
|
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 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 CsrfTokenManagerInterface $csrf,
|
|
) {
|
|
}
|
|
|
|
#[Route('', name: 'admin_partials', methods: ['GET'])]
|
|
public function index(): Response
|
|
{
|
|
$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_EDITOR');
|
|
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_EDITOR');
|
|
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
|
|
{
|
|
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
|
|
{
|
|
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_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' => '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);
|
|
|
|
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->partials->save($id, $document, $body['expectedVersion']);
|
|
$partial['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'];
|
|
}
|
|
}
|
|
|
|
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' => [],
|
|
'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document),
|
|
'googleFonts' => $this->theme->googleFontsUrl($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(),
|
|
];
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|