243 lines
7.9 KiB
PHP
243 lines
7.9 KiB
PHP
<?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()
|
||
{
|
||
parent::__construct(Base::instance()->get('DB'), 'media');
|
||
}
|
||
|
||
public static function bootstrap(DB\SQL $db): void
|
||
{
|
||
if ($db->schema('media', null, 0)) {
|
||
return;
|
||
}
|
||
|
||
$db->exec('CREATE TABLE 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 idx_media_created_at ON media(created_at 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 findByIds(array $ids): array
|
||
{
|
||
$ids = array_filter(array_unique(array_map('intval', $ids)));
|
||
if ($ids === []) {
|
||
return [];
|
||
}
|
||
|
||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||
$results = $this->find(["id IN ($placeholders)", ...array_values($ids)]);
|
||
|
||
$map = [];
|
||
foreach ($results ?: [] as $m) {
|
||
$row = $this->decorate($m->cast());
|
||
$map[$row['id']] = $row;
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
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());
|
||
}
|
||
|
||
// Traite le fichier temporaire déposé par Web::receive() et publie l'image.
|
||
public function upload(string $srcPath, string $originalName = ''): int
|
||
{
|
||
$target = null;
|
||
$committed = false; // Contrôle le nettoyage de $target dans finally.
|
||
|
||
try {
|
||
$meta = self::inspectUpload($srcPath);
|
||
|
||
// F3 Image : load() utilise imagecreatefromstring + imagesavealpha.
|
||
$img = new \Image();
|
||
if (!$img->load(file_get_contents($srcPath))) {
|
||
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
|
||
}
|
||
|
||
// PNG/WebP → PNG (préserve la transparence), JPG → JPG.
|
||
$isJpeg = ($meta['mime'] === 'image/jpeg');
|
||
$extension = $isJpeg ? 'jpg' : 'png';
|
||
|
||
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
|
||
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
||
$target = app_public_media_dir() . '/' . $fileName;
|
||
|
||
// dump() appelle image{format}($data, NULL, $quality).
|
||
$binary = $isJpeg ? $img->dump('jpeg', 85) : $img->dump('png', 6);
|
||
if ($binary === '' || file_put_contents($target, $binary) === false) {
|
||
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
||
}
|
||
|
||
$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();
|
||
$committed = true;
|
||
} catch (Throwable $e) {
|
||
$this->db->rollback();
|
||
throw $e;
|
||
}
|
||
|
||
return (int) $this->get('id');
|
||
} catch (Throwable $e) {
|
||
throw $e instanceof RuntimeException ? $e : new RuntimeException('Impossible d\'enregistrer cette image.');
|
||
} finally {
|
||
// Le destructeur de F3 Image libère la ressource GD.
|
||
if (is_file($srcPath)) {
|
||
@unlink($srcPath);
|
||
}
|
||
if (!$committed && $target !== null && is_file($target)) {
|
||
@unlink($target);
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
{
|
||
$this->load(['id = ?', $id]);
|
||
if ($this->dry()) {
|
||
throw new RuntimeException('Image introuvable.');
|
||
}
|
||
|
||
$path = app_public_media_dir() . '/' . $this->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,
|
||
];
|
||
}
|
||
|
||
// 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));
|
||
}
|
||
|
||
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'],
|
||
'url' => app_media_url((string) $row['file_name']),
|
||
'markdown' => '',
|
||
];
|
||
}
|
||
}
|