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(), '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 { $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); $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']; } } $navigation = []; foreach ($this->menus->all() as $slot => $menu) { $navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']); } return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($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']); } $page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']); $page['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); } } 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()]; } }