- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
638 lines
32 KiB
PHP
638 lines
32 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\Languages;
|
||
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\SiteSettings;
|
||
use App\Content\StyleCompiler;
|
||
use App\Content\StyleSchema;
|
||
use App\Content\Theme;
|
||
use App\Content\Translations;
|
||
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, private Languages $languages, private SiteSettings $siteSettings)
|
||
{
|
||
}
|
||
|
||
#[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')],
|
||
// Vorhandene Kategorien und Schlagwoerter als Vorschlag - sonst entstehen „Projekt“
|
||
// und „Projekte“ nebeneinander.
|
||
'termNames' => 'post' === $type ? $this->termNames() : ['category' => [], 'tag' => []],
|
||
];
|
||
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]);
|
||
}
|
||
|
||
/** @return array{category: list<string>, tag: list<string>} */
|
||
private function termNames(): array
|
||
{
|
||
$names = ['category' => [], 'tag' => []];
|
||
foreach ($this->posts->allTerms() as $term) {
|
||
if (isset($names[$term['kind']])) {
|
||
$names[$term['kind']][] = $term['name'];
|
||
}
|
||
}
|
||
|
||
return $names;
|
||
}
|
||
|
||
/** 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(),
|
||
// Was ein Baustein per Adresse verknuepft, waehlt man aus einer Liste - niemand soll
|
||
// die Adresse eines Formulars oder einer Kategorie auswendig wissen muessen.
|
||
'choices' => $this->choices(),
|
||
]);
|
||
}
|
||
|
||
/** @return array<string, array<string, string>> Adresse => Name, je Art */
|
||
private function choices(): array
|
||
{
|
||
$choices = ['forms' => [], 'sections' => $this->partials->choices('section'), 'categories' => [], 'tags' => []];
|
||
foreach ($this->forms->all() as $form) {
|
||
$choices['forms'][$form['slug']] = $form['name'];
|
||
}
|
||
foreach ($this->posts->allTerms() as $term) {
|
||
$choices['category' === $term['kind'] ? 'categories' : 'tags'][$term['slug']] = $term['name'];
|
||
}
|
||
|
||
return $choices;
|
||
}
|
||
|
||
/** 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.');
|
||
}
|
||
if ($blocked = $this->liveNeedsPublisher($id, false)) {
|
||
return $blocked;
|
||
}
|
||
$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.');
|
||
}
|
||
if ($blocked = $this->liveNeedsPublisher($id, false)) {
|
||
return $blocked;
|
||
}
|
||
// 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.');
|
||
}
|
||
if ($blocked = $this->liveNeedsPublisher($id, true)) {
|
||
return $blocked;
|
||
}
|
||
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');
|
||
}
|
||
|
||
/**
|
||
* Ein Redakteur bearbeitet Entwürfe; was eine veröffentlichte Seite sofort verändert, darf
|
||
* nur, wer veröffentlichen darf (ADR 116): Adresse, Papierkorb, Kopf- und Fußbereich,
|
||
* Beitragsdaten. Bei Seiten, die nie veröffentlicht waren, bleibt alles wie bisher.
|
||
*/
|
||
private function liveNeedsPublisher(string $id, bool $subtree): ?Response
|
||
{
|
||
if ($this->isGranted('ROLE_PUBLISHER') || !$this->pages->isLive($id, $subtree)) {
|
||
return null;
|
||
}
|
||
|
||
return $this->back($subtree
|
||
? 'Diese Seite oder eine ihrer Unterseiten ist veröffentlicht. Adresse und Papierkorb ändert, wer veröffentlichen darf.'
|
||
: 'Diese Seite ist veröffentlicht. Diese Angaben wirken sofort öffentlich und ändert deshalb, wer veröffentlichen darf.');
|
||
}
|
||
|
||
#[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 – mit dem Entwurf von jetzt. Spätere Änderungen gehen erst mit erneutem Planen oder Veröffentlichen online. 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.');
|
||
}
|
||
if ($blocked = $this->liveNeedsPublisher($id, true)) {
|
||
return $blocked;
|
||
}
|
||
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);
|
||
}
|
||
// Eine Seite darf 256 KiB haben, mit ihren Übersetzungen zusammen 1 MiB (ADR 111).
|
||
if (strlen($request->getContent()) > 1100000) {
|
||
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 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);
|
||
// Die Vorschau einer Übersetzung ist dieselbe Seite mit den übersetzten Texten -
|
||
// genau so, wie die Website sie in dieser Sprache ausliefert.
|
||
$language = $body['language'] ?? null;
|
||
$locale = is_string($language) && $this->languages->isExtra($language) ? $language : $this->languages->main();
|
||
$seoTitle = $locale === $this->languages->main() ? ($page['seo_title'] ?? null) : ($document['translations'][$locale]['seoTitle'] ?? '');
|
||
$document = $locale === $this->languages->main() ? Translations::strip($document) : Translations::apply($document, $locale);
|
||
$request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all()));
|
||
|
||
$request->attributes->set('kant_locale', $locale);
|
||
$localise = fn (array $doc): array => $locale === $this->languages->main() ? Translations::strip($doc) : Translations::apply($doc, $locale);
|
||
|
||
$theme = $this->theme->load();
|
||
$areas = [];
|
||
foreach (['header', 'footer'] as $kind) {
|
||
$area = $this->partials->findByKind($kind);
|
||
if (null !== $area) {
|
||
$areas[$kind] = $localise($area['document']);
|
||
}
|
||
}
|
||
$sections = [];
|
||
foreach ($this->partials->referencedSlugs($document) as $slug) {
|
||
$section = $this->partials->resolveSection($slug);
|
||
if (null !== $section) {
|
||
$sections[$slug] = $localise($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'], language: $locale === $this->languages->main() ? null : $locale);
|
||
}
|
||
|
||
// 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).$this->menus->buttonCss($theme['buttons']);
|
||
foreach ($documents as $doc) {
|
||
$styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc);
|
||
}
|
||
$forms = $this->previewForms($documents, $page, $locale === $this->languages->main() ? null : $locale);
|
||
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, 'locale' => $locale, 'seo_title' => $seoTitle], '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 (InvalidPage $e) {
|
||
// Etwa eine übersetzte Adresse, die eine andere Seite schon hat.
|
||
return $this->json(['error' => $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, ?string $language = null): array
|
||
{
|
||
$states = [];
|
||
$walk = function (array $blocks) use (&$walk, &$states, $page, $language): void {
|
||
foreach ($blocks as $block) {
|
||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||
if (null !== $form && null !== $language) {
|
||
$form = FormRepository::localise($form, $language);
|
||
}
|
||
$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(),
|
||
// Für den Sprachumschalter (ADR 111). Ohne weitere Sprache leer - dann gibt es ihn nicht.
|
||
'languages' => [
|
||
'main' => $this->languages->describe($this->languages->main()),
|
||
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||
'slug' => $page['slug'],
|
||
'home' => $page['path'] === $this->siteSettings->homePath(),
|
||
'published' => [] === $this->languages->extra() ? [] : $this->pages->translatedPaths($page['id']),
|
||
],
|
||
];
|
||
}
|
||
}
|