From 68c547ddcb059ae870dadb1404642c1e9c223726 Mon Sep 17 00:00:00 2001 From: julien Date: Fri, 27 Mar 2026 20:14:11 +0100 Subject: [PATCH] More robust --- README.md | 34 ++-- app/Controllers/AssetController.php | 2 - app/Controllers/AuthController.php | 18 +- app/Controllers/BaseController.php | 43 ++++- app/Controllers/DashboardController.php | 13 +- app/Controllers/MediaController.php | 33 ++-- app/Controllers/PostController.php | 54 +++--- app/Controllers/SiteController.php | 10 +- app/Models/Media.php | 194 ++++++++++++++++---- app/Services/MarkdownService.php | 226 +++++++++++++++--------- app/Views/admin/dashboard.html | 4 +- app/Views/admin/media.html | 12 +- app/Views/admin/post_form.html | 7 +- app/Views/auth/login.html | 2 +- app/Views/errors/error.html | 4 +- app/Views/layout.html | 4 +- app/Views/partials/media_card.html | 4 +- app/Views/partials/nav_items.html | 8 +- app/Views/partials/pagination.html | 4 +- app/Views/partials/post_card.html | 2 +- app/Views/partials/post_card_admin.html | 9 +- app/Views/partials/site_brand.html | 2 +- app/config.ini | 6 +- compose.yaml | 1 - composer.lock | 2 +- 25 files changed, 474 insertions(+), 224 deletions(-) diff --git a/README.md b/README.md index a3ddb0b..a68426d 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,15 @@ project/ ├── public/ │ ├── assets/ # Sources CSS/JS (servis minifiés via /min/@file) │ └── uploads/ -│ └── media/ # Images converties en PNG (sans perte) +│ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG) └── tmp/ ├── cache/ # Cache F3 (pages publiques + assets minifiés) - └── uploads/ # Transit Web::receive() — vidé après chaque upload + └── uploads/ # Transit Web::receive() — nettoyé après chaque upload ``` ## Philosophie des dossiers runtime -Le projet garde la même logique en local et dans Docker : +Le projet sépare les données persistantes du runtime jetable : - `tmp/` = runtime temporaire, recréable - `db/` = base SQLite persistante @@ -42,14 +42,14 @@ Autrement dit, `tmp/` peut être vidé sans perte métier. Les données à sauve ## Fonctionnalités F3 utilisées -- **Routage nommé** — `config.ini [routes]`, `$f3->alias()` -- **Cache HTTP + serveur** — `$f3->expire()` sur les routes publiques, `Cache::reset('.url')` à la mutation +- **Routage nommé** — `config.ini [routes]`, filtre `alias` dans les templates, `reroute('@route')` dans les contrôleurs +- **Cache HTTP + serveur** — TTL déclarés directement dans `[routes]`, `Cache::reset('.url')` à la mutation - **Assets minifiés** — `Web::minify()` via `AssetController` (`GET /min/@file`) -- **Upload** — `Web::receive()` avec callback de validation taille -- **Images** — `Image` (chargement, conversion PNG, dimensions), `Base::write()` pour l'écriture -- **Markdown** — `Markdown::instance()->convert()` +- **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle +- **Images** — normalisation des médias via GD (`JPG` conservé, `PNG/WebP` convertis en `PNG` pour préserver la transparence) +- **Markdown** — `Markdown::instance()->convert()` + reconstruction DOM en liste blanche - **Slugs** — `Web::instance()->slug()` -- **Session** — `$f3->set('JAR', …)`, token CSRF dans `SESSION.csrf_token` +- **Session** — `$f3->set('JAR', …)`, hooks `beforeRoute()` sur les contrôleurs protégés, token CSRF créé seulement sur les vues qui contiennent des formulaires - **Logging** — `Log` de F3 avec fallback `file_put_contents` - **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()` @@ -115,7 +115,7 @@ cp config.local.ini.example config.local.ini docker compose up -d --build ``` -Docker monte les mêmes dossiers runtime que le développement local. Seuls les répertoires persistants restent séparés de `tmp/`. +Docker ne monte que les dossiers persistants (`db/`, `logs/`, `public/uploads/media/`) et laisse `tmp/` dans le conteneur pour qu'il reste réellement éphémère. Si `config.local.ini` n'existe pas, le conteneur démarre avec les valeurs par défaut de `app/config.ini`. @@ -128,6 +128,20 @@ docker compose exec app php scripts/create-admin.php admin # mot de passe : 10 caractères minimum ``` +## Cache public et navigation + +Les pages publiques (`/` et `/posts/@slug`) restent cacheables parce que leur rendu n'accède ni à la session, ni au token CSRF. La navigation publique affiche donc un lien statique vers la connexion / l'administration, tandis que les vues d'administration restent session-aware. + +## Médias et limites d'upload + +- Formats acceptés à l'entrée : `JPG`, `PNG`, `WebP` +- Taille max du fichier reçu : `10 Mo` +- Dimensions max : `8000 × 8000 px` +- Limite de surface : `40 mégapixels` +- Sortie publiée : `JPG` pour les sources JPEG, `PNG` pour les sources PNG/WebP + +La médiathèque admin est paginée et le picker dans l'éditeur charge seulement les images les plus récentes pour éviter de charger toute la bibliothèque en mémoire à chaque formulaire. + ## Reverse proxy Caddy ### Caddy sur le même hôte diff --git a/app/Controllers/AssetController.php b/app/Controllers/AssetController.php index f410b79..32b6567 100644 --- a/app/Controllers/AssetController.php +++ b/app/Controllers/AssetController.php @@ -18,8 +18,6 @@ class AssetController extends BaseController return; } - $this->f3->expire(86400); // 24 h côté navigateur - echo Web::instance()->minify( $file, self::ALLOWED[$file], diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 1ed2ca4..0044c84 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -4,15 +4,19 @@ declare(strict_types=1); class AuthController extends BaseController { + public function beforeRoute(): void + { + $this->disableCache(); + } + public function show(): void { if ($this->currentUser() !== null) { - $this->f3->reroute($this->f3->alias('dashboard')); + $this->f3->reroute('@dashboard'); return; } - $this->f3->expire(0); - $this->render('auth/login.html', ['pageTitle' => 'Connexion']); + $this->renderSession('auth/login.html', ['pageTitle' => 'Connexion'], true); } public function login(): void @@ -26,22 +30,24 @@ class AuthController extends BaseController if ($user === null || !password_verify($password, $user['password_hash'])) { usleep(1_500_000); // 1,5 s — ralentit le brute-force $this->flash('error', 'Identifiants invalides.'); - $this->f3->reroute($this->f3->alias('login')); + $this->f3->reroute('@login'); return; } session_regenerate_id(true); $this->f3->set('SESSION.user_id', $user['id']); + $this->refreshCsrfToken(); $this->flash('success', 'Connexion réussie.'); - $this->f3->reroute($this->f3->alias('dashboard')); + $this->f3->reroute('@dashboard'); } public function logout(): void { $this->verifyCsrf(); $this->f3->clear('SESSION.user_id'); + $this->f3->clear('SESSION.csrf_token'); session_regenerate_id(true); $this->flash('success', 'Déconnexion effectuée.'); - $this->f3->reroute($this->f3->alias('home')); + $this->f3->reroute('@login'); } } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 811c758..1619281 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -13,13 +13,31 @@ abstract class BaseController $this->db = $this->f3->get('DB'); } - protected function render(string $view, array $data = []): void + protected function renderPublic(string $view, array $data = []): void { + $user = $this->currentUser(); + $this->f3->mset($data + [ - 'view' => $view, + 'view' => $view, + 'currentUser' => $user, + 'flash' => array_key_exists('flash', $data) && is_array($data['flash']) ? $data['flash'] : null, + 'csrfToken' => $user !== null ? $this->csrfToken() : null, + ]); + + echo Template::instance()->render('layout.html'); + } + + protected function renderSession(string $view, array $data = [], bool $withCsrf = false): void + { + $flash = array_key_exists('flash', $data) && is_array($data['flash']) + ? $data['flash'] + : $this->pullFlash(); + + $this->f3->mset($data + [ + 'view' => $view, 'currentUser' => $this->currentUser(), - 'flash' => $this->pullFlash(), - 'csrfToken' => $this->csrfToken(), + 'flash' => $flash, + 'csrfToken' => $withCsrf ? $this->csrfToken() : null, ]); echo Template::instance()->render('layout.html'); @@ -38,12 +56,16 @@ abstract class BaseController } $this->flash('error', 'Connecte-toi pour continuer.'); - $this->f3->reroute($this->f3->alias('login')); + $this->f3->reroute('@login'); + } + + protected function disableCache(): void + { + $this->f3->expire(0); } protected function csrfToken(): string { - // Génère un token CSRF et le stocke en session au premier appel. $token = (string) ($this->f3->get('SESSION.csrf_token') ?? ''); if ($token === '') { $token = bin2hex(random_bytes(32)); @@ -53,10 +75,17 @@ abstract class BaseController return $token; } + protected function refreshCsrfToken(): string + { + $token = bin2hex(random_bytes(32)); + $this->f3->set('SESSION.csrf_token', $token); + return $token; + } + protected function verifyCsrf(): void { $submitted = (string) ($this->f3->get('POST.csrf_token') ?? ''); - $expected = (string) ($this->f3->get('SESSION.csrf_token') ?? ''); + $expected = (string) ($this->f3->get('SESSION.csrf_token') ?? ''); if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) { return; diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php index beefc39..713ef8b 100644 --- a/app/Controllers/DashboardController.php +++ b/app/Controllers/DashboardController.php @@ -4,19 +4,22 @@ declare(strict_types=1); class DashboardController extends BaseController { - public function index(): void + public function beforeRoute(): void { $this->requireAuth(); - $this->f3->expire(0); + $this->disableCache(); + } + public function index(): void + { $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); $result = (new Post($this->db))->paginateList($page, 24); - $this->render('admin/dashboard.html', [ + $this->renderSession('admin/dashboard.html', [ 'pageTitle' => 'Tableau de bord', 'posts' => $result['posts'], 'pagination' => $result, - 'paginationBase' => $this->f3->alias('dashboard'), - ]); + 'paginationAlias' => 'dashboard', + ], true); } } diff --git a/app/Controllers/MediaController.php b/app/Controllers/MediaController.php index e8a0fdd..f25b4c7 100644 --- a/app/Controllers/MediaController.php +++ b/app/Controllers/MediaController.php @@ -5,21 +5,29 @@ declare(strict_types=1); class MediaController extends BaseController { private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo + private const PER_PAGE = 24; + + public function beforeRoute(): void + { + $this->requireAuth(); + $this->disableCache(); + } public function index(): void { - $this->requireAuth(); - $this->f3->expire(0); + $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); + $result = (new Media($this->db))->paginateLibrary($page, self::PER_PAGE); - $this->render('admin/media.html', [ + $this->renderSession('admin/media.html', [ 'pageTitle' => 'Médiathèque', - 'items' => (new Media($this->db))->all(), - ]); + 'items' => $result['items'], + 'pagination' => $result, + 'paginationAlias' => 'media_index', + ], true); } public function upload(): void { - $this->requireAuth(); $this->verifyCsrf(); try { @@ -27,7 +35,8 @@ class MediaController extends BaseController $originalName = (string) ($this->f3->get('FILES.image.name') ?? ''); $received = Web::instance()->receive( - fn(array $file): bool => $file['size'] <= self::UPLOAD_MAX_BYTES, + fn(array $file): bool => (int) ($file['size'] ?? 0) > 0 + && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES, overwrite: false, slug: true ); @@ -36,7 +45,7 @@ class MediaController extends BaseController $accepted = array_keys(array_filter($received)); if ($accepted === []) { - throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int)(self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).'); + throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int) (self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).'); } foreach ($accepted as $destPath) { @@ -48,12 +57,11 @@ class MediaController extends BaseController $this->flash('error', $e->getMessage()); } - $this->f3->reroute($this->f3->alias('media_index')); + $this->f3->reroute('@media_index'); } public function updateAlt(): void { - $this->requireAuth(); $this->verifyCsrf(); try { @@ -64,12 +72,11 @@ class MediaController extends BaseController $this->flash('error', $e->getMessage()); } - $this->f3->reroute($this->f3->alias('media_index')); + $this->f3->reroute('@media_index'); } public function delete(): void { - $this->requireAuth(); $this->verifyCsrf(); try { @@ -79,6 +86,6 @@ class MediaController extends BaseController $this->flash('error', $e->getMessage()); } - $this->f3->reroute($this->f3->alias('media_index')); + $this->f3->reroute('@media_index'); } } diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php index 63390fc..7ab6380 100644 --- a/app/Controllers/PostController.php +++ b/app/Controllers/PostController.php @@ -4,16 +4,21 @@ declare(strict_types=1); class PostController extends BaseController { - public function create(): void + private const MEDIA_PICKER_LIMIT = 60; + + public function beforeRoute(): void { $this->requireAuth(); - $this->f3->expire(0); + $this->disableCache(); + } + + public function create(): void + { $this->renderForm('Nouvel article', $this->f3->alias('post_store'), Post::emptyForm()); } public function store(): void { - $this->requireAuth(); $this->verifyCsrf(); $input = $this->postInput(); @@ -22,18 +27,14 @@ class PostController extends BaseController (new Post($this->db))->create($input); Cache::instance()->reset('.url'); // invalide le cache des pages publiques $this->flash('success', 'Article créé.'); - $this->f3->reroute($this->f3->alias('dashboard')); + $this->f3->reroute('@dashboard'); } catch (RuntimeException $e) { - $this->f3->expire(0); $this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage()); } } public function edit(): void { - $this->requireAuth(); - $this->f3->expire(0); - $post = (new Post($this->db))->findForEdit((int) $this->f3->get('PARAMS.id')); if ($post === null) { $this->f3->error(404, 'Article introuvable.'); @@ -45,7 +46,6 @@ class PostController extends BaseController public function update(): void { - $this->requireAuth(); $this->verifyCsrf(); $id = (int) $this->f3->get('PARAMS.id'); @@ -60,21 +60,19 @@ class PostController extends BaseController Cache::instance()->reset('.url'); // invalide le cache des pages publiques $this->flash('success', 'Article mis à jour.'); - $this->f3->reroute($this->f3->alias('dashboard')); + $this->f3->reroute('@dashboard'); } catch (RuntimeException $e) { - $this->f3->expire(0); $this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage()); } } public function delete(): void { - $this->requireAuth(); $this->verifyCsrf(); (new Post($this->db))->delete((int) $this->f3->get('PARAMS.id')); Cache::instance()->reset('.url'); // invalide le cache des pages publiques $this->flash('success', 'Article supprimé.'); - $this->f3->reroute($this->f3->alias('dashboard')); + $this->f3->reroute('@dashboard'); } private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null): void @@ -84,27 +82,33 @@ class PostController extends BaseController $coverPreview = (new Media($this->db))->findById((int) $post['cover_media_id']); } + $media = new Media($this->db); + $mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT); + $mediaCount = $media->countAll(); $flash = $error !== null ? ['type' => 'error', 'message' => $error] : null; - $this->render('admin/post_form.html', [ - 'pageTitle' => $pageTitle, - 'formAction' => $formAction, - 'post' => $post, + $this->renderSession('admin/post_form.html', [ + 'pageTitle' => $pageTitle, + 'formAction' => $formAction, + 'post' => $post, 'coverPreview' => $coverPreview, - 'mediaItems' => (new Media($this->db))->all(), - 'titleMax' => Post::TITLE_MAX_LENGTH, - 'excerptMax' => Post::EXCERPT_MAX_LENGTH, - 'flash' => $flash, - ]); + 'mediaItems' => $mediaItems, + 'mediaCount' => $mediaCount, + 'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT, + 'mediaPickerTruncated' => $mediaCount > count($mediaItems), + 'titleMax' => Post::TITLE_MAX_LENGTH, + 'excerptMax' => Post::EXCERPT_MAX_LENGTH, + 'flash' => $flash, + ], true); } private function postInput(): array { return [ - 'title' => trim((string) ($this->f3->get('POST.title') ?? '')), - 'excerpt' => trim((string) ($this->f3->get('POST.excerpt') ?? '')), + 'title' => trim((string) ($this->f3->get('POST.title') ?? '')), + 'excerpt' => trim((string) ($this->f3->get('POST.excerpt') ?? '')), 'cover_media_id' => (string) ($this->f3->get('POST.cover_media_id') ?? ''), - 'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?? '')), + 'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?? '')), ]; } } diff --git a/app/Controllers/SiteController.php b/app/Controllers/SiteController.php index c8548e5..ed6f491 100644 --- a/app/Controllers/SiteController.php +++ b/app/Controllers/SiteController.php @@ -6,30 +6,26 @@ class SiteController extends BaseController { public function home(): void { - $this->f3->expire(300); // 5 min — page publique, contenu peu volatile - $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); $result = (new Post($this->db))->paginateList($page); - $this->render('site/home.html', [ + $this->renderPublic('site/home.html', [ 'pageTitle' => 'Accueil', 'posts' => $result['posts'], 'pagination' => $result, - 'paginationBase' => $this->f3->alias('home'), + 'paginationAlias' => 'home', ]); } public function show(): void { - $this->f3->expire(3600); // 1 h — les articles bougent rarement - $post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug')); if ($post === null) { $this->f3->error(404, 'Article introuvable.'); return; } - $this->render('site/post.html', [ + $this->renderPublic('site/post.html', [ 'pageTitle' => $post['title'], 'post' => $post, ]); diff --git a/app/Models/Media.php b/app/Models/Media.php index 0ddf69d..f59f373 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -4,6 +4,10 @@ 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'); @@ -25,11 +29,40 @@ class Media extends DB\SQL\Mapper public function all(): array { return array_map( - fn (self $m): array => $this->decorate($m->cast()), + 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) { @@ -50,36 +83,50 @@ class Media extends DB\SQL\Mapper // pour dériver un texte alternatif lisible. public function upload(string $srcPath, string $originalName = ''): int { - // Image::dump() gère le chargement et la compression. - // Attention : en JPEG, la transparence n'est pas conservée. - // $path='' indique à F3 que le chemin est absolu. + $target = null; + $image = null; + try { - $img = new Image($srcPath, false, ''); + $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('Fichier image invalide ou format source non supporté.'); - } - - $data = $img->dump('jpeg', 85); - - $fileName = bin2hex(random_bytes(16)) . '.jpg'; - $target = app_public_media_dir() . '/' . $fileName; - - if (!Base::instance()->write($target, $data)) { throw new RuntimeException('Impossible d\'enregistrer cette image.'); + } finally { + if ($image instanceof GdImage) { + imagedestroy($image); + } + if (is_file($srcPath)) { + @unlink($srcPath); + } } - - // Supprimer le fichier intermédiaire déposé par Web::receive(). - @unlink($srcPath); - - $this->reset(); - $this->file_name = $fileName; - $this->alt = $originalName !== '' ? self::altFromFilename($originalName) : ''; - $this->width = $img->width(); - $this->height = $img->height(); - $this->created_at = app_now(); - $this->save(); - - return (int) $this->get('id'); } public function updateAlt(int $id, string $alt): void @@ -119,6 +166,83 @@ class Media extends DB\SQL\Mapper } } + 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 { @@ -142,15 +266,15 @@ class Media extends DB\SQL\Mapper $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'], + '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'] . ')', + 'url' => app_media_url((string) $row['file_name']), + 'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')', ]; } } diff --git a/app/Services/MarkdownService.php b/app/Services/MarkdownService.php index dde8927..a1ca04a 100644 --- a/app/Services/MarkdownService.php +++ b/app/Services/MarkdownService.php @@ -10,11 +10,6 @@ class MarkdownService 'strong', 'em', 'a', 'img', 'hr', 'br', ]; - private const ALLOWED_ATTRS = [ - 'a' => ['href', 'title', 'rel', 'target'], - 'img' => ['src', 'alt', 'title', 'loading', 'decoding'], - ]; - public static function compile(string $markdown, Media $media): string { $markdown = trim($markdown); @@ -34,109 +29,190 @@ class MarkdownService return $html; } - // Passe DOM unique : sanitise les balises/attributs et résout les références media:. + // Reconstruction en liste blanche : les descendants d'une balise interdite + // sont retraités récursivement avant d'être réinsérés. private static function sanitizeAndResolve(string $html, Media $media): string { - $dom = new DOMDocument('1.0', 'UTF-8'); - libxml_use_internal_errors(true); - $dom->loadHTML('' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); - libxml_clear_errors(); + $source = new DOMDocument('1.0', 'UTF-8'); + $clean = new DOMDocument('1.0', 'UTF-8'); + $cleanBody = $clean->createElement('body'); + $clean->appendChild($cleanBody); - $body = $dom->getElementsByTagName('body')->item(0); - if (!$body instanceof DOMElement) { + $previousUseInternalErrors = libxml_use_internal_errors(true); + $source->loadHTML('' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previousUseInternalErrors); + + $sourceBody = $source->getElementsByTagName('body')->item(0); + if (!$sourceBody instanceof DOMElement) { return ''; } - self::processNode($body, $media); + self::appendSanitizedChildren($sourceBody, $cleanBody, $clean, $media); $out = ''; - foreach ($body->childNodes as $child) { - $out .= $dom->saveHTML($child); + for ($i = 0; $i < $cleanBody->childNodes->length; $i++) { + $child = $cleanBody->childNodes->item($i); + if ($child !== null) { + $out .= $clean->saveHTML($child); + } } return trim($out); } - private static function processNode(DOMNode $parent, Media $media): void + private static function appendSanitizedChildren(DOMNode $sourceParent, DOMNode $targetParent, DOMDocument $target, Media $media): void { - for ($i = $parent->childNodes->length - 1; $i >= 0; $i--) { - $child = $parent->childNodes->item($i); - if ($child === null) { - continue; + $children = []; + for ($i = 0; $i < $sourceParent->childNodes->length; $i++) { + $child = $sourceParent->childNodes->item($i); + if ($child !== null) { + $children[] = $child; } + } + foreach ($children as $child) { if ($child instanceof DOMComment) { - $parent->removeChild($child); continue; } if ($child instanceof DOMText) { + $targetParent->appendChild($target->createTextNode($child->nodeValue ?? '')); continue; } if (!$child instanceof DOMElement) { - $parent->removeChild($child); continue; } - if (!in_array($child->tagName, self::ALLOWED_TAGS, true)) { - self::unwrap($child); - continue; - } - - self::sanitizeAttributes($child, $media); - - // img may have been removed by sanitizeAttributes - if ($child->parentNode !== null) { - self::processNode($child, $media); - } + self::appendSanitizedElement($child, $targetParent, $target, $media); } } - private static function sanitizeAttributes(DOMElement $element, Media $media): void + private static function appendSanitizedElement(DOMElement $sourceElement, DOMNode $targetParent, DOMDocument $target, Media $media): void { - $allowed = self::ALLOWED_ATTRS[$element->tagName] ?? []; - $toRemove = []; - foreach ($element->attributes as $attribute) { - if (!in_array($attribute->name, $allowed, true)) { - $toRemove[] = $attribute->name; - } - } - foreach ($toRemove as $name) { - $element->removeAttribute($name); + $tag = strtolower($sourceElement->tagName); + if (!in_array($tag, self::ALLOWED_TAGS, true)) { + self::appendSanitizedChildren($sourceElement, $targetParent, $target, $media); + return; } - if ($element->tagName === 'a') { - $href = trim($element->getAttribute('href')); - if ($href === '' || !preg_match('~^(https?:|mailto:|tel:|/)~i', $href)) { - $element->removeAttribute('href'); - } else { - $element->setAttribute('rel', 'noopener noreferrer'); - if (preg_match('~^https?://~i', $href)) { - $element->setAttribute('target', '_blank'); - } + if ($tag === 'img') { + $image = self::buildSanitizedImage($sourceElement, $target, $media); + if ($image !== null) { + $targetParent->appendChild($image); + } + return; + } + + $cleanElement = $target->createElement($tag); + self::sanitizeAttributes($sourceElement, $cleanElement); + $targetParent->appendChild($cleanElement); + self::appendSanitizedChildren($sourceElement, $cleanElement, $target, $media); + } + + private static function sanitizeAttributes(DOMElement $sourceElement, DOMElement $targetElement): void + { + if ($targetElement->tagName !== 'a') { + return; + } + + $href = self::sanitizeHref((string) $sourceElement->getAttribute('href')); + if ($href !== null) { + $targetElement->setAttribute('href', $href); + $targetElement->setAttribute('rel', 'noopener noreferrer'); + if (preg_match('~^https?://~i', $href) === 1) { + $targetElement->setAttribute('target', '_blank'); } } - if ($element->tagName === 'img') { - $src = trim($element->getAttribute('src')); - if ($src === '' || !str_starts_with($src, 'media:')) { - $element->parentNode?->removeChild($element); - return; - } - - $fileName = substr($src, 6); - $item = $media->findByFileName($fileName); - if ($item === null) { - throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.'); - } - - $element->setAttribute('src', (string) $item['url']); - $element->setAttribute('loading', 'lazy'); - $element->setAttribute('decoding', 'async'); + $title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title')); + if ($title !== null) { + $targetElement->setAttribute('title', $title); } } + private static function buildSanitizedImage(DOMElement $sourceElement, DOMDocument $target, Media $media): ?DOMElement + { + $src = trim((string) $sourceElement->getAttribute('src')); + if ($src === '' || !str_starts_with($src, 'media:')) { + return null; + } + + $fileName = substr($src, 6); + if ($fileName === '' || preg_match('/[\x00-\x1F\x7F]/u', $fileName) === 1) { + return null; + } + + $item = $media->findByFileName($fileName); + if ($item === null) { + throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.'); + } + + $image = $target->createElement('img'); + $image->setAttribute('src', (string) $item['url']); + $image->setAttribute('loading', 'lazy'); + $image->setAttribute('decoding', 'async'); + + if ($sourceElement->hasAttribute('alt')) { + $image->setAttribute('alt', self::sanitizeAttributeValue((string) $sourceElement->getAttribute('alt'), true) ?? ''); + } elseif ((string) $item['alt'] !== '') { + $image->setAttribute('alt', (string) $item['alt']); + } else { + $image->setAttribute('alt', ''); + } + + $title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title')); + if ($title !== null) { + $image->setAttribute('title', $title); + } + + return $image; + } + + private static function sanitizeHref(string $href): ?string + { + $href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ($href === '' || preg_match('/[\x00-\x1F\x7F]/u', $href) === 1) { + return null; + } + + if (preg_match('~^(https?://|mailto:|tel:)~i', $href) === 1) { + return $href; + } + + if (self::isSafeRelativeHref($href)) { + return $href; + } + + return null; + } + + private static function isSafeRelativeHref(string $href): bool + { + if ($href === '/') { + return true; + } + + if (str_starts_with($href, '//')) { + return false; + } + + return preg_match('~^(?:/[^/]|\./|\.\./|#|\?)~', $href) === 1; + } + + private static function sanitizeAttributeValue(string $value, bool $allowEmpty = false): ?string + { + $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $value = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value)); + + if ($value === '' && !$allowEmpty) { + return null; + } + + return $value; + } + private static function normalizeMarkdown(string $markdown): string { $markdown = str_replace(["\r\n", "\r"], "\n", $markdown); @@ -175,18 +251,4 @@ class MarkdownService return trim(implode("\n", $normalized)); } - - private static function unwrap(DOMElement $element): void - { - $parent = $element->parentNode; - if ($parent === null) { - return; - } - - while ($element->firstChild !== null) { - $parent->insertBefore($element->firstChild, $element); - } - - $parent->removeChild($element); - } } diff --git a/app/Views/admin/dashboard.html b/app/Views/admin/dashboard.html index b0e4931..e928296 100644 --- a/app/Views/admin/dashboard.html +++ b/app/Views/admin/dashboard.html @@ -3,8 +3,8 @@

