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] 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();