Varous improvements

This commit is contained in:
julien
2026-03-09 16:07:17 +01:00
parent edb1752f32
commit 03ce72ce00
7 changed files with 203 additions and 108 deletions

View File

@@ -2,7 +2,7 @@
* [PHP](https://www.php.net) comme langage * [PHP](https://www.php.net) comme langage
* [Slim](https://www.slimframework.com) comme framework * [Slim](https://www.slimframework.com) comme framework
* [RedBeanPHP](https://www.redbeanphp.com/index.php) comme ORM * [Medoo](https://medoo.in) comme ORM
* [Twig](https://twig.symfony.com) comme template engine * [Twig](https://twig.symfony.com) comme template engine
## HOWTO ## HOWTO
@@ -12,12 +12,6 @@ Installer les dépendances :
$ composer install $ composer install
``` ```
Initialiser la base de données :
```
$ mkdir database
$ php scripts/migrations.php
```
Lancer le serveur de développement : Lancer le serveur de développement :
``` ```
$ php -S localhost:8080 -t public $ php -S localhost:8080 -t public

View File

@@ -5,16 +5,22 @@ declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv; use Dotenv\Dotenv;
// Charger les variables d'environnement
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
use Slim\Factory\AppFactory; use Slim\Factory\AppFactory;
use Slim\Views\TwigMiddleware; use Slim\Views\TwigMiddleware;
use Slim\Views\Twig; use Slim\Views\Twig;
use Medoo\Medoo; use Medoo\Medoo;
use App\Controllers\PostController; use App\Controllers\PostController;
use App\Repositories\PostRepository;
use App\Services\HtmlSanitizer;
use App\Routes;
use App\Config;
// ============================================
// Charger les variables d'environnement
// ============================================
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// ============================================ // ============================================
// Configuration // Configuration
@@ -23,26 +29,6 @@ use App\Controllers\PostController;
$env = $_ENV['APP_ENV'] ?? 'production'; $env = $_ENV['APP_ENV'] ?? 'production';
$isDev = strtolower($env) === 'development'; $isDev = strtolower($env) === 'development';
// Dossier de cache Twig (false en dev, chemin en prod)
$twigCache = false;
if (!$isDev) {
$twigCache = __DIR__ . '/../var/cache/twig';
if (!is_dir($twigCache)) {
@mkdir($twigCache, 0755, true);
}
}
// Chemin base de données
$dbFile = __DIR__ . '/../database/app.sqlite';
$dbDir = dirname($dbFile);
if (!is_dir($dbDir)) {
@mkdir($dbDir, 0755, true);
}
if (!file_exists($dbFile)) {
@touch($dbFile);
@chmod($dbFile, 0664);
}
// ============================================ // ============================================
// Initialisation des services // Initialisation des services
// ============================================ // ============================================
@@ -50,10 +36,11 @@ if (!file_exists($dbFile)) {
// Twig // Twig
$twig = Twig::create( $twig = Twig::create(
__DIR__ . '/../views', __DIR__ . '/../views',
['cache' => $twigCache] ['cache' => Config::getTwigCache($isDev)]
); );
// Medoo (SQLite) // Medoo (SQLite)
$dbFile = Config::getDatabasePath();
$db = new Medoo([ $db = new Medoo([
'type' => 'sqlite', 'type' => 'sqlite',
'database' => $dbFile, 'database' => $dbFile,
@@ -71,8 +58,11 @@ $db->pdo->exec("
) )
"); ");
// Sanitizer HTML // HtmlSanitizer
$htmlSanitizer = new \App\Services\HtmlSanitizer(); $htmlSanitizer = new HtmlSanitizer();
// PostRepository
$postRepository = new PostRepository($db);
// ============================================ // ============================================
// Slim App // Slim App
@@ -86,19 +76,17 @@ $app->add(TwigMiddleware::create($app, $twig));
// Routes // Routes
// ============================================ // ============================================
$controller = new PostController($twig, $db, $htmlSanitizer); $controller = new PostController($twig, $postRepository, $htmlSanitizer);
Routes::register($app, $controller);
$app->get('/', [$controller, 'index']); // ============================================
$app->get('/article/{slug}', [$controller, 'show']); // Error Handling
$app->get('/admin', [$controller, 'admin']); // ============================================
$app->get('/admin/edit/{id}', [$controller, 'form']);
$app->post('/admin/create', [$controller, 'create']); $errorMiddleware = $app->addErrorMiddleware($isDev, $isDev, $isDev);
$app->post('/admin/edit/{id}', [$controller, 'update']);
$app->post('/admin/delete/{id}', [$controller, 'delete']);
// ============================================ // ============================================
// Run // Run
// ============================================ // ============================================
$errorMiddleware = $app->addErrorMiddleware($isDev, $isDev, $isDev);
$app->run(); $app->run();

34
src/Config.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App;
final class Config
{
public static function getTwigCache(bool $isDev): string|bool
{
if ($isDev) {
return false;
}
$path = __DIR__ . '/../var/cache/twig';
if (!is_dir($path)) {
@mkdir($path, 0755, true);
}
return $path;
}
public static function getDatabasePath(): string
{
$path = __DIR__ . '/../database/app.sqlite';
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
if (!file_exists($path)) {
@touch($path);
@chmod($path, 0664);
}
return $path;
}
}

View File

@@ -7,8 +7,9 @@ namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig; use Slim\Views\Twig;
use Medoo\Medoo; use App\Repositories\PostRepository;
use App\Models\Post; use App\Models\Post;
use App\Services\HtmlSanitizer;
/** /**
* Contrôleur pour les posts. * Contrôleur pour les posts.
@@ -17,8 +18,8 @@ class PostController
{ {
public function __construct( public function __construct(
private Twig $view, private Twig $view,
private Medoo $db, private PostRepository $postRepository,
private \App\Services\HtmlSanitizer $sanitizer private HtmlSanitizer $sanitizer
) { ) {
} }
@@ -27,9 +28,7 @@ class PostController
*/ */
public function index(Request $req, Response $res): Response public function index(Request $req, Response $res): Response
{ {
$rows = $this->db->select('post', '*', ['ORDER' => ['id' => 'DESC']]); $posts = $this->postRepository->findAll();
$posts = array_map(fn ($row) => Post::fromArray($row), $rows ?: []);
return $this->view->render($res, 'pages/home.twig', ['posts' => $posts]); return $this->view->render($res, 'pages/home.twig', ['posts' => $posts]);
} }
@@ -39,16 +38,15 @@ class PostController
public function show(Request $req, Response $res, array $args): Response public function show(Request $req, Response $res, array $args): Response
{ {
$slug = (string)($args['slug'] ?? ''); $slug = (string)($args['slug'] ?? '');
$row = $this->db->get('post', '*', ['slug' => $slug]); $post = $this->postRepository->findBySlug($slug);
if (!$row) { if (!$post) {
$res->getBody()->write('Article non trouvé'); $res->getBody()->write('Article non trouvé');
return $res->withStatus(404); return $res->withStatus(404);
} }
$post = Post::fromArray($row); $sanitizedContent = $this->sanitizer->sanitize($post->getContent());
// Nettoyer le contenu avant de l'envoyer au template $post = $post->withSanitizedContent($sanitizedContent);
$post = $post->withSanitizedContent($this->sanitizer->sanitize($post->getContent()));
return $this->view->render($res, 'pages/post_detail.twig', ['post' => $post]); return $this->view->render($res, 'pages/post_detail.twig', ['post' => $post]);
} }
@@ -58,9 +56,7 @@ class PostController
*/ */
public function admin(Request $req, Response $res): Response public function admin(Request $req, Response $res): Response
{ {
$rows = $this->db->select('post', '*', ['ORDER' => ['id' => 'DESC']]); $posts = $this->postRepository->findAll();
$posts = array_map(fn ($row) => Post::fromArray($row), $rows ?: []);
return $this->view->render($res, 'pages/admin.twig', ['posts' => $posts]); return $this->view->render($res, 'pages/admin.twig', ['posts' => $posts]);
} }
@@ -73,12 +69,11 @@ class PostController
$post = null; $post = null;
if ($id > 0) { if ($id > 0) {
$row = $this->db->get('post', '*', ['id' => $id]); $post = $this->postRepository->findById($id);
if (!$row) { if (!$post) {
$res->getBody()->write('Article non trouvé'); $res->getBody()->write('Article non trouvé');
return $res->withStatus(404); return $res->withStatus(404);
} }
$post = Post::fromArray($row);
} }
$action = $id ? "/admin/edit/{$id}" : "/admin/create"; $action = $id ? "/admin/edit/{$id}" : "/admin/create";
@@ -97,41 +92,19 @@ class PostController
$title = trim((string)($data['title'] ?? '')); $title = trim((string)($data['title'] ?? ''));
$content = trim((string)($data['content'] ?? '')); $content = trim((string)($data['content'] ?? ''));
try {
$post = new Post(0, $title, $content); $post = new Post(0, $title, $content);
$slug = $post->getSlug(); } catch (\InvalidArgumentException) {
// Validation échouée, retour à l'admin
// Gérer les collisions
$slug = $this->ensureUniqueSlug($slug);
$this->db->insert('post', [
'title' => $post->getTitle(),
'content' => $post->getContent(),
'slug' => $slug,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $res->withHeader('Location', '/admin')->withStatus(302); return $res->withHeader('Location', '/admin')->withStatus(302);
} }
private function ensureUniqueSlug(string $slug, ?int $excludeId = null): string $slug = $post->getSlug();
{ $slug = $this->ensureUniqueSlug($slug);
$original = $slug;
$counter = 1;
while (true) { $this->postRepository->create($post, $slug);
$existing = $this->db->get('post', 'id', ['slug' => $slug]);
// Si pas de collision, ou si c'est le même post, le slug est unique return $res->withHeader('Location', '/admin')->withStatus(302);
if (!$existing || ($excludeId && $existing === $excludeId)) {
break;
}
$slug = $original . '-' . $counter;
$counter++;
}
return $slug;
} }
/** /**
@@ -140,7 +113,7 @@ class PostController
public function update(Request $req, Response $res, array $args): Response public function update(Request $req, Response $res, array $args): Response
{ {
$id = (int)$args['id']; $id = (int)$args['id'];
$existing = $this->db->get('post', 'id', ['id' => $id]); $existing = $this->postRepository->findById($id);
if (!$existing) { if (!$existing) {
$res->getBody()->write('Article non trouvé'); $res->getBody()->write('Article non trouvé');
@@ -151,7 +124,6 @@ class PostController
$title = trim((string)($data['title'] ?? '')); $title = trim((string)($data['title'] ?? ''));
$content = trim((string)($data['content'] ?? '')); $content = trim((string)($data['content'] ?? ''));
// Créer un objet Post pour valider
try { try {
$post = new Post($id, $title, $content); $post = new Post($id, $title, $content);
} catch (\InvalidArgumentException) { } catch (\InvalidArgumentException) {
@@ -159,17 +131,10 @@ class PostController
return $res->withHeader('Location', '/admin')->withStatus(302); return $res->withHeader('Location', '/admin')->withStatus(302);
} }
// Calculer le nouveau slug
$newSlug = $post->getSlug(); $newSlug = $post->getSlug();
$newSlug = $this->ensureUniqueSlug($newSlug, $id); // Passer l'ID pour exclure le post actuel $newSlug = $this->ensureUniqueSlug($newSlug, $id);
// Persister en DB $this->postRepository->update($id, $post, $newSlug);
$this->db->update('post', [
'title' => $post->getTitle(),
'content' => $post->getContent(),
'slug' => $newSlug,
'updated_at' => date('Y-m-d H:i:s'),
], ['id' => $id]);
return $res->withHeader('Location', '/admin')->withStatus(302); return $res->withHeader('Location', '/admin')->withStatus(302);
} }
@@ -180,14 +145,34 @@ class PostController
public function delete(Request $req, Response $res, array $args): Response public function delete(Request $req, Response $res, array $args): Response
{ {
$id = (int)$args['id']; $id = (int)$args['id'];
$existing = $this->db->get('post', 'id', ['id' => $id]); $existing = $this->postRepository->findById($id);
if (!$existing) { if (!$existing) {
$res->getBody()->write('Article non trouvé'); $res->getBody()->write('Article non trouvé');
return $res->withStatus(404); return $res->withStatus(404);
} }
$this->db->delete('post', ['id' => $id]); $this->postRepository->delete($id);
return $res->withHeader('Location', '/admin')->withStatus(302); return $res->withHeader('Location', '/admin')->withStatus(302);
} }
/**
* Assure l'unicité d'un slug en ajoutant un suffixe numérique si nécessaire.
*
* @param string $slug Le slug de base
* @param int|null $excludeId L'ID du post à exclure de la vérification (pour les mises à jour)
* @return string Le slug unique
*/
private function ensureUniqueSlug(string $slug, ?int $excludeId = null): string
{
$original = $slug;
$counter = 1;
while ($this->postRepository->slugExists($slug, $excludeId)) {
$slug = $original . '-' . $counter;
$counter++;
}
return $slug;
}
} }

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use Medoo\Medoo;
use App\Models\Post;
final class PostRepository
{
public function __construct(private Medoo $db)
{
}
public function findAll(): array
{
$rows = $this->db->select('post', '*', ['ORDER' => ['id' => 'DESC']]);
return array_map(fn ($row) => Post::fromArray($row), $rows ?: []);
}
public function findBySlug(string $slug): ?Post
{
$row = $this->db->get('post', '*', ['slug' => $slug]);
return $row ? Post::fromArray($row) : null;
}
public function findById(int $id): ?Post
{
$row = $this->db->get('post', '*', ['id' => $id]);
return $row ? Post::fromArray($row) : null;
}
public function create(Post $post, string $slug): int
{
$this->db->insert('post', [
'title' => $post->getTitle(),
'content' => $post->getContent(),
'slug' => $slug,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->db->id();
}
public function update(int $id, Post $post, string $slug): void
{
$this->db->update('post', [
'title' => $post->getTitle(),
'content' => $post->getContent(),
'slug' => $slug,
'updated_at' => date('Y-m-d H:i:s'),
], ['id' => $id]);
}
public function delete(int $id): void
{
$this->db->delete('post', ['id' => $id]);
}
public function slugExists(string $slug, ?int $excludeId = null): bool
{
$existing = $this->db->get('post', 'id', ['slug' => $slug]);
return $existing && (!$excludeId || $existing !== $excludeId);
}
}

22
src/Routes.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App;
use Slim\App;
use App\Controllers\PostController;
final class Routes
{
public static function register(App $app, PostController $controller): void
{
$app->get('/', [$controller, 'index']);
$app->get('/article/{slug}', [$controller, 'show']);
$app->get('/admin', [$controller, 'admin']);
$app->get('/admin/edit/{id}', [$controller, 'form']);
$app->post('/admin/create', [$controller, 'create']);
$app->post('/admin/edit/{id}', [$controller, 'update']);
$app->post('/admin/delete/{id}', [$controller, 'delete']);
}
}

View File

@@ -13,6 +13,12 @@ final class HtmlSanitizer
public function __construct() public function __construct()
{ {
// Créer le répertoire de cache s'il n'existe pas
$cacheDir = __DIR__ . '/../../var/cache/htmlpurifier';
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0755, true);
}
$config = HTMLPurifier_Config::createDefault(); $config = HTMLPurifier_Config::createDefault();
// Autoriser les balises courantes de formatage // Autoriser les balises courantes de formatage
$config->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]'); $config->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]');
@@ -20,7 +26,7 @@ final class HtmlSanitizer
$config->set('HTML.AllowedAttributes', 'href,src,alt,width,height,title'); $config->set('HTML.AllowedAttributes', 'href,src,alt,width,height,title');
// Activer le cache // Activer le cache
$config->set('Cache.DefinitionImpl', 'Serializer'); $config->set('Cache.DefinitionImpl', 'Serializer');
$config->set('Cache.SerializerPath', __DIR__ . '/../../var/cache/htmlpurifier'); $config->set('Cache.SerializerPath', $cacheDir);
$this->purifier = new HTMLPurifier($config); $this->purifier = new HTMLPurifier($config);
} }