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