'webp', 'image/png' => 'webp', 'image/gif' => 'gif', 'image/webp' => 'webp', ]; private const MIME_EXTENSIONS_FALLBACK = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', ]; private const MAX_PIXELS = 40000000; public function __construct( private readonly MediaRepositoryInterface $mediaRepository, private readonly PostRepositoryInterface $postRepository, private readonly string $uploadDir, private readonly string $uploadUrl, private readonly int $maxSize, ) { } public function findAll(): array { return $this->mediaRepository->findAll(); } /** * @return PaginatedResult */ public function findPaginated(int $page, int $perPage): PaginatedResult { $page = max(1, $page); $total = $this->mediaRepository->countAll(); $offset = ($page - 1) * $perPage; return new PaginatedResult( $this->mediaRepository->findPage($perPage, $offset), $total, $page, $perPage, ); } public function findByUserId(int $userId): array { return $this->mediaRepository->findByUserId($userId); } /** * @return PaginatedResult */ public function findByUserIdPaginated(int $userId, int $page, int $perPage): PaginatedResult { $page = max(1, $page); $total = $this->mediaRepository->countByUserId($userId); $offset = ($page - 1) * $perPage; return new PaginatedResult( $this->mediaRepository->findByUserPage($userId, $perPage, $offset), $total, $page, $perPage, ); } public function findById(int $id): ?Media { return $this->mediaRepository->findById($id); } public function store(UploadedFileInterface $uploadedFile, int $userId): string { $size = $uploadedFile->getSize(); if (!is_int($size)) { throw new StorageException('Impossible de déterminer la taille du fichier uploadé'); } if ($size > $this->maxSize) { throw new FileTooLargeException($this->maxSize); } $tmpPathRaw = $uploadedFile->getStream()->getMetadata('uri'); if (!is_string($tmpPathRaw) || $tmpPathRaw === '') { throw new StorageException('Impossible de localiser le fichier temporaire uploadé'); } $tmpPath = $tmpPathRaw; $finfo = new \finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($tmpPath); if ($mime === false || !in_array($mime, self::ALLOWED_MIME_TYPES, true)) { throw new InvalidMimeTypeException($mime === false ? 'unknown' : $mime); } $this->assertReasonableDimensions($tmpPath); $converted = false; if (in_array($mime, self::WEBP_CONVERTIBLE, true)) { $convertedPath = $this->convertToWebP($tmpPath); if ($convertedPath !== null) { $tmpPath = $convertedPath; $converted = true; } } $rawHash = hash_file('sha256', $tmpPath); if ($rawHash === false) { if ($converted) { @unlink($tmpPath); } throw new StorageException('Impossible de calculer le hash du fichier'); } $hash = $rawHash; $existing = $this->mediaRepository->findByHashForUser($hash, $userId); if ($existing !== null) { if ($converted) { @unlink($tmpPath); } return $existing->getUrl(); } if (!is_dir($this->uploadDir) && !@mkdir($this->uploadDir, 0755, true)) { throw new StorageException("Impossible de créer le répertoire d'upload"); } $extension = $converted ? self::MIME_EXTENSIONS[$mime] : self::MIME_EXTENSIONS_FALLBACK[$mime]; $filename = uniqid('', true) . '_' . bin2hex(random_bytes(4)) . '.' . $extension; $destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; if ($converted) { if (!copy($tmpPath, $destPath)) { @unlink($tmpPath); throw new StorageException('Impossible de déplacer le fichier converti'); } @unlink($tmpPath); } else { $uploadedFile->moveTo($destPath); } $url = $this->uploadUrl . '/' . $filename; $media = new Media(0, $filename, $url, $hash, $userId); try { $this->mediaRepository->create($media); } catch (PDOException $e) { @unlink($destPath); $duplicate = $this->mediaRepository->findByHashForUser($hash, $userId); if ($duplicate !== null) { return $duplicate->getUrl(); } throw $e; } return $url; } public function getUsageSummary(Media $media, int $sampleLimit = 5): array { return [ 'count' => $this->postRepository->countByEmbeddedMediaUrl($media->getUrl()), 'posts' => $this->postRepository->findByEmbeddedMediaUrl($media->getUrl(), $sampleLimit), ]; } public function delete(Media $media): void { $filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $media->getFilename(); if (file_exists($filePath)) { @unlink($filePath); } $this->mediaRepository->delete($media->getId()); } private function assertReasonableDimensions(string $path): void { $size = @getimagesize($path); if ($size === false) { throw new StorageException('Impossible de lire les dimensions de l\'image'); } [$width, $height] = $size; if ($width <= 0 || $height <= 0) { throw new StorageException('Dimensions d\'image invalides'); } if (($width * $height) > self::MAX_PIXELS) { throw new StorageException('Image trop volumineuse en dimensions pour être traitée'); } } private function convertToWebP(string $sourcePath): ?string { if (!function_exists('imagewebp')) { return null; } $data = file_get_contents($sourcePath); if ($data === false || $data === '') { return null; } $image = imagecreatefromstring($data); if ($image === false) { return null; } imagealphablending($image, false); imagesavealpha($image, true); $tmpFile = tempnam(sys_get_temp_dir(), 'slim_webp_'); if ($tmpFile === false) { imagedestroy($image); return null; } @unlink($tmpFile); $tmpPath = $tmpFile . '.webp'; if (!imagewebp($image, $tmpPath, 85)) { imagedestroy($image); @unlink($tmpPath); return null; } imagedestroy($image); return $tmpPath; } }