Less home code more F3

This commit is contained in:
julien
2026-03-30 00:00:03 +02:00
parent d71cf304a9
commit fac7f60190
30 changed files with 818 additions and 1552 deletions

View File

@@ -4,10 +4,6 @@ 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');
@@ -30,49 +26,27 @@ class Media extends DB\SQL\Mapper
$db->exec('CREATE INDEX idx_media_created_at ON media(created_at DESC)');
}
public function paginateLibrary(int $page = 1, int $perPage = 24): array
public function page(int $page, int $perPage): array
{
$result = $this->paginate(
max(0, $page - 1),
$perPage,
null,
['order' => 'created_at DESC, id DESC']
);
$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,
'items' => array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []),
'pagination' => [
'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => max(1, (int) ($result['count'] ?: 1)),
],
];
}
public function latest(int $limit = 60): array
public function recent(int $limit): array
{
return array_map(
fn(self $m): array => $this->decorate($m->cast()),
fn(self $row): array => $this->decorate($row->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) {
@@ -89,64 +63,50 @@ class Media extends DB\SQL\Mapper
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
public function upload(string $path, 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();
$f3 = Base::instance();
if (!$img->load($f3->read($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 = rtrim((string) $f3->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
// dump() appelle image{format}($data, NULL, $quality).
$binary = $isJpeg ? $img->dump('jpeg', 85) : $img->dump('png', 6);
if ($binary === '' || $f3->write($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);
}
if (!is_file($path)) {
throw new RuntimeException('Fichier image introuvable.');
}
$info = @getimagesize($path);
if (!is_array($info)) {
@unlink($path);
throw new RuntimeException('Fichier image invalide.');
}
$mime = strtolower((string) ($info['mime'] ?? ''));
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
default => null,
};
if ($extension === null) {
@unlink($path);
throw new RuntimeException('Format non supporté. Utilise JPG ou PNG.');
}
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
if (!@rename($path, $target)) {
if (!@copy($path, $target)) {
@unlink($path);
throw new RuntimeException('Impossible denregistrer cette image.');
}
@unlink($path);
}
$this->reset();
$this->file_name = $fileName;
$this->alt = $this->altFromName($originalName);
$this->width = (int) $info[0];
$this->height = (int) $info[1];
$this->created_at = app_now();
$this->save();
return (int) $this->id;
}
public function updateAlt(int $id, string $alt): void
@@ -160,7 +120,7 @@ class Media extends DB\SQL\Mapper
$this->save();
}
public function delete(int $id): void
public function deleteById(int $id): void
{
$this->load(['id = ?', $id]);
if ($this->dry()) {
@@ -168,85 +128,33 @@ class Media extends DB\SQL\Mapper
}
$path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $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.');
$this->erase();
if (is_file($path)) {
@unlink($path);
}
}
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 mediaUrl(string $fileName): string
{
$f3 = Base::instance();
$base = rtrim((string) $f3->get('BASE'), '/');
$prefix = '/' . trim((string) $f3->get('paths.media_base'), '/');
return $base . $prefix . '/' . rawurlencode($fileName);
}
private function decorate(array $row): array
{
$file = (string) $row['file_name'];
$alt = (string) $row['alt'];
return [
'id' => (int) $row['id'],
'file_name' => (string) $row['file_name'],
'file_name' => $file,
'alt' => $alt,
'width' => (int) $row['width'],
'height' => (int) $row['height'],
'created_at' => (string) $row['created_at'],
'url' => $this->mediaUrl((string) $row['file_name']),
'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')',
'url' => rtrim((string) Base::instance()->get('BASE'), '/') . rtrim((string) Base::instance()->get('paths.media_base'), '/') . '/' . rawurlencode($file),
'markdown' => '![' . $alt . '](media:' . $file . ')',
];
}
private function altFromName(string $name): string
{
$name = trim(pathinfo($name, PATHINFO_FILENAME));
$name = preg_replace('/[-_]+/', ' ', $name) ?: '';
return trim($name);
}
}