pages->listForEditor(); foreach ($pages as &$page) { $page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug']; $page['depth'] = substr_count((string) $page['path'], '/'); $page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => '']; } unset($page); return $this->render('admin/pages.html.twig', [ 'pages' => $pages, 'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)), ]); } #[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 { $id = $this->pages->createPage($slug, $parentId, $type, DemoContent::starter($title, $type)); } 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'], ]); } #[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'), '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'); } #[Route('/pages/{id}/delete', name: 'admin_delete', methods: ['POST'])] public function delete(Request $request, string $id): Response { // Deleting removes every revision of a page, so it stays with the administrator role. $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); return $this->redirectToRoute('admin_pages'); } #[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); $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, 'needsTabs' => false, 'pageStyles' => $this->theme->css($this->theme->load()).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($this->theme->load())]); } 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(); } $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()]; } }