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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user