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

+62
View File
@@ -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;
}
}
+43
View File
@@ -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;
}
}
+42
View File
@@ -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;
}
}