diff --git a/composer.json b/composer.json index 554cdd2..adc583e 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "twig/twig": "*", "slim/twig-view": "^3.4", "catfan/medoo": "2.*", - "vlucas/phpdotenv": "^5.6" + "vlucas/phpdotenv": "^5.6", + "ezyang/htmlpurifier": "^4.19" }, "autoload": { "psr-4": { diff --git a/public/index.php b/public/index.php index d1d5f94..7d01da0 100644 --- a/public/index.php +++ b/public/index.php @@ -71,6 +71,9 @@ $db->pdo->exec(" ) "); +// Sanitizer HTML +$htmlSanitizer = new \App\Services\HtmlSanitizer(); + // ============================================ // Slim App // ============================================ @@ -83,7 +86,7 @@ $app->add(TwigMiddleware::create($app, $twig)); // Routes // ============================================ -$controller = new PostController($twig, $db); +$controller = new PostController($twig, $db, $htmlSanitizer); $app->get('/', [$controller, 'index']); $app->get('/article/{slug}', [$controller, 'show']); diff --git a/src/Controllers/PostController.php b/src/Controllers/PostController.php index 46f262a..9606242 100644 --- a/src/Controllers/PostController.php +++ b/src/Controllers/PostController.php @@ -15,8 +15,11 @@ use App\Models\Post; */ class PostController { - public function __construct(private Twig $view, private Medoo $db) - { + public function __construct( + private Twig $view, + private Medoo $db, + private \App\Services\HtmlSanitizer $sanitizer + ) { } /** @@ -36,7 +39,6 @@ class PostController public function show(Request $req, Response $res, array $args): Response { $slug = (string)($args['slug'] ?? ''); - $row = $this->db->get('post', '*', ['slug' => $slug]); if (!$row) { @@ -45,6 +47,9 @@ class PostController } $post = Post::fromArray($row); + // Nettoyer le contenu avant de l'envoyer au template + $post = $post->withSanitizedContent($this->sanitizer->sanitize($post->getContent())); + return $this->view->render($res, 'pages/post_detail.twig', ['post' => $post]); } @@ -109,12 +114,19 @@ class PostController return $res->withHeader('Location', '/admin')->withStatus(302); } - private function ensureUniqueSlug(string $slug): string + private function ensureUniqueSlug(string $slug, ?int $excludeId = null): string { $original = $slug; $counter = 1; - while ($this->db->get('post', 'id', ['slug' => $slug])) { + while (true) { + $existing = $this->db->get('post', 'id', ['slug' => $slug]); + + // Si pas de collision, ou si c'est le même post, le slug est unique + if (!$existing || ($excludeId && $existing === $excludeId)) { + break; + } + $slug = $original . '-' . $counter; $counter++; } @@ -147,10 +159,15 @@ class PostController return $res->withHeader('Location', '/admin')->withStatus(302); } + // Calculer le nouveau slug + $newSlug = $post->getSlug(); + $newSlug = $this->ensureUniqueSlug($newSlug, $id); // Passer l'ID pour exclure le post actuel + // Persister en DB $this->db->update('post', [ 'title' => $post->getTitle(), 'content' => $post->getContent(), + 'slug' => $newSlug, 'updated_at' => date('Y-m-d H:i:s'), ], ['id' => $id]); diff --git a/src/Models/Post.php b/src/Models/Post.php index 47cadfe..c977945 100644 --- a/src/Models/Post.php +++ b/src/Models/Post.php @@ -118,11 +118,15 @@ final class Post public function getExcerpt(int $length = 150): string { $text = strip_tags($this->content); - return mb_strlen($text) > $length + $excerpt = mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '...' : $text; + + // Échapper les caractères HTML restants + return htmlspecialchars($excerpt, ENT_QUOTES, 'UTF-8'); } + /** * Retourne un slug (URL-friendly) du titre. * @@ -147,4 +151,10 @@ final class Post 'content' => $this->content, ]; } + + public function withSanitizedContent(string $content): self + { + return new self($this->id, $this->title, $content, $this->createdAt, $this->updatedAt); + } + } diff --git a/src/Services/HtmlSanitizer.php b/src/Services/HtmlSanitizer.php new file mode 100644 index 0000000..5792c73 --- /dev/null +++ b/src/Services/HtmlSanitizer.php @@ -0,0 +1,32 @@ +set('HTML.Allowed', 'p,br,strong,em,u,h1,h2,h3,h4,h5,h6,ul,ol,li,blockquote,a[href],img[src|alt|width|height]'); + // Désactiver les attributs dangereux + $config->set('HTML.AllowedAttributes', 'href,src,alt,width,height,title'); + // Activer le cache + $config->set('Cache.DefinitionImpl', 'Serializer'); + $config->set('Cache.SerializerPath', __DIR__ . '/../../var/cache/htmlpurifier'); + + $this->purifier = new HTMLPurifier($config); + } + + public function sanitize(string $html): string + { + return $this->purifier->purify($html); + } +} diff --git a/views/pages/post_detail.twig b/views/pages/post_detail.twig index b35b9ca..9270f17 100644 --- a/views/pages/post_detail.twig +++ b/views/pages/post_detail.twig @@ -18,7 +18,7 @@ {% endif %}