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:
kantarciandClaude Opus 5 committed 2026-09-12 10:19:05 +02:00
commit 82b07820bc
87 files changed
+14463

No files matched your search

+115
View File
@@ -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()];
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Controller;
use App\Content\MediaLibrary;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
final class MediaController extends AbstractController
{
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf)
{
}
#[Route('/admin/media', name: 'admin_media', methods: ['GET'])]
public function index(): Response
{
return $this->render('admin/media.html.twig');
}
#[Route('/admin/api/media', methods: ['GET'])]
public function list(): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
}
#[Route('/admin/api/media/upload', methods: ['POST'])]
#[Route('/admin/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
public function write(Request $request, ?string $id = null): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
}
try {
if (null === $id) {
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
}
return $this->json(['item' => $this->media->upload($file)], 201);
}
if (!$this->media->find($id)) {
throw $this->createNotFoundException();
}
if (strlen($request->getContent()) > 4096) {
throw new \InvalidArgumentException('Medienangaben sind zu groß.');
}
$data = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
if (!is_array($data)) {
throw new \InvalidArgumentException('Ungültige Medienangaben.');
}
return $this->json(['item' => $this->media->update($id, $data)]);
} catch (StaleWrite) {
return $this->json(['error' => 'Das Bild wurde inzwischen geändert. Angaben kopieren und die Mediathek neu öffnen.'], 409);
} catch (\InvalidArgumentException|\JsonException $error) {
return $this->json(['error' => $error->getMessage()], 422);
} catch (\RuntimeException $error) {
return $this->json(['error' => 'Das Bild konnte nicht gespeichert werden. GD/WebP und Schreibrechte des Medienordners prüfen.'], 503);
}
}
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
public function serve(string $id, int $width): Response
{
$path = $this->media->file($id, $width) ?? throw $this->createNotFoundException();
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Controller;
use App\Content\PageRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private string $siteOrigin)
{
}
#[Route('/', name: 'home', methods: ['GET'])]
#[Route('/{slug}', name: 'page', requirements: ['slug' => '[a-z0-9]+(?:-[a-z0-9]+)*'], methods: ['GET'])]
public function page(Request $request, string $slug = 'home'): Response
{
// One canonical address for the home page.
if ('home' === $slug && '/home' === $request->getPathInfo()) {
return $this->redirectToRoute('home', status: 301);
}
$page = $this->pages->findPublished($slug);
if (null === $page) {
$response = $this->render('not-found.html.twig', [], new Response(status: 404));
} else {
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => rtrim($this->siteOrigin, '/').('home' === $slug ? '/' : '/'.$slug),
]);
}
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
return $response;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
final class SecurityController extends AbstractController
{
#[Route('/admin/login', name: 'admin_login', methods: ['GET', 'POST'])]
public function login(AuthenticationUtils $auth): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('admin_pages');
}
return $this->render('admin/login.html.twig', ['error' => $auth->getLastAuthenticationError(), 'username' => $auth->getLastUsername()]);
}
#[Route('/admin/logout', name: 'admin_logout', methods: ['POST'])]
public function logout(): never
{
throw new \LogicException('Handled by Symfony Security.');
}
}