From c17cad9c66f9234541cda7eeb80fcfb81f08174c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Fri, 15 May 2026 23:36:09 +0200 Subject: [PATCH 01/16] =?UTF-8?q?nettoyage=20&=20typo=20:=20dead=20code,?= =?UTF-8?q?=20helpers=20factoris=C3=A9s,=20guillemets=20courbes=20(v1.6.13?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #19 : suppression AuthService / UserRepository / Domain\User — dead code incompatible session - #22 : env() et db() centralisés dans src/helpers.php, chargé par config/config.php - #15 : typographieHtml() appliquée après Parsedown dans post_view.php Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 11 +++ config/config.php | 2 + public/login/index.php | 29 ------- public/login/magic.php | 17 ---- public/oidc/callback.php | 14 ---- public/oidc/start.php | 14 ---- public/version.txt | 2 +- src/Domain/User.php | 16 ---- src/Repository/UserRepository.php | 129 ------------------------------ src/Service/AuthService.php | 105 ------------------------ src/helpers.php | 66 +++++++++++++++ templates/post_view.php | 1 + 12 files changed, 81 insertions(+), 325 deletions(-) delete mode 100644 src/Domain/User.php delete mode 100644 src/Repository/UserRepository.php delete mode 100644 src/Service/AuthService.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b2ad0..ebd6242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.13] - 2026-05-15 + +### Ajouté +- Typographie : guillemets droits convertis en guillemets courbes (`"` → `"` / `"`, `'` → `'` / `'`) dans le rendu des articles — blocs `` et `
` préservés (#15)
+
+### Corrigé
+- Suppression du dead code : `AuthService`, `UserRepository` et `Domain\User` — incompatibles avec le système de session actuel, aucune référence active (#19)
+- Factorisation des helpers `env()` et `db()` dans `src/helpers.php`, chargé par `config/config.php` — plus de triple définition dans les pages login/OIDC (#22)
+
+---
+
 ## [1.6.12] - 2026-05-15
 
 ### Ajouté
diff --git a/config/config.php b/config/config.php
index 2c6b447..4d6f9f5 100644
--- a/config/config.php
+++ b/config/config.php
@@ -44,3 +44,5 @@ if (!function_exists('url')) {
         return $u;
     }
 }
+
+require_once BASE_PATH . '/src/helpers.php';
diff --git a/public/login/index.php b/public/login/index.php
index 43c669a..51a680b 100644
--- a/public/login/index.php
+++ b/public/login/index.php
@@ -6,35 +6,6 @@ declare(strict_types=1);
 
 use App\Http\Csrf;
 
-// --- Helpers AVANT tout usage ---
-if (!function_exists('env')) {
-    function env(string $key, ?string $default = null): ?string
-    {
-        if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
-            return (string)$_ENV[$key];
-        }
-        $v = getenv($key);
-        if ($v !== false && $v !== '') {
-            return (string)$v;
-        }
-        return $default;
-    }
-}
-if (!function_exists('db')) {
-    function db(): \PDO
-    {
-        return \App\Infrastructure\Database::get();
-    }
-}
-if (!function_exists('url')) {
-    function url(string $path = '/'): string
-    {
-        $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
-        $host   = $_SERVER['HTTP_HOST'] ?? 'localhost';
-        return $scheme . '://' . $host . $path;
-    }
-}
-
 if (!defined('BASE_PATH')) {
     define('BASE_PATH', dirname(__DIR__, 2));
 }
diff --git a/public/login/magic.php b/public/login/magic.php
index e43135e..674c295 100644
--- a/public/login/magic.php
+++ b/public/login/magic.php
@@ -12,23 +12,6 @@ require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
 require_once dirname(__DIR__, 2) . '/config/config.php';
 require_once dirname(__DIR__, 2) . '/bootstrap.php';
 
-// si tu as un service pour ouvrir une session
-
-if (!function_exists('db')) {
-    function db(): PDO
-    {
-        return \App\Infrastructure\Database::get();
-    }
-}
-if (!function_exists('url')) {
-    function url(string $path = '/'): string
-    {
-        $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
-        $host   = $_SERVER['HTTP_HOST'] ?? 'localhost';
-        return $scheme . '://' . $host . $path;
-    }
-}
-
 $token = (string)($_GET['token'] ?? '');
 if ($token === '' || preg_match('/[^A-Za-z0-9\-\_]/', $token)) {
     http_response_code(400);
diff --git a/public/oidc/callback.php b/public/oidc/callback.php
index 549a168..fb712b5 100644
--- a/public/oidc/callback.php
+++ b/public/oidc/callback.php
@@ -9,20 +9,6 @@ require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
 require_once dirname(__DIR__, 2) . '/config/config.php';
 require_once dirname(__DIR__, 2) . '/bootstrap.php';
 
-if (!function_exists('env')) {
-    function env(string $key, ?string $default = null): ?string
-    {
-        if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
-            return (string)$_ENV[$key];
-        }
-        $v = getenv($key);
-        if ($v !== false && $v !== '') {
-            return (string)$v;
-        }
-        return $default;
-    }
-}
-
 $debug = (env('APP_DEBUG', '0') === '1');
 
 $OIDC_ISSUER        = rtrim((string)(env('OIDC_ISSUER') ?? ''), '/');
diff --git a/public/oidc/start.php b/public/oidc/start.php
index ce1ca37..7f8c844 100644
--- a/public/oidc/start.php
+++ b/public/oidc/start.php
@@ -16,20 +16,6 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
     exit;
 }
 
-if (!function_exists('env')) {
-    function env(string $key, ?string $default = null): ?string
-    {
-        if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
-            return (string)$_ENV[$key];
-        }
-        $v = getenv($key);
-        if ($v !== false && $v !== '') {
-            return (string)$v;
-        }
-        return $default;
-    }
-}
-
 $flow = $_GET['flow'] ?? 'login';                  // 'login' ou 'register'
 if (!in_array($flow, ['login','register'], true)) {
     $flow = 'login';
diff --git a/public/version.txt b/public/version.txt
index 9e7398a..d4ca915 100644
--- a/public/version.txt
+++ b/public/version.txt
@@ -1 +1 @@
-1.6.12
+1.6.13
diff --git a/src/Domain/User.php b/src/Domain/User.php
deleted file mode 100644
index da5e745..0000000
--- a/src/Domain/User.php
+++ /dev/null
@@ -1,16 +0,0 @@
-pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
-        $st->execute([':email' => $email]);
-        $id = $st->fetchColumn();
-        if ($id !== false && $id !== null) {
-            return (string)$id;
-        }
-
-        // 2) Création
-        // Génère un hash robuste sur une valeur aléatoire (aucune chance de connexion par mot de passe).
-        $randomSecret = bin2hex(random_bytes(32));
-        $randomHash   = password_hash($randomSecret, PASSWORD_DEFAULT);
-
-        $sql = <<pdo->prepare($sql);
-            $st->execute([
-                ':email' => $email,
-                ':hash'  => $randomHash,
-            ]);
-            return (string)$st->fetchColumn();
-        } catch (\PDOException $e) {
-            // Unique violation sur email (23505) → on relit l’id (race condition)
-            if ($e->getCode() === '23505') {
-                $st = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
-                $st->execute([':email' => $email]);
-                $id = $st->fetchColumn();
-                if ($id !== false && $id !== null) {
-                    return (string)$id;
-                }
-            }
-            throw $e;
-        }
-    }
-
-    public function findByEmail(string $email): ?User
-    {
-        $sql = 'SELECT id, email, password_hash, is_active FROM users WHERE email = :email LIMIT 1';
-        $st  = $this->pdo->prepare($sql);
-        $st->execute([':email' => $email]);
-        $row = $st->fetch(PDO::FETCH_ASSOC);
-        if (!$row) {
-            return null;
-        }
-
-        $isActive = $this->toBool($row['is_active']);
-
-        return new User(
-            (string)$row['id'],
-            (string)$row['email'],
-            (string)$row['password_hash'],
-            $isActive
-        );
-    }
-
-    public function create(string $email, string $passwordHash): string
-    {
-        // PostgreSQL
-        $sql = 'INSERT INTO users (email, password_hash) VALUES (:email, :hash) RETURNING id';
-        $st  = $this->pdo->prepare($sql);
-        $st->execute([':email' => $email, ':hash' => $passwordHash]);
-        return (string)$st->fetchColumn();
-    }
-
-    public function updatePassword(string $userId, string $newHash): void
-    {
-        $sql = <<pdo->prepare($sql);
-        $st->execute([':h' => $newHash, ':id' => $userId]);
-    }
-
-    /**
-     * Normalise un bool venant de PDO/pgsql ('t','f',1,0,true,false,'1','0','true','false')
-     */
-    private function toBool(mixed $v): bool
-    {
-        if (is_bool($v)) {
-            return $v;
-        }
-        if (is_int($v)) {
-            return $v === 1;
-        }
-        if (is_string($v)) {
-            $v = strtolower($v);
-            return in_array($v, ['t', '1', 'true', 'on', 'yes'], true);
-        }
-        return (bool)$v;
-    }
-}
diff --git a/src/Service/AuthService.php b/src/Service/AuthService.php
deleted file mode 100644
index 4169fe7..0000000
--- a/src/Service/AuthService.php
+++ /dev/null
@@ -1,105 +0,0 @@
- now() - interval '5 minutes'
-           and success = false";
-        $st = \App\Infrastructure\Database::pdo()->prepare($sql);
-        $st->execute([':ip' => $ip]);
-        $fails = (int)$st->fetchColumn();
-        return $fails < 10; // à ajuster
-    }
-
-
-
-    public function login(string $email, string $password, string $ip): bool
-    {
-        $user = $this->users->findByEmail($email);
-        $ok = $user && $user->isActive && password_verify($password, $user->passwordHash);
-
-        $pdo = \App\Infrastructure\Database::pdo();
-        $st  = $pdo->prepare('insert into login_attempts(email, ip, success) values(:e, :ip, :s)');
-        $st->bindValue(':e', $email, \PDO::PARAM_STR);
-        $st->bindValue(':ip', $ip, \PDO::PARAM_STR);
-        $st->bindValue(':s', $ok, \PDO::PARAM_BOOL);
-        $st->execute();
-
-        if ($ok) {
-            \App\Infrastructure\Session::regenerate();
-            $_SESSION['uid'] = $user->id;
-            $_SESSION['email'] = $user->email;
-        }
-        return $ok;
-    }
-
-
-    public function changePassword(string $userId, string $currentPassword, string $newPassword): bool
-    {
-        // Récupération de l’utilisateur (rapide : requête directe ; tu peux créer findById() si tu préfères)
-        $pdo = \App\Infrastructure\Database::pdo();
-        $st  = $pdo->prepare('select id, email, password_hash, is_active from users where id = :id');
-        $st->execute([':id' => $userId]);
-        $row = $st->fetch(\PDO::FETCH_ASSOC);
-        if (!$row || !(bool)$row['is_active']) {
-            return false;
-        }
-
-        // Vérifier l’ancien mot de passe
-        if (!password_verify($currentPassword, (string)$row['password_hash'])) {
-            return false;
-        }
-
-        // Politique minimale : longueur uniquement (espaces autorisés)
-        if (mb_strlen($newPassword) < 7) {
-            return false;
-        }
-        // (optionnel) interdire seulement le caractère NUL
-        if (strpos($newPassword, "\0") !== false) {
-            return false;
-        }
-
-        // Mettre à jour le hash
-        $newHash = password_hash($newPassword, PASSWORD_ARGON2ID);
-        (new \App\Repository\UserRepository(\App\Infrastructure\Database::get()))->updatePassword($row['id'], $newHash);
-
-        // (Optionnel) rotation session
-        \App\Infrastructure\Session::regenerate();
-        return true;
-    }
-
-    public function register(string $email, string $password): string
-    {
-        $hash = password_hash($password, PASSWORD_ARGON2ID);
-        return $this->users->create($email, $hash);
-    }
-
-    public static function requireAuth(): void
-    {
-        if (!isset($_SESSION['uid'])) {
-            header('Location: /login');
-            exit;
-        }
-    }
-
-    public static function logout(): void
-    {
-        $_SESSION = [];
-        session_destroy();
-    }
-}
diff --git a/src/helpers.php b/src/helpers.php
index 96e10d8..e0867df 100644
--- a/src/helpers.php
+++ b/src/helpers.php
@@ -2,6 +2,27 @@
 
 declare(strict_types=1);
 
+if (!function_exists('env')) {
+    function env(string $key, ?string $default = null): ?string
+    {
+        if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
+            return (string)$_ENV[$key];
+        }
+        $v = getenv($key);
+        if ($v !== false && $v !== '') {
+            return (string)$v;
+        }
+        return $default;
+    }
+}
+
+if (!function_exists('db')) {
+    function db(): \PDO
+    {
+        return \App\Infrastructure\Database::get();
+    }
+}
+
 function vd($var, ...$moreVars)
 {
     ob_start();
@@ -149,3 +170,48 @@ function _paletteGradient(array $rgb, int $tier): string
 
     return "linear-gradient({$angle}deg,rgb($tr,$tg,$tb) 0%,rgb($sr,$sg,$sb) 100%)";
 }
+
+/**
+ * Post-traite le HTML produit par Parsedown pour y appliquer la typographie française :
+ * guillemets droits → guillemets courbes, apostrophes droites → apostrophes courbes.
+ * Le contenu des balises 
 et  est strictement préservé.
+ */
+function typographieHtml(string $html): string
+{
+    // Protéger les blocs pre/code (y compris imbriqués)
+    $protected = [];
+    $html = preg_replace_callback(
+        '#<(pre|code)(\b[^>]*)>(.*?)#si',
+        static function (array $m) use (&$protected): string {
+            $key = "\x02" . count($protected) . "\x03";
+            $protected[$key] = $m[0];
+            return $key;
+        },
+        $html
+    ) ?? $html;
+
+    // Traiter uniquement les nœuds texte (entre les balises HTML)
+    $html = preg_replace_callback(
+        '#(<[^>]+>)|([^<]+)#s',
+        static function (array $m): string {
+            if ($m[1] !== '') {
+                return $m[1]; // balise HTML — intacte
+            }
+            $t = $m[2];
+            // Guillemets doubles : précédé d'un mot → fermant, sinon → ouvrant
+            $t = preg_replace('/(?<=\w)"/u', "\u{201D}", $t);
+            $t = str_replace('"', "\u{201C}", $t);
+            // Apostrophes / guillemets simples : précédé d'un mot → fermant/apostrophe, sinon → ouvrant
+            $t = preg_replace("/(?<=\w)'/u", "\u{2019}", $t);
+            $t = str_replace("'", "\u{2018}", $t);
+            return $t;
+        },
+        $html
+    ) ?? $html;
+
+    // Restaurer les blocs protégés
+    if ($protected) {
+        $html = str_replace(array_keys($protected), array_values($protected), $html);
+    }
+    return $html;
+}
diff --git a/templates/post_view.php b/templates/post_view.php
index e47f181..2246962 100644
--- a/templates/post_view.php
+++ b/templates/post_view.php
@@ -35,6 +35,7 @@ $_renderedContent = preg_replace_callback(
     },
     $Parsedown->text($_rawForRender)
 );
+$_renderedContent = typographieHtml($_renderedContent ?? '');
 
 ob_start();
 

From 347e4be0b7f387b2b2afa8d9628fb90705918881 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=C3=A9drix?= 
Date: Fri, 15 May 2026 23:50:58 +0200
Subject: [PATCH 02/16] perf : getAll() sans contenu, search_index + featured,
 excerpts via plain (v1.6.14)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- loadArticle($dir, false) dans loadAll() — meta.json seulement, pas d'index.md
- loadAll() enrichit les articles avec plain depuis search_index (1 lecture JSON)
- rebuildSearchIndex() lit index.md directement + ajoute featured au schéma
- getSearchIndex() rebuilde automatiquement si featured absent
- post_list, author_articles, author_profile : excerpts via plain, plus de Parsedown
- Ferme #24

Co-Authored-By: Claude Sonnet 4.6 
---
 CHANGELOG.md                  |  9 +++++
 public/version.txt            |  2 +-
 src/ArticleManager.php        | 70 ++++++++++++++++++++++++-----------
 templates/author_articles.php |  5 +--
 templates/author_profile.php  |  5 +--
 templates/post_list.php       |  8 +++-
 6 files changed, 67 insertions(+), 32 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebd6242..f5e2cb1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
 
 ---
 
+## [1.6.14] - 2026-05-15
+
+### Modifié
+- Perf : `getAll()` ne charge plus le contenu Markdown — `loadArticle()` reçoit `$withContent = false` dans `loadAll()`, seul `getByUuid()` lit encore `index.md` (#24)
+- Perf : `search_index.json` enrichi du champ `featured` ; `rebuildSearchIndex()` lit `index.md` directement (indépendant du cache article)
+- Perf : excerpts dans `post_list`, `author_articles`, `author_profile` proviennent du champ `plain` pré-calculé — plus de passage par Parsedown (#24)
+
+---
+
 ## [1.6.13] - 2026-05-15
 
 ### Ajouté
diff --git a/public/version.txt b/public/version.txt
index d4ca915..5577648 100644
--- a/public/version.txt
+++ b/public/version.txt
@@ -1 +1 @@
-1.6.13
+1.6.14
diff --git a/src/ArticleManager.php b/src/ArticleManager.php
index 36a2f4b..82b8ae7 100644
--- a/src/ArticleManager.php
+++ b/src/ArticleManager.php
@@ -44,7 +44,7 @@ class ArticleManager
                 continue;
             }
 
-            $article = $this->loadArticle($dir);
+            $article = $this->loadArticle($dir, false);
             if (!$article) {
                 continue;
             }
@@ -53,6 +53,19 @@ class ArticleManager
 
         usort($articles, static fn ($a, $b) => strcmp($b['published_at'] ?? '', $a['published_at'] ?? ''));
 
+        // Enrichir avec le plain text pré-calculé (pour les excerpts sans charger index.md)
+        $siPath = $this->dataDir . '/search_index.json';
+        if (file_exists($siPath)) {
+            $si = json_decode((string)file_get_contents($siPath), true);
+            if (is_array($si)) {
+                $plainByUuid = array_column($si, 'plain', 'uuid');
+                foreach ($articles as &$a) {
+                    $a['plain'] = $plainByUuid[$a['uuid']] ?? '';
+                }
+                unset($a);
+            }
+        }
+
         return $articles;
     }
 
@@ -944,19 +957,25 @@ class ArticleManager
     {
         $index = [];
         foreach ($this->getAll() as $article) {
+            $uuid        = $article['uuid'] ?? '';
+            $contentPath = $this->dataDir . '/' . $uuid . '/index.md';
+            $content     = $uuid !== '' && file_exists($contentPath)
+                ? (string)file_get_contents($contentPath)
+                : '';
             $index[] = [
-                'uuid'         => $article['uuid'],
+                'uuid'         => $uuid,
                 'slug'         => $article['slug'] ?? '',
                 'title'        => $article['title'] ?? '',
                 'category'     => $article['category'] ?? '',
                 'author'       => $article['author'] ?? '',
                 'cover'        => $article['cover'] ?? '',
+                'featured'     => (bool)($article['featured'] ?? false),
                 'published'    => $article['published'],
                 'published_at' => $article['published_at'] ?? '',
                 'created_at'   => $article['created_at'] ?? '',
                 'updated_at'   => $article['updated_at'] ?? '',
                 'tags'         => $article['tags'] ?? [],
-                'plain'        => $this->stripForIndex($article['content'] ?? ''),
+                'plain'        => $this->stripForIndex($content),
             ];
         }
         file_put_contents(
@@ -1027,8 +1046,8 @@ class ArticleManager
         if (!is_array($data) || empty($data)) {
             return null;
         }
-        // Rebuild automatique si le format est obsolète (champs cover/created_at absents)
-        if (!array_key_exists('cover', $data[0])) {
+        // Rebuild automatique si le format est obsolète (champs manquants)
+        if (!array_key_exists('cover', $data[0]) || !array_key_exists('featured', $data[0])) {
             $this->rebuildSearchIndex();
             return $this->searchIndexCache;
         }
@@ -1200,21 +1219,22 @@ class ArticleManager
         };
     }
 
-    private function loadArticle(string $dir): ?array
+    private function loadArticle(string $dir, bool $withContent = true): ?array
     {
-        $metaPath  = $dir . '/meta.json';
+        $metaPath = $dir . '/meta.json';
         if (!file_exists($metaPath)) {
             return null;
         }
-        $uuid      = basename($dir);
-        $cachePath = $this->articleCachePath($uuid);
 
-        // Utiliser le cache si plus récent que meta.json ET index.md
-        $contentMtime = file_exists($dir . '/index.md') ? filemtime($dir . '/index.md') : 0;
-        if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath) && filemtime($cachePath) >= $contentMtime) {
-            $cached = json_decode((string) file_get_contents($cachePath), true);
-            if (is_array($cached) && !empty($cached['uuid'])) {
-                return $cached;
+        if ($withContent) {
+            $uuid         = basename($dir);
+            $cachePath    = $this->articleCachePath($uuid);
+            $contentMtime = file_exists($dir . '/index.md') ? filemtime($dir . '/index.md') : 0;
+            if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath) && filemtime($cachePath) >= $contentMtime) {
+                $cached = json_decode((string) file_get_contents($cachePath), true);
+                if (is_array($cached) && !empty($cached['uuid'])) {
+                    return $cached;
+                }
             }
         }
 
@@ -1227,8 +1247,11 @@ class ArticleManager
             return null;
         }
 
-        $contentPath            = $dir . '/index.md';
-        $meta['content']        = file_exists($contentPath) ? (string)file_get_contents($contentPath) : '';
+        if ($withContent) {
+            $contentPath     = $dir . '/index.md';
+            $meta['content'] = file_exists($contentPath) ? (string)file_get_contents($contentPath) : '';
+        }
+
         $meta['published']      = (bool)($meta['published'] ?? false);
         $meta['featured']       = (bool)($meta['featured'] ?? false);
         $meta['files_meta']     = $meta['files_meta'] ?? [];
@@ -1242,12 +1265,15 @@ class ArticleManager
             }
         }
 
-        // Écrire le cache
-        $cacheDir = dirname($cachePath);
-        if (!is_dir($cacheDir)) {
-            mkdir($cacheDir, 0755, true);
+        if ($withContent) {
+            $uuid      = $meta['uuid'];
+            $cachePath = $this->articleCachePath($uuid);
+            $cacheDir  = dirname($cachePath);
+            if (!is_dir($cacheDir)) {
+                mkdir($cacheDir, 0755, true);
+            }
+            file_put_contents($cachePath, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
         }
-        file_put_contents($cachePath, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
 
         return $meta;
     }
diff --git a/templates/author_articles.php b/templates/author_articles.php
index 9f8d530..ead17ad 100644
--- a/templates/author_articles.php
+++ b/templates/author_articles.php
@@ -1,6 +1,4 @@
 
 
text($post['content']); - $preview = mb_strimwidth(strip_tags($html), 0, 120, '…'); + $preview = mb_strimwidth($post['plain'] ?? '', 0, 120, '…'); $category = trim((string)($post['category'] ?? '')); $gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []); $postUrl = '/post/' . rawurlencode($post['slug']); diff --git a/templates/author_profile.php b/templates/author_profile.php index b83c931..21eafb0 100644 --- a/templates/author_profile.php +++ b/templates/author_profile.php @@ -1,6 +1,4 @@
text($post['content']); - $preview = mb_strimwidth(strip_tags($html), 0, 120, '…'); + $preview = mb_strimwidth($post['plain'] ?? '', 0, 120, '…'); $category = trim((string)($post['category'] ?? '')); $gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []); $postUrl = '/post/' . rawurlencode($post['slug']); diff --git a/templates/post_list.php b/templates/post_list.php index ab71c44..b6a47c4 100644 --- a/templates/post_list.php +++ b/templates/post_list.php @@ -17,7 +17,13 @@ function _cardCoverStyle(array $post, array $allCats): string function _cardExcerpt(array $post, \Parsedown $pd, int $len = 120): string { - return mb_strimwidth(strip_tags($pd->text($post['content'])), 0, $len, '…'); + if (($post['plain'] ?? '') !== '') { + return mb_strimwidth($post['plain'], 0, $len, '…'); + } + if (($post['content'] ?? '') !== '') { + return mb_strimwidth(strip_tags($pd->text($post['content'])), 0, $len, '…'); + } + return ''; } function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown $pd): void From ae4ac11305be68b0d91bf9ae6c0392025124955f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Sat, 16 May 2026 09:40:43 +0200 Subject: [PATCH 03/16] =?UTF-8?q?feat=20:=20recherche=20titre,=20toggle=20?= =?UTF-8?q?=C3=A0=20la=20une,=20date=20modif,=20retour=20sources=20(v1.6.1?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin/articles : champ filter_search (titre, insensible casse) cumulable avec auteur/catégorie/statut (#85) - admin/articles : colonne ★ avec toggle rapide featured + filtre filter_featured (#84) - post/ : date de modification sous la date de publication si modifié après mise en ligne (#81) - sources/ : bouton ← Retour à l'article vers post/ au lieu de /edit/ (#83) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 12 ++++++++++++ public/index.php | 25 +++++++++++++++++++++++++ public/version.txt | 2 +- templates/admin.php | 31 ++++++++++++++++++++++++++++++- templates/post_view.php | 11 +++++++++++ templates/sources.php | 2 +- 6 files changed, 80 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e2cb1..5b98386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.15] - 2026-05-16 + +### Ajouté +- `admin/articles` : champ de recherche par titre (`filter_search`), cumulable avec les autres filtres (#85) +- `admin/articles` : colonne « ★ À la une » avec toggle rapide par ligne et filtre `filter_featured` (#84) +- `post/` : date de modification affichée sous la date de publication si l'article a été modifié après sa mise en ligne (#81) + +### Modifié +- `sources/` : bouton « ← Modifier » remplacé par « ← Retour à l'article » pointant vers `post/` (#83) + +--- + ## [1.6.14] - 2026-05-15 ### Modifié diff --git a/public/index.php b/public/index.php index a8c2e5f..37a305d 100644 --- a/public/index.php +++ b/public/index.php @@ -2355,9 +2355,13 @@ switch ($action) { $filterAuthor = trim($_GET['filter_author'] ?? ''); $filterCategory = trim($_GET['filter_category'] ?? ''); $filterStatus = trim($_GET['filter_status'] ?? ''); + $filterSearch = trim($_GET['filter_search'] ?? ''); + $filterFeatured = trim($_GET['filter_featured'] ?? ''); $adminData['filter_author'] = $filterAuthor; $adminData['filter_category'] = $filterCategory; $adminData['filter_status'] = $filterStatus; + $adminData['filter_search'] = $filterSearch; + $adminData['filter_featured'] = $filterFeatured; $nowTs = time(); if ($filterAuthor !== '') { @@ -2373,6 +2377,12 @@ switch ($action) { } elseif ($filterStatus === 'preview') { $allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $nowTs)); } + if ($filterSearch !== '') { + $allArticles = array_values(array_filter($allArticles, fn ($a) => mb_stripos($a['title'] ?? '', $filterSearch) !== false)); + } + if ($filterFeatured === 'yes') { + $allArticles = array_values(array_filter($allArticles, fn ($a) => !empty($a['featured']))); + } $sortBy = in_array($_GET['sort'] ?? '', ['title', 'published', 'updated']) ? $_GET['sort'] : 'updated'; $sortDir = ($_GET['dir'] ?? '') === 'asc' ? 'asc' : 'desc'; @@ -2741,6 +2751,21 @@ switch ($action) { header('Location: /admin/smtp'); exit; + case 'admin_toggle_featured': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $uid = trim((string)($_POST['uuid'] ?? '')); + $art = $uid !== '' ? $articles->getByUuid($uid) : null; + if ($art) { + $articles->setFeatured($uid, !((bool)($art['featured'] ?? false))); + } + $back = $_POST['_back'] ?? '/admin/articles'; + header('Location: ' . $back); + exit; + case 'admin_bulk_delete': requireAuth(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { diff --git a/public/version.txt b/public/version.txt index 5577648..7e84a78 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.14 +1.6.15 diff --git a/templates/admin.php b/templates/admin.php index bc6268f..d0dedaf 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -214,6 +214,8 @@ function adminStatusBadge(array $a, int $now): string 'filter_author' => $adminData['filter_author'] ?? '', 'filter_category' => $adminData['filter_category'] ?? '', 'filter_status' => $adminData['filter_status'] ?? '', + 'filter_search' => $adminData['filter_search'] ?? '', + 'filter_featured' => $adminData['filter_featured'] ?? '', ], fn ($v) => $v !== ''); $p['sort'] = $col; $p['dir'] = $dir; @@ -263,9 +265,19 @@ function adminStatusBadge(array $a, int $now): string
+
+ +
+
+ +
- + Réinitialiser @@ -304,6 +316,7 @@ function adminStatusBadge(array $a, int $now): string Auteur Catégorie Statut + ★ @@ -330,6 +343,22 @@ function adminStatusBadge(array $a, int $now): string + + + + $adminData['filter_author'] ?? '', 'filter_category' => $adminData['filter_category'] ?? '', 'filter_status' => $adminData['filter_status'] ?? '', 'filter_search' => $adminData['filter_search'] ?? '', 'filter_featured' => $adminData['filter_featured'] ?? '', 'sort' => $_sortBy, 'dir' => $_sortDir], fn ($v) => $v !== '')); ?> +
+ + + +
+ + + + diff --git a/templates/post_view.php b/templates/post_view.php index 2246962..dcd8317 100644 --- a/templates/post_view.php +++ b/templates/post_view.php @@ -96,6 +96,14 @@ $authorName = ($authorEmail !== '' && function_exists('authorDisplayName') $authorProfileUrl = ($authorEmail !== '' && function_exists('authorProfileUrl')) ? authorProfileUrl($authorEmail) : ''; $authorSlugVal = ($authorEmail !== '' && function_exists('authorSlug')) ? authorSlug($authorEmail) : ''; $pubDate = htmlspecialchars(date('d/m/Y', strtotime((string)($article['published_at'] ?? $article['created_at'] ?? '')))); +$modDate = ''; +$_updatedTs = strtotime((string)($article['updated_at'] ?? '')); +$_publishedTs = strtotime((string)($article['published_at'] ?? $article['created_at'] ?? '')); +if ($_updatedTs > 0 && $_publishedTs > 0 && $_updatedTs > $_publishedTs) { + $_frMonths = ['janvier','février','mars','avril','mai','juin','juillet','août','septembre','octobre','novembre','décembre']; + $modDate = 'Modifié le ' . (int)date('j', $_updatedTs) . ' ' . $_frMonths[(int)date('n', $_updatedTs) - 1] + . ' ' . date('Y', $_updatedTs) . ' à ' . date('H', $_updatedTs) . 'h' . date('i', $_updatedTs); +} $hasCover = $coverFile !== ''; $heroExtraClass = $hasCover ? '' : ' article-cover--gradient'; $heroStyle = $hasCover ? '' : ' style="background:' . htmlspecialchars($gradient) . '"'; @@ -137,6 +145,9 @@ $hasSources = (!empty($externalLinks) || !empty($files)) · + +
+

diff --git a/templates/sources.php b/templates/sources.php index c51dda4..edf6875 100644 --- a/templates/sources.php +++ b/templates/sources.php @@ -38,7 +38,7 @@ function renderMetaCell(string $key, mixed $val, array $row = []): string ?>

From dc4701d66777df53843bb3176f6dafb86a445f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Sat, 16 May 2026 09:50:56 +0200 Subject: [PATCH 04/16] =?UTF-8?q?feat=20:=20visiteurs=20uniques,=20filtre?= =?UTF-8?q?=20jours,=20redirect=20404=E2=86=92search,=20edit=5Ftags=20(v1.?= =?UTF-8?q?6.16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchLogParser : visiteurs uniques par terme (IPs distinctes) au lieu de hits bruts (#41) - SearchLogParser : paramètre $days (7/14), cache distinct par période, filtre logFiles par date (#46) - admin/searches : boutons 7 j / 14 j, label dynamique, colonne « Visiteurs » (#41, #46) - URL inconnue / slug absent : redirect 302 /search?q=… au lieu de page 404 (#57) - edit_tags : masquer abbrev/camel si des valeurs connues existent pour le type (#48) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 13 +++++++++++++ public/index.php | 42 +++++++++++++++++------------------------ public/version.txt | 2 +- src/SearchLogParser.php | 39 +++++++++++++++++++++++++------------- templates/admin.php | 12 ++++++++++-- templates/edit_tags.php | 2 ++ 6 files changed, 69 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b98386..9ba9bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.16] - 2026-05-16 + +### Ajouté +- `SearchLogParser` : paramètre `$days` (7 ou 14) — cache distinct par période, filtre logFiles par date (#46) +- `admin/searches` : boutons 7 j / 14 j pour choisir la fenêtre d'analyse (#46) + +### Modifié +- `SearchLogParser` : tri par visiteurs uniques (IPs distinctes) au lieu de hits bruts — colonne renommée « Visiteurs » (#41) +- URL inconnue / article introuvable : redirection 302 vers `/search?q=…` au lieu de page 404 (#57) +- `edit_tags` : sections « Abréviations » et « Noms composés » masquées si des valeurs connues existent pour le type (#48) + +--- + ## [1.6.15] - 2026-05-16 ### Ajouté diff --git a/public/index.php b/public/index.php index 37a305d..0cc6c77 100644 --- a/public/index.php +++ b/public/index.php @@ -50,12 +50,17 @@ $metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : unset($_noindexActions); // ─── Recherche de l'article le plus proche et redirection 301 ──────────────── +function slugToSearchQuery(string $rawPath): string +{ + return trim((string)preg_replace('/\s{2,}/', ' ', (string)preg_replace( + '/[^a-zA-ZÀ-ÿ0-9\s]/u', ' ', str_replace(['-', '_', '/'], ' ', $rawPath) + ))); +} + function searchAndRedirect(string $rawPath, ArticleManager $articles): void { require_once BASE_PATH . '/src/SearchEngine.php'; - $query = (string)preg_replace('/\s{2,}/', ' ', trim( - (string)preg_replace('/[^a-zA-ZÀ-ÿ0-9\s]/u', ' ', str_replace(['-', '_', '/'], ' ', $rawPath)) - )); + $query = slugToSearchQuery($rawPath); if ($query === '') { return; } @@ -666,9 +671,8 @@ switch ($action) { case 'view': $article = $slug !== '' ? $articles->getBySlug($slug) : null; if (!$article) { - searchAndRedirect($slug, $articles); - http_response_code(404); - echo 'Article introuvable.'; + $q = slugToSearchQuery($slug); + header('Location: /search' . ($q !== '' ? '?q=' . urlencode($q) : ''), true, 302); exit; } @@ -2577,9 +2581,11 @@ switch ($action) { exit; } require_once BASE_PATH . '/src/SearchLogParser.php'; - $parser = new SearchLogParser('/var/log/apache2', apacheAccessLog()); - $adminData['search_terms'] = $parser->topTerms(100); + $days = in_array((int)($_GET['days'] ?? 14), [7, 14], true) ? (int)$_GET['days'] : 14; + $parser = new SearchLogParser('/var/log/apache2', apacheAccessLog(), '', 600, $days); + $adminData['search_terms'] = $parser->topTerms(100); $adminData['search_log_readable'] = $parser->isReadable(); + $adminData['search_days'] = $days; } if ($tab === 'stats') { @@ -3383,23 +3389,9 @@ switch ($action) { (string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''), '/' ); - if ($notFoundPath !== '') { - searchAndRedirect(basename($notFoundPath), $articles); - } - http_response_code(404); - ob_start(); - ?> -
-

Page introuvable

-

Cette adresse ne correspond à aucun article.
Vous avez peut-être suivi un ancien lien.

- ← Retour à l'accueil -
- logDir = rtrim($logDir, '/'); $this->vhostBase = $vhostBase; + $this->days = max(1, min(30, $days)); $this->cacheFile = $cacheFile !== '' ? $cacheFile - : dirname(__DIR__) . '/_cache/search_terms.json'; + : dirname(__DIR__) . '/_cache/search_terms_' . $this->days . 'd.json'; $this->cacheTtl = $cacheTtl; } - /** @return array terme => nombre d'occurrences, trié desc */ + /** @return array terme => visiteurs uniques, trié desc */ public function topTerms(int $limit = 100): array { if ($this->cacheValid()) { @@ -33,9 +36,14 @@ class SearchLogParser } } - $counts = []; + $visitors = []; // terme => [ip => true] foreach ($this->logFiles() as $file) { - $this->parseFile($file, $counts); + $this->parseFile($file, $visitors); + } + + $counts = []; + foreach ($visitors as $term => $ips) { + $counts[$term] = count($ips); } arsort($counts); @@ -61,6 +69,7 @@ class SearchLogParser { $pattern = $this->logDir . '/' . $this->vhostBase; $files = []; + $cutoff = time() - $this->days * 86400; // Fichiers correspondant au pattern de base (courants + rotations incluses si glob) $bases = glob($pattern) ?: []; @@ -75,6 +84,9 @@ class SearchLogParser if (!is_readable($path)) { continue; } + if (@filemtime($path) < $cutoff) { + continue; + } if (str_ends_with($path, '.tar.gz')) { $files[] = ['path' => $path, 'type' => 'tgz']; } elseif (str_ends_with($path, '.gz')) { @@ -88,7 +100,7 @@ class SearchLogParser return $files; } - private function parseFile(array $file, array &$counts): void + private function parseFile(array $file, array &$visitors): void { if ($file['type'] === 'tgz') { try { @@ -99,7 +111,7 @@ class SearchLogParser continue; } foreach (explode("\n", $content) as $line) { - $this->parseLine($line, $counts); + $this->parseLine($line, $visitors); } } } catch (\Exception $e) { @@ -113,7 +125,7 @@ class SearchLogParser while (!gzeof($h)) { $line = gzgets($h, 8192); if ($line !== false) { - $this->parseLine($line, $counts); + $this->parseLine($line, $visitors); } } gzclose($h); @@ -123,28 +135,29 @@ class SearchLogParser return; } while (($line = fgets($h)) !== false) { - $this->parseLine($line, $counts); + $this->parseLine($line, $visitors); } fclose($h); } } - private function parseLine(string $line, array &$counts): void + private function parseLine(string $line, array &$visitors): void { if (!str_contains($line, 'GET /search?')) { return; } - if (!preg_match('/"GET \/search\?([^"]*) HTTP\//', $line, $m)) { + if (!preg_match('/^(\S+) \S+ \S+ \[[^\]]+\] "GET \/search\?([^"]*) HTTP\//', $line, $m)) { return; } - parse_str($m[1], $params); + $ip = $m[1]; + parse_str($m[2], $params); $q = trim(urldecode($params['q'] ?? '')); if ($q === '' || mb_strlen($q) > 200) { return; } $q = mb_strtolower($q); - $counts[$q] = ($counts[$q] ?? 0) + 1; + $visitors[$q][$ip] = true; } } diff --git a/templates/admin.php b/templates/admin.php index d0dedaf..6acda9b 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -1207,7 +1207,15 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
Termes recherchés
- Derniers 14 jours de logs · cache 10 min +
+ Derniers jours · cache 10 min +
+ 7 j + 14 j +
+
@@ -1228,7 +1236,7 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb): # Terme recherché - Fois + Visiteurs diff --git a/templates/edit_tags.php b/templates/edit_tags.php index 7621331..99007b2 100644 --- a/templates/edit_tags.php +++ b/templates/edit_tags.php @@ -97,8 +97,10 @@ $_typeLabel = $isCatField ? 'Catégorie' : ($tagTypes[$tagType] ?? ucfirst($tag + +

Aucun terme détecté dans cet article.

From 51055b732193d6de476890c57df888d0543d3269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Sat, 16 May 2026 10:00:37 +0200 Subject: [PATCH 05/16] =?UTF-8?q?feat=20:=20RSS=20content,=20feed=20cat?= =?UTF-8?q?=C3=A9gorie,=20cookie=20commentaires,=20flux=20erreurs,=20email?= =?UTF-8?q?=20preview=20(v1.6.17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RSS : content:encoded (HTML complet) + fix description via plain (#42) - RSS : flux filtré par ?category=nom (#43) - Commentaires : cookie nom/email pour pré-remplir le formulaire (#51) - flux/ : bandeau admin des feeds en erreur (#45) - admin/emails : bouton « Voir ↗ » vers /admin/email-preview/{id} en nouvel onglet (#37) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 14 ++++++++++ public/.htaccess | 3 ++- public/feed.php | 47 +++++++++++++++++++++++----------- public/index.php | 38 ++++++++++++++++++++++++--- public/version.txt | 2 +- templates/admin.php | 18 +++++-------- templates/comments_section.php | 26 +++++++++++++++++++ templates/flux.php | 15 +++++++++++ 8 files changed, 131 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba9bf2..9cd03bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.17] - 2026-05-16 + +### Ajouté +- RSS : élément `` avec HTML complet par article + namespace `content` (#42) +- RSS : filtre `?category=nom` — flux filtré par catégorie, titre et description du channel adaptés (#43) +- Commentaires : cookie `cmt_name` / `cmt_email` (1 an) pour pré-remplir le formulaire à la prochaine visite (#51) +- `flux/` : bandeau d'alerte admin listant les feeds en erreur (URL, label, email) (#45) +- `admin/emails` : bouton « Voir ↗ » ouvre le contenu HTML de l'email dans un nouvel onglet via `/admin/email-preview/{id}` (#37) + +### Modifié +- RSS : `` utilise désormais le champ `plain` pré-calculé (fix : contenu vide depuis v1.6.14) (#42) + +--- + ## [1.6.16] - 2026-05-16 ### Ajouté diff --git a/public/.htaccess b/public/.htaccess index aa662e6..e33d5bc 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -41,8 +41,9 @@ RewriteRule ^diff/([0-9a-f-]{36})/(\d+)/?$ /index.php?action=diff&uuid=$1&rev=$2 RewriteRule ^files/([0-9a-f-]{36})/add/?$ /index.php?action=add_files&uuid=$1 [L,QSA] RewriteRule ^import/([0-9a-f-]{36})/?$ /index.php?action=import_image&uuid=$1 [L,QSA] -# Admin (regen-thumbs et role/ avant la règle générique admin/) +# Admin (regen-thumbs, email-preview et role/ avant la règle générique admin/) RewriteRule ^admin/regen-thumbs/?$ /index.php?action=regen_thumbs [L,QSA] +RewriteRule ^admin/email-preview/(\d+)/?$ /index.php?action=admin_email_preview&id=$1 [L,QSA] RewriteRule ^admin/role/([a-z0-9_-]+)/?$ /index.php?action=admin_role_edit&role_name=$1 [L,QSA] RewriteRule ^admin/([a-z0-9-]+)/?$ /index.php?action=admin&tab=$1 [L,QSA] RewriteRule ^admin/?$ /index.php?action=admin [L,QSA] diff --git a/public/feed.php b/public/feed.php index 2b12f95..5cdc849 100644 --- a/public/feed.php +++ b/public/feed.php @@ -16,17 +16,24 @@ $articles = new ArticleManager(DATA_PATH); $privateCats = $articles->getPrivateCategories(); $Parsedown = new Parsedown(); -$now = time(); -$base = rtrim(APP_URL, '/'); +$now = time(); +$base = rtrim(APP_URL, '/'); +$filterCat = trim($_GET['category'] ?? ''); $all = array_values(array_filter( $articles->getAll(publishedOnly: true), - static function (array $a) use ($now, $privateCats): bool { + static function (array $a) use ($now, $privateCats, $filterCat): bool { if (strtotime((string)($a['published_at'] ?? '')) > $now) { return false; } $cat = trim($a['category'] ?? ''); - return $cat === '' || !in_array($cat, $privateCats, true); + if ($cat !== '' && in_array($cat, $privateCats, true)) { + return false; + } + if ($filterCat !== '' && $cat !== $filterCat) { + return false; + } + return true; } )); @@ -42,13 +49,16 @@ if ($after !== '') { } } -$items = array_slice($all, $offset, FEED_PAGE_SIZE); +$items = array_slice($all, $offset, FEED_PAGE_SIZE); $nextCursor = (count($all) > $offset + FEED_PAGE_SIZE) ? ($all[$offset + FEED_PAGE_SIZE - 1]['uuid'] ?? null) : null; -$feedUrl = $base . '/feed'; -$feedNextUrl = $nextCursor !== null ? $base . '/feed/' . $nextCursor : null; +$feedUrl = $base . '/feed' . ($filterCat !== '' ? '?category=' . rawurlencode($filterCat) : ''); +$feedNextUrl = $nextCursor !== null ? $base . '/feed/' . $nextCursor . ($filterCat !== '' ? '?category=' . rawurlencode($filterCat) : '') : null; + +$channelTitle = siteTitle() . ($filterCat !== '' ? ' — ' . $filterCat : ''); +$channelDesc = $filterCat !== '' ? 'Articles de la catégorie « ' . $filterCat . ' »' : siteClaim(); // ─── lastBuildDate ─────────────────────────────────────────────────────────── $lastBuild = ''; @@ -69,11 +79,12 @@ echo '' . "\n"; ?> - <?= htmlspecialchars(siteTitle()) ?> + <?= htmlspecialchars($channelTitle) ?> - + @@ -91,17 +102,23 @@ echo '' . "\n"; text($article['content'] ?? ''))); - $desc = htmlspecialchars(mb_strimwidth(trim((string)$plain), 0, 300, '…'), ENT_XML1); - $guid = htmlspecialchars($base . '/post/' . rawurlencode($article['slug'] ?? ''), ENT_XML1); + $pubDate = date(DATE_RSS, (int)strtotime((string)($article['published_at'] ?? $article['created_at'] ?? ''))); + $link = $base . '/post/' . rawurlencode($article['slug'] ?? ''); + $title = htmlspecialchars($article['title'] ?? '', ENT_XML1); + $plain = preg_replace('/\s+/', ' ', trim($article['plain'] ?? '')); + $desc = htmlspecialchars(mb_strimwidth($plain, 0, 300, '…'), ENT_XML1); + $guid = htmlspecialchars($base . '/post/' . rawurlencode($article['slug'] ?? ''), ENT_XML1); + $mdPath = DATA_PATH . '/' . ($article['uuid'] ?? '') . '/index.md'; + $rawMd = file_exists($mdPath) ? (string)file_get_contents($mdPath) : ''; + $fullHtml = $rawMd !== '' ? $Parsedown->text($rawMd) : ''; ?> <?= $title ?> + + ]]> + diff --git a/public/index.php b/public/index.php index 0cc6c77..0f0ac0f 100644 --- a/public/index.php +++ b/public/index.php @@ -1402,9 +1402,10 @@ switch ($action) { case 'flux': require_once BASE_PATH . '/src/FeedFetcher.php'; - $fetcher = new FeedFetcher(DATA_PATH . '/_cache/feeds'); - $fluxItems = []; - $pdo = dbPdo(); + $fetcher = new FeedFetcher(DATA_PATH . '/_cache/feeds'); + $fluxItems = []; + $fluxErrors = []; + $pdo = dbPdo(); if ($pdo) { try { $st = $pdo->query( @@ -1417,6 +1418,11 @@ switch ($action) { foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $_row) { $data = $fetcher->get($_row['feed_url']); if (!$data) { + $fluxErrors[] = [ + 'feed_url' => $_row['feed_url'], + 'label' => $_row['label'], + 'user_email' => $_row['user_email'], + ]; continue; } $feedTitle = $_row['label'] !== '' ? $_row['label'] : $data['feed_title']; @@ -2541,7 +2547,7 @@ switch ($action) { 'queued' => (int)($row['queued'] ?? 0), ]; $adminData['emails'] = $pdo->query( - "SELECT id, created_at, to_email, subject, status, error_message, content_text, sent_at + "SELECT id, created_at, to_email, subject, status, error_message, content_text, content_html, sent_at FROM journal_smtp $whereEml ORDER BY created_at DESC LIMIT $emlLimit OFFSET $emlOffset" @@ -2757,6 +2763,30 @@ switch ($action) { header('Location: /admin/smtp'); exit; + case 'admin_email_preview': + requireAuth(); + if (!isAdmin()) { + http_response_code(403); + exit; + } + $previewId = (int)($_GET['id'] ?? 0); + $pdo = dbPdo(); + $emailRow = null; + if ($pdo && $previewId > 0) { + $st = $pdo->prepare('SELECT subject, content_html, content_text FROM journal_smtp WHERE id = :id'); + $st->execute([':id' => $previewId]); + $emailRow = $st->fetch(PDO::FETCH_ASSOC) ?: null; + } + if (!$emailRow) { + http_response_code(404); + echo 'Email introuvable.'; + exit; + } + header('Content-Type: text/html; charset=UTF-8'); + $previewHtml = !empty($emailRow['content_html']) ? $emailRow['content_html'] : nl2br(htmlspecialchars((string)$emailRow['content_text'])); + echo '' . htmlspecialchars((string)$emailRow['subject']) . '' . $previewHtml . ''; + exit; + case 'admin_toggle_featured': requireAuth(); if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { diff --git a/public/version.txt b/public/version.txt index 9494224..5f3f715 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.16 +1.6.17 diff --git a/templates/admin.php b/templates/admin.php index 6acda9b..ea3bd29 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -1005,17 +1005,13 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb): -
- Voir -
- -

Erreur :

- - -
- -
-
+ + Voir ↗ + + + ⚠ Erreur + diff --git a/templates/comments_section.php b/templates/comments_section.php index f6af609..6fee5b8 100644 --- a/templates/comments_section.php +++ b/templates/comments_section.php @@ -142,3 +142,29 @@ setcookie('_csrf_c', $_csrfToken, [
+ diff --git a/templates/flux.php b/templates/flux.php index 401e912..9d98e97 100644 --- a/templates/flux.php +++ b/templates/flux.php @@ -4,6 +4,21 @@

Flux agrégés

+ +
+ flux en erreur +
    + +
  • + + — + () +
  • + +
+
+ +

Aucun article disponible pour l'instant.

From 11399a54a667ad11cdecf0d0c657814156b425b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Sat, 16 May 2026 10:30:55 +0200 Subject: [PATCH 06/16] feat : magic link confirm, notif auteur, rate-limit IP, duplicate, cache MD, lazy img (v1.6.18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - magic.php : GET=confirmation page, POST=consommation (protège vs scanners) (#27) - verify_comment : email de notification à l'auteur de l'article (#44) - login/index.php : rate limit par IP (MAGIC_MAX_PER_IP_HOUR=10) (#23) - ArticleManager::duplicate() + route POST /duplicate/{uuid} + bouton ⧉ admin/articles (#7) - post_view.php : cache JSON du rendu Markdown (invalidé sur mtime index.md) (#17) - post_view.php : loading="lazy" sur toutes les du contenu (#21) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 12 ++++++ public/.htaccess | 1 + public/index.php | 56 ++++++++++++++++++++++++++- public/login/index.php | 18 +++++++-- public/login/magic.php | 53 +++++++++++++++++++++----- public/version.txt | 2 +- src/ArticleManager.php | 15 ++++++++ templates/admin.php | 4 ++ templates/post_view.php | 83 +++++++++++++++++++++++++++-------------- 9 files changed, 201 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd03bd..54e967e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.18] - 2026-05-16 + +### Ajouté +- Lien magique : page de confirmation GET avant consommation POST — protège contre les scanners email (#27) +- Lien magique : notification email à l'auteur de l'article lors de la vérification d'un commentaire (#44) +- Lien magique : rate limit par IP (`MAGIC_MAX_PER_IP_HOUR`, défaut 10/h) en plus du rate limit par email (#23) +- `ArticleManager::duplicate()` + route `/duplicate/{uuid}` + bouton ⧉ dans `admin/articles` (#7) +- Cache du rendu Markdown par article (`_cache/content_rendered.json`, invalidé sur `mtime` de `index.md`) (#17) +- Lazy loading (`loading="lazy"`) sur toutes les images du contenu Markdown (#21) + +--- + ## [1.6.17] - 2026-05-16 ### Ajouté diff --git a/public/.htaccess b/public/.htaccess index e33d5bc..0699297 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -32,6 +32,7 @@ RewriteRule ^edit/([0-9a-f-]{36})/?$ /index.php?action=edit&uuid=$1 [L,QSA] RewriteRule ^new/([0-9a-f-]{36})/([1-5])/?$ /index.php?action=create&uuid=$1&step=$2 [L,QSA] RewriteRule ^new/?$ /index.php?action=create [L,QSA] RewriteRule ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [L,QSA] +RewriteRule ^duplicate/([0-9a-f-]{36})/?$ /index.php?action=duplicate&uuid=$1 [L,QSA] # Sources et diff RewriteRule ^sources/([0-9a-f-]{36})/?$ /index.php?action=sources&uuid=$1 [L,QSA] diff --git a/public/index.php b/public/index.php index 0f0ac0f..3d75668 100644 --- a/public/index.php +++ b/public/index.php @@ -1122,6 +1122,27 @@ switch ($action) { header('Location: /edit/' . rawurlencode($uuid)); exit; + case 'duplicate': + requireAuth(); + if ($uuid !== '' && $_SERVER['REQUEST_METHOD'] === 'POST') { + $srcArticle = $articles->getByUuid($uuid); + if (!$srcArticle) { + header('Location: /admin/articles'); + exit; + } + if (!isAdmin() && ($srcArticle['author'] ?? '') !== (currentUserEmail() ?? '')) { + http_response_code(403); + exit; + } + $newUuid = $articles->duplicate($uuid, currentUserEmail() ?? ''); + if ($newUuid) { + header('Location: /edit/' . rawurlencode($newUuid) . '/1'); + exit; + } + } + header('Location: /admin/articles'); + exit; + case 'delete': requireAuth(); if ($uuid !== '') { @@ -2170,11 +2191,44 @@ switch ($action) { $pdo = dbPdo(); if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) { require_once BASE_PATH . '/src/CommentManager.php'; - $cm = new CommentManager($pdo); + $cm = new CommentManager($pdo); + + // Récupère les données du commentaire avant vérification (le token est effacé après) + $vcPreSt = $pdo->prepare( + 'SELECT author_name, content FROM comments WHERE verify_token = :t AND verified = FALSE LIMIT 1' + ); + $vcPreSt->execute([':t' => $vcToken]); + $vcPreInfo = $vcPreSt->fetch(PDO::FETCH_ASSOC) ?: null; + $result = $cm->verify($vcToken, $vcCode); if (is_string($result)) { $vcArticle = $articles->getByUuid($result); $vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $result) : $result; + + // Notification email à l'auteur de l'article + $vcAuthorEmail = $vcArticle['author'] ?? ''; + if ($vcAuthorEmail !== '' && $vcPreInfo) { + require_once BASE_PATH . '/src/mailer.php'; + $vcPostUrl = rtrim(APP_URL, '/') . '/post/' . rawurlencode($vcSlug) . '#comments'; + $vcAdminUrl = rtrim(APP_URL, '/') . '/admin/comments'; + $vcExcerpt = mb_strimwidth(trim((string)$vcPreInfo['content']), 0, 200, '…'); + $vcSubject = '[' . siteTitle() . '] Nouveau commentaire sur « ' . ($vcArticle['title'] ?? '') . ' »'; + $vcHtml = '' + . '

Bonjour,

' + . '

' . htmlspecialchars((string)$vcPreInfo['author_name']) . '' + . ' a commenté votre article ' . htmlspecialchars($vcArticle['title'] ?? '') . ' :

' + . '
' + . nl2br(htmlspecialchars($vcExcerpt)) . '
' + . '

Voir le commentaire' + . '  ·  Modérer

' + . ''; + try { + envoyer_mail_smtp($vcAuthorEmail, $vcSubject, $vcHtml); + } catch (\RuntimeException) { + // Taux limité ou SMTP indisponible, on ne bloque pas le visiteur + } + } + header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments'); exit; } diff --git a/public/login/index.php b/public/login/index.php index 51a680b..4f96c85 100644 --- a/public/login/index.php +++ b/public/login/index.php @@ -16,10 +16,11 @@ require_once dirname(__DIR__, 2) . '/src/SiteSettings.php'; require_once dirname(__DIR__, 2) . '/src/mailer.php'; // Paramètres (env) -$ttlMin = (int) env('MAGIC_LINK_TTL_MINUTES', '30'); -$coolMin = (int) env('MAGIC_COOLDOWN_MINUTES', '5'); -$winHours = (int) env('MAGIC_WINDOW_HOURS', '12'); -$maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5'); +$ttlMin = (int) env('MAGIC_LINK_TTL_MINUTES', '30'); +$coolMin = (int) env('MAGIC_COOLDOWN_MINUTES', '5'); +$winHours = (int) env('MAGIC_WINDOW_HOURS', '12'); +$maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5'); +$maxPerIpHour = (int) env('MAGIC_MAX_PER_IP_HOUR', '10'); // --- return_to --- $defaultReturn = '/'; @@ -94,6 +95,15 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { throw new RuntimeException('Quota atteint. Réessayez plus tard.'); } + // 3) rate limit par IP + $stmt = $pdo->prepare( + "SELECT COUNT(*) FROM auth_magic_links WHERE ip = :ip AND created_at >= NOW() - INTERVAL '1 hour'" + ); + $stmt->execute([':ip' => $ip]); + if ((int)$stmt->fetchColumn() >= $maxPerIpHour) { + throw new RuntimeException('Quota atteint. Réessayez plus tard.'); + } + // Génère et enregistre le lien avec TTL ttlMin $raw = random_bytes(32); $token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); diff --git a/public/login/magic.php b/public/login/magic.php index 674c295..ddac7f9 100644 --- a/public/login/magic.php +++ b/public/login/magic.php @@ -1,8 +1,4 @@ Ce lien de connexion est invalide.

', null)); } $pdo = db(); + +// ─── Rendu minimal standalone ──────────────────────────────────────────────── +function renderMagicPage(string $title, string $body, ?string $token): string +{ + $formHtml = $token !== null + ? '
' + . '' + . '' + . '
' + : ''; + return '' + . '' . htmlspecialchars($title) . '' + . '' + . '

' . htmlspecialchars($title) . '

' . $body . $formHtml . ''; +} + +// ─── GET : afficher la page de confirmation ────────────────────────────────── +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $stmt = $pdo->prepare('SELECT id, expires_at, consumed_at FROM auth_magic_links WHERE token = :t'); + $stmt->execute([':t' => $token]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + http_response_code(400); + exit(renderMagicPage('Lien inconnu', '

Ce lien de connexion est introuvable.

', null)); + } + if ($row['consumed_at'] !== null) { + http_response_code(400); + exit(renderMagicPage('Lien déjà utilisé', '

Ce lien de connexion a déjà été utilisé.

Demander un nouveau lien

', null)); + } + if (strtotime((string)$row['expires_at']) < time()) { + http_response_code(400); + exit(renderMagicPage('Lien expiré', '

Ce lien de connexion a expiré.

Demander un nouveau lien

', null)); + } + + exit(renderMagicPage('Connexion', '

Cliquez sur le bouton ci-dessous pour vous connecter.

', $token)); +} + +// ─── POST : consommer le token et ouvrir la session ────────────────────────── $pdo->beginTransaction(); try { - // récupère lien non consommé et non expiré - $sql = 'SELECT id, email, token, created_at, expires_at, consumed_at, return_to + $sql = 'SELECT id, email, expires_at, consumed_at, return_to FROM auth_magic_links WHERE token = :t FOR UPDATE'; @@ -40,7 +75,6 @@ try { throw new RuntimeException('Lien expiré.'); } - // consomme le lien $pdo->prepare('UPDATE auth_magic_links SET consumed_at = NOW() WHERE id = :id')->execute([':id' => $row['id']]); $pdo->commit(); @@ -51,7 +85,6 @@ try { $_SESSION['user_email'] = strtolower(trim((string)$row['email'])); $dest = $row['return_to'] ?? '/'; - // sécurité: ne renvoyer que des chemins relatifs if (!is_string($dest) || !str_starts_with($dest, '/')) { $dest = '/'; } @@ -62,5 +95,5 @@ try { $pdo->rollBack(); } http_response_code(400); - echo htmlspecialchars($e->getMessage(), ENT_QUOTES); + exit(renderMagicPage('Erreur', '

' . htmlspecialchars($e->getMessage()) . '

Retour à la connexion

', null)); } diff --git a/public/version.txt b/public/version.txt index 5f3f715..7a9d793 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.17 +1.6.18 diff --git a/src/ArticleManager.php b/src/ArticleManager.php index 82b8ae7..d206d91 100644 --- a/src/ArticleManager.php +++ b/src/ArticleManager.php @@ -150,6 +150,21 @@ class ArticleManager return $uuid; } + /** Crée un brouillon en copiant titre, contenu, catégorie et tags d'un article existant. */ + public function duplicate(string $sourceUuid, string $author = ''): ?string + { + $source = $this->getByUuid($sourceUuid); + if (!$source) { + return null; + } + $newTitle = 'Copie de ' . ($source['title'] ?? ''); + $content = $source['content'] ?? ''; + $category = $source['category'] ?? ''; + $tags = $source['tags'] ?? []; + $newAuthor = $author !== '' ? $author : ($source['author'] ?? ''); + return $this->create($newTitle, $content, false, '', '', $newAuthor, '', '', '', $category, $tags); + } + public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt, string $revisionComment = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', ?array $tags = null, bool $skipGit = false): void { $article = $this->getByUuid($uuid); diff --git a/templates/admin.php b/templates/admin.php index ea3bd29..a260c25 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -365,6 +365,10 @@ function adminStatusBadge(array $a, int $now): string Modifier +
+ +
diff --git a/templates/post_view.php b/templates/post_view.php index dcd8317..1f9ae0b 100644 --- a/templates/post_view.php +++ b/templates/post_view.php @@ -9,33 +9,62 @@ $_accentMap = [ ]; $_tocItems = []; $_tocSeen = []; -// Le titre H1 est déjà affiché par le template ; on le retire du rendu. -$_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent); -$_renderedContent = preg_replace_callback( - '/<(h[23])>(.+?)<\/h[23]>/i', - function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) { - $tag = $m[1]; - $inner = $m[2]; - $level = (int) substr($tag, 1); - $plain = strip_tags($inner); - $slug = trim(preg_replace( - '/[^a-z0-9]+/', - '-', - mb_strtolower(strtr($plain, $_accentMap), 'UTF-8') - ), '-') ?: 'section'; - if (isset($_tocSeen[$slug])) { - $_tocSeen[$slug]++; - $id = $slug . '-' . $_tocSeen[$slug]; - } else { - $_tocSeen[$slug] = 0; - $id = $slug; - } - $_tocItems[] = ['level' => $level, 'text' => $plain, 'id' => $id]; - return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}"; - }, - $Parsedown->text($_rawForRender) -); -$_renderedContent = typographieHtml($_renderedContent ?? ''); + +// Cache du rendu Markdown (invalidé si index.md est plus récent) +$_mdFile = defined('DATA_PATH') ? DATA_PATH . '/' . ($article['uuid'] ?? '') . '/index.md' : ''; +$_cacheFile = defined('DATA_PATH') ? DATA_PATH . '/' . ($article['uuid'] ?? '') . '/_cache/content_rendered.json' : ''; +$_mdMtime = ($_mdFile !== '' && file_exists($_mdFile)) ? (int)filemtime($_mdFile) : 0; + +$_renderedContent = null; +if ($_cacheFile !== '' && file_exists($_cacheFile)) { + $_tmp = json_decode((string)file_get_contents($_cacheFile), true); + if (is_array($_tmp) && isset($_tmp['ts'], $_tmp['html'], $_tmp['toc']) + && (int)$_tmp['ts'] >= $_mdMtime && $_mdMtime > 0) { + $_renderedContent = $_tmp['html']; + $_tocItems = $_tmp['toc']; + } +} + +if ($_renderedContent === null) { + // Le titre H1 est déjà affiché par le template ; on le retire du rendu. + $_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent); + $_renderedContent = preg_replace_callback( + '/<(h[23])>(.+?)<\/h[23]>/i', + function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) { + $tag = $m[1]; + $inner = $m[2]; + $level = (int) substr($tag, 1); + $plain = strip_tags($inner); + $slug = trim(preg_replace( + '/[^a-z0-9]+/', + '-', + mb_strtolower(strtr($plain, $_accentMap), 'UTF-8') + ), '-') ?: 'section'; + if (isset($_tocSeen[$slug])) { + $_tocSeen[$slug]++; + $id = $slug . '-' . $_tocSeen[$slug]; + } else { + $_tocSeen[$slug] = 0; + $id = $slug; + } + $_tocItems[] = ['level' => $level, 'text' => $plain, 'id' => $id]; + return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}"; + }, + $Parsedown->text($_rawForRender) + ); + $_renderedContent = typographieHtml($_renderedContent ?? ''); + // Lazy loading sur toutes les images du contenu + $_renderedContent = preg_replace('/]*)>/i', '', $_renderedContent ?? '') ?? $_renderedContent; + + // Écriture du cache + if ($_cacheFile !== '' && $_mdMtime > 0) { + @mkdir(dirname($_cacheFile), 0755, true); + @file_put_contents($_cacheFile, json_encode( + ['ts' => $_mdMtime, 'html' => $_renderedContent, 'toc' => $_tocItems], + JSON_UNESCAPED_UNICODE + )); + } +} ob_start(); From 5ce91da06aacdc73a678803fe3e9bf32ccf71ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Sat, 16 May 2026 10:44:08 +0200 Subject: [PATCH 07/16] perf & ux : cache getAll, fingerprint assets, Last-Modified, 404 log, row-click bulk (v1.6.19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getAll() : cache fichier articles_list.json, invalidé à chaque écriture (#16) - layout.php : fingerprinting ?v= sur CSS/JS pour invalidation navigateur (#18) - case 'view' : Last-Modified + 304 Not Modified pour les articles publiés (#18) - case 'not_found' : logging JSON des 404 dans _logs/not_found.jsonl (#52) - case 'view' : echo nu → templates/404.php pour brouillons/privés (#52) - admin.js : clic sur ligne tableau → toggle bulk-check (#86) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 14 +++++++++++ public/assets/js/admin.js | 17 ++++++++++++++ public/index.php | 49 ++++++++++++++++++++++++++++++++++++--- public/version.txt | 2 +- src/ArticleManager.php | 23 +++++++++++++++++- templates/layout.php | 17 ++++++++++---- 6 files changed, 113 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e967e..8806ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.19] - 2026-05-16 + +### Ajouté +- `admin/articles` : clic sur la ligne entière pour cocher/décocher la case de sélection bulk (#86) +- Cache HTTP `Last-Modified` + réponse `304 Not Modified` pour les articles publiés (#18) +- Fingerprinting des assets CSS/JS dans `layout.php` (`?v=`) pour invalidation automatique du cache navigateur (#18) +- Cache fichier `_cache/articles_list.json` pour `getAll()` — invalidé à chaque écriture, élimine le scan complet par requête (#16) +- Logging des 404 dans `DATA_PATH/_logs/not_found.json` (url, referer, user-agent, date) (#52) + +### Corrigé +- `case 'view'` : les accès refusés (brouillon, avant-première, catégorie privée) utilisent désormais `templates/404.php` au lieu d'un `echo` nu (#52) + +--- + ## [1.6.18] - 2026-05-16 ### Ajouté diff --git a/public/assets/js/admin.js b/public/assets/js/admin.js index 33b859f..227d996 100644 --- a/public/assets/js/admin.js +++ b/public/assets/js/admin.js @@ -19,6 +19,23 @@ document.addEventListener('DOMContentLoaded', function () { }); } + // Clic sur la ligne entière pour cocher/décocher la case de sélection + document.querySelectorAll('table tbody tr').forEach(function (tr) { + var cb = tr.querySelector('.bulk-check'); + if (!cb) { return; } + tr.style.cursor = 'pointer'; + tr.addEventListener('click', function (e) { + if (e.target.closest('a, button, input, label')) { return; } + cb.checked = !cb.checked; + if (checkAll) { + var total = document.querySelectorAll('.bulk-check').length; + var checked = document.querySelectorAll('.bulk-check:checked').length; + checkAll.checked = total > 0 && checked === total; + checkAll.indeterminate = checked > 0 && checked < total; + } + }); + }); + // Indicateurs de traitement formulaire SMTP (config + tester connexion) var smtpForm = document.getElementById('smtp-config-form'); if (smtpForm) { diff --git a/public/index.php b/public/index.php index 3d75668..87cabff 100644 --- a/public/index.php +++ b/public/index.php @@ -50,6 +50,25 @@ $metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : unset($_noindexActions); // ─── Recherche de l'article le plus proche et redirection 301 ──────────────── +function log404(string $url): void +{ + if (!defined('DATA_PATH')) { + return; + } + $logDir = DATA_PATH . '/_logs'; + $logFile = $logDir . '/not_found.jsonl'; + if (!is_dir($logDir)) { + @mkdir($logDir, 0755, true); + } + $entry = json_encode([ + 'ts' => date('Y-m-d H:i:s'), + 'url' => $url, + 'ref' => $_SERVER['HTTP_REFERER'] ?? '', + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + @file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX); +} + function slugToSearchQuery(string $rawPath): string { return trim((string)preg_replace('/\s{2,}/', ' ', (string)preg_replace( @@ -679,7 +698,7 @@ switch ($action) { if (!$article['published']) { if (!canDoOnArticle('view_drafts', $article)) { http_response_code(404); - echo 'Article introuvable.'; + include BASE_PATH . '/templates/404.php'; exit; } } @@ -688,7 +707,7 @@ switch ($action) { if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) { if (!hasCapability('view_previews')) { http_response_code(404); - echo 'Article introuvable.'; + include BASE_PATH . '/templates/404.php'; exit; } } @@ -700,10 +719,33 @@ switch ($action) { $isPrivateCat = $articleCat !== '' && in_array($articleCat, $privateCats, true); if ($isPrivateCat && !isLoggedIn()) { http_response_code(404); - echo 'Article introuvable.'; + include BASE_PATH . '/templates/404.php'; exit; } + // Cache HTTP : Last-Modified + 304 pour les articles publiés + if ($article['published']) { + $_uuid = $article['uuid'] ?? ''; + $_mdFile = DATA_PATH . '/' . $_uuid . '/index.md'; + $_mfFile = DATA_PATH . '/' . $_uuid . '/meta.json'; + $_lm = max( + is_file($_mdFile) ? (int)filemtime($_mdFile) : 0, + is_file($_mfFile) ? (int)filemtime($_mfFile) : 0 + ); + if ($_lm > 0) { + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $_lm) . ' GMT'); + header('Cache-Control: public, max-age=60'); + $ifModSince = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) + ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) + : false; + if ($ifModSince !== false && $_lm <= $ifModSince) { + http_response_code(304); + exit; + } + } + unset($_uuid, $_mdFile, $_mfFile, $_lm, $ifModSince); + } + $files = $articles->getFiles($article['uuid']); // Résout les chemins de fichiers relatifs dans le contenu @@ -3473,6 +3515,7 @@ switch ($action) { (string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''), '/' ); + log404('/' . $notFoundPath); $q = slugToSearchQuery($notFoundPath); header('Location: /search' . ($q !== '' ? '?q=' . urlencode($q) : ''), true, 302); exit; diff --git a/public/version.txt b/public/version.txt index 7a9d793..e55f803 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.18 +1.6.19 diff --git a/src/ArticleManager.php b/src/ArticleManager.php index d206d91..9946ab9 100644 --- a/src/ArticleManager.php +++ b/src/ArticleManager.php @@ -30,6 +30,14 @@ class ArticleManager private function loadAll(): array { + $cachePath = $this->allListCachePath(); + if (file_exists($cachePath)) { + $cached = json_decode((string)file_get_contents($cachePath), true); + if (is_array($cached) && $cached !== []) { + return $cached; + } + } + $articles = []; if (!is_dir($this->dataDir)) { return $articles; @@ -66,6 +74,12 @@ class ArticleManager } } + $cacheDir = dirname($cachePath); + if (!is_dir($cacheDir)) { + @mkdir($cacheDir, 0755, true); + } + @file_put_contents($cachePath, json_encode($articles, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return $articles; } @@ -849,6 +863,7 @@ class ArticleManager $this->allCache = null; @unlink($this->articleCachePath($uuid)); @unlink($this->slugIndexPath()); + @unlink($this->allListCachePath()); $this->removeDir($dir); } if (is_dir($dir)) { @@ -879,6 +894,11 @@ class ArticleManager return $this->dataDir . '/_cache/slug_index.json'; } + private function allListCachePath(): string + { + return $this->dataDir . '/_cache/articles_list.json'; + } + private function buildSlugIndex(): void { $cacheDir = $this->dataDir . '/_cache'; @@ -1343,9 +1363,10 @@ class ArticleManager $this->searchIndexCache = null; $uuid = $meta['uuid'] ?? basename($dir); - // Invalider le cache article et le slug index + // Invalider les caches article, liste et slug index @unlink($this->articleCachePath($uuid)); @unlink($this->slugIndexPath()); + @unlink($this->allListCachePath()); file_put_contents( $dir . '/meta.json', diff --git a/templates/layout.php b/templates/layout.php index c96bf79..23abc9c 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -46,11 +46,20 @@ - + + class=""> - +