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

+140
View File
@@ -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;
}
}