chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

View File

@@ -0,0 +1,82 @@
<?php
namespace Media\Controllers;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\FileResult;
use Media\Entities\Image;
use Media\Services\ImageService;
class MediaController
{
public function __construct(
private ImageService $imageService,
private PathProvider $pathProvider,
) {}
#[Route('/media/{path}-{filename}', method: Method::GET)]
public function serveMedia(string $path, string $filename): FileResult
{
$image = $this->imageService->resolveFromUrlId($path);
$imagePath = $this->pathProvider->resolvePath('storage' . $image->uploadPath . '/' . $filename);
#debug($imagePath);
// Prüfen ob Datei existiert
if (!file_exists($imagePath)) {
die('oh');
return $this->notFound();
}
// Lese Datei und sende sie zurück
$content = file_get_contents($imagePath);
$mimeType = $this->getMimeTypeFromFormat($image->mimeType);
$headers = new Headers([
'Content-Type' => $mimeType,
'Content-Length' => filesize($imagePath),
'Cache-Control' => 'public, max-age=31536000', // 1 Jahr cachen
'ETag' => '"' . md5_file($imagePath) . '"',
]);
return new FileResult($imagePath);
return new HttpResponse(
Status::OK,
$headers,
$content
);
}
private function constructImagePath(Image $image, string $variant, string $format): string
{
return $this->pathProvider->resolvePath('storage' . $image->uploadPath . '/' . $variant . '.' . $format);
}
private function getMimeTypeFromFormat(string $format): string
{
return match ($format) {
'jpg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'avif' => 'image/avif',
default => 'application/octet-stream'
};
}
private function notFound(): HttpResponse
{
return new HttpResponse(
Status::NOT_FOUND,
new Headers(['Content-Type' => 'text/plain']),
'Bild nicht gefunden'
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Media\Entities;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'images')]
class Image
{
public function __construct(
public ?int $id = null,
public string $filename = '',
public string $originalFilename = '',
public string $mimeType = '',
public int $fileSize = 0,
public int $width = 0,
public int $height = 0,
public string $hash = '',
public string $uploadPath = '',
public \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
public ?\DateTimeImmutable $updatedAt = null,
) {}
public function getUploadDirectory(): string
{
return sprintf(
'/uploads/%s/%s/%s',
$this->createdAt->format('Y'),
$this->createdAt->format('m'),
$this->createdAt->format('d')
);
}
public function getFilePathPattern(): string
{
$idStr = str_pad((string)$this->id, 9, '0', STR_PAD_LEFT);
return sprintf(
'%s/%s/%s/%s',
substr($idStr, 0, 3),
substr($idStr, 3, 3),
substr($idStr, 6, 3),
$idStr
);
}
/**
* Generiert eine eindeutige URL-freundliche ID ohne Slashes
*/
public function getUrlId(): string
{
// Kombiniert Datum und ID zu einem eindeutigen String
$dateStr = $this->createdAt->format('Ymd');
$idStr = str_pad((string)$this->id, 8, '0', STR_PAD_LEFT);
return $dateStr . $idStr;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Media\Entities;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'image_variants')]
class ImageVariant
{
public function __construct(
public ?int $id = null,
public int $imageId = 0,
public string $variant = '', // 'thumbnail', 'small', 'medium', 'large'
public string $format = '', // 'jpg', 'webp', 'avif'
public int $width = 0,
public int $height = 0,
public int $fileSize = 0,
public string $filename = '',
public \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
) {}
public function getFullPath(Image $image): string
{
return sprintf(
'%s/%s/%s.%s',
$image->getUploadDirectory(),
$image->getFilePathPattern(),
$this->variant,
$this->format
);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Media\Repositories;
use App\Framework\Database\Connection;
use App\Framework\Database\ConnectionInterface;
use Media\Entities\Image;
class ImageRepository
{
public function __construct(
private ConnectionInterface $db
) {
}
public function save(Image $image): Image
{
$query = "INSERT INTO images "
. "(filename, original_filename, mime_type, file_size, width, height, hash, upload_path, created_at) "
. "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);";
$this->db->execute(
$query,
[
$image->filename,
$image->originalFilename,
$image->mimeType,
$image->fileSize,
$image->width,
$image->height,
$image->hash,
$image->uploadPath,
$image->createdAt->format('Y-m-d H:i:s'),
]
);
$image->id = (int)$this->db->lastInsertId();
return $image;
}
public function update(Image $image): void
{
$query = "UPDATE images SET "
. "filename = ?, original_filename = ?, mime_type = ?, file_size = ?, "
. "width = ?, height = ?, hash = ?, upload_path = ?, updated_at = CURRENT_TIMESTAMP "
. "WHERE id = ?;";
$this->db->execute(
$query,
[
$image->filename,
$image->originalFilename,
$image->mimeType,
$image->fileSize,
$image->width,
$image->height,
$image->hash,
$image->uploadPath,
$image->id,
]
);
}
public function findById(int $id): ?Image
{
$query = "SELECT * FROM images WHERE id = ?;";
$result = $this->db->query($query, [$id])->fetch();
if (null === $result) {
return null;
}
return $this->mapToEntity($result);
}
public function findByHash(string $hash): ?Image
{
$query = "SELECT * FROM images WHERE hash = ?;";
$result = $this->db->query($query, [$hash])->fetch();
if (!$result) {
return null;
}
return $this->mapToEntity($result);
}
private function mapToEntity(array $data): Image
{
return new Image(
id: (int)$data['id'],
filename: $data['filename'],
originalFilename: $data['original_filename'],
mimeType: $data['mime_type'],
fileSize: (int)$data['file_size'],
width: (int)$data['width'],
height: (int)$data['height'],
hash: $data['hash'],
uploadPath: $data['upload_path'],
createdAt: new \DateTimeImmutable($data['created_at']),
updatedAt: $data['updated_at'] ? new \DateTimeImmutable($data['updated_at']) : null,
);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Media\Repositories;
use App\Framework\Database\Connection;
use App\Framework\Database\ConnectionInterface;
use Media\Entities\ImageVariant;
class ImageVariantRepository
{
public function __construct(
private ConnectionInterface $db
) {}
public function save(ImageVariant $variant): ImageVariant
{
$query = "INSERT INTO image_variants "
. "(image_id, variant, format, width, height, file_size, filename, created_at) "
. "VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$this->db->execute(
$query,
[
$variant->imageId,
$variant->variant,
$variant->format,
$variant->width,
$variant->height,
$variant->fileSize,
$variant->filename,
$variant->createdAt->format('Y-m-d H:i:s'),
]
);
$variant->id = (int)$this->db->lastInsertId();
return $variant;
}
public function findByImageId(int $imageId): array
{
$query = "SELECT * FROM image_variants WHERE image_id = ?;";
$results = $this->db->query($query, [$imageId]);
return array_map(fn($data) => $this->mapToEntity($data), $results);
}
public function findByImageIdAndVariant(int $imageId, string $variant, string $format): ?ImageVariant
{
$query = "SELECT * FROM image_variants WHERE image_id = ? AND variant = ? AND format = ?;";
$result = $this->db->query($query, [$imageId, $variant, $format]);
if (empty($result)) {
return null;
}
return $this->mapToEntity($result[0]);
}
private function mapToEntity(array $data): ImageVariant
{
return new ImageVariant(
id: (int)$data['id'],
imageId: (int)$data['image_id'],
variant: $data['variant'],
format: $data['format'],
width: (int)$data['width'],
height: (int)$data['height'],
fileSize: (int)$data['file_size'],
filename: $data['filename'],
createdAt: new \DateTimeImmutable($data['created_at']),
);
}
}

View File

@@ -0,0 +1,265 @@
<?php
namespace Media\Services;
use App\Framework\Core\PathProvider;
use App\Framework\Http\UploadedFile;
use Media\Entities\Image;
use Media\Entities\ImageVariant;
use Media\Repositories\ImageRepository;
use Media\Repositories\ImageVariantRepository;
use function filesize;
use function getimagesize;
use function imageavif;
use function imagecopyresampled;
use function imagecreatefromstring;
use function imagecreatetruecolor;
use function imagedestroy;
use function imagejpeg;
use function imagesx;
use function imagesy;
use function imagewebp;
class ImageService
{
private const array VARIANTS = [
'thumbnail' => ['width' => 150, 'height' => 150, 'crop' => true],
'small' => ['width' => 400, 'height' => 400, 'crop' => false],
'medium' => ['width' => 800, 'height' => 800, 'crop' => false],
'large' => ['width' => 1200, 'height' => 1200, 'crop' => false],
];
private const array FORMATS = ['jpg', 'webp', 'avif'];
public function __construct(
private PathProvider $pathProvider,
private ImageRepository $imageRepository,
private ImageVariantRepository $variantRepository,
) {}
public function uploadImage(UploadedFile $file): Image
{
// Validierung
if (!$file->isValid()) {
throw new \InvalidArgumentException('Ungültige Datei');
}
if (!$this->isValidImageType($file->getMimeType())) {
throw new \InvalidArgumentException('Ungültiger Bildtyp');
}
// Basis-Image-Informationen erfassen
$imageInfo = getimagesize($file->tmpName);
$hash = \hash_file('sha256', $file->tmpName);
// Prüfen ob ein Bild mit gleichem Hash bereits existiert
$existingImage = $this->imageRepository->findByHash($hash);
if ($existingImage) {
return $existingImage; // Duplikat gefunden, vorhandenes Bild zurückgeben
}
// Image Entity erstellen
$image = new Image(
filename: $this->generateSecureFilename($file->name),
originalFilename: $file->name,
mimeType: $file->getMimeType(),
fileSize: $file->size,
width: $imageInfo[0],
height: $imageInfo[1],
hash: $hash,
uploadPath: '',
);
// In Datenbank speichern um ID zu erhalten
$image = $this->imageRepository->save($image);
// Upload-Pfad setzen
$image->uploadPath = $image->getUploadDirectory() . '/' . $image->getFilePathPattern();
$this->imageRepository->update($image);
// Ordnerstruktur erstellen
$fullUploadPath = $this->pathProvider->resolvePath('storage' . $image->uploadPath);
$this->createDirectoryStructure($fullUploadPath);
// Original-Datei speichern
$originalPath = $fullUploadPath . '/original.' . $this->getFileExtension($file->getMimeType());
$file->moveTo($originalPath);
// Varianten erstellen
$this->createImageVariants($image, $originalPath);
// Image in Datenbank aktualisieren
#$this->imageRepository->update($image);
return $image;
}
private function createImageVariants(Image $image, string $originalPath): void
{
foreach (self::VARIANTS as $variantName => $config) {
foreach (self::FORMATS as $format) {
$variant = $this->createImageVariant(
$image,
$originalPath,
$variantName,
$format,
$config
);
// In Datenbank speichern
$this->variantRepository->save($variant);
}
}
}
private function createImageVariant(
Image $image,
string $originalPath,
string $variantName,
string $format,
array $config
): ImageVariant {
$outputPath = $this->pathProvider->resolvePath('storage' . $image->uploadPath . '/' . $variantName . '.' . $format);
// Bild verarbeiten
$resizedImage = $this->resizeImage($originalPath, $config['width'], $config['height'], $config['crop'] ?? false);
$this->saveImageInFormat($resizedImage, $outputPath, $format);
// Neue Dateigröße und Dimensionen ermitteln
$newImageInfo = getimagesize($outputPath);
$fileSize = filesize($outputPath);
return new ImageVariant(
imageId: $image->id,
variant: $variantName,
format: $format,
width: $newImageInfo[0],
height: $newImageInfo[1],
fileSize: $fileSize,
filename: $variantName . '.' . $format
);
}
private function resizeImage(string $sourcePath, int $maxWidth, int $maxHeight, bool $crop = false): \GdImage
{
$sourceImage = imagecreatefromstring(\file_get_contents($sourcePath));
$sourceWidth = imagesx($sourceImage);
$sourceHeight = imagesy($sourceImage);
if ($crop) {
// Crop-Logik für quadratische Thumbnails
$ratio = max($maxWidth / $sourceWidth, $maxHeight / $sourceHeight);
$newWidth = (int)round($sourceWidth * $ratio);
$newHeight = (int)round($sourceHeight * $ratio);
$tempImage = imagecreatetruecolor($newWidth, $newHeight);
imagecopyresampled($tempImage, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $sourceWidth, $sourceHeight);
$finalImage = imagecreatetruecolor($maxWidth, $maxHeight);
$cropX = (int)floor(($newWidth - $maxWidth) / 2);
$cropY = (int)floor(($newHeight - $maxHeight) / 2);
\imagecopy($finalImage, $tempImage, 0, 0, $cropX, $cropY, $maxWidth, $maxHeight);
imagedestroy($tempImage);
} else {
// Proportionales Skalieren
$ratio = min($maxWidth / $sourceWidth, $maxHeight / $sourceHeight);
$newWidth = (int)round($sourceWidth * $ratio);
$newHeight = (int)round($sourceHeight * $ratio);
$finalImage = imagecreatetruecolor($newWidth, $newHeight);
imagecopyresampled($finalImage, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $sourceWidth, $sourceHeight);
}
imagedestroy($sourceImage);
return $finalImage;
}
private function saveImageInFormat(\GdImage $image, string $path, string $format): void
{
switch ($format) {
case 'jpg':
imagejpeg($image, $path, 85);
break;
case 'webp':
imagewebp($image, $path, 85);
break;
case 'avif':
if (\function_exists('imageavif')) {
imageavif($image, $path, 85);
} else {
// Fallback auf WebP wenn AVIF nicht verfügbar
imagewebp($image, $path, 85);
}
break;
}
imagedestroy($image);
}
private function createDirectoryStructure(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
}
private function isValidImageType(string $mimeType): bool
{
return in_array($mimeType, [
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
]);
}
private function getFileExtension(string $mimeType): string
{
return match ($mimeType) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'jpg'
};
}
private function generateSecureFilename(string $originalName): string
{
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
return uniqid('img_', true) . '.' . strtolower($extension);
}
public function getImageUrl(Image $image, string $variant = 'medium', string $format = 'jpg'): string
{
return '/media/' . $image->getUrlId() . '-' . $variant . '.' . $format;
}
public function findById(int $id): ?Image
{
return $this->imageRepository->findById($id);
}
public function getVariants(Image $image): array
{
return $this->variantRepository->findByImageId($image->id);
}
/**
* Löst eine URL-ID zu einem Image-Objekt auf
*/
public function resolveFromUrlId(string $urlId): ?Image
{
// URL-ID format: YYYYMMDDXXXXXXXX (Datum + 8-stellige ID)
if (strlen($urlId) !== 16) {
return null;
}
$idPart = substr($urlId, 8); // Die letzten 8 Zeichen
$id = (int)ltrim($idPart, '0'); // Führende Nullen entfernen
return $this->imageRepository->findById($id);
}
}