Refatoring : Working state
This commit is contained in:
@@ -27,8 +27,12 @@ use App\Category\CategoryRepositoryInterface;
|
|||||||
use App\Category\CategoryService;
|
use App\Category\CategoryService;
|
||||||
use App\Category\CategoryServiceInterface;
|
use App\Category\CategoryServiceInterface;
|
||||||
use App\Category\Infrastructure\PdoCategoryRepository;
|
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\MediaRepository;
|
||||||
use App\Media\MediaRepositoryInterface;
|
use App\Media\MediaRepositoryInterface;
|
||||||
|
use App\Media\Application\MediaApplicationService;
|
||||||
use App\Media\MediaService;
|
use App\Media\MediaService;
|
||||||
use App\Media\MediaServiceInterface;
|
use App\Media\MediaServiceInterface;
|
||||||
use App\Post\Application\PostApplicationService;
|
use App\Post\Application\PostApplicationService;
|
||||||
@@ -73,7 +77,7 @@ return [
|
|||||||
UserServiceInterface::class => autowire(UserApplicationService::class),
|
UserServiceInterface::class => autowire(UserApplicationService::class),
|
||||||
CategoryServiceInterface::class => autowire(CategoryApplicationService::class),
|
CategoryServiceInterface::class => autowire(CategoryApplicationService::class),
|
||||||
CategoryRepositoryInterface::class => autowire(PdoCategoryRepository::class),
|
CategoryRepositoryInterface::class => autowire(PdoCategoryRepository::class),
|
||||||
MediaRepositoryInterface::class => autowire(MediaRepository::class),
|
MediaRepositoryInterface::class => autowire(PdoMediaRepository::class),
|
||||||
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
||||||
UserRepositoryInterface::class => autowire(PdoUserRepository::class),
|
UserRepositoryInterface::class => autowire(PdoUserRepository::class),
|
||||||
LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class),
|
LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class),
|
||||||
@@ -82,6 +86,9 @@ return [
|
|||||||
FlashServiceInterface::class => autowire(FlashService::class),
|
FlashServiceInterface::class => autowire(FlashService::class),
|
||||||
SessionManagerInterface::class => autowire(SessionManager::class),
|
SessionManagerInterface::class => autowire(SessionManager::class),
|
||||||
HtmlSanitizerInterface::class => autowire(HtmlSanitizer::class),
|
HtmlSanitizerInterface::class => autowire(HtmlSanitizer::class),
|
||||||
|
MediaStorageInterface::class => factory(function (): MediaStorageInterface {
|
||||||
|
return new LocalMediaStorage(dirname(__DIR__) . '/public/media');
|
||||||
|
}),
|
||||||
|
|
||||||
// ── Infrastructure ────────────────────────────────────────────────────────
|
// ── Infrastructure ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -148,11 +155,15 @@ return [
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
MediaServiceInterface::class => factory(
|
MediaServiceInterface::class => factory(
|
||||||
function (MediaRepositoryInterface $mediaRepository, PostRepositoryInterface $postRepository): MediaServiceInterface {
|
function (
|
||||||
return new MediaService(
|
MediaRepositoryInterface $mediaRepository,
|
||||||
|
PostRepositoryInterface $postRepository,
|
||||||
|
MediaStorageInterface $mediaStorage,
|
||||||
|
): MediaServiceInterface {
|
||||||
|
return new MediaApplicationService(
|
||||||
mediaRepository: $mediaRepository,
|
mediaRepository: $mediaRepository,
|
||||||
postRepository: $postRepository,
|
postRepository: $postRepository,
|
||||||
uploadDir: dirname(__DIR__) . '/public/media',
|
mediaStorage: $mediaStorage,
|
||||||
uploadUrl: '/media',
|
uploadUrl: '/media',
|
||||||
maxSize: (int) ($_ENV['UPLOAD_MAX_SIZE'] ?? 5 * 1024 * 1024),
|
maxSize: (int) ($_ENV['UPLOAD_MAX_SIZE'] ?? 5 * 1024 * 1024),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Architecture
|
# 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
|
> `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
|
> 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
|
> 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/` |
|
| `PostServiceInterface` | `PostService` | `Post/` |
|
||||||
| `CategoryRepositoryInterface` | `CategoryRepository` | `Category/`|
|
| `CategoryRepositoryInterface` | `CategoryRepository` | `Category/`|
|
||||||
| `CategoryServiceInterface` | `CategoryService` | `Category/`|
|
| `CategoryServiceInterface` | `CategoryService` | `Category/`|
|
||||||
| `MediaRepositoryInterface` | `MediaRepository` | `Media/` |
|
| `MediaRepositoryInterface` | `PdoMediaRepository` | `Media/` |
|
||||||
| `MediaServiceInterface` | `MediaService` | `Media/` |
|
| `MediaServiceInterface` | `MediaApplicationService` | `Media/` |
|
||||||
|
| `MediaStorageInterface` | `LocalMediaStorage` | `Media/` |
|
||||||
| `SessionManagerInterface` | `SessionManager` | `Shared/` |
|
| `SessionManagerInterface` | `SessionManager` | `Shared/` |
|
||||||
| `MailServiceInterface` | `MailService` | `Shared/` |
|
| `MailServiceInterface` | `MailService` | `Shared/` |
|
||||||
| `FlashServiceInterface` | `FlashService` | `Shared/` |
|
| `FlashServiceInterface` | `FlashService` | `Shared/` |
|
||||||
|
|||||||
118
src/Media/Application/MediaApplicationService.php
Normal file
118
src/Media/Application/MediaApplicationService.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media\Application;
|
||||||
|
|
||||||
|
use App\Media\Domain\MediaStorageInterface;
|
||||||
|
use App\Media\Media;
|
||||||
|
use App\Media\MediaRepositoryInterface;
|
||||||
|
use App\Media\MediaServiceInterface;
|
||||||
|
use App\Post\PostRepositoryInterface;
|
||||||
|
use App\Shared\Pagination\PaginatedResult;
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\UploadedFileInterface;
|
||||||
|
|
||||||
|
class MediaApplicationService implements MediaServiceInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MediaRepositoryInterface $mediaRepository,
|
||||||
|
private readonly PostRepositoryInterface $postRepository,
|
||||||
|
private readonly MediaStorageInterface $mediaStorage,
|
||||||
|
private readonly string $uploadUrl,
|
||||||
|
private readonly int $maxSize,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Media[] */
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
return $this->mediaRepository->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return PaginatedResult<Media> */
|
||||||
|
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<Media> */
|
||||||
|
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<int, \App\Post\Post>} */
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Media/Domain/MediaStorageInterface.php
Normal file
17
src/Media/Domain/MediaStorageInterface.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media\Domain;
|
||||||
|
|
||||||
|
use Psr\Http\Message\UploadedFileInterface;
|
||||||
|
|
||||||
|
interface MediaStorageInterface
|
||||||
|
{
|
||||||
|
public function prepareUpload(UploadedFileInterface $uploadedFile, int $maxSize): PreparedMediaUpload;
|
||||||
|
|
||||||
|
public function storePreparedUpload(UploadedFileInterface $uploadedFile, PreparedMediaUpload $preparedUpload): string;
|
||||||
|
|
||||||
|
public function cleanupPreparedUpload(PreparedMediaUpload $preparedUpload): void;
|
||||||
|
|
||||||
|
public function deleteStoredFile(string $filename): void;
|
||||||
|
}
|
||||||
35
src/Media/Domain/PreparedMediaUpload.php
Normal file
35
src/Media/Domain/PreparedMediaUpload.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media\Domain;
|
||||||
|
|
||||||
|
final class PreparedMediaUpload
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $temporaryPath,
|
||||||
|
private readonly string $hash,
|
||||||
|
private readonly string $extension,
|
||||||
|
private readonly bool $copyFromTemporaryPath,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTemporaryPath(): string
|
||||||
|
{
|
||||||
|
return $this->temporaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHash(): string
|
||||||
|
{
|
||||||
|
return $this->hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExtension(): string
|
||||||
|
{
|
||||||
|
return $this->extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldCopyFromTemporaryPath(): bool
|
||||||
|
{
|
||||||
|
return $this->copyFromTemporaryPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/Media/Http/MediaController.php
Normal file
157
src/Media/Http/MediaController.php
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media\Http;
|
||||||
|
|
||||||
|
use App\Media\Exception\FileTooLargeException;
|
||||||
|
use App\Media\Exception\InvalidMimeTypeException;
|
||||||
|
use App\Media\Exception\StorageException;
|
||||||
|
use App\Media\MediaServiceInterface;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class 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<string, mixed> $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<int, \App\Post\Post>}
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/Media/Infrastructure/LocalMediaStorage.php
Normal file
184
src/Media/Infrastructure/LocalMediaStorage.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media\Infrastructure;
|
||||||
|
|
||||||
|
use App\Media\Domain\MediaStorageInterface;
|
||||||
|
use App\Media\Domain\PreparedMediaUpload;
|
||||||
|
use App\Media\Exception\FileTooLargeException;
|
||||||
|
use App\Media\Exception\InvalidMimeTypeException;
|
||||||
|
use App\Media\Exception\StorageException;
|
||||||
|
use Psr\Http\Message\UploadedFileInterface;
|
||||||
|
|
||||||
|
class LocalMediaStorage implements MediaStorageInterface
|
||||||
|
{
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/Media/Infrastructure/PdoMediaRepository.php
Normal file
131
src/Media/Infrastructure/PdoMediaRepository.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media\Infrastructure;
|
||||||
|
|
||||||
|
use App\Media\Media;
|
||||||
|
use App\Media\MediaRepositoryInterface;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class PdoMediaRepository implements MediaRepositoryInterface
|
||||||
|
{
|
||||||
|
private const SELECT = 'SELECT id, filename, url, hash, user_id, created_at FROM media';
|
||||||
|
|
||||||
|
public function __construct(private readonly PDO $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Media[] */
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,135 +3,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Media;
|
namespace App\Media;
|
||||||
|
|
||||||
use App\Media\Exception\FileTooLargeException;
|
/**
|
||||||
use App\Media\Exception\InvalidMimeTypeException;
|
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
|
||||||
use App\Media\Exception\StorageException;
|
* App\Media\Http\MediaController.
|
||||||
use App\Shared\Http\FlashServiceInterface;
|
*/
|
||||||
use App\Shared\Http\SessionManagerInterface;
|
final class MediaController extends Http\MediaController
|
||||||
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
|
|
||||||
{
|
{
|
||||||
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<string, mixed> $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<string, mixed> $usage */
|
|
||||||
$usage = $this->mediaService->getUsageSummary($media, 3);
|
|
||||||
$usageCount = isset($usage['count']) && is_int($usage['count']) ? $usage['count'] : 0;
|
|
||||||
/** @var array<int, \App\Post\Post> $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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,123 +3,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Media;
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,260 +3,29 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Media;
|
namespace App\Media;
|
||||||
|
|
||||||
use App\Media\Exception\FileTooLargeException;
|
use App\Media\Application\MediaApplicationService;
|
||||||
use App\Media\Exception\InvalidMimeTypeException;
|
use App\Media\Infrastructure\LocalMediaStorage;
|
||||||
use App\Media\Exception\StorageException;
|
|
||||||
use App\Post\PostRepositoryInterface;
|
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(
|
public function __construct(
|
||||||
private readonly MediaRepositoryInterface $mediaRepository,
|
MediaRepositoryInterface $mediaRepository,
|
||||||
private readonly PostRepositoryInterface $postRepository,
|
PostRepositoryInterface $postRepository,
|
||||||
private readonly string $uploadDir,
|
string $uploadDir,
|
||||||
private readonly string $uploadUrl,
|
string $uploadUrl,
|
||||||
private readonly int $maxSize,
|
int $maxSize,
|
||||||
) {
|
) {
|
||||||
}
|
parent::__construct(
|
||||||
|
mediaRepository: $mediaRepository,
|
||||||
public function findAll(): array
|
postRepository: $postRepository,
|
||||||
{
|
mediaStorage: new LocalMediaStorage($uploadDir),
|
||||||
return $this->mediaRepository->findAll();
|
uploadUrl: $uploadUrl,
|
||||||
}
|
maxSize: $maxSize,
|
||||||
|
|
||||||
/**
|
|
||||||
* @return PaginatedResult<Media>
|
|
||||||
*/
|
|
||||||
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<Media>
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user