diff --git a/config/container.php b/config/container.php index 98d1be4..df0d70d 100644 --- a/config/container.php +++ b/config/container.php @@ -27,8 +27,12 @@ use App\Category\CategoryRepositoryInterface; use App\Category\CategoryService; use App\Category\CategoryServiceInterface; use App\Category\Infrastructure\PdoCategoryRepository; +use App\Media\Domain\MediaStorageInterface; +use App\Media\Infrastructure\LocalMediaStorage; +use App\Media\Infrastructure\PdoMediaRepository; use App\Media\MediaRepository; use App\Media\MediaRepositoryInterface; +use App\Media\Application\MediaApplicationService; use App\Media\MediaService; use App\Media\MediaServiceInterface; use App\Post\Application\PostApplicationService; @@ -73,7 +77,7 @@ return [ UserServiceInterface::class => autowire(UserApplicationService::class), CategoryServiceInterface::class => autowire(CategoryApplicationService::class), CategoryRepositoryInterface::class => autowire(PdoCategoryRepository::class), - MediaRepositoryInterface::class => autowire(MediaRepository::class), + MediaRepositoryInterface::class => autowire(PdoMediaRepository::class), PostRepositoryInterface::class => autowire(PdoPostRepository::class), UserRepositoryInterface::class => autowire(PdoUserRepository::class), LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class), @@ -82,6 +86,9 @@ return [ FlashServiceInterface::class => autowire(FlashService::class), SessionManagerInterface::class => autowire(SessionManager::class), HtmlSanitizerInterface::class => autowire(HtmlSanitizer::class), + MediaStorageInterface::class => factory(function (): MediaStorageInterface { + return new LocalMediaStorage(dirname(__DIR__) . '/public/media'); + }), // ── Infrastructure ──────────────────────────────────────────────────────── @@ -148,11 +155,15 @@ return [ }), MediaServiceInterface::class => factory( - function (MediaRepositoryInterface $mediaRepository, PostRepositoryInterface $postRepository): MediaServiceInterface { - return new MediaService( + function ( + MediaRepositoryInterface $mediaRepository, + PostRepositoryInterface $postRepository, + MediaStorageInterface $mediaStorage, + ): MediaServiceInterface { + return new MediaApplicationService( mediaRepository: $mediaRepository, postRepository: $postRepository, - uploadDir: dirname(__DIR__) . '/public/media', + mediaStorage: $mediaStorage, uploadUrl: '/media', maxSize: (int) ($_ENV['UPLOAD_MAX_SIZE'] ?? 5 * 1024 * 1024), ); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa03a8a..31e2514 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,8 +1,8 @@ # Architecture -> **Refactor DDD légère — lots 1 et 2** +> **Refactor DDD légère — lots 1 à 3** > -> `Post/`, `Category/` et `User/` introduisent maintenant une organisation verticale +> `Post/`, `Category/`, `User/` et `Media/` introduisent maintenant une organisation verticale > `Application / Infrastructure / Http / Domain` pour alléger la lecture et préparer > un découpage plus fin par cas d'usage. Les classes historiques à la racine du domaine > sont conservées comme **ponts de compatibilité** afin de préserver les routes, le conteneur @@ -93,8 +93,9 @@ final class PostService | `PostServiceInterface` | `PostService` | `Post/` | | `CategoryRepositoryInterface` | `CategoryRepository` | `Category/`| | `CategoryServiceInterface` | `CategoryService` | `Category/`| -| `MediaRepositoryInterface` | `MediaRepository` | `Media/` | -| `MediaServiceInterface` | `MediaService` | `Media/` | +| `MediaRepositoryInterface` | `PdoMediaRepository` | `Media/` | +| `MediaServiceInterface` | `MediaApplicationService` | `Media/` | +| `MediaStorageInterface` | `LocalMediaStorage` | `Media/` | | `SessionManagerInterface` | `SessionManager` | `Shared/` | | `MailServiceInterface` | `MailService` | `Shared/` | | `FlashServiceInterface` | `FlashService` | `Shared/` | diff --git a/src/Media/Application/MediaApplicationService.php b/src/Media/Application/MediaApplicationService.php new file mode 100644 index 0000000..92e2890 --- /dev/null +++ b/src/Media/Application/MediaApplicationService.php @@ -0,0 +1,118 @@ +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, + ); + } + + /** @return Media[] */ + 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 + { + $preparedUpload = $this->mediaStorage->prepareUpload($uploadedFile, $this->maxSize); + $hash = $preparedUpload->getHash(); + $existing = $this->mediaRepository->findByHashForUser($hash, $userId); + + if ($existing !== null) { + $this->mediaStorage->cleanupPreparedUpload($preparedUpload); + return $existing->getUrl(); + } + + $filename = $this->mediaStorage->storePreparedUpload($uploadedFile, $preparedUpload); + $url = rtrim($this->uploadUrl, '/') . '/' . $filename; + $media = new Media(0, $filename, $url, $hash, $userId); + + try { + $this->mediaRepository->create($media); + } catch (PDOException $e) { + $this->mediaStorage->deleteStoredFile($filename); + + $duplicate = $this->mediaRepository->findByHashForUser($hash, $userId); + if ($duplicate !== null) { + return $duplicate->getUrl(); + } + + throw $e; + } + + return $url; + } + + /** @return array{count:int, posts:array} */ + 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 + { + $this->mediaStorage->deleteStoredFile($media->getFilename()); + $this->mediaRepository->delete($media->getId()); + } +} diff --git a/src/Media/Domain/MediaStorageInterface.php b/src/Media/Domain/MediaStorageInterface.php new file mode 100644 index 0000000..edd89d8 --- /dev/null +++ b/src/Media/Domain/MediaStorageInterface.php @@ -0,0 +1,17 @@ +temporaryPath; + } + + public function getHash(): string + { + return $this->hash; + } + + public function getExtension(): string + { + return $this->extension; + } + + public function shouldCopyFromTemporaryPath(): bool + { + return $this->copyFromTemporaryPath; + } +} diff --git a/src/Media/Http/MediaController.php b/src/Media/Http/MediaController.php new file mode 100644 index 0000000..34fd34b --- /dev/null +++ b/src/Media/Http/MediaController.php @@ -0,0 +1,157 @@ +sessionManager->isAdmin() || $this->sessionManager->isEditor(); + $userId = $this->sessionManager->getUserId(); + $page = PaginationPresenter::resolvePage($req->getQueryParams()); + + $paginated = $isAdmin + ? $this->mediaService->findPaginated($page, self::PER_PAGE) + : $this->mediaService->findByUserIdPaginated((int) $userId, $page, self::PER_PAGE); + + $usageByMediaId = []; + foreach ($paginated->getItems() as $item) { + $usageByMediaId[$item->getId()] = $this->mediaService->getUsageSummary($item, 5); + } + + return $this->view->render($res, 'admin/media/index.twig', [ + 'media' => $paginated->getItems(), + 'mediaUsage' => $usageByMediaId, + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'error' => $this->flash->get('media_error'), + 'success' => $this->flash->get('media_success'), + ]); + } + + public function upload(Request $req, Response $res): Response + { + $files = $req->getUploadedFiles(); + $uploadedFile = $files['image'] ?? null; + + if ($uploadedFile === null || $uploadedFile->getError() !== UPLOAD_ERR_OK) { + return $this->jsonError($res, "Aucun fichier reçu ou erreur d'upload", 400); + } + + try { + $url = $this->mediaService->store($uploadedFile, $this->sessionManager->getUserId() ?? 0); + } catch (FileTooLargeException $e) { + return $this->jsonError($res, $e->getMessage(), 413); + } catch (InvalidMimeTypeException $e) { + return $this->jsonError($res, $e->getMessage(), 415); + } catch (StorageException $e) { + return $this->jsonError($res, $e->getMessage(), 500); + } + + return $this->jsonSuccess($res, $url); + } + + /** + * @param array $args + */ + public function delete(Request $req, Response $res, array $args): Response + { + $id = (int) ($args['id'] ?? 0); + $media = $this->mediaService->findById($id); + + if ($media === null) { + $this->flash->set('media_error', 'Fichier introuvable'); + return $res->withHeader('Location', '/admin/media')->withStatus(302); + } + + $userId = $this->sessionManager->getUserId(); + $isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor(); + + if (!$isAdmin && $media->getUserId() !== $userId) { + $this->flash->set('media_error', "Vous n'êtes pas autorisé à supprimer ce fichier"); + return $res->withHeader('Location', '/admin/media')->withStatus(302); + } + + $usageRaw = $this->mediaService->getUsageSummary($media, 3); + $usage = self::normalizeUsageSummary($usageRaw); + $usageCount = $usage['count']; + $usagePosts = $usage['posts']; + + if ($usageCount > 0) { + $titles = array_map( + static fn ($post) => '« ' . $post->getTitle() . ' »', + $usagePosts + ); + $details = $titles === [] ? '' : ' Utilisé dans : ' . implode(', ', $titles) . '.'; + $this->flash->set( + 'media_error', + 'Ce média est encore référencé dans ' . $usageCount . ' article(s) et ne peut pas être supprimé.' . $details + ); + + return $res->withHeader('Location', '/admin/media')->withStatus(302); + } + + $this->mediaService->delete($media); + $this->flash->set('media_success', 'Fichier supprimé'); + + return $res->withHeader('Location', '/admin/media')->withStatus(302); + } + + + /** + * @param mixed $usage + * @return array{count:int, posts:array} + */ + private static function normalizeUsageSummary(mixed $usage): array + { + if (!is_array($usage)) { + return ['count' => 0, 'posts' => []]; + } + + $count = isset($usage['count']) && is_int($usage['count']) ? $usage['count'] : 0; + $posts = isset($usage['posts']) && is_array($usage['posts']) ? $usage['posts'] : []; + + return [ + 'count' => $count, + 'posts' => $posts, + ]; + } + + private function jsonSuccess(Response $res, string $fileUrl): Response + { + $res->getBody()->write(json_encode([ + 'success' => true, + 'file' => $fileUrl, + ], JSON_THROW_ON_ERROR)); + + return $res->withHeader('Content-Type', 'application/json')->withStatus(200); + } + + private function jsonError(Response $res, string $message, int $status): Response + { + $res->getBody()->write(json_encode(['error' => $message], JSON_THROW_ON_ERROR)); + + return $res->withHeader('Content-Type', 'application/json')->withStatus($status); + } +} diff --git a/src/Media/Infrastructure/LocalMediaStorage.php b/src/Media/Infrastructure/LocalMediaStorage.php new file mode 100644 index 0000000..2578946 --- /dev/null +++ b/src/Media/Infrastructure/LocalMediaStorage.php @@ -0,0 +1,184 @@ + '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 string $uploadDir) + { + } + + public function prepareUpload(UploadedFileInterface $uploadedFile, int $maxSize): PreparedMediaUpload + { + $size = $uploadedFile->getSize(); + + if (!is_int($size)) { + throw new StorageException('Impossible de déterminer la taille du fichier uploadé'); + } + + if ($size > $maxSize) { + throw new FileTooLargeException($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); + + $copyFromTemporaryPath = false; + if (in_array($mime, self::WEBP_CONVERTIBLE, true)) { + $convertedPath = $this->convertToWebP($tmpPath); + if ($convertedPath !== null) { + $tmpPath = $convertedPath; + $copyFromTemporaryPath = true; + } + } + + $rawHash = hash_file('sha256', $tmpPath); + + if ($rawHash === false) { + if ($copyFromTemporaryPath) { + @unlink($tmpPath); + } + throw new StorageException('Impossible de calculer le hash du fichier'); + } + + $extension = $copyFromTemporaryPath + ? self::MIME_EXTENSIONS[$mime] + : self::MIME_EXTENSIONS_FALLBACK[$mime]; + + return new PreparedMediaUpload($tmpPath, $rawHash, $extension, $copyFromTemporaryPath); + } + + public function storePreparedUpload(UploadedFileInterface $uploadedFile, PreparedMediaUpload $preparedUpload): string + { + if (!is_dir($this->uploadDir) && !@mkdir($this->uploadDir, 0755, true)) { + throw new StorageException("Impossible de créer le répertoire d'upload"); + } + + $filename = uniqid('', true) . '_' . bin2hex(random_bytes(4)) . '.' . $preparedUpload->getExtension(); + $destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; + + if ($preparedUpload->shouldCopyFromTemporaryPath()) { + if (!copy($preparedUpload->getTemporaryPath(), $destPath)) { + $this->cleanupPreparedUpload($preparedUpload); + throw new StorageException('Impossible de déplacer le fichier converti'); + } + $this->cleanupPreparedUpload($preparedUpload); + return $filename; + } + + $uploadedFile->moveTo($destPath); + + return $filename; + } + + public function cleanupPreparedUpload(PreparedMediaUpload $preparedUpload): void + { + if ($preparedUpload->shouldCopyFromTemporaryPath() && file_exists($preparedUpload->getTemporaryPath())) { + @unlink($preparedUpload->getTemporaryPath()); + } + } + + public function deleteStoredFile(string $filename): void + { + $filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename; + + if (file_exists($filePath)) { + @unlink($filePath); + } + } + + 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('imagecreatefromjpeg') || !function_exists('imagecreatefrompng') || !function_exists('imagewebp')) { + return null; + } + + $imageInfo = @getimagesize($sourcePath); + if ($imageInfo === false) { + return null; + } + + $mime = $imageInfo['mime']; + $src = match ($mime) { + 'image/jpeg' => @imagecreatefromjpeg($sourcePath), + 'image/png' => @imagecreatefrompng($sourcePath), + default => null, + }; + + if ($src === false || $src === null) { + return null; + } + + $tmpWebp = tempnam(sys_get_temp_dir(), 'media_webp_'); + if ($tmpWebp === false) { + imagedestroy($src); + return null; + } + + $ok = @imagewebp($src, $tmpWebp, 82); + imagedestroy($src); + + if ($ok !== true) { + @unlink($tmpWebp); + return null; + } + + return $tmpWebp; + } +} diff --git a/src/Media/Infrastructure/PdoMediaRepository.php b/src/Media/Infrastructure/PdoMediaRepository.php new file mode 100644 index 0000000..1b4d660 --- /dev/null +++ b/src/Media/Infrastructure/PdoMediaRepository.php @@ -0,0 +1,131 @@ +db->query(self::SELECT . ' ORDER BY id DESC'); + if ($stmt === false) { + throw new \RuntimeException('La requête SELECT sur media a échoué.'); + } + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return Media[] */ + public function findPage(int $limit, int $offset): array + { + $stmt = $this->db->prepare(self::SELECT . ' ORDER BY id DESC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countAll(): int + { + $stmt = $this->db->query('SELECT COUNT(*) FROM media'); + if ($stmt === false) { + throw new \RuntimeException('La requête COUNT sur media a échoué.'); + } + + return (int) ($stmt->fetchColumn() ?: 0); + } + + /** @return Media[] */ + public function findByUserId(int $userId): array + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC'); + $stmt->execute([':user_id' => $userId]); + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return Media[] */ + public function findByUserPage(int $userId, int $limit, int $offset): array + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC LIMIT :limit OFFSET :offset'); + $stmt->bindValue(':user_id', $userId, PDO::PARAM_INT); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + public function countByUserId(int $userId): int + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM media WHERE user_id = :user_id'); + $stmt->execute([':user_id' => $userId]); + + return (int) $stmt->fetchColumn(); + } + + public function findById(int $id): ?Media + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE id = :id'); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Media::fromArray($row) : null; + } + + public function findByHash(string $hash): ?Media + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash ORDER BY id DESC LIMIT 1'); + $stmt->execute([':hash' => $hash]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Media::fromArray($row) : null; + } + + public function findByHashForUser(string $hash, int $userId): ?Media + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash AND user_id = :user_id ORDER BY id DESC LIMIT 1'); + $stmt->execute([':hash' => $hash, ':user_id' => $userId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? Media::fromArray($row) : null; + } + + public function create(Media $media): int + { + $stmt = $this->db->prepare( + 'INSERT INTO media (filename, url, hash, user_id, created_at) + VALUES (:filename, :url, :hash, :user_id, :created_at)' + ); + + $stmt->execute([ + ':filename' => $media->getFilename(), + ':url' => $media->getUrl(), + ':hash' => $media->getHash(), + ':user_id' => $media->getUserId(), + ':created_at' => date('Y-m-d H:i:s'), + ]); + + return (int) $this->db->lastInsertId(); + } + + public function delete(int $id): int + { + $stmt = $this->db->prepare('DELETE FROM media WHERE id = :id'); + $stmt->execute([':id' => $id]); + + return $stmt->rowCount(); + } +} diff --git a/src/Media/MediaController.php b/src/Media/MediaController.php index 664c515..57a091a 100644 --- a/src/Media/MediaController.php +++ b/src/Media/MediaController.php @@ -3,135 +3,10 @@ declare(strict_types=1); namespace App\Media; -use App\Media\Exception\FileTooLargeException; -use App\Media\Exception\InvalidMimeTypeException; -use App\Media\Exception\StorageException; -use App\Shared\Http\FlashServiceInterface; -use App\Shared\Http\SessionManagerInterface; -use App\Shared\Pagination\PaginationPresenter; -use Psr\Http\Message\ResponseInterface as Response; -use Psr\Http\Message\ServerRequestInterface as Request; -use Slim\Views\Twig; - -final class MediaController +/** + * Pont de compatibilité : le contrôleur HTTP principal vit désormais dans + * App\Media\Http\MediaController. + */ +final class MediaController extends Http\MediaController { - private const PER_PAGE = 12; - - public function __construct( - private readonly Twig $view, - private readonly MediaServiceInterface $mediaService, - private readonly FlashServiceInterface $flash, - private readonly SessionManagerInterface $sessionManager, - ) { - } - - public function index(Request $req, Response $res): Response - { - $isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor(); - $userId = $this->sessionManager->getUserId(); - $page = PaginationPresenter::resolvePage($req->getQueryParams()); - - $paginated = $isAdmin - ? $this->mediaService->findPaginated($page, self::PER_PAGE) - : $this->mediaService->findByUserIdPaginated((int) $userId, $page, self::PER_PAGE); - - $usageByMediaId = []; - foreach ($paginated->getItems() as $item) { - $usageByMediaId[$item->getId()] = $this->mediaService->getUsageSummary($item, 5); - } - - return $this->view->render($res, 'admin/media/index.twig', [ - 'media' => $paginated->getItems(), - 'mediaUsage' => $usageByMediaId, - 'pagination' => PaginationPresenter::fromRequest($req, $paginated), - 'error' => $this->flash->get('media_error'), - 'success' => $this->flash->get('media_success'), - ]); - } - - public function upload(Request $req, Response $res): Response - { - $files = $req->getUploadedFiles(); - $uploadedFile = $files['image'] ?? null; - - if ($uploadedFile === null || $uploadedFile->getError() !== UPLOAD_ERR_OK) { - return $this->jsonError($res, "Aucun fichier reçu ou erreur d'upload", 400); - } - - try { - $url = $this->mediaService->store($uploadedFile, $this->sessionManager->getUserId() ?? 0); - } catch (FileTooLargeException $e) { - return $this->jsonError($res, $e->getMessage(), 413); - } catch (InvalidMimeTypeException $e) { - return $this->jsonError($res, $e->getMessage(), 415); - } catch (StorageException $e) { - return $this->jsonError($res, $e->getMessage(), 500); - } - - return $this->jsonSuccess($res, $url); - } - - /** - * @param array $args - */ - public function delete(Request $req, Response $res, array $args): Response - { - $id = (int) ($args['id'] ?? 0); - $media = $this->mediaService->findById($id); - - if ($media === null) { - $this->flash->set('media_error', 'Fichier introuvable'); - return $res->withHeader('Location', '/admin/media')->withStatus(302); - } - - $userId = $this->sessionManager->getUserId(); - $isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor(); - - if (!$isAdmin && $media->getUserId() !== $userId) { - $this->flash->set('media_error', "Vous n'êtes pas autorisé à supprimer ce fichier"); - return $res->withHeader('Location', '/admin/media')->withStatus(302); - } - - /** @var array $usage */ - $usage = $this->mediaService->getUsageSummary($media, 3); - $usageCount = isset($usage['count']) && is_int($usage['count']) ? $usage['count'] : 0; - /** @var array $usagePosts */ - $usagePosts = isset($usage['posts']) && is_array($usage['posts']) ? $usage['posts'] : []; - - if ($usageCount > 0) { - $titles = array_map( - static fn ($post) => '« ' . $post->getTitle() . ' »', - $usagePosts - ); - $details = $titles === [] ? '' : ' Utilisé dans : ' . implode(', ', $titles) . '.'; - $this->flash->set( - 'media_error', - 'Ce média est encore référencé dans ' . $usageCount . ' article(s) et ne peut pas être supprimé.' . $details - ); - - return $res->withHeader('Location', '/admin/media')->withStatus(302); - } - - $this->mediaService->delete($media); - $this->flash->set('media_success', 'Fichier supprimé'); - - return $res->withHeader('Location', '/admin/media')->withStatus(302); - } - - private function jsonSuccess(Response $res, string $fileUrl): Response - { - $res->getBody()->write(json_encode([ - 'success' => true, - 'file' => $fileUrl, - ], JSON_THROW_ON_ERROR)); - - return $res->withHeader('Content-Type', 'application/json')->withStatus(200); - } - - private function jsonError(Response $res, string $message, int $status): Response - { - $res->getBody()->write(json_encode(['error' => $message], JSON_THROW_ON_ERROR)); - - return $res->withHeader('Content-Type', 'application/json')->withStatus($status); - } } diff --git a/src/Media/MediaRepository.php b/src/Media/MediaRepository.php index 8f80c05..b709b87 100644 --- a/src/Media/MediaRepository.php +++ b/src/Media/MediaRepository.php @@ -3,123 +3,12 @@ declare(strict_types=1); namespace App\Media; -use PDO; +use App\Media\Infrastructure\PdoMediaRepository; -final class MediaRepository implements MediaRepositoryInterface +/** + * Pont de compatibilité : l'implémentation PDO principale vit désormais dans + * App\Media\Infrastructure\PdoMediaRepository. + */ +final class MediaRepository extends PdoMediaRepository implements MediaRepositoryInterface { - private const SELECT = 'SELECT id, filename, url, hash, user_id, created_at FROM media'; - - public function __construct(private readonly PDO $db) - { - } - - public function findAll(): array - { - $stmt = $this->db->query(self::SELECT . ' ORDER BY id DESC'); - if ($stmt === false) { - throw new \RuntimeException('La requête SELECT sur media a échoué.'); - } - - return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); - } - - public function findPage(int $limit, int $offset): array - { - $stmt = $this->db->prepare(self::SELECT . ' ORDER BY id DESC LIMIT :limit OFFSET :offset'); - $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); - $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); - $stmt->execute(); - - return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); - } - - public function countAll(): int - { - $stmt = $this->db->query('SELECT COUNT(*) FROM media'); - if ($stmt === false) { - throw new \RuntimeException('La requête COUNT sur media a échoué.'); - } - - return (int) ($stmt->fetchColumn() ?: 0); - } - - public function findByUserId(int $userId): array - { - $stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC'); - $stmt->execute([':user_id' => $userId]); - - return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); - } - - public function findByUserPage(int $userId, int $limit, int $offset): array - { - $stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC LIMIT :limit OFFSET :offset'); - $stmt->bindValue(':user_id', $userId, PDO::PARAM_INT); - $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); - $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); - $stmt->execute(); - - return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC)); - } - - public function countByUserId(int $userId): int - { - $stmt = $this->db->prepare('SELECT COUNT(*) FROM media WHERE user_id = :user_id'); - $stmt->execute([':user_id' => $userId]); - - return (int) $stmt->fetchColumn(); - } - - public function findById(int $id): ?Media - { - $stmt = $this->db->prepare(self::SELECT . ' WHERE id = :id'); - $stmt->execute([':id' => $id]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - return $row ? Media::fromArray($row) : null; - } - - public function findByHash(string $hash): ?Media - { - $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash ORDER BY id DESC LIMIT 1'); - $stmt->execute([':hash' => $hash]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - return $row ? Media::fromArray($row) : null; - } - - public function findByHashForUser(string $hash, int $userId): ?Media - { - $stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash AND user_id = :user_id ORDER BY id DESC LIMIT 1'); - $stmt->execute([':hash' => $hash, ':user_id' => $userId]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - return $row ? Media::fromArray($row) : null; - } - - public function create(Media $media): int - { - $stmt = $this->db->prepare( - 'INSERT INTO media (filename, url, hash, user_id, created_at) - VALUES (:filename, :url, :hash, :user_id, :created_at)' - ); - - $stmt->execute([ - ':filename' => $media->getFilename(), - ':url' => $media->getUrl(), - ':hash' => $media->getHash(), - ':user_id' => $media->getUserId(), - ':created_at' => date('Y-m-d H:i:s'), - ]); - - return (int) $this->db->lastInsertId(); - } - - public function delete(int $id): int - { - $stmt = $this->db->prepare('DELETE FROM media WHERE id = :id'); - $stmt->execute([':id' => $id]); - - return $stmt->rowCount(); - } } diff --git a/src/Media/MediaService.php b/src/Media/MediaService.php index 1648540..8e42ac3 100644 --- a/src/Media/MediaService.php +++ b/src/Media/MediaService.php @@ -3,260 +3,29 @@ declare(strict_types=1); namespace App\Media; -use App\Media\Exception\FileTooLargeException; -use App\Media\Exception\InvalidMimeTypeException; -use App\Media\Exception\StorageException; +use App\Media\Application\MediaApplicationService; +use App\Media\Infrastructure\LocalMediaStorage; use App\Post\PostRepositoryInterface; -use App\Shared\Pagination\PaginatedResult; -use PDOException; -use Psr\Http\Message\UploadedFileInterface; -final class MediaService implements MediaServiceInterface +/** + * Pont de compatibilité : l'implémentation métier principale vit désormais dans + * App\Media\Application\MediaApplicationService. + */ +final class MediaService extends MediaApplicationService implements MediaServiceInterface { - private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; - private const WEBP_CONVERTIBLE = ['image/jpeg', 'image/png']; - private const MIME_EXTENSIONS = [ - 'image/jpeg' => '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, + MediaRepositoryInterface $mediaRepository, + PostRepositoryInterface $postRepository, + string $uploadDir, + string $uploadUrl, + 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, + parent::__construct( + mediaRepository: $mediaRepository, + postRepository: $postRepository, + mediaStorage: new LocalMediaStorage($uploadDir), + uploadUrl: $uploadUrl, + maxSize: $maxSize, ); } - - 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; - } }