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] 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=""> - +