diff --git a/.gitignore b/.gitignore index cd7f5d3..36bdd1f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ /logs/* !/logs/.gitkeep /vendor/ -/tmp/ +/tmp/* +!/tmp/.gitkeep /public/uploads/media/* !/public/uploads/media/.gitkeep /config.local.ini diff --git a/Caddyfile.example b/Caddyfile.example index bc57767..bc946f9 100644 --- a/Caddyfile.example +++ b/Caddyfile.example @@ -1,9 +1,9 @@ -# Exemple de configuration Caddy en reverse proxy vers l'application. -# Copier ce fichier vers Caddyfile et adapter le domaine / la cible. -# TLS, compression et en-têtes de sécurité restent gérés ici, pas dans l'app PHP. +# Exemple de reverse proxy Caddy. +# Copier vers Caddyfile et adapter le domaine et la cible. +# TLS et en-têtes HTTP se gèrent ici. blog.example.com { - # ── En-têtes de sécurité (toutes les réponses) ─────────────────── + # En-têtes de sécurité header { Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; img-src 'self' data:; style-src 'self'; script-src 'self'" diff --git a/Dockerfile b/Dockerfile index dfdfc32..dcfcd25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,12 @@ RUN apt-get update \ libonig-dev \ libicu-dev \ libxml2-dev \ + libjpeg62-turbo-dev \ + libpng-dev \ unzip \ git \ - && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \ + && docker-php-ext-configure gd --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom gd \ && rm -rf /var/lib/apt/lists/* COPY --from=composer:2 /usr/bin/composer /usr/bin/composer @@ -29,7 +32,10 @@ RUN apt-get update \ libonig-dev \ libicu-dev \ libxml2-dev \ - && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \ + libjpeg62-turbo-dev \ + libpng-dev \ + && docker-php-ext-configure gd --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom gd \ && printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \ && a2enconf servername \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index 9fcd91d..7ffdd67 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,74 @@ # F3 Simple Blog -Blog simple construit avec Fat-Free Framework et SQLite. +Blog minimal en **Fat-Free Framework** avec **SQLite**. -Le projet vise un blog léger, lisible et facile à déployer, avec un petit back-office d’administration, une médiathèque locale et un rendu Markdown sécurisé. +- rendu côté serveur +- routes dans `config.ini` +- modèles `DB\SQL\Mapper` +- édition en Markdown +- médiathèque paginée séparée de l’éditeur +- nettoyage des fichiers orphelins via script CLI -## Fonctionnalités +## Prérequis -- listing public des articles avec pagination ; -- page article ; -- authentification admin ; -- création, modification et suppression d’articles ; -- médiathèque locale avec upload, texte alternatif, copie de la syntaxe Markdown et suppression ; -- vignette de carte dérivée de la première image du contenu ; -- rendu Markdown avec images locales `media:...` ; -- stockage des images en **JPG/PNG bruts** sans transformation. +- PHP 8.3+ +- extensions PHP : `pdo_sqlite`, `mbstring`, `intl`, `dom`, `gd` +- Composer + +## Installation + +```bash +composer install +php scripts/install.php +php scripts/create-admin.php admin +``` + +Lancer ensuite un serveur local : + +```bash +php -S 127.0.0.1:8000 -t public +``` ## Structure ```text -project/ -├── app/ -│ ├── bootstrap.php -│ ├── config.ini -│ ├── helpers.php -│ ├── Controllers/ -│ ├── Models/ -│ ├── Services/ -│ └── Views/ -├── db/ -├── logs/ -├── public/ -│ ├── assets/ -│ └── uploads/media/ -├── docker/ -├── scripts/ -│ ├── bootstrap.php -│ ├── install.php -│ └── create-admin.php -└── tmp/uploads/ +app/ contrôleurs, modèles, vues et service Markdown +public/ assets et fichiers médias +scripts/ installation, création d’admin, nettoyage des orphelins ``` -## Architecture +## Flux éditorial -Le backend s’appuie sur un petit noyau : +- Les articles sont rédigés en Markdown. +- Les images sont gérées dans la médiathèque. +- La médiathèque fournit un bouton **Copier le Markdown**. +- L’éditeur reste simple : textarea, petite toolbar Markdown et lien vers la médiathèque. -- `SiteController` : pages publiques ; -- `AuthController` : connexion / déconnexion ; -- `AdminController` : back-office articles et médiathèque ; -- `Controller` : rendu, session courante, flash, CSRF ; -- `Post`, `Media`, `User` : modèles `DB\SQL\Mapper` ; -- `MarkdownService` : compilation et sanitation du contenu Markdown. +## Médias -## Intégration F3 +La table `media` est la référence côté application. -Le projet utilise directement les briques natives du framework : +- Supprimer un média retire la ligne SQL. +- Les fichiers orphelins éventuels sont nettoyés via un script CLI. -- routes et aliases dans `app/config.ini` ; -- `DB\SQL\Mapper` pour les tables principales ; -- `Auth` pour la connexion ; -- `Session` pour la session ; -- `Template` pour le rendu ; -- `Web::receive()` pour la réception des uploads ; -- `Markdown` pour le parsing Markdown ; -- `Web::slug()` pour les slugs. - -## Contenu et médiathèque - -Les articles contiennent leur texte en Markdown. Les images sont insérées dans le corps du contenu, et la première image rendue dans `body_html` sert de vignette dans les cartes d’article. - -Les images du contenu utilisent la syntaxe : - -```md -![Texte alternatif](media:nom-de-fichier.jpg) -``` - -La médiathèque : - -- accepte uniquement **JPG** et **PNG** ; -- vérifie que le fichier reçu est bien une image ; -- conserve le fichier tel quel, sans réencodage ; -- stocke en base le nom du fichier, le texte alternatif, la largeur, la hauteur et la date de création. - -## Contrat Markdown - -Le rendu Markdown suit les règles suivantes : - -- le HTML brut saisi par l’auteur n’est pas rendu ; -- les liens sont filtrés avant rendu ; -- seules les images locales en `media:...` sont rendues ; -- les images externes ne sont pas affichées ; -- avec le parseur Markdown de F3, il faut laisser une ligne vide entre deux blocs (titre, liste, citation, image, code) pour un rendu fiable. - -## Sécurité - -Le projet inclut : - -- session d’administration ; -- jeton CSRF sur les formulaires ; -- rotation du jeton CSRF après connexion et déconnexion ; -- sanitation du HTML produit à partir du Markdown ; -- validation des uploads image ; -- cookies `httponly` et `samesite=Lax`. - -## Pré-requis - -### Développement local - -- PHP 8.3+ -- Composer -- extensions PHP : `pdo_sqlite`, `mbstring`, `intl`, `dom` - -### Déploiement Docker - -- Docker -- Docker Compose - -## Démarrage local +Lister les orphelins : ```bash -composer install -cp config.local.ini.example config.local.ini -php scripts/install.php -php -S 127.0.0.1:8080 -t public +php scripts/clean-orphan-media.php list ``` -Puis ouvrir `http://127.0.0.1:8080`. - -Créer un compte admin : +Supprimer les orphelins : ```bash -php scripts/create-admin.php admin +php scripts/clean-orphan-media.php delete ``` -## Configuration +## Déploiement Docker -Le fichier `app/config.ini` contient les valeurs par défaut. Tu peux les surcharger dans `config.local.ini`. - -Exemple minimal en production : - -```ini -[globals] -app.env=prod -app.timezone=Europe/Paris -``` - -Le paramètre `app.env` doit être défini à `prod` sur un déploiement réel. - -## Déploiement Docker derrière Caddy - -Déploiement Docker recommandé : - -- Apache sert `public/` dans le conteneur ; -- `compose.yaml` expose l’application sur `127.0.0.1:8888` par défaut ; -- Caddy termine TLS et reverse-proxy vers cette cible ; -- SQLite, les logs et les uploads sont montés sur des volumes persistants. - -Exemple : +Une configuration Docker est fournie : ```bash -cp config.local.ini.example config.local.ini -# édite config.local.ini : app.env=prod -docker compose up -d --build +docker compose up --build ``` -Créer un compte admin dans le conteneur : - -```bash -docker compose exec app php scripts/create-admin.php admin -``` - -La commande demande ensuite le mot de passe du compte. - -Le `Caddyfile.example` fournit une base de reverse proxy. En production, il faut exposer publiquement **Caddy uniquement**. L’application Apache/PHP ne doit pas être accessible directement depuis Internet. - -## Sauvegarde - -Pour sauvegarder le blog, il faut au minimum conserver : - -- `db/app.sqlite` -- `public/uploads/media/` - -Les logs peuvent aussi être conservés si tu veux garder l’historique d’erreurs. - -## Limites assumées - -- base SQLite ; -- instance applicative unique ; -- pas de système de migrations automatique ; -- en cas de changement de schéma, une migration manuelle ou une base recréée sera nécessaire. +L’application écoute sur `127.0.0.1:8888` par défaut. diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 5251bf5..0239e04 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -4,7 +4,6 @@ declare(strict_types=1); class AdminController extends Controller { - private const MEDIA_PICKER_LIMIT = 60; private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; public function beforeRoute(): void @@ -28,52 +27,52 @@ class AdminController extends Controller public function create(): void { - $this->postForm('Nouvel article', $this->f3->alias('post_store'), Post::blank()); + $this->renderPostForm('Nouvel article', $this->f3->alias('post_store'), Post::blank()); } public function store(): void { - $this->checkCsrf(); - $input = $this->postInput(); + $this->verifyCsrf(); + $input = $this->readPostInput(); try { (new Post())->savePost($input); $this->flash('success', 'Article créé.'); $this->f3->reroute('@dashboard'); } catch (RuntimeException $e) { - $this->postForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage()); + $this->renderPostForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage()); } } public function edit(): void { $post = (new Post())->findForForm((int) $this->f3->get('PARAMS.id')); - if (!$post) { + if ($post === null) { $this->f3->error(404); return; } - $this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post); + $this->renderPostForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post); } public function update(): void { - $this->checkCsrf(); + $this->verifyCsrf(); $id = (int) $this->f3->get('PARAMS.id'); - $input = $this->postInput() + ['id' => $id]; + $input = $this->readPostInput() + ['id' => $id]; try { (new Post())->savePost($input, $id); $this->flash('success', 'Article mis à jour.'); $this->f3->reroute('@dashboard'); } catch (RuntimeException $e) { - $this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage()); + $this->renderPostForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage()); } } public function delete(): void { - $this->checkCsrf(); + $this->verifyCsrf(); try { (new Post())->deleteById((int) $this->f3->get('PARAMS.id')); @@ -95,25 +94,27 @@ class AdminController extends Controller 'items' => $result['items'], 'pagination' => $result['pagination'], 'paginationAlias' => 'media_index', + 'adminMode' => true, ]); } public function mediaUpload(): void { - $this->checkCsrf(); + $this->verifyCsrf(); try { - $original = (string) ($this->f3->get('FILES.image.name') ?: ''); + $originalName = (string) ($this->f3->get('FILES.image.name') ?: ''); $received = Web::instance()->receive( fn(array $file): bool => (int) ($file['size'] ?? 0) > 0 && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES, overwrite: false ); + $path = array_key_first(array_filter($received)); if (!$path) { - throw new RuntimeException('Choisis une image valide à envoyer.'); + throw new RuntimeException('Choisis une image JPG ou PNG valide.'); } - (new Media())->upload($path, $original); + (new Media())->upload($path, $originalName); $this->flash('success', 'Image ajoutée.'); } catch (RuntimeException $e) { $this->flash('error', $e->getMessage()); @@ -124,11 +125,14 @@ class AdminController extends Controller public function mediaAlt(): void { - $this->checkCsrf(); + $this->verifyCsrf(); try { - (new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), $this->f3->clean((string) ($this->f3->get('POST.alt') ?: ''))); - $this->flash('success', 'Texte alternatif mis à jour.'); + (new Media())->updateAlt( + (int) $this->f3->get('PARAMS.id'), + $this->f3->clean((string) ($this->f3->get('POST.alt') ?: '')) + ); + $this->flash('success', 'Texte alternatif enregistré.'); } catch (RuntimeException $e) { $this->flash('error', $e->getMessage()); } @@ -138,17 +142,18 @@ class AdminController extends Controller public function mediaDelete(): void { - $this->checkCsrf(); + $this->verifyCsrf(); try { $media = new Media(); $item = $media->findById((int) $this->f3->get('PARAMS.id')); - if (!$item) { + if ($item === null) { throw new RuntimeException('Image introuvable.'); } if ((new Post())->usesMedia($item['file_name'])) { - throw new RuntimeException('Cette image est encore utilisée par un article.'); + throw new RuntimeException('Cette image est utilisée dans un article.'); } + $media->deleteById($item['id']); $this->flash('success', 'Image supprimée.'); } catch (RuntimeException $e) { @@ -158,27 +163,22 @@ class AdminController extends Controller $this->f3->reroute('@media_index'); } - private function postForm(string $title, string $action, array $post, ?string $error = null): void + private function renderPostForm(string $title, string $action, array $post, ?string $error = null): void { - $media = new Media(); - $items = $media->recent(self::MEDIA_PICKER_LIMIT); - $count = $media->count(); + $flash = $error ? [['type' => 'error', 'message' => $error]] : []; $this->render('admin/post_form.html', [ 'pageTitle' => $title, 'formAction' => $action, 'post' => $post, - 'mediaItems' => $items, - 'mediaCount' => $count, - 'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT, - 'mediaPickerTruncated' => $count > count($items), 'titleMax' => Post::TITLE_MAX_LENGTH, 'excerptMax' => Post::EXCERPT_MAX_LENGTH, - 'flash' => $error ? [['type' => 'error', 'message' => $error]] : [], + 'flash' => $flash, + 'adminMode' => true, ]); } - private function postInput(): array + private function readPostInput(): array { return [ 'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?: '')), diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index e34a4ec..5bbe528 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -6,7 +6,7 @@ class AuthController extends Controller { public function show(): void { - if ($this->user()) { + if ($this->currentUser()) { $this->f3->reroute('@dashboard'); return; } @@ -16,10 +16,11 @@ class AuthController extends Controller public function login(): void { - $this->checkCsrf(); + $this->verifyCsrf(); $user = new User(); $auth = new Auth($user, ['id' => 'username', 'pw' => 'password_hash'], 'password_verify'); + $ok = $auth->login( $this->f3->clean((string) ($this->f3->get('POST.username') ?: '')), (string) ($this->f3->get('POST.password') ?: '') @@ -27,7 +28,7 @@ class AuthController extends Controller if (!$ok) { usleep(1000000); - $this->flash('error', 'Identifiants invalides.'); + $this->flash('error', 'Identifiants incorrects.'); $this->f3->reroute('@login'); return; } @@ -41,11 +42,11 @@ class AuthController extends Controller public function logout(): void { - $this->checkCsrf(); + $this->verifyCsrf(); $this->f3->clear('SESSION.user_id'); session_regenerate_id(true); $this->rotateCsrf(); - $this->flash('success', 'Déconnexion effectuée.'); + $this->flash('success', 'Déconnexion réussie.'); $this->f3->reroute('@login'); } } diff --git a/app/Controllers/Controller.php b/app/Controllers/Controller.php index a6e5b89..288c7f2 100644 --- a/app/Controllers/Controller.php +++ b/app/Controllers/Controller.php @@ -13,77 +13,69 @@ abstract class Controller protected function render(string $view, array $data = [], int $ttl = 0): void { - $this->ensureCsrf(); - $user = $this->user(); + $user = $this->currentUser(); $flash = array_key_exists('flash', $data) ? $data['flash'] : $this->pullFlash(); $this->f3->expire($user ? 0 : $ttl); $this->f3->mset($data + [ - 'view' => $view, - 'flash' => is_array($flash) ? $flash : [], + 'CSRF' => $this->csrfToken(), 'currentUser' => $user, + 'flash' => is_array($flash) ? $flash : [], + 'view' => $view, 'adminMode' => false, 'metaDescription' => null, - 'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'), ]); echo Template::instance()->render('layout.html'); } - protected function user(): ?array + protected function currentUser(): ?array { - if (!$this->f3->exists('ctx.user_loaded')) { - $id = (int) ($this->f3->get('SESSION.user_id') ?: 0); - $this->f3->set('currentUser', $id > 0 ? (new User())->findPublic($id) : null); - $this->f3->set('ctx.user_loaded', true); - } - - return $this->f3->get('currentUser'); + $id = (int) ($this->f3->get('SESSION.user_id') ?: 0); + return $id > 0 ? (new User())->findPublic($id) : null; } protected function requireAuth(): void { - if ($this->user()) { + if ($this->currentUser()) { return; } - $this->flash('error', 'Connecte-toi pour continuer.'); + $this->flash('error', 'Connecte-toi pour accéder à cette page.'); $this->f3->reroute('@login'); } - protected function checkCsrf(): void + protected function csrfToken(): string { - $sent = trim((string) ($this->f3->get('POST.csrf_token') ?: '')); - $expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?: '')); + $token = trim((string) ($this->f3->get('SESSION.csrf') ?: '')); + if ($token === '') { + $token = bin2hex(random_bytes(32)); + $this->f3->set('SESSION.csrf', $token); + } - if ($sent !== '' && $expected !== '' && hash_equals($expected, $sent)) { + return $token; + } + + protected function verifyCsrf(): void + { + $sent = trim((string) ($this->f3->get('POST.csrf') ?: '')); + if ($sent !== '' && hash_equals($this->csrfToken(), $sent)) { return; } $this->f3->error(400); } + protected function rotateCsrf(): void + { + $this->f3->set('SESSION.csrf', bin2hex(random_bytes(32))); + } + protected function flash(string $type, string $message): void { $this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]); } - protected function rotateCsrf(): void - { - $this->f3->clear('SESSION.csrf_token'); - $this->ensureCsrf(); - } - - private function ensureCsrf(): void - { - if ($this->f3->exists('SESSION.csrf_token')) { - return; - } - - $seed = trim((string) ($this->f3->get('CSRF') ?: '')); - $this->f3->set('SESSION.csrf_token', $seed !== '' ? $seed : bin2hex(random_bytes(16))); - } - private function pullFlash(): array { return $this->f3->pull('SESSION.flash') ?: []; diff --git a/app/Controllers/SiteController.php b/app/Controllers/SiteController.php index 4ca0f32..75c793e 100644 --- a/app/Controllers/SiteController.php +++ b/app/Controllers/SiteController.php @@ -20,7 +20,7 @@ class SiteController extends Controller public function show(): void { $post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug')); - if (!$post) { + if ($post === null) { $this->f3->error(404); return; } diff --git a/app/Models/Media.php b/app/Models/Media.php index c311fe2..8cae622 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -29,9 +29,10 @@ class Media extends DB\SQL\Mapper public function page(int $page, int $perPage): array { $result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']); + $items = array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []); return [ - 'items' => array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []), + 'items' => $items, 'pagination' => [ 'page' => max(1, min($page, $result['count'] ?: 1)), 'pages' => max(1, (int) ($result['count'] ?: 1)), @@ -39,14 +40,6 @@ class Media extends DB\SQL\Mapper ]; } - public function recent(int $limit): array - { - return array_map( - fn(self $row): array => $this->decorate($row->cast()), - $this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: [] - ); - } - public function findById(int $id): ?array { if ($id <= 0) { @@ -63,18 +56,22 @@ class Media extends DB\SQL\Mapper return $this->dry() ? null : $this->decorate($this->cast()); } - public function upload(string $path, string $originalName = ''): int + public function upload(string $temporaryPath, string $originalName = ''): int { - if (!is_file($path)) { + $f3 = Base::instance(); + + if (!is_file($temporaryPath)) { throw new RuntimeException('Fichier image introuvable.'); } - $info = @getimagesize($path); - if (!is_array($info)) { - @unlink($path); + $binary = $f3->read($temporaryPath); + $image = new Image(); + if ($binary === '' || $image->load($binary) === false) { + @unlink($temporaryPath); throw new RuntimeException('Fichier image invalide.'); } + $info = @getimagesizefromstring($binary); $mime = strtolower((string) ($info['mime'] ?? '')); $extension = match ($mime) { 'image/jpeg' => 'jpg', @@ -83,28 +80,39 @@ class Media extends DB\SQL\Mapper }; if ($extension === null) { - @unlink($path); + @unlink($temporaryPath); 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; + $encoded = match ($extension) { + 'jpg' => $image->dump('jpeg', 90), + 'png' => $image->dump('png'), + }; - if (!@rename($path, $target)) { - if (!@copy($path, $target)) { - @unlink($path); - throw new RuntimeException('Impossible d’enregistrer cette image.'); - } - @unlink($path); + $fileName = bin2hex(random_bytes(16)) . '.' . $extension; + $target = $this->storagePath($fileName); + + try { + $f3->write($target, $encoded); + } catch (Throwable $e) { + @unlink($temporaryPath); + throw new RuntimeException('Impossible d’enregistrer cette image.', 0, $e); } - $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(); + @unlink($temporaryPath); + + try { + $this->reset(); + $this->file_name = $fileName; + $this->alt = $this->guessAlt($originalName); + $this->width = $image->width(); + $this->height = $image->height(); + $this->created_at = app_now(); + $this->save(); + } catch (Throwable $e) { + @unlink($target); + throw new RuntimeException('Impossible de finaliser cette image.', 0, $e); + } return (int) $this->id; } @@ -127,34 +135,41 @@ class Media extends DB\SQL\Mapper throw new RuntimeException('Image introuvable.'); } - $path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name; - $this->erase(); - if (is_file($path)) { - @unlink($path); + try { + $this->erase(); + } catch (Throwable $e) { + throw new RuntimeException('Impossible de supprimer cette image.', 0, $e); } } private function decorate(array $row): array { - $file = (string) $row['file_name']; + $fileName = (string) $row['file_name']; $alt = (string) $row['alt']; + $base = rtrim((string) Base::instance()->get('BASE'), '/'); + $mediaBase = rtrim((string) Base::instance()->get('paths.media_base'), '/'); return [ 'id' => (int) $row['id'], - 'file_name' => $file, + 'file_name' => $fileName, 'alt' => $alt, 'width' => (int) $row['width'], 'height' => (int) $row['height'], 'created_at' => (string) $row['created_at'], - 'url' => rtrim((string) Base::instance()->get('BASE'), '/') . rtrim((string) Base::instance()->get('paths.media_base'), '/') . '/' . rawurlencode($file), - 'markdown' => '![' . $alt . '](media:' . $file . ')', + 'url' => $base . $mediaBase . '/' . rawurlencode($fileName), + 'markdown' => '![' . $alt . '](media:' . $fileName . ')', ]; } - private function altFromName(string $name): string + private function storagePath(string $fileName): string { - $name = trim(pathinfo($name, PATHINFO_FILENAME)); - $name = preg_replace('/[-_]+/', ' ', $name) ?: ''; - return trim($name); + return rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName; + } + + private function guessAlt(string $originalName): string + { + $label = trim(pathinfo($originalName, PATHINFO_FILENAME)); + $label = preg_replace('/[-_]+/', ' ', $label) ?: ''; + return trim($label); } } diff --git a/app/Models/Post.php b/app/Models/Post.php index e516b92..2a7dd8e 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -62,13 +62,16 @@ class Post extends DB\SQL\Mapper } $row = $this->cast(); - $post = $this->summary($row) + ['body_html' => (string) $row['body_html']]; - return $post; + return $this->summary($row) + ['body_html' => (string) $row['body_html']]; } public function findForForm(int $id): ?array { + if ($id <= 0) { + return null; + } + $this->load(['id = ?', $id]); if ($this->dry()) { return null; @@ -84,12 +87,12 @@ class Post extends DB\SQL\Mapper public function savePost(array $input, ?int $id = null): int { - $payload = $this->payload($input); + $payload = $this->normalizePayload($input); $now = app_now(); if ($id === null) { $this->reset(); - $payload['slug'] = $this->uniqueSlug($payload['title']); + $payload['slug'] = $this->nextSlug($payload['title']); $payload['created_at'] = $now; } else { $this->load(['id = ?', $id]); @@ -120,11 +123,11 @@ class Post extends DB\SQL\Mapper return $this->count(['body_markdown LIKE ?', '%media:' . $fileName . '%']) > 0; } - private function payload(array $input): array + private function normalizePayload(array $input): array { $title = trim((string) ($input['title'] ?? '')); $excerpt = trim((string) ($input['excerpt'] ?? '')); - $body = trim((string) ($input['body_markdown'] ?? '')); + $bodyMarkdown = trim((string) ($input['body_markdown'] ?? '')); if ($title === '') { throw new RuntimeException('Ajoute un titre.'); @@ -142,20 +145,20 @@ class Post extends DB\SQL\Mapper return [ 'title' => $title, 'excerpt' => $excerpt, - 'body_markdown' => $body, - 'body_html' => MarkdownService::instance()->compile($body, new Media()), + 'body_markdown' => $bodyMarkdown, + 'body_html' => MarkdownService::instance()->compile($bodyMarkdown, new Media()), ]; } - private function uniqueSlug(string $title): string + private function nextSlug(string $title): string { $base = app_slug($title); $slug = $base; - $n = 2; + $suffix = 2; while ($this->count(['slug = ?', $slug]) > 0) { - $slug = $base . '-' . $n; - $n++; + $slug = $base . '-' . $suffix; + $suffix++; } return $slug; @@ -163,7 +166,7 @@ class Post extends DB\SQL\Mapper private function summary(array $row): array { - $thumbnail = $this->firstImage((string) ($row['body_html'] ?? '')); + $thumbnail = $this->extractThumbnail((string) ($row['body_html'] ?? '')); return [ 'id' => (int) $row['id'], @@ -177,20 +180,14 @@ class Post extends DB\SQL\Mapper ]; } - private function firstImage(string $html): array + private function extractThumbnail(string $html): array { - if ($html === '') { + if ($html === '' || !preg_match('~(]*src="([^"]+)"[^>]*>)~i', $html, $match)) { return ['url' => '', 'alt' => '']; } - if (!preg_match('~(]*src="([^"]+)"[^>]*>)~i', $html, $match)) { - return ['url' => '', 'alt' => '']; - } - - $tag = $match[1]; $alt = ''; - - if (preg_match('~alt="([^"]*)"~i', $tag, $altMatch)) { + if (preg_match('~alt="([^"]*)"~i', $match[1], $altMatch)) { $alt = html_entity_decode($altMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); } diff --git a/app/Models/User.php b/app/Models/User.php index 0b5ea5a..35c4ccf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,6 +25,10 @@ class User extends DB\SQL\Mapper public function findPublic(int $id): ?array { + if ($id <= 0) { + return null; + } + $this->load(['id = ?', $id]); if ($this->dry()) { return null; diff --git a/app/Services/MarkdownService.php b/app/Services/MarkdownService.php index ba5aa29..dedc8ab 100644 --- a/app/Services/MarkdownService.php +++ b/app/Services/MarkdownService.php @@ -4,8 +4,8 @@ declare(strict_types=1); class MarkdownService extends Prefab { - private const TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br']; - private const ATTRS = [ + private const ALLOWED_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br']; + private const ALLOWED_ATTRIBUTES = [ 'a' => ['href', 'title', 'rel', 'target'], 'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'], ]; @@ -17,31 +17,30 @@ class MarkdownService extends Prefab throw new RuntimeException('Ajoute du contenu avant de publier.'); } - $markdown = $this->neutralizeRawHtml($markdown); - $doc = new DOMDocument('1.0', 'UTF-8'); - $html = '
' . Markdown::instance()->convert($markdown) . '
'; + $html = '
' . Markdown::instance()->convert($this->escapeRawHtml($markdown)) . '
'; + $previous = libxml_use_internal_errors(true); $doc->loadHTML('' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); $root = $doc->getElementById('content'); - if (!$root) { - return ''; + if (!$root instanceof DOMElement) { + throw new RuntimeException('Impossible de générer le contenu HTML.'); } - $this->sanitizeChildren($root, $media); + $this->sanitizeNode($root, $media); - $out = ''; + $output = ''; foreach (iterator_to_array($root->childNodes) as $child) { - $out .= $doc->saveHTML($child); + $output .= $doc->saveHTML($child); } - return trim($out); + return trim($output); } - private function neutralizeRawHtml(string $markdown): string + private function escapeRawHtml(string $markdown): string { return preg_replace_callback( '~|]*)?/?>~s', @@ -50,7 +49,7 @@ class MarkdownService extends Prefab ) ?? $markdown; } - private function sanitizeChildren(DOMNode $parent, Media $media): void + private function sanitizeNode(DOMNode $parent, Media $media): void { foreach (iterator_to_array($parent->childNodes) as $child) { if (!$child instanceof DOMElement) { @@ -58,60 +57,72 @@ class MarkdownService extends Prefab } $tag = strtolower($child->tagName); - if (!in_array($tag, self::TAGS, true)) { + if (!in_array($tag, self::ALLOWED_TAGS, true)) { $this->unwrap($child); - $this->sanitizeChildren($parent, $media); + $this->sanitizeNode($parent, $media); continue; } - foreach (iterator_to_array($child->attributes) as $attr) { - if (!in_array(strtolower($attr->name), self::ATTRS[$tag] ?? [], true)) { - $child->removeAttributeNode($attr); + foreach (iterator_to_array($child->attributes) as $attribute) { + if (!in_array(strtolower($attribute->name), self::ALLOWED_ATTRIBUTES[$tag] ?? [], true)) { + $child->removeAttributeNode($attribute); } } if ($tag === 'a') { - $href = trim((string) $child->getAttribute('href')); - if (!$this->allowedHref($href)) { - $this->unwrap($child); - $this->sanitizeChildren($parent, $media); - continue; - } - - $child->setAttribute('href', $href); - $child->setAttribute('rel', 'noopener noreferrer'); - - if (preg_match('~^https?://~i', $href)) { - $child->setAttribute('target', '_blank'); - } else { - $child->removeAttribute('target'); - } + $this->sanitizeLink($child); } if ($tag === 'img') { - $src = trim((string) $child->getAttribute('src')); - if (!str_starts_with($src, 'media:')) { - $child->parentNode?->removeChild($child); + $this->sanitizeImage($child, $media); + if (!$child->parentNode) { continue; } - - $item = $media->findByFileName(substr($src, 6)); - if (!$item) { - throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.'); - } - - $child->setAttribute('src', $item['url']); - $child->setAttribute('alt', trim((string) $child->getAttribute('alt')) ?: (string) $item['alt']); - $child->setAttribute('width', (string) $item['width']); - $child->setAttribute('height', (string) $item['height']); - $child->setAttribute('loading', 'lazy'); - $child->setAttribute('decoding', 'async'); } - $this->sanitizeChildren($child, $media); + $this->sanitizeNode($child, $media); } } + private function sanitizeLink(DOMElement $node): void + { + $href = trim((string) $node->getAttribute('href')); + if (!$this->isAllowedHref($href)) { + $this->unwrap($node); + return; + } + + $node->setAttribute('href', $href); + $node->setAttribute('rel', 'noopener noreferrer'); + if (preg_match('~^https?://~i', $href)) { + $node->setAttribute('target', '_blank'); + return; + } + + $node->removeAttribute('target'); + } + + private function sanitizeImage(DOMElement $node, Media $media): void + { + $src = trim((string) $node->getAttribute('src')); + if (!str_starts_with($src, 'media:')) { + $node->parentNode?->removeChild($node); + return; + } + + $item = $media->findByFileName(substr($src, 6)); + if ($item === null) { + throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.'); + } + + $node->setAttribute('src', $item['url']); + $node->setAttribute('alt', trim((string) $node->getAttribute('alt')) ?: (string) $item['alt']); + $node->setAttribute('width', (string) $item['width']); + $node->setAttribute('height', (string) $item['height']); + $node->setAttribute('loading', 'lazy'); + $node->setAttribute('decoding', 'async'); + } + private function unwrap(DOMElement $node): void { $parent = $node->parentNode; @@ -131,16 +142,14 @@ class MarkdownService extends Prefab $parent->removeChild($node); } - private function allowedHref(string $href): bool + private function isAllowedHref(string $href): bool { - if ($href === '') { + if ($href === '' || str_starts_with($href, '//')) { return false; } - if (str_starts_with($href, '#') || str_starts_with($href, '/')) { return true; } - if (preg_match('~^(?:https?://|mailto:)~i', $href)) { return true; } diff --git a/app/Views/admin/dashboard.html b/app/Views/admin/dashboard.html index 4af2ef2..ccf8e5a 100644 --- a/app/Views/admin/dashboard.html +++ b/app/Views/admin/dashboard.html @@ -19,8 +19,8 @@
-

Aucun article

-

Commence par créer un premier article.

+

Aucun article.

+

Crée un article pour commencer.

diff --git a/app/Views/admin/media.html b/app/Views/admin/media.html index 8943be0..a338a39 100644 --- a/app/Views/admin/media.html +++ b/app/Views/admin/media.html @@ -2,7 +2,7 @@ -
-
- + + - + - + -
-
-
-

Contenu

-

Markdown avec insertion d’image au curseur. La première image du contenu est utilisée dans les cartes d’article.

-
-
- - - - -

Laisse une ligne vide entre deux blocs Markdown (titre, liste, citation, image, code).

-
- - - - - -
+ + + +

Laisse une ligne vide entre deux blocs. Pour ajouter une image, ouvre la médiathèque, copie le Markdown, puis colle-le ici.

+ + + + diff --git a/app/Views/errors/error.html b/app/Views/errors/error.html index 958c1da..91a0f6a 100644 --- a/app/Views/errors/error.html +++ b/app/Views/errors/error.html @@ -3,19 +3,18 @@ - {{ @errorTitle ?: 'Erreur' }} + {{ @errorTitle }} · {{ @app.name }} -
+
-
-

Erreur {{ @errorCode ?: 500 }}

-

{{ @errorTitle ?: 'Erreur' }}

-

{{ @errorMessage ?: 'Une erreur est survenue.' }}

-

Vérifie l’adresse ou reviens à l’accueil.

-

Retour à l’accueil

+
+

Erreur {{ @errorCode }}

+

{{ @errorTitle }}

+

{{ @errorMessage }}

+ Retour à l’accueil
diff --git a/app/Views/layout.html b/app/Views/layout.html index 2aa0f55..326328c 100644 --- a/app/Views/layout.html +++ b/app/Views/layout.html @@ -12,7 +12,7 @@ -
+
diff --git a/app/Views/partials/csrf_field.html b/app/Views/partials/csrf_field.html index 1e66770..219586d 100644 --- a/app/Views/partials/csrf_field.html +++ b/app/Views/partials/csrf_field.html @@ -1 +1 @@ - + diff --git a/app/Views/partials/media_card.html b/app/Views/partials/media_card.html index a199b63..2311b42 100644 --- a/app/Views/partials/media_card.html +++ b/app/Views/partials/media_card.html @@ -7,7 +7,7 @@ diff --git a/app/Views/partials/site_navigation.html b/app/Views/partials/site_navigation.html index f6f8936..9841d34 100644 --- a/app/Views/partials/site_navigation.html +++ b/app/Views/partials/site_navigation.html @@ -1,13 +1,18 @@ - - -
- +
+ -
+