v1.6.25 — intégration IA éditeur, onglet admin IA, corrections CSP #98

Merged
cedricAbonnel merged 16 commits from dev into main 2026-05-16 12:07:34 +00:00
40 changed files with 1682 additions and 513 deletions
+8
View File
@@ -55,3 +55,11 @@ DATA_PATH=/srv/data/folio
# Logs Apache (onglet Recherches dans /admin)
# Nom du fichier de log d'accès du vhost dans /var/log/apache2/
APACHE_ACCESS_LOG=lan.acegrp.varlog-access.log
# IA — analyse critique et réécriture d'articles dans l'éditeur
# Provider : anthropic (API) ou claude_code (CLI local)
# AI_PROVIDER=anthropic
# Clé API Anthropic (obtenir sur https://console.anthropic.com/)
ANTHROPIC_API_KEY=
# Modèle à utiliser (défaut : claude-haiku-4-5-20251001) — ignoré si provider=claude_code
# AI_MODEL=claude-haiku-4-5-20251001
+143
View File
@@ -5,6 +5,149 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
---
## [1.6.25] - 2026-05-16
### Ajouté
- Admin : onglet « IA » — statut provider/clé, sélecteur `anthropic`/`claude_code`, champ modèle, procédure d'installation CLI, sauvegarde dans `site_settings.json` (#97)
- `AiService` : support du provider Claude Code CLI via `proc_open` + lecture provider/modèle depuis `SiteSettings` (#97)
- Éditeur : bouton IA unique « Analyser et proposer » — un seul appel retourne l'analyse critique et la réécriture via séparateur `===CRITIQUE===/===REWRITE===` (#96)
### Corrigé
- Éditeur IA : boutons placés dans `wizard/step1.php` (la vraie page d'édition) ; `ai-editor.js` adapté pour `#wz-content` et extraction du titre depuis le Markdown (#96)
- Sécurité CSP : extraction du `<script>` inline de `comments_section.php` vers `comments.js` (#95)
- Sécurité CSP : remplacement du `onclick` inline dans `wizard/step6.php` par `data-confirm-discard` + listener dans `admin.js` (#95)
- Sécurité CSP : remplacement du `oninput` inline dans `post_confirm.php` par un `addEventListener` dans `post_confirm.js` (#95)
---
## [1.6.24] - 2026-05-16
### Ajouté
- Éditeur : intégration IA — service `AiService`, route `ai_query`, script `ai-editor.js`, clé `ANTHROPIC_API_KEY` dans `.env` (#96)
---
## [1.6.23] - 2026-05-16
### Ajouté
- Section « Historique » dans la sidebar des articles (connectés) : liste des révisions avec lien vers le diff (#82)
---
## [1.6.22] - 2026-05-16
### Ajouté
- Widget de notation ★ (1-5 étoiles) sur les articles, accessible aux utilisateurs connectés ; affiche la moyenne et le nombre de votes pour tous (#13)
- Admin `flux` : onglet listant tous les flux RSS agrégés avec action de suppression admin (#87)
---
## [1.6.21] - 2026-05-16
### Ajouté
- Feed RSS : balise `<media:thumbnail>` avec l'image de couverture de l'article (namespace `media:`) (#90)
- Admin livres : slug généré automatiquement depuis le titre à la création (#89)
- Admin livres : champ de filtre texte en temps réel sur le sélecteur « Ajouter une page » (#89)
### Corrigé
- `autoSeoDesc` : décodage des entités HTML (`&amp;`, `&nbsp;`…) + suppression du titre en tête de description (#91)
- `post_confirm.js` : guard null sur `#confirm-slug` absent (étape 5 du wizard) — plus d'erreur JS (#91)
---
## [1.6.20] - 2026-05-16
### Ajouté
- Barre de partage sur les articles publiés : mail, X, LinkedIn, Mastodon, copie de lien, Web Share API mobile (#47)
- Déduplication des images uploadées par hardlink si même hash+taille existe déjà dans un autre article (#35)
---
## [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=<hash>`) 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é
- 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é
- RSS : élément `<content:encoded>` 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 : `<description>` utilise désormais le champ `plain` pré-calculé (fix : contenu vide depuis v1.6.14) (#42)
---
## [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é
- `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/<slug>` (#83)
---
## [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é
- Typographie : guillemets droits convertis en guillemets courbes (`"``"` / `"`, `'``'` / `'`) dans le rendu des articles — blocs `<code>` et `<pre>` 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é
+2
View File
@@ -44,3 +44,5 @@ if (!function_exists('url')) {
return $u;
}
}
require_once BASE_PATH . '/src/helpers.php';
+1
View File
@@ -130,3 +130,4 @@ sudo git config --system --add safe.directory /var/www/lan.acegrp.abonnel-www
- Ne **jamais** versionner `data/`, `.env`, ou `vendor/` dans le dépôt folio.
- Toujours bumper la version **et** mettre à jour le changelog dans le même commit que la PR.
- Dans les pools PHP-FPM, toujours utiliser `user = www-data` / `group = www-data`. `cedrix` est un admin ordinaire, pas un compte de service.
- **CSP** : le header `Content-Security-Policy` est défini dans la config Apache (`varlog/server/apache/lan.acegrp.varlog.conf`), pas dans PHP. La directive `script-src 'self'` interdit les scripts inline — ne jamais écrire de `<script>` inline dans les templates ; toujours utiliser des fichiers `.js` externes dans `public/assets/js/`. Les erreurs CSP mentionnant `content.js` viennent d'extensions navigateur bloquées par le CSP (comportement normal, pas un bug Folio). Concernant les formulaires HTML : les `<form>` imbriqués sont invalides — un bouton submit dans un form imbriqué soumet le form parent. Utiliser l'attribut HTML5 `form="id-du-form"` pour associer un bouton à un form situé hors du form englobant.
+3 -1
View File
@@ -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]
@@ -41,8 +42,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/<email> avant la règle générique admin/<tab>)
# Admin (regen-thumbs, email-preview et role/<email> avant la règle générique admin/<tab>)
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]
+59
View File
@@ -9,6 +9,16 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
// Boutons data-confirm-discard (évite onclick inline bloqué par CSP)
document.querySelectorAll('[data-confirm-discard]').forEach(function (btn) {
btn.addEventListener('click', function () {
var msg = btn.getAttribute('data-confirm-msg') || 'Confirmer ?';
if (window.confirm(msg)) {
window.location = btn.getAttribute('data-discard-url');
}
});
});
// Sélection globale articles
var checkAll = document.getElementById('check-all');
if (checkAll) {
@@ -19,6 +29,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) {
@@ -68,5 +95,37 @@ document.addEventListener('DOMContentLoaded', function () {
if (lines.indexOf(slug) === -1) { lines.push(slug); ta.value = lines.join('\n'); }
bookArticleSel.value = '';
});
// Filtre texte en temps réel pour le sélecteur d'articles
var bookFilter = document.getElementById('book-article-filter');
if (bookFilter) {
var bookOptions = Array.from(bookArticleSel.options);
bookFilter.addEventListener('input', function () {
var q = bookFilter.value.trim().toLowerCase();
bookArticleSel.innerHTML = '';
bookOptions.forEach(function (opt) {
if (opt.value === '' || q === '' || opt.textContent.toLowerCase().includes(q)) {
bookArticleSel.appendChild(opt.cloneNode(true));
}
});
});
}
}
// Slug auto pour la création d'un livre
var newBookTitle = document.getElementById('new-book-title');
var newBookSlugPreview = document.getElementById('new-book-slug-preview');
var newBookSlugHidden = document.getElementById('new-book-slug-hidden');
if (newBookTitle && newBookSlugPreview && newBookSlugHidden) {
function toBookSlug(s) {
var map = { 'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe' };
s = s.toLowerCase().replace(/[àâäéèêëîïôöùûüçæœ]/g, function (c) { return map[c] || c; });
return s.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
newBookTitle.addEventListener('input', function () {
var slug = toBookSlug(newBookTitle.value);
newBookSlugPreview.value = slug;
newBookSlugHidden.value = slug;
});
}
});
+78
View File
@@ -0,0 +1,78 @@
// ai-editor.js — bouton IA dans la sidebar éditeur
document.addEventListener('DOMContentLoaded', function () {
var btnAnalyze = document.getElementById('btn-ai-analyze');
if (!btnAnalyze) return;
var panel = document.getElementById('ai-result-panel');
var critiqueEl = document.getElementById('ai-critique-content');
var rewriteEl = document.getElementById('ai-rewrite-content');
var btnApply = document.getElementById('btn-ai-apply');
var btnClose = document.getElementById('btn-ai-close');
var ta = document.getElementById('wz-content') || document.getElementById('content');
var titleEl = document.getElementById('title');
var lastRewrite = '';
btnAnalyze.addEventListener('click', async function () {
btnAnalyze.disabled = true;
btnAnalyze._origText = btnAnalyze.textContent;
btnAnalyze.textContent = 'En cours…';
panel.style.display = 'none';
lastRewrite = '';
try {
var titleVal = titleEl ? titleEl.value : '';
if (!titleVal && ta) {
var m = ta.value.match(/^#\s+(.+)/m);
if (m) { titleVal = m[1].trim(); }
}
var res = await fetch('/?action=ai_query', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
action: 'analyze',
title: titleVal,
content: ta ? ta.value : '',
}),
});
var data = await res.json();
if (!data.ok) {
critiqueEl.textContent = data.error || 'Erreur inconnue.';
rewriteEl.textContent = '';
btnApply.style.display = 'none';
} else {
critiqueEl.textContent = data.critique || '';
rewriteEl.textContent = data.rewrite || '';
lastRewrite = data.rewrite || '';
btnApply.style.display = lastRewrite ? '' : 'none';
}
panel.style.display = '';
} catch (e) {
critiqueEl.textContent = 'Erreur de connexion.';
rewriteEl.textContent = '';
btnApply.style.display = 'none';
panel.style.display = '';
} finally {
btnAnalyze.disabled = false;
btnAnalyze.textContent = btnAnalyze._origText;
}
});
btnApply.addEventListener('click', function () {
if (!lastRewrite) return;
if (!confirm("Remplacer le contenu de l'éditeur par la proposition IA ?")) return;
if (ta) {
ta.value = lastRewrite;
ta.dispatchEvent(new Event('input'));
}
panel.style.display = 'none';
lastRewrite = '';
});
btnClose.addEventListener('click', function () {
panel.style.display = 'none';
lastRewrite = '';
});
});
+24
View File
@@ -0,0 +1,24 @@
document.addEventListener('DOMContentLoaded', function () {
var maxAge = 365 * 24 * 3600;
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function setCookie(name, value) {
document.cookie = name + '=' + encodeURIComponent(value) + ';max-age=' + maxAge + ';path=/;SameSite=Lax';
}
var nameEl = document.getElementById('comment-name');
var emailEl = document.getElementById('comment-email');
if (!nameEl || !emailEl) { return; }
var savedName = getCookie('cmt_name');
var savedEmail = getCookie('cmt_email');
if (savedName) { nameEl.value = savedName; }
if (savedEmail) { emailEl.value = savedEmail; }
var form = document.getElementById('comment-form');
if (form) {
form.addEventListener('submit', function () {
if (nameEl.value.trim()) { setCookie('cmt_name', nameEl.value.trim()); }
if (emailEl.value.trim()) { setCookie('cmt_email', emailEl.value.trim()); }
});
}
});
+14 -6
View File
@@ -22,12 +22,14 @@ document.addEventListener('DOMContentLoaded', function () {
initCounter('seo_description', 'seo_desc_counter', 155);
function updatePreview() {
var seoTitle = document.getElementById('seo_title').value.trim();
var seoDesc = document.getElementById('seo_description').value.trim();
var slug = document.getElementById('confirm-slug').value.trim();
var seoTitle = document.getElementById('seo_title').value.trim();
var seoDesc = document.getElementById('seo_description').value.trim();
var slugEl = document.getElementById('confirm-slug');
document.getElementById('preview-title').textContent = seoTitle || defaultTitle;
document.getElementById('preview-desc').textContent = seoDesc || defaultDesc;
document.getElementById('preview-url').textContent = baseUrl + slug;
if (slugEl) {
document.getElementById('preview-url').textContent = baseUrl + slugEl.value.trim();
}
}
['seo_title', 'seo_description', 'confirm-slug'].forEach(function (id) {
@@ -38,8 +40,14 @@ document.addEventListener('DOMContentLoaded', function () {
var slugInput = document.getElementById('confirm-slug');
var slugDisplay = document.getElementById('slug-display');
if (slugInput && slugDisplay) {
slugInput.addEventListener('input', function () {
slugDisplay.textContent = slugInput.value;
});
}
var btnSuggest = document.getElementById('slug-btn-suggest');
if (btnSuggest) {
if (btnSuggest && slugInput && slugDisplay) {
btnSuggest.addEventListener('click', function () {
var val = btnSuggest.dataset.slugSuggest;
slugInput.value = val;
@@ -49,7 +57,7 @@ document.addEventListener('DOMContentLoaded', function () {
}
var btnKeep = document.getElementById('slug-btn-keep');
if (btnKeep) {
if (btnKeep && slugInput && slugDisplay) {
btnKeep.addEventListener('click', function () {
var val = btnKeep.dataset.slugKeep;
slugInput.value = val;
+40
View File
@@ -0,0 +1,40 @@
(function () {
'use strict';
var bar = document.getElementById('share-bar');
if (!bar) { return; }
var url = bar.getAttribute('data-url') || window.location.href;
var title = bar.getAttribute('data-title') || document.title;
var copyBtn = document.getElementById('share-copy');
if (copyBtn) {
copyBtn.addEventListener('click', function () {
if (!navigator.clipboard) {
var ta = document.createElement('textarea');
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.textContent = 'Copié !';
setTimeout(function () { copyBtn.textContent = 'Copier le lien'; }, 2000);
return;
}
navigator.clipboard.writeText(url).then(function () {
copyBtn.textContent = 'Copié !';
setTimeout(function () { copyBtn.textContent = 'Copier le lien'; }, 2000);
});
});
}
var nativeBtn = document.getElementById('share-native');
if (nativeBtn) {
if (navigator.share) {
nativeBtn.hidden = false;
nativeBtn.addEventListener('click', function () {
navigator.share({ title: title, url: url }).catch(function () {});
});
}
}
}());
+40 -15
View File
@@ -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,13 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
?>
<rss version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:media="http://search.yahoo.com/mrss/"
xmlns:fh="http://purl.org/syndication/history/1.0">
<channel>
<title><?= htmlspecialchars(siteTitle()) ?></title>
<title><?= htmlspecialchars($channelTitle) ?></title>
<link><?= htmlspecialchars($base) ?></link>
<description><?= htmlspecialchars(siteClaim()) ?></description>
<description><?= htmlspecialchars($channelDesc) ?></description>
<language><?= htmlspecialchars(siteLang()) ?></language>
<lastBuildDate><?= htmlspecialchars($lastBuild) ?></lastBuildDate>
@@ -91,17 +103,30 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
<?php endif; ?>
<?php foreach ($items as $article):
$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+/', ' ', strip_tags($Parsedown->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) : '';
$imgUrl = trim($article['og_image'] ?? '');
if ($imgUrl === '' && ($article['cover'] ?? '') !== '') {
$imgUrl = $base . '/file?uuid=' . rawurlencode($article['uuid']) . '&name=' . rawurlencode($article['cover']);
}
?>
<item>
<title><?= $title ?></title>
<link><?= htmlspecialchars($link) ?></link>
<description><?= $desc ?></description>
<?php if ($fullHtml !== ''): ?>
<content:encoded><![CDATA[<?= $fullHtml ?>]]></content:encoded>
<?php endif; ?>
<?php if ($imgUrl !== ''): ?>
<media:thumbnail url="<?= htmlspecialchars($imgUrl, ENT_XML1) ?>"/>
<?php endif; ?>
<pubDate><?= htmlspecialchars($pubDate) ?></pubDate>
<guid isPermaLink="true"><?= $guid ?></guid>
</item>
+276 -46
View File
@@ -45,17 +45,54 @@ $action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? '';
$slug = $_GET['slug'] ?? '';
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config', 'run_engine_update', 'run_content_migrations'];
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config', 'run_engine_update', 'run_content_migrations', 'admin_delete_feed', 'rate', 'admin_save_ai_config'];
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
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 buildAutoSeoDesc(string $content, string $title = ''): string
{
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$_plain = trim((string)preg_replace('/\s+/', ' ',
html_entity_decode(strip_tags($_pd->text($content)), ENT_QUOTES | ENT_HTML5, 'UTF-8')
));
if ($title !== '' && stripos($_plain, $title) === 0) {
$_plain = ltrim(substr($_plain, strlen($title)));
}
return mb_strimwidth($_plain, 0, 155, '…');
}
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;
}
@@ -629,10 +666,7 @@ switch ($action) {
header('Location: /new');
exit;
}
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
$autoSeoDesc = buildAutoSeoDesc((string)($draft['content'] ?? ''), trim($draft['title'] ?? ''));
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$seoTitle = trim($_POST['seo_title'] ?? '');
$seoDesc = trim($_POST['seo_description'] ?? '') ?: $autoSeoDesc;
@@ -666,16 +700,15 @@ 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;
}
if (!$article['published']) {
if (!canDoOnArticle('view_drafts', $article)) {
http_response_code(404);
echo 'Article introuvable.';
include BASE_PATH . '/templates/404.php';
exit;
}
}
@@ -684,7 +717,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;
}
}
@@ -696,10 +729,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
@@ -975,10 +1031,7 @@ switch ($action) {
header('Location: /edit/' . rawurlencode($uuid) . '/6');
exit;
}
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
$autoSeoDesc = buildAutoSeoDesc((string)($draft['content'] ?? ''), trim($draft['title'] ?? ''));
$title = $draft['title'];
$seoTitle = $draft['seo_title'] ?? '';
$seoDescription = $draft['seo_description'] ?? '';
@@ -1000,10 +1053,7 @@ switch ($action) {
exit;
}
$draftData = $articles->getDraftOverlay($uuid) ?? $article;
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draftData['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
$autoSeoDesc = buildAutoSeoDesc((string)($draftData['content'] ?? ''), trim($draftData['title'] ?? ''));
$diffLines = lineDiff((string)($article['content'] ?? ''), (string)($draftData['content'] ?? ''));
$titleChanged = ($draftData['title'] ?? '') !== ($article['title'] ?? '');
$postSlug = $draftData['slug'] ?? $article['slug'];
@@ -1118,6 +1168,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 !== '') {
@@ -1398,9 +1469,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(
@@ -1413,6 +1485,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'];
@@ -1611,6 +1688,24 @@ switch ($action) {
echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? '')));
exit;
case 'ai_query':
requireAuth();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'Méthode invalide']);
exit;
}
$_aiAction = trim($_POST['action'] ?? '');
$_aiTitle = trim($_POST['title'] ?? '');
$_aiContent = str_replace("\r\n", "\n", trim($_POST['content'] ?? ''));
if (!in_array($_aiAction, ['critique', 'rewrite', 'analyze'], true) || $_aiContent === '') {
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']);
exit;
}
require_once BASE_PATH . '/src/Service/AiService.php';
echo json_encode((new AiService())->query($_aiAction, $_aiTitle, $_aiContent));
exit;
case 'import_image_step2':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -2160,11 +2255,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 = '<!DOCTYPE html><html><body style="font-family:sans-serif;max-width:560px;margin:0 auto">'
. '<p>Bonjour,</p>'
. '<p><strong>' . htmlspecialchars((string)$vcPreInfo['author_name']) . '</strong>'
. ' a commenté votre article <em>' . htmlspecialchars($vcArticle['title'] ?? '') . '</em> :</p>'
. '<blockquote style="border-left:3px solid #ddd;margin:0;padding:0 1em;color:#555">'
. nl2br(htmlspecialchars($vcExcerpt)) . '</blockquote>'
. '<p><a href="' . htmlspecialchars($vcPostUrl) . '">Voir le commentaire</a>'
. ' &nbsp;·&nbsp; <a href="' . htmlspecialchars($vcAdminUrl) . '">Modérer</a></p>'
. '</body></html>';
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;
}
@@ -2355,9 +2483,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 +2505,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';
@@ -2527,7 +2665,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"
@@ -2567,9 +2705,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') {
@@ -2613,6 +2753,21 @@ switch ($action) {
$adminData['tagTypes'] = $articles->getTagTypes();
}
if ($tab === 'flux') {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$pdo = dbPdo();
$adminData['flux_feeds'] = [];
if ($pdo) {
try {
$st = $pdo->query('SELECT id, user_email, feed_url, label, created_at FROM rss_feeds ORDER BY created_at DESC');
$adminData['flux_feeds'] = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {}
}
}
if ($tab === 'books') {
if (!isAdmin()) {
http_response_code(403);
@@ -2628,9 +2783,37 @@ switch ($action) {
}
}
if ($tab === 'ia') {
if (!isAdmin()) { http_response_code(403); exit; }
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/src/Service/AiService.php';
$adminData['ai_provider'] = aiProvider();
$adminData['ai_model'] = aiModel();
$adminData['anthropic_key_set'] = (($_ENV['ANTHROPIC_API_KEY'] ?? getenv('ANTHROPIC_API_KEY') ?: '') !== '');
$adminData['claude_cli_found'] = is_executable('/usr/local/bin/claude');
$adminData['ai_notice'] = $_GET['notice'] ?? '';
}
include BASE_PATH . '/templates/admin.php';
break;
case 'admin_save_ai_config':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403); exit;
}
require_once BASE_PATH . '/src/SiteSettings.php';
$allowedProviders = ['anthropic', 'claude_code'];
$aiProvider = in_array($_POST['ai_provider'] ?? '', $allowedProviders, true)
? $_POST['ai_provider']
: 'anthropic';
$ok = saveSiteSettings([
'ai_provider' => $aiProvider,
'ai_model' => trim($_POST['ai_model'] ?? ''),
]);
header('Location: /admin/ia?notice=' . ($ok ? 'saved' : 'error'));
exit;
case 'admin_smtp_save':
requireAuth();
if (!isAdmin()) {
@@ -2741,6 +2924,45 @@ 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 '<!doctype html><html lang="fr"><head><meta charset="utf-8"><title>' . htmlspecialchars((string)$emailRow['subject']) . '</title></head><body>' . $previewHtml . '</body></html>';
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') {
@@ -3205,6 +3427,24 @@ switch ($action) {
header('Location: /profile#feeds');
exit;
case 'admin_delete_feed':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$feedId = (int)($_POST['id'] ?? 0);
if ($feedId > 0) {
$pdo = dbPdo();
if ($pdo) {
try {
$pdo->prepare('DELETE FROM rss_feeds WHERE id = :id')->execute([':id' => $feedId]);
} catch (\Throwable) {}
}
}
header('Location: /admin/flux?deleted=1');
exit;
case 'search_files':
requireAuth();
header('Content-Type: application/json');
@@ -3334,6 +3574,9 @@ switch ($action) {
$bTitle = trim($_POST['title'] ?? '');
$bDesc = trim($_POST['description'] ?? '');
$bArts = array_values(array_filter(array_map('trim', preg_split('/[\r\n]+/', $_POST['articles'] ?? ''))));
if ($bSlug === '' && $bTitle !== '') {
$bSlug = $books->sanitizeSlug($bTitle);
}
if ($bSlug !== '' && $bTitle !== '') {
$books->save(['slug' => $bSlug, 'title' => $bTitle, 'description' => $bDesc, 'articles' => $bArts]);
}
@@ -3358,23 +3601,10 @@ 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();
?>
<div class="container py-5 text-center">
<h1 class="h2 mb-3">Page introuvable</h1>
<p class="text-muted mb-4">Cette adresse ne correspond à aucun article.<br>Vous avez peut-être suivi un ancien lien.</p>
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
</div>
<?php
$content = ob_get_clean();
$title = '404 — ' . siteTitle();
$metaRobots = 'noindex, nofollow';
include BASE_PATH . '/templates/layout.php';
break;
log404('/' . $notFoundPath);
$q = slugToSearchQuery($notFoundPath);
header('Location: /search' . ($q !== '' ? '?q=' . urlencode($q) : ''), true, 302);
exit;
case 'list':
default:
+14 -33
View File
@@ -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));
}
@@ -45,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 = '/';
@@ -123,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), '+/', '-_'), '=');
+43 -27
View File
@@ -1,8 +1,4 @@
<?php
// projet : mug.a5l.fr
// fichier : pages/login/magic.php
// version : 20251011
declare(strict_types=1);
if (!defined('BASE_PATH')) {
@@ -12,34 +8,56 @@ 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);
exit('Lien invalide.');
exit(renderMagicPage('Lien invalide', '<p>Ce lien de connexion est invalide.</p>', null));
}
$pdo = db();
// ─── Rendu minimal standalone ────────────────────────────────────────────────
function renderMagicPage(string $title, string $body, ?string $token): string
{
$formHtml = $token !== null
? '<form method="post" action="' . htmlspecialchars($_SERVER['REQUEST_URI'] ?? '') . '">'
. '<input type="hidden" name="confirm" value="1">'
. '<button type="submit" style="display:inline-block;padding:10px 24px;background:#0d6efd;color:#fff;border:none;border-radius:4px;font-size:1rem;cursor:pointer">Se connecter</button>'
. '</form>'
: '';
return '<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
. '<title>' . htmlspecialchars($title) . '</title>'
. '<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 1rem;text-align:center}'
. 'h1{font-size:1.4rem;margin-bottom:1rem}</style></head>'
. '<body><h1>' . htmlspecialchars($title) . '</h1>' . $body . $formHtml . '</body></html>';
}
// ─── 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', '<p>Ce lien de connexion est introuvable.</p>', null));
}
if ($row['consumed_at'] !== null) {
http_response_code(400);
exit(renderMagicPage('Lien déjà utilisé', '<p>Ce lien de connexion a déjà été utilisé.</p><p><a href="/login">Demander un nouveau lien</a></p>', null));
}
if (strtotime((string)$row['expires_at']) < time()) {
http_response_code(400);
exit(renderMagicPage('Lien expiré', '<p>Ce lien de connexion a expiré.</p><p><a href="/login">Demander un nouveau lien</a></p>', null));
}
exit(renderMagicPage('Connexion', '<p style="color:#555;margin-bottom:1.5rem">Cliquez sur le bouton ci-dessous pour vous connecter.</p>', $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';
@@ -57,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();
@@ -68,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 = '/';
}
@@ -79,5 +95,5 @@ try {
$pdo->rollBack();
}
http_response_code(400);
echo htmlspecialchars($e->getMessage(), ENT_QUOTES);
exit(renderMagicPage('Erreur', '<p>' . htmlspecialchars($e->getMessage()) . '</p><p><a href="/login">Retour à la connexion</a></p>', null));
}
-14
View File
@@ -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') ?? ''), '/');
-14
View File
@@ -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';
+1 -1
View File
@@ -1 +1 @@
1.6.12
1.6.25
+95 -24
View File
@@ -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;
@@ -44,7 +52,7 @@ class ArticleManager
continue;
}
$article = $this->loadArticle($dir);
$article = $this->loadArticle($dir, false);
if (!$article) {
continue;
}
@@ -53,6 +61,25 @@ 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);
}
}
$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;
}
@@ -137,6 +164,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);
@@ -821,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)) {
@@ -851,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';
@@ -944,19 +992,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 +1081,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;
}
@@ -1143,7 +1197,16 @@ class ArticleManager
$size = filesize($uploadedFile['tmp_name']);
$name = "{$hash}-{$size}.{$ext}";
$dest = $dir . '/' . $name;
if (!rename($uploadedFile['tmp_name'], $dest) && !move_uploaded_file($uploadedFile['tmp_name'], $dest)) {
// Déduplication : si ce fichier identique existe déjà dans un autre article, crée un hardlink
if (!file_exists($dest)) {
$existing = glob($this->dataDir . '/*/files/' . $name);
if (!empty($existing) && is_file($existing[0])) {
link($existing[0], $dest) || copy($existing[0], $dest);
@unlink($uploadedFile['tmp_name']);
return $name;
}
}
if (!file_exists($dest) && !rename($uploadedFile['tmp_name'], $dest) && !move_uploaded_file($uploadedFile['tmp_name'], $dest)) {
return null;
}
return $name;
@@ -1200,21 +1263,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 +1291,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 +1309,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;
}
@@ -1302,9 +1372,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',
+1 -1
View File
@@ -117,7 +117,7 @@ class BookManager
return $this->booksDir . '/' . $slug . '.json';
}
private function sanitizeSlug(string $slug): string
public function sanitizeSlug(string $slug): string
{
$map = [
'à' => 'a', 'â' => 'a', 'ä' => 'a',
-16
View File
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain;
final class User
{
public function __construct(
public string $id,
public string $email,
public string $passwordHash,
public bool $isActive = true,
) {
}
}
-129
View File
@@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Domain\User;
use PDO;
final class UserRepository
{
public function __construct(private PDO $pdo)
{
}
/**
* Crée (si besoin) un utilisateur OIDC.
* - Idempotent par email : si existe, retourne l'id existant.
* - Génère un password_hash aléatoire inutilisable (compte OIDC).
*
* @return string ID (uuid) sous forme de chaîne
*/
public function createFromOidc(string $email): string
{
$email = strtolower(trim($email));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Email OIDC invalide.');
}
// 1) Existe déjà ?
$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;
}
// 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 = <<<SQL
INSERT INTO users (email, password_hash)
VALUES (:email, :hash)
RETURNING id
SQL;
try {
$st = $this->pdo->prepare($sql);
$st->execute([
':email' => $email,
':hash' => $randomHash,
]);
return (string)$st->fetchColumn();
} catch (\PDOException $e) {
// Unique violation sur email (23505) → on relit lid (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 = <<<SQL
UPDATE users
SET password_hash = :h,
updated_at = NOW(),
password_changed_at = NOW()
WHERE id = :id
SQL;
$st = $this->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;
}
}
+26 -13
View File
@@ -8,22 +8,25 @@ class SearchLogParser
private string $vhostBase;
private string $cacheFile;
private int $cacheTtl;
private int $days;
public function __construct(
string $logDir = '/var/log/apache2',
string $vhostBase = '*-access.log',
string $cacheFile = '',
int $cacheTtl = 600
int $cacheTtl = 600,
int $days = 14
) {
$this->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<string,int> terme => nombre d'occurrences, trié desc */
/** @return array<string,int> 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;
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
class AiService
{
private const SYSTEM_CRITIQUE = <<<'PROMPT'
Tu es un relecteur expert de blogs. Analyse l'article ci-dessous et identifie ses faiblesses : arguments insuffisamment étayés, imprécisions, manques de clarté, structure à améliorer, points à développer. Sois constructif et précis. Réponds en markdown avec des sections claires.
PROMPT;
private const SYSTEM_REWRITE = <<<'PROMPT'
Tu es un rédacteur expert. Réécris l'article ci-dessous en améliorant le style, la clarté et la structure, sans modifier le sens ni les faits. Conserve le format markdown, les liens et les références aux images. Réponds uniquement avec l'article réécrit, sans commentaire ni explication.
PROMPT;
private const SYSTEM_ANALYZE = <<<'PROMPT'
Tu es un relecteur et rédacteur expert de blogs. Pour l'article ci-dessous, fais deux choses :
1. Identifie ses faiblesses (arguments faibles, imprécisions, manques de clarté, structure à revoir, points à développer). Sois bref et précis — quelques lignes suffisent.
2. Propose une version améliorée de l'article : meilleur style, clarté, structure. Conserve le sens, les faits, le format markdown, les liens et les références aux images.
Réponds EXACTEMENT dans ce format (les deux séparateurs doivent être présents tels quels) :
===CRITIQUE===
[ton analyse ici]
===REWRITE===
[l'article réécrit ici]
PROMPT;
private string $apiKey;
private string $model;
private string $provider;
public function __construct()
{
require_once BASE_PATH . '/src/SiteSettings.php';
$this->provider = aiProvider();
$this->model = aiModel();
$this->apiKey = $_ENV['ANTHROPIC_API_KEY'] ?? getenv('ANTHROPIC_API_KEY') ?: '';
}
public function isConfigured(): bool
{
if ($this->provider === 'claude_code') {
return is_executable('/usr/local/bin/claude');
}
return $this->apiKey !== '';
}
/** @return array{ok: bool, text?: string, error?: string} */
public function query(string $action, string $title, string $content): array
{
$content = mb_substr(trim($content), 0, 8000);
if ($content === '') {
return ['ok' => false, 'error' => "Contenu de l'article vide"];
}
$userMsg = $title !== '' ? "# {$title}\n\n{$content}" : $content;
if ($action === 'analyze') {
$raw = $this->provider === 'claude_code'
? $this->queryClaudeCode(self::SYSTEM_ANALYZE, $userMsg)
: $this->queryAnthropicRaw(self::SYSTEM_ANALYZE, $userMsg, 4096);
if (!$raw['ok']) return $raw;
return $this->parseAnalyzeResponse($raw['text'] ?? '');
}
$systemPrompt = match ($action) {
'critique' => self::SYSTEM_CRITIQUE,
'rewrite' => self::SYSTEM_REWRITE,
default => null,
};
if ($systemPrompt === null) {
return ['ok' => false, 'error' => 'Action inconnue'];
}
if ($this->provider === 'claude_code') {
return $this->queryClaudeCode($systemPrompt, $userMsg);
}
return $this->queryAnthropic($action, $systemPrompt, $userMsg);
}
/** @return array{ok: bool, critique?: string, rewrite?: string, error?: string} */
private function parseAnalyzeResponse(string $text): array
{
$parts = preg_split('/===CRITIQUE===|===REWRITE===/', $text);
if (count($parts) < 3) {
// Fallback : pas de séparateurs trouvés, on met tout en critique
return ['ok' => true, 'critique' => trim($text), 'rewrite' => ''];
}
return [
'ok' => true,
'critique' => trim($parts[1]),
'rewrite' => trim($parts[2]),
];
}
/** @return array{ok: bool, text?: string, error?: string} */
private function queryAnthropicRaw(string $systemPrompt, string $userMsg, int $maxTokens): array
{
if ($this->apiKey === '') {
return ['ok' => false, 'error' => 'Clé Anthropic non configurée (ANTHROPIC_API_KEY manquante dans .env)'];
}
$payload = json_encode([
'model' => $this->model,
'max_tokens' => $maxTokens,
'system' => $systemPrompt,
'messages' => [['role' => 'user', 'content' => $userMsg]],
]);
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
]);
$resp = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err !== '') return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
$data = json_decode((string) $resp, true);
if ($http !== 200) {
return ['ok' => false, 'error' => $data['error']['message'] ?? ('Anthropic HTTP ' . $http)];
}
return ['ok' => true, 'text' => $data['content'][0]['text'] ?? ''];
}
/** @return array{ok: bool, text?: string, error?: string} */
private function queryAnthropic(string $action, string $systemPrompt, string $userMsg): array
{
if ($this->apiKey === '') {
return ['ok' => false, 'error' => 'Clé Anthropic non configurée (ANTHROPIC_API_KEY manquante dans .env)'];
}
$maxTokens = ($action === 'rewrite') ? 4096 : 1200;
$payload = json_encode([
'model' => $this->model,
'max_tokens' => $maxTokens,
'system' => $systemPrompt,
'messages' => [['role' => 'user', 'content' => $userMsg]],
]);
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
]);
$resp = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err !== '') {
return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
}
$data = json_decode((string) $resp, true);
if ($http !== 200) {
$msg = $data['error']['message'] ?? ('Anthropic HTTP ' . $http);
return ['ok' => false, 'error' => $msg];
}
$text = $data['content'][0]['text'] ?? '';
return ['ok' => true, 'text' => $text];
}
/** @return array{ok: bool, text?: string, error?: string} */
private function queryClaudeCode(string $systemPrompt, string $userMsg): array
{
$bin = '/usr/local/bin/claude';
if (!is_executable($bin)) {
return ['ok' => false, 'error' => 'Claude Code CLI introuvable (/usr/local/bin/claude)'];
}
$prompt = $systemPrompt . "\n\n" . $userMsg;
$cmd = $bin . ' --print ' . escapeshellarg($prompt) . ' 2>&1';
$env = ['HOME' => '/var/lib/claude-www', 'PATH' => '/usr/local/bin:/usr/bin:/bin'];
$desc = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $desc, $pipes, '/tmp', $env);
if (!is_resource($proc)) {
return ['ok' => false, 'error' => 'proc_open échoué'];
}
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
return ['ok' => false, 'error' => 'Claude Code exit ' . $code . ' : ' . trim((string)$out)];
}
return ['ok' => true, 'text' => trim((string)$out)];
}
}
-105
View File
@@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\UserRepository;
final class AuthService
{
public function __construct(private UserRepository $users)
{
}
public function canAttempt(string $email, string $ip): bool
{
// backoff: 5 dernières tentatives/5 min
$sql = "select count(*)
from login_attempts
where ip = :ip
and attempted_at > 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 lutilisateur (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 lancien 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();
}
}
+15 -1
View File
@@ -93,10 +93,24 @@ function asGroups(): array
return is_array($raw) ? $raw : [];
}
function aiProvider(): string
{
$v = siteSettings()['ai_provider'] ?? '';
if ($v !== '') return $v;
return $_ENV['AI_PROVIDER'] ?? getenv('AI_PROVIDER') ?: 'anthropic';
}
function aiModel(): string
{
$v = siteSettings()['ai_model'] ?? '';
if ($v !== '') return $v;
return $_ENV['AI_MODEL'] ?? getenv('AI_MODEL') ?: 'claude-haiku-4-5-20251001';
}
function saveSiteSettings(array $data): bool
{
$current = siteSettings();
$stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url', 'apache_access_log', 'folio_repo_url', 'folio_update_branch'];
$stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url', 'apache_access_log', 'folio_repo_url', 'folio_update_branch', 'ai_provider', 'ai_model'];
foreach ($stringKeys as $key) {
if (array_key_exists($key, $data)) {
$val = trim((string)$data[$key]);
+66
View File
@@ -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 <pre> et <code> 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;
}
+256 -21
View File
@@ -69,6 +69,14 @@ function adminStatusBadge(array $a, int $now): string
<a class="nav-link <?= $tab === 'books' ? 'active' : '' ?>"
href="/admin/books">Livres</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab === 'flux' ? 'active' : '' ?>"
href="/admin/flux">Flux</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab === 'ia' ? 'active' : '' ?>"
href="/admin/ia">IA</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab === 'stats' ? 'active' : '' ?>"
href="/admin/stats">Statistiques</a>
@@ -214,6 +222,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 +273,19 @@ function adminStatusBadge(array $a, int $now): string
<option value="preview" <?= ($adminData['filter_status'] ?? '') === 'preview' ? 'selected' : '' ?>>Avant-première</option>
</select>
</div>
<div class="col-auto">
<select name="filter_featured" class="form-select form-select-sm">
<option value="">Tous</option>
<option value="yes" <?= ($adminData['filter_featured'] ?? '') === 'yes' ? 'selected' : '' ?>>★ À la une</option>
</select>
</div>
<div class="col-auto">
<input type="text" name="filter_search" class="form-control form-control-sm"
placeholder="Rechercher…" value="<?= htmlspecialchars($adminData['filter_search'] ?? '') ?>">
</div>
<div class="col-auto d-flex gap-2">
<button type="submit" class="btn btn-secondary btn-sm">Filtrer</button>
<?php $hasFilter = ($adminData['filter_author'] ?? '') !== '' || ($adminData['filter_category'] ?? '') !== '' || ($adminData['filter_status'] ?? '') !== ''; ?>
<?php $hasFilter = ($adminData['filter_author'] ?? '') !== '' || ($adminData['filter_category'] ?? '') !== '' || ($adminData['filter_status'] ?? '') !== '' || ($adminData['filter_search'] ?? '') !== '' || ($adminData['filter_featured'] ?? '') !== ''; ?>
<?php if ($hasFilter): ?>
<a href="/admin/articles" class="btn btn-link btn-sm p-0">Réinitialiser</a>
<?php endif; ?>
@@ -304,6 +324,7 @@ function adminStatusBadge(array $a, int $now): string
<?php if (isAdmin()): ?><th>Auteur</th><?php endif; ?>
<th>Catégorie</th>
<th>Statut</th>
<th title="À la une">★</th>
<th>
<a href="<?= htmlspecialchars($_mkSortUrl('published')) ?>"
class="text-decoration-none text-reset">
@@ -330,18 +351,53 @@ function adminStatusBadge(array $a, int $now): string
<?php endif; ?>
<td class="text-muted small"><?= htmlspecialchars($a['category'] ?? '') ?></td>
<td><?= adminStatusBadge($a, $now) ?></td>
<td class="text-center">
<?php if (isAdmin()): ?>
<?php $_isFeatured = !empty($a['featured']); ?>
<button type="submit" form="toggle-featured-<?= htmlspecialchars($a['uuid']) ?>"
class="btn btn-link p-0 border-0 lh-1 fs-6"
title="<?= $_isFeatured ? 'Retirer de la une' : 'Mettre à la une' ?>">
<?= $_isFeatured ? '★' : '<span class="text-muted">☆</span>' ?>
</button>
<?php else: ?>
<?= !empty($a['featured']) ? '★' : '' ?>
<?php endif; ?>
</td>
<td class="text-muted small text-nowrap">
<?= htmlspecialchars(date('d/m/Y', strtotime((string)($a['published_at'] ?? $a['created_at'] ?? '')))) ?>
</td>
<td class="text-end text-nowrap">
<a href="/edit/<?= htmlspecialchars($a['uuid']) ?>"
class="btn btn-outline-secondary btn-sm">Modifier</a>
<button type="submit" form="dup-<?= htmlspecialchars($a['uuid']) ?>"
class="btn btn-outline-secondary btn-sm ms-1"
title="Dupliquer en brouillon">⧉</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</form>
<?php
/* Formulaires hors bulk-form (nested forms invalides en HTML) */
$_backUrl = '/admin/articles?' . http_build_query(array_filter([
'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'] ?? '',
'sort' => $_sortBy,
'dir' => $_sortDir,
], fn ($v) => $v !== ''));
foreach ($adminData['articles'] as $_fa):
?>
<form id="toggle-featured-<?= htmlspecialchars($_fa['uuid']) ?>" method="post" action="/?action=admin_toggle_featured" hidden>
<input type="hidden" name="uuid" value="<?= htmlspecialchars($_fa['uuid']) ?>">
<input type="hidden" name="_back" value="<?= htmlspecialchars($_backUrl) ?>">
</form>
<form id="dup-<?= htmlspecialchars($_fa['uuid']) ?>" method="post" action="/duplicate/<?= htmlspecialchars($_fa['uuid']) ?>" hidden>
</form>
<?php endforeach; ?>
<script src="/assets/js/admin.js" defer></script>
<?php endif; ?>
@@ -976,17 +1032,13 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<td class="small"><?= htmlspecialchars((string)$em['subject']) ?></td>
<td><?= $emBadge ?></td>
<td>
<details>
<summary class="btn btn-outline-secondary btn-sm" style="display:inline;cursor:pointer">Voir</summary>
<div class="mt-2 p-2 border rounded bg-light" style="max-width:600px">
<?php if (!empty($em['error_message'])): ?>
<p class="text-danger small mb-2"><strong>Erreur :</strong> <?= htmlspecialchars((string)$em['error_message']) ?></p>
<?php endif; ?>
<?php if (!empty($em['content_text'])): ?>
<pre class="mb-0 small" style="white-space:pre-wrap;font-size:0.75rem"><?= htmlspecialchars((string)$em['content_text']) ?></pre>
<?php endif; ?>
</div>
</details>
<?php if (!empty($em['content_html']) || !empty($em['content_text'])): ?>
<a href="/admin/email-preview/<?= (int)$em['id'] ?>" target="_blank" rel="noopener"
class="btn btn-outline-secondary btn-sm">Voir ↗</a>
<?php endif; ?>
<?php if (!empty($em['error_message'])): ?>
<span class="text-danger small d-block mt-1" title="<?= htmlspecialchars((string)$em['error_message']) ?>">⚠ Erreur</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
@@ -1178,7 +1230,15 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<h5 class="mb-0">Termes recherchés
<span class="badge bg-secondary ms-1"><?= count($adminData['search_terms'] ?? []) ?></span>
</h5>
<span class="text-muted small">Derniers 14 jours de logs · cache 10 min</span>
<div class="d-flex align-items-center gap-3">
<span class="text-muted small">Derniers <?= (int)($adminData['search_days'] ?? 14) ?> jours · cache 10 min</span>
<div class="btn-group btn-group-sm" role="group">
<a href="/admin/searches?days=7"
class="btn <?= ($adminData['search_days'] ?? 14) === 7 ? 'btn-primary' : 'btn-outline-secondary' ?>">7 j</a>
<a href="/admin/searches"
class="btn <?= ($adminData['search_days'] ?? 14) === 14 ? 'btn-primary' : 'btn-outline-secondary' ?>">14 j</a>
</div>
</div>
</div>
<?php if (!($adminData['search_log_readable'] ?? false)): ?>
@@ -1199,7 +1259,7 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<tr>
<th style="width:3rem">#</th>
<th>Terme recherché</th>
<th style="width:6rem" class="text-end">Fois</th>
<th style="width:6rem" class="text-end">Visiteurs</th>
<th style="width:12rem"></th>
</tr>
</thead>
@@ -1230,6 +1290,57 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<?php endif; ?>
<!-- ─────────────────────────── FLUX RSS ─────────────────────────── -->
<?php if ($tab === 'flux' && isAdmin()): ?>
<?php if (($_GET['deleted'] ?? '') === '1'): ?>
<div class="alert alert-success py-2 small">Flux supprimé.</div>
<?php endif; ?>
<h5>Flux RSS agrégés</h5>
<p class="text-muted small">Tous les flux enregistrés par les utilisateurs. Seul un administrateur peut les supprimer.</p>
<?php if (empty($adminData['flux_feeds'] ?? [])): ?>
<p class="text-muted">Aucun flux enregistré.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Utilisateur</th>
<th>Libellé</th>
<th>URL</th>
<th>Ajouté le</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($adminData['flux_feeds'] as $_feed): ?>
<tr>
<td class="small"><?= htmlspecialchars($_feed['user_email'] ?? '') ?></td>
<td class="small"><?= htmlspecialchars($_feed['label'] ?? '') ?></td>
<td class="small text-truncate" style="max-width:260px">
<a href="<?= htmlspecialchars($_feed['feed_url'] ?? '') ?>" target="_blank" rel="noopener" class="text-muted">
<?= htmlspecialchars($_feed['feed_url'] ?? '') ?>
</a>
</td>
<td class="small text-nowrap"><?= htmlspecialchars(substr($_feed['created_at'] ?? '', 0, 10)) ?></td>
<td>
<form method="POST" action="/?action=admin_delete_feed"
data-confirm="Supprimer ce flux ?">
<input type="hidden" name="id" value="<?= (int)$_feed['id'] ?>">
<button type="submit" class="btn btn-outline-danger btn-sm py-0">Supprimer</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php endif; ?>
<!-- ─────────────────────────── LIVRES ─────────────────────────── -->
<?php if ($tab === 'books' && isAdmin()): ?>
@@ -1305,6 +1416,8 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
</div>
<div class="mb-3">
<label class="form-label small fw-medium">Ajouter une page existante</label>
<input type="text" id="book-article-filter" class="form-control form-control-sm mb-1"
placeholder="Filtrer les articles…" autocomplete="off">
<select class="form-select" id="book-article-select">
<option value="">— Choisir un article —</option>
<?php
@@ -1340,15 +1453,16 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<?php elseif (isset($_GET['new'])): ?>
<h5>Nouveau livre</h5>
<form method="POST" action="/?action=book_save">
<div class="mb-3">
<label class="form-label small fw-medium">Slug (identifiant URL)</label>
<input type="text" name="slug" class="form-control" required
placeholder="mon-livre" pattern="[a-z0-9][a-z0-9-]*">
<div class="form-text">Minuscules, chiffres, tirets. Exemple : <code>esp8266</code></div>
</div>
<input type="hidden" name="slug" id="new-book-slug-hidden">
<div class="mb-3">
<label class="form-label small fw-medium">Titre</label>
<input type="text" name="title" class="form-control" required placeholder="Titre du livre">
<input type="text" name="title" id="new-book-title" class="form-control" required placeholder="Titre du livre">
</div>
<div class="mb-2">
<label class="form-label small fw-medium">Slug (généré automatiquement)</label>
<input type="text" id="new-book-slug-preview" class="form-control form-control-sm bg-light font-monospace"
readonly placeholder="slug-du-livre">
<div class="form-text">Minuscules, chiffres, tirets — basé sur le titre.</div>
</div>
<div class="mb-3">
<label class="form-label small fw-medium">Description (optionnelle)</label>
@@ -1376,6 +1490,127 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<?php endif; ?>
<?php if ($tab === 'ia' && isAdmin()): ?>
<?php
$_aiNotice = $adminData['ai_notice'] ?? '';
$_aiProvider = $adminData['ai_provider'] ?? 'anthropic';
$_aiModel = $adminData['ai_model'] ?? '';
$_anthropicOk = $adminData['anthropic_key_set'] ?? false;
$_cliOk = $adminData['claude_cli_found'] ?? false;
?>
<?php if ($_aiNotice === 'saved'): ?>
<div class="alert alert-success py-2 small">Configuration IA enregistrée.</div>
<?php elseif ($_aiNotice === 'error'): ?>
<div class="alert alert-danger py-2 small">Erreur lors de l'enregistrement.</div>
<?php endif; ?>
<h5 class="mb-3">Intelligence artificielle</h5>
<!-- Section 1 — Statut -->
<div class="card mb-4">
<div class="card-header fw-semibold small">Statut</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr>
<th class="ps-3" scope="row">Clé Anthropic (<code>ANTHROPIC_API_KEY</code>)</th>
<td><?= $_anthropicOk ? '<span class="text-success">✓ Configurée</span>' : '<span class="text-danger">✗ Absente</span>' ?></td>
</tr>
<tr>
<th class="ps-3" scope="row">Claude Code CLI (<code>/usr/local/bin/claude</code>)</th>
<td><?= $_cliOk ? '<span class="text-success">✓ Trouvé</span>' : '<span class="text-danger">✗ Introuvable</span>' ?></td>
</tr>
<tr>
<th class="ps-3" scope="row">Provider actif</th>
<td><code><?= htmlspecialchars($_aiProvider) ?></code></td>
</tr>
<tr>
<th class="ps-3" scope="row">Modèle actif</th>
<td><code><?= htmlspecialchars($_aiModel ?: 'claude-haiku-4-5-20251001 (défaut)') ?></code></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Section 2 — Configuration -->
<div class="card mb-4">
<div class="card-header fw-semibold small">Configuration</div>
<div class="card-body">
<form method="POST" action="/?action=admin_save_ai_config">
<div class="mb-3">
<label class="form-label fw-semibold small">Provider</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="ai_provider" id="ai_provider_anthropic"
value="anthropic" <?= $_aiProvider === 'anthropic' ? 'checked' : '' ?>>
<label class="form-check-label" for="ai_provider_anthropic">Anthropic (API)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="ai_provider" id="ai_provider_claude_code"
value="claude_code" <?= $_aiProvider === 'claude_code' ? 'checked' : '' ?>>
<label class="form-check-label" for="ai_provider_claude_code">Claude Code CLI</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="ai_model" class="form-label fw-semibold small">Modèle Anthropic</label>
<input type="text" class="form-control form-control-sm font-monospace" id="ai_model" name="ai_model"
value="<?= htmlspecialchars($_aiModel) ?>"
placeholder="claude-haiku-4-5-20251001">
<div class="form-text">Laisser vide pour utiliser le défaut (<code>claude-haiku-4-5-20251001</code>). Ignoré si le provider est Claude Code CLI.</div>
</div>
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
</form>
</div>
</div>
<!-- Section 3 — Clé Anthropic -->
<div class="card mb-4">
<div class="card-header fw-semibold small">Clé API Anthropic</div>
<div class="card-body">
<div class="alert alert-warning py-2 small mb-0">
<strong>La clé API Anthropic ne peut pas être saisie ici.</strong><br>
Elle doit être définie dans le fichier <code>.env</code> du serveur :
<pre class="mt-2 mb-0 small"><code>ANTHROPIC_API_KEY=sk-ant-...</code></pre>
<div class="mt-2">Statut actuel : <?= $_anthropicOk ? '<span class="text-success">✓ Configurée</span>' : '<span class="text-danger">✗ Absente</span>' ?></div>
</div>
</div>
</div>
<!-- Section 4 — Procédure Claude Code CLI -->
<div class="card mb-4">
<div class="card-header fw-semibold small">Procédure d'installation de Claude Code CLI</div>
<div class="card-body">
<?php if ($_cliOk): ?>
<div class="alert alert-success py-2 small mb-3">✓ <code>/usr/local/bin/claude</code> détecté.</div>
<?php else: ?>
<div class="alert alert-secondary py-2 small mb-3">✗ <code>/usr/local/bin/claude</code> introuvable — suivez les étapes ci-dessous.</div>
<?php endif; ?>
<p class="small text-muted">À exécuter en SSH sur le serveur (en root ou via sudo) :</p>
<pre class="bg-dark text-light p-3 rounded small"><code># 1. Installer Claude Code CLI (en root)
sudo npm install -g @anthropic-ai/claude-code
# Vérifier l'installation
/usr/local/bin/claude --version
# 2. Créer le répertoire HOME de www-data pour Claude
sudo mkdir -p /var/lib/claude-www
sudo chown www-data:www-data /var/lib/claude-www
# 3. Authentifier Claude en tant que www-data
sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude auth login
# → Suivre les instructions (OAuth navigateur ou clé API)
# 4. Vérifier que ça fonctionne
sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude --print "Réponds juste OK"</code></pre>
</div>
</div>
<?php endif; ?>
<?php if ($tab === 'stats' && isAdmin()): ?>
<?php include __DIR__ . '/admin_stats.php'; ?>
+1 -4
View File
@@ -1,6 +1,4 @@
<?php
require_once BASE_PATH . '/src/Parsedown.php';
$Parsedown = new Parsedown();
$_apName = $authorRow['display_name'] ?? '';
$_apSlug = $authorRow['profile_slug'] ?? '';
@@ -19,8 +17,7 @@ ob_start();
<?php else: ?>
<div class="post-grid">
<?php foreach ($posts as $post):
$html = $Parsedown->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']);
+1 -4
View File
@@ -1,6 +1,4 @@
<?php
require_once BASE_PATH . '/src/Parsedown.php';
$Parsedown = new Parsedown();
ob_start();
@@ -36,8 +34,7 @@ $_initials = mb_strtoupper(mb_substr($_apName, 0, 1, 'UTF-8'), 'UTF-8');
<?php else: ?>
<div class="post-grid">
<?php foreach (array_slice($authorArticles, 0, 6) as $post):
$html = $Parsedown->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']);
+1
View File
@@ -142,3 +142,4 @@ setcookie('_csrf_c', $_csrfToken, [
</div>
</div>
<script src="/assets/js/comments.js" defer></script>
+2
View File
@@ -97,8 +97,10 @@ $_typeLabel = $isCatField ? 'Catégorie' : ($tagTypes[$tagType] ?? ucfirst($tag
<?php renderTagGroup('Déjà taggués', $_current, true); ?>
<?php renderTagGroup('Valeurs connues dans d\'autres articles', $_known, false); ?>
<?php if (empty($_known)): ?>
<?php renderTagGroup('Abréviations détectées', $_abbrevs, false, true); ?>
<?php renderTagGroup('Noms composés détectés', $_camel + $_proper, false, true); ?>
<?php endif; ?>
<?php if (empty($suggestions)): ?>
<p class="text-muted">Aucun terme détecté dans cet article.</p>
+15
View File
@@ -4,6 +4,21 @@
<h1 class="h4 mb-0">Flux agrégés</h1>
</div>
<?php if (!empty($fluxErrors) && function_exists('isAdmin') && isAdmin()): ?>
<div class="alert alert-warning py-2 mb-4">
<strong><?= count($fluxErrors) ?> flux en erreur</strong>
<ul class="mb-0 mt-1 small">
<?php foreach ($fluxErrors as $_err): ?>
<li>
<?= htmlspecialchars($_err['label'] !== '' ? $_err['label'] : $_err['feed_url']) ?>
— <code><?= htmlspecialchars($_err['feed_url']) ?></code>
<span class="text-muted">(<?= htmlspecialchars($_err['user_email']) ?>)</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if (empty($fluxItems)): ?>
<p class="text-muted">Aucun article disponible pour l'instant.</p>
<?php else: ?>
+19 -4
View File
@@ -46,11 +46,20 @@
<!-- CSS -->
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/style.css">
<?php
$_pub = BASE_PATH . '/public/assets/';
if (!function_exists('_av')) {
function _av(string $base, string $rel): string {
$f = $base . $rel;
return '/assets/' . $rel . (is_file($f) ? '?v=' . substr(md5_file($f), 0, 8) : '');
}
}
?>
<link rel="stylesheet" href="<?= _av($_pub, 'css/style.css') ?>">
</head>
<body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>>
<script src="/assets/js/density-fouc.js"></script>
<script src="<?= _av($_pub, 'js/density-fouc.js') ?>"></script>
<header>
<nav class="navbar navbar-expand-lg navbar-dark mb-0" role="navigation" aria-label="Navigation principale">
@@ -150,9 +159,15 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? '');
<!-- JS -->
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
<script src="<?= _av($_pub, 'js/app.js') ?>"></script>
<?php if (isset($reactionStats)): ?>
<script src="/assets/js/reactions.js"></script>
<script src="<?= _av($_pub, 'js/reactions.js') ?>"></script>
<?php endif; ?>
<?php if (!empty($shareBar ?? false)): ?>
<script src="<?= _av($_pub, 'js/share.js') ?>" defer></script>
<?php endif; ?>
<?php if (!empty($aiEditor ?? false)): ?>
<script src="<?= _av($_pub, 'js/ai-editor.js') ?>"></script>
<?php endif; ?>
</body>
+1 -2
View File
@@ -115,8 +115,7 @@ $slugOriginal = $postSlug;
</label>
<input type="text" class="form-control form-control-sm font-monospace" id="confirm-slug" name="slug"
value="<?= htmlspecialchars($slugDefault) ?>"
pattern="[a-z0-9][a-z0-9\-]*"
oninput="document.getElementById('slug-display').textContent=this.value">
pattern="[a-z0-9][a-z0-9\-]*">
<?php if ($titleChanged && $autoSlug !== $slugOriginal): ?>
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
<small class="text-muted">Slug recalculé depuis le nouveau titre. Slug initial :</small>
+33
View File
@@ -9,6 +9,7 @@ $dateValue = isset($published_at)
?>
<?php if ($action === 'edit'): ?>
<?php $aiEditor = true; ?>
<div id="vl-page"
data-uuid="<?= htmlspecialchars($uuid) ?>"
data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>"
@@ -221,6 +222,38 @@ $dateValue = isset($published_at)
<hr class="my-3">
<div class="mb-3">
<p class="fw-semibold small mb-2">IA</p>
<div class="d-flex flex-column gap-2">
<button type="button" id="btn-ai-critique"
class="btn btn-outline-secondary btn-sm">
Analyse critique
</button>
<button type="button" id="btn-ai-rewrite"
class="btn btn-outline-secondary btn-sm">
Réécrire l'article
</button>
</div>
<div id="ai-result-panel" class="mt-3" style="display:none">
<div class="d-flex align-items-center justify-content-between mb-1">
<span id="ai-result-label" class="fw-semibold small"></span>
<button type="button" id="btn-ai-close" class="btn-close btn-sm"
aria-label="Fermer"></button>
</div>
<div id="ai-result-content"
class="border rounded p-2 small"
style="max-height:400px;overflow-y:auto;white-space:pre-wrap;font-family:inherit;background:#f8f9fa">
</div>
<button type="button" id="btn-ai-apply"
class="btn btn-warning btn-sm mt-2"
style="display:none">
Appliquer dans l'éditeur
</button>
</div>
</div>
<hr class="my-3">
<?php if (!empty($existingFiles)): ?>
<?php $coverFile = $article['cover'] ?? ''; ?>
<?php $filesMeta = $article['files_meta'] ?? []; ?>
+7 -1
View File
@@ -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
+139 -26
View File
@@ -9,32 +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)
);
// 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('/<img\b([^>]*)>/i', '<img$1 loading="lazy">', $_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();
@@ -95,6 +125,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) . '"';
@@ -136,6 +174,9 @@ $hasSources = (!empty($externalLinks) || !empty($files))
<span class="mx-1 opacity-50">·</span>
<?php endif; ?>
<?= $pubDate ?>
<?php if ($modDate !== ''): ?>
<br><small class="opacity-75"><?= htmlspecialchars($modDate) ?></small>
<?php endif; ?>
</p>
</div>
<div class="article-hero-right">
@@ -220,6 +261,59 @@ $hasSources = (!empty($externalLinks) || !empty($files))
</div>
</div>
<?php if (($ratingStats['count'] ?? 0) > 0 || isLoggedIn()): ?>
<div class="d-flex align-items-center flex-wrap gap-3 my-3 py-2 border-top small">
<span class="text-muted">Note :</span>
<?php if (($ratingStats['avg'] ?? null) !== null): ?>
<span>
<strong><?= number_format((float)$ratingStats['avg'], 1) ?></strong>/5
<span class="text-muted">(<?= (int)$ratingStats['count'] ?> vote<?= (int)$ratingStats['count'] > 1 ? 's' : '' ?>)</span>
</span>
<?php else: ?>
<span class="text-muted">Pas encore noté</span>
<?php endif; ?>
<?php if (isLoggedIn()): ?>
<form method="POST" action="/?action=rate" class="d-flex align-items-center gap-1 mb-0">
<input type="hidden" name="uuid" value="<?= htmlspecialchars($article['uuid']) ?>">
<?php for ($_s = 1; $_s <= 5; $_s++): ?>
<button type="submit" name="rating" value="<?= $_s ?>"
class="btn btn-link p-0 lh-1 text-decoration-none<?= (($userRating ?? 0) >= $_s) ? ' text-warning' : ' text-muted' ?>"
title="<?= $_s ?> étoile<?= $_s > 1 ? 's' : '' ?>">
<?= (($userRating ?? 0) >= $_s) ? '★' : '☆' ?>
</button>
<?php endfor; ?>
<?php if (($userRating ?? null) !== null): ?>
<span class="text-muted ms-1">(votre note)</span>
<?php endif; ?>
</form>
<?php else: ?>
<span class="text-muted fst-italic">Connectez-vous pour noter</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($article['published'] ?? false): ?>
<?php
$_shareUrl = rtrim(defined('APP_URL') ? APP_URL : '', '/') . '/post/' . rawurlencode($article['slug'] ?? '');
$_shareTitle = $article['title'] ?? '';
?>
<div class="d-flex flex-wrap align-items-center gap-2 my-3 py-2 border-top"
id="share-bar"
data-url="<?= htmlspecialchars($_shareUrl) ?>"
data-title="<?= htmlspecialchars($_shareTitle) ?>">
<span class="text-muted small me-1">Partager :</span>
<a href="mailto:?subject=<?= rawurlencode($_shareTitle) ?>&amp;body=<?= rawurlencode($_shareUrl) ?>"
class="btn btn-outline-secondary btn-sm" title="Par e-mail">✉ Mail</a>
<a href="https://x.com/intent/tweet?text=<?= rawurlencode($_shareTitle) ?>&amp;url=<?= rawurlencode($_shareUrl) ?>"
class="btn btn-outline-secondary btn-sm" target="_blank" rel="noopener noreferrer" title="X / Twitter">X</a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url=<?= rawurlencode($_shareUrl) ?>"
class="btn btn-outline-secondary btn-sm" target="_blank" rel="noopener noreferrer" title="LinkedIn">in</a>
<a href="https://mastodon.social/share?text=<?= rawurlencode($_shareTitle . ' ' . $_shareUrl) ?>"
class="btn btn-outline-secondary btn-sm" target="_blank" rel="noopener noreferrer" title="Mastodon">🐘</a>
<button type="button" class="btn btn-outline-secondary btn-sm" id="share-copy">Copier le lien</button>
<button type="button" class="btn btn-outline-primary btn-sm" id="share-native" hidden>⬆ Partager</button>
</div>
<?php endif; ?>
<?php include __DIR__ . '/comments_section.php'; ?>
@@ -346,6 +440,24 @@ $hasSources = (!empty($externalLinks) || !empty($files))
</div>
<?php endif; ?>
<?php
$_revisions = array_reverse($article['revisions'] ?? []);
if (!empty($_revisions) && isLoggedIn()):
?>
<h6 class="related-sidebar-title mt-3">Historique</h6>
<ul class="toc-list small">
<?php foreach (array_slice($_revisions, 0, 10) as $_rev): ?>
<li>
<a href="/diff/<?= rawurlencode($article['uuid']) ?>/<?= (int)$_rev['n'] ?>">
<?= htmlspecialchars(substr($_rev['date'] ?? '', 0, 10)) ?>
<?php if (($_rev['comment'] ?? '') !== ''): ?>
— <span class="text-muted"><?= htmlspecialchars(mb_strimwidth($_rev['comment'], 0, 40, '…')) ?></span>
<?php endif; ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</aside>
</div>
@@ -356,6 +468,7 @@ $hasSources = (!empty($externalLinks) || !empty($files))
<?php
$content = ob_get_clean();
$shareBar = (bool)($article['published'] ?? false);
$title = htmlspecialchars($article['title']);
$seoTitle = ($article['seo_title'] ?? '') ?: $article['title'];
$ogType = 'article';
+1 -1
View File
@@ -38,7 +38,7 @@ function renderMetaCell(string $key, mixed $val, array $row = []): string
?>
<div class="d-flex align-items-center gap-3 mb-1">
<a href="/edit/<?= rawurlencode($article['uuid']) ?>" class="btn btn-secondary btn-sm">← Modifier</a>
<a href="/post/<?= rawurlencode($article['slug'] ?? $article['uuid']) ?>" class="btn btn-secondary btn-sm">← Retour à l'article</a>
<h1 class="h4 mb-0">Sources &amp; médias</h1>
</div>
<p class="text-muted small mb-4"><?= htmlspecialchars($article['title']) ?></p>
+33 -3
View File
@@ -49,15 +49,44 @@ $_hasUuid = $_wizUuid !== '';
</div><!-- /col-lg-9 -->
<!-- Plan (TOC dynamique) ───────────────────────────────────────────────── -->
<!-- Sidebar droite : TOC + IA ──────────────────────────────────────────── -->
<div class="col-lg-3 d-none d-lg-block">
<div class="position-sticky" style="top:1rem">
<div class="card border-secondary-subtle">
<div class="card border-secondary-subtle mb-3">
<div class="card-header bg-transparent py-2 small fw-semibold text-muted">Plan</div>
<div class="card-body p-2" style="max-height:80vh;overflow-y:auto">
<div class="card-body p-2" style="max-height:40vh;overflow-y:auto">
<ul id="wz-toc-list" class="list-unstyled mb-0"></ul>
</div>
</div>
<?php if ($mode === 'edit'): ?>
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent py-2 small fw-semibold text-muted">IA</div>
<div class="card-body p-2">
<button type="button" id="btn-ai-analyze" class="btn btn-outline-secondary btn-sm w-100">
Analyser et proposer
</button>
<div id="ai-result-panel" class="mt-2" style="display:none">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-semibold small">Ce qui n'allait pas</span>
<button type="button" id="btn-ai-close" class="btn-close btn-sm" aria-label="Fermer"></button>
</div>
<div id="ai-critique-content"
class="border rounded p-2 small mb-2"
style="max-height:220px;overflow-y:auto;white-space:pre-wrap;font-family:inherit;background:#f8f9fa">
</div>
<div class="fw-semibold small mb-1">Proposition</div>
<div id="ai-rewrite-content"
class="border rounded p-2 small"
style="max-height:220px;overflow-y:auto;white-space:pre-wrap;font-family:inherit;background:#f8f9fa">
</div>
<button type="button" id="btn-ai-apply" class="btn btn-warning btn-sm mt-2 w-100" style="display:none">
Appliquer la proposition
</button>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div><!-- /row -->
@@ -172,4 +201,5 @@ $_hasUuid = $_wizUuid !== '';
<?php
$content = ob_get_clean();
$title = ($mode === 'create' ? 'Nouvel article' : 'Modifier') . ' — Étape 1/' . $totalSteps;
if ($mode === 'edit') { $aiEditor = true; }
include BASE_PATH . '/templates/layout.php';
+3 -1
View File
@@ -26,7 +26,9 @@ $_formAction = '/edit/' . rawurlencode($uuid) . '/6';
<div class="d-flex gap-2 flex-wrap align-items-center">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="if(confirm('Abandonner les modifications et supprimer ce brouillon ?')) window.location='/edit/<?= rawurlencode($uuid) ?>/discard'">
data-confirm-discard
data-discard-url="/edit/<?= rawurlencode($uuid) ?>/discard"
data-confirm-msg="Abandonner les modifications et supprimer ce brouillon ?">
Abandonner
</button>
<button type="submit" class="btn btn-success">✓ Confirmer et enregistrer</button>