Kant CMS: Grundgerüst, visueller Editor, Mediathek
Symfony 7.4 / Twig / Doctrine DBAL auf herkömmlichem PHP-Webhosting. Versionierte Blockdokumente (v1-v3) mit unveränderlichen Revisionen, getrennten Entwürfen/Veröffentlichungen und optimistischer Konflikterkennung. Visueller Editor mit Strukturbaum, Live-Vorschau, responsiven Gerätewerten, Abschnittsvorlagen, Bildmediathek, Galerien und Effekten. Öffentliche Ausgabe ohne Editor-JavaScript. Geprüft: 41 PHP-Tests / 155 Assertions, 10 JavaScript-Modelltests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
82b07820bc
87 files changed
+14463
No files matched your search
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\PageRepository;
|
||||
use App\Domain\InvalidDocument;
|
||||
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;
|
||||
|
||||
#[Route('/admin')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private CsrfTokenManagerInterface $csrf)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('', name: 'admin_pages', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$pages = $this->pages->listForEditor();
|
||||
foreach ($pages as &$page) {
|
||||
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
|
||||
}
|
||||
|
||||
return $this->render('admin/pages.html.twig', ['pages' => $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);
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '']);
|
||||
}
|
||||
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()];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user