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(); $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); } } } 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 = 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.'); } } 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 { $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' => $this->mediaUrl((string) $row['file_name']), 'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')', ]; } }