Files
f3-simple-blog/app/Models/Media.php
2026-03-27 20:14:11 +01:00

281 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
class Media extends DB\SQL\Mapper
{
private const MAX_WIDTH = 8000;
private const MAX_HEIGHT = 8000;
private const MAX_PIXELS = 40_000_000;
public function __construct(DB\SQL $db)
{
parent::__construct($db, 'media');
}
public static function bootstrap(DB\SQL $db): void
{
$db->exec('CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL UNIQUE,
alt TEXT NOT NULL DEFAULT \'\',
width INTEGER NOT NULL,
height INTEGER NOT NULL,
created_at TEXT NOT NULL
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_media_created_at ON media(created_at DESC)');
}
public function all(): array
{
return array_map(
fn(self $m): array => $this->decorate($m->cast()),
$this->find(null, ['order' => 'created_at DESC, id DESC']) ?: []
);
}
public function paginateLibrary(int $page = 1, int $perPage = 24): array
{
$result = $this->paginate(
max(0, $page - 1),
$perPage,
null,
['order' => 'created_at DESC, id DESC']
);
return [
'items' => array_map(fn(self $m): array => $this->decorate($m->cast()), $result['subset']),
'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => $result['count'] ?: 1,
];
}
public function latest(int $limit = 60): array
{
return array_map(
fn(self $m): array => $this->decorate($m->cast()),
$this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: []
);
}
public function countAll(): int
{
return $this->count();
}
public function findById(int $id): ?array
{
if ($id <= 0) {
return null;
}
$this->load(['id = ?', $id]);
return $this->dry() ? null : $this->decorate($this->cast());
}
public function findByFileName(string $fileName): ?array
{
$this->load(['file_name = ?', $fileName]);
return $this->dry() ? null : $this->decorate($this->cast());
}
// Reçoit le chemin absolu déposé par Web::receive() et le nom d'origine
// pour dériver un texte alternatif lisible.
public function upload(string $srcPath, string $originalName = ''): int
{
$target = null;
$image = null;
try {
$meta = self::inspectUpload($srcPath);
$image = self::openImageResource($srcPath, $meta['mime']);
[$format, $extension] = self::targetFormat($meta['mime']);
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = app_public_media_dir() . '/' . $fileName;
self::writeImage($image, $target, $format);
$this->db->begin();
try {
$this->reset();
$this->file_name = $fileName;
$this->alt = $originalName !== '' ? self::altFromFilename($originalName) : '';
$this->width = $meta['width'];
$this->height = $meta['height'];
$this->created_at = app_now();
$this->save();
$this->db->commit();
} catch (Throwable $e) {
$this->db->rollback();
if ($target !== null && is_file($target)) {
@unlink($target);
}
throw $e;
}
return (int) $this->get('id');
} catch (RuntimeException $e) {
throw $e;
} catch (Throwable) {
throw new RuntimeException('Impossible d\'enregistrer cette image.');
} finally {
if ($image instanceof GdImage) {
imagedestroy($image);
}
if (is_file($srcPath)) {
@unlink($srcPath);
}
}
}
public function updateAlt(int $id, string $alt): void
{
$this->load(['id = ?', $id]);
if ($this->dry()) {
throw new RuntimeException('Image introuvable.');
}
$this->alt = trim($alt);
$this->save();
}
public function delete(int $id): void
{
$item = $this->findById($id);
if ($item === null) {
throw new RuntimeException('Image introuvable.');
}
if ($this->isUsed($item)) {
throw new RuntimeException('Cette image est encore utilisée par un article.');
}
$path = app_public_media_dir() . '/' . $item['file_name'];
$this->db->begin();
try {
$this->erase();
if (is_file($path) && !unlink($path)) {
throw new RuntimeException('Impossible de supprimer le fichier image.');
}
$this->db->commit();
} catch (Throwable $e) {
$this->db->rollback();
throw $e instanceof RuntimeException ? $e : new RuntimeException('Suppression impossible.');
}
}
private static function inspectUpload(string $srcPath): array
{
if (!is_file($srcPath)) {
throw new RuntimeException('Fichier image introuvable.');
}
$info = @getimagesize($srcPath);
if (!is_array($info)) {
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
}
$width = (int) ($info[0] ?? 0);
$height = (int) ($info[1] ?? 0);
$mime = strtolower((string) ($info['mime'] ?? ''));
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
throw new RuntimeException('Format non supporté. Utilise JPG, PNG ou WebP.');
}
if ($width <= 0 || $height <= 0) {
throw new RuntimeException('Dimensions image invalides.');
}
if ($width > self::MAX_WIDTH || $height > self::MAX_HEIGHT || ($width * $height) > self::MAX_PIXELS) {
throw new RuntimeException('Image trop grande. Limite : 8000 × 8000 px et 40 mégapixels.');
}
return [
'width' => $width,
'height' => $height,
'mime' => $mime,
];
}
private static function openImageResource(string $srcPath, string $mime): GdImage
{
$image = match ($mime) {
'image/jpeg' => @imagecreatefromjpeg($srcPath),
'image/png' => @imagecreatefrompng($srcPath),
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($srcPath) : false,
default => false,
};
if (!$image instanceof GdImage) {
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
}
return $image;
}
private static function targetFormat(string $mime): array
{
return match ($mime) {
'image/jpeg' => ['jpeg', 'jpg'],
'image/png', 'image/webp' => ['png', 'png'],
default => throw new RuntimeException('Format non supporté. Utilise JPG, PNG ou WebP.'),
};
}
private static function writeImage(GdImage $image, string $target, string $format): void
{
if ($format === 'png') {
if (function_exists('imagepalettetotruecolor') && !imageistruecolor($image)) {
imagepalettetotruecolor($image);
}
imagealphablending($image, false);
imagesavealpha($image, true);
$written = @imagepng($image, $target, 6);
} else {
$written = @imagejpeg($image, $target, 85);
}
if ($written !== true || !is_file($target)) {
throw new RuntimeException('Impossible d\'enregistrer cette image.');
}
}
// Dérive un texte alternatif lisible depuis le nom de fichier d'origine.
private static function altFromFilename(string $filename): string
{
$name = pathinfo($filename, PATHINFO_FILENAME);
$name = trim((string) preg_replace('/[-_]+/', ' ', $name));
// mb_ucfirst() n'existe qu'en PHP 8.4 — on l'émule.
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
}
// Une seule requête SQL pour les deux cas d'utilisation (couverture et body).
private function isUsed(array $item): bool
{
return $this->db->exec(
'SELECT 1 FROM posts WHERE cover_media_id = ? OR body_markdown LIKE ? LIMIT 1',
[$item['id'], '%media:' . $item['file_name'] . '%']
) !== [];
}
private function decorate(array $row): array
{
$alt = (string) $row['alt'];
return [
'id' => (int) $row['id'],
'file_name' => (string) $row['file_name'],
'alt' => $alt,
'width' => (int) $row['width'],
'height' => (int) $row['height'],
'created_at' => (string) $row['created_at'],
'created_at_label' => app_format_datetime_fr((string) $row['created_at']),
'url' => app_media_url((string) $row['file_name']),
'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')',
];
}
}