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'] . ')', ]; } }