Tableau de bord

- Nouvel article - Médiathèque + Nouvel article + Médiathèque
diff --git a/app/Views/admin/media.html b/app/Views/admin/media.html index 96a7bef..5e904c5 100644 --- a/app/Views/admin/media.html +++ b/app/Views/admin/media.html @@ -1,18 +1,21 @@
-
+
@@ -24,6 +27,7 @@ +
diff --git a/app/Views/admin/post_form.html b/app/Views/admin/post_form.html index ed57471..d809d7a 100644 --- a/app/Views/admin/post_form.html +++ b/app/Views/admin/post_form.html @@ -3,7 +3,7 @@

{{ @pageTitle }}

@@ -94,6 +94,11 @@ + + +

Affichage limité aux {{ @mediaPickerLimit }} images les plus récentes sur {{ @mediaCount }}. Utilise la médiathèque pour parcourir toute la bibliothèque.

+
+
diff --git a/app/Views/auth/login.html b/app/Views/auth/login.html index d3943a5..dcc9adb 100644 --- a/app/Views/auth/login.html +++ b/app/Views/auth/login.html @@ -3,7 +3,7 @@

Connexion

-
+
diff --git a/app/Views/layout.html b/app/Views/layout.html index b232eba..1468d9c 100644 --- a/app/Views/layout.html +++ b/app/Views/layout.html @@ -6,8 +6,8 @@ {{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }} - - + + diff --git a/app/Views/partials/media_card.html b/app/Views/partials/media_card.html index cf121dd..be45f3f 100644 --- a/app/Views/partials/media_card.html +++ b/app/Views/partials/media_card.html @@ -3,7 +3,7 @@

{{ @item.width }} × {{ @item.height }}
{{ @item.created_at_label }}

- +