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