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[^>]*)>(.*?)\1>#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
| = htmlspecialchars($a['category'] ?? '–') ?> |
= adminStatusBadge($a, $now) ?> |
+
+
+
+ $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 !== '')); ?>
+
+
+ = !empty($a['featured']) ? '★' : '' ?>
+
+ |
= htmlspecialchars(date('d/m/Y', strtotime((string)($a['published_at'] ?? $a['created_at'] ?? '')))) ?>
|
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))
·
= $pubDate ?>
+
+
= htmlspecialchars($modDate) ?>
+
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
?>
= htmlspecialchars($article['title']) ?>
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
= count($adminData['search_terms'] ?? []) ?>
- Derniers 14 jours de logs · cache 10 min
+
+
Derniers = (int)($adminData['search_days'] ?? 14) ?> jours · cache 10 min
+
+
@@ -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) ?>
= htmlspecialchars($base) ?>
- = htmlspecialchars(siteClaim()) ?>
+ = htmlspecialchars($channelDesc) ?>
= htmlspecialchars(siteLang()) ?>
= htmlspecialchars($lastBuild) ?>
@@ -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 ?>
= htmlspecialchars($link) ?>
= $desc ?>
+
+ ]]>
+
= htmlspecialchars($pubDate) ?>
= $guid ?>
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):
= htmlspecialchars((string)$em['subject']) ?> |
= $emBadge ?> |
-
- Voir
-
-
- Erreur : = htmlspecialchars((string)$em['error_message']) ?>
-
-
- = htmlspecialchars((string)$em['content_text']) ?>
-
-
-
+
+ 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
+
+
+
= count($fluxErrors) ?> flux en erreur
+
+
+ -
+ = htmlspecialchars($_err['label'] !== '' ? $_err['label'] : $_err['feed_url']) ?>
+ —
= htmlspecialchars($_err['feed_url']) ?>
+ (= htmlspecialchars($_err['user_email']) ?>)
+
+
+
+
+
+
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}{$tag}>";
- },
- $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}{$tag}>";
+ },
+ $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="= htmlspecialchars($bodyClass) ?>">
-
+