first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Media\Exception;
/**
* Exception levée lorsqu'un fichier uploadé dépasse la taille autorisée.
*/
final class FileTooLargeException extends \InvalidArgumentException
{
public function __construct(int $maxBytes)
{
$mb = round($maxBytes / 1024 / 1024);
parent::__construct("Fichier trop volumineux (maximum {$mb} Mo)");
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Media\Exception;
/**
* Exception levée lorsqu'un fichier uploadé a un type MIME non autorisé.
*/
final class InvalidMimeTypeException extends \InvalidArgumentException
{
public function __construct(string $mime)
{
parent::__construct("Type de fichier non autorisé : {$mime} (JPEG, PNG, GIF ou WebP uniquement)");
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Media\Exception;
/**
* Exception levée lorsqu'une opération sur le système de fichiers échoue
* (création de répertoire, copie ou déplacement d'un fichier converti).
*/
final class StorageException extends \RuntimeException
{
}

130
src/Media/Media.php Normal file
View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Media;
use App\Shared\Util\DateParser;
use DateTime;
/**
* Modèle représentant un fichier média uploadé.
*
* Encapsule les métadonnées d'un fichier stocké dans public/media/.
* Le fichier physique est identifié par son nom de stockage opaque (filename),
* distinct du nom affiché à l'utilisateur.
*
* Le hash SHA-256 du contenu permet la détection des doublons à l'upload :
* si un fichier identique a déjà été uploadé, son URL est retournée
* directement sans créer un second fichier sur disque.
*/
final class Media
{
/**
* @var DateTime Date d'upload — toujours non nulle après construction
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
*/
private readonly DateTime $createdAt;
/**
* @param int $id Identifiant en base (0 pour un nouveau média)
* @param string $filename Nom de stockage opaque sur disque (ex: "a3f8c1d2_9f33.jpg")
* @param string $url URL publique d'accès au fichier (ex: "/media/a3f8c1d2_9f33.jpg")
* @param string $hash Hash SHA-256 du contenu binaire du fichier
* @param int|null $userId Identifiant de l'auteur (null si le compte a été supprimé)
* @param DateTime|null $createdAt Date d'upload (défaut : maintenant)
*/
public function __construct(
private readonly int $id,
private readonly string $filename,
private readonly string $url,
private readonly string $hash,
private readonly ?int $userId,
?DateTime $createdAt = null,
) {
$this->createdAt = $createdAt ?? new DateTime();
}
/**
* Crée une instance depuis un tableau associatif (ligne de base de données).
*
* @param array<string, mixed> $data Données issues de la base de données
*
* @return self L'instance hydratée
*/
public static function fromArray(array $data): self
{
return new self(
id: (int) ($data['id'] ?? 0),
filename: (string) ($data['filename'] ?? ''),
url: (string) ($data['url'] ?? ''),
hash: (string) ($data['hash'] ?? ''),
userId: isset($data['user_id']) ? (int) $data['user_id'] : null,
createdAt: DateParser::parse($data['created_at'] ?? null),
);
}
/**
* Retourne l'identifiant du média.
*
* @return int L'identifiant en base (0 si non encore persisté)
*/
public function getId(): int
{
return $this->id;
}
/**
* Retourne le nom de stockage du fichier sur disque.
*
* Ce nom est opaque et généré aléatoirement à l'upload.
* Il ne doit pas être affiché à l'utilisateur tel quel.
*
* @return string Le nom de fichier sur disque
*/
public function getFilename(): string
{
return $this->filename;
}
/**
* Retourne l'URL publique d'accès au fichier.
*
* @return string L'URL publique (ex: "/media/a3f8c1d2_9f33.jpg")
*/
public function getUrl(): string
{
return $this->url;
}
/**
* Retourne le hash SHA-256 du contenu binaire du fichier.
*
* Utilisé pour la détection des doublons à l'upload.
*
* @return string Le hash hexadécimal SHA-256
*/
public function getHash(): string
{
return $this->hash;
}
/**
* Retourne l'identifiant de l'auteur du média.
*
* @return int|null L'identifiant de l'auteur, ou null si le compte a été supprimé
*/
public function getUserId(): ?int
{
return $this->userId;
}
/**
* Retourne la date d'upload du fichier.
*
* @return DateTime La date d'upload
*/
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,180 @@
<?php
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\MediaServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
/**
* Contrôleur du domaine Media.
*
* Gère deux responsabilités HTTP :
* 1. Upload d'images depuis l'éditeur Trumbowyg (réponse JSON)
* 2. Administration des médias uploadés (liste, suppression)
*
* Toute la logique métier (validation, conversion WebP, déduplication,
* stockage disque) est déléguée à MediaService via MediaServiceInterface.
*
* Droits d'accès :
* - Upload : tout utilisateur connecté
* - Liste : chaque utilisateur voit uniquement ses propres médias ;
* l'administrateur et l'éditeur voient tous les médias
* - Suppression : propriétaire du média, éditeur ou administrateur
*/
final class MediaController
{
/**
* @param Twig $view Moteur de templates Twig
* @param MediaServiceInterface $mediaService Service de gestion des médias
* @param FlashServiceInterface $flash Service de messages flash
* @param SessionManagerInterface $sessionManager Gestionnaire de session
*/
public function __construct(
private readonly Twig $view,
private readonly MediaServiceInterface $mediaService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
) {
}
/**
* Affiche la page de gestion des médias.
*
* Un éditeur ou un administrateur voit tous les médias.
* Un utilisateur avec le rôle 'user' voit uniquement ses propres médias.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La page HTML de gestion des médias
*/
public function index(Request $req, Response $res): Response
{
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
$userId = $this->sessionManager->getUserId();
$media = $isAdmin
? $this->mediaService->findAll()
: $this->mediaService->findByUserId((int) $userId);
return $this->view->render($res, 'admin/media/index.twig', [
'media' => $media,
'error' => $this->flash->get('media_error'),
'success' => $this->flash->get('media_success'),
]);
}
/**
* Traite l'upload d'une image envoyée par le plugin Trumbowyg Upload.
*
* Vérifie la présence et l'absence d'erreur PSR-7 avant de déléguer
* à MediaService. Les erreurs métier (taille, MIME, stockage) sont
* converties en réponses JSON avec le code HTTP approprié.
*
* @param Request $req La requête HTTP multipart contenant le champ "image"
* @param Response $res La réponse HTTP
*
* @return Response JSON {"success": true, "file": "/media/..."} ou {"error": "..."}
*/
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);
}
/**
* Supprime un média (fichier sur disque + entrée en base).
*
* Vérifie que l'utilisateur connecté est le propriétaire du média
* ou un administrateur / éditeur. Redirige avec un message flash dans les deux cas.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Paramètres de route (id)
*
* @return Response Redirection vers /admin/media
*/
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);
}
$this->mediaService->delete($media);
$this->flash->set('media_success', 'Fichier supprimé');
return $res->withHeader('Location', '/admin/media')->withStatus(302);
}
/**
* Retourne une réponse JSON de succès avec l'URL du fichier uploadé.
*
* @param Response $res La réponse HTTP
* @param string $fileUrl L'URL publique du fichier
*
* @return Response La réponse JSON {"success": true, "file": "..."}
*/
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);
}
/**
* Retourne une réponse JSON d'erreur.
*
* @param Response $res La réponse HTTP
* @param string $message Le message d'erreur
* @param int $status Le code HTTP de l'erreur
*
* @return Response La réponse JSON {"error": "..."}
*/
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);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Media;
use PDO;
/**
* Dépôt pour la persistance des médias uploadés.
*
* Responsabilité unique : exécuter les requêtes SQL liées à la table `media`
* et retourner des instances de Media hydratées.
*/
final class MediaRepository implements MediaRepositoryInterface
{
/**
* Fragment SELECT commun à toutes les requêtes de lecture.
*/
private const SELECT = 'SELECT id, filename, url, hash, user_id, created_at FROM media';
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db)
{
}
/**
* Retourne tous les médias triés du plus récent au plus ancien.
*
* @return Media[] La liste complète des médias
*/
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é.');
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Media::fromArray($row), $rows);
}
/**
* Retourne tous les médias appartenant à un utilisateur donné,
* triés du plus récent au plus ancien.
*
* @param int $userId Identifiant de l'utilisateur
*
* @return Media[] La liste des médias de cet utilisateur
*/
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]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Media::fromArray($row), $rows);
}
/**
* Trouve un média par son identifiant.
*
* @param int $id Identifiant du média
*
* @return Media|null Le média trouvé, ou null s'il n'existe pas
*/
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;
}
/**
* Trouve un média par le hash SHA-256 de son contenu.
*
* Utilisé pour la détection des doublons à l'upload.
*
* @param string $hash Hash SHA-256 du contenu binaire du fichier
*
* @return Media|null Le média existant, ou null si aucun doublon
*/
public function findByHash(string $hash): ?Media
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash');
$stmt->execute([':hash' => $hash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Media::fromArray($row) : null;
}
/**
* Persiste un nouveau média en base de données.
*
* @param Media $media Le média à créer
*
* @return int L'identifiant généré par la base de données
*/
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();
}
/**
* Supprime un média de la base de données.
*
* La suppression du fichier physique sur disque est à la charge de l'appelant.
*
* @param int $id Identifiant du média à supprimer
*
* @return int Nombre de lignes supprimées (0 si le média n'existe plus)
*/
public function delete(int $id): int
{
$stmt = $this->db->prepare('DELETE FROM media WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->rowCount();
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Media;
/**
* Contrat de persistance des médias uploadés.
*
* Découple les contrôleurs de l'implémentation concrète PDO/SQLite,
* facilitant les mocks dans les tests unitaires.
*/
interface MediaRepositoryInterface
{
/**
* Retourne tous les médias triés du plus récent au plus ancien.
*
* @return Media[]
*/
public function findAll(): array;
/**
* Retourne tous les médias d'un utilisateur donné.
*
* @param int $userId Identifiant de l'utilisateur
*
* @return Media[]
*/
public function findByUserId(int $userId): array;
/**
* Trouve un média par son identifiant.
*
* @param int $id Identifiant du média
*
* @return Media|null Le média trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?Media;
/**
* Trouve un média par le hash SHA-256 de son contenu (déduplication).
*
* @param string $hash Hash SHA-256 du contenu binaire du fichier
*
* @return Media|null Le média existant, ou null si aucun doublon
*/
public function findByHash(string $hash): ?Media;
/**
* Persiste un nouveau média en base de données.
*
* @param Media $media Le média à créer
*
* @return int L'identifiant généré par la base de données
*/
public function create(Media $media): int;
/**
* Supprime un média de la base de données.
*
* @param int $id Identifiant du média à supprimer
*
* @return int Nombre de lignes supprimées
*/
public function delete(int $id): int;
}

228
src/Media/MediaService.php Normal file
View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Media;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Exception\StorageException;
use PDOException;
use Psr\Http\Message\UploadedFileInterface;
final class MediaService 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 string $uploadDir,
private readonly string $uploadUrl,
private readonly int $maxSize,
) {
}
public function findAll(): array
{
return $this->mediaRepository->findAll();
}
public function findByUserId(int $userId): array
{
return $this->mediaRepository->findByUserId($userId);
}
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->findByHash($hash);
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) {
$duplicate = $this->mediaRepository->findByHash($hash);
if ($duplicate !== null) {
@unlink($destPath);
return $duplicate->getUrl();
}
@unlink($destPath);
throw $e;
}
return $url;
}
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;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Media;
use Psr\Http\Message\UploadedFileInterface;
/**
* Contrat du service de gestion des médias.
*
* Permet de mocker le service dans les tests unitaires sans dépendre
* de la classe concrète finale MediaService.
*/
interface MediaServiceInterface
{
/**
* Retourne tous les médias triés du plus récent au plus ancien.
*
* @return Media[]
*/
public function findAll(): array;
/**
* Retourne tous les médias appartenant à un utilisateur donné.
*
* @param int $userId Identifiant de l'utilisateur
*
* @return Media[]
*/
public function findByUserId(int $userId): array;
/**
* Trouve un média par son identifiant.
*
* @param int $id Identifiant du média
*
* @return Media|null Le média trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?Media;
/**
* Valide, convertit, déduplique et stocke un fichier uploadé.
*
* @param UploadedFileInterface $uploadedFile Le fichier PSR-7 reçu
* @param int $userId Identifiant de l'auteur
*
* @return string L'URL publique du fichier stocké
*
* @throws \App\Media\Exception\FileTooLargeException Si la taille dépasse le maximum autorisé
* @throws \App\Media\Exception\InvalidMimeTypeException Si le type MIME n'est pas autorisé
* @throws \App\Media\Exception\StorageException Si une opération disque échoue
*/
public function store(UploadedFileInterface $uploadedFile, int $userId): string;
/**
* Supprime un média : fichier physique sur disque et entrée en base.
*
* @param Media $media Le média à supprimer
* @return void
*/
public function delete(Media $media): void;
}