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,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Domain\CmsUser;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
#[AsCommand(name: 'cms:user:create', description: 'Create an account with a hidden password prompt. Existing users are never overwritten.')]
|
||||
final class CreateUserCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $db, private UserPasswordHasherInterface $hasher)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('username', InputArgument::REQUIRED)
|
||||
->addOption('role', null, InputOption::VALUE_REQUIRED, 'admin, publisher or editor', 'admin');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$username = strtolower(trim($input->getArgument('username')));
|
||||
$roles = ['admin' => 'ROLE_ADMIN', 'publisher' => 'ROLE_PUBLISHER', 'editor' => 'ROLE_EDITOR'];
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9._-]{2,99}$/D', $username) || !isset($roles[$input->getOption('role')])) {
|
||||
throw new \InvalidArgumentException('Benutzername: 3–100 Zeichen (a–z, 0–9, Punkt, Unterstrich, Bindestrich). Rolle: admin, publisher oder editor.');
|
||||
}
|
||||
if ($this->db->fetchOne('SELECT id FROM cms_user WHERE username = ?', [$username])) {
|
||||
throw new \InvalidArgumentException('Dieser Benutzer existiert bereits und bleibt unverändert.');
|
||||
}
|
||||
if (!$input->isInteractive()) {
|
||||
throw new \InvalidArgumentException('Für das Passwort ist ein interaktives Terminal erforderlich.');
|
||||
}
|
||||
$password = $io->askHidden('Passwort (mindestens 14 Zeichen, maximal 72 Bytes)', static function ($password): string {
|
||||
if (!is_string($password) || strlen($password) < 14 || strlen($password) > 72 || str_contains($password, "\0")) {
|
||||
throw new \InvalidArgumentException('Bitte 14–72 Bytes verwenden.');
|
||||
}
|
||||
|
||||
return $password;
|
||||
});
|
||||
if (!hash_equals($password, (string) $io->askHidden('Passwort wiederholen'))) {
|
||||
throw new \InvalidArgumentException('Die Passwörter stimmen nicht überein.');
|
||||
}
|
||||
$user = new CmsUser(bin2hex(random_bytes(16)), $username, '', [$roles[$input->getOption('role')]]);
|
||||
$hash = $this->hasher->hashPassword($user, $password);
|
||||
unset($password);
|
||||
$this->db->insert('cms_user', ['id' => $user->id, 'username' => $username, 'password_hash' => $hash, 'roles' => json_encode($user->getRoles())]);
|
||||
$io->success('Benutzer angelegt. Anmeldung unter /admin/login.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\PageRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(name: 'cms:save-draft', description: 'Save a validated JSON document with optimistic locking (local CLI only).')]
|
||||
final class SaveDocumentCommand extends Command
|
||||
{
|
||||
public function __construct(private PageRepository $pages)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('page-id', InputArgument::REQUIRED)->addArgument('file', InputArgument::REQUIRED)->addArgument('expected-version', InputArgument::REQUIRED);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$file = $input->getArgument('file');
|
||||
if (!is_file($file) || !is_readable($file) || filesize($file) > 262144) {
|
||||
throw new \InvalidArgumentException('A readable JSON file of at most 256 KiB is required.');
|
||||
}
|
||||
$version = filter_var($input->getArgument('expected-version'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
|
||||
if (false === $version) {
|
||||
throw new \InvalidArgumentException('Version must be a non-negative integer.');
|
||||
}
|
||||
$document = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($document)) {
|
||||
throw new \InvalidArgumentException('Document must be an object.');
|
||||
}
|
||||
$output->writeln($this->pages->saveDraft($input->getArgument('page-id'), $document, $version));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\PageRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(name: 'cms:seed-demo', description: 'Create missing demo pages without overwriting existing content.')]
|
||||
final class SeedDemoCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $db, private PageRepository $pages)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->db->transactional(function () use ($output): void {
|
||||
if (!$this->db->fetchOne('SELECT id FROM website WHERE id = ?', ['default'])) {
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Kant CMS', 'default_locale' => 'de', 'theme' => json_encode(['template' => 'kant-starter', 'version' => 1])]);
|
||||
}
|
||||
foreach (DemoContent::pages() as $slug => $document) {
|
||||
if ($this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND slug = ?', ['default', 'de', $slug])) {
|
||||
$output->writeln('Unchanged: '.$slug);
|
||||
continue;
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$this->db->insert('page', ['id' => $id, 'website_id' => 'default', 'locale' => 'de', 'slug' => $slug, 'version' => 0]);
|
||||
$revision = $this->pages->saveDraft($id, $document, 0);
|
||||
$this->pages->publish($id, $revision, 1);
|
||||
$output->writeln('Created: '.$slug);
|
||||
}
|
||||
});
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
final class DemoContent
|
||||
{
|
||||
public static function pages(): array
|
||||
{
|
||||
return [
|
||||
'home' => [
|
||||
'schemaVersion' => 1,
|
||||
'title' => 'Kant CMS · Raum für deine Website',
|
||||
'description' => 'Ein eigenes CMS für individuelle Kundenwebsites. Entdecke die technische Grundlage für strukturierte Inhalte, schnelle Seiten und einen visuellen Editor.',
|
||||
'blocks' => [
|
||||
self::section('hero', [
|
||||
self::text('eyebrow', 'KANT CMS / DER ANFANG IST GEMACHT'),
|
||||
self::heading('headline', 'Deine Inhalte.\nDein Gestaltungsspielraum.', 1),
|
||||
self::text('intro', 'Eine Website sollte zu dir passen. Wir entwickeln die Grundlage dafür: klar strukturierte Inhalte, unabhängiges Hosting und einen Editor, der Gestaltung einfach macht.'),
|
||||
self::button('discover', 'Die Grundlage entdecken', '/grundlagen'),
|
||||
]),
|
||||
self::section('principles', [
|
||||
self::text('principles-label', '01 / WAS UNS WICHTIG IST'),
|
||||
self::heading('principles-heading', 'Ein guter Auftritt beginnt\nunter der Oberfläche.', 2),
|
||||
['id' => 'principles-grid', 'type' => 'row', 'children' => [
|
||||
self::card('content', '01 — INHALTE', 'Klar aufgebaut.', 'Texte und Gestaltung bleiben getrennt. Ein versionierter Blockbaum bildet die Grundlage, damit sich Inhalte später weiterentwickeln lassen.'),
|
||||
self::card('delivery', '02 — AUSGABE', 'Direkt lesbar.', 'Der Hauptinhalt kommt als vollständiges HTML beim Browser an. Zum Lesen braucht diese Seite kein JavaScript und keine Editor-Bibliothek.'),
|
||||
self::card('control', '03 — KONTROLLE', 'Bewusst veröffentlicht.', 'Entwurf und Veröffentlichung haben eigene Stände. Eine gespeicherte Änderung erscheint erst dann öffentlich, wenn sie veröffentlicht wird.'),
|
||||
]],
|
||||
], 'mint'),
|
||||
self::section('next', [
|
||||
self::text('next-label', '02 / VON DER GRUNDLAGE ZUM WERKZEUG'),
|
||||
self::heading('next-heading', 'Schritt für Schritt.\nMit einem klaren Ziel.', 2),
|
||||
self::text('next-text', 'Diese Seite ist der erste technische Prototyp von Kant CMS. Als Nächstes entsteht der visuelle Editor mit Strukturbaum, Vorschau und Eigenschaften. Das fertige CMS wächst an einer echten Pilotwebsite.'),
|
||||
self::button('progress', 'Den Projektstand ansehen', '/projektstand'),
|
||||
], 'ink'),
|
||||
],
|
||||
],
|
||||
'grundlagen' => self::article('Die Grundlage · Kant CMS', 'Wie Kant CMS Inhalte als Blöcke speichert und Seiten als HTML ausliefert.', 'Ein Fundament für\nveränderbare Websites.', [
|
||||
['Inhalte mit Struktur', 'Eine Seite besteht aus einem versionierten Dokument. Sektionen, Zeilen und Spalten ordnen Überschriften, Texte, Bilder und Links. Jeder Block hat eine stabile Kennung. Text wird bei der Ausgabe automatisch maskiert; frei ausführbares HTML ist kein Inhaltsformat.'],
|
||||
['Ein Entwurf bleibt ein Entwurf', 'Speichern erzeugt einen eigenen Stand. Die öffentliche Route liest ausschließlich die veröffentlichte Revision. Veraltete Schreibstände werden abgewiesen, damit eine ältere Bearbeitung neue Inhalte nicht unbemerkt überschreibt.'],
|
||||
['PHP-Webhosting als Ziel', 'Der Prototyp nutzt Symfony, Twig und Doctrine. Die lokale Installation verwendet SQLite. Für den späteren Hostingbetrieb ist MySQL oder MariaDB vorgesehen. Der öffentlich erreichbare Dokumentenstamm ist ausschließlich das Verzeichnis public.'],
|
||||
['Qualität von Anfang an', 'Semantische Elemente, sichtbarer Tastaturfokus und ein Sprunglink sind bereits Teil der Ausgabe. Automatische Prüfungen begleiten die Entwicklung. Belastbare Qualitätswerte für Kundenwebsites benötigen repräsentative Inhalte und Messungen auf dem tatsächlichen Hosting.'],
|
||||
]),
|
||||
'projektstand' => self::article('Projektstand · Kant CMS', 'Der aktuelle Entwicklungsstand von Kant CMS und die nächsten Schritte zum visuellen Editor.', 'Der erste Schritt\nist eine solide Basis.', [
|
||||
['Jetzt: technische Grundlage', 'Projektregeln und Entwicklungsplan sind abgelegt. Das Grundgerüst speichert Seiten in einer Datenbank und liefert veröffentlichte Blockdokumente als HTML aus. Diese Demonstrationsinhalte gehören zum Prototyp; sie sind keine Kundenwebsite.'],
|
||||
['Als Nächstes: visuell bearbeiten', 'Geplant sind eine Elementbibliothek, ein synchronisierter Strukturbaum, Live-Vorschau und ein Eigenschaftenpanel. Verschieben, Duplizieren, Löschen und Undo/Redo werden zusammen mit dem Speichern und Laden geprüft.'],
|
||||
['Noch zu konkretisieren', 'Die Pilotwebsite, konkrete UX-Builder-Referenzen und die Ausstattung des PHP-Webhostings stehen noch aus. Anmeldung, Medienverwaltung, Formulare, Templates und Plugins folgen in den dafür vorgesehenen Ausbauschritten.'],
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private static function article(string $title, string $description, string $heading, array $sections): array
|
||||
{
|
||||
$blocks = [self::section('article-hero', [self::text('article-label', 'KANT CMS / ENTWICKLUNG'), self::heading('article-heading', $heading, 1)], 'mint')];
|
||||
$children = [];
|
||||
foreach ($sections as $i => [$label, $text]) {
|
||||
$children[] = self::heading('heading-'.$i, $label, 2);
|
||||
$children[] = self::text('text-'.$i, $text);
|
||||
}
|
||||
$blocks[] = self::section('article-body', $children, 'paper', 'narrow');
|
||||
|
||||
return ['schemaVersion' => 1, 'title' => $title, 'description' => $description, 'blocks' => $blocks];
|
||||
}
|
||||
|
||||
private static function section(string $id, array $children, string $tone = 'paper', string $width = 'normal'): array
|
||||
{
|
||||
return ['id' => $id, 'type' => 'section', 'layout' => ['tone' => $tone, 'width' => $width], 'children' => $children];
|
||||
}
|
||||
|
||||
private static function heading(string $id, string $text, int $level): array
|
||||
{
|
||||
return ['id' => $id, 'type' => 'heading', 'props' => ['text' => str_replace('\\n', ' ', $text), 'level' => $level]];
|
||||
}
|
||||
|
||||
private static function text(string $id, string $text): array
|
||||
{
|
||||
return ['id' => $id, 'type' => 'text', 'props' => ['text' => $text]];
|
||||
}
|
||||
|
||||
private static function button(string $id, string $text, string $href): array
|
||||
{
|
||||
return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href]];
|
||||
}
|
||||
|
||||
private static function card(string $id, string $label, string $title, string $text): array
|
||||
{
|
||||
return ['id' => $id, 'type' => 'column', 'children' => [self::text($id.'-label', $label), self::heading($id.'-heading', $title, 3), self::text($id.'-text', $text)]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidDocument;
|
||||
|
||||
final class DocumentValidator
|
||||
{
|
||||
public function validate(array $document): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
$this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.');
|
||||
$ids = [];
|
||||
$headings = 0;
|
||||
foreach ($document['blocks'] as $block) {
|
||||
$this->block($block, $ids, $headings, 0, $schemaVersion);
|
||||
}
|
||||
$this->require(1 === $headings, 'A page requires exactly one main heading.');
|
||||
}
|
||||
|
||||
private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion): void
|
||||
{
|
||||
$this->require(is_array($block) && $depth <= 10 && count($ids) < 300, 'Invalid block or document complexity limit exceeded.');
|
||||
$id = $block['id'] ?? null;
|
||||
$this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && !isset($ids[$id]), 'Block IDs must be stable and unique.');
|
||||
$ids[$id] = true;
|
||||
$type = $block['type'] ?? null;
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || (3 === $schemaVersion && 'gallery' === $type), 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$props = $block['props'] ?? [];
|
||||
$layout = $block['layout'] ?? [];
|
||||
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
|
||||
foreach ($layout as $key => $value) {
|
||||
if ('effects' === $key && 3 === $schemaVersion) {
|
||||
$this->require(is_array($value), 'Effects must be an object.');
|
||||
$effects = ['shadow' => ['none', 'soft', 'strong'], 'hover' => ['none', 'lift', 'zoom'], 'entrance' => ['none', 'fade', 'rise']];
|
||||
foreach ($value as $effect => $token) {
|
||||
$this->require(isset($effects[$effect]) && in_array($token, $effects[$effect], true), 'Unknown effect.');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ('design' === $key && $schemaVersion >= 2) {
|
||||
$this->design($value, $type);
|
||||
continue;
|
||||
}
|
||||
$this->require(in_array($key, ['tone', 'width'], true), 'Unknown layout setting.');
|
||||
$this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow'], true), 'Invalid design token.');
|
||||
}
|
||||
if (in_array($type, ['heading', 'text', 'button'], true)) {
|
||||
$this->text($props['text'] ?? null, 'text' === $type ? 10000 : 300);
|
||||
}
|
||||
if ('heading' === $type) {
|
||||
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
||||
$headings += 1 === $props['level'] ? 1 : 0;
|
||||
}
|
||||
if ('button' === $type) {
|
||||
$this->url($props['href'] ?? null);
|
||||
}
|
||||
if ('image' === $type) {
|
||||
$this->url($props['src'] ?? null);
|
||||
$this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.');
|
||||
$this->text($props['alt'] ?? null, 300, $props['decorative']);
|
||||
$this->require(!$props['decorative'] || '' === $props['alt'], 'Decorative images need empty alternative text.');
|
||||
foreach (['width', 'height'] as $dimension) {
|
||||
$this->require(is_int($props[$dimension] ?? null) && $props[$dimension] > 0 && $props[$dimension] <= 8000, 'Image dimensions are required.');
|
||||
}
|
||||
foreach (['caption', 'mediaId', 'sources'] as $extra) {
|
||||
if (isset($props[$extra])) {
|
||||
$this->require(3 === $schemaVersion, 'Media properties require schema v3.');
|
||||
}
|
||||
}
|
||||
if (isset($props['caption'])) {
|
||||
$this->text($props['caption'], 500, true);
|
||||
}
|
||||
if (isset($props['mediaId'])) {
|
||||
$this->require(is_string($props['mediaId']) && 1 === preg_match('/^[a-f0-9]{32}$/D', $props['mediaId']), 'Invalid media ID.');
|
||||
}
|
||||
if (isset($props['sources'])) {
|
||||
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= 3, 'Invalid image variants.');
|
||||
$widths = [];
|
||||
foreach ($props['sources'] as $source) {
|
||||
$this->require(is_array($source) && is_int($source['width'] ?? null) && $source['width'] > 0 && $source['width'] <= 8000 && !in_array($source['width'], $widths, true), 'Invalid image variant width.');
|
||||
$this->url($source['src'] ?? null);
|
||||
$this->require(!str_contains($source['src'], ','), 'Invalid srcset URL.');
|
||||
$widths[] = $source['width'];
|
||||
}
|
||||
}
|
||||
}
|
||||
$children = $block['children'] ?? [];
|
||||
$this->require(is_array($children) && array_is_list($children), 'Children must be a list.');
|
||||
$container = in_array($type, ['section', 'row', 'column', 'gallery'], true);
|
||||
$this->require($container || [] === $children, 'Leaf blocks cannot contain children.');
|
||||
foreach ($children as $child) {
|
||||
if ('gallery' === $type) {
|
||||
$this->require(is_array($child) && 'image' === ($child['type'] ?? null), 'Galleries only contain images.');
|
||||
}
|
||||
$this->block($child, $ids, $headings, $depth + 1, $schemaVersion);
|
||||
}
|
||||
}
|
||||
|
||||
private function design(mixed $design, string $type): void
|
||||
{
|
||||
static $tokens = null;
|
||||
$tokens ??= json_decode(file_get_contents(__DIR__.'/../../public/assets/design-tokens.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->require(is_array($design), 'Design must be an object.');
|
||||
foreach ($design as $device => $settings) {
|
||||
$this->require(in_array($device, ['desktop', 'tablet', 'mobile'], true) && is_array($settings), 'Invalid responsive device.');
|
||||
foreach ($settings as $key => $value) {
|
||||
$this->require(isset($tokens[$key]) && in_array($type, $tokens[$key]['types'], true), 'Design setting does not apply to this block.');
|
||||
$this->require(is_string($value) && isset($tokens[$key]['values'][$value]), 'Invalid responsive design token.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function text(mixed $value, int $max, bool $emptyAllowed = false): void
|
||||
{
|
||||
$this->require(is_string($value) && strlen($value) <= $max && ($emptyAllowed || '' !== trim($value)), 'Missing or oversized text.');
|
||||
}
|
||||
|
||||
private function url(mixed $url): void
|
||||
{
|
||||
$this->require(is_string($url) && strlen($url) <= 2048, 'Invalid URL.');
|
||||
$this->require(!preg_match('/[\x00-\x20\\\\]/', $url), 'Unsafe URL characters.');
|
||||
$local = str_starts_with($url, '/') && !str_starts_with($url, '//');
|
||||
$external = str_starts_with($url, 'https://') && false !== filter_var($url, FILTER_VALIDATE_URL);
|
||||
$this->require($local || $external || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $url), 'Only local paths, fragments and HTTPS URLs are allowed.');
|
||||
}
|
||||
|
||||
private function require(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new InvalidDocument($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
final class MediaLibrary
|
||||
{
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative('SELECT * FROM media ORDER BY created_at DESC, id DESC'));
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
|
||||
|
||||
return $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
$row['variants'] = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$row['version'] = (int) $row['version'];
|
||||
$row['archived'] = (bool) $row['archived'];
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file): array
|
||||
{
|
||||
if (!$file->isValid() || $file->getSize() > 8 * 1024 * 1024) {
|
||||
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 8 MB pro Bild; das PHP-Hosting kann ein kleineres Limit setzen.');
|
||||
}
|
||||
if (!function_exists('imagewebp')) {
|
||||
throw new \RuntimeException('Für Bildverarbeitung wird PHP GD mit WebP benötigt.');
|
||||
}
|
||||
$info = @getimagesize($file->getPathname());
|
||||
if (!$info || !in_array($info['mime'], ['image/jpeg', 'image/png', 'image/webp'], true) || $info[0] * $info[1] > 12000000 || max($info[0], $info[1]) > 8000) {
|
||||
throw new \InvalidArgumentException('Bitte ein JPEG-, PNG- oder WebP-Bild mit maximal 12 Megapixeln und 8000 px Kantenlänge wählen.');
|
||||
}
|
||||
$memoryLimit = ini_parse_quantity(ini_get('memory_limit'));
|
||||
if ($memoryLimit > 0 && memory_get_usage(true) + $info[0] * $info[1] * 12 + 16777216 > $memoryLimit) {
|
||||
throw new \InvalidArgumentException('Dieses Bild benötigt mehr Arbeitsspeicher als das Hosting bereitstellt. Bitte vor dem Upload verkleinern.');
|
||||
}
|
||||
$source = @imagecreatefromstring(file_get_contents($file->getPathname()));
|
||||
if (!$source) {
|
||||
throw new \InvalidArgumentException('Die Bilddatei konnte nicht gelesen werden.');
|
||||
}
|
||||
$paths = [];
|
||||
try {
|
||||
if ('image/jpeg' === $info['mime'] && function_exists('exif_read_data')) {
|
||||
$orientation = (int) (@exif_read_data($file->getPathname())['Orientation'] ?? 1);
|
||||
if (in_array($orientation, [2, 4, 5, 7], true)) {
|
||||
imageflip($source, IMG_FLIP_HORIZONTAL);
|
||||
}
|
||||
$angle = match ($orientation) {
|
||||
3, 4 => 180, 6, 7 => -90, 5, 8 => 90, default => 0,
|
||||
};
|
||||
if ($angle) {
|
||||
$rotated = imagerotate($source, $angle, 0);
|
||||
imagedestroy($source);
|
||||
$source = $rotated;
|
||||
}
|
||||
}
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$variants = [];
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
foreach (array_unique([min(480, $width), min(960, $width), min(1600, $width)]) as $targetWidth) {
|
||||
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
|
||||
$target = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
try {
|
||||
imagealphablending($target, false);
|
||||
imagesavealpha($target, true);
|
||||
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
|
||||
$path = $this->directory.'/'.$id.'-'.$targetWidth.'.webp';
|
||||
$paths[] = $path;
|
||||
if (!imagewebp($target, $path, 82) || !is_file($path) || !filesize($path)) {
|
||||
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
|
||||
}
|
||||
$variants[] = ['src' => '/media/'.$id.'/'.$targetWidth, 'width' => $targetWidth, 'height' => $targetHeight, 'bytes' => filesize($path)];
|
||||
} finally {
|
||||
imagedestroy($target);
|
||||
}
|
||||
}
|
||||
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Bild', 0, 160, 'UTF-8');
|
||||
$row = ['id' => $id, 'title' => $title, 'alt' => '', 'variants' => json_encode($variants, JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||
$this->db->insert('media', $row);
|
||||
|
||||
return $this->present($row);
|
||||
} catch (\Throwable $error) {
|
||||
foreach ($paths as $path) {
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
throw $error;
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_string($data['alt'] ?? null) || strlen($data['alt']) > 300 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes), Alternativtext (max. 300 Bytes) und Version prüfen.');
|
||||
}
|
||||
$count = $this->db->executeStatement('UPDATE media SET title = ?, alt = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], $data['alt'], (int) $data['archived'], $id, $data['version']]);
|
||||
if (1 !== $count) {
|
||||
throw new StaleWrite();
|
||||
}
|
||||
|
||||
return [...$data, 'id' => $id, 'version' => $data['version'] + 1];
|
||||
}
|
||||
|
||||
public function file(string $id, int $width): ?string
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
return null;
|
||||
}
|
||||
$media = $this->find($id);
|
||||
if (!$media || !in_array($width, array_column($media['variants'], 'width'), true)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->directory.'/'.$id.'-'.$width.'.webp';
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
final class PageRepository
|
||||
{
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator)
|
||||
{
|
||||
}
|
||||
|
||||
public function saveDraft(string $pageId, array $document, int $expectedVersion): string
|
||||
{
|
||||
$this->validator->validate($document);
|
||||
|
||||
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET version = version + 1, draft_revision_id = ? WHERE id = ? AND version = ?',
|
||||
[$id, $pageId, $expectedVersion],
|
||||
);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('The page changed. Reload before saving.');
|
||||
}
|
||||
$this->db->insert('revision', [
|
||||
'id' => $id, 'page_id' => $pageId,
|
||||
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
public function publish(string $pageId, string $revisionId, int $expectedVersion): void
|
||||
{
|
||||
$this->db->transactional(function () use ($pageId, $revisionId, $expectedVersion): void {
|
||||
$raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]);
|
||||
if (false === $raw) {
|
||||
throw new \InvalidArgumentException('Revision does not belong to this page.');
|
||||
}
|
||||
$this->validator->validate(json_decode($raw, true, 512, JSON_THROW_ON_ERROR));
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET published_revision_id = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[$revisionId, $pageId, $expectedVersion],
|
||||
);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('The page changed. Reload before publishing.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function findPublished(string $slug, string $locale = 'de'): ?array
|
||||
{
|
||||
$page = $this->db->fetchAssociative(
|
||||
'SELECT p.id, p.slug, p.locale, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.slug = ? AND p.locale = ? AND p.website_id = ?',
|
||||
[$slug, $locale, 'default'],
|
||||
);
|
||||
if (false === $page) {
|
||||
return null;
|
||||
}
|
||||
$page['document'] = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->validator->validate($page['document']);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
public function listForEditor(): array
|
||||
{
|
||||
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.locale, p.version, p.published_revision_id, p.draft_revision_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.slug', ['default']);
|
||||
}
|
||||
|
||||
public function findForEditor(string $id): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ?', [$id, 'default']);
|
||||
if (false === $row) {
|
||||
return null;
|
||||
}
|
||||
$row['version'] = (int) $row['version'];
|
||||
$row['document'] = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->validator->validate($row['document']);
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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'"]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
use Symfony\Component\Security\Core\User\EquatableInterface;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class CmsUser implements UserInterface, PasswordAuthenticatedUserInterface, EquatableInterface
|
||||
{
|
||||
public function __construct(public readonly string $id, private string $username, private string $password, private array $roles)
|
||||
{
|
||||
}
|
||||
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function setPasswordHash(string $hash): void
|
||||
{
|
||||
$this->password = $hash;
|
||||
}
|
||||
|
||||
public function getRoles(): array
|
||||
{
|
||||
return $this->roles;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function isEqualTo(UserInterface $user): bool
|
||||
{
|
||||
return $user instanceof self && $this->id === $user->id
|
||||
&& $this->username === $user->username
|
||||
&& hash_equals($this->password, $user->password)
|
||||
&& $this->roles === $user->roles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
final class InvalidDocument extends \InvalidArgumentException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
final class StaleWrite extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::RESPONSE => ['onResponse', -300]];
|
||||
}
|
||||
|
||||
public function onResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!preg_match('#^/admin(?:/|$)#', $event->getRequest()->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
$headers = $event->getResponse()->headers;
|
||||
$headers->set('Cache-Control', 'private, no-store');
|
||||
$headers->set('X-Robots-Tag', 'noindex, nofollow');
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$headers->set('Referrer-Policy', 'same-origin');
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Doctrine\DBAL\Tools\DsnParser;
|
||||
|
||||
final class ConnectionFactory
|
||||
{
|
||||
public static function create(string $url): Connection
|
||||
{
|
||||
$params = (new DsnParser(['sqlite' => 'pdo_sqlite', 'mysql' => 'pdo_mysql']))->parse($url);
|
||||
$connection = DriverManager::getConnection($params);
|
||||
if ('pdo_sqlite' === $params['driver']) {
|
||||
$connection->executeStatement('PRAGMA foreign_keys = ON');
|
||||
$connection->executeStatement('PRAGMA busy_timeout = 5000');
|
||||
}
|
||||
|
||||
return $connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Domain\CmsUser;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
|
||||
final class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
|
||||
{
|
||||
public function __construct(private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function loadUserByIdentifier(string $identifier): CmsUser
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM cms_user WHERE username = ?', [strtolower(trim($identifier))]);
|
||||
if (false === $row) {
|
||||
throw new UserNotFoundException();
|
||||
}
|
||||
|
||||
return new CmsUser($row['id'], $row['username'], $row['password_hash'], json_decode($row['roles'], true, 512, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function refreshUser(UserInterface $user): UserInterface
|
||||
{
|
||||
if (!$user instanceof CmsUser) {
|
||||
throw new UnsupportedUserException();
|
||||
}
|
||||
$fresh = $this->loadUserByIdentifier($user->getUserIdentifier());
|
||||
if ($fresh->id !== $user->id) {
|
||||
throw new UserNotFoundException();
|
||||
}
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
public function supportsClass(string $class): bool
|
||||
{
|
||||
return CmsUser::class === $class;
|
||||
}
|
||||
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||
{
|
||||
if (!$user instanceof CmsUser) {
|
||||
throw new UnsupportedUserException();
|
||||
}
|
||||
$changed = $this->db->executeStatement('UPDATE cms_user SET password_hash = ? WHERE id = ? AND password_hash = ?', [$newHashedPassword, $user->id, $user->getPassword()]);
|
||||
if (1 === $changed) {
|
||||
$user->setPasswordHash($newHashedPassword);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user