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 '' + . '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é.
', null)); + } + if (strtotime((string)$row['expires_at']) < time()) { + http_response_code(400); + exit(renderMagicPage('Lien expiré', 'Ce lien de connexion a expiré.
', 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()) . '
', 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