120 Commits

Author SHA1 Message Date
cedricAbonnel 29cb9d7723 fix: stats visiteurs — fond semi-transparent + blur sur hero
Ajout de background:rgba(0,0,0,.45) + backdrop-filter:blur(4px)
pour garantir la lisibilité sur image blanche comme sur image noire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:00:19 +02:00
cedricAbonnel 4b44486abb fix: stats visiteurs hero — blanc explicite + text-shadow pour visibilité
Remplace opacity par color:rgba(255,255,255,.95) et text-shadow
pour que les chiffres soient lisibles sur toute image de couverture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:56:59 +02:00
cedricAbonnel 9eab9ba7c3 feat: affichage clair des visiteurs 7/14/30 jours pour tous
Remplace l'affichage "X lecteurs · 30 j (Y/14 j · Z/7 j)" par
"X / 7 j · Y / 14 j · Z / 30 j lecteurs" — trois valeurs explicites
visibles par tous les visiteurs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:54:17 +02:00
cedricAbonnel e12bbe1ef9 fix: ipData non défini dans la Pages IIFE cause ReferenceError
La Pages IIFE n'avait pas accès à ipData (défini seulement dans la Country IIFE).
Le ReferenceError faisait tomber le fetch dans le catch → 'Impossible de charger le flux'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:49:55 +02:00
cedricAbonnel ebef8c225e fix: conflit de variable $ips dans stats() écrase le top IPs
Le foreach artIp30 utilisait $ips comme variable de boucle, écrasant
le tableau de comptage des requêtes par IP. Résultat : ips=['66.249…':true]
au lieu des vrais top 200 IPs. Renommé en $_artIpSet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:45:45 +02:00
cedricAbonnel fce4ae6a79 fix: visitors.json clés perdues, bouton AS inaccessible, graphique visiteurs
- Fix array_merge → + pour préserver clés 7/14/30 dans visitors.json
- Bouton ✕ exclusion AS sorti du div 9rem + stopPropagation
- Handler délégué unique (removeEventListener avant de rajouter)
- Graphique trend : visiteurs uniques/jour depuis ip_data (top 200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:39:21 +02:00
cedricAbonnel dbbe60f28e feat: visiteurs uniques par article (7/14/30 j) stockés dans visitors.json
- AccessLogParser : suivi des IPs non-bot uniques par /post/ sur 3 fenêtres (7/14/30 j)
- index.php : écriture de data/UUID/visitors.json à chaque recalcul des stats admin
- post_view.php : affichage du compteur de lecteurs dans la zone hero

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:27:10 +02:00
cedricAbonnel 1e41ef207e v1.6.33 : exclusion AS, compteurs 7/14/30j, 👍 uniquement
- Carte visiteurs uniques non-bot : 7 / 14 / 30 jours en tête de /admin/stats
- Bouton ✕ par AS pour l'exclure des stats ; section AS exclus avec ↺
- Alerte IPs sans résolution AS dans la carte pays
- Parser : fenêtre 30 jours, calcul visiteurs uniques toutes IPs non-bot
- Graphiques adaptés à 30 jours (labels x/3)
- Réactions articles : 👍 uniquement (suppression 🔥 et 🤔)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:14:40 +02:00
cedricAbonnel b0f4814bb0 v1.6.32 : UA en entier + bouton « + bot » + filtrage bots des stats
- Agents détectés : UA affiché sans troncature (drill-down et liste)
- Bouton « + bot » pour ajouter un agent aux patterns via AJAX (CSRF)
- Section Agents alimentée par all_uas (tous UAs publics, bots inclus)
- AccessLogParser : bots exclus des compteurs pages/livres/visiteurs
- Caches stats vidés après chaque modification des patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:45:10 +02:00
cedricAbonnel d53b5da31a v1.6.31 : analyse complète des logs + détection bots
- AccessLogParser : tous chemins/statuts pour IPs publiques (ipAllPaths, ipAllDays, ipAgents)
- Détection bots par patterns (data/bots.json, ~50 patterns initiaux)
- Section « Agents détectés » en bas de page admin/stats avec badge 🤖
- Panneau d'édition des patterns bots (formulaire avec CSRF)
- Drill-down IP : section « Autres chemins » (hors articles/livres)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:33:47 +02:00
cedricAbonnel 68a44d19d1 v1.6.30 : agents utilisateur dans le drill-down IP
- Drill-down IP : user agents affichés sous l'adresse IP, top 5 par fréquence
- AccessLogParser : regex COMBINED étendue pour capturer le UA (groupe 5)
- Tracking ipAgents [ip => [ua => count]], ip_agents dans le résultat de stats()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:16:20 +02:00
cedricAbonnel e3d7e433e0 v1.6.29 : chemins IP triés par date, un par ligne avec compteur
- Drill-down IP : articles/livres affichés un par ligne (compteur entre parenthèses), triés par date de dernier accès desc
- AccessLogParser : ipPathTs trace le dernier timestamp par chemin/IP
- ip_top_paths : structure {n, ts} au lieu de count simple

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:09:44 +02:00
cedricAbonnel 40656631ba v1.6.28 : drill-down IP par AS dans stats pays, suppression Répartition par réseau
- Admin stats : clic sur un réseau AS affiche les IPs avec mini sparkline 14 jours + articles/livres consultés
- AccessLogParser : calcul ip_data (daily + top paths) inclus dans le cache stats
- Suppression du tableau statique "Répartition par réseau" (fusionné dans accordéon pays)
- PHP-CS-Fixer appliqué sur l'ensemble des fichiers modifiés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:59:44 +02:00
cedricAbonnel d6a7033e9e feat : graphique visiteurs par pays + réseaux détail + suppression groupes AS 2026-05-19 19:44:27 +02:00
cedricAbonnel be8a95ac4f feat : graphique x3 hauteur + multi-lignes par article 2026-05-19 19:40:27 +02:00
cedricAbonnel af169bccc9 feat : graphique trafic area chart lisse avec axes (style analytics) 2026-05-19 19:37:52 +02:00
cedricAbonnel ddc7607972 merge: résolution conflits dev→main 2026-05-19 19:34:27 +02:00
cedricAbonnel d729e943a3 feat : graphique trafic global 14j (v1.6.27) 2026-05-19 19:33:46 +02:00
cedricAbonnel a578604ec3 chore : version 1.6.27 2026-05-19 18:50:53 +02:00
cedricAbonnel e8b361e720 feat : sparklines 14j stats + filtre IPs LAN (v1.6.27)
- Admin stats : sparklines SVG par page (120×28 px, courbe + dégradé),
  carte « Pages les plus visitées » en pleine largeur
- AccessLogParser : données par jour (pages_by_day) sur 14 jours
- AccessLogParser : IPs privées/LAN exclues de la répartition réseau
- ArticleManager : suppression opérateur nullsafe superflu (PHPStan)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:50:53 +02:00
cedricAbonnel 868e68fa85 chore : version 1.6.27 2026-05-19 18:47:21 +02:00
cedricAbonnel ed3f8062da feat : sparklines 14j stats + filtre IPs LAN (v1.6.27)
- Admin stats : sparklines SVG par page (120×28 px, courbe + dégradé),
  carte « Pages les plus visitées » en pleine largeur
- AccessLogParser : données par jour (pages_by_day) sur 14 jours
- AccessLogParser : IPs privées/LAN exclues de la répartition réseau
- ArticleManager : suppression opérateur nullsafe superflu (PHPStan)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:20:19 +02:00
cedricAbonnel 007895d24a Merge pull request 'v1.6.26 — page /books, section livres accueil, fix onglet books' (#102) from dev into main
Merge dev → main (v1.6.26)
2026-05-16 15:04:48 +00:00
cedricAbonnel c2035314fb fix : chargement admin.js manquant dans l'onglet books
Le select « Ajouter une page » ne fonctionnait pas car admin.js
n'était pas chargé dans la section books du template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:01:39 +02:00
cedricAbonnel c140ba4069 feat : page /books et section livres accueil (v1.6.26)
- #99 : page publique /books — catalogue des books avec ≥1 article publié
- #100 : section « Livres » sur la homepage (max 6, après redécouvertes)
- CSS : .book-grid, .book-home-card*, .home-section-more
- .htaccess : règle RewriteRule ^books/?$

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 16:53:23 +02:00
cedricAbonnel 1eb6ca25f9 Merge pull request 'v1.6.25 — intégration IA éditeur, onglet admin IA, corrections CSP' (#98) from dev into main
Merge dev → main (v1.6.25)
2026-05-16 12:07:33 +00:00
cedricAbonnel 84d4b12fb2 docs : mise à jour CHANGELOG v1.6.25 — corrections IA wizard + bouton unique
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 14:06:03 +02:00
cedricAbonnel c979238b0c refactor : IA éditeur — un seul bouton analyse+réécriture combinées
Un seul appel API retourne l'analyse critique ET la proposition d'article
via le séparateur ===CRITIQUE===/===REWRITE===. Le panneau affiche les deux
sections avec un bouton « Appliquer la proposition ».

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:06:23 +02:00
cedricAbonnel e03594c22e fix : boutons IA dans wizard/step1.php (éditeur réel) + adaptation ids textarea
post_form.php n'était jamais inclus — les boutons IA sont ajoutés dans la vraie
page d'édition (wizard/step1.php). ai-editor.js cherche #wz-content en priorité
et extrait le titre depuis la première ligne # du Markdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:01:46 +02:00
cedricAbonnel 298f18dabe feat & fix : intégration IA éditeur + onglet admin IA + corrections CSP (v1.6.24-25)
- #96 : boutons IA sidebar éditeur (analyse critique / réécriture) via Anthropic API
- #97 : onglet admin /admin/ia — provider anthropic/claude_code, modèle, procédure CLI
- #95 : extraction scripts inline vers fichiers JS (comments.js, post_confirm.js, admin.js)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:18:38 +02:00
cedricAbonnel fabe5a9f53 fix : formulaires imbriqués dans bulk-form (toggle à la une + dupliquer)
Les <form> admin_toggle_featured et duplicate étaient imbriqués dans
#bulk-form — HTML invalide, le navigateur soumettait le form parent
(suppression) au lieu du bon. Fix : attribut form="id" HTML5 + forms
cachés placés après le bulk-form. Ajoute note CSP + nested forms dans
consignes.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:30:12 +02:00
cedricAbonnel 430b7ddd6f feat : historique des révisions dans la sidebar article (v1.6.23)
- post_view.php : section Historique dans la sidebar pour les connectés
  liste les 10 dernières révisions avec date + commentaire → lien diff (#82)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:21:54 +02:00
cedricAbonnel e2d218f364 feat : widget notation étoiles + admin onglet flux RSS (v1.6.22)
- post_view.php : widget ★ 1-5 étoiles pour les connectés, moyenne + nb votes pour tous (#13)
- admin : onglet /admin/flux liste tous les flux rss_feeds avec suppression (#87)
- case 'admin_delete_feed' : suppression admin d'un flux sans contrainte email (#87)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:18:55 +02:00
cedricAbonnel ca6cfa4ebf fix & feat : SEO desc, feed cover, livres slug auto + filtre (v1.6.21)
- buildAutoSeoDesc() : entités HTML décodées + titre supprimé en tête (#91)
- post_confirm.js : guard null sur #confirm-slug absent (#91)
- feed.php : <media:thumbnail> avec image de couverture RSS (#90)
- admin livres : slug auto depuis le titre + filtre articles (#89)
- BookManager::sanitizeSlug() passé public

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:05:01 +02:00
cedricAbonnel 3b22be94e8 feat : barre de partage articles + déduplication images uploadées (v1.6.20)
- post_view.php : barre de partage (mail, X, LinkedIn, Mastodon, copier, Web Share) sur articles publiés (#47)
- share.js : logique clipboard + navigator.share sans script tiers, compatible CSP (#47)
- addFile() : hardlink vers fichier identique si même hash16-size.ext dans un autre article (#35)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:56:43 +02:00
cedricAbonnel 5ce91da06a perf & ux : cache getAll, fingerprint assets, Last-Modified, 404 log, row-click bulk (v1.6.19)
- getAll() : cache fichier articles_list.json, invalidé à chaque écriture (#16)
- layout.php : fingerprinting ?v=<hash> sur CSS/JS pour invalidation navigateur (#18)
- case 'view' : Last-Modified + 304 Not Modified pour les articles publiés (#18)
- case 'not_found' : logging JSON des 404 dans _logs/not_found.jsonl (#52)
- case 'view' : echo nu → templates/404.php pour brouillons/privés (#52)
- admin.js : clic sur ligne tableau → toggle bulk-check (#86)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:44:08 +02:00
cedricAbonnel 11399a54a6 feat : magic link confirm, notif auteur, rate-limit IP, duplicate, cache MD, lazy img (v1.6.18)
- magic.php : GET=confirmation page, POST=consommation (protège vs scanners) (#27)
- verify_comment : email de notification à l'auteur de l'article (#44)
- login/index.php : rate limit par IP (MAGIC_MAX_PER_IP_HOUR=10) (#23)
- ArticleManager::duplicate() + route POST /duplicate/{uuid} + bouton ⧉ admin/articles (#7)
- post_view.php : cache JSON du rendu Markdown (invalidé sur mtime index.md) (#17)
- post_view.php : loading="lazy" sur toutes les <img> du contenu (#21)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:30:55 +02:00
cedricAbonnel 51055b7321 feat : RSS content, feed catégorie, cookie commentaires, flux erreurs, email preview (v1.6.17)
- RSS : content:encoded (HTML complet) + fix description via plain (#42)
- RSS : flux filtré par ?category=nom (#43)
- Commentaires : cookie nom/email pour pré-remplir le formulaire (#51)
- flux/ : bandeau admin des feeds en erreur (#45)
- admin/emails : bouton « Voir ↗ » vers /admin/email-preview/{id} en nouvel onglet (#37)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:00:37 +02:00
cedricAbonnel dc4701d667 feat : visiteurs uniques, filtre jours, redirect 404→search, edit_tags (v1.6.16)
- SearchLogParser : visiteurs uniques par terme (IPs distinctes) au lieu de hits bruts (#41)
- SearchLogParser : paramètre $days (7/14), cache distinct par période, filtre logFiles par date (#46)
- admin/searches : boutons 7 j / 14 j, label dynamique, colonne « Visiteurs » (#41, #46)
- URL inconnue / slug absent : redirect 302 /search?q=… au lieu de page 404 (#57)
- edit_tags : masquer abbrev/camel si des valeurs connues existent pour le type (#48)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 09:50:56 +02:00
cedricAbonnel ae4ac11305 feat : recherche titre, toggle à la une, date modif, retour sources (v1.6.15)
- admin/articles : champ filter_search (titre, insensible casse) cumulable avec auteur/catégorie/statut (#85)
- admin/articles : colonne ★ avec toggle rapide featured + filtre filter_featured (#84)
- post/ : date de modification sous la date de publication si modifié après mise en ligne (#81)
- sources/ : bouton ← Retour à l'article vers post/<slug> au lieu de /edit/<uuid> (#83)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 09:40:43 +02:00
cedricAbonnel 347e4be0b7 perf : getAll() sans contenu, search_index + featured, excerpts via plain (v1.6.14)
- loadArticle($dir, false) dans loadAll() — meta.json seulement, pas d'index.md
- loadAll() enrichit les articles avec plain depuis search_index (1 lecture JSON)
- rebuildSearchIndex() lit index.md directement + ajoute featured au schéma
- getSearchIndex() rebuilde automatiquement si featured absent
- post_list, author_articles, author_profile : excerpts via plain, plus de Parsedown
- Ferme #24

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:50:58 +02:00
cedricAbonnel c17cad9c66 nettoyage & typo : dead code, helpers factorisés, guillemets courbes (v1.6.13)
- #19 : suppression AuthService / UserRepository / Domain\User — dead code incompatible session
- #22 : env() et db() centralisés dans src/helpers.php, chargé par config/config.php
- #15 : typographieHtml() appliquée après Parsedown dans post_view.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:36:09 +02:00
cedricAbonnel d329872404 Merge pull request 'v1.6.12 — image de couverture modifiable en édition' (#80) from dev into main
Merge pull request 'v1.6.12 — image de couverture modifiable en édition' (#80) from dev into main
2026-05-15 21:09:45 +00:00
cedricAbonnel 88cc67d945 feat : image de couverture modifiable en mode édition (v1.6.12)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:08:14 +02:00
cedricAbonnel 8a42dfe981 Merge pull request 'v1.6.11 — slug immuable en édition' (#79) from dev into main
Merge pull request 'v1.6.11 — slug immuable en édition' (#79) from dev into main
2026-05-15 20:58:15 +00:00
cedricAbonnel 6092cf940d docs : consignes déploiement abonnel.fr — sudo, www-data, .sessions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:56:34 +02:00
cedricAbonnel 5b16fb465b fix : slug immuable en édition — suppression de la propagation auto (v1.6.11)
En mode édition, le slug ne doit jamais changer. Suppression du
hidden[slug] dans step6.php et du bloc qui le sauvegardait dans
le draft overlay avant commitDraftOverlay().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:42:49 +02:00
cedricAbonnel 5203b2c514 Merge pull request 'v1.6.10 — fix suppression article (permissions répertoire)' (#78) from dev into main
Merge pull request 'v1.6.10 — fix suppression article (permissions répertoire)' (#78) from dev into main
2026-05-15 20:30:49 +00:00
cedricAbonnel 996ab3e508 fix : suppression article — permissions répertoire et gestion d'erreur (v1.6.10)
- mkArticleDir() crée les répertoires avec chmod 0775 explicite (bypass umask)
- delete() retourne bool et détecte l'échec sans reconstruire les index
- removeDir() supprime les warnings PHP (@unlink, @rmdir, @scandir)
- post_view.php affiche un message d'erreur si delete_failed=1
- index.php redirige vers l'article avec ?delete_failed=1 si échec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:27:24 +02:00
cedricAbonnel 8af2c8e20b Merge pull request 'v1.6.9 — tri titre/date dans admin/articles' (#77) from dev into main
Merge PR #77 : v1.6.9
2026-05-15 19:20:19 +00:00
cedricAbonnel 04a7713286 feat : tri par titre et date dans /admin/articles (v1.6.9)
En-têtes "Titre" et "Date" cliquables, indicateur ↑/↓, paramètres sort/dir
préservés lors du filtrage. Tri appliqué après filtres côté PHP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:14:22 +02:00
cedricAbonnel 3ddfc1dcf3 Merge pull request 'v1.6.8 — scripts CSP-conformes, densité L/M/S, RSS XML' (#76) from dev into main
Merge PR #76 : v1.6.8
2026-05-15 19:09:09 +00:00
cedricAbonnel fa00f61ee0 chore : version 1.6.8 — scripts CSP-conformes, densité M par défaut
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:08:23 +02:00
cedricAbonnel 8889110133 fix : densité M par défaut (au lieu de L)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:07:36 +02:00
cedricAbonnel 3e856dc476 fix : externaliser tous les scripts inline (CSP script-src 'self')
Tous les <script> inline et event handlers inline bloqués par la CSP sont
déplacés vers des fichiers JS statiques servis par 'self' :
- density-fouc.js  : anti-FOUC densité (chargé en <head>)
- density.js       : widget L/M/S
- trending-home.js : AJAX "Meilleures audiences" (RSS XML)
- admin-stats.js   : groupes AS + pages trending (RSS XML)
- admin.js         : bookAddArticle + bulk-delete (onclick/onchange → listeners)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:00:26 +02:00
cedricAbonnel 58a110d5b9 fix : densité L/M/S — injection <style> dynamique dans <head>
Remplace body[data-density] + CSS externe par un élément <style id="density-fouc">
injecté dynamiquement dans <head>, insensible aux problèmes de spécificité et de cache CSS.
Remplace aussi closest() par une boucle parentNode et dataset par getAttribute.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:49:08 +02:00
cedricAbonnel 5e88d44129 fix : densité L/M/S — widget fixe haut-droite, CSS !important
- Widget retiré du hero-search, replacé en position:fixed top-right (sous navbar)
- max-width !important pour garantir l'override de Bootstrap sur main.container-fluid
- transition douce sur main, caché en < 576px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:35:59 +02:00
cedricAbonnel a55e22f1f4 feat : sélecteur de densité L/M/S sur la page liste (v1.6.7)
Boutons [L][M][S] dans la barre de recherche hero : pleine largeur (défaut),
980 px centré, 660 px compact. Préférence localStorage. Anti-FOUC inline dans layout.php.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:24:28 +02:00
cedricAbonnel 5cea473d17 feat : "Meilleures audiences" + admin/stats pages via flux RSS XML (v1.6.6)
- post_list.php : section AJAX qui lit /trending?period=1h en XML (DOMParser) — plus de rendu PHP
- admin_stats.php : colonne "Pages les plus visitées" chargée en AJAX depuis /trending?period=14d XML
- index.php/stats : suppression de topGrouped pour /post/ ; seuls /book/ et ASN restent côté serveur

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:08:24 +02:00
cedricAbonnel 1d05138329 docs : deployment.md — bouton Mettre à jour (sudoers) + flux trending
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:27:28 +02:00
cedricAbonnel ee2b8a4ac7 docs : documenter la configuration sudoers pour le bouton Mettre à jour
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:26:16 +02:00
cedricAbonnel 556c2cfea9 Merge pull request 'release 1.6.5 : trending — seul /trending génère le cache' (#75) from dev into main
release 1.6.5 : trending — seul /trending génère le cache
2026-05-15 16:20:16 +00:00
cedricAbonnel e19d20ca17 refactor : trending — seul /trending génère le cache, les consommateurs lisent (v1.6.5)
Page d'accueil et /tendances lisent uniquement le cache trending_{period}.json
produit par le flux RSS /trending?period=…. Aucun parsing de logs en dehors du
flux RSS. Rubrique renommée "Meilleures audiences · 1 heure".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:19:10 +02:00
cedricAbonnel d0b486f11c Merge pull request 'release 1.6.4 : TrendingParser, flux RSS /trending, page /tendances' (#74) from dev into main
release 1.6.4 : TrendingParser, flux RSS /trending, page /tendances
2026-05-15 15:38:28 +00:00
cedricAbonnel 18b7194069 feat : TrendingParser + flux RSS tendances + page publique /tendances (v1.6.4)
- TrendingParser : lit les logs Apache, compte les visiteurs uniques (IPs, HTTP 200),
  supporte plusieurs préfixes (/post/, /book/) et un seul parse via topGrouped()
- /trending?period=… : flux RSS des 50 articles les plus consultés, 10 périodes
  de 10 min à 1 an, cache TTL adaptatif
- /tendances : page publique avec sélecteur de période, top 20 articles,
  tableau des flux RSS et section méthodologie
- /admin/stats : remplace AccessLogParser (hits) par TrendingParser (visiteurs uniques)
- Page d'accueil : rubrique Tendances alimentée par les logs 1h (fallback DB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:38:06 +02:00
cedricAbonnel 21f6e75878 Merge pull request 'release 1.6.3 : UpdateChecker sudo + cache stats 60 s' (#73) from dev into main
release 1.6.3 : UpdateChecker sudo + cache stats 60 s
2026-05-15 14:12:28 +00:00
cedricAbonnel 2a60790006 Merge https://git.abonnel.fr/cedricAbonnel/folio into dev 2026-05-15 16:05:59 +02:00
cedricAbonnel 3647289f86 chore : version 1.6.3 — UpdateChecker sudo + cache stats 60 s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:05:21 +02:00
cedricAbonnel ea950f2c25 perf : cache 60 s pour les stats admin (logs Apache + lookup ASN)
Les données coûteuses (parsing des logs, batchLookup ASN) sont mises en cache
dans DATA_PATH/.stats_cache.json. Le cache expire après 60 secondes via filemtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:04:56 +02:00
cedricAbonnel af0a0bb9d5 feat : UpdateChecker délègue la mise à jour à un script sudo externe
Le bouton "Mettre à jour" appelle désormais `sudo /usr/local/bin/folio-upgrade.sh`
via exec() plutôt que d'exécuter git pull + composer + migrations directement en PHP.
Le script shell (template dans scripts/server/) gère la séquence complète : clone fresh,
permissions www-data, restauration .env, composer install, migrations SQL, .sessions,
safe.directory. Le journal de la dernière mise à jour est conservé dans DATA_PATH/.upgrade-log
et affiché en <details> dans l'admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:46:26 +02:00
cedricAbonnel 797937340a Merge pull request 'release 1.6.2 : gardes session OIDC, règle PHP-FPM www-data' (#72) from dev into main
Reviewed-on: #72
2026-05-15 12:39:41 +00:00
cedricAbonnel d5bba5e6e5 Merge branch 'main' into dev 2026-05-15 12:39:34 +00:00
cedricAbonnel 53dbce5bb0 fix : gardes session OIDC + règle PHP-FPM www-data (v1.6.2)
- oidc/start.php : arrêt immédiat (500) si session_start() échoue, évite
  un flux OIDC condamné à l'échec silencieux (ex. session.save_path absent)
- oidc/callback.php : même garde + error_log sur échec du contrôle de state
  pour faciliter le diagnostic (STATE absent/présent + session_id)
- consignes.md : règle PHP-FPM — toujours user=www-data, pas le compte admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:34:39 +02:00
cedricAbonnel 4e262ddde8 Merge pull request 'release 1.6.1 : fix ordre require login/logout, data/site/ hors git' (#70) from dev into main
Merge PR #70 : release 1.6.1
2026-05-15 10:58:07 +00:00
cedricAbonnel 7737edf402 chore : version 1.6.1 — fix ordre require login/logout, data/site/ hors git
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:57:21 +02:00
cedricAbonnel 6d159e7dda fix : ordre require config→bootstrap dans login et logout, data/site/ hors git
- public/login/index.php, login/magic.php, logout.php : config/config.php
  chargé avant bootstrap.php pour que SESSION_NAME soit défini avant session_start()
- data/site/ retiré du suivi git (.gitignore corrigé) : contenu site-spécifique
  déjà présent dans varlog-data/site/ et fr.abonnel.www-data/site/
- CLAUDE.md : chemins articles corrigés (varlog-data/, fr.abonnel.www-data/)
- consignes.md : ajouté (architecture, workflow, règles, procédures déploiement)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:52:06 +02:00
cedricAbonnel ebf0e2df65 Merge pull request 'release 1.6.0 : bouton Mettre à jour, branche dev, guard git pull' (#69) from dev into main
release 1.6.0
2026-05-15 09:21:51 +00:00
cedricAbonnel 331e9c9ecd chore : version 1.6.0 — bouton Mettre à jour, branche dev, guard git pull
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:16:35 +02:00
cedricAbonnel 0280ef3ca1 docs : architecture articles git (varlog.git + abonnel-www.git), sync bidirectionnelle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:16:35 +02:00
cedricAbonnel eddde2165a fix : run_engine_update vérifie origin == folio_repo_url avant git pull
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:16:35 +02:00
cedricAbonnel 07d004b3f0 feat : bouton unique Mettre à jour (git pull + SQL + contenu), branche dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:16:35 +02:00
cedricAbonnel 5cb0e854fd Merge pull request 'release 1.5.0 : config admin FOLIO_REPO_URL, APP_TIMEZONE, push.sh protégé' (#68) from dev into main
release 1.5.0 : config admin FOLIO_REPO_URL, APP_TIMEZONE, push.sh protégé
2026-05-15 08:07:04 +00:00
cedricAbonnel 8f6c17f0f2 chore : version 1.5.0, push.sh bloque main, CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:55:16 +02:00
cedricAbonnel 5452fb4927 Merge pull request 'feat : FOLIO_REPO_URL et APP_TIMEZONE configurables depuis admin' (#67) from feat/folio-repo-url-admin-config into main
Reviewed-on: #67
2026-05-15 07:52:17 +00:00
cedricAbonnel de8785d088 fix : déplacer config mises à jour Folio dans l'onglet Site
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:48:40 +02:00
cedricAbonnel 4b5943c0a4 feat : FOLIO_REPO_URL et branche configurables depuis l'admin (dashboard)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:48:40 +02:00
cedricAbonnel a552f105cd fix : masquer bouton Vérifier si FOLIO_REPO_URL non configuré, message explicatif
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:48:40 +02:00
cedricAbonnel 16afec3039 fix : APP_TIMEZONE configurable (défaut Europe/Paris), FOLIO_REPO_URL dans .env.example
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:48:40 +02:00
cedricAbonnel 2d2148079d Merge pull request 'docs: ajouter README avec guide d'installation' (#56) from docs/readme-installation into main
docs: ajouter README avec guide d'installation (v1.4.0)

Fixes #55.
2026-05-15 07:44:28 +00:00
cedricAbonnel 3965be6854 docs : mettre à jour README — DATA_PATH, permissions, setup.sh
- Ajouter DATA_PATH dans le tableau des variables .env avec note prod
- Étape 6 : permissions sur DATA_PATH (hors document root) au lieu de data/
- Étape 7 : référencer $DATA_PATH/site_settings.json
- Mentionner scripts/setup.sh
- Structure : retirer data/ (géré par DATA_PATH), ajouter scripts/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:36:37 +02:00
cedricAbonnel e803d2d0a7 fix : DATA_PATH défini dans config/config.php (manquant à l'exécution)
bootstrap.php ne suffisait pas — index.php, feed.php et sitemap.php passent
par config/config.php. DATA_PATH est maintenant défini là, juste après le
chargement du .env. file.php charge désormais config/config.php.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:24:01 +02:00
cedricAbonnel 9069a64a0c Merge pull request 'feat : statistiques admin, livres, setup.sh, permissions rsync (v1.3.0)' (#66) from feat/books into main
feat/books → main
2026-05-15 07:18:49 +00:00
cedricAbonnel 819d6d1b8f chore : ajouter fichiers non versionnés (migrations SQL, 404, PROJET.md)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:18:34 +02:00
cedricAbonnel 16965ee8cb feat : DATA_PATH configurable, DataGit auto-commit, UpdateChecker branche (v1.4.0)
- DATA_PATH : chemin /data hors document root, configurable via .env
  (fallback sur BASE_PATH/data si absent)
- DataGit : auto-commit git sur toutes les écritures articles/livres
  (create, update, delete, meta, tags, fichiers, liens…) sauf autosave
- UpdateChecker : getBranch() / getLastChecked() / clearCache(),
  branche configurable via FOLIO_UPDATE_BRANCH (plus de main hardcodé)
- Admin dashboard : affiche la branche suivie, date du dernier contrôle,
  bouton Vérifier pour forcer le check sans attendre le TTL
- CLAUDE.md : architecture DATA_PATH et flux de déploiement documentés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:17:55 +02:00
cedricAbonnel 55a2120be1 chore : CHANGELOG + bump version 1.3.0 (statistiques, permissions, setup.sh)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:53:33 +02:00
cedricAbonnel 8be56bc27f Merge pull request 'feat #64 : onglet Statistiques — pages, livres, répartition AS' (#65) from feat/books into main
Reviewed-on: #65
2026-05-14 22:50:47 +00:00
cedricAbonnel ce70daaa34 Merge pull request 'fix : vérification écriture settings + script setup.sh' (#63) from feat/wizard-multi-step into main
Reviewed-on: #63
2026-05-14 22:50:36 +00:00
cedricAbonnel 8cab6362a3 feat : onglet Statistiques — pages, livres, répartition AS avec groupes configurables
- AccessLogParser : parse COMBINED, agrège hits /post/ et /book/, cache 10 min
- AsnLookup : batch lookup ip-api.com, cache 30j, agrégation et groupes AS
- Onglet Statistiques dans l'admin : top pages, top livres, répartition réseau
- Filtrage par groupe AS (badges) + formulaire de configuration des groupes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:48:34 +02:00
cedricAbonnel dbd76556fb feat : notion de livre — grouper des pages en série ordonnée
Ajoute un concept de "livre" (série de pages ordonnées) avec :
- BookManager : CRUD JSON dans data/books/<slug>.json
- Route /book/<slug> → page de sommaire (table des matières)
- Navigation chapitre ← → en bas de chaque article membre du livre
- Bandeau "Chapitre X/N — Nom du livre" en haut de l'article
- Admin → onglet Livres : créer, éditer, supprimer un livre, ajouter/ordonner les pages via textarea slug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:47:41 +02:00
cedricAbonnel 3bb83b3ffd feat : SearchLogParser accepte un pattern glob pour les logs d'accès
Balaye tous les fichiers correspondant au pattern (ex: *-access.log)
et leurs rotations .gz/.tar.gz. Valeur par défaut : *-access.log.
Label renommé en "Pattern des logs d'accès".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:35:19 +02:00
cedricAbonnel 981c9f6cb3 feat : SearchLogParser supporte tar.gz + config log dans onglet Recherches
- logFiles() utilise glob() au lieu d'un range fixe 1-14
- Support .tar.gz via PharData
- Champ apache_access_log déplacé du tab Site vers un bloc dédié
  dans le tab Recherches (action admin_save_searches_config)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:26:16 +02:00
cedricAbonnel d18f9abd16 feat : log Apache configurable via Administration → Site (apache_access_log)
Ajoute apacheAccessLog() dans SiteSettings — priorité au réglage admin,
fallback sur APACHE_ACCESS_LOG dans .env. Champ ajouté dans le formulaire
Site de l'administration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:16:49 +02:00
cedricAbonnel d488bcd00c fix : setup.sh — rappel redémarrage PHP-FPM en plus d'Apache pour groupe adm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:12:31 +02:00
cedricAbonnel 157c30f20c feat : setup.sh — ajout de www-data au groupe adm pour les logs Apache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:11:51 +02:00
cedricAbonnel fd2397ff90 feat : script setup.sh pour le déploiement initial de Folio
Crée les dossiers requis, applique setgid sur data/ et _cache/ pour
que les fichiers héritent du groupe web (www-data) quelle que soit
leur origine (PHP ou rsync), installe les dépendances et lance les
migrations SQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:10:30 +02:00
cedricAbonnel 9091a00a32 fix : saveSiteSettings et saveSmtpSettings retournent bool, erreur affichée
file_put_contents() échouait silencieusement (permissions), provoquant
un saved=1 trompeur. Les deux fonctions retournent maintenant bool ;
les callers redirigent vers ?error=write et le template affiche un
message d'erreur explicite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:02:54 +02:00
cedricAbonnel 370e1a9062 Merge pull request 'fix #61 : afficher les résultats de recherche au lieu de rediriger vers le 1er' (#62) from feat/wizard-multi-step into main
Reviewed-on: #62
2026-05-14 21:47:29 +00:00
cedricAbonnel d6b75d44e3 chore : version 1.2.2 + CHANGELOG (#61)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:47:04 +02:00
cedricAbonnel dbb4684d7c chore : version 1.3.0 + CHANGELOG (#61)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:46:32 +02:00
cedricAbonnel edb5f03956 fix #61 : afficher les résultats de recherche au lieu de rediriger vers le 1er
Remplace la redirection 301 vers le premier résultat par une redirection
302 vers /search?q=... pour laisser l'utilisateur choisir parmi les
résultats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:45:24 +02:00
cedricAbonnel 99a7f2e790 Merge pull request 'feat : wizard multi-étapes, migrations contenu, versionnage semver (v1.2.1)' (#60) from feat/wizard-multi-step into main
Reviewed-on: #60
2026-05-14 21:17:19 +00:00
cedricAbonnel 72cb7acae4 fix 1.2.1 : cache index.md, H1 rendu, scroll wizard, titre Modifier
- ArticleManager : invalider le cache si index.md est plus récent que meta.json
- migration_001 : touch(meta.json) après maj index.md pour forcer l'invalidation
- post_view.php : masquer le H1 initial du contenu (déjà affiché par le template)
- step1.php : en-tête "Modifier" sans le titre de l'article
- wizard.js : retirer scrollToCursor (erroné sur auto-resize) ; Ctrl+Home/End via scrollIntoView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:07:15 +02:00
cedricAbonnel 1dbe6d8dd3 feat : versionnage semver, migrations contenu, bandeau mise à jour admin
- CHANGELOG.md : structure semver (1.0.0 / 1.1.0 / 1.2.0) remplace le journal non versionné
- public/version.txt : généré à chaque push depuis la première entrée CHANGELOG
- scripts/push.sh : extrait la version CHANGELOG avant git add
- src/UpdateChecker.php : compare version déployée vs version Gitea (raw file), cache 1 h
- templates/layout.php : bandeau alerte admin (nouvelle version / migrations en attente)
- templates/admin.php : dashboard moteur Folio (version déployée / disponible)
- scripts/migrate_content.php + migration_001 : ajout # titre dans les articles existants
- templates/maintenance.php : page HTTP 503 pendant une migration
- src/helpers.php : extractMarkdownTitle(), normalisation \r\n dans lineDiff()
- templates/wizard/step1.php : suppression champ titre, plan TOC dynamique
- public/assets/js/wizard.js : scope titleEl, scrollToCursor, buildToc, handlers externalisés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:45:35 +02:00
cedricAbonnel 6200444e6d Merge pull request 'feat #58 : wizard multi-étapes création/édition article' (#59) from feat/wizard-multi-step into main
Reviewed-on: #59
2026-05-14 19:50:33 +00:00
cedricAbonnel c503f1dd66 style : formatage PHP-CS-Fixer sur le wizard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:48:33 +02:00
cedricAbonnel 6895a3bf65 feat #58 : wizard multi-étapes création/édition d'article
Remplace le formulaire unique par un wizard 5 étapes (création) et
6 étapes (édition) avec auto-sauvegarde en brouillon, détection de
tags depuis le texte (TagSuggester), aperçu SEO, diff avant validation
et plan Markdown dynamique dans l'éditeur.

Détail des changements :
- ArticleManager : +6 méthodes (updatePartialMeta, saveDraftOverlay,
  getDraftOverlay, hasDraftOverlay, discardDraftOverlay, commitDraftOverlay)
- .htaccess : routes /new/{uuid}/{1-5} et /edit/{uuid}/{1-6}
- index.php : cases create et edit réécrits en switch($step),
  nouveau case autosave_draft et edit_discard_draft
- assets/js/wizard.js : autosave debounce, auto-resize textarea,
  scroll curseur, plan TOC dynamique, toggle pills tags
- templates/wizard/ : nav.php + step1..6.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:46:11 +02:00
cedricAbonnel 24bb244352 perf : session lazy + CSRF cookie + 410 DokuWiki
Contexte : sur abonnel.fr, session_start() était appelé sur chaque
requête PHP (y compris bots), créant ~17 000 fichiers de session/jour
dans un répertoire custom non nettoyé par le cron Debian. Les workers
PHP-FPM grossissaient en mémoire et le pool saturait (1 188 erreurs
503 en 30 minutes).

Changements :

public/index.php
- session_start() uniquement si le cookie de session existe déjà ou si
  la requête est POST. Les bots (GET sans cookie) ne créent plus de
  session.
- CSRF commentaires migré de $_SESSION['comment_csrf'] vers un double-
  submit cookie (_csrf_c, SameSite=Strict, HttpOnly). La session n'est
  plus requise pour les visiteurs anonymes qui postent un commentaire.

templates/comments_section.php
- Génère le token CSRF et le pose en cookie (_csrf_c) au lieu de
  l'écrire en session.

public/.htaccess
- Règle Apache 410 Gone pour toute URL contenant un paramètre ?do=
  (anciens paramètres DokuWiki : do=media, do=export_pdf…). Traité par
  Apache en 2ms sans toucher PHP-FPM.

public/oidc/{start,callback,me}.php
- Correction du bug introduit par 0b8077e : config.php (qui utilise
  BASE_PATH) était chargé avant bootstrap.php (qui définit BASE_PATH).
  Fix : define('BASE_PATH', …) ajouté avant le require config.php.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:06:54 +02:00
cedricAbonnel f92e9425ed docs : config PHP-FPM recommandée et protection bots DokuWiki
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:16:02 +02:00
cedricAbonnel 0b8077e43c fix : orphelins search_index + SESSION_NAME non appliqué
- ArticleManager::getSearchIndex() : rebuild automatique si un UUID
  référencé dans search_index.json n'existe plus sur le disque (article
  supprimé hors CMS via rsync ou suppression manuelle). Même logique que
  getBySlug() qui nettoie déjà le slug_index à la volée.

- bootstrap.php : lire SESSION_NAME depuis $_ENV avant session_start(),
  permettant de personnaliser le nom du cookie de session via le .env.

- oidc/{start,callback,me}.php : inverser l'ordre des require pour charger
  config.php (dotenv) avant bootstrap.php, condition nécessaire pour que
  SESSION_NAME soit disponible au démarrage de la session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:06:11 +02:00
cedricAbonnel 5828aac4f5 docs: ajouter README avec guide d'installation
Couvre prérequis, installation pas-à-pas (clone, .env, BDD, migrations,
vhost Apache/Nginx, permissions), paramètres du site, mise à jour et
structure du projet. Fixes #55.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 09:58:05 +02:00
99 changed files with 8741 additions and 1092 deletions
+24
View File
@@ -6,6 +6,7 @@
APP_URL=https://example.com
APP_ENV=prod
APP_DEBUG=0
APP_TIMEZONE=Europe/Paris
# Authentification admin (email de l'administrateur principal)
ADMIN_EMAIL=
@@ -39,3 +40,26 @@ SMTP_FROM_NAME=
# Formulaire de contact
CONTACT_EMAIL=
CONTACT_FROM_EMAIL=
# Dépôt Folio pour le vérificateur de mises à jour (UpdateChecker)
# URL de base du dépôt Gitea (sans slash final)
FOLIO_REPO_URL=https://git.abonnel.fr/cedricAbonnel/folio
# Branche suivie pour les mises à jour (défaut : main)
# FOLIO_UPDATE_BRANCH=main
# Chemin absolu vers le répertoire des articles (data/)
# Par défaut : BASE_PATH/data (dans le répertoire de l'application)
# Recommandé en production : chemin hors du répertoire web, ex. /srv/data/folio
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
-1
View File
@@ -11,5 +11,4 @@ Thumbs.db
# Données des sites (articles, config, cache) — propres à chaque workspace
data/*
!data/.gitkeep
!data/site/
_cache/
+1 -1
View File
File diff suppressed because one or more lines are too long
+525 -127
View File
@@ -1,140 +1,538 @@
# Changelog — varlog
# Changelog
## [Unreleased] — 2026-05-13
Toutes les modifications notables sont documentées ici.
Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnage [semver](https://semver.org/lang/fr/).
### Performances
---
- **Cache multi-niveaux pour les vues d'articles** : temps de chargement réduit
de +5 s à ~0,4 s sur 1 062 articles.
- Mémoïsation de `getAll()` et `getSearchIndex()` dans la requête PHP
(`$allCache`, `$searchIndexCache`) — évite les appels répétés.
- Cache disque par article (`_cache/articles/{uuid}.json`) avec invalidation
par comparaison `mtime` — 1 lecture au lieu de 2 par article.
- Slug index (`_cache/slug_index.json`) : `getBySlug()` en O(1) sans scanner
tous les articles ; construit depuis `search_index.json` en un seul fichier.
- `getCategories()` et `$_allPublished` chargés depuis `search_index.json`
au lieu de `getAll()` — 1 fichier lu quelle que soit la taille du catalogue.
- `search_index.json` enrichi avec `cover`, `created_at`, `author` ; rebuild
automatique si le format est obsolète.
- `SearchEngine::scorePool()` : tokenise chaque article une seule fois pour
N mots de titre (vs N passes séparées qui retokenisaient chaque article
N fois et calculaient la similarité trigramme sur le contenu).
- Le nombre de lectures de fichiers par vue d'article est désormais constant
(~4), indépendamment du nombre total d'articles.
- Documentation : `docs/cache-architecture.md`.
## [1.6.40] - 2026-05-19
### Modifié
- Page article / stats visiteurs : fond `rgba(0,0,0,.45)` + `backdrop-filter:blur` pour lisibilité sur image claire ou foncée
---
## [1.6.39] - 2026-05-19
### Modifié
- Page article / stats visiteurs : couleur blanche explicite + text-shadow pour lisibilité sur hero image sombre ou claire
---
## [1.6.38] - 2026-05-19
### Modifié
- Page article : stats visiteurs affichées clairement pour tous — trois valeurs explicites « X / 7 j · Y / 14 j · Z / 30 j lecteurs »
---
## [1.6.37] - 2026-05-19
### Corrigé
- **Upload de fichiers (#48)** : les fichiers > 8 Mo étaient rejetés silencieusement.
Le serveur utilise `mod_php` (non PHP-FPM) ; les limites ont été corrigées dans
`/etc/php/8.3/apache2/php.ini` : `upload_max_filesize = 500M`, `post_max_size = 2048M`.
Le handler `add_files` détecte désormais le dépassement et affiche un message
d'erreur explicite au lieu de rediriger sans rien faire.
### Fonctionnalités
- **Réactions visiteurs** : trois boutons (👍 Utile / 🔥 Important / 🤔 À creuser)
affichés sous chaque article. Toggle : recliquer retire la réaction. Accessible sans
compte via un cookie UUID (`vl_vid`, 1 an, `HttpOnly`). Comportement async fetch avec
fallback formulaire natif (compatible CSP `script-src 'self'`). Routes :
`POST /react`. Table BDD : `article_reactions`.
- **Commentaires avec vérification email** : formulaire nom + email (non publié) +
texte (2 000 caractères max). Protection honeypot + CSRF en session. Un code à
6 chiffres est envoyé par email (expire 24 h) ; le commentaire est auto-publié au clic
sur le lien de confirmation. Routes : `POST /comment`,
`GET /verify-comment/<6chiffres>`. Table BDD : `comments`.
- **Modération commentaires** : onglet **Commentaires** dans `/admin/comments` listant
tous les commentaires avec statut (vérifié / publié) et actions masquer/republier.
Route : `POST /comment-moderate`.
- **Page de confirmation à l'enregistrement** : cliquer sur "Enregistrer" affiche une
page intermédiaire avec le diff du contenu, le slug (déplacé ici depuis le formulaire,
avec suggestion auto si le titre a changé), un commentaire de révision pré-rempli
d'après les modifications détectées, et un aperçu SEO (snippet Google). La
sauvegarde effective n'a lieu qu'après confirmation.
- **URLs propres** : toutes les routes internes migrent vers des chemins lisibles.
Les anciennes URLs `/?action=…` restent fonctionnelles (compatibilité).
| Ancienne URL | Nouvelle URL |
|---|---|
| `/?action=edit&uuid=<u>` | `/edit/<u>` |
| `/?action=sources&uuid=<u>` | `/sources/<u>` |
| `/?action=diff&uuid=<u>&rev=<n>` | `/diff/<u>/<n>` |
| `/?action=create` | `/new` |
| `/?action=admin[&tab=<t>]` | `/admin[/<t>]` |
| `/?action=categories` | `/categories` |
| `/?action=profile` | `/profile` |
| `/?action=about\|legal\|licenses\|contact` | `/about`, `/legal`… |
| `/?action=regen_thumbs` | `/admin/regen-thumbs` |
| `/?action=add_files&uuid=<u>` | `/files/<u>/add` |
| `/?action=import_image&uuid=<u>` | `/import/<u>` |
| `/?cat=<cat>` | `/categorie/<cat>` |
| `/?cursor=<uuid>` | `/page/<uuid>` |
- **Moteur de recherche** : index trigram+substring pré-construit (`search_index.json`,
reconstruit à chaque écriture), accessible depuis la navbar.
### Corrections
- **Métadonnées fichiers (sources)** : `addFileMeta()` ne sauvegardait pas l'auteur et
l'URL source en raison d'un guard `file_exists()` trop strict — supprimé.
- **Authentification OIDC** (`State invalide.`) : `session_start()` était appelé avant
`bootstrap.php` dans les fichiers OIDC, écrasant les paramètres de cookie
(`SameSite=Lax`, `Secure`, `HttpOnly`) — corrigé dans `start.php`, `callback.php`
et `me.php`.
- **Sidebar droite de l'article** : classe Bootstrap `flex-nowrap-lg` inexistante,
remplacée par `flex-lg-nowrap` — la sidebar ne tombe plus en bas de page.
- **Date d'affichage en liste** : `created_at` affiché à la place de `published_at`
— corrigé avec fallback approprié.
- **Formulaire d'édition** : "Fichiers existants" déplacé dans la colonne de droite ;
attribution auteur/source étendue à tous les types de fichiers (pas seulement images).
- **Historique des révisions** : plus de révision créée si le contenu et le titre
sont inchangés. Ajout des boutons de suppression par révision et suppression globale.
- **Canonical URL catégorie** : passe de `/?cat=…` à `/categorie/…`.
- **Flux RSS** : `/rss` et `/rss.xml` redirigent en 301 vers `/feed` (URL
canonique) ; les articles des catégories privées sont exclus du flux ;
la description est convertie depuis Markdown en texte brut.
- Admin stats / Pages : `ipData` non défini dans la Pages IIFE → ReferenceError → catch → "Impossible de charger le flux"
---
## 2026-05-09
## [1.6.36] - 2026-05-19
### Fonctionnalités
- **SEO** : balises canonical, `sitemap.xml`, `robots.txt`, JSON-LD (`BlogPosting` /
`WebSite`), `noindex` sur les pages d'administration.
- **Recherche** : page de résultats avec score de pertinence, mise en évidence des
termes, lien vers la catégorie depuis les résultats.
- **Support HEIC/HEIF** : conversion automatique en JPEG à l'upload.
- **Support SVG** : upload autorisé, servi avec Content-Type correct.
- **Avant-première** : article visible en liste mais verrouillé avant sa date de
publication.
- **Pagination curseur** : navigation par UUID de dernier article vu, sans offset SQL.
- **Layout article 3 colonnes** : sidebar gauche (catégorie), contenu central,
sidebar droite (pièces jointes, liens externes, articles liés).
- **Import depuis URL** : téléchargement de fichiers distants avec extraction
automatique des métadonnées (EXIF, OpenGraph, PDF).
- **Gestion des pièces jointes** dans le formulaire d'édition, avec attribution
auteur/source affichée dans la vue article.
### Corrections
- Login intégré dans `layout.php`, chemins CSS en absolu.
- Redéclaration de `url()` dans `config.php` — fatal error corrigée.
- Correction permissions `www-data` sur `data/`.
### Corrigé
- AccessLogParser : `foreach ($this->artIp30 as $path => $ips)` écrasait la variable locale `$ips` (top IPs par volume), la remplaçant par le dernier ensemble d'IPs d'article. Renommé en `$_artIpSet`.
---
## 2026-04 et antérieur
## [1.6.35] - 2026-05-19
- Flux RSS paginé (`/feed`, `/rss`, `/rss.xml`) avec autodiscovery.
- Stockage des articles en fichiers Markdown (migration depuis base de données).
- SSO via Keycloak/OIDC avec PKCE.
- Images de couverture (liste, vue article, `og:image`).
- Brouillons visibles uniquement par l'auteur.
- Formulaire de contact (CSRF, honeypot, rate-limit).
- Pages : mentions légales (LCEN/RGPD), licences, à propos.
- Auto-hébergement Bootstrap 5, police Inter, favicon SVG.
- Headers HTTP de sécurité, CSP stricte.
### Corrigé
- `visitors.json` : utilisation de `+` au lieu de `array_merge` pour préserver les clés entières 7/14/30 (array_merge les renumérote en 0/1/2)
- Admin stats / Visiteurs par pays : bouton ✕ déplacé hors du div 9rem (il était écrasé par le nom de l'AS) ; `e.stopPropagation()` ajouté pour ne pas déclencher l'accordéon
- Admin stats / Visiteurs par pays : listener délégué stocké et retiré avant réajout (évite l'accumulation de handlers après chaque `renderCountry()`)
### Modifié
- Graphique "Trafic total" → "Visiteurs uniques / jour" calculé depuis les IPs du top 200 (approximation)
---
## [1.6.34] - 2026-05-19
### Ajouté
- AccessLogParser : calcul des visiteurs uniques par article (IPs non-bot publiques, /post/ statut 200) sur 7 / 14 / 30 jours — stocké dans `data/UUID/visitors.json`
- Page article : affichage du nombre de lecteurs (7 / 14 / 30 jours) dans la zone hero, recalculé à chaque visite de `/admin/stats`
---
## [1.6.33] - 2026-05-19
### Ajouté
- Admin stats : carte « Visiteurs uniques non-bot » en tête de page avec compteurs 7 / 14 / 30 jours (calculés sur toutes les IPs non-bot, pas seulement le top 200)
- Admin stats / Visiteurs par pays : bouton « ✕ » sur chaque AS pour l'exclure des stats — les AS exclus apparaissent dans une section dédiée avec bouton « ↺ Inclure »
- Admin stats / Visiteurs par pays : badge d'alerte si des IPs du top 200 n'ont pas de résolution AS
- Admin stats / Visiteurs par pays : les AS exclus sont filtrés du décompte par pays et des compteurs visiteurs (soustraction approximative via le top 200)
- Nouvelles actions AJAX `admin_add_excluded_as` / `admin_remove_excluded_as` (CSRF) pour gérer `data/excluded_as.json`
### Modifié
- AccessLogParser : fenêtre d'analyse étendue à **30 jours** (était 14) ; calcul des visiteurs uniques par période (7 / 14 / 30 jours) sur l'ensemble des IPs non-bot
- Graphiques de tendance / par article : adaptés à 30 jours (labels x toutes les 3 jours)
- Articles : un seul bouton de réaction 👍 (suppression de 🔥 et 🤔)
---
## [1.6.32] - 2026-05-19
### Modifié
- Admin stats / Agents détectés : UA affiché en entier (plus de troncature à 55 car.) dans le drill-down IP et la liste agents
- Admin stats / Agents détectés : bouton « + bot » sur chaque agent non classé — ajoute le UA aux patterns via AJAX sans recharger la page, déplace la ligne vers "Bots connus"
- Admin stats / Agents détectés : section alimentée par `FOLIO_ALL_UAS` (tous UAs publics, bots inclus) plutôt que par agrégation depuis `ip_data`
- AccessLogParser : filtrage des bots dans les compteurs pages/livres/IPs — les requêtes détectées comme bot n'alimentent plus les stats de fréquentation ; `all_uas` expose tous les UAs (bots inclus) pour la section Agents
- `index.php` : chargement de `bots.json` avant la création du parser pour passer les patterns au constructeur ; `admin_add_bot` vide les caches stats après ajout ; `admin_save_bots` vide également les caches
- Template : `FOLIO_ALL_UAS` et `FOLIO_CSRF` ajoutés aux variables JS de la page stats
---
## [1.6.31] - 2026-05-19
### Ajouté
- Admin stats : section « Agents détectés » en bas de page — agrège tous les user agents, détecte bots/humains, badge 🤖 pour les bots connus
- Admin stats : panneau d'édition des patterns bots (un par ligne, correspondance insensible à la casse), sauvegardé dans `data/bots.json`
- Admin stats / drill-down IP : section « Autres chemins » (tous chemins/statuts hors articles et livres), triée par volume
- AccessLogParser : analyse tous les chemins et statuts pour les IPs publiques (pas seulement /post/ et /book/ en 200), tracking `ipAllPaths`, `ipAllDays`, `ipAgents`
- `index.php` : action `admin_save_bots` — enregistre les patterns bots avec token CSRF ; initialisation automatique de `data/bots.json` avec ~50 patterns connus (Googlebot, GPTBot, curl, Scrapy…)
---
## [1.6.30] - 2026-05-19
### Ajouté
- Admin stats / drill-down IP : user agents affichés sous l'adresse IP (top 5 par fréquence, sans corrélation avec les pages)
- AccessLogParser : capture du user agent (groupe 5 de la regex COMBINED), tracking `ipAgents` par IP, `ip_agents` dans le résultat
---
## [1.6.29] - 2026-05-19
### Modifié
- Admin stats / drill-down IP : chemins affichés un par ligne avec compteur entre parenthèses, triés par date de dernier accès (plus récent en premier)
- AccessLogParser : suivi du dernier horodatage par chemin/IP (`ipPathTs`), `ip_top_paths` devient `{n: count, ts: timestamp}`
---
## [1.6.28] - 2026-05-19
### Ajouté
- Admin stats : drill-down AS → IPs dans l'accordéon « Visiteurs par pays » — mini sparkline 14 jours + articles/livres consultés par IP
- Admin stats : `ip_data` dans le cache stats (daily + top paths par IP publique)
### Supprimé
- Admin stats : section « Répartition par réseau » (fusionnée dans l'accordéon pays)
---
## [1.6.27] - 2026-05-19
### Ajouté
- Admin stats : sparklines SVG 14 jours par page dans « Pages les plus visitées » — courbe + dégradé, carte pleine largeur (#101)
### Corrigé
- Admin stats : IPs privées/LAN exclues de la répartition par réseau (Uptime Kuma et hairpin NAT ne polluent plus les stats) (#102)
---
## [1.6.26] - 2026-05-16
### Ajouté
- Page publique `/books` — catalogue de tous les livres avec ≥ 1 article publié, cards cover/titre/description/nombre de pages (#99)
- Accueil : section « Livres » (max 6) après les redécouvertes avec lien « Voir tous → /books » (#100)
---
## [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é
- Wizard édition — étape SEO : sélecteur d'**image de couverture** (og:image) désormais affiché en mode édition (était limité à la création) — sélection parmi les fichiers images existants, appliquée immédiatement via `setCover()` et mémorisée dans le draft overlay
---
## [1.6.11] - 2026-05-15
### Corrigé
- En mode édition, le slug de l'article n'est plus jamais modifié : suppression du `hidden[slug]` dans l'étape 6 de confirmation et du bloc qui le propagait dans le draft overlay
---
## [1.6.10] - 2026-05-15
### Corrigé
- Suppression d'article échouait silencieusement quand le répertoire UUID appartenait à un autre utilisateur que `www-data` (permissions `2755` → groupe sans écriture) — `removeDir()` échouait sans erreur visible et l'article restait accessible
- `ArticleManager::delete()` retourne maintenant `bool` : si le répertoire existe encore après tentative de suppression, les index ne sont pas reconstruits et l'utilisateur est redirigé vers l'article avec un message d'erreur
- `removeDir()` : erreurs PHP supprimées silencieusement (`@unlink`, `@rmdir`, `@scandir`) pour éviter les warnings qui cassaient les redirects
- `mkArticleDir()` : nouvelle méthode privée qui crée les répertoires d'articles avec `chmod 0775` explicite (contourne le umask), garantissant que `www-data` (groupe) a toujours les droits d'écriture
---
## [Unreleased]
---
## [1.6.9] - 2026-05-15
### Ajouté
- `/admin/articles` : tri par **Titre** (A→Z / Z→A) et par **Date** (publication) en cliquant les en-têtes de colonne — indicateur ↑ / ↓ sur la colonne active, paramètres `sort` et `dir` préservés lors du filtrage
---
## [1.6.8] - 2026-05-15
### Corrigé
- Tous les scripts inline déplacés vers des fichiers JS statiques (`density-fouc.js`, `density.js`, `trending-home.js`, `admin-stats.js`) — conformité CSP `script-src 'self'` (varlog)
- `onclick` / `onchange` inline dans `admin.php` migrés vers `admin.js`
- Densité M (980 px) définie comme valeur par défaut au lieu de L (pleine largeur)
---
## [1.6.7] - 2026-05-15
### Ajouté
- Sélecteur de densité L / M / S sur la page liste : pleine largeur (défaut), normal (980 px), compact (660 px) — préférence persistée dans `localStorage`
---
## [1.6.6] - 2026-05-15
### Modifié
- Page d'accueil "Meilleures audiences" : chargement AJAX depuis le flux RSS XML `/trending?period=1h` (DOMParser côté client, plus de rendu PHP)
- `/admin/stats` section "Pages les plus visitées" : chargement AJAX depuis le flux RSS XML `/trending?period=14d` — plus de parsing de logs direct pour cette colonne
- `/admin/stats` : suppression de `topGrouped` pour les pages ; seuls les livres (`/book/`) et l'ASN conservent le parsing log côté serveur
---
## [1.6.5] - 2026-05-15
### Modifié
- `/tendances` et page d'accueil (rubrique "Meilleures audiences") : lecture seule du cache généré par `/trending?period=…` — plus aucun parsing de logs en dehors du flux RSS
- Rubrique renommée "Meilleures audiences · 1 heure" (ex "Tendances · 10 derniers jours")
---
## [1.6.4] - 2026-05-15
### Ajouté
- `src/TrendingParser.php` : parseur de logs Apache comptant les visiteurs uniques (IPs distinctes, HTTP 200) par article, avec support multi-préfixes et méthode `topGrouped()` (un seul parse pour pages + livres)
- `public/trending.php` : flux RSS des 50 articles les plus consultés, paramétrable par période (`?period=10m|20m|30m|1h|8h|1d|7d|14d|30d|1y`), cache TTL adaptatif
- `public/tendances.php` : page publique présentant les tendances par période, les flux RSS disponibles et la méthodologie
- Route `/tendances` dans `.htaccess`
### Modifié
- `/admin/stats` : utilise `TrendingParser` (visiteurs uniques) au lieu d'`AccessLogParser` (hits bruts) pour les pages et les livres ; label mis à jour
- Page d'accueil — rubrique Tendances : source principale désormais les logs Apache sur 1 heure (cache 12 min), fallback sur le score pondéré DB si les logs ne sont pas lisibles
---
## [1.6.3] - 2026-05-15
### Ajouté
- `scripts/server/folio-upgrade.sh` : script de déploiement serveur (clone fresh, permissions, composer, migrations SQL, `.sessions`, `safe.directory`) appelé par `sudo` depuis le bouton admin "Mettre à jour"
- `UpdateChecker::getLastUpgradeLog()` : affiche le journal de la dernière mise à jour dans l'admin (`<details>`)
### Modifié
- `run_engine_update` : délègue entièrement le déploiement au script `sudo /usr/local/bin/folio-upgrade.sh` — supprime le `git pull` inline qui ne fonctionnait pas avec les contraintes de permissions root
- `run_content_migrations` ajouté aux actions `noindex`
- Stats admin (`/admin/stats`) : cache 60 s dans `DATA_PATH/.stats_cache.json` pour le parsing des logs Apache et le lookup ASN
---
## [1.6.2] - 2026-05-15
### Corrigé
- `oidc/start.php` : garde explicite après `session_start()` — erreur 500 immédiate si `session.save_path` est inaccessible, évite un flux OIDC condamné à l'échec silencieux
- `oidc/callback.php` : même garde de session ; `error_log` en cas d'échec du contrôle de state pour faciliter le diagnostic
- `consignes.md` : règle ajoutée — pool PHP-FPM avec `user = www-data`, pas le compte admin personnel
---
## [1.6.1] - 2026-05-15
### Corrigé
- `login/index.php`, `login/magic.php`, `logout.php` : ordre de chargement corrigé (`config.php` avant `bootstrap.php`) pour que `SESSION_NAME` soit défini avant `session_start()`
- `data/site/` retiré du suivi git du moteur (contenu site-spécifique) ; `.gitignore` mis à jour
---
## [1.6.0] - 2026-05-15
### Ajouté
- Admin → Dashboard : bouton unique **Mettre à jour** (git pull + migrations SQL + migrations contenu) remplace les boutons séparés
- Branche `dev` : branche d'intégration permanente pour le développement quotidien
### Corrigé
- `run_engine_update` : vérifie que le remote git `origin` correspond à `FOLIO_REPO_URL` avant tout `git pull` (évite le pull sur le mauvais dépôt)
---
## [1.5.0] - 2026-05-15
### Ajouté
- Admin → Site : configuration de l'URL du dépôt Folio et de la branche suivie pour les mises à jour, sans modifier le `.env` (`folio_repo_url`, `folio_update_branch` dans `site_settings.json`)
- `APP_TIMEZONE` : fuseau horaire configurable via `.env` (défaut `Europe/Paris`), appliqué globalement dans `bootstrap.php`
### Corrigé
- Bouton « Vérifier » masqué avec message explicatif si `FOLIO_REPO_URL` n'est pas configuré (ni dans `.env` ni dans l'admin)
- `FOLIO_REPO_URL` et `FOLIO_UPDATE_BRANCH` documentés dans `.env.example`
- `scripts/push.sh` : ne pousse plus directement sur `main` — pousse sur la branche courante pour forcer le passage par une PR
---
## [1.4.0] - 2026-05-15
### Ajouté
- **`DATA_PATH`** : chemin des articles configurable via `.env`, indépendant du document root — permet de stocker `/data` hors de l'arborescence web (ex. `/srv/data/folio`)
- **`DataGit`** : auto-commit git sur toutes les écritures articles et livres (création, modification, suppression, métadonnées, tags, fichiers, liens…) sauf `autosave` — no-op silencieux si `DATA_PATH` n'est pas un dépôt git
- **Admin — Moteur Folio** : affiche la branche suivie pour les mises à jour (`FOLIO_UPDATE_BRANCH`, défaut `main`), la date du dernier contrôle, et un bouton **Vérifier** pour forcer la vérification sans attendre le TTL du cache (1 h)
### Modifié
- `UpdateChecker` : branche cible configurable via `FOLIO_UPDATE_BRANCH` (plus de `main` hardcodé dans l'URL Gitea)
---
## [1.3.0] - 2026-05-15
### Ajouté
- Onglet **Statistiques** dans l'admin : pages les plus visitées, livres consultés, répartition par AS (#64)
- `AccessLogParser` : lecture des logs Apache (plain, `.gz`, `.tar.gz`), cache 10 min
- `AsnLookup` : résolution ASN via ip-api.com (batch, cache 30 j), détection LAN automatique
- Filtrage des AS par groupes configurables (motifs case-insensitive, formulaire admin)
- Pattern de log configurable via l'UI (onglet Recherches) avec support glob
### Corrigé
- Permissions rsync : `--chmod=Fug+rw,Fo-w` assure la lisibilité groupe sur les fichiers déployés
- `saveSiteSettings()` et `saveSmtpSettings()` : retournent un `bool` et affichent une erreur si l'écriture échoue
- `scripts/setup.sh` : script d'initialisation Folio (composer, répertoires, droits, migrations, groupe `adm`)
---
## [1.2.2] - 2026-05-14
### Corrigé
- URL introuvable : redirige vers la page de recherche (`/search?q=…`) au lieu du premier résultat (#61)
---
## [1.2.1] - 2026-05-14
### Corrigé
- Cache article invalidé si `index.md` est plus récent que `meta.json` (migration de contenu ne se reflétait pas)
- Migration 001 : `touch(meta.json)` après écriture de `index.md` pour invalider le cache
- `post_view.php` : le `# Titre` Markdown est retiré du rendu (déjà affiché par le template)
- Wizard étape 1 : en-tête affiche « Modifier » sans répéter le titre de l'article
- `wizard.js` : suppression de `scrollToCursor` (calcul erroné sur textarea auto-resize) ; Ctrl+Home / Ctrl+End scrollent correctement via `scrollIntoView`
---
## [1.2.0] - 2026-05-14
### Ajouté
- Wizard multi-étapes pour la création (5 écrans) et l'édition (6 écrans) d'articles (#58)
- Auto-sauvegarde en brouillon (debounce 3 s) avec indicateur visible
- Étape tags : champ plat avec détection automatique depuis le texte (abréviations, CamelCase, noms propres)
- Étape SEO : aperçu moteur de recherche en temps réel
- Étape 6 (édition) : diff ligne à ligne avant confirmation
- Plan Markdown dynamique (TOC) dans la colonne droite de l'éditeur
- Titre extrait du premier `# …` du contenu Markdown (plus de champ titre séparé)
- Système de migrations de contenu (`scripts/migrate_content.php`)
- Mode maintenance automatique (`data/.maintenance` → page HTTP 503)
- Migration `001` : ajout du titre Markdown dans les articles existants
- Bouton "Mettre à jour" dans l'administration (sans accès CLI)
- `UpdateChecker` : détection de mise à jour et migrations en attente
- Bandeau d'alerte pour les administrateurs sur toutes les pages
- Dashboard `/admin` : version déployée vs version disponible
### Modifié
- `ArticleManager` : +6 méthodes pour les brouillons overlay
- `lineDiff` : normalisation `\r\n``\n`, seuil relevé à 2 000 000, fallback ligne par ligne
- `push.sh` : génère `public/version.txt` (numéro de version semver) à chaque release
### Corrigé
- Diff étape 6 "violent" (tout supprimé/ajouté) dû aux fins de ligne `\r\n` du navigateur
---
## [1.1.0] - 2026-05-13
### Ajouté
- **Réactions visiteurs** : boutons 👍 / 🔥 / 🤔 sous chaque article, toggle async avec fallback formulaire natif
- **Commentaires avec vérification email** : code 6 chiffres, honeypot + CSRF, modération dans `/admin`
- **URLs propres** : `/edit/<u>`, `/new`, `/admin`, `/categorie/<cat>`, `/files/<u>/add`, `/import/<u>`, etc.
- **Moteur de recherche** : index trigramme+substring pré-construit, résultats scorés avec mise en évidence
### Amélioré
- **Cache multi-niveaux** : chargement réduit de ~5 s à ~0,4 s sur 1 000+ articles (mémoïsation, cache disque, slug index O(1))
- **Upload fichiers** : détection et message d'erreur explicite pour les fichiers > limite PHP
### Corrigé
- Métadonnées fichiers (`addFileMeta`) : guard `file_exists()` trop strict supprimé
- Sidebar droite article : classe Bootstrap `flex-nowrap-lg``flex-lg-nowrap`
- Flux RSS : exclusion catégories privées, redirection 301 `/rss``/feed`
---
## [1.0.0] - 2026-05-09
### Ajouté
- Moteur de blog PHP Folio — première release versionnée
- Articles en Markdown avec fichiers attachés, liens externes, images de couverture
- Authentification par lien magique envoyé par email (#29)
- SSO via Keycloak/OIDC avec PKCE
- Rôles, capacités et gestion des utilisateurs
- Catégories avec swatches couleur générées algorithmiquement
- Tags par type avec suggestions
- SEO : canonical, `sitemap.xml`, `robots.txt`, JSON-LD, `og:image`
- Avant-premières (articles futurs visibles aux utilisateurs autorisés)
- Pagination curseur (sans offset SQL)
- Import depuis URL (EXIF, OpenGraph, PDF)
- Historique des révisions avec diff
- Flux RSS (`/feed`) paginé avec autodiscovery
- Formulaire de contact (CSRF, honeypot, rate-limit)
- Pages légales (LCEN/RGPD), licences, à propos
- Migrations SQL versionnées (`database/migrate.php`)
- Système de déploiement par rsync
+86
View File
@@ -0,0 +1,86 @@
# CLAUDE.md
## Ce qu'est ce dépôt
**Folio** est un moteur de blog PHP.
Ce répertoire est la **copie locale du dépôt Git** (`https://git.abonnel.fr/cedricAbonnel/folio`), branche DEV.
Il contient uniquement le code du moteur — pas de données, pas de credentials.
## Architecture
| Répertoire local | Site distant | Rôle |
|-----------------|-------------|------|
| `~/Projects/folio/` | — | Copie du dépôt Folio (branche DEV). On code ici. |
| `~/Projects/varlog/` | varlog.a5l.fr | Workspace varlog (scripts de déploiement/sync). Sert de site de test pour le moteur. |
| `~/Projects/varlog-data/` | varlog.a5l.fr | Articles de varlog. Sync bidirectionnelle. |
| `~/Projects/fr.abonnel.www/` | www.abonnel.fr | Workspace abonnel.fr (scripts de déploiement/sync). |
| `~/Projects/fr.abonnel.www-data/` | www.abonnel.fr | Articles de abonnel.fr. Sync bidirectionnelle. |
**abonnel.fr** utilise Folio mais se met à jour seul via son UpdateChecker interne (vérifie `version.txt` sur Gitea). Aucune action manuelle nécessaire côté serveur.
## Articles (`data/`)
Les articles ne sont pas versionnés dans ce dépôt. Ils ont leur propre dépôt git (`~/Projects/varlog-data/`, `~/Projects/fr.abonnel.www-data/`), synchronisé de façon bidirectionnelle avec le serveur distant.
## Modifier le moteur
### Branches
| Branche | Rôle |
|---------|------|
| `dev` | Branche d'intégration permanente. **Tout le développement courant se fait ici.** |
| `main` | Branche de production. **Jamais de commit direct.** |
| `feat/*` | Branches feature optionnelles pour du travail isolé, mergées dans `dev`. |
### Workflow
1. Toujours travailler sur `dev` (ou une branche feature mergée dans `dev`) :
```bash
git checkout dev
```
2. **Tester sur varlog.a5l.fr** à chaque itération (rsync des fichiers locaux, DB persistante) :
```bash
~/Projects/varlog/scripts/sync.sh
# puis tester sur http://varlog.acegrp.lan
```
3. Quand `dev` est stable et prête pour la production :
- Bumper `public/version.txt` (semver)
- Ajouter une entrée `CHANGELOG.md` (`### Ajouté / Corrigé / Modifié`)
- Ouvrir une **PR `dev` → `main`** sur Gitea
4. Merger la PR → abonnel.fr se met à jour automatiquement.
**Règle absolue : ne jamais commiter directement sur `main`.** Le script `scripts/push.sh` bloque cette action.
### Pourquoi `dev` et non des branches feature à la volée
- La DB de varlog (test) accumule les migrations au fil du temps — changer de branche ne fait pas reculer les migrations.
- Travailler toujours sur `dev` évite toute désynchronisation entre le code rsyncé et la DB.
## Données articles (`DATA_PATH`)
Les articles sont stockés dans un répertoire **hors du dépôt Folio**, configurable via `DATA_PATH` dans `.env` (défaut production : `/srv/data/folio`).
| Environnement | Dépôt local articles | Dépôt Gitea | Serveur |
|--------------|---------------------|------------|---------|
| varlog | `~/Projects/varlog-data/` | `cedricAbonnel/varlog` | `varlog:/srv/data/folio` |
| abonnel.fr | `~/Projects/fr.abonnel.www-data/` | `cedricAbonnel/abonnel-www` | `abonnel-wiki:/srv/data/folio` |
Sync bidirectionnelle via **git** (pas rsync). Scripts dans `~/Projects/varlog/scripts/` et `~/Projects/fr.abonnel.www/scripts/` :
- `pull-data.sh` : commit auto serveur + git pull local
- `push-data.sh` : git commit local + git push + git pull serveur
- `sync.sh` : moteur (rsync) + articles (git bidirectionnel)
## Asymétrie de déploiement moteur
| Site | Mécanisme | Raison |
|------|-----------|--------|
| varlog (test) | rsync depuis `~/Projects/folio/` | Itération rapide, pas de contrainte de stabilité |
| abonnel.fr (prod) | `git pull origin main` sur le serveur | Contrôle via PR/merge, UpdateChecker autonome |
Pour initialiser git sur un serveur abonnel.fr déployé via rsync : `scripts/git-init-remote.sh`
## Ne pas mettre ici
- `.env` (credentials → dans chaque workspace site)
- `data/` (articles → dans chaque workspace site)
- `vendor/` (non versionné)
+69
View File
@@ -0,0 +1,69 @@
# FOLIO
Moteur de blog PHP — utilisé par plusieurs sites.
## Dépôt
`https://git.abonnel.fr/cedricAbonnel/folio` — branche `main`
## Sites utilisant Folio
| Site | Workspace local | Serveur |
|---|---|---|
| varlog.a5l.fr | `~/Projects/varlog/` | `ssh varlog` |
| www.abonnel.fr | `~/Projects/fr.abonnel.www/` | `ssh abonnel-wiki` |
## Structure du moteur
```
folio/
├── src/ Classes PHP (ArticleManager, PostManager, auth…)
├── public/ Point d'entrée web (index.php, route.php, assets/)
├── templates/ Vues PHP (layout, header, footer, post_*)
├── config/ Configuration (config.php)
├── database/ Schéma SQL + migrate.php
├── composer.json
└── CHANGELOG.md
```
## Workflow de modification du moteur
### 1. Développement et test sur varlog.a5l.fr
Modifier le code ici dans `~/Projects/folio/`, tester sur **varlog.a5l.fr** :
```bash
# Déployer sur varlog pour test
~/Projects/varlog/scripts/sync.sh
# Tester sur http://varlog.acegrp.lan (ou https://varlog.a5l.fr)
```
### 2. Validation
Une fois validé sur varlog.a5l.fr :
```bash
# Commiter sur le serveur varlog (git de déploiement)
~/Projects/varlog/scripts/commit.sh "description du changement"
```
### 3. Push vers le dépôt Folio
Pousser le code validé vers le dépôt canonique Folio :
```bash
cd ~/Projects/folio
./scripts/push.sh "description du changement"
```
### 4. Déployer sur les autres sites si nécessaire
```bash
~/Projects/fr.abonnel.www/scripts/sync.sh
~/Projects/fr.abonnel.www/scripts/commit.sh "même message"
```
## Credentials locaux
Aucun credential dans folio/ — les `.env` sont dans chaque workspace site.
+166
View File
@@ -0,0 +1,166 @@
# Folio
Moteur de blog PHP minimaliste — articles Markdown, authentification SSO (OIDC) ou lien magique, commentaires, recherche, flux RSS.
---
## Prérequis
- PHP ≥ 8.2 avec les extensions `pdo`, `pdo_pgsql`, `mbstring`, `openssl`
- PostgreSQL ≥ 14
- Composer
- Apache avec `mod_rewrite` (ou Nginx — voir ci-dessous)
## Installation
### 1. Cloner et installer les dépendances
```bash
git clone https://git.abonnel.fr/cedricAbonnel/folio mon-site
cd mon-site
composer install --no-dev
```
### 2. Configurer l'environnement
```bash
cp .env.example .env
```
Remplir les valeurs dans `.env` :
| Variable | Description |
|---|---|
| `APP_URL` | URL publique du site (`https://example.com`) |
| `ADMIN_EMAIL` | Email de l'administrateur principal |
| `SESSION_NAME` | Nom du cookie de session — doit être unique par instance |
| `DATA_PATH` | Chemin absolu vers le répertoire des articles (ex. `/srv/data/mon-site`). Par défaut : `<racine>/data` |
| `OIDC_ISSUER` / `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | SSO OpenID Connect |
| `DB_DSN` / `DB_USER` / `DB_PASS` | Connexion PostgreSQL |
| `SMTP_*` | Serveur email sortant (commentaires, contact, lien magique) |
| `CONTACT_EMAIL` | Destinataire du formulaire de contact |
> En production, placer `DATA_PATH` **hors du document root** (ex. `/srv/data/mon-site`) pour que les articles ne soient pas accessibles directement via le serveur web.
### 3. Créer la base de données
```bash
createdb monsite
```
### 4. Initialiser le schéma et jouer les migrations
```bash
php database/migrate.php
```
Ce script crée toutes les tables et applique les migrations dans l'ordre. À relancer après chaque mise à jour.
### 5. Configurer le vhost Apache
```apache
<VirtualHost *:443>
ServerName example.com
DocumentRoot /var/www/mon-site/public
<Directory /var/www/mon-site/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
Le fichier `public/.htaccess` gère le routage via `mod_rewrite`. `AllowOverride All` est requis.
<details>
<summary>Nginx</summary>
```nginx
server {
listen 443 ssl;
server_name example.com;
root /var/www/mon-site/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
```
</details>
### 6. Permissions fichiers
```bash
# Répertoire des articles
mkdir -p /srv/data/mon-site
chown -R www-data:www-data /srv/data/mon-site
# .env lisible par www-data uniquement
chown user:www-data .env
chmod 640 .env
```
PHP-FPM tourne en `www-data`. Le `.env` doit être lisible par `www-data` mais pas par les autres.
> Le script `scripts/setup.sh` automatise la vérification des prérequis, la création des répertoires et les droits.
### 7. Paramètres du site
Au premier lancement, se connecter en tant qu'admin et aller dans **Administration → Paramètres du site** pour définir le titre, le claim, la langue et la licence.
Ou créer directement `$DATA_PATH/site_settings.json` :
```json
{
"site_title": "Mon site",
"site_claim": "Un blog propulsé par Folio",
"site_lang": "fr-FR",
"site_license_label": "CC BY 4.0",
"site_license_url": "https://creativecommons.org/licenses/by/4.0/",
"posts_per_page": 12
}
```
---
## Mise à jour
### Manuelle
```bash
git pull
composer install --no-dev
php database/migrate.php
```
### Via le bouton admin ("Mettre à jour")
L'interface d'administration propose un bouton **Mettre à jour** qui déclenche un déploiement complet via `sudo /usr/local/bin/folio-upgrade.sh`. Une configuration sudoers est requise une fois par serveur.
→ Voir **[docs/deployment.md](docs/deployment.md)** pour la procédure complète.
## Structure du projet
```
├── config/ Configuration (charge .env, définit APP_URL et DATA_PATH)
├── database/ Schéma SQL et runner de migrations
├── docs/ Documentation technique
├── public/ Racine web (index.php, assets, .htaccess)
├── scripts/ Scripts utilitaires (setup.sh, migrations de contenu)
├── src/ Code applicatif
└── templates/ Vues PHP
```
Les articles sont stockés dans `DATA_PATH` (hors dépôt git).
## Licence
[MIT](LICENSE)
+14
View File
@@ -6,8 +6,22 @@ if (!defined('BASE_PATH')) {
define('BASE_PATH', __DIR__);
}
$__tz = $_ENV['APP_TIMEZONE'] ?? getenv('APP_TIMEZONE') ?: 'Europe/Paris';
date_default_timezone_set($__tz);
unset($__tz);
if (!defined('DATA_PATH')) {
$__dataPath = $_ENV['DATA_PATH'] ?? getenv('DATA_PATH') ?: '';
define('DATA_PATH', $__dataPath !== '' ? rtrim($__dataPath, '/') : BASE_PATH . '/data');
unset($__dataPath);
}
if (session_status() === PHP_SESSION_NONE) {
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: null);
if ($sessionName !== null && $sessionName !== '') {
session_name($sessionName);
}
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
+8
View File
@@ -20,6 +20,12 @@ if (!$_ENV['APP_URL']) {
// Normalise: toujours un trailing slash unique
define('APP_URL', rtrim($_ENV['APP_URL'], '/') . '/');
if (!defined('DATA_PATH')) {
$__dp = $_ENV['DATA_PATH'] ?? getenv('DATA_PATH') ?: '';
define('DATA_PATH', $__dp !== '' ? rtrim($__dp, '/') : BASE_PATH . '/data');
unset($__dp);
}
// (Optionnel) Expose dans $_ENV si besoin
$_ENV['APP_URL'] = APP_URL;
@@ -38,3 +44,5 @@ if (!function_exists('url')) {
return $u;
}
}
require_once BASE_PATH . '/src/helpers.php';
+133
View File
@@ -0,0 +1,133 @@
# Consignes — Folio
## Architecture
**Folio** est un moteur de blog PHP.
### Dépôts et rôles
| Répertoire local | Rôle | Remote Gitea |
|-----------------|------|-------------|
| `~/Projects/folio/` | Copie du moteur Folio, branche `dev`. **Tout le développement se fait ici.** | `git.abonnel.fr/cedricAbonnel/folio` |
| `~/Projects/varlog/` | Workspace du site varlog (scripts, config). | — |
| `~/Projects/varlog-data/` | Articles de varlog.a5l.fr. Sync bidirectionnelle. | `cedricAbonnel/varlog` |
| `~/Projects/fr.abonnel.www/` | Workspace du site abonnel.fr (scripts, config). | — |
| `~/Projects/fr.abonnel.www-data/` | Articles de www.abonnel.fr. Sync bidirectionnelle. | `cedricAbonnel/abonnel-www` |
### Environnements
| Site | Rôle | Mise à jour moteur | Articles |
|------|------|--------------------|---------|
| varlog.a5l.fr | Test | rsync depuis `~/Projects/folio/` | `varlog-data/``varlog:/srv/data/folio` |
| www.abonnel.fr | Production | Auto (UpdateChecker vérifie `version.txt` sur Gitea) | `fr.abonnel.www-data/``abonnel-wiki:/srv/data/folio` |
### Articles (`DATA_PATH`)
Les articles ne sont **jamais** dans le dépôt folio. Ils vivent dans un répertoire séparé, configurable via `DATA_PATH` dans le `.env` de chaque instance.
- Serveur varlog : `DATA_PATH=/srv/data/folio`
- Serveur abonnel.fr : `DATA_PATH=/srv/data/folio`
- En local pour tester : pointer `DATA_PATH` vers `~/Projects/varlog-data/`
La sync des articles se fait via git (pas rsync) avec les scripts `pull-data.sh` et `push-data.sh`.
---
## Workflow moteur
1. Travailler sur `dev` dans `~/Projects/folio/`
2. Tester sur varlog.a5l.fr :
```bash
~/Projects/varlog/scripts/sync.sh
# puis vérifier sur http://varlog.acegrp.lan (ou varlog.a5l.fr)
```
3. Quand `dev` est stable :
- Bumper `public/version.txt` (semver)
- Ajouter une entrée dans `CHANGELOG.md`
- Ouvrir une **PR `dev` → `main`** sur Gitea
4. Merger la PR → abonnel.fr se met à jour automatiquement.
**Règle absolue : jamais de commit direct sur `main`.**
---
## Mise à jour du moteur (varlog)
Le poste local n'a pas de base de données. Tout ce qui touche à la DB ou au contenu s'exécute **sur le serveur varlog** via SSH — les scripts locaux ne font qu'ouvrir une connexion SSH et lancer le PHP distant.
**Cycle de développement :**
1. Modifier le code dans `~/Projects/folio/` (local)
2. Déployer et tester :
```bash
# Rsync moteur (folio → varlog) + sync articles bidirectionnel
~/Projects/varlog/scripts/sync.sh
# puis tester sur http://varlog.acegrp.lan
```
3. Si des migrations de schéma BDD sont nécessaires :
```bash
~/Projects/varlog/scripts/db-migrate.sh # exécute le PHP sur varlog via SSH
```
4. Si des migrations de contenu sont nécessaires :
```bash
~/Projects/varlog/scripts/content-migrate.sh # exécute le PHP sur varlog via SSH
```
**Déploiement complet en une commande (lint + rsync + DB + contenu + commit serveur) :**
```bash
~/Projects/varlog/scripts/deploy.sh "message de commit"
```
Chemin serveur : `varlog:/var/www/lan.acegrp.varlog/`
---
## Mise à jour manuelle du moteur (abonnel.fr)
À utiliser uniquement si l'UpdateChecker échoue :
```bash
# Sauvegarde du .env
cp /var/www/lan.acegrp.abonnel-www/.env /tmp/.env.bak
# Clone fresh (en root car /var/www appartient à root)
sudo rm -rf /var/www/lan.acegrp.abonnel-www
sudo git clone --depth=1 https://git.abonnel.fr/cedricAbonnel/folio.git /var/www/lan.acegrp.abonnel-www
# Permissions : www-data propriétaire (PHP-FPM tourne en www-data)
sudo chown -R www-data:www-data /var/www/lan.acegrp.abonnel-www
sudo chmod -R g+rwX,o= /var/www/lan.acegrp.abonnel-www
# Restauration du .env
sudo cp /tmp/.env.bak /var/www/lan.acegrp.abonnel-www/.env
sudo chown www-data:www-data /var/www/lan.acegrp.abonnel-www/.env
# Dépendances et migrations (en tant que www-data car le répertoire lui appartient)
cd /var/www/lan.acegrp.abonnel-www
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo -u www-data php database/migrate.php
# Répertoire de sessions
sudo mkdir -p /var/www/lan.acegrp.abonnel-www/.sessions
sudo chown www-data:www-data /var/www/lan.acegrp.abonnel-www/.sessions
sudo chmod 700 /var/www/lan.acegrp.abonnel-www/.sessions
# Autoriser git à opérer sur ce dépôt (multi-utilisateurs)
sudo git config --system --add safe.directory /var/www/lan.acegrp.abonnel-www
```
---
## Règles à respecter
- Ne **jamais** écraser le `.env` serveur (ni scp, ni réécriture). Indiquer les variables à l'utilisateur pour qu'il les saisisse lui-même.
- 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.
-18
View File
@@ -1,18 +0,0 @@
{
"uuid": "a3d8f2c1-7b4e-4f9a-8c3d-2e5a9b6f1d4c",
"slug": "about",
"title": "À propos",
"author": "cedric@abonnel.fr",
"published": true,
"published_at": "2021-01-16 04:02:40",
"created_at": "2021-01-16 04:02:40",
"updated_at": "2026-05-13 00:00:00",
"revisions": [],
"cover": "",
"files_meta": [],
"external_links": [],
"seo_title": "",
"seo_description": "",
"og_image": "",
"category": ""
}
-39
View File
@@ -1,39 +0,0 @@
# À propos
Qui se cache derrière varlog ?
Je m'appelle **Cédric**. Passionné d'informatique depuis longtemps, je gère un **HomeLab** à la maison — un petit laboratoire personnel où je fais tourner des serveurs, expérimente des configs réseau et casse des choses pour mieux les comprendre.
varlog est mon carnet de bord technique. J'y documente ce que je fais, ce que j'apprends, et parfois ce qui tourne mal — les incidents sont souvent les meilleures leçons.
Le blog a été lancé publiquement aux **JDLL 2025** (Journées Du Logiciel Libre), à Lyon.
## Ce dont je parle ici
### HomeLab & infrastructure
Proxmox, virtualisation, domotique (Zigbee, MQTT, Home Assistant), supervision avec Uptime Kuma, auto-hébergement de services (Gitea, Keycloak…), incidents réseau et leurs post-mortems.
### Réseaux & télécom
Passionné par les réseaux mobiles (3G/4G/5G/6G), la fibre optique (50G-PON), les stratégies des opérateurs et les infrastructures qui font fonctionner tout ça sans qu'on y pense.
### Linux & développement
Debian au quotidien, scripts, administration système, et un peu de PHP — dont ce blog lui-même, développé maison sous le nom de code *Folio*.
### Numérique & société
Souveraineté numérique, données personnelles, IA et plateformes qui monétisent nos contenus — des sujets qui m'intéressent autant qu'ils m'inquiètent.
### Le reste
Bricolage, travaux, anecdotes techniques, lectures, liseuses Kobo, et quelques billets qui n'entrent dans aucune case. La vie ne se range pas en catégories.
## Contact
Vous pouvez me joindre via le [formulaire de contact](/contact). Je lis tous les messages, même si je ne réponds pas toujours vite.
---
Le contenu de ce blog est publié sous licence [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) sauf mention contraire. Le moteur *Folio* est distribué sous [licence MIT](/LICENSE).
-18
View File
@@ -1,18 +0,0 @@
{
"uuid": "b2c7e1f4-4a3d-4e8b-9f2a-1d6c8e3f5a7b",
"slug": "legal",
"title": "Mentions légales",
"author": "cedric@abonnel.fr",
"published": true,
"published_at": "2021-01-16 04:02:40",
"created_at": "2021-01-16 04:02:40",
"updated_at": "2026-05-13 00:00:00",
"revisions": [],
"cover": "",
"files_meta": [],
"external_links": [],
"seo_title": "",
"seo_description": "",
"og_image": "",
"category": ""
}
-43
View File
@@ -1,43 +0,0 @@
# Mentions légales
Conformément à la loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économie numérique (LCEN).
## Éditeur du site
**Responsable de publication :** Cédric Abonnel
**Qualité :** Particulier — site personnel non commercial
**Contact :** [formulaire de contact](/contact)
## Hébergement
**Type :** Auto-hébergement sur infrastructure personnelle (HomeLab)
**Exploitant :** Cédric Abonnel
**Fournisseur d'accès à internet :** Infrastructure personnelle auto-hébergée
## Propriété intellectuelle
Le **contenu éditorial** de ce site (articles, textes, images produites par l'auteur) est publié sous licence [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/), sauf mention contraire.
Le **moteur du site** (*Folio*) est un logiciel libre distribué sous [licence MIT](/LICENSE).
Les composants tiers (Bootstrap, PHPMailer, police Inter…) sont soumis à leurs licences respectives, détaillées sur la [page des licences](/licenses).
## Données personnelles (RGPD)
Ce site est un blog personnel **sans publicité, sans pistage, sans système de commentaires** ni inscription publique.
Les seules données traitées automatiquement sont les **journaux de connexion du serveur web** (adresse IP, horodatage, page demandée), conservés conformément aux obligations légales (article L34-1 du Code des postes et des communications électroniques — durée maximale : 1 an).
Ces données ne sont ni vendues, ni transmises à des tiers, ni utilisées à des fins commerciales.
Conformément au RGPD (règlement UE 2016/679), vous disposez d'un droit d'accès, de rectification et de suppression des données vous concernant. Pour exercer ces droits : [formulaire de contact](/contact).
## Cookies
Ce site utilise uniquement un **cookie de session technique**, nécessaire au fonctionnement de l'authentification. Il n'est déposé que lors d'une connexion au compte d'administration et n'est pas utilisé à des fins de suivi ou de profilage. Aucun cookie tiers n'est déposé.
## Responsabilité
L'éditeur s'efforce de maintenir les informations publiées à jour et exactes, mais ne peut garantir l'exhaustivité ou l'absence d'erreurs du contenu.
Les liens vers des sites tiers sont fournis à titre informatif. L'éditeur n'est pas responsable du contenu de ces sites externes.
-18
View File
@@ -1,18 +0,0 @@
{
"uuid": "fdff8ad3-d369-4bd7-bbb9-e14d433868d7",
"slug": "licenses",
"title": "Licences",
"author": "cedric@abonnel.fr",
"published": true,
"published_at": "2021-01-16 04:02:40",
"created_at": "2021-01-16 04:02:40",
"updated_at": "2021-01-16 04:02:40",
"revisions": [],
"cover": "",
"files_meta": [],
"external_links": [],
"seo_title": "",
"seo_description": "",
"og_image": "",
"category": ""
}
-38
View File
@@ -1,38 +0,0 @@
# Licences
Composants logiciels utilisés par ce site et leurs licences.
## Ce site
| Composant | Licence | Usage |
|-----------|---------|-------|
| **Folio** — moteur de blog PHP | MIT | Moteur de ce blog — par Cédric Abonnel ([voir la licence](/LICENSE)) |
| **Contenu éditorial** | CC BY 4.0 | Articles et textes du blog — [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) |
## Bibliothèques (production)
| Composant | Version | Licence | Usage |
|-----------|---------|---------|-------|
| **Bootstrap** | 5.3.3 | MIT | Framework CSS/JS — auto-hébergé ([voir la licence](/assets/css/LICENSE-Bootstrap.txt)) |
| **PHPMailer** | 6.12.0 | LGPL-2.1 | Envoi d'e-mails SMTP |
| **phpdotenv** | 5.6.2 | BSD-3-Clause | Variables d'environnement |
| **openid-connect-php** | 1.0.2 | Apache-2.0 | Authentification SSO (OIDC) |
| **Police Inter** | v20 | OFL-1.1 | Typographie — auto-hébergée ([voir la licence](/assets/fonts/LICENSE-Inter.txt)) |
## Outils de développement
| Composant | Version | Licence | Usage |
|-----------|---------|---------|-------|
| **PHPStan** | 1.12.32 | MIT | Analyse statique PHP |
| **PHP-CS-Fixer** | 3.89.1 | MIT | Formatage du code |
| **Claude Code CLI** | — | Commercial | Outil de développement (Anthropic) — [Conditions d'utilisation](https://www.anthropic.com/legal/aup) |
## Infrastructure
| Composant | Licence | Usage |
|-----------|---------|-------|
| **PHP 8.3** | PHP License v3.01 | Langage côté serveur |
| **PostgreSQL** | PostgreSQL License | Base de données relationnelle |
| **Apache HTTP Server** | Apache-2.0 | Serveur web |
+49
View File
@@ -0,0 +1,49 @@
-- Schéma initial : tables créées avant la mise en place du système de migrations.
-- Remplace tables_create.sql et interactions_create.sql.
CREATE TABLE IF NOT EXISTS posts (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
is_published BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS post_files (
id SERIAL PRIMARY KEY,
post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
file_type TEXT,
file_path TEXT,
original_name TEXT,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS article_reactions (
id SERIAL PRIMARY KEY,
article_uuid TEXT NOT NULL,
reaction_type TEXT NOT NULL CHECK (reaction_type IN ('useful', 'important', 'interesting')),
visitor_hash TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (article_uuid, reaction_type, visitor_hash)
);
CREATE INDEX IF NOT EXISTS article_reactions_article_uuid_idx ON article_reactions (article_uuid);
CREATE TABLE IF NOT EXISTS comments (
id SERIAL PRIMARY KEY,
article_uuid TEXT NOT NULL,
author_name TEXT NOT NULL,
author_email TEXT NOT NULL,
content TEXT NOT NULL CHECK (LENGTH(content) <= 2000),
verify_token TEXT,
verification_code TEXT,
verify_attempts INTEGER NOT NULL DEFAULT 0,
verified BOOLEAN NOT NULL DEFAULT FALSE,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS comments_article_uuid_idx ON comments (article_uuid, verified, published);
CREATE INDEX IF NOT EXISTS comments_verify_token_idx ON comments (verify_token)
WHERE verified = FALSE AND verify_token IS NOT NULL;
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS user_profiles (
email TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT now(),
profile_url TEXT NOT NULL DEFAULT '',
profile_slug TEXT NOT NULL DEFAULT '',
bio TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_profile_slug_idx
ON user_profiles (profile_slug) WHERE profile_slug <> '';
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS journal_smtp (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
script_path VARCHAR(512),
to_email VARCHAR(255) NOT NULL,
subject VARCHAR(512),
content_html TEXT,
content_text TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'queued',
ip VARCHAR(128),
user_agent VARCHAR(512),
error_message VARCHAR(1000),
sent_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_journal_smtp_created_at ON journal_smtp (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_journal_smtp_to_email ON journal_smtp (to_email);
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS role_capabilities (
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
capability VARCHAR(50) NOT NULL,
PRIMARY KEY (role_id, capability)
);
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS user_capabilities (
user_email TEXT NOT NULL,
capability TEXT NOT NULL,
granted_by TEXT,
granted_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
PRIMARY KEY (user_email, capability)
);
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP,
password_changed_at TIMESTAMP
);
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS profiles (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
description TEXT,
permissions JSONB NOT NULL DEFAULT '[]',
is_system BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS app_config (
id INTEGER PRIMARY KEY DEFAULT 1,
allow_password BOOLEAN NOT NULL DEFAULT TRUE,
allow_oidc BOOLEAN NOT NULL DEFAULT FALSE,
registrations_open BOOLEAN NOT NULL DEFAULT TRUE,
oidc_issuer TEXT,
oidc_name TEXT,
oidc_client_id TEXT,
oidc_client_secret TEXT,
oidc_redirect_uri TEXT,
updated_at TIMESTAMP,
CONSTRAINT app_config_single_row CHECK (id = 1)
);
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS mail_queue (
id SERIAL PRIMARY KEY,
to_email TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
available_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
locked_at TIMESTAMP WITH TIME ZONE,
last_error TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_mail_queue_pending
ON mail_queue (available_at ASC, id ASC)
WHERE status = 'pending';
+44
View File
@@ -0,0 +1,44 @@
-- Tables du dictionnaire de données (formulaires dynamiques)
CREATE TABLE IF NOT EXISTS dd_entities (
id SERIAL PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS dd_fields (
id SERIAL PRIMARY KEY,
entity_id INTEGER NOT NULL REFERENCES dd_entities(id) ON DELETE CASCADE,
code TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
field_type TEXT NOT NULL DEFAULT 'text',
ui_order INTEGER,
is_required BOOLEAN NOT NULL DEFAULT FALSE,
default_val TEXT,
UNIQUE (entity_id, code)
);
CREATE TABLE IF NOT EXISTS dd_rules (
id SERIAL PRIMARY KEY,
entity_id INTEGER NOT NULL REFERENCES dd_entities(id) ON DELETE CASCADE,
rule_type TEXT NOT NULL,
expression TEXT,
message TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS dd_enums (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS dd_enum_values (
id SERIAL PRIMARY KEY,
enum_id INTEGER NOT NULL REFERENCES dd_enums(id) ON DELETE CASCADE,
code TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (enum_id, code)
);
+115
View File
@@ -0,0 +1,115 @@
# Déploiement et mise à jour
## Mise à jour via le bouton admin
L'interface d'administration propose un bouton **Mettre à jour** (onglet Dashboard). Il appelle `sudo /usr/local/bin/folio-upgrade.sh` depuis PHP (`www-data`) et exécute la séquence complète :
1. Sauvegarde du `.env`
2. `git clone --depth=1` dans un répertoire temporaire
3. Remplacement atomique du répertoire applicatif
4. `chown -R www-data:www-data` + `chmod g+rwX,o=`
5. Restauration du `.env`
6. `composer install --no-dev --optimize-autoloader`
7. `php database/migrate.php` (migrations SQL)
8. Création de `.sessions/` avec les bons droits
9. `git config --system --add safe.directory`
### Pré-requis serveur (à faire une fois)
```bash
# 1. Installer le script (copié depuis le dépôt)
sudo install -o root -m 750 /var/www/mon-site/scripts/server/folio-upgrade.sh \
/usr/local/bin/folio-upgrade.sh
# 2. Adapter APP_DIR et REPO_URL en tête du script
sudo nano /usr/local/bin/folio-upgrade.sh
# 3. Créer la règle sudoers (www-data sans mot de passe)
echo "www-data ALL=(root) NOPASSWD: /usr/local/bin/folio-upgrade.sh" \
| sudo tee /etc/sudoers.d/folio-upgrade
sudo chmod 440 /etc/sudoers.d/folio-upgrade
# 4. Vérifier la syntaxe sudoers
sudo visudo -c
```
Variables à configurer dans le script :
| Variable | Exemple |
|---|---|
| `APP_DIR` | `/var/www/lan.acegrp.abonnel-www` |
| `REPO_URL` | `https://git.abonnel.fr/cedricAbonnel/folio.git` |
> **Sans cette configuration**, le bouton retourne :
> `sudo: a terminal is required to read the password`
### Fonctionnement du cache de mise à jour
Le résultat de la dernière mise à jour est conservé dans `DATA_PATH/.upgrade-log` et affiché en `<details>` dans l'admin.
---
## Mise à jour manuelle
Si le bouton admin n'est pas configuré ou si une mise à jour d'urgence est nécessaire :
```bash
# Sauvegarde du .env
cp /var/www/mon-site/.env /tmp/.env.bak
# Clone fresh
sudo rm -rf /var/www/mon-site
sudo git clone --depth=1 https://git.abonnel.fr/cedricAbonnel/folio.git /var/www/mon-site
# Permissions
sudo chown -R www-data:www-data /var/www/mon-site
sudo chmod -R g+rwX,o= /var/www/mon-site
# Restaurer .env
cp /tmp/.env.bak /var/www/mon-site/.env
# Dépendances et migrations
cd /var/www/mon-site
composer install --no-dev --optimize-autoloader
php database/migrate.php
# Répertoire de sessions
sudo mkdir -p /var/www/mon-site/.sessions
sudo chown www-data:www-data /var/www/mon-site/.sessions
sudo chmod 700 /var/www/mon-site/.sessions
# Autoriser git (accès multi-utilisateurs)
sudo git config --system --add safe.directory /var/www/mon-site
```
---
## Flux RSS des tendances (`/trending`)
Le flux RSS des articles les plus consultés est alimenté par `TrendingParser` qui lit les logs Apache.
- **Source** : `GET /trending?period=<période>` — parse les logs et écrit `DATA_PATH/_cache/trending_<période>.json`
- **Consommateurs** (lecture seule du cache) : page d'accueil (rubrique "Meilleures audiences") et `/tendances`
### Périodes disponibles
| Paramètre | Fenêtre | Cache TTL |
|---|---|---|
| `10m` | 10 min | 2 min |
| `20m` | 20 min | 4 min |
| `30m` | 30 min | 6 min |
| `1h` | 1 heure | 12 min |
| `8h` | 8 heures | 96 min |
| `1d` | 24 heures | 5 h |
| `7d` | 7 jours | 8 h |
| `14d` | 14 jours | 8 h |
| `30d` | 30 jours | 8 h |
| `1y` | 1 an | 8 h |
### Prérequis
`www-data` doit appartenir au groupe `adm` pour lire `/var/log/apache2/` :
```bash
sudo usermod -aG adm www-data
```
+25
View File
@@ -37,3 +37,28 @@ $dateValue = $published_at ?? date('Y-m-d\TH:i');
## Permissions serveur
PHP-FPM tourne en `www-data`. Les fichiers sensibles (`.env`) appartiennent à `cedrix:www-data 640`. Voir `PROJET.md` § Permissions serveur.
## Configuration PHP-FPM recommandée
Sur un serveur 2 GB RAM, chaque worker PHP-FPM consomme ~40 MB. Pool recommandé (`/etc/php/8.3/fpm/pool.d/<site>.conf`) :
```ini
pm = dynamic
pm.max_children = 20
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 8
```
Symptôme de saturation : `server reached pm.max_children` dans `/var/log/php8.3-fpm.log`.
## Protection contre les bots (anciennes URLs DokuWiki)
Les anciens sites migrés depuis DokuWiki reçoivent du trafic de bots sur `/lib/`, `/doku.php`, etc. Utiliser `RedirectMatch 410` dans Apache plutôt que `Require all denied` — le 410 "Gone" est un signal définitif qui pousse les moteurs à retirer ces URLs de leur index.
```apache
# Dans le VirtualHost
RedirectMatch 410 "^/(lib|doku\.php|feed\.php|install\.php|_media|_detail)(/.*)?$"
```
Un 403 ("accès refusé") est ignoré par les bots sérieux qui continuent de réessayer. Un 410 ("disparu définitivement") les fait arrêter.
+1
View File
@@ -3,3 +3,4 @@
declare(strict_types=1);
define('BASE_PATH', __DIR__);
define('DATA_PATH', BASE_PATH . '/data');
+17 -1
View File
@@ -3,6 +3,10 @@ DirectoryIndex index.php
RewriteEngine On
# Paramètres DokuWiki (?do=media, ?do=export_pdf, etc.) — 410 Gone, jamais de contenu ici
RewriteCond %{QUERY_STRING} (^|&)do= [NC]
RewriteRule ^ - [R=410,L]
# Fichiers et répertoires réels servis directement
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
@@ -11,6 +15,12 @@ RewriteRule ^ - [L]
# URL propre pour les articles : /post/<slug>
RewriteRule ^post/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=view&slug=$1 [L,QSA]
# Catalogue de tous les livres : /books
RewriteRule ^books/?$ /index.php?action=books_list [L,QSA]
# Livres : /book/<slug>
RewriteRule ^book/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=book&book_slug=$1 [L,QSA]
# Filtre par catégorie : /categorie/<nom>
RewriteRule ^categorie/(.+?)/?$ /index.php?cat=$1 [L,QSA,B]
@@ -19,9 +29,13 @@ RewriteRule ^page/([0-9a-f-]{36})/?$ /index.php?cursor=$1 [L,QSA]
# Édition / création
RewriteRule ^edit/([0-9a-f-]{36})/tags/(.+?)/?$ /index.php?action=edit_tags&uuid=$1&tag_type=$2 [L,QSA,B]
RewriteRule ^edit/([0-9a-f-]{36})/discard/?$ /index.php?action=edit_discard_draft&uuid=$1 [L,QSA]
RewriteRule ^edit/([0-9a-f-]{36})/([1-6])/?$ /index.php?action=edit&uuid=$1&step=$2 [L,QSA]
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]
@@ -31,8 +45,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]
@@ -47,6 +62,7 @@ RewriteRule ^verify-comment/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-
RewriteRule ^categories/?$ /index.php?action=categories [L,QSA]
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
RewriteRule ^search/?$ /index.php?action=search [L,QSA]
RewriteRule ^tendances/?$ /tendances.php [L,QSA]
RewriteRule ^flux/?$ /index.php?action=flux [L,QSA]
RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
+264
View File
@@ -1266,6 +1266,40 @@ footer.mt-5 { margin-top: 0 !important; }
letter-spacing: .01em;
}
/* ─── Densité d'affichage L / M / S ──────── */
main { transition: max-width .22s ease; }
/* Widget fixe haut-droite */
.density-widget {
position: fixed;
top: 3.6rem;
right: 1rem;
z-index: 1010;
display: flex;
gap: 2px;
background: var(--vl-surface);
border: 1px solid var(--vl-border);
border-radius: 6px;
padding: 3px;
box-shadow: var(--vl-shadow-sm);
}
.density-btn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
color: var(--vl-muted);
cursor: pointer;
font-size: .68rem;
font-weight: 700;
letter-spacing: .06em;
line-height: 1;
padding: 4px 8px;
transition: background .15s, color .15s, border-color .15s;
}
.density-btn:hover { background: rgba(0,0,0,.06); color: var(--vl-text); }
.density-btn.active { background: var(--vl-text); color: var(--vl-bg); }
@media (max-width: 576px) { .density-widget { display: none; } }
/* ─── Page de recherche ───────────────────── */
.search-page { max-width: 780px; margin: 0 auto; }
@@ -1807,3 +1841,233 @@ footer.mt-5 { margin-top: 0 !important; }
color: var(--vl-muted);
margin-top: 0.15rem;
}
/* ─── Livres ─────────────────────────────────────────────────────── */
/* Grille catalogue /books + section accueil */
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.book-home-card {
display: flex;
flex-direction: column;
border-radius: var(--vl-radius);
overflow: hidden;
text-decoration: none;
color: inherit;
background: var(--vl-card-bg, var(--bs-body-bg));
box-shadow: var(--vl-shadow-sm, 0 1px 3px rgba(0,0,0,.08));
transition: transform .15s, box-shadow .15s;
}
.book-home-card:hover {
transform: translateY(-2px);
box-shadow: var(--vl-shadow-md, 0 4px 12px rgba(0,0,0,.14));
color: inherit;
}
.book-home-card-cover {
height: 120px;
background-size: cover;
background-position: center;
flex-shrink: 0;
}
.book-home-card-body {
padding: .7rem;
display: flex;
flex-direction: column;
gap: .2rem;
flex: 1;
}
.book-home-card-title {
font-weight: 600;
font-size: .9rem;
line-height: 1.3;
}
.book-home-card-desc {
font-size: .78rem;
color: var(--vl-muted);
line-height: 1.4;
}
.book-home-card-meta {
font-size: .72rem;
color: var(--vl-muted);
margin-top: auto;
padding-top: .3rem;
}
.home-section-more {
font-size: .75rem;
font-weight: 400;
margin-left: .5rem;
color: var(--vl-accent);
text-decoration: none;
letter-spacing: 0;
text-transform: none;
}
.home-section-more:hover { text-decoration: underline; }
/* Bandeau dans un article appartenant à un livre */
.book-article-banner {
border-radius: var(--vl-radius);
background: var(--vl-accent-soft);
border: 1px solid rgba(79,70,229,.18);
overflow: hidden;
}
.book-article-banner-link {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1rem;
text-decoration: none;
color: var(--vl-accent);
transition: background 0.15s;
}
.book-article-banner-link:hover {
background: rgba(79,70,229,.08);
color: var(--vl-accent-dark);
}
.book-article-banner-icon { font-size: 1.1rem; flex-shrink: 0; }
.book-article-banner-text { flex: 1; font-size: 0.875rem; }
.book-article-banner-cta { font-size: 0.8rem; opacity: .75; white-space: nowrap; }
/* Navigation précédent/suivant en bas d'article */
.book-chapter-nav {
border-top: 1px solid var(--vl-border);
margin-top: 1.5rem;
padding-top: 1rem;
}
.book-chapter-nav-inner {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.book-nav-btn {
flex: 1;
display: flex;
flex-direction: column;
padding: 0.65rem 0.875rem;
background: var(--vl-surface);
border: 1px solid var(--vl-border);
border-radius: var(--vl-radius);
text-decoration: none;
color: var(--vl-text);
transition: border-color 0.15s, box-shadow 0.15s;
min-width: 0;
}
.book-nav-btn:hover {
border-color: var(--vl-accent);
box-shadow: var(--vl-shadow-sm);
color: var(--vl-text);
}
.book-nav-btn--disabled {
opacity: .45;
cursor: default;
pointer-events: none;
}
.book-nav-btn--next { text-align: right; }
.book-nav-dir {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--vl-muted);
display: block;
}
.book-nav-title {
font-size: 0.875rem;
font-weight: 500;
display: block;
margin-top: 0.15rem;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.book-nav-toc {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem;
border: 1px solid var(--vl-border);
border-radius: var(--vl-radius);
color: var(--vl-muted);
text-decoration: none;
font-size: 1.1rem;
transition: border-color 0.15s, color 0.15s;
flex-shrink: 0;
}
.book-nav-toc:hover {
border-color: var(--vl-accent);
color: var(--vl-accent);
}
/* Page sommaire d'un livre (/book/<slug>) */
.book-page { max-width: 720px; margin: 0 auto; padding: 2rem 0; }
.book-label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--vl-accent);
}
.book-chapters {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.book-chapter-link {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.75rem 1rem;
background: var(--vl-surface);
border: 1px solid var(--vl-border);
border-radius: var(--vl-radius);
text-decoration: none;
color: var(--vl-text);
transition: border-color 0.15s, box-shadow 0.15s;
}
.book-chapter-link:hover {
border-color: var(--vl-accent);
box-shadow: var(--vl-shadow-sm);
color: var(--vl-text);
}
.book-chapter-num {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--vl-accent-soft);
color: var(--vl-accent);
border-radius: 50%;
font-size: 0.8rem;
font-weight: 700;
flex-shrink: 0;
}
.book-chapter-thumb {
width: 56px;
height: 44px;
border-radius: 6px;
flex-shrink: 0;
background: var(--vl-accent-soft);
background-size: cover;
background-position: center;
}
.book-chapter-body { flex: 1; min-width: 0; }
.book-chapter-title {
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.3;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.book-chapter-meta {
font-size: 0.78rem;
color: var(--vl-muted);
margin-top: 0.15rem;
}
+727
View File
@@ -0,0 +1,727 @@
/* Admin stats : graphiques, sparklines, accordéon pays/AS/IP, agents */
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function trunc(s, n) {
return s.length > n ? s.slice(0, n) + '…' : s;
}
// Détection de bot par correspondance partielle insensible à la casse
var _botPatterns = (typeof FOLIO_BOT_PATTERNS !== 'undefined') ? FOLIO_BOT_PATTERNS : [];
function isBot(ua) {
if (!ua) { return false; }
var lo = ua.toLowerCase();
for (var i = 0; i < _botPatterns.length; i++) {
if (lo.indexOf(_botPatterns[i].toLowerCase()) !== -1) { return true; }
}
return false;
}
function botBadge(ua) {
return isBot(ua) ? '<span title="Bot connu" style="font-size:.85rem">🤖</span> ' : '';
}
// AS exclus (modifié dynamiquement par les boutons)
var _excludedAs = (typeof FOLIO_EXCLUDED_AS !== 'undefined') ? FOLIO_EXCLUDED_AS.slice() : [];
var _csrf = (typeof FOLIO_CSRF !== 'undefined') ? FOLIO_CSRF : '';
// ── Résumé visiteurs ─────────────────────────────────────────────────────────
(function () {
var el = document.getElementById('stats-summary-container');
var uv = (typeof FOLIO_UNIQUE_VISITORS !== 'undefined') ? FOLIO_UNIQUE_VISITORS : {};
var ipd = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
if (!el) { return; }
function computeCounts() {
var base = { 7: uv[7] || 0, 14: uv[14] || 0, 30: uv[30] || 0 };
// Soustraire les IPs des AS exclus (top 200 uniquement — approximation)
Object.keys(ipd).forEach(function (ip) {
var d = ipd[ip];
if (!d.asn || _excludedAs.indexOf(d.asn) === -1) { return; }
var daily = d.daily || [];
var n = daily.length;
if (daily.some(function (v) { return v > 0; })) { base[30] = Math.max(0, base[30] - 1); }
if (daily.slice(Math.max(0, n - 14)).some(function (v) { return v > 0; })) { base[14] = Math.max(0, base[14] - 1); }
if (daily.slice(Math.max(0, n - 7)).some(function (v) { return v > 0; })) { base[7] = Math.max(0, base[7] - 1); }
});
return base;
}
function render() {
var c = computeCounts();
el.innerHTML =
'<div class="card mb-4">'
+ '<div class="card-body py-2 px-3">'
+ '<div class="d-flex flex-wrap gap-4 align-items-center">'
+ '<span class="small fw-semibold text-muted">Visiteurs uniques non-bot</span>'
+ '<span class="d-flex flex-column align-items-center"><span class="fs-5 fw-bold">' + c[7].toLocaleString('fr-FR') + '</span><span class="text-muted" style="font-size:.7rem">7 jours</span></span>'
+ '<span class="d-flex flex-column align-items-center"><span class="fs-5 fw-bold">' + c[14].toLocaleString('fr-FR') + '</span><span class="text-muted" style="font-size:.7rem">14 jours</span></span>'
+ '<span class="d-flex flex-column align-items-center"><span class="fs-5 fw-bold">' + c[30].toLocaleString('fr-FR') + '</span><span class="text-muted" style="font-size:.7rem">30 jours</span></span>'
+ (_excludedAs.length ? '<span class="badge bg-warning text-dark" style="font-size:.65rem">' + _excludedAs.length + ' AS exclu(s)</span>' : '')
+ '</div>'
+ '</div>'
+ '</div>';
}
render();
document.addEventListener('folio:excluded-as-changed', render);
}());
// ── Visiteurs par pays ────────────────────────────────────────────────────────
(function () {
var el = document.getElementById('stats-country-container');
var asList = (typeof FOLIO_AS_LIST !== 'undefined') ? FOLIO_AS_LIST : [];
var ipData = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
if (!el || !asList.length) { return; }
var _countryClickHandler = null;
var dispNames = null;
try { dispNames = new Intl.DisplayNames(['fr'], { type: 'region' }); } catch (e) {}
function countryName(code) {
if (!code || code === '??') { return 'Inconnu'; }
try { return dispNames ? dispNames.of(code) : code; } catch (e) { return code; }
}
function flag(code) {
if (!code || code.length !== 2) { return ''; }
var cp = Array.from(code.toUpperCase()).map(function (c) { return 0x1F1E6 + c.charCodeAt(0) - 65; });
return String.fromCodePoint(cp[0], cp[1]) + ' ';
}
// Index IPs par ASN
var ipsByAsn = {};
Object.keys(ipData).forEach(function (ip) {
var d = ipData[ip];
var key = d.asn || '__unknown__';
if (!ipsByAsn[key]) { ipsByAsn[key] = []; }
ipsByAsn[key].push({ ip: ip, hits: d.hits, daily: d.daily, paths: d.paths, agents: d.agents || [] });
});
Object.keys(ipsByAsn).forEach(function (k) {
ipsByAsn[k].sort(function (a, b) { return b.hits - a.hits; });
});
// IPs sans AS
var noAsCount = Object.keys(ipData).filter(function (ip) { return !ipData[ip].asn; }).length;
function ipSparkline(daily) {
if (!daily || !daily.length) { return ''; }
var W = 80, H = 20, padX = 1, padY = 2;
var max = Math.max.apply(null, daily) || 1;
var n = daily.length;
var pts = daily.map(function (v, i) {
var x = padX + i * (W - 2 * padX) / (n - 1);
var y = H - padY - (v / max) * (H - 2 * padY);
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H
+ '" style="display:block;flex-shrink:0">'
+ '<polyline points="' + pts + '" fill="none" stroke="#6c757d"'
+ ' stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>'
+ '</svg>';
}
function excludeAs(asn, name) {
var fd = new FormData();
fd.append('_csrf', _csrf);
fd.append('asn', asn);
fetch('/?action=admin_add_excluded_as', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.ok && _excludedAs.indexOf(asn) === -1) {
_excludedAs.push(asn);
document.dispatchEvent(new CustomEvent('folio:excluded-as-changed'));
renderCountry();
}
});
}
function includeAs(asn) {
var fd = new FormData();
fd.append('_csrf', _csrf);
fd.append('asn', asn);
fetch('/?action=admin_remove_excluded_as', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.ok) {
_excludedAs = _excludedAs.filter(function (a) { return a !== asn; });
document.dispatchEvent(new CustomEvent('folio:excluded-as-changed'));
renderCountry();
}
});
}
function buildIpRow(ipInfo) {
// Agents sous l'IP avec badge bot (UA en entier)
var agentsHtml = '';
(ipInfo.agents || []).forEach(function (ua) {
agentsHtml += '<div style="font-size:.65rem;color:#adb5bd;line-height:1.4;word-break:break-all">'
+ botBadge(ua) + esc(ua) + '</div>';
});
// Chemins triés : /post/ et /book/ avec ts, reste sans ts
var postBook = [], other = [];
Object.keys(ipInfo.paths || {}).forEach(function (path) {
var p = ipInfo.paths[path];
var cnt = (p && typeof p === 'object') ? p.n : p;
var ts = (p && typeof p === 'object') ? p.ts : 0;
if (ts > 0) { postBook.push({ path: path, cnt: cnt, ts: ts }); }
else { other.push({ path: path, cnt: cnt }); }
});
postBook.sort(function (a, b) { return b.ts - a.ts; });
other.sort(function (a, b) { return b.cnt - a.cnt; });
function pathLine(p, prefix) {
var raw = p.path.replace(prefix, '');
var slug = '';
try { slug = decodeURIComponent(raw); } catch (e) { slug = raw; }
return '<div style="font-size:.75rem;line-height:1.5">'
+ '<a href="' + esc(p.path) + '" target="_blank" style="color:#495057">'
+ esc(trunc(slug || p.path, 40)) + '</a>'
+ ' <span style="color:#adb5bd">(' + p.cnt + ')</span></div>';
}
function otherLine(p) {
return '<div style="font-size:.72rem;color:#868e96;line-height:1.4">'
+ '<code style="font-size:.72rem;color:#868e96">' + esc(trunc(p.path, 44)) + '</code>'
+ ' <span style="color:#adb5bd">(' + p.cnt + ')</span></div>';
}
var pathsHtml = '';
var articles = postBook.filter(function (p) { return p.path.indexOf('/post/') === 0; });
var books = postBook.filter(function (p) { return p.path.indexOf('/book/') === 0; });
if (articles.length) {
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Articles</div>'
+ articles.map(function (p) { return pathLine(p, '/post/'); }).join('');
}
if (books.length) {
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Livres</div>'
+ books.map(function (p) { return pathLine(p, '/book/'); }).join('');
}
if (other.length) {
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Autres chemins</div>'
+ other.map(otherLine).join('');
}
if (!pathsHtml) { pathsHtml = '<span style="font-size:.75rem;color:#adb5bd">—</span>'; }
return '<div class="d-flex gap-2 py-2 border-bottom align-items-start">'
+ '<div style="width:9rem;flex-shrink:0">'
+ '<code style="font-size:.72rem;color:#6c757d">' + esc(ipInfo.ip) + '</code>'
+ agentsHtml
+ '</div>'
+ '<div style="flex-shrink:0;padding-top:2px">' + ipSparkline(ipInfo.daily || []) + '</div>'
+ '<div class="flex-grow-1">' + pathsHtml + '</div>'
+ '<div class="text-end text-muted small" style="width:4rem;flex-shrink:0;padding-top:2px">'
+ (ipInfo.hits || 0).toLocaleString('fr-FR') + '</div>'
+ '</div>';
}
function renderCountry() {
// Filtrer les AS actifs / exclus
var activeLists = asList.filter(function (as) { return _excludedAs.indexOf(as.asn) === -1; });
var excludedLists = asList.filter(function (as) { return _excludedAs.indexOf(as.asn) !== -1; });
// Agréger par pays (AS actifs uniquement)
var byCountry = {}, asByCountry = {};
activeLists.forEach(function (as) {
var c = as.country || '??';
byCountry[c] = (byCountry[c] || 0) + as.hits;
if (!asByCountry[c]) { asByCountry[c] = []; }
asByCountry[c].push(as);
});
var countries = Object.keys(byCountry).map(function (c) {
return { code: c, hits: byCountry[c], networks: asByCountry[c] };
}).sort(function (a, b) { return b.hits - a.hits; }).slice(0, 20);
// En-tête avec alerte IPs sans AS
var headerExtra = '';
if (noAsCount > 0) {
headerExtra = '<span class="badge bg-warning text-dark ms-2" style="font-size:.65rem" '
+ 'title="' + noAsCount + ' IP(s) parmi le top 200 sans résolution AS">'
+ noAsCount + ' IP(s) sans AS</span>';
}
var countryCard = document.querySelector('#stats-country-container');
if (countryCard) {
var hdr = countryCard.closest('.card')
? countryCard.closest('.card').querySelector('.card-header')
: null;
if (hdr && !hdr.querySelector('.no-as-badge')) {
var span = document.createElement('span');
span.className = 'no-as-badge';
span.innerHTML = headerExtra;
hdr.appendChild(span);
}
}
if (!countries.length) { el.innerHTML = '<p class="text-muted mb-0">Aucune donnée.</p>'; return; }
var maxH = countries[0].hits || 1;
var html = '<div class="accordion accordion-flush" id="acc-countries">';
countries.forEach(function (c, ci) {
var pct = Math.round(c.hits / maxH * 100);
var cname = flag(c.code) + countryName(c.code);
var vis = c.hits.toLocaleString('fr-FR');
var accId = 'acc-country-' + ci;
var nets = c.networks.slice().sort(function (a, b) { return b.hits - a.hits; });
var maxN = nets[0] ? nets[0].hits : 1;
var netRows = nets.map(function (n, ni) {
var npct = Math.round(n.hits / maxN * 100);
var asId = 'acc-as-' + ci + '-' + ni;
var asnKey = n.asn || '__unknown__';
var ips = ipsByAsn[asnKey] || [];
var ipRows = ips.slice(0, 20).map(buildIpRow).join('');
var hasIps = ips.length > 0;
var toggleAttrs = hasIps ? ' data-bs-toggle="collapse" data-bs-target="#' + asId + '" role="button"' : '';
var chevron = hasIps ? '<span class="text-muted ms-1" style="font-size:.65rem">▾</span>' : '';
var excludeBtn = n.asn
? '<button class="btn btn-sm py-0 px-1 text-muted border-0 exclude-as-btn" style="font-size:.65rem;flex-shrink:0" title="Exclure cet AS des stats" data-asn="' + esc(n.asn) + '" data-name="' + esc(n.name || '') + '">✕</button>'
: '';
return '<div>'
+ '<div class="d-flex align-items-center gap-1 py-1"' + toggleAttrs + '>'
+ '<div class="small d-flex align-items-center" style="min-width:0;flex:1 1 9rem;overflow:hidden">'
+ '<span class="text-truncate">' + esc(n.name || '?') + '</span>'
+ (n.asn ? '&nbsp;<span class="text-muted text-nowrap">AS' + esc(n.asn) + '</span>' : '')
+ chevron
+ '</div>'
+ excludeBtn
+ '<div class="flex-grow-1"><div class="progress" style="height:4px">'
+ '<div class="progress-bar bg-info" style="width:' + npct + '%"></div>'
+ '</div></div>'
+ '<div class="text-end text-muted small" style="width:4rem;flex-shrink:0">'
+ n.hits.toLocaleString('fr-FR') + '</div>'
+ '</div>'
+ (hasIps ? '<div id="' + asId + '" class="collapse">'
+ '<div class="border-start ms-3 ps-2 pb-1">' + ipRows + '</div>'
+ '</div>' : '')
+ '</div>';
}).join('');
html += '<div class="accordion-item border-0">'
+ '<div class="d-flex align-items-center gap-2 py-2 px-0" data-bs-toggle="collapse"'
+ ' data-bs-target="#' + accId + '" role="button" aria-expanded="false">'
+ '<div class="fw-medium" style="width:10rem;flex-shrink:0">' + cname + '</div>'
+ '<div class="flex-grow-1"><div class="progress" style="height:6px">'
+ '<div class="progress-bar" style="width:' + pct + '%"></div>'
+ '</div></div>'
+ '<div class="text-end fw-semibold" style="width:5rem;flex-shrink:0">'
+ vis + ' <span class="text-muted fw-normal small">vis.</span></div>'
+ '<span class="text-muted" style="width:1rem;flex-shrink:0;font-size:.7rem">▾</span>'
+ '</div>'
+ '<div id="' + accId + '" class="collapse">'
+ '<div class="ps-2 pb-2 border-start ms-3">' + netRows + '</div>'
+ '</div>'
+ '</div>';
});
html += '</div>';
// Section AS exclus
if (excludedLists.length) {
html += '<div class="mt-3 pt-2 border-top">'
+ '<div class="small fw-semibold text-muted mb-2">AS exclus des stats (' + excludedLists.length + ')</div>'
+ '<div class="d-flex flex-wrap gap-2">';
excludedLists.forEach(function (n) {
html += '<span class="badge border text-muted fw-normal d-inline-flex align-items-center gap-1" style="font-size:.7rem">'
+ esc(n.name || '?')
+ (n.asn ? ' <span class="text-muted">AS' + esc(n.asn) + '</span>' : '')
+ '<button class="btn btn-sm p-0 ms-1 border-0 include-as-btn" data-asn="' + esc(n.asn || '') + '" title="Inclure" style="line-height:1;color:inherit;background:none">↺</button>'
+ '</span>';
});
html += '</div></div>';
}
el.innerHTML = html;
// Délégation : boutons exclure / inclure (handler unique pour éviter les doublons)
if (_countryClickHandler) { el.removeEventListener('click', _countryClickHandler); }
_countryClickHandler = function (e) {
var btn = e.target.closest('.exclude-as-btn');
if (btn) { e.stopPropagation(); excludeAs(btn.getAttribute('data-asn'), btn.getAttribute('data-name')); return; }
btn = e.target.closest('.include-as-btn');
if (btn) { e.stopPropagation(); includeAs(btn.getAttribute('data-asn')); }
};
el.addEventListener('click', _countryClickHandler);
}
renderCountry();
document.addEventListener('folio:excluded-as-changed', renderCountry);
}());
// ── Liste consolidée de tous les agents ──────────────────────────────────────
(function () {
var el = document.getElementById('stats-agents-container');
var badge = document.getElementById('agents-count');
var allUas = (typeof FOLIO_ALL_UAS !== 'undefined') ? FOLIO_ALL_UAS : {};
var csrf = (typeof FOLIO_CSRF !== 'undefined') ? FOLIO_CSRF : '';
if (!el) { return; }
var agents = Object.keys(allUas).map(function (ua) {
return { ua: ua, hits: allUas[ua], bot: isBot(ua) };
}).sort(function (a, b) {
if (a.bot !== b.bot) { return a.bot ? -1 : 1; }
return b.hits - a.hits;
});
if (!agents.length) {
el.innerHTML = '<p class="text-muted p-3 mb-0">Aucun agent détecté.</p>';
return;
}
var bots = agents.filter(function (a) { return a.bot; });
var unknown = agents.filter(function (a) { return !a.bot; });
if (badge) { badge.textContent = '— ' + bots.length + ' bot(s) sur ' + agents.length; }
function addBot(ua, btn) {
btn.disabled = true;
var fd = new FormData();
fd.append('_csrf', csrf);
fd.append('pattern', ua);
fetch('/?action=admin_add_bot', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.ok) {
_botPatterns.push(ua);
btn.closest('tr').querySelector('td:first-child').innerHTML = '<span title="Bot">🤖</span>';
btn.remove();
} else {
btn.disabled = false;
}
})
.catch(function () { btn.disabled = false; });
}
function agentRow(a) {
var addBtn = (!a.bot)
? '<button class="btn btn-outline-secondary btn-sm py-0 px-1 ms-2 add-bot-btn"'
+ ' style="font-size:.65rem;white-space:nowrap" title="Ajouter aux bots">+ bot</button>'
: '';
return '<tr>'
+ '<td class="ps-3" style="width:1.5rem;vertical-align:top;padding-top:6px">'
+ (a.bot ? '<span title="Bot">🤖</span>' : '<span class="text-muted" title="Inconnu">?</span>') + '</td>'
+ '<td style="word-break:break-all;vertical-align:top">'
+ '<code style="font-size:.72rem">' + esc(a.ua) + '</code>'
+ addBtn + '</td>'
+ '<td class="text-end text-muted small pe-3" style="width:5rem;vertical-align:top;white-space:nowrap">'
+ a.hits.toLocaleString('fr-FR') + '</td>'
+ '</tr>';
}
var botsHtml = bots.map(agentRow).join('');
var unknownHtml = unknown.map(agentRow).join('');
var html = '<div class="table-responsive">'
+ '<table class="table table-sm table-hover mb-0 small">'
+ '<thead class="table-light"><tr>'
+ '<th class="ps-3" style="width:1.5rem"></th>'
+ '<th>User-Agent</th>'
+ '<th class="text-end pe-3" style="width:5rem">Req.</th>'
+ '</tr></thead>'
+ '<tbody>';
if (botsHtml) {
html += '<tr class="table-light"><td colspan="3" class="ps-3 py-1">'
+ '<small class="fw-semibold text-muted">Bots connus (' + bots.length + ')</small></td></tr>'
+ botsHtml;
}
if (unknownHtml) {
html += '<tr class="table-light"><td colspan="3" class="ps-3 py-1">'
+ '<small class="fw-semibold text-muted">Agents non classés (' + unknown.length + ')</small></td></tr>'
+ unknownHtml;
}
html += '</tbody></table></div>';
el.innerHTML = html;
// Délégation : boutons "+ bot"
el.addEventListener('click', function (e) {
var btn = e.target.closest('.add-bot-btn');
if (!btn) { return; }
var row = btn.closest('tr');
var code = row ? row.querySelector('code') : null;
if (code) { addBot(code.textContent, btn); }
});
}());
// ── Pages les plus visitées (RSS XML + sparklines) ───────────────────────────
(function () {
var container = document.getElementById('stats-pages-container');
var badge = document.getElementById('stats-pages-count');
var pagesByDay = (typeof FOLIO_PAGES_BY_DAY !== 'undefined') ? FOLIO_PAGES_BY_DAY : {};
var ipData = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
if (!container) { return; }
function sparkline(data) {
var W = 120, H = 28, padX = 2, padY = 3;
var max = Math.max.apply(null, data) || 1;
var n = data.length;
var pts = data.map(function (v, i) {
var x = padX + i * (W - 2 * padX) / (n - 1);
var y = H - padY - (v / max) * (H - 2 * padY);
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
var first = padX.toFixed(1) + ',' + (H - padY).toFixed(1);
var last = (W - padX).toFixed(1) + ',' + (H - padY).toFixed(1);
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H + '" style="display:block;overflow:visible">'
+ '<defs><linearGradient id="spk-grad" x1="0" y1="0" x2="0" y2="1">'
+ '<stop offset="0%" stop-color="var(--bs-primary,#0d6efd)" stop-opacity="0.18"/>'
+ '<stop offset="100%" stop-color="var(--bs-primary,#0d6efd)" stop-opacity="0"/>'
+ '</linearGradient></defs>'
+ '<polygon points="' + first + ' ' + pts + ' ' + last + '" fill="url(#spk-grad)"/>'
+ '<polyline points="' + pts + '" fill="none" stroke="var(--bs-primary,#0d6efd)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>'
+ '</svg>';
}
function trendChart(totals) {
var trendEl = document.getElementById('stats-trend-container');
if (!trendEl || !totals.length) { return; }
var n = totals.length;
var VW = 900, VH = 480;
var ml = 44, mr = 12, mt = 12, mb = 28;
var W = VW - ml - mr;
var H = VH - mt - mb;
var rawMax = Math.max.apply(null, totals) || 1;
var mag = Math.pow(10, Math.floor(Math.log(rawMax) / Math.LN10));
var maxV = Math.ceil(rawMax / mag) * mag;
var nTicks = 4;
var now = new Date();
var labels = totals.map(function (_, i) {
var d = new Date(now);
d.setDate(d.getDate() - (n - 1 - i));
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
});
var pts = totals.map(function (v, i) {
return { x: ml + i * W / (n - 1), y: mt + H - (v / maxV) * H, v: v, l: labels[i] };
});
function smoothPath(points) {
var d = 'M ' + points[0].x.toFixed(1) + ' ' + points[0].y.toFixed(1);
for (var i = 0; i < points.length - 1; i++) {
var p0 = points[i > 0 ? i - 1 : i];
var p1 = points[i];
var p2 = points[i + 1];
var p3 = points[i + 2 < points.length ? i + 2 : i + 1];
var t = 0.35;
var cp1x = p1.x + t * (p2.x - p0.x) / 2;
var cp1y = p1.y + t * (p2.y - p0.y) / 2;
var cp2x = p2.x - t * (p3.x - p1.x) / 2;
var cp2y = p2.y - t * (p3.y - p1.y) / 2;
d += ' C ' + cp1x.toFixed(1) + ' ' + cp1y.toFixed(1)
+ ', ' + cp2x.toFixed(1) + ' ' + cp2y.toFixed(1)
+ ', ' + p2.x.toFixed(1) + ' ' + p2.y.toFixed(1);
}
return d;
}
var linePath = smoothPath(pts);
var areaPath = linePath
+ ' L ' + pts[n - 1].x.toFixed(1) + ' ' + (mt + H)
+ ' L ' + pts[0].x.toFixed(1) + ' ' + (mt + H) + ' Z';
var grid = '', yLabels = '';
for (var t = 0; t <= nTicks; t++) {
var val = Math.round(maxV * t / nTicks);
var gy = (mt + H - (val / maxV) * H).toFixed(1);
grid += '<line x1="' + ml + '" y1="' + gy + '" x2="' + (VW - mr) + '" y2="' + gy
+ '" stroke="#e9ecef" stroke-width="1"/>';
yLabels += '<text x="' + (ml - 6) + '" y="' + gy + '" text-anchor="end" dominant-baseline="middle"'
+ ' font-size="11" fill="#adb5bd">' + val + '</text>';
}
var xLabels = '';
pts.forEach(function (p, i) {
if (i % 3 === 0 || i === n - 1) {
xLabels += '<text x="' + p.x.toFixed(1) + '" y="' + (VH - 4) + '" text-anchor="middle"'
+ ' font-size="11" fill="#adb5bd">' + p.l + '</text>';
}
});
var dots = pts.map(function (p) {
return '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="14"'
+ ' fill="transparent" cursor="default">'
+ '<title>' + esc(p.l) + ' : ' + p.v + ' visiteur(s)</title>'
+ '</circle>'
+ '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="3"'
+ ' fill="var(--bs-primary,#0d6efd)" stroke="#fff" stroke-width="1.5" pointer-events="none"/>';
}).join('');
trendEl.innerHTML =
'<p class="small text-muted mb-2 fw-semibold">Visiteurs uniques / jour — 30 derniers jours <span class="fw-normal opacity-50">(top 200 IPs)</span></p>'
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + VW + ' ' + VH + '"'
+ ' style="width:100%;height:480px;display:block;overflow:visible">'
+ '<defs>'
+ '<linearGradient id="area-grad" x1="0" y1="0" x2="0" y2="1">'
+ '<stop offset="0%" stop-color="var(--bs-primary,#0d6efd)" stop-opacity="0.2"/>'
+ '<stop offset="100%" stop-color="var(--bs-primary,#0d6efd)" stop-opacity="0"/>'
+ '</linearGradient>'
+ '</defs>'
+ grid
+ '<path d="' + areaPath + '" fill="url(#area-grad)"/>'
+ '<path d="' + linePath + '" fill="none" stroke="var(--bs-primary,#0d6efd)"'
+ ' stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>'
+ dots + yLabels + xLabels
+ '</svg>';
}
function multiLineChart(pagesByDay, rssRows) {
var el = document.getElementById('stats-multiline-container');
if (!el) { return; }
var COLORS = ['#0d6efd','#198754','#dc3545','#fd7e14','#6f42c1',
'#20c997','#0dcaf0','#e63946','#f4a261','#457b9d'];
var VW = 900, VH = 480;
var ml = 44, mr = 12, mt = 12, mb = 28;
var W = VW - ml - mr;
var H = VH - mt - mb;
var series = [];
rssRows.forEach(function (row) {
var pm = row.link.match(/\/post\/[^?#]*/);
var data = pm ? (pagesByDay[pm[0]] || null) : null;
if (data && series.length < 10) {
series.push({ title: row.title || row.slug, data: data });
}
});
if (!series.length) { return; }
var n = series[0].data.length;
var allVals = series.reduce(function (acc, s) { return acc.concat(s.data); }, []);
var rawMax = Math.max.apply(null, allVals) || 1;
var mag = Math.pow(10, Math.floor(Math.log(rawMax) / Math.LN10));
var maxV = Math.ceil(rawMax / mag) * mag;
var nTicks = 4;
var now = new Date();
var labels = series[0].data.map(function (_, i) {
var d = new Date(now);
d.setDate(d.getDate() - (n - 1 - i));
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
});
function smoothPath(pts) {
var d = 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
for (var i = 0; i < pts.length - 1; i++) {
var p0 = pts[i > 0 ? i - 1 : i];
var p1 = pts[i], p2 = pts[i + 1];
var p3 = pts[i + 2 < pts.length ? i + 2 : i + 1];
var t = 0.35;
var cp1x = p1.x + t * (p2.x - p0.x) / 2, cp1y = p1.y + t * (p2.y - p0.y) / 2;
var cp2x = p2.x - t * (p3.x - p1.x) / 2, cp2y = p2.y - t * (p3.y - p1.y) / 2;
d += ' C ' + cp1x.toFixed(1) + ' ' + cp1y.toFixed(1)
+ ', ' + cp2x.toFixed(1) + ' ' + cp2y.toFixed(1)
+ ', ' + p2.x.toFixed(1) + ' ' + p2.y.toFixed(1);
}
return d;
}
var grid = '', yLabels = '';
for (var t = 0; t <= nTicks; t++) {
var val = Math.round(maxV * t / nTicks);
var gy = (mt + H - (val / maxV) * H).toFixed(1);
grid += '<line x1="' + ml + '" y1="' + gy + '" x2="' + (VW - mr) + '" y2="' + gy
+ '" stroke="#e9ecef" stroke-width="1"/>';
yLabels += '<text x="' + (ml - 6) + '" y="' + gy + '" text-anchor="end" dominant-baseline="middle"'
+ ' font-size="11" fill="#adb5bd">' + val + '</text>';
}
var xLabels = '';
labels.forEach(function (lbl, i) {
if (i % 3 === 0 || i === n - 1) {
var x = (ml + i * W / (n - 1)).toFixed(1);
xLabels += '<text x="' + x + '" y="' + (VH - 4) + '" text-anchor="middle"'
+ ' font-size="11" fill="#adb5bd">' + lbl + '</text>';
}
});
var lines = series.map(function (s, si) {
var color = COLORS[si % COLORS.length];
var pts = s.data.map(function (v, i) {
return { x: ml + i * W / (n - 1), y: mt + H - (v / maxV) * H, v: v, l: labels[i] };
});
var dots = pts.map(function (p) {
return '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="14"'
+ ' fill="transparent"><title>' + esc(p.l) + ' — ' + esc(s.title) + ' : ' + p.v + ' vis.</title></circle>'
+ '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="2.5"'
+ ' fill="' + color + '" stroke="#fff" stroke-width="1" pointer-events="none"/>';
}).join('');
return '<path d="' + smoothPath(pts) + '" fill="none" stroke="' + color
+ '" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>' + dots;
}).join('');
var legend = series.map(function (s, si) {
var color = COLORS[si % COLORS.length];
return '<span class="d-inline-flex align-items-center gap-1 me-3 mb-1 small">'
+ '<svg width="16" height="3" style="flex-shrink:0"><line x1="0" y1="1.5" x2="16" y2="1.5"'
+ ' stroke="' + color + '" stroke-width="2.5" stroke-linecap="round"/></svg>'
+ '<span class="text-truncate" style="max-width:160px" title="' + esc(s.title) + '">'
+ esc(trunc(s.title, 32)) + '</span></span>';
}).join('');
el.innerHTML =
'<p class="small text-muted mb-2 fw-semibold">Par article — 30 derniers jours</p>'
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + VW + ' ' + VH + '"'
+ ' style="width:100%;height:480px;display:block;overflow:visible">'
+ grid + lines + yLabels + xLabels + '</svg>'
+ '<div class="d-flex flex-wrap mt-2">' + legend + '</div>';
}
fetch('/trending?period=14d')
.then(function (r) { return r.ok ? r.text() : Promise.reject(); })
.then(function (xml) {
var doc = new DOMParser().parseFromString(xml, 'application/xml');
var items = Array.from(doc.querySelectorAll('item'));
if (!items.length) {
container.innerHTML = '<p class="text-muted p-3 mb-0">Aucune donnée.</p>';
return;
}
var rows = items.map(function (item) {
var raw = (item.querySelector('title') || { textContent: '' }).textContent;
var link = ((item.querySelector('link') || {}).textContent || '').trim();
var m = raw.match(/\((\d+)\s+visiteurs?\)$/);
var vis = m ? parseInt(m[1], 10) : 0;
var title = raw.replace(/\s*\(\d+\s+visiteurs?\)$/, '');
var slug = decodeURIComponent(link.replace(/.*\/post\//, ''));
var pm = link.match(/\/post\/[^?#]*/);
var daily = pm ? (pagesByDay[pm[0]] || null) : null;
return { title: title, link: link, slug: slug, vis: vis, daily: daily };
});
var nDays = Object.values(pagesByDay)[0] ? Object.values(pagesByDay)[0].length : 30;
// Visiteurs uniques par jour — compté sur les IPs du top 200 (approximation)
var dailyVisitors = new Array(nDays).fill(0);
Object.keys(ipData).forEach(function (ip) {
var daily = ipData[ip].daily || [];
daily.forEach(function (v, i) { if (i < nDays && v > 0) { dailyVisitors[i]++; } });
});
trendChart(dailyVisitors);
multiLineChart(pagesByDay, rows);
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0 small w-100"><tbody>';
rows.forEach(function (row, i) {
var vis = row.vis.toLocaleString('fr-FR');
var spk = row.daily ? sparkline(row.daily) : '';
html += '<tr>'
+ '<td class="text-muted ps-3" style="width:2rem;vertical-align:middle">' + (i + 1) + '</td>'
+ '<td style="vertical-align:middle"><a href="' + esc(row.link) + '" target="_blank"'
+ ' class="text-decoration-none" title="' + esc(row.slug) + '">'
+ esc(row.title || row.slug) + '</a></td>'
+ '<td style="width:130px;vertical-align:middle;padding:4px 8px">' + spk + '</td>'
+ '<td class="text-end fw-semibold pe-3" style="width:5rem;vertical-align:middle">'
+ vis + ' <span class="text-muted fw-normal">vis.</span></td>'
+ '</tr>';
});
html += '</tbody></table></div>';
if (badge) { badge.textContent = rows.length + ' URLs'; }
container.innerHTML = html;
})
.catch(function () {
container.innerHTML = '<p class="text-muted p-3 mb-0">Impossible de charger le flux.</p>';
});
}());
+83
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) {
@@ -45,4 +72,60 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
}
// Suppression groupée avec confirmation (remplace onclick inline)
var bulkDeleteBtn = document.getElementById('bulk-delete-btn');
if (bulkDeleteBtn) {
bulkDeleteBtn.addEventListener('click', function (e) {
var checked = document.querySelectorAll('.bulk-check:checked').length;
if (checked === 0) { e.preventDefault(); return; }
var msg = bulkDeleteBtn.getAttribute('data-confirm-bulk') || 'Confirmer ?';
if (!window.confirm(msg)) { e.preventDefault(); }
});
}
// Ajout d'un article à un livre (remplace onchange="bookAddArticle(this)")
var bookArticleSel = document.getElementById('book-article-select');
if (bookArticleSel) {
bookArticleSel.addEventListener('change', function () {
var slug = bookArticleSel.value;
if (!slug) { return; }
var ta = document.getElementById('book-articles-ta');
var lines = ta.value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
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()); }
});
}
});
+11
View File
@@ -0,0 +1,11 @@
/* Anti-FOUC densité — chargé tôt dans <head> pour appliquer max-width avant rendu de <main> */
(function () {
var d = localStorage.getItem('folio_density') || 'm';
if (d !== 'l') {
var mw = d === 'm' ? '980px' : '660px';
var s = document.createElement('style');
s.id = 'density-fouc';
s.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}';
document.head.appendChild(s);
}
}());
+38
View File
@@ -0,0 +1,38 @@
/* Sélecteur de densité L/M/S — persisté dans localStorage */
(function () {
var KEY = 'folio_density';
var cur = localStorage.getItem(KEY) || 'm';
function applyDensity(d) {
var fouc = document.getElementById('density-fouc');
if (d !== 'l') {
var mw = d === 'm' ? '980px' : '660px';
if (!fouc) {
fouc = document.createElement('style');
fouc.id = 'density-fouc';
document.head.appendChild(fouc);
}
fouc.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}';
} else {
if (fouc) { fouc.parentNode.removeChild(fouc); }
}
document.querySelectorAll('.density-btn').forEach(function (btn) {
btn.classList.toggle('active', btn.getAttribute('data-d') === d);
});
}
applyDensity(cur);
document.addEventListener('click', function (e) {
var el = e.target;
while (el && el !== document) {
if (el.classList && el.classList.contains('density-btn')) {
cur = el.getAttribute('data-d') || 'l';
try { localStorage.setItem(KEY, cur); } catch (ignore) {}
applyDensity(cur);
return;
}
el = el.parentNode;
}
});
}());
+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 () {});
});
}
}
}());
+51
View File
@@ -0,0 +1,51 @@
/* Chargement AJAX de la section "Meilleures audiences" via le flux RSS XML /trending?period=1h */
(function () {
var grid = document.getElementById('home-audiences-grid');
if (!grid) { return; }
var gradients = [
'linear-gradient(135deg,#667eea 0%,#764ba2 100%)',
'linear-gradient(135deg,#f093fb 0%,#f5576c 100%)',
'linear-gradient(135deg,#4facfe 0%,#00f2fe 100%)',
'linear-gradient(135deg,#43e97b 0%,#38f9d7 100%)',
'linear-gradient(135deg,#fa709a 0%,#fee140 100%)',
'linear-gradient(135deg,#a18cd1 0%,#fbc2eb 100%)'
];
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
fetch('/trending?period=1h')
.then(function (r) { return r.ok ? r.text() : Promise.reject(); })
.then(function (xml) {
var doc = new DOMParser().parseFromString(xml, 'application/xml');
var items = Array.from(doc.querySelectorAll('item')).slice(0, 6);
if (!items.length) { return; }
grid.innerHTML = items.map(function (item, i) {
var raw = (item.querySelector('title') || { textContent: '' }).textContent;
var title = raw.replace(/\s*\(\d+\s+visiteurs?\)$/, '');
var link = ((item.querySelector('link') || {}).textContent || '#').trim();
var pd = (item.querySelector('pubDate') || { textContent: '' }).textContent;
var date = '';
try { if (pd) { date = new Date(pd).toLocaleDateString('fr-FR'); } } catch (err) {}
var grad = gradients[i % gradients.length];
return '<article class="card">'
+ '<div class="card-cover" style="background:' + grad + '"></div>'
+ '<div class="card-body d-flex flex-column">'
+ '<h2 class="card-title"><a href="' + esc(link) + '">' + esc(title) + '</a></h2>'
+ '<div class="post-entry-meta mt-auto">'
+ (date ? '<span>' + esc(date) + '</span>' : '')
+ '<a href="' + esc(link) + '" class="post-entry-read">→ lire</a>'
+ '</div></div>'
+ '<a href="' + esc(link) + '" class="stretched-link"></a>'
+ '</article>';
}).join('');
var section = document.getElementById('home-audiences-section');
if (section) { section.hidden = false; }
})
.catch(function () {});
}());
+262
View File
@@ -0,0 +1,262 @@
// wizard.js — autosave, insertions, couleur catégorie, génération slug
document.addEventListener('DOMContentLoaded', function () {
var page = document.getElementById('vl-page');
var uuid = page ? page.dataset.uuid : '';
var autosaveUrl = page ? page.dataset.autosaveUrl : '';
// ─── Auto-resize textarea + scroll curseur ──────────────────────────────
var ta = document.getElementById('wz-content');
if (ta) {
function resizeTa() { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; }
ta.addEventListener('input', resizeTa);
resizeTa();
// Ctrl+Home / Ctrl+End : scroller la fenêtre vers le début/fin du textarea
ta.addEventListener('keydown', function (e) {
if (!(e.ctrlKey || e.metaKey) || (e.key !== 'Home' && e.key !== 'End')) return;
requestAnimationFrame(function () {
ta.scrollIntoView({ block: e.key === 'Home' ? 'start' : 'end', behavior: 'smooth' });
});
});
}
// ─── Ctrl+Enter soumet le formulaire ────────────────────────────────────
var form = document.querySelector('form[method="POST"]');
if (form) {
form.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { form.submit(); }
});
}
var slugField = document.getElementById('slug');
// ─── Autosave ────────────────────────────────────────────────────────────
var indicator = document.getElementById('autosave-indicator');
if (indicator && uuid && autosaveUrl) {
var timer = null;
function scheduleAutosave() {
clearTimeout(timer);
timer = setTimeout(doAutosave, 3000);
}
async function doAutosave() {
if (!ta) return;
indicator.textContent = 'Sauvegarde…';
try {
var res = await fetch(autosaveUrl, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ content: ta.value }),
});
var data = await res.json();
if (data.ok) {
indicator.textContent = 'Sauvegardé à ' + data.time;
} else {
indicator.textContent = 'Erreur de sauvegarde';
}
} catch (err) {
indicator.textContent = 'Erreur de sauvegarde';
}
}
if (ta) ta.addEventListener('input', scheduleAutosave);
}
// ─── Insertion Markdown depuis miniatures ────────────────────────────────
var insertUrl = page ? page.dataset.insertUrl : '';
document.querySelectorAll('[data-insert-ref]').forEach(function (el) {
el.addEventListener('click', function () {
if (!ta) return;
var ref = this.dataset.insertRef;
var isImage = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(ref);
var md = isImage ? '![](' + ref + ')' : '[' + ref + '](' + ref + ')';
var sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : '';
ta.value += sep + md;
ta.focus();
ta.selectionStart = ta.selectionEnd = ta.value.length;
ta.dispatchEvent(new Event('input'));
});
});
if (insertUrl) {
var isImg = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(insertUrl);
var name = decodeURIComponent(insertUrl.split('/').pop().split('?')[0]) || 'fichier';
var ref = isImg ? '![](' + insertUrl + ')' : '[' + name + '](' + insertUrl + ')';
if (ta) {
var sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : '';
ta.value += sep + ref;
ta.dispatchEvent(new Event('input'));
}
}
// ─── Copier référence Markdown (bouton MD dans la liste des fichiers) ────
document.querySelectorAll('[data-copy-md-name]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!ta) return;
var name = this.dataset.copyMdName;
var isImage = this.dataset.copyMdIsImage === '1';
var md = isImage ? '![](' + name + ')' : '[' + name + '](' + name + ')';
var sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : '';
ta.value += sep + md;
ta.focus();
ta.dispatchEvent(new Event('input'));
});
});
// ─── Aperçu couleur catégorie (étape 3) ─────────────────────────────────
var KNOWN_CATS = {
'actualité': 10, 'travaux': 35, 'scolaire': 55,
'linux': 120, 'domotique': 160, 'télécom': 190,
'blog': 220, 'informatique': 255, 'réflexion': 285,
'loisirs': 320, 'perso': 345,
};
var FREE_HUES = [87, 140, 205, 237, 302];
var catInput = document.getElementById('category');
var catSwatch = document.getElementById('cat-swatch');
var catHint = document.getElementById('cat-hint');
var catSwatches = document.getElementById('cat-free-swatches');
function catHue(name) {
var key = name.toLowerCase().trim();
if (KNOWN_CATS[key] !== undefined) return KNOWN_CATS[key];
var h = 0;
for (var i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) & 0xffff;
return h % 360;
}
function updateCatSwatch() {
if (!catInput || !catSwatch) return;
var v = catInput.value.trim();
if (v === '') {
catSwatch.style.background = '#e5e7eb';
catSwatch.title = '';
if (catHint) catHint.textContent = '';
} else {
var hue = catHue(v);
catSwatch.style.background = 'hsl(' + hue + ',55%,52%)';
catSwatch.title = 'hsl(' + hue + ', 55%, 52%)';
if (catHint) {
var known = KNOWN_CATS[v.toLowerCase()] !== undefined;
catHint.textContent = known ? 'Couleur fixe' : 'Nouvelle catégorie (couleur générée)';
}
}
}
if (catInput) {
catInput.addEventListener('input', updateCatSwatch);
updateCatSwatch();
if (catSwatches) {
FREE_HUES.forEach(function (h) {
var sw = document.createElement('span');
sw.style.cssText = 'display:inline-block;width:20px;height:20px;border-radius:4px;cursor:pointer;background:hsl(' + h + ',55%,52%)';
sw.title = 'hsl(' + h + ', 55%, 52%)';
sw.addEventListener('click', function () {
// trouver ou créer le nom correspondant
catInput.dispatchEvent(new Event('input'));
});
catSwatches.appendChild(sw);
});
}
}
// ─── Plan (TOC dynamique) ────────────────────────────────────────────────
var tocList = document.getElementById('wz-toc-list');
if (tocList && ta) {
function buildToc() {
var lines = ta.value.split('\n');
var items = [];
lines.forEach(function (line) {
var m = line.match(/^(#{1,6})\s+(.+)/);
if (m) { items.push({ level: m[1].length, text: m[2].trim() }); }
});
if (items.length === 0) {
tocList.innerHTML = '<li class="small text-muted fst-italic px-1">Aucun titre</li>';
return;
}
var minLevel = Math.min.apply(null, items.map(function (i) { return i.level; }));
tocList.innerHTML = items.map(function (item) {
var indent = (item.level - minLevel) * 12;
var escaped = item.text.replace(/&/g,'&amp;').replace(/</g,'&lt;');
return '<li class="small text-truncate py-0" style="padding-left:' + indent + 'px" title="' + escaped + '">'
+ '<span class="text-muted me-1" style="font-size:.65rem">H' + item.level + '</span>'
+ escaped + '</li>';
}).join('');
}
ta.addEventListener('input', buildToc);
buildToc();
}
// ─── Sélection catégorie — pills .wz-cat-pick (étape 3) ─────────────────
document.querySelectorAll('.wz-cat-pick').forEach(function (btn) {
btn.addEventListener('click', function () {
var catInp = document.getElementById('category');
if (catInp) {
catInp.value = this.dataset.cat;
catInp.dispatchEvent(new Event('input'));
}
document.querySelectorAll('.wz-cat-pick').forEach(function (b) { b.classList.remove('active'); });
this.classList.add('active');
});
});
// ─── Toggle tags — pills .wz-tag-pill (étape 4) ──────────────────────────
document.querySelectorAll('.wz-tag-pills').forEach(function (container) {
var targetId = container.dataset.target;
var inp = document.getElementById(targetId);
if (!inp) return;
container.querySelectorAll('.wz-tag-pill').forEach(function (pill) {
pill.addEventListener('click', function () {
var val = this.dataset.value;
var parts = inp.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
var idx = parts.indexOf(val);
if (idx >= 0) {
parts.splice(idx, 1);
this.classList.remove('btn-secondary', 'btn-info');
this.classList.add(this.classList.contains('btn-outline-info') ? 'btn-outline-info' : 'btn-outline-secondary');
} else {
parts.push(val);
var isDetected = this.classList.contains('btn-outline-info') || this.classList.contains('btn-info');
this.classList.remove('btn-outline-secondary', 'btn-outline-info');
this.classList.add(isDetected ? 'btn-info' : 'btn-secondary');
}
inp.value = parts.join(', ');
});
});
});
// ─── Image de couverture .wz-cover-thumb (étape 5) ───────────────────────
document.querySelectorAll('.wz-cover-thumb').forEach(function (img) {
img.addEventListener('click', function () {
document.querySelectorAll('.wz-cover-thumb').forEach(function (i) { i.classList.remove('wz-cover-selected'); });
this.classList.add('wz-cover-selected');
});
});
// ─── Compteurs SEO (étape 5) ──────────────────────────────────────────────
(function () {
function counter(inputId, counterId, warn) {
var el = document.getElementById(inputId);
var ct = document.getElementById(counterId);
if (!el || !ct) return;
function upd() { var l = el.value.length; ct.textContent = l + ' / ' + warn; ct.className = 'small ' + (l > warn ? 'text-danger' : (l < warn * 0.5 ? 'text-muted' : 'text-success')); }
el.addEventListener('input', upd);
upd();
}
counter('seo_title', 'seo_title_counter', 60);
counter('seo_description', 'seo_desc_counter', 155);
}());
// ─── Confirmation data-confirm ────────────────────────────────────────────
document.querySelectorAll('[data-confirm]').forEach(function (el) {
el.addEventListener('click', function (e) {
if (!confirm(this.dataset.confirm)) e.preventDefault();
});
});
});
+41 -16
View File
@@ -12,21 +12,28 @@ require_once BASE_PATH . '/src/Parsedown.php';
const FEED_PAGE_SIZE = 20;
$articles = new ArticleManager(BASE_PATH . '/data');
$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>
+4 -1
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/vendor/autoload.php';
require_once BASE_PATH . '/config/config.php';
$uuid = $_GET['uuid'] ?? '';
$name = $_GET['name'] ?? '';
@@ -20,7 +23,7 @@ if ($name === '' || $name[0] === '.') {
exit;
}
$path = BASE_PATH . '/data/' . $uuid . '/files/' . $name;
$path = DATA_PATH . '/' . $uuid . '/files/' . $name;
if (!is_file($path)) {
http_response_code(404);
+1192 -252
View File
File diff suppressed because it is too large Load Diff
+17 -33
View File
@@ -6,46 +6,21 @@ 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 (!defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 2));
}
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;
}
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
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 = '/';
@@ -120,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), '+/', '-_'), '=');
+47 -27
View File
@@ -1,42 +1,64 @@
<?php
// projet : mug.a5l.fr
// fichier : pages/login/magic.php
// version : 20251011
declare(strict_types=1);
if (!defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 2));
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.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;
}
}
require_once dirname(__DIR__, 2) . '/bootstrap.php';
$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';
@@ -54,7 +76,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();
@@ -65,7 +86,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 = '/';
}
@@ -76,5 +96,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));
}
+3 -5
View File
@@ -4,12 +4,10 @@ declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/vendor/autoload.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/bootstrap.php';
require_once BASE_PATH . '/src/auth.php';
$logoutUrl = ssoLogoutUrl();
+13 -16
View File
@@ -2,23 +2,12 @@
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.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;
}
if (!defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 2));
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
$debug = (env('APP_DEBUG', '0') === '1');
@@ -36,7 +25,15 @@ if (!$OIDC_ISSUER || !$OIDC_CLIENT_ID || !$OIDC_REDIRECT_URI) {
$tokenEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/token';
$userInfoEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/userinfo';
if (session_status() !== PHP_SESSION_ACTIVE) {
error_log('[OIDC/callback] session_start() a échoué — vérifier session.save_path');
http_response_code(500);
echo $debug ? 'Erreur de session (session.save_path inaccessible ?).' : 'Erreur interne.';
exit;
}
if (!isset($_GET['state'], $_SESSION['oidc_state']) || !hash_equals((string)$_SESSION['oidc_state'], (string)$_GET['state'])) {
error_log('[OIDC/callback] State invalide — GET:' . ($_GET['state'] ?? 'absent') . ' SESSION:' . (isset($_SESSION['oidc_state']) ? 'présent' : 'absent') . ' session_id:' . session_id());
http_response_code(400);
echo $debug ? 'State invalide.' : 'Requête invalide.';
exit;
+4 -1
View File
@@ -4,9 +4,12 @@
// version : 20251005
declare(strict_types=1);
if (!defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 2));
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
function maskToken(?string $t): string
{
+9 -13
View File
@@ -2,22 +2,18 @@
declare(strict_types=1);
if (!defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 2));
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/bootstrap.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;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
error_log('[OIDC/start] session_start() a échoué — vérifier session.save_path');
http_response_code(500);
echo 'Erreur de session. Contactez l\'administrateur.';
exit;
}
$flow = $_GET['flow'] ?? 'login'; // 'login' ou 'register'
+1 -1
View File
@@ -8,7 +8,7 @@ require_once BASE_PATH . '/src/helpers.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/ArticleManager.php';
$articles = new ArticleManager(BASE_PATH . '/data');
$articles = new ArticleManager(DATA_PATH);
$privateCats = $articles->getPrivateCategories();
$published = array_filter($articles->getAll(true), static function (array $a) use ($privateCats): bool {
+194
View File
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/ArticleManager.php';
const TENDANCES_PERIODS = [
'10m' => ['seconds' => 600, 'label' => '10 dernières minutes', 'short' => '10 min'],
'20m' => ['seconds' => 1200, 'label' => '20 dernières minutes', 'short' => '20 min'],
'30m' => ['seconds' => 1800, 'label' => '30 dernières minutes', 'short' => '30 min'],
'1h' => ['seconds' => 3600, 'label' => 'dernière heure', 'short' => '1 h'],
'8h' => ['seconds' => 28800, 'label' => '8 dernières heures', 'short' => '8 h'],
'1d' => ['seconds' => 86400, 'label' => '24 dernières heures', 'short' => '24 h'],
'7d' => ['seconds' => 604800, 'label' => '7 derniers jours', 'short' => '7 j'],
'14d' => ['seconds' => 1209600, 'label' => '14 derniers jours', 'short' => '14 j'],
'30d' => ['seconds' => 2592000, 'label' => '30 derniers jours', 'short' => '30 j'],
'1y' => ['seconds' => 31536000, 'label' => 'dernière année', 'short' => '1 an'],
];
// Période active (affichage du top)
$period = $_GET['period'] ?? '1d';
if (!array_key_exists($period, TENDANCES_PERIODS)) {
$period = '1d';
}
$seconds = TENDANCES_PERIODS[$period]['seconds'];
$label = TENDANCES_PERIODS[$period]['label'];
$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
// Lecture seule du cache généré par /trending?period=…
$cacheFile = DATA_PATH . '/_cache/trending_' . $period . '.json';
$topPaths = null;
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
$topPaths = json_decode((string) file_get_contents($cacheFile), true) ?: null;
}
// Index slug → article
$articleManager = new ArticleManager(DATA_PATH);
$now = time();
$privateCats = $articleManager->getPrivateCategories();
$slugIndex = [];
foreach ($articleManager->getAll(publishedOnly: true) as $a) {
if (strtotime((string) ($a['published_at'] ?? '')) > $now) {
continue;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true)) {
continue;
}
$slugIndex[$a['slug']] = $a;
}
// Top articles pour la période affichée
$topItems = [];
foreach ($topPaths as $path => $visitors) {
if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
continue;
}
$article = $slugIndex[rawurldecode($m[1])] ?? null;
if ($article === null) {
continue;
}
$topItems[] = ['article' => $article, 'visitors' => (int) $visitors];
if (count($topItems) >= 20) {
break;
}
}
$base = rtrim(APP_URL, '/');
$pageTitle = 'Tendances — ' . siteTitle();
ob_start();
?>
<div class="container py-4" style="max-width:860px">
<h1 class="h3 mb-1">Tendances</h1>
<p class="text-muted mb-4">
Articles les plus consultés, calculés en temps réel depuis les journaux d'accès du serveur.
Seuls les visiteurs uniques (une IP = un visiteur) sur des réponses <code>200</code> sont comptabilisés.
Aucun cookie, aucun traceur tiers.
</p>
<!-- Sélecteur de période -->
<div class="d-flex flex-wrap gap-2 mb-4">
<?php foreach (TENDANCES_PERIODS as $p => $info): ?>
<a href="/tendances?period=<?= rawurlencode($p) ?>"
class="btn btn-sm <?= $p === $period ? 'btn-primary' : 'btn-outline-secondary' ?>">
<?= htmlspecialchars($info['short']) ?>
</a>
<?php endforeach; ?>
</div>
<!-- Top articles -->
<?php if (empty($topItems)): ?>
<p class="text-muted">Aucune donnée disponible pour cette période.</p>
<?php else: ?>
<h2 class="h5 mb-3">Top articles — <?= htmlspecialchars($label) ?></h2>
<ol class="list-unstyled">
<?php foreach ($topItems as $i => ['article' => $a, 'visitors' => $v]): ?>
<li class="d-flex align-items-baseline gap-3 py-2 border-bottom">
<span class="text-muted" style="min-width:1.5rem;font-variant-numeric:tabular-nums"><?= $i + 1 ?></span>
<div class="flex-grow-1 overflow-hidden">
<a href="<?= htmlspecialchars($base . '/post/' . rawurlencode($a['slug'])) ?>"
class="text-decoration-none fw-medium text-truncate d-block">
<?= htmlspecialchars($a['title'] ?? '') ?>
</a>
<?php if (!empty($a['category'])): ?>
<span class="badge bg-secondary fw-normal small"><?= htmlspecialchars($a['category']) ?></span>
<?php endif; ?>
</div>
<span class="text-muted small text-nowrap"><?= number_format($v, 0, ',', "\u{202F}") ?> vis.</span>
</li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
<!-- Flux RSS -->
<div class="card mt-5">
<div class="card-header bg-transparent py-2 small fw-semibold">Flux RSS disponibles</div>
<div class="card-body py-2">
<p class="small text-muted mb-3">
Chaque flux retourne les 50 articles les plus consultés pour la période choisie,
mis à jour automatiquement. Abonnez-vous à celui qui correspond à vos besoins.
</p>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 small">
<thead class="table-light">
<tr>
<th>Période</th>
<th>Cache</th>
<th>URL</th>
</tr>
</thead>
<tbody>
<?php
$cacheTtlLabels = [
'10m' => '2 min', '20m' => '4 min', '30m' => '6 min',
'1h' => '12 min', '8h' => '96 min', '1d' => '5 h',
'7d' => '8 h', '14d' => '8 h', '30d' => '8 h',
'1y' => '8 h',
];
foreach (TENDANCES_PERIODS as $p => $info):
$url = $base . '/trending?period=' . rawurlencode($p);
?>
<tr>
<td><?= htmlspecialchars($info['label']) ?></td>
<td class="text-muted"><?= $cacheTtlLabels[$p] ?></td>
<td>
<a href="<?= htmlspecialchars($url) ?>" class="font-monospace text-decoration-none small">
/trending?period=<?= htmlspecialchars($p) ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Méthodologie -->
<div class="card mt-4">
<div class="card-header bg-transparent py-2 small fw-semibold">Méthodologie</div>
<div class="card-body small text-muted">
<ul class="mb-0 ps-3">
<li>Source : journaux d'accès Apache (<code>access.log</code> et rotations <code>.gz</code>).</li>
<li>Seules les requêtes <code>GET</code> sur <code>/post/*</code> avec code <code>HTTP 200</code> sont comptabilisées.</li>
<li>Un visiteur = une adresse IP distincte par article sur la fenêtre temporelle.</li>
<li>Les IPs ne sont ni stockées ni transmises ; seuls les compteurs agrégés sont conservés en cache.</li>
<li>Les articles dans des catégories privées et les avant-premières ne figurent pas dans les résultats.</li>
</ul>
</div>
</div>
</div>
<?php
$content = ob_get_clean();
http_response_code(200);
header('Cache-Control: public, max-age=' . $cacheTtl);
$templateVars = [
'title' => $pageTitle,
'content' => $content,
'mainClass' => '',
];
extract($templateVars);
require BASE_PATH . '/templates/layout.php';
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/ArticleManager.php';
require_once BASE_PATH . '/src/TrendingParser.php';
// ── Périodes supportées ───────────────────────────────────────────────────────
const TRENDING_PERIODS = [
'10m' => ['seconds' => 600, 'label' => '10 dernières minutes'],
'20m' => ['seconds' => 1200, 'label' => '20 dernières minutes'],
'30m' => ['seconds' => 1800, 'label' => '30 dernières minutes'],
'1h' => ['seconds' => 3600, 'label' => 'dernière heure'],
'8h' => ['seconds' => 28800, 'label' => '8 dernières heures'],
'1d' => ['seconds' => 86400, 'label' => '24 dernières heures'],
'7d' => ['seconds' => 604800, 'label' => '7 derniers jours'],
'14d' => ['seconds' => 1209600, 'label' => '14 derniers jours'],
'30d' => ['seconds' => 2592000, 'label' => '30 derniers jours'],
'1y' => ['seconds' => 31536000, 'label' => 'dernière année'],
];
$period = $_GET['period'] ?? '1d';
if (!array_key_exists($period, TRENDING_PERIODS)) {
http_response_code(400);
header('Content-Type: text/plain; charset=UTF-8');
echo 'Période invalide. Valeurs acceptées : ' . implode(', ', array_keys(TRENDING_PERIODS));
exit;
}
$seconds = TRENDING_PERIODS[$period]['seconds'];
$label = TRENDING_PERIODS[$period]['label'];
$cutoff = time() - $seconds;
$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
// ── Cache ─────────────────────────────────────────────────────────────────────
@mkdir(DATA_PATH . '/_cache', 0755, true);
$cacheFile = DATA_PATH . '/_cache/trending_' . $period . '.json';
$topPaths = null;
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
$topPaths = json_decode((string) file_get_contents($cacheFile), true) ?: null;
}
if ($topPaths === null) {
$parser = new TrendingParser('/var/log/apache2', apacheAccessLog());
$topPaths = $parser->top($cutoff, 50);
@file_put_contents($cacheFile, json_encode($topPaths));
}
// ── Index slug → article (publiés, non privés) ────────────────────────────────
$articleManager = new ArticleManager(DATA_PATH);
$now = time();
$privateCats = $articleManager->getPrivateCategories();
$slugIndex = [];
foreach ($articleManager->getAll(publishedOnly: true) as $a) {
if (strtotime((string) ($a['published_at'] ?? '')) > $now) {
continue;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true)) {
continue;
}
$slugIndex[$a['slug']] = $a;
}
// ── Construction des items ────────────────────────────────────────────────────
$base = rtrim(APP_URL, '/');
$items = [];
foreach ($topPaths as $path => $visitors) {
if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
continue;
}
$slug = rawurldecode($m[1]);
$article = $slugIndex[$slug] ?? null;
if ($article === null) {
continue;
}
$items[] = ['article' => $article, 'visitors' => (int) $visitors];
if (count($items) >= 50) {
break;
}
}
// ── Réponse RSS ───────────────────────────────────────────────────────────────
header('Content-Type: application/rss+xml; charset=UTF-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: public, max-age=' . $cacheTtl);
$feedTitle = htmlspecialchars(siteTitle() . ' — Tendances (' . $label . ')', ENT_XML1);
$feedUrl = htmlspecialchars($base . '/trending?period=' . rawurlencode($period), ENT_XML1);
$baseXml = htmlspecialchars($base, ENT_XML1);
$buildDate = htmlspecialchars(date(DATE_RSS));
$descXml = htmlspecialchars('Top 50 articles par visiteurs uniques — ' . $label, ENT_XML1);
$langXml = htmlspecialchars(siteLang(), ENT_XML1);
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title><?= $feedTitle ?></title>
<link><?= $baseXml ?></link>
<description><?= $descXml ?></description>
<language><?= $langXml ?></language>
<lastBuildDate><?= $buildDate ?></lastBuildDate>
<atom:link href="<?= $feedUrl ?>" rel="self" type="application/rss+xml"/>
<?php foreach ($items as ['article' => $a, 'visitors' => $v]):
$link = htmlspecialchars($base . '/post/' . rawurlencode($a['slug']), ENT_XML1);
$pubDate = htmlspecialchars(date(DATE_RSS, (int) strtotime((string) ($a['published_at'] ?? $a['created_at'] ?? ''))));
$title = htmlspecialchars(($a['title'] ?? ''), ENT_XML1);
$plural = $v > 1 ? 's' : '';
$desc = htmlspecialchars($title . ' — ' . $v . ' visiteur' . $plural . ' unique' . $plural . ' (' . $label . ')', ENT_XML1);
?>
<item>
<title><?= $title ?> (<?= $v ?> visiteur<?= $plural ?>)</title>
<link><?= $link ?></link>
<description><?= $desc ?></description>
<pubDate><?= $pubDate ?></pubDate>
<guid isPermaLink="true"><?= $link ?></guid>
</item>
<?php endforeach; ?>
</channel>
</rss>
+1
View File
@@ -0,0 +1 @@
1.6.40
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
// Migration 001 : préfixe chaque article avec `# Titre` si aucun titre
// Markdown de niveau 1 n'est déjà présent dans le contenu.
//
// Variables disponibles depuis le runner : $dataDir
/** @var string $dataDir */
$updated = 0;
$skipped = 0;
foreach (glob($dataDir . '/*/meta.json') as $metaPath) {
$dir = dirname($metaPath);
$mdPath = $dir . '/index.md';
if (!file_exists($mdPath)) {
$skipped++;
continue;
}
$meta = json_decode((string) file_get_contents($metaPath), true);
if (!is_array($meta)) {
$skipped++;
continue;
}
$title = trim($meta['title'] ?? '');
$content = (string) file_get_contents($mdPath);
$content = str_replace("\r\n", "\n", $content);
// Déjà un titre Markdown niveau 1 → on ne touche pas
if (preg_match('/^\s*#\s+\S/', $content)) {
$skipped++;
continue;
}
if ($title === '') {
$skipped++;
continue;
}
file_put_contents($mdPath, '# ' . $title . "\n\n" . ltrim($content));
touch($metaPath);
$updated++;
}
echo " $updated article(s) mis à jour, $skipped ignoré(s)\n";
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
// Runner de migrations de contenu (fichiers articles).
// Analogie avec database/migrate.php, mais pour les fichiers data/.
//
// Usage : php scripts/migrate_content.php [/chemin/vers/data]
//
// - Lit data/.content_migrations.json pour savoir ce qui a déjà tourné.
// - Active data/.maintenance pendant l'exécution (→ page 503 aux visiteurs).
// - Applique les scripts scripts/content/migration_*.php non encore appliqués.
$baseDir = dirname(__DIR__);
$dataDir = $argv[1] ?? ($baseDir . '/data');
if (!is_dir($dataDir)) {
fwrite(STDERR, "Répertoire data introuvable : $dataDir\n");
exit(1);
}
$trackFile = $dataDir . '/.content_migrations.json';
$maintenanceFlag = $dataDir . '/.maintenance';
$applied = [];
if (file_exists($trackFile)) {
$applied = json_decode((string) file_get_contents($trackFile), true) ?? [];
}
$files = glob(__DIR__ . '/content/migration_*.php') ?: [];
sort($files);
$pending = array_values(array_filter($files, fn ($f) => !isset($applied[basename($f)])));
if (empty($pending)) {
echo " (aucune migration de contenu en attente)\n";
exit(0);
}
file_put_contents($maintenanceFlag, date('Y-m-d H:i:s'));
echo "→ Mode maintenance activé\n";
$count = 0;
$errors = 0;
foreach ($pending as $file) {
$name = basename($file);
echo "$name ... ";
try {
require $file;
$applied[$name] = date('Y-m-d H:i:s');
file_put_contents(
$trackFile,
json_encode($applied, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"
);
echo "\n";
$count++;
} catch (Throwable $e) {
echo '✗ ' . $e->getMessage() . "\n";
$errors++;
break;
}
}
if (file_exists($maintenanceFlag)) {
unlink($maintenanceFlag);
}
echo "→ Mode maintenance désactivé\n";
echo "$count migration(s) appliquée(s)" . ($errors ? ", $errors erreur(s)" : '') . ".\n";
exit($errors > 0 ? 1 : 0);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Pousse la branche courante vers git.abonnel.fr/cedricAbonnel/folio
# Ne pousse JAMAIS directement sur main — passer par une PR.
# Usage : ./scripts/push.sh "message de commit"
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$SCRIPT_DIR/.."
MSG="${1:-}"
if [[ -z "$MSG" ]]; then
echo "Usage: $0 \"message de commit\""
exit 1
fi
cd "$ROOT"
if [ ! -d .git ]; then
git init -b main
git remote add origin https://git.abonnel.fr/cedricAbonnel/folio.git
echo "→ Dépôt git initialisé"
fi
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$BRANCH" == "main" ]]; then
echo "✗ Refus de pousser directement sur main."
echo " Travailler sur 'dev' ou une branche feature : git checkout dev"
exit 1
fi
# Extraire la version depuis CHANGELOG.md (première entrée ## [X.Y.Z])
FOLIO_VERSION=$(grep -m1 '^\#\# \[[0-9]' CHANGELOG.md | sed 's/.*\[\([^]]*\)\].*/\1/')
if [[ -z "$FOLIO_VERSION" ]]; then
echo "✗ Impossible d'extraire la version depuis CHANGELOG.md"
exit 1
fi
echo "$FOLIO_VERSION" > public/version.txt
echo "→ Version : $FOLIO_VERSION"
git add -A
git diff --cached --quiet && echo "(rien à committer)" && exit 0
git commit -m "$MSG"
git push origin "$BRANCH"
echo "✓ Poussé vers folio (branche $BRANCH)"
echo " → Ouvrir une PR sur https://git.abonnel.fr/cedricAbonnel/folio/pulls/new/$BRANCH"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# folio-upgrade.sh — déploie Folio à la demande (appelé par PHP via sudo).
#
# Usage : folio-upgrade.sh <branche>
#
# Installation sur chaque serveur :
# sudo install -o root -m 750 folio-upgrade.sh /usr/local/bin/folio-upgrade.sh
#
# Autorisation sudo (sans mot de passe) :
# echo "www-data ALL=(root) NOPASSWD: /usr/local/bin/folio-upgrade.sh" \
# | sudo tee /etc/sudoers.d/folio-upgrade
#
# ── Configuration (à adapter par site) ───────────────────────────────────────
APP_DIR=/var/www/lan.acegrp.abonnel-www
REPO_URL=https://git.abonnel.fr/cedricAbonnel/folio.git
# ──────────────────────────────────────────────────────────────────────────────
BRANCH=${1:-main}
ENV_FILE="$APP_DIR/.env"
[ -f "$ENV_FILE" ] || { echo "ERREUR : $ENV_FILE introuvable"; exit 1; }
DATA_DIR=$(grep -m1 '^DATA_PATH=' "$ENV_FILE" | cut -d= -f2- | tr -d '"'"'" | xargs)
[ -n "$DATA_DIR" ] || { echo "ERREUR : DATA_PATH absent du .env"; exit 1; }
LOG="$DATA_DIR/.upgrade-log"
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
{
echo "=== $(date '+%Y-%m-%d %H:%M:%S') — démarrage ==="
echo "Branche : $BRANCH"
echo ""
# 1. Sauvegarder .env avant de toucher APP_DIR
cp "$ENV_FILE" "$WORK_DIR/.env.bak" || { echo "ERREUR : sauvegarde .env impossible"; exit 1; }
# 2. Cloner dans un répertoire de travail (APP_DIR reste intact en cas d'échec du clone)
git clone --depth=1 --branch "$BRANCH" "$REPO_URL" "$WORK_DIR/app" \
|| { echo "ERREUR : git clone"; exit 1; }
# 3. Déployer
rm -rf "$APP_DIR"
mv "$WORK_DIR/app" "$APP_DIR"
# 4. Permissions (PHP-FPM tourne en www-data)
chown -R www-data:www-data "$APP_DIR"
chmod -R g+rwX,o= "$APP_DIR"
# 5. Restaurer .env
cp "$WORK_DIR/.env.bak" "$APP_DIR/.env"
chown www-data:www-data "$APP_DIR/.env"
chmod 640 "$APP_DIR/.env"
cd "$APP_DIR"
# 6. Dépendances Composer
if command -v composer > /dev/null 2>&1; then
sudo -u www-data composer install --no-dev --optimize-autoloader \
|| echo "AVERTISSEMENT : composer install a échoué"
else
echo "AVERTISSEMENT : composer introuvable — dépendances non installées"
fi
# 7. Migrations SQL
sudo -u www-data php database/migrate.php \
|| echo "AVERTISSEMENT : migrations SQL — vérifier manuellement"
# 8. Répertoire de sessions PHP
mkdir -p "$APP_DIR/.sessions"
chown www-data:www-data "$APP_DIR/.sessions"
chmod 700 "$APP_DIR/.sessions"
# 9. Autoriser git pour ce répertoire (accès multi-utilisateurs)
git config --system --add safe.directory "$APP_DIR" 2>/dev/null || true
echo ""
echo "=== $(date '+%Y-%m-%d %H:%M:%S') — succès ==="
} > "$LOG" 2>&1
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# setup.sh — déploiement initial de Folio sur un serveur
#
# Usage : sudo ./scripts/setup.sh [--web-group www-data] [--data-dir /chemin/data]
#
# Ce script est idempotent : il peut être relancé sans risque.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$SCRIPT_DIR/.."
# ─── Paramètres ──────────────────────────────────────────────────────────────
WEB_GROUP="www-data"
DATA_DIR="$ROOT/data"
while [[ $# -gt 0 ]]; do
case "$1" in
--web-group) WEB_GROUP="$2"; shift 2 ;;
--data-dir) DATA_DIR="$2"; shift 2 ;;
*) echo "Option inconnue : $1"; exit 1 ;;
esac
done
# ─── Vérifications préalables ─────────────────────────────────────────────────
if [[ ! -f "$ROOT/.env" ]]; then
echo "✗ Fichier .env manquant. Copier .env.example en .env et remplir les valeurs."
exit 1
fi
if ! getent group "$WEB_GROUP" > /dev/null 2>&1; then
echo "✗ Groupe '$WEB_GROUP' introuvable. Utiliser --web-group <groupe>."
exit 1
fi
# ─── Dépendances PHP ──────────────────────────────────────────────────────────
echo "→ Installation des dépendances PHP..."
composer install --no-dev --optimize-autoloader --working-dir="$ROOT"
# ─── Dossiers requis ─────────────────────────────────────────────────────────
echo "→ Création des dossiers..."
mkdir -p "$DATA_DIR"
mkdir -p "$ROOT/public/_cache"
mkdir -p "$ROOT/.sessions"
# ─── Permissions ─────────────────────────────────────────────────────────────
echo "→ Configuration des permissions (groupe : $WEB_GROUP)..."
# data/ : setgid pour héritage de groupe + lecture/écriture propriétaire et groupe
chown -R :"$WEB_GROUP" "$DATA_DIR"
chmod g+s "$DATA_DIR"
find "$DATA_DIR" -type d -exec chmod g+s {} +
find "$DATA_DIR" -exec chmod ug+rw {} +
# public/_cache/ : créé et écrit par PHP
chown -R :"$WEB_GROUP" "$ROOT/public/_cache"
chmod g+s "$ROOT/public/_cache"
find "$ROOT/public/_cache" -exec chmod ug+rw {} +
# .sessions/ : écrit par PHP
chown -R :"$WEB_GROUP" "$ROOT/.sessions"
chmod 770 "$ROOT/.sessions"
# ─── Groupe adm pour lecture des logs Apache ─────────────────────────────────
echo "→ Ajout de $WEB_GROUP au groupe adm (logs Apache)..."
if getent group adm > /dev/null 2>&1; then
usermod -aG adm "$WEB_GROUP"
echo " Redémarrer Apache et PHP-FPM pour que le changement prenne effet :"
echo " systemctl restart apache2 php8.3-fpm"
else
echo " Groupe adm absent, ignoré."
fi
# ─── Migrations SQL ───────────────────────────────────────────────────────────
echo "→ Migrations SQL..."
php "$ROOT/database/migrate.php"
echo ""
echo "✓ Folio est prêt."
echo " Vérifier que APP_URL et ADMIN_EMAIL sont corrects dans .env."
+383
View File
@@ -0,0 +1,383 @@
<?php
declare(strict_types=1);
class AccessLogParser
{
private string $logDir;
private string $pattern;
private string $cacheFile;
private int $cacheTtl;
private int $days;
/** @var list<string> */
private array $botPatterns;
/** @var array<string,array<string,true>> */
private array $artIp7 = [];
/** @var array<string,array<string,true>> */
private array $artIp14 = [];
/** @var array<string,array<string,true>> */
private array $artIp30 = [];
private static ?array $memo = null;
// Apache COMBINED : IP - - [timestamp] "METHOD /path HTTP/x" STATUS bytes "ref" "ua"
private const RE = '/^(\S+) \S+ \S+ \[(\d{2}\/\w+\/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] "[A-Z-]+ ([^\s"?]+)[^"]*" (\d{3}) \S+ "[^"]*" "([^"]*)"/u';
/**
* @param list<string> $botPatterns
*/
public function __construct(
string $logDir = '/var/log/apache2',
string $pattern = '*-access.log',
string $cacheFile = '',
int $cacheTtl = 600,
int $days = 30,
array $botPatterns = []
) {
$this->logDir = rtrim($logDir, '/');
$this->pattern = $pattern;
$this->cacheFile = $cacheFile !== '' ? $cacheFile : dirname(__DIR__) . '/_cache/access_stats.json';
$this->cacheTtl = $cacheTtl;
$this->days = $days;
$this->botPatterns = array_map('strtolower', $botPatterns);
}
/**
* @return array{
* pages:array<string,int>,
* books:array<string,int>,
* ips:array<string,int>,
* pages_by_day:array<string,list<int>>,
* ips_by_day:array<string,list<int>>,
* ip_top_paths:array<string,array<string,array{n:int,ts:int}>>,
* ip_agents:array<string,list<string>>,
* all_uas:array<string,int>,
* unique_visitors:array<int,int>,
* article_unique_visitors:array<string,array<int,int>>
* }
*/
public function stats(): array
{
if (self::$memo !== null) {
return self::$memo;
}
if ($this->cacheValid()) {
$d = json_decode((string) file_get_contents($this->cacheFile), true);
if (is_array($d)) {
return self::$memo = $d;
}
}
$cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400);
$pages = [];
$books = [];
$ips = []; // requêtes publiques non-bot (tous chemins, tous statuts)
$dayPages = [];
$ipPaths = []; // chemins /post/ et /book/ avec statut 200 (pour les ts)
$ipPathTs = [];
$ipAllPaths = []; // tous chemins, tous statuts, non-bots
$ipAllDays = []; // tous jours, tous statuts, non-bots
$ipAgents = []; // user-agents non-bot par IP
$allUas = []; // tous UAs publics (bots inclus) pour "Agents détectés"
foreach ($this->logFiles() as $file) {
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
}
arsort($pages);
arsort($books);
arsort($ips);
arsort($allUas);
$pagesByDay = [];
foreach ($dayPages as $path => $byOffset) {
$arr = array_fill(0, $this->days, 0);
foreach ($byOffset as $offset => $count) {
if ($offset >= 0 && $offset < $this->days) {
$arr[$offset] = $count;
}
}
$pagesByDay[$path] = $arr;
}
// Top 200 IPs non-bot par volume total de requêtes
$topIpKeys = array_keys(array_slice($ips, 0, 200, true));
$ipsByDay = [];
$ipTopPaths = [];
$ipTopAgents = [];
foreach ($topIpKeys as $ip) {
// Sparkline : activité totale par jour
$arr = array_fill(0, $this->days, 0);
foreach ($ipAllDays[$ip] ?? [] as $offset => $count) {
if ($offset >= 0 && $offset < $this->days) {
$arr[$offset] = $count;
}
}
$ipsByDay[$ip] = $arr;
// Top 20 chemins tous types confondus
$allPaths = $ipAllPaths[$ip] ?? [];
arsort($allPaths);
$ipTopPaths[$ip] = [];
foreach (array_slice($allPaths, 0, 20, true) as $p => $cnt) {
$ipTopPaths[$ip][$p] = ['n' => $cnt, 'ts' => $ipPathTs[$ip][$p] ?? 0];
}
// Top 5 user-agents
$agents = $ipAgents[$ip] ?? [];
arsort($agents);
$ipTopAgents[$ip] = array_keys(array_slice($agents, 0, 5, true));
}
// Visiteurs uniques par période — calculé sur TOUS les IPs non-bot (pas seulement le top 200)
$uniqueVisitors = [7 => 0, 14 => 0, 30 => 0];
$start7 = $this->days - 7;
$start14 = $this->days - 14;
foreach ($ipAllDays as $ipDay) {
$active7 = $active14 = $active30 = false;
foreach ($ipDay as $offset => $cnt) {
if ($cnt <= 0) {
continue;
}
$active30 = true;
if ($offset >= $start14) {
$active14 = true;
}
if ($offset >= $start7) {
$active7 = true;
}
}
if ($active7) {
++$uniqueVisitors[7];
}
if ($active14) {
++$uniqueVisitors[14];
}
if ($active30) {
++$uniqueVisitors[30];
}
}
// Visiteurs uniques par article (IPs publiques non-bot, /post/ statut 200)
$articleUv = [];
foreach ($this->artIp30 as $path => $_artIpSet) {
$articleUv[$path] = [
'7' => count($this->artIp7[$path] ?? []),
'14' => count($this->artIp14[$path] ?? []),
'30' => count($_artIpSet),
];
}
$result = [
'pages' => $pages,
'books' => $books,
'ips' => $ips,
'pages_by_day' => $pagesByDay,
'ips_by_day' => $ipsByDay,
'ip_top_paths' => $ipTopPaths,
'ip_agents' => $ipTopAgents,
'all_uas' => array_slice($allUas, 0, 300, true),
'unique_visitors' => $uniqueVisitors,
'article_unique_visitors' => $articleUv,
];
@mkdir(dirname($this->cacheFile), 0755, true);
@file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::$memo = $result;
}
public function isReadable(): bool
{
return count($this->logFiles()) > 0;
}
private function cacheValid(): bool
{
return file_exists($this->cacheFile)
&& (time() - filemtime($this->cacheFile)) < $this->cacheTtl;
}
private function matchesBot(string $ua): bool
{
if ($ua === '' || $this->botPatterns === []) {
return false;
}
$lo = strtolower($ua);
foreach ($this->botPatterns as $p) {
if ($p !== '' && str_contains($lo, $p)) {
return true;
}
}
return false;
}
/** @return list<array{path:string,type:string}> */
private function logFiles(): array
{
$files = [];
$cutoff = time() - ($this->days + 1) * 86400;
foreach (glob($this->logDir . '/' . $this->pattern) ?: [] as $base) {
if (str_ends_with($base, '.gz') || preg_match('/\.\d+$/', $base)) {
continue;
}
foreach (array_merge([$base], glob($base . '.*') ?: []) as $path) {
if ($path !== $base && filemtime($path) < $cutoff) {
continue;
}
if (!is_readable($path)) {
continue;
}
if (str_ends_with($path, '.tar.gz')) {
$files[] = ['path' => $path, 'type' => 'tgz'];
} elseif (str_ends_with($path, '.gz')) {
$files[] = ['path' => $path, 'type' => 'gz'];
} else {
$files[] = ['path' => $path, 'type' => 'plain'];
}
}
}
return $files;
}
private static function parseTimestamp(string $raw): int
{
// "15/May/2026:00:41:01 +0200"
if (!preg_match('/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4})/', $raw, $m)) {
return 0;
}
return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}");
}
private function parseLine(
string $line,
int $cutoff,
array &$pages,
array &$books,
array &$ips,
array &$dayPages,
array &$ipPaths,
array &$ipPathTs,
array &$ipAllPaths,
array &$ipAllDays,
array &$ipAgents,
array &$allUas
): void {
if (!preg_match(self::RE, $line, $m)) {
return;
}
[, $ip, $ts, $path, $status, $ua] = $m;
$tsVal = self::parseTimestamp($ts);
if ($tsVal < $cutoff) {
return;
}
$publicIp = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
$dayOffset = (int) floor(($tsVal - $cutoff) / 86400);
$isBot = $this->matchesBot($ua);
// Tous les UAs publics pour la section "Agents détectés" (bots inclus)
if ($publicIp && $ua !== '') {
$allUas[$ua] = ($allUas[$ua] ?? 0) + 1;
}
// Requêtes publiques non-bot : comptage visiteurs, chemins, jours, agents
if ($publicIp && !$isBot) {
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
$ipAllPaths[$ip][$path] = ($ipAllPaths[$ip][$path] ?? 0) + 1;
$ipAllDays[$ip][$dayOffset] = ($ipAllDays[$ip][$dayOffset] ?? 0) + 1;
if ($ua !== '') {
$ipAgents[$ip][$ua] = ($ipAgents[$ip][$ua] ?? 0) + 1;
}
}
// Comptage spécifique aux pages de contenu (statut 200, non-bot)
if ($status !== '200' || $isBot) {
return;
}
if (str_starts_with($path, '/post/') && strlen($path) > 6) {
$pages[$path] = ($pages[$path] ?? 0) + 1;
$dayPages[$path][$dayOffset] = ($dayPages[$path][$dayOffset] ?? 0) + 1;
if ($publicIp) {
$ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1;
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
$ipPathTs[$ip][$path] = $tsVal;
}
// Visiteurs uniques par article (IPs publiques non-bot uniquement)
$this->artIp30[$path][$ip] = true;
if ($dayOffset >= $this->days - 14) {
$this->artIp14[$path][$ip] = true;
}
if ($dayOffset >= $this->days - 7) {
$this->artIp7[$path][$ip] = true;
}
}
} elseif (str_ends_with($path, '/') === false && str_starts_with($path, '/book/') && strlen($path) > 6) {
$books[$path] = ($books[$path] ?? 0) + 1;
if ($publicIp) {
$ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1;
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
$ipPathTs[$ip][$path] = $tsVal;
}
}
}
}
private function parseFile(
array $file,
int $cutoff,
array &$pages,
array &$books,
array &$ips,
array &$dayPages,
array &$ipPaths,
array &$ipPathTs,
array &$ipAllPaths,
array &$ipAllDays,
array &$ipAgents,
array &$allUas
): void {
if ($file['type'] === 'tgz') {
try {
$phar = new PharData($file['path']);
foreach ($phar as $entry) {
$content = @file_get_contents('phar://' . $file['path'] . '/' . $entry->getFilename());
if ($content === false) {
continue;
}
foreach (explode("\n", $content) as $line) {
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
}
}
} catch (\Exception $e) {
}
} elseif ($file['type'] === 'gz') {
$h = @gzopen($file['path'], 'rb');
if (!$h) {
return;
}
while (!gzeof($h)) {
$line = gzgets($h, 8192);
if ($line !== false) {
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
}
}
gzclose($h);
} else {
$h = @fopen($file['path'], 'rb');
if (!$h) {
return;
}
while (($line = fgets($h)) !== false) {
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
}
fclose($h);
}
}
}
+289 -39
View File
@@ -9,7 +9,7 @@ class ArticleManager
private ?array $allCache = null;
private ?array $searchIndexCache = null;
public function __construct(private string $dataDir)
public function __construct(private string $dataDir, private ?DataGit $git = null)
{
}
@@ -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;
}
@@ -105,8 +132,8 @@ class ArticleManager
$publishedAt = $publishedAt !== '' ? $publishedAt : $now;
$dir = $this->dataDir . '/' . $uuid;
mkdir($dir, 0755, true);
mkdir($dir . '/files', 0755, true);
$this->mkArticleDir($dir);
$this->mkArticleDir($dir . '/files');
$meta = [
'uuid' => $uuid,
@@ -132,11 +159,27 @@ class ArticleManager
file_put_contents($dir . '/index.md', ltrim($content));
$this->rebuildSearchIndex();
$this->rebuildBacklinksCache();
$this->git?->commit("add: $title");
return $uuid;
}
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): void
/** 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);
if (!$article) {
@@ -154,7 +197,7 @@ class ArticleManager
if ($contentChanged || $titleChanged) {
$revDir = $this->dataDir . '/' . $uuid . '/revisions';
if (!is_dir($revDir)) {
mkdir($revDir, 0755, true);
$this->mkArticleDir($revDir);
}
$n = count($revisions) + 1;
$revFile = sprintf('%s/%04d.md', $revDir, $n);
@@ -199,6 +242,9 @@ class ArticleManager
file_put_contents($dir . '/index.md', ltrim($content));
$this->rebuildSearchIndex();
$this->rebuildBacklinksCache();
if (!$skipGit) {
$this->git?->commit("update: $title");
}
}
public function autosave(string $uuid, string $title, string $content, string $slug): bool
@@ -228,7 +274,142 @@ class ArticleManager
return true;
}
public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = []): void
public function updatePartialMeta(string $uuid, array $updates): void
{
if (!$this->isValidUuid($uuid)) {
return;
}
$dir = $this->dataDir . '/' . $uuid;
$raw = @file_get_contents($dir . '/meta.json');
if ($raw === false) {
return;
}
$meta = json_decode($raw, true);
if (!is_array($meta)) {
return;
}
foreach ($updates as $key => $value) {
$meta[$key] = $value;
}
$meta['updated_at'] = date('Y-m-d H:i:s');
$this->writeMeta($dir, $meta);
$this->git?->commit('meta: ' . ($meta['title'] ?? $uuid));
}
public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void
{
if (!$this->isValidUuid($uuid)) {
return;
}
$dir = $this->dataDir . '/' . $uuid;
$existing = [];
$raw = @file_get_contents($dir . '/draft_overlay.json');
if ($raw !== false) {
$existing = json_decode($raw, true) ?? [];
}
$overlay = array_merge($existing, $metaFields);
$overlay['_updated_at'] = date('Y-m-d H:i:s');
file_put_contents(
$dir . '/draft_overlay.json',
json_encode($overlay, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
);
if ($content !== null) {
file_put_contents($dir . '/draft_overlay.md', $content);
}
$raw2 = @file_get_contents($dir . '/meta.json');
$title = is_string($raw2) ? (json_decode($raw2, true)['title'] ?? $uuid) : $uuid;
$this->git?->commit("draft: $title");
}
public function getDraftOverlay(string $uuid): ?array
{
if (!$this->isValidUuid($uuid)) {
return null;
}
$dir = $this->dataDir . '/' . $uuid;
if (!file_exists($dir . '/draft_overlay.json')) {
return null;
}
$article = $this->getByUuid($uuid);
if (!$article) {
return null;
}
$raw = file_get_contents($dir . '/draft_overlay.json');
if ($raw === false) {
return null;
}
$overlay = json_decode($raw, true);
if (!is_array($overlay)) {
return null;
}
$merged = $article;
foreach ($overlay as $key => $value) {
if (!str_starts_with($key, '_')) {
$merged[$key] = $value;
}
}
if (file_exists($dir . '/draft_overlay.md')) {
$c = file_get_contents($dir . '/draft_overlay.md');
if ($c !== false) {
$merged['content'] = $c;
}
}
return $merged;
}
public function hasDraftOverlay(string $uuid): bool
{
if (!$this->isValidUuid($uuid)) {
return false;
}
return file_exists($this->dataDir . '/' . $uuid . '/draft_overlay.json');
}
public function discardDraftOverlay(string $uuid, bool $skipGit = false): void
{
if (!$this->isValidUuid($uuid)) {
return;
}
$dir = $this->dataDir . '/' . $uuid;
$title = null;
if (!$skipGit && $this->git !== null) {
$raw = @file_get_contents($dir . '/meta.json');
$title = is_string($raw) ? (json_decode($raw, true)['title'] ?? $uuid) : $uuid;
}
@unlink($dir . '/draft_overlay.json');
@unlink($dir . '/draft_overlay.md');
if ($title !== null) {
$this->git->commit("discard-draft: $title");
}
}
public function commitDraftOverlay(string $uuid, string $revisionComment = ''): void
{
$draft = $this->getDraftOverlay($uuid);
if (!$draft) {
return;
}
$title = $draft['title'];
$this->update(
$uuid,
$title,
$draft['content'],
(bool)$draft['published'],
$draft['slug'] ?? '',
$draft['published_at'] ?? '',
$revisionComment,
$draft['seo_title'] ?? '',
$draft['seo_description'] ?? '',
$draft['og_image'] ?? '',
$draft['category'] ?? '',
$draft['tags'] ?? [],
true // skipGit — commit unique ci-dessous
);
$this->discardDraftOverlay($uuid, skipGit: true);
$this->git?->commit("publish: $title");
}
public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = [], bool $skipGit = false): void
{
if (!$this->isValidUuid($uuid)) {
return;
@@ -257,6 +438,9 @@ class ArticleManager
}
$meta['files_meta'][$filename] = $entry;
$this->writeMeta($this->dataDir . '/' . $uuid, $meta);
if (!$skipGit) {
$this->git?->commit("file-meta: {$uuid}/{$filename}");
}
}
public function setCover(string $uuid, string $filename): void
@@ -304,6 +488,7 @@ class ArticleManager
}
$meta['cover'] = $coverName;
$this->writeMeta($this->dataDir . '/' . $uuid, $meta);
$this->git?->commit('cover: ' . ($article['title'] ?? $uuid));
}
public function addFileFromUrl(string $uuid, string $url, bool $isCover = false, string $author = '', string $sourceUrl = '', string $title = '', array $extraMeta = []): ?string
@@ -339,7 +524,7 @@ class ArticleManager
$isImage = str_starts_with($mime, 'image/');
$filesDir = $this->dataDir . '/' . $uuid . '/files';
if (!is_dir($filesDir)) {
mkdir($filesDir, 0755, true);
$this->mkArticleDir($filesDir);
}
if ($isImage) {
@@ -379,7 +564,7 @@ class ArticleManager
rename($tmp, $filesDir . '/' . $filename);
if ($author !== '' || $sourceUrl !== '' || $title !== '' || !empty($extraMeta)) {
$this->addFileMeta($uuid, $filename, $author, $sourceUrl, $title, $extraMeta);
$this->addFileMeta($uuid, $filename, $author, $sourceUrl, $title, $extraMeta, skipGit: true);
}
if ($isCover && $isImage) {
@@ -393,6 +578,7 @@ class ArticleManager
}
}
$this->git?->commit("add-file: {$uuid}/{$filename}");
return $filename;
}
@@ -433,6 +619,7 @@ class ArticleManager
$meta['external_links'][] = $entry;
$this->writeMeta($dir, $meta);
$this->rebuildBacklinksCache();
$this->git?->commit("link: {$uuid}");
return true;
}
@@ -463,6 +650,7 @@ class ArticleManager
return false;
}
$this->writeMeta($dir, $meta);
$this->git?->commit("link-meta: {$uuid}");
return true;
}
@@ -486,6 +674,7 @@ class ArticleManager
));
$this->writeMeta($dir, $meta);
$this->rebuildBacklinksCache();
$this->git?->commit("unlink: {$uuid}");
return true;
}
@@ -503,7 +692,7 @@ class ArticleManager
return $cats;
}
public function renameCategory(string $old, string $new): void
public function renameCategory(string $old, string $new, bool $skipGit = false): void
{
if (!is_dir($this->dataDir)) {
return;
@@ -527,11 +716,15 @@ class ArticleManager
$meta['category'] = $new;
$this->writeMeta($this->dataDir . '/' . $entry, $meta);
}
if (!$skipGit) {
$this->git?->commit("rename-cat: $old$new");
}
}
public function deleteCategory(string $name): void
{
$this->renameCategory($name, '');
$this->renameCategory($name, '', skipGit: true);
$this->git?->commit("delete-cat: $name");
}
public function getPrivateCategories(): array
@@ -556,6 +749,7 @@ class ArticleManager
$this->dataDir . '/private_cats.json',
json_encode(array_values($cats), JSON_UNESCAPED_UNICODE)
);
$this->git?->commit("private-cat: $cat");
}
// ─── Tag types ──────────────────────────────────────────────────────────────
@@ -581,6 +775,7 @@ class ArticleManager
$this->tagTypesPath(),
json_encode($types, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"
);
$this->git?->commit('tag-types');
}
/** Enregistre les tags d'un article directement (utile pour les scripts de migration). */
@@ -600,6 +795,7 @@ class ArticleManager
$meta['tags'] = $this->normalizeTags($tags);
$this->writeMeta($dir, $meta);
$this->rebuildSearchIndex();
$this->git?->commit('tags: ' . ($meta['title'] ?? $uuid));
}
/** @return list<string> Toutes les valeurs distinctes d'un type de tag, triées. */
@@ -649,22 +845,34 @@ class ArticleManager
$this->writeMeta($dir, $meta);
$this->allCache = null;
@unlink($this->articleCachePath($uuid));
$this->git?->commit('featured: ' . ($meta['title'] ?? $uuid) . ' (' . ($featured ? 'on' : 'off') . ')');
}
public function delete(string $uuid): void
public function delete(string $uuid): bool
{
if (!$this->isValidUuid($uuid)) {
return;
return false;
}
$dir = $this->dataDir . '/' . $uuid;
$title = null;
if ($this->git !== null && is_dir($dir)) {
$raw = @file_get_contents($dir . '/meta.json');
$title = is_string($raw) ? (json_decode($raw, true)['title'] ?? null) : null;
}
if (is_dir($dir)) {
$this->allCache = null;
@unlink($this->articleCachePath($uuid));
@unlink($this->slugIndexPath());
@unlink($this->allListCachePath());
$this->removeDir($dir);
}
if (is_dir($dir)) {
return false;
}
$this->rebuildSearchIndex();
$this->rebuildBacklinksCache();
$this->git?->commit('delete: ' . ($title ?? $uuid));
return true;
}
// ------------------------------------------------------------------ //
@@ -686,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';
@@ -779,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(
@@ -862,11 +1081,18 @@ 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;
}
// Rebuild si des UUID ont été supprimés hors CMS (ex. rsync, suppression manuelle)
foreach ($data as $entry) {
if (!is_dir($this->dataDir . '/' . ($entry['uuid'] ?? ''))) {
$this->rebuildSearchIndex();
return $this->searchIndexCache;
}
}
$this->searchIndexCache = $data;
return $this->searchIndexCache;
}
@@ -942,7 +1168,7 @@ class ArticleManager
}
$dir = $this->dataDir . '/' . $uuid . '/files';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
$this->mkArticleDir($dir);
}
$mime = mime_content_type($uploadedFile['tmp_name']) ?: 'application/octet-stream';
@@ -971,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;
@@ -1028,20 +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
if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath)) {
$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;
}
}
}
@@ -1054,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'] ?? [];
@@ -1069,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;
}
@@ -1129,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',
@@ -1201,13 +1445,19 @@ class ArticleManager
*/
private function removeDir(string $dir): void
{
foreach (scandir($dir) as $entry) {
foreach (@scandir($dir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
is_dir($path) ? $this->removeDir($path) : unlink($path);
is_dir($path) ? $this->removeDir($path) : @unlink($path);
}
rmdir($dir);
@rmdir($dir);
}
private function mkArticleDir(string $path): void
{
mkdir($path, 0777, true);
chmod($path, 0775);
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
class AsnLookup
{
private string $cacheDir;
private int $ttl;
public function __construct(string $cacheDir = '', int $ttl = 86400 * 30)
{
$this->cacheDir = $cacheDir !== '' ? $cacheDir : dirname(__DIR__) . '/_cache/asn';
$this->ttl = $ttl;
}
/**
* Lookup AS info pour une liste d'IPs.
* IPs privées : retournées avec name='LAN', pas d'appel API.
*
* @param list<string> $ips
* @return array<string, array{asn:string,name:string,country:string}>
*/
public function batchLookup(array $ips): array
{
$results = [];
$missing = [];
foreach (array_unique($ips) as $ip) {
if ($this->isPrivate($ip)) {
$results[$ip] = ['asn' => '', 'name' => 'LAN', 'country' => ''];
continue;
}
$cached = $this->fromCache($ip);
if ($cached !== null) {
$results[$ip] = $cached;
} else {
$missing[] = $ip;
}
}
foreach (array_chunk($missing, 100) as $chunk) {
foreach ($this->fetchBatch($chunk) as $ip => $info) {
$this->toCache($ip, $info);
$results[$ip] = $info;
}
}
return $results;
}
public function isPrivate(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
}
/**
* Agrège les hits par AS depuis un tableau [ip => hits] et les infos AS.
* Retourne [asKey => [asn, name, country, hits]] trié par hits desc.
*
* @param array<string,int> $ipHits
* @param array<string, array{asn:string,name:string,country:string}> $asnMap
* @return list<array{asn:string,name:string,country:string,hits:int}>
*/
public static function aggregateByAs(array $ipHits, array $asnMap): array
{
$byAs = [];
foreach ($ipHits as $ip => $hits) {
$info = $asnMap[$ip] ?? ['asn' => '?', 'name' => '?', 'country' => ''];
$key = $info['asn'] !== '' ? $info['asn'] : $info['name'];
if (!isset($byAs[$key])) {
$byAs[$key] = ['asn' => $info['asn'], 'name' => $info['name'], 'country' => $info['country'], 'hits' => 0];
}
$byAs[$key]['hits'] += $hits;
}
usort($byAs, static fn ($a, $b) => $b['hits'] <=> $a['hits']);
return array_values($byAs);
}
/**
* Applique les groupes définis par l'admin.
* Chaque groupe : ['label' => string, 'patterns' => [string, ...]]
* Un AS est affecté au premier groupe dont un pattern est contenu dans son nom (case-insensitive).
*
* @param list<array{asn:string,name:string,country:string,hits:int}> $asList
* @param list<array{label:string,patterns:list<string>}> $groups
* @return array<string, list<array{asn:string,name:string,country:string,hits:int}>>
* clés : labels des groupes + 'Autres'
*/
public static function applyGroups(array $asList, array $groups): array
{
$result = [];
foreach ($groups as $g) {
$result[$g['label']] = [];
}
$result['Autres'] = [];
foreach ($asList as $as) {
$matched = false;
foreach ($groups as $g) {
foreach ($g['patterns'] as $pattern) {
if ($pattern !== '' && mb_stripos($as['name'], $pattern) !== false) {
$result[$g['label']][] = $as;
$matched = true;
break 2;
}
}
}
if (!$matched) {
$result['Autres'][] = $as;
}
}
return $result;
}
// ─── Cache ────────────────────────────────────────────────────────────────
private function cacheFile(string $ip): string
{
return $this->cacheDir . '/' . md5($ip) . '.json';
}
/** @return array{asn:string,name:string,country:string}|null */
private function fromCache(string $ip): ?array
{
$f = $this->cacheFile($ip);
if (!file_exists($f) || (time() - filemtime($f)) > $this->ttl) {
return null;
}
$d = json_decode((string) file_get_contents($f), true);
return is_array($d) ? $d : null;
}
/** @param array{asn:string,name:string,country:string} $data */
private function toCache(string $ip, array $data): void
{
@mkdir($this->cacheDir, 0755, true);
@file_put_contents($this->cacheFile($ip), json_encode($data));
}
// ─── API ip-api.com ───────────────────────────────────────────────────────
/**
* @param list<string> $ips
* @return array<string, array{asn:string,name:string,country:string}>
*/
private function fetchBatch(array $ips): array
{
$body = json_encode($ips);
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nContent-Length: " . strlen((string) $body) . "\r\n",
'content' => $body,
'timeout' => 10,
]]);
$resp = @file_get_contents(
'http://ip-api.com/batch?fields=query,as,org,country,countryCode',
false,
$context
);
if ($resp === false) {
return [];
}
$rows = json_decode($resp, true);
if (!is_array($rows)) {
return [];
}
$results = [];
foreach ($rows as $row) {
$ip = $row['query'] ?? '';
if ($ip === '') {
continue;
}
$asRaw = $row['as'] ?? '';
$asn = '';
if (preg_match('/^AS(\d+)/', $asRaw, $m)) {
$asn = $m[1];
}
$name = $row['org'] !== '' ? ($row['org'] ?? '') : preg_replace('/^AS\d+\s*/', '', $asRaw);
$country = $row['countryCode'] ?? '';
$results[$ip] = ['asn' => $asn, 'name' => (string) $name, 'country' => $country];
}
return $results;
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
class BookManager
{
public function __construct(private string $booksDir, private ?DataGit $git = null)
{
}
// ------------------------------------------------------------------ //
// Lecture
// ------------------------------------------------------------------ //
public function getAll(): array
{
$books = [];
if (!is_dir($this->booksDir)) {
return $books;
}
foreach (scandir($this->booksDir) as $file) {
if (!str_ends_with($file, '.json')) {
continue;
}
$raw = file_get_contents($this->booksDir . '/' . $file);
if ($raw === false) {
continue;
}
$book = json_decode($raw, true);
if (!is_array($book) || empty($book['slug'])) {
continue;
}
$books[] = $book;
}
usort($books, static fn ($a, $b) => strcmp($a['title'] ?? '', $b['title'] ?? ''));
return $books;
}
public function getBySlug(string $slug): ?array
{
$path = $this->bookPath($slug);
if (!file_exists($path)) {
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
return null;
}
$book = json_decode($raw, true);
return is_array($book) && !empty($book['slug']) ? $book : null;
}
/**
* Cherche dans quel livre se trouve un article (par son slug).
* Retourne le contexte complet ou null si l'article n'appartient à aucun livre.
*
* @return array{book: array, position: int, total: int, prev: ?string, next: ?string}|null
*/
public function findForArticle(string $articleSlug): ?array
{
foreach ($this->getAll() as $book) {
$arts = $book['articles'] ?? [];
$pos = array_search($articleSlug, $arts, true);
if ($pos === false) {
continue;
}
$pos = (int) $pos;
return [
'book' => $book,
'position' => $pos + 1,
'total' => count($arts),
'prev' => $pos > 0 ? $arts[$pos - 1] : null,
'next' => $pos < count($arts) - 1 ? $arts[$pos + 1] : null,
];
}
return null;
}
// ------------------------------------------------------------------ //
// Écriture
// ------------------------------------------------------------------ //
public function save(array $book): void
{
$slug = $this->sanitizeSlug($book['slug'] ?? '');
if ($slug === '') {
return;
}
$book['slug'] = $slug;
$book['articles'] = array_values(array_filter(array_map('strval', $book['articles'] ?? [])));
if (!is_dir($this->booksDir)) {
mkdir($this->booksDir, 0755, true);
}
file_put_contents(
$this->bookPath($slug),
json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
);
$this->git?->commit('book: ' . ($book['title'] ?? $slug));
}
public function delete(string $slug): void
{
$title = $this->getBySlug($slug)['title'] ?? $slug;
$path = $this->bookPath($slug);
if (file_exists($path)) {
@unlink($path);
}
$this->git?->commit("delete-book: $title");
}
// ------------------------------------------------------------------ //
// Helpers
// ------------------------------------------------------------------ //
private function bookPath(string $slug): string
{
return $this->booksDir . '/' . $slug . '.json';
}
public function sanitizeSlug(string $slug): string
{
$map = [
'à' => 'a', 'â' => 'a', 'ä' => 'a',
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
'î' => 'i', 'ï' => 'i',
'ô' => 'o', 'ö' => 'o',
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe',
];
$slug = mb_strtolower(strtr(trim($slug), $map), 'UTF-8');
$slug = (string) preg_replace('/[^a-z0-9]+/', '-', $slug);
return trim($slug, '-');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
class DataGit
{
public function __construct(private string $dataDir)
{
}
public function commit(string $message): void
{
if (!is_dir($this->dataDir . '/.git')) {
return;
}
$dir = escapeshellarg($this->dataDir);
$msg = escapeshellarg($message);
shell_exec("git -C $dir add -A 2>/dev/null");
exec("git -C $dir diff --cached --quiet 2>/dev/null", $_, $rc);
if ($rc !== 0) {
shell_exec("git -C $dir commit -m $msg 2>/dev/null");
}
}
}
-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;
}
}
+66 -30
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 = 'lan.acegrp.varlog-access.log',
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);
@@ -47,8 +55,7 @@ class SearchLogParser
public function isReadable(): bool
{
$f = $this->logDir . '/' . $this->vhostBase;
return file_exists($f) && is_readable($f);
return count($this->logFiles()) > 0;
}
private function cacheValid(): bool
@@ -57,32 +64,60 @@ class SearchLogParser
&& (time() - filemtime($this->cacheFile)) < $this->cacheTtl;
}
/** @return list<array{path:string,gz:bool}> */
/** @return list<array{path:string,type:string}> type: plain|gz|tgz */
private function logFiles(): array
{
$base = $this->logDir . '/' . $this->vhostBase;
$files = [];
$pattern = $this->logDir . '/' . $this->vhostBase;
$files = [];
$cutoff = time() - $this->days * 86400;
if (file_exists($base) && is_readable($base)) {
$files[] = ['path' => $base, 'gz' => false];
}
for ($i = 1; $i <= 14; $i++) {
$plain = $base . '.' . $i;
$gz = $plain . '.gz';
if (file_exists($plain) && is_readable($plain)) {
$files[] = ['path' => $plain, 'gz' => false];
} elseif (file_exists($gz) && is_readable($gz)) {
$files[] = ['path' => $gz, 'gz' => true];
// Fichiers correspondant au pattern de base (courants + rotations incluses si glob)
$bases = glob($pattern) ?: [];
// Ajouter aussi les rotations (.N, .N.gz, .N.tar.gz) pour chaque base trouvée
foreach ($bases as $base) {
// Exclure les rotations déjà capturées par le pattern glob
if (str_ends_with($base, '.gz') || preg_match('/\.\d+$/', $base)) {
continue;
}
$candidates = array_merge([$base], glob($base . '.*') ?: []);
foreach ($candidates as $path) {
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')) {
$files[] = ['path' => $path, 'type' => 'gz'];
} else {
$files[] = ['path' => $path, 'type' => 'plain'];
}
}
}
return $files;
}
private function parseFile(array $file, array &$counts): void
private function parseFile(array $file, array &$visitors): void
{
if ($file['gz']) {
if ($file['type'] === 'tgz') {
try {
$phar = new PharData($file['path']);
foreach ($phar as $entry) {
$content = @file_get_contents('phar://' . $file['path'] . '/' . $entry->getFilename());
if ($content === false) {
continue;
}
foreach (explode("\n", $content) as $line) {
$this->parseLine($line, $visitors);
}
}
} catch (\Exception $e) {
// archive illisible, on ignore
}
} elseif ($file['type'] === 'gz') {
$h = @gzopen($file['path'], 'rb');
if (!$h) {
return;
@@ -90,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);
@@ -100,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;
}
}
+225
View File
@@ -0,0 +1,225 @@
<?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();
}
}
+61 -5
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
function siteSettingsPath(): string
{
return BASE_PATH . '/data/site_settings.json';
return DATA_PATH . '/site_settings.json';
}
function siteSettings(): array
@@ -59,10 +59,62 @@ function siteLicenseUrl(): string
return siteSettings()['site_license_url'] ?? 'https://creativecommons.org/licenses/by/4.0/';
}
function saveSiteSettings(array $data): void
function apacheAccessLog(): string
{
$fromSettings = siteSettings()['apache_access_log'] ?? '';
if ($fromSettings !== '') {
return $fromSettings;
}
return (string)($_ENV['APACHE_ACCESS_LOG'] ?? getenv('APACHE_ACCESS_LOG') ?: '*-access.log');
}
function folioRepoUrl(): string
{
$fromSettings = siteSettings()['folio_repo_url'] ?? '';
if ($fromSettings !== '') {
return rtrim($fromSettings, '/');
}
return rtrim((string)($_ENV['FOLIO_REPO_URL'] ?? getenv('FOLIO_REPO_URL') ?: ''), '/');
}
function folioUpdateBranch(): string
{
$fromSettings = siteSettings()['folio_update_branch'] ?? '';
if ($fromSettings !== '') {
return $fromSettings;
}
return (string)($_ENV['FOLIO_UPDATE_BRANCH'] ?? getenv('FOLIO_UPDATE_BRANCH') ?: 'main');
}
/** @return list<array{label:string,patterns:list<string>}> */
function asGroups(): array
{
$raw = siteSettings()['as_groups'] ?? [];
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'];
$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]);
@@ -77,8 +129,12 @@ function saveSiteSettings(array $data): void
$current['posts_per_page'] = $val;
}
}
file_put_contents(
if (array_key_exists('as_groups', $data) && is_array($data['as_groups'])) {
$current['as_groups'] = $data['as_groups'];
}
return file_put_contents(
siteSettingsPath(),
json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
) !== false;
}
+4 -4
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
function smtpSettingsPath(): string
{
return BASE_PATH . '/data/smtp_settings.json';
return DATA_PATH . '/smtp_settings.json';
}
function smtpSettings(): array
@@ -35,7 +35,7 @@ function smtpCfg(string $key, string $envKey, string $default = ''): string
return ($v !== false && $v !== '') ? (string)$v : $default;
}
function saveSmtpSettings(array $data): void
function saveSmtpSettings(array $data): bool
{
$current = smtpSettings();
foreach (['host', 'port', 'secure', 'user', 'from', 'from_name'] as $key) {
@@ -46,8 +46,8 @@ function saveSmtpSettings(array $data): void
if (!empty($data['pass']) && trim((string)$data['pass']) !== '') {
$current['pass'] = trim((string)$data['pass']);
}
file_put_contents(
return file_put_contents(
smtpSettingsPath(),
json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
) !== false;
}
+194
View File
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
/**
* Lit les logs Apache et retourne les chemins /post/* les plus consultés
* sur une fenêtre temporelle donnée, en comptant les visiteurs uniques (IPs distinctes)
* avec code HTTP 200 uniquement.
*/
class TrendingParser
{
// Apache COMBINED : IP - - [timestamp] "METHOD /path HTTP/x" STATUS bytes "ref" "ua"
private const RE = '/^(\S+) \S+ \S+ \[(\d{2}\/\w+\/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] "[A-Z-]+ ([^\s"?]+)[^"]*" (\d{3}) /';
public function __construct(
private string $logDir,
private string $pattern,
) {
}
/**
* Retourne les $limit chemins les plus consultés depuis $cutoff,
* triés par nombre décroissant de visiteurs uniques.
*
* @param list<string> $prefixes ex. ['/post/'], ['/post/', '/book/']
* @return array<string, int> chemin => nb visiteurs uniques
*/
public function top(int $cutoff, int $limit = 50, array $prefixes = ['/post/']): array
{
$visitors = []; // [path][ip] = true
foreach ($this->logFiles($cutoff) as $file) {
$this->parseFile($file, $cutoff, $visitors, $prefixes);
}
$counts = [];
foreach ($visitors as $path => $ips) {
$counts[$path] = count($ips);
}
arsort($counts);
return array_slice($counts, 0, $limit, true);
}
/**
* Parse une seule fois les logs et retourne les tops séparés par préfixe.
* Plus efficace que plusieurs appels à top() sur la même période.
*
* @param array<string, int> $limits préfixe => limite
* @return array<string, array<string, int>> préfixe => (chemin => visiteurs)
*/
public function topGrouped(int $cutoff, array $limits): array
{
$prefixes = array_keys($limits);
$visitors = []; // [path][ip] = true
foreach ($this->logFiles($cutoff) as $file) {
$this->parseFile($file, $cutoff, $visitors, $prefixes);
}
$result = array_fill_keys($prefixes, []);
foreach ($visitors as $path => $ips) {
foreach ($prefixes as $prefix) {
if (str_starts_with($path, $prefix)) {
$result[$prefix][$path] = count($ips);
break;
}
}
}
foreach ($prefixes as $prefix) {
arsort($result[$prefix]);
$result[$prefix] = array_slice($result[$prefix], 0, $limits[$prefix], true);
}
return $result;
}
public function isReadable(): bool
{
return count($this->logFiles(time() - 86400)) > 0;
}
// ── Fichiers de log ───────────────────────────────────────────────────────
/** @return list<array{path:string,type:string}> */
private function logFiles(int $cutoff): array
{
$files = [];
$oldest = $cutoff - 86400; // une journée de marge pour les rotations
foreach (glob($this->logDir . '/' . $this->pattern) ?: [] as $base) {
if (str_ends_with($base, '.gz') || preg_match('/\.\d+$/', $base)) {
continue;
}
foreach (array_merge([$base], glob($base . '.*') ?: []) as $path) {
if ($path !== $base && filemtime($path) < $oldest) {
continue;
}
if (!is_readable($path)) {
continue;
}
if (str_ends_with($path, '.tar.gz')) {
$files[] = ['path' => $path, 'type' => 'tgz'];
} elseif (str_ends_with($path, '.gz')) {
$files[] = ['path' => $path, 'type' => 'gz'];
} else {
$files[] = ['path' => $path, 'type' => 'plain'];
}
}
}
return $files;
}
// ── Parsing ───────────────────────────────────────────────────────────────
private static function parseTimestamp(string $raw): int
{
if (!preg_match('/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4})/', $raw, $m)) {
return 0;
}
return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}");
}
/**
* @param array<string, array<string, true>> $visitors
* @param list<string> $prefixes
*/
private function parseLine(string $line, int $cutoff, array &$visitors, array $prefixes): void
{
if (!preg_match(self::RE, $line, $m)) {
return;
}
[, $ip, $ts, $path, $status] = $m;
if ($status !== '200') {
return;
}
if (self::parseTimestamp($ts) < $cutoff) {
return;
}
foreach ($prefixes as $prefix) {
if (str_starts_with($path, $prefix) && strlen($path) > strlen($prefix)) {
$visitors[$path][$ip] = true;
break;
}
}
}
/**
* @param array<string, array<string, true>> $visitors
* @param list<string> $prefixes
*/
private function parseFile(array $file, int $cutoff, array &$visitors, array $prefixes): void
{
if ($file['type'] === 'tgz') {
try {
$phar = new PharData($file['path']);
foreach ($phar as $entry) {
$content = @file_get_contents('phar://' . $file['path'] . '/' . $entry->getFilename());
if ($content === false) {
continue;
}
foreach (explode("\n", $content) as $line) {
$this->parseLine($line, $cutoff, $visitors, $prefixes);
}
}
} catch (\Exception) {
}
} elseif ($file['type'] === 'gz') {
$h = @gzopen($file['path'], 'rb');
if (!$h) {
return;
}
while (!gzeof($h)) {
$line = gzgets($h, 8192);
if ($line !== false) {
$this->parseLine($line, $cutoff, $visitors, $prefixes);
}
}
gzclose($h);
} else {
$h = @fopen($file['path'], 'rb');
if (!$h) {
return;
}
while (($line = fgets($h)) !== false) {
$this->parseLine($line, $cutoff, $visitors, $prefixes);
}
fclose($h);
}
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
/**
* Vérifie si une mise à jour de Folio est disponible sur le dépôt Git,
* et si des migrations de contenu sont en attente.
*
* Aucune dépendance externe : utilise file_get_contents() + cache JSON.
*/
class UpdateChecker
{
private string $dataDir;
private string $baseDir;
public function __construct(string $dataDir, string $baseDir)
{
$this->dataDir = $dataDir;
$this->baseDir = $baseDir;
}
/** Retourne la liste des alertes à afficher aux administrateurs. */
public function adminNotices(): array
{
$notices = [];
if ($this->hasPendingContentMigrations()) {
$notices[] = [
'type' => 'warning',
'message' => 'Des migrations de contenu sont en attente.',
];
}
$update = $this->checkRemoteVersion();
if ($update !== null) {
$notices[] = [
'type' => 'info',
'message' => 'Une nouvelle version de Folio est disponible : <strong>v' . htmlspecialchars($update) . '</strong>.',
];
}
return $notices;
}
// ─── Migrations de contenu en attente ────────────────────────────────────
private function hasPendingContentMigrations(): bool
{
$trackFile = $this->dataDir . '/.content_migrations.json';
$applied = [];
if (file_exists($trackFile)) {
$applied = json_decode((string) file_get_contents($trackFile), true) ?? [];
}
foreach (glob($this->baseDir . '/scripts/content/migration_*.php') ?: [] as $f) {
if (!isset($applied[basename($f)])) {
return true;
}
}
return false;
}
// ─── Vérification version distante (Gitea) ───────────────────────────────
/**
* Retourne le numéro de la version disponible si elle est supérieure
* à la version déployée, null sinon.
*/
private function checkRemoteVersion(): ?string
{
$repoUrl = folioRepoUrl();
if ($repoUrl === '') {
return null;
}
$deployedFile = $this->baseDir . '/public/version.txt';
if (!file_exists($deployedFile)) {
return null;
}
$deployedVer = trim((string) file_get_contents($deployedFile));
if ($deployedVer === '' || !preg_match('/^\d+\.\d+\.\d+/', $deployedVer)) {
return null;
}
$remoteVer = $this->fetchRemoteVersion($repoUrl);
if ($remoteVer === null) {
return null;
}
return version_compare($remoteVer, $deployedVer, '>') ? $remoteVer : null;
}
public function getBranch(): string
{
return folioUpdateBranch();
}
public function getLastChecked(): ?int
{
$cacheFile = $this->dataDir . '/.version_check_cache.json';
if (!file_exists($cacheFile)) {
return null;
}
$cache = json_decode((string) file_get_contents($cacheFile), true) ?? [];
return isset($cache['fetched_at']) ? (int) $cache['fetched_at'] : null;
}
public function clearCache(): void
{
$cacheFile = $this->dataDir . '/.version_check_cache.json';
if (file_exists($cacheFile)) {
unlink($cacheFile);
}
}
public function getLastUpgradeLog(): ?string
{
$logFile = $this->dataDir . '/.upgrade-log';
if (!file_exists($logFile)) {
return null;
}
return (string) file_get_contents($logFile);
}
/**
* Récupère `public/version.txt` depuis le dépôt Gitea.
* Résultat mis en cache 1 h dans `data/.version_check_cache.json`.
*/
private function fetchRemoteVersion(string $repoUrl): ?string
{
$cacheFile = $this->dataDir . '/.version_check_cache.json';
$ttl = 3600;
if (file_exists($cacheFile)) {
$cache = json_decode((string) file_get_contents($cacheFile), true) ?? [];
if (isset($cache['fetched_at'], $cache['version'])
&& (time() - (int) $cache['fetched_at']) < $ttl
) {
return (string) $cache['version'];
}
}
$branch = $this->getBranch();
// URL du fichier brut : {repo}/raw/branch/{branch}/public/version.txt
$rawUrl = $repoUrl . '/raw/branch/' . $branch . '/public/version.txt';
$token = (string) ($_ENV['GITEA_TOKEN'] ?? getenv('GITEA_TOKEN') ?: '');
$opts = [
'http' => [
'timeout' => 5,
'header' => $token !== '' ? "Authorization: token $token" : '',
],
];
$body = @file_get_contents($rawUrl, false, stream_context_create($opts));
if ($body === false) {
return null;
}
$version = trim($body);
if (!preg_match('/^\d+\.\d+\.\d+/', $version)) {
return null;
}
file_put_contents(
$cacheFile,
json_encode(['fetched_at' => time(), 'version' => $version]) . "\n"
);
return $version;
}
}
+92 -6
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();
@@ -19,19 +40,39 @@ function slugify(string $s): string
return trim($s, '-');
}
/** Extrait le titre depuis le premier titre Markdown `# ...` du contenu. */
function extractMarkdownTitle(string $content): string
{
foreach (explode("\n", str_replace("\r\n", "\n", $content)) as $line) {
if (preg_match('/^#\s+(.+)/', rtrim($line), $m)) {
return trim($m[1]);
}
}
return '';
}
/**
* Diff ligne-à-ligne via LCS. Retourne un tableau de [op, line] où
* op est '=' (inchangé), '-' (supprimé), '+' (ajouté).
*/
function lineDiff(string $old, string $new): array
{
$a = explode("\n", $old);
$b = explode("\n", $new);
$n = count($a);
$m = count($b);
$old = str_replace("\r\n", "\n", $old);
$new = str_replace("\r\n", "\n", $new);
$a = explode("\n", $old);
$b = explode("\n", $new);
$n = count($a);
$m = count($b);
if ($n * $m > 300000) {
return [['!', "Diff trop grand ({$n}×{$m} lignes), affichage brut."], ['-', $old], ['+', $new]];
if ($n * $m > 2_000_000) {
$diff = [['!', "Diff trop grand ({$n}×{$m} lignes) affichage simplifié."]];
foreach ($a as $line) {
$diff[] = ['-', $line];
}
foreach ($b as $line) {
$diff[] = ['+', $line];
}
return $diff;
}
$dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
@@ -133,3 +174,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;
}
+19
View File
@@ -0,0 +1,19 @@
<?php
// Page d'erreur 404 — à inclure après http_response_code(404).
// Aucune variable externe requise.
$title = '404 — ' . siteTitle();
$metaRobots = 'noindex, nofollow';
ob_start();
?>
<div class="container py-5 text-center">
<p class="display-1 fw-bold text-muted mb-0">404</p>
<h1 class="h3 mb-3">Page introuvable</h1>
<p class="text-muted mb-4">
Cette adresse ne correspond à aucun contenu.<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();
include __DIR__ . '/layout.php';
+578 -19
View File
@@ -65,6 +65,22 @@ function adminStatusBadge(array $a, int $now): string
<a class="nav-link <?= $tab === 'searches' ? 'active' : '' ?>"
href="/admin/searches">Recherches</a>
</li>
<li class="nav-item">
<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>
</li>
<?php endif; ?>
</ul>
@@ -91,6 +107,81 @@ function adminStatusBadge(array $a, int $now): string
<?php endforeach; ?>
</div>
<!-- Version Folio ──────────────────────────────────────────────────────── -->
<?php
$_deployedVer = trim((string) @file_get_contents(BASE_PATH . '/public/version.txt'));
$_deployedLabel = $_deployedVer !== '' ? $_deployedVer : '—';
$_notices = isset($_updateChecker) ? $_updateChecker->adminNotices() : [];
$_branch = isset($_updateChecker) ? $_updateChecker->getBranch() : 'main';
$_lastChecked = isset($_updateChecker) ? $_updateChecker->getLastChecked() : null;
$_upgradeLog = isset($_updateChecker) ? $_updateChecker->getLastUpgradeLog() : null;
$_repoConfigured = folioRepoUrl() !== '';
$_remoteLabel = '—';
foreach ($_notices as $_n) {
if ($_n['type'] === 'info' && preg_match('/v([\d]+\.[\d]+\.[\d]+)/', $_n['message'], $_m)) {
$_remoteLabel = $_m[1];
}
}
?>
<div class="card mb-4">
<div class="card-header bg-transparent py-2 small fw-semibold">Moteur Folio</div>
<div class="card-body py-2">
<table class="table table-sm table-borderless mb-0 small">
<tbody>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap" style="width:160px">Version déployée</th>
<td><?= htmlspecialchars($_deployedLabel) ?></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Dernière version disponible</th>
<td class="d-flex align-items-center gap-2 flex-wrap">
<span><?= htmlspecialchars($_remoteLabel) ?></span>
<?php if ($_remoteLabel !== '—' && $_remoteLabel !== $_deployedLabel): ?>
<form method="POST" action="/?action=run_engine_update" class="d-inline">
<button type="submit" class="btn btn-primary btn-sm">Mettre à jour vers v<?= htmlspecialchars($_remoteLabel) ?></button>
</form>
<?php elseif ($_repoConfigured): ?>
<form method="POST" action="/?action=force_update_check" class="d-inline">
<button type="submit" class="btn btn-outline-secondary btn-sm py-0">Vérifier</button>
</form>
<?php else: ?>
<span class="text-muted small">(<code>FOLIO_REPO_URL</code> non configuré)</span>
<?php endif; ?>
</td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Branche suivie</th>
<td><code><?= htmlspecialchars($_branch) ?></code><?= $_lastChecked !== null ? ' <span class="text-muted ms-2">· vérifié le ' . date('d/m/Y à H:i', $_lastChecked) . '</span>' : '' ?></td>
</tr>
<?php if (($_GET['notice'] ?? '') === 'engine_updated'): ?>
<tr><td colspan="2"><div class="alert alert-success py-1 mb-0 small">Moteur mis à jour avec succès.</div></td></tr>
<?php elseif (($_GET['notice'] ?? '') === 'upgrade_error'): ?>
<tr><td colspan="2">
<div class="alert alert-danger py-1 mb-0 small">
Erreur lors de la mise à jour.
<?php if (!empty($_SESSION['_upgrade_log'])): ?>
<pre class="mt-1 mb-0 small"><?= htmlspecialchars($_SESSION['_upgrade_log']) ?></pre>
<?php unset($_SESSION['_upgrade_log']); ?>
<?php endif; ?>
</div>
</td></tr>
<?php endif; ?>
<?php if ($_upgradeLog !== null): ?>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap align-top">Journal</th>
<td>
<details>
<summary class="small text-muted" style="cursor:pointer">Dernière mise à jour</summary>
<pre class="mt-1 mb-0 small"><?= htmlspecialchars($_upgradeLog) ?></pre>
</details>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<h5>Activité récente</h5>
<table class="table table-sm table-hover">
<thead>
@@ -122,8 +213,34 @@ function adminStatusBadge(array $a, int $now): string
<!-- ─────────────────────────── ARTICLES ─────────────────────────── -->
<?php elseif ($tab === 'articles'): ?>
<?php
$_sortBy = $adminData['sort_by'] ?? 'updated';
$_sortDir = $adminData['sort_dir'] ?? 'desc';
$_mkSortUrl = function (string $col) use ($_sortBy, $_sortDir, $adminData): string {
$dir = ($_sortBy === $col && $_sortDir === 'asc') ? 'desc' : 'asc';
$p = 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'] ?? '',
], fn ($v) => $v !== '');
$p['sort'] = $col;
$p['dir'] = $dir;
return '/admin/articles?' . http_build_query($p);
};
$_sortIcon = function (string $col) use ($_sortBy, $_sortDir): string {
if ($_sortBy !== $col) {
return '<span class="text-muted ms-1" style="font-size:.75em">↕</span>';
}
return '<span class="ms-1" style="font-size:.75em">' . ($_sortDir === 'asc' ? '↑' : '↓') . '</span>';
};
?>
<!-- Filtres -->
<form class="row g-2 align-items-center mb-3" method="get" action="/admin/articles">
<input type="hidden" name="sort" value="<?= htmlspecialchars($_sortBy) ?>">
<input type="hidden" name="dir" value="<?= htmlspecialchars($_sortDir) ?>">
<?php if (isAdmin() && !empty($adminData['filter_authors'])): ?>
<div class="col-auto">
<select name="filter_author" class="form-select form-select-sm">
@@ -158,9 +275,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; ?>
@@ -181,8 +308,8 @@ function adminStatusBadge(array $a, int $now): string
<input class="form-check-input" type="checkbox" id="check-all">
<label class="form-check-label small text-muted" for="check-all">Tout sélectionner</label>
</div>
<button type="submit" class="btn btn-danger btn-sm"
onclick="return document.querySelectorAll('.bulk-check:checked').length > 0 && confirm('Supprimer les articles sélectionnés ? Cette action est irréversible.')">
<button type="submit" id="bulk-delete-btn" class="btn btn-danger btn-sm"
data-confirm-bulk="Supprimer les articles sélectionnés ? Cette action est irréversible.">
Supprimer la sélection
</button>
</div>
@@ -190,11 +317,22 @@ function adminStatusBadge(array $a, int $now): string
<thead>
<tr>
<th style="width:2rem"></th>
<th>Titre</th>
<th>
<a href="<?= htmlspecialchars($_mkSortUrl('title')) ?>"
class="text-decoration-none text-reset">
Titre<?= $_sortIcon('title') ?>
</a>
</th>
<?php if (isAdmin()): ?><th>Auteur</th><?php endif; ?>
<th>Catégorie</th>
<th>Statut</th>
<th>Date</th>
<th title="À la une">★</th>
<th>
<a href="<?= htmlspecialchars($_mkSortUrl('published')) ?>"
class="text-decoration-none text-reset">
Date<?= $_sortIcon('published') ?>
</a>
</th>
<th></th>
</tr>
</thead>
@@ -215,18 +353,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; ?>
@@ -415,6 +588,8 @@ function adminStatusBadge(array $a, int $now): string
<?php if (!empty($siteSettingsSaved)): ?>
<div class="alert alert-success py-2 mb-3">Paramètres enregistrés.</div>
<?php elseif (!empty($siteSettingsError)): ?>
<div class="alert alert-danger py-2 mb-3">Impossible d'enregistrer : le fichier n'est pas accessible en écriture.</div>
<?php endif; ?>
<div class="card" style="max-width:540px">
@@ -472,6 +647,33 @@ function adminStatusBadge(array $a, int $now): string
</div>
</div>
<?php if (($_GET['notice'] ?? '') === 'folio_saved'): ?>
<div class="alert alert-success py-2 mt-3 small">Configuration Folio enregistrée.</div>
<?php elseif (($_GET['notice'] ?? '') === 'folio_error'): ?>
<div class="alert alert-danger py-2 mt-3 small">Impossible d'enregistrer.</div>
<?php endif; ?>
<div class="card mt-4" style="max-width:540px">
<div class="card-header bg-transparent py-2 small fw-semibold">Mises à jour du moteur</div>
<div class="card-body">
<form method="POST" action="/?action=admin_save_folio_config">
<div class="mb-3">
<label class="form-label small fw-semibold mb-1">URL du dépôt Folio</label>
<input type="url" name="folio_repo_url" class="form-control form-control-sm font-monospace"
placeholder="https://git.abonnel.fr/cedricAbonnel/folio"
value="<?= htmlspecialchars(folioRepoUrl()) ?>">
<div class="form-text">Sans slash final. Laissez vide pour utiliser <code>FOLIO_REPO_URL</code> du .env.</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold mb-1">Branche suivie</label>
<input type="text" name="folio_update_branch" class="form-control form-control-sm font-monospace"
placeholder="main"
value="<?= htmlspecialchars(folioUpdateBranch()) ?>">
</div>
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
</form>
</div>
</div>
<?php endif; ?>
<!-- ─────────────────────────── CATÉGORIES & TAGS ─────────────────── -->
@@ -627,6 +829,8 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<?php if (isset($_GET['saved'])): ?>
<div class="alert alert-success py-2 mb-3">Paramètres SMTP enregistrés.</div>
<?php elseif (($_GET['error'] ?? '') === 'write'): ?>
<div class="alert alert-danger py-2 mb-3">Impossible d'enregistrer : le fichier n'est pas accessible en écriture.</div>
<?php endif; ?>
<div class="row g-4">
@@ -830,17 +1034,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; ?>
@@ -1005,11 +1205,42 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<!-- ─────────────────────────── RECHERCHES ─────────────────────────── -->
<?php if ($tab === 'searches' && isAdmin()): ?>
<?php if (isset($_GET['saved'])): ?>
<div class="alert alert-success py-2 mb-3">Configuration enregistrée.</div>
<?php elseif (($_GET['error'] ?? '') === 'write'): ?>
<div class="alert alert-danger py-2 mb-3">Impossible d'enregistrer : le fichier n'est pas accessible en écriture.</div>
<?php endif; ?>
<div class="card mb-4" style="max-width:540px">
<div class="card-header bg-transparent py-2 small fw-semibold">Configuration des logs</div>
<div class="card-body py-3">
<form method="post" action="/?action=admin_save_searches_config">
<div class="mb-3">
<label for="apache-access-log" class="form-label small fw-semibold">Pattern des logs d'accès</label>
<input type="text" id="apache-access-log" name="apache_access_log"
class="form-control form-control-sm font-monospace"
value="<?= htmlspecialchars(apacheAccessLog()) ?>"
maxlength="200" placeholder="ex : *-access.log">
<div class="form-text">Pattern glob dans <code>/var/log/apache2/</code>. Les rotations (<code>.gz</code>, <code>.tar.gz</code>) sont automatiquement incluses.</div>
</div>
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
</form>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<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)): ?>
@@ -1030,7 +1261,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>
@@ -1061,7 +1292,335 @@ 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()): ?>
<?php if (($_GET['deleted'] ?? '') === '1'): ?>
<div class="alert alert-success py-2 small mb-3">Livre supprimé.</div>
<?php endif; ?>
<div class="row g-4">
<!-- Liste des livres -->
<div class="col-md-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">
Livres
<span class="badge bg-secondary ms-1"><?= count($adminData['books']) ?></span>
</h5>
<a href="/admin/books?new=1" class="btn btn-sm btn-primary">+ Nouveau</a>
</div>
<?php if (empty($adminData['books'])): ?>
<p class="text-muted small">Aucun livre pour l'instant.</p>
<?php else: ?>
<div class="list-group">
<?php foreach ($adminData['books'] as $bk):
$isEdited = ($adminData['edit_book']['slug'] ?? '') === $bk['slug'];
?>
<a href="/admin/books?edit=<?= rawurlencode($bk['slug']) ?>"
class="list-group-item list-group-item-action<?= $isEdited ? ' active' : '' ?>">
<div class="fw-medium"><?= htmlspecialchars($bk['title']) ?></div>
<div class="small <?= $isEdited ? 'text-white-50' : 'text-muted' ?>">
<?= count($bk['articles'] ?? []) ?> page<?= count($bk['articles'] ?? []) > 1 ? 's' : '' ?>
· <a href="/book/<?= rawurlencode($bk['slug']) ?>" target="_blank"
class="<?= $isEdited ? 'text-white-50' : 'text-muted' ?>">Voir ↗</a>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Formulaire édition / création -->
<div class="col-md-8">
<?php if (($adminData['edit_book'] ?? null) !== null): ?>
<?php $eb = $adminData['edit_book']; ?>
<h5>Modifier le livre</h5>
<?php if (($_GET['saved'] ?? '') === '1'): ?>
<div class="alert alert-success py-2 small">Livre sauvegardé.</div>
<?php endif; ?>
<form method="POST" action="/?action=book_save">
<input type="hidden" name="slug" value="<?= htmlspecialchars($eb['slug']) ?>">
<div class="mb-3">
<label class="form-label small fw-medium">Slug (identifiant URL)</label>
<input type="text" class="form-control bg-light" value="<?= htmlspecialchars($eb['slug']) ?>" readonly>
<div class="form-text">Le slug ne peut pas être modifié après création.</div>
</div>
<div class="mb-3">
<label class="form-label small fw-medium">Titre</label>
<input type="text" name="title" class="form-control" required
value="<?= htmlspecialchars($eb['title'] ?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label small fw-medium">Description</label>
<textarea name="description" class="form-control" rows="2"><?= htmlspecialchars($eb['description'] ?? '') ?></textarea>
</div>
<div class="mb-3">
<label class="form-label small fw-medium">Pages (slugs dans l'ordre, un par ligne)</label>
<textarea name="articles" class="form-control font-monospace"
id="book-articles-ta"
rows="<?= max(6, count($eb['articles'] ?? []) + 2) ?>"><?= htmlspecialchars(implode("\n", $eb['articles'] ?? [])) ?></textarea>
<div class="form-text">Un slug par ligne. L'ordre définit la navigation précédent/suivant.</div>
</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
$alreadyIn = $eb['articles'] ?? [];
foreach ($adminData['all_articles'] as $aa):
if (in_array($aa['slug'] ?? '', $alreadyIn, true)) {
continue;
}
?>
<option value="<?= htmlspecialchars($aa['slug'] ?? '') ?>">
<?= htmlspecialchars($aa['title']) ?>
<?= !$aa['published'] ? ' (brouillon)' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Sauvegarder</button>
<a href="/book/<?= rawurlencode($eb['slug']) ?>" target="_blank"
class="btn btn-outline-secondary">Voir le livre ↗</a>
</div>
</form>
<hr class="my-4">
<form method="POST" action="/?action=book_delete"
data-confirm="Supprimer le livre « <?= htmlspecialchars($eb['title']) ?> » ? Les pages resteront intactes.">
<input type="hidden" name="slug" value="<?= htmlspecialchars($eb['slug']) ?>">
<button type="submit" class="btn btn-outline-danger btn-sm">🗑 Supprimer ce livre</button>
</form>
<?php elseif (isset($_GET['new'])): ?>
<h5>Nouveau livre</h5>
<form method="POST" action="/?action=book_save">
<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" 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>
<textarea name="description" class="form-control" rows="2" placeholder="Courte description…"></textarea>
</div>
<input type="hidden" name="articles" value="">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Créer le livre</button>
<a href="/admin/books" class="btn btn-outline-secondary">Annuler</a>
</div>
</form>
<?php else: ?>
<p class="text-muted mt-2">
<?php if (!empty($adminData['books'])): ?>
Sélectionnez un livre à gauche pour le modifier, ou créez-en un nouveau.
<?php else: ?>
Cliquez sur <strong>+ Nouveau</strong> pour créer votre premier livre.
<?php endif; ?>
</p>
<?php endif; ?>
</div>
</div>
<script src="/assets/js/admin.js" defer></script>
<?php endif; ?>
<?php if ($tab === 'ia' && isAdmin()): ?>
<?php
$content = ob_get_clean();
$_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'; ?>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = 'Administration — ' . siteTitle();
include __DIR__ . '/layout.php';
+138
View File
@@ -0,0 +1,138 @@
<?php
$_statsSaved = isset($_GET['saved']);
$_statsError = ($_GET['error'] ?? '') === 'write';
$_readable = $adminData['stats_readable'] ?? false;
$_books = $adminData['stats_books'] ?? [];
$_asList = $adminData['stats_as'] ?? [];
$_pagesByDay = $adminData['stats_pages_by_day'] ?? [];
$_ipData = $adminData['stats_ip_data'] ?? [];
$_botPatterns = $adminData['bot_patterns'] ?? [];
$_allUas = $adminData['stats_all_uas'] ?? [];
$_uniqueVisitors = $adminData['stats_unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0];
$_excludedAs = $adminData['excluded_as'] ?? [];
?>
<?php if ($_statsSaved): ?>
<div class="alert alert-success py-2 mb-3">Configuration enregistrée.</div>
<?php elseif ($_statsError): ?>
<div class="alert alert-danger py-2 mb-3">Impossible d'enregistrer : fichier non accessible en écriture.</div>
<?php endif; ?>
<?php if (!$_readable): ?>
<div class="alert alert-warning">
Les logs ne sont pas lisibles. Vérifiez le pattern dans l'onglet <a href="/admin/searches">Recherches</a>
et que <code>www-data</code> appartient au groupe <code>adm</code>.
</div>
<?php else: ?>
<p class="text-muted small mb-4">30 derniers jours · tous les chemins · flux RSS XML</p>
<script>
var FOLIO_PAGES_BY_DAY = <?= json_encode($_pagesByDay, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_AS_LIST = <?= json_encode($_asList, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_IP_DATA = <?= json_encode($_ipData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_BOT_PATTERNS = <?= json_encode($_botPatterns, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_ALL_UAS = <?= json_encode($_allUas, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_CSRF = <?= json_encode($_session['csrf'] ?? '', JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_UNIQUE_VISITORS = <?= json_encode($_uniqueVisitors, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_EXCLUDED_AS = <?= json_encode($_excludedAs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>
<div id="stats-summary-container"></div>
<div class="card mb-4">
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
<span>Pages les plus visitées</span>
<span class="text-muted" id="stats-pages-count"></span>
</div>
<div class="card-body p-0" id="stats-pages-container">
<p class="text-muted p-3 mb-0">Chargement…</p>
</div>
<div class="card-footer bg-transparent border-top px-3 pt-3 pb-2" id="stats-trend-container"></div>
<div class="card-footer bg-transparent border-top px-3 pt-3 pb-2" id="stats-multiline-container"></div>
</div>
<div class="card mb-4">
<div class="card-header bg-transparent py-2 small fw-semibold">Visiteurs par pays</div>
<div class="card-body p-3" id="stats-country-container">
<p class="text-muted mb-0">Chargement…</p>
</div>
</div>
<div class="row g-4">
<!-- Livres -->
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
<span>Livres consultés</span>
<span class="text-muted"><?= count($_books) ?> livres</span>
</div>
<div class="card-body p-0">
<?php if (empty($_books)): ?>
<p class="text-muted p-3 mb-0">Aucun accès à <code>/book/</code> dans les logs.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 small">
<tbody>
<?php
$maxB = max($_books) ?: 1;
$rankB = 0;
foreach ($_books as $url => $hits):
$rankB++;
$slug = rawurldecode(substr($url, 6));
$pct = round($hits / $maxB * 100);
?>
<tr>
<td class="text-muted ps-3" style="width:2rem"><?= $rankB ?></td>
<td>
<a href="<?= htmlspecialchars($url) ?>" target="_blank"
class="text-decoration-none text-truncate d-block" style="max-width:260px"
title="<?= htmlspecialchars($slug) ?>">
<?= htmlspecialchars($slug) ?>
</a>
<div class="progress mt-1" style="height:3px">
<div class="progress-bar bg-success" style="width:<?= $pct ?>%"></div>
</div>
</td>
<td class="text-end fw-semibold pe-3"><?= number_format($hits, 0, ',', '\u{202F}') ?> <span class="text-muted fw-normal">vis.</span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div><!-- /row -->
<!-- Agents détectés -->
<div class="card mt-4">
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between align-items-center">
<span>Agents détectés <span class="text-muted fw-normal" id="agents-count"></span></span>
<button class="btn btn-sm btn-outline-secondary py-0" type="button"
data-bs-toggle="collapse" data-bs-target="#agents-edit-panel">
Gérer les patterns
</button>
</div>
<div class="card-body p-0" id="stats-agents-container">
<p class="text-muted p-3 mb-0">Chargement…</p>
</div>
<!-- Panneau d'édition des patterns bots -->
<div id="agents-edit-panel" class="collapse">
<div class="card-footer bg-transparent border-top p-3">
<p class="small text-muted mb-2">Un pattern par ligne (correspondance insensible à la casse, recherche partielle dans le User-Agent).</p>
<form method="post" action="/?action=admin_save_bots">
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($_session['csrf'] ?? '') ?>">
<textarea name="bot_patterns" class="form-control form-control-sm font-monospace mb-2"
rows="12" style="font-size:.75rem"><?= htmlspecialchars(implode("\n", $_botPatterns)) ?></textarea>
<button type="submit" class="btn btn-sm btn-primary">Enregistrer les patterns</button>
</form>
</div>
</div>
</div>
<?php endif; // readable?>
<script src="/assets/js/admin-stats.js" defer></script>
+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']);
+62
View File
@@ -0,0 +1,62 @@
<?php ob_start(); ?>
<div class="book-page">
<div class="book-header mb-4">
<p class="book-label">Livre</p>
<h1 class="h2 mb-2"><?= htmlspecialchars($book['title']) ?></h1>
<?php if (!empty($book['description'])): ?>
<p class="lead text-muted"><?= htmlspecialchars($book['description']) ?></p>
<?php endif; ?>
<p class="text-muted small"><?= count($bookArticles) ?> page<?= count($bookArticles) > 1 ? 's' : '' ?></p>
</div>
<?php if (empty($bookArticles)): ?>
<p class="text-muted">Ce livre ne contient pas encore de pages publiées.</p>
<?php else: ?>
<ol class="book-chapters">
<?php foreach ($bookArticles as $i => $a):
$cat = trim($a['category'] ?? '');
$gradient = coverGradient($cat !== '' ? $cat : $a['uuid'], $allCats);
$cover = $a['cover'] ?? '';
$date = $a['published_at'] ? date('d/m/Y', strtotime((string)$a['published_at'])) : '';
?>
<li class="book-chapter">
<a href="/post/<?= rawurlencode($a['slug'] ?? '') ?>" class="book-chapter-link">
<span class="book-chapter-num"><?= $i + 1 ?></span>
<div class="book-chapter-thumb" style="<?= $cover !== ''
? 'background-image:url(/file?uuid=' . rawurlencode($a['uuid']) . '&name=' . rawurlencode($cover) . ');background-size:cover;background-position:center'
: 'background:' . htmlspecialchars($gradient) ?>">
</div>
<div class="book-chapter-body">
<div class="book-chapter-title"><?= htmlspecialchars($a['title'] ?? '') ?></div>
<div class="book-chapter-meta">
<?php if ($cat !== ''): ?><?= htmlspecialchars($cat) ?><?php endif; ?>
<?php if ($cat !== '' && $date !== ''): ?> · <?php endif; ?>
<?php if ($date !== ''): ?><?= $date ?><?php endif; ?>
</div>
<?php if (!$a['published']): ?>
<span class="badge bg-secondary small">Brouillon</span>
<?php endif; ?>
</div>
</a>
</li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<div class="mt-4 text-end">
<a href="/admin/books?edit=<?= rawurlencode($book['slug']) ?>" class="btn btn-sm btn-outline-secondary">
✎ Modifier ce livre
</a>
</div>
<?php endif; ?>
</div>
<?php
$content = ob_get_clean();
$title = htmlspecialchars($book['title']) . ' — ' . siteTitle();
$canonical = rtrim(APP_URL, '/') . '/book/' . rawurlencode($book['slug']);
include __DIR__ . '/layout.php';
+49
View File
@@ -0,0 +1,49 @@
<?php
ob_start();
?>
<div class="container py-4">
<div class="d-flex align-items-baseline justify-content-between mb-4">
<h1 class="h3 mb-0">Livres</h1>
<span class="text-muted small"><?= count($booksData) ?> livre<?= count($booksData) > 1 ? 's' : '' ?></span>
</div>
<?php if (empty($booksData)): ?>
<p class="text-muted">Aucun livre disponible pour l'instant.</p>
<?php else: ?>
<div class="book-grid">
<?php foreach ($booksData as $_bd):
$_book = $_bd['book'];
$_first = $_bd['first'];
$_count = $_bd['count'];
$_cover = $_first['cover'] ?? '';
$_cat = trim($_first['category'] ?? '');
$_coverStyle = $_cover !== ''
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . '&name=' . rawurlencode($_cover) . "')"
: 'background:' . coverGradient($_cat !== '' ? $_cat : $_first['uuid'], $allCats ?? []);
?>
<a href="/book/<?= rawurlencode($_book['slug']) ?>" class="book-home-card">
<div class="book-home-card-cover" style="<?= $_coverStyle ?>"></div>
<div class="book-home-card-body">
<div class="book-home-card-title"><?= htmlspecialchars($_book['title']) ?></div>
<?php if (!empty($_book['description'])): ?>
<div class="book-home-card-desc"><?= htmlspecialchars(mb_strimwidth($_book['description'], 0, 100, '…')) ?></div>
<?php endif; ?>
<div class="book-home-card-meta"><?= $_count ?> page<?= $_count > 1 ? 's' : '' ?></div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php
$content = ob_get_clean();
$title = 'Livres — ' . siteTitle();
$metaRobots = 'index, follow';
$canonical = rtrim((string)($_ENV['APP_URL'] ?? getenv('APP_URL') ?: ''), '/') . '/books';
include BASE_PATH . '/templates/layout.php';
+9 -4
View File
@@ -9,13 +9,17 @@
// $commentError — string|null (message d'erreur)
$_reactionDefs = [
'useful' => ['👍', 'Utile'],
'important' => ['🔥', 'Important'],
'interesting' => ['🤔', 'À creuser'],
'useful' => ['👍', 'Utile'],
];
$_csrfToken = bin2hex(random_bytes(16));
$_SESSION['comment_csrf'] = $_csrfToken;
setcookie('_csrf_c', $_csrfToken, [
'expires' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Strict',
]);
?>
<?php if (!empty($alsoReadArticles ?? [])): ?>
@@ -136,3 +140,4 @@ $_SESSION['comment_csrf'] = $_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: ?>
+1 -1
View File
@@ -60,7 +60,7 @@ $preSource = $step2Meta['canonical'] ?? $step2Meta['source'] ?? $step2Url;
<div class="mb-4">
<p class="fw-semibold small mb-2">Aperçu de la page</p>
<?php
$previewMtime = @filemtime(BASE_PATH . '/data/' . $step2Article['uuid'] . '/files/' . $step2Screenshot) ?: time();
$previewMtime = @filemtime(DATA_PATH . '/' . $step2Article['uuid'] . '/files/' . $step2Screenshot) ?: time();
?>
<img src="/file?uuid=<?= rawurlencode($step2Article['uuid']) ?>&name=<?= rawurlencode($step2Screenshot) ?>&v=<?= $previewMtime ?>"
class="img-fluid rounded shadow-sm d-block"
+40 -4
View File
@@ -46,16 +46,27 @@
<!-- 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="<?= _av($_pub, 'js/density-fouc.js') ?>"></script>
<header>
<nav class="navbar navbar-expand-lg navbar-dark mb-0" role="navigation" aria-label="Navigation principale">
<div class="container-fluid">
<?php
$_layoutAction = $_GET['action'] ?? 'list';
$_layoutAction = $_GET['action'] ?? 'list';
$_layoutPrivateCats = isset($articles) ? $articles->getPrivateCategories() : [];
$_layoutCats = isset($articles) ? array_filter(
$articles->getCategories(),
@@ -106,6 +117,25 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? '');
</nav>
</header>
<?php if (function_exists('isAdmin') && isAdmin() && isset($_updateChecker)):
$_adminNotices = $_updateChecker->adminNotices();
foreach ($_adminNotices as $_notice):
$_isWarning = $_notice['type'] === 'warning'; ?>
<div class="alert alert-<?= $_isWarning ? 'warning' : 'info' ?> alert-dismissible rounded-0 border-0 border-bottom py-2 mb-0 small" role="alert">
<div class="container-fluid d-flex align-items-center gap-2 flex-wrap">
<span><?= $_notice['message'] ?></span>
<?php if ($_isWarning): ?>
<form method="POST" action="/?action=run_content_migrations" class="d-inline">
<button type="submit" class="btn btn-warning btn-sm py-0">Mettre à jour</button>
</form>
<?php else: ?>
<a href="/admin?tab=dashboard" class="btn btn-outline-primary btn-sm py-0">Voir dans l'admin</a>
<?php endif; ?>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Fermer"></button>
</div>
</div>
<?php endforeach; endif; ?>
<main class="<?= htmlspecialchars($mainClass ?? 'container') ?>" role="main">
<?= $content ?>
</main>
@@ -130,9 +160,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>
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mise à jour en cours</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8f9fa;color:#212529}
.box{text-align:center;padding:2.5rem 2rem;max-width:420px}
.icon{font-size:2.5rem;margin-bottom:1rem}
h1{font-size:1.4rem;font-weight:600;margin-bottom:.6rem}
p{color:#6c757d;line-height:1.6}
</style>
</head>
<body>
<div class="box">
<div class="icon">⚙️</div>
<h1>Mise à jour en cours</h1>
<p>Le site est temporairement indisponible pendant une mise à jour.<br>Merci de réessayer dans quelques instants.</p>
</div>
</body>
</html>
+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'] ?? []; ?>
+54 -15
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
@@ -88,9 +94,7 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
</form>
<p class="hero-search-stats">
<?= $totalPublished ?> article<?= $totalPublished > 1 ? 's' : '' ?>
<?php if ($totalUpcoming > 0): ?>
· <?= $totalUpcoming ?> à venir
<?php endif; ?>
<?php if ($totalUpcoming > 0): ?>· <?= $totalUpcoming ?> à venir<?php endif; ?>
</p>
</div>
@@ -155,19 +159,14 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
</section>
<?php endif; ?>
<?php /* ─── Tendances ───────────────────────────────────────────────────── */ ?>
<?php if (!empty($popularPosts)): ?>
<section class="home-section">
<?php /* ─── Meilleures audiences (AJAX — flux RSS XML /trending?period=1h) ── */ ?>
<section class="home-section" id="home-audiences-section" hidden>
<h2 class="home-section-title">
Tendances <span class="home-section-title-sub">· 10 derniers jours</span>
Meilleures audiences <span class="home-section-title-sub">· 1 heure</span>
</h2>
<div class="post-grid">
<?php foreach ($popularPosts as $_pp): ?>
<?php _renderCard($_pp, $privateCats ?? [], $allCats ?? [], $Parsedown); ?>
<?php endforeach; ?>
</div>
<div class="post-grid" id="home-audiences-grid"></div>
</section>
<?php endif; ?>
<script src="/assets/js/trending-home.js"></script>
<?php /* ─── Récemment mis à jour ──────────────────────────────────────── */ ?>
<?php if (!empty($recentlyUpdated)): ?>
@@ -215,6 +214,38 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
</section>
<?php endif; ?>
<?php if (!empty($homeBooks ?? [])): ?>
<section class="home-section">
<h2 class="home-section-title">
Livres
<a href="/books" class="home-section-more">Voir tous →</a>
</h2>
<div class="book-grid">
<?php foreach ($homeBooks as $_hb):
$_book = $_hb['book'];
$_first = $_hb['first'];
$_count = $_hb['count'];
$_cover = $_first['cover'] ?? '';
$_cat = trim($_first['category'] ?? '');
$_coverStyle = $_cover !== ''
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . '&name=' . rawurlencode($_cover) . "')"
: 'background:' . coverGradient($_cat !== '' ? $_cat : $_first['uuid'], $allCats ?? []);
?>
<a href="/book/<?= rawurlencode($_book['slug']) ?>" class="book-home-card">
<div class="book-home-card-cover" style="<?= $_coverStyle ?>"></div>
<div class="book-home-card-body">
<div class="book-home-card-title"><?= htmlspecialchars($_book['title']) ?></div>
<?php if (!empty($_book['description'])): ?>
<div class="book-home-card-desc"><?= htmlspecialchars(mb_strimwidth($_book['description'], 0, 80, '…')) ?></div>
<?php endif; ?>
<div class="book-home-card-meta"><?= $_count ?> page<?= $_count > 1 ? 's' : '' ?></div>
</div>
</a>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php else: /* ─── VUE PAGINÉE / FILTRÉE ─────────────────────────────── */ ?>
<?php if ($cursor === '' && $filterCat === ''): ?>
@@ -244,7 +275,7 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
<?php if ($prevCursor !== null || $nextCursor !== null): ?>
<nav class="pagination-nav mt-5" aria-label="Navigation">
<?php
$hasCat = $filterCat !== '';
$hasCat = $filterCat !== '';
$catBase = $hasCat ? '/categorie/' . rawurlencode($filterCat) : null;
?>
<?php if ($prevCursor !== null): ?>
@@ -302,6 +333,14 @@ if (!empty($_tagCats)):
<a href="/new" class="fab-new" title="Nouvel article" aria-label="Nouvel article">+</a>
<?php endif; ?>
<div class="density-widget" id="density-toggle-widget" role="group" aria-label="Taille d'affichage">
<button type="button" class="density-btn" data-d="l" title="Pleine largeur">L</button>
<button type="button" class="density-btn" data-d="m" title="Normal">M</button>
<button type="button" class="density-btn" data-d="s" title="Compact">S</button>
</div>
<script src="/assets/js/density.js"></script>
<?php
$content = ob_get_clean();
$title = siteTitle();
+218 -29
View File
@@ -9,30 +9,62 @@ $_accentMap = [
];
$_tocItems = [];
$_tocSeen = [];
$_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($rawContent)
);
// 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();
@@ -68,6 +100,19 @@ $externalLinks = $article['external_links'] ?? [];
<!-- Colonne principale -->
<div class="col">
<?php if (!empty($bookContext)): ?>
<div class="book-article-banner mb-3">
<a href="/book/<?= rawurlencode($bookContext['book']['slug']) ?>" class="book-article-banner-link">
<span class="book-article-banner-icon">📖</span>
<span class="book-article-banner-text">
Chapitre <?= $bookContext['position'] ?>/<?= $bookContext['total'] ?> —
<strong><?= htmlspecialchars($bookContext['book']['title']) ?></strong>
</span>
<span class="book-article-banner-cta">Voir le sommaire →</span>
</a>
</div>
<?php endif; ?>
<div class="card mb-4">
<?php if (!$article['published']): ?>
<div class="draft-ribbon">Brouillon</div>
@@ -80,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) . '"';
@@ -121,18 +174,41 @@ $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>
<?php
$_v30 = (int) ($articleVisitors[30] ?? $articleVisitors['30'] ?? 0);
$_v14 = (int) ($articleVisitors[14] ?? $articleVisitors['14'] ?? 0);
$_v7 = (int) ($articleVisitors[7] ?? $articleVisitors['7'] ?? 0);
if ($_v30 > 0):
?>
<p class="article-hero-visitors" style="margin-top:.5rem;font-size:.82rem;color:#fff;display:inline-block;background:rgba(0,0,0,.45);backdrop-filter:blur(4px);padding:.15rem .55rem;border-radius:.35rem">
<span title="Lecteurs uniques sur 7 jours">
<strong><?= number_format($_v7, 0, ',', "\xE2\x80\xAF") ?></strong>&thinsp;<span style="opacity:.7;font-size:.75em">/ 7 j</span>
</span>
<span style="opacity:.45">&nbsp;·&nbsp;</span>
<span title="Lecteurs uniques sur 14 jours">
<strong><?= number_format($_v14, 0, ',', "\xE2\x80\xAF") ?></strong>&thinsp;<span style="opacity:.7;font-size:.75em">/ 14 j</span>
</span>
<span style="opacity:.45">&nbsp;·&nbsp;</span>
<span title="Lecteurs uniques sur 30 jours">
<strong><?= number_format($_v30, 0, ',', "\xE2\x80\xAF") ?></strong>&thinsp;<span style="opacity:.7;font-size:.75em">/ 30 j</span>
</span>
<span style="opacity:.65">&nbsp;lecteurs</span>
</p>
<?php endif;
unset($_v30, $_v14, $_v7); ?>
</div>
<div class="article-hero-right">
<?php if ($hasSources): ?>
<a href="/sources/<?= rawurlencode($article['uuid']) ?>" class="hero-btn"> Sources</a>
<?php endif; ?>
<?php
$_heroReactionDefs = [
'useful' => ['👍', 'Utile'],
'important' => ['🔥', 'Important'],
'interesting' => ['🤔', 'À creuser'],
];
$_heroReactionDefs = [
'useful' => ['👍', 'Utile'],
];
?>
<div class="hero-reactions" id="reactions">
<?php foreach ($_heroReactionDefs as $type => [$icon, $label]): ?>
@@ -158,12 +234,106 @@ $hasSources = (!empty($externalLinks) || !empty($files))
</div>
</div>
<div class="card-body">
<?php if (($_GET['delete_failed'] ?? '') === '1' && function_exists('isAdmin') && isAdmin()): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
Suppression impossible — droits insuffisants sur le répertoire de données.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<div class="card-text post-content">
<?= $_renderedContent ?>
</div>
<?php if (!empty($bookContext)): ?>
<nav class="book-chapter-nav">
<div class="book-chapter-nav-inner">
<?php if (!empty($bookContext['prev_article'])): ?>
<a href="/post/<?= rawurlencode($bookContext['prev_article']['slug'] ?? '') ?>"
class="book-nav-btn book-nav-btn--prev">
<span class="book-nav-dir">← Précédent</span>
<span class="book-nav-title"><?= htmlspecialchars($bookContext['prev_article']['title'] ?? '') ?></span>
</a>
<?php else: ?>
<span class="book-nav-btn book-nav-btn--prev book-nav-btn--disabled">
<span class="book-nav-dir">Premier chapitre</span>
</span>
<?php endif; ?>
<a href="/book/<?= rawurlencode($bookContext['book']['slug']) ?>"
class="book-nav-toc" title="Sommaire du livre">
</a>
<?php if (!empty($bookContext['next_article'])): ?>
<a href="/post/<?= rawurlencode($bookContext['next_article']['slug'] ?? '') ?>"
class="book-nav-btn book-nav-btn--next">
<span class="book-nav-dir">Suivant →</span>
<span class="book-nav-title"><?= htmlspecialchars($bookContext['next_article']['title'] ?? '') ?></span>
</a>
<?php else: ?>
<span class="book-nav-btn book-nav-btn--next book-nav-btn--disabled">
<span class="book-nav-dir">Dernier chapitre</span>
</span>
<?php endif; ?>
</div>
</nav>
<?php endif; ?>
</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'; ?>
@@ -290,6 +460,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>
@@ -300,6 +488,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>
+36
View File
@@ -0,0 +1,36 @@
<?php
// Attendu : $step (int), $totalSteps (int), $mode ('create'|'edit'), $uuid (string)
$_wizLabels = $mode === 'create'
? ['Contenu', 'Publication', 'Catégorie', 'Tags', 'SEO & Validation']
: ['Contenu', 'Publication', 'Catégorie', 'Tags', 'SEO', 'Diff & Validation'];
$_base = $mode === 'create' ? '/new/' . rawurlencode($uuid ?? '') : '/edit/' . rawurlencode($uuid ?? '');
?>
<nav class="wizard-nav mb-4">
<div class="d-flex align-items-center gap-1 flex-wrap">
<?php foreach ($_wizLabels as $_wi => $_wl):
$_wn = $_wi + 1;
$_wActive = ($_wn === $step);
$_wDone = ($_wn < $step);
$_wHref = ($_wDone && ($uuid ?? '') !== '') ? htmlspecialchars($_base . '/' . $_wn) : null;
?>
<?php if ($_wi > 0): ?>
<span class="wizard-sep text-muted px-1"></span>
<?php endif; ?>
<div class="wizard-step<?= $_wActive ? ' wz-active' : ($_wDone ? ' wz-done' : ' wz-upcoming') ?>">
<?php if ($_wHref): ?><a href="<?= $_wHref ?>" class="wz-link"><?php endif; ?>
<span class="wz-num"><?= $_wDone ? '✓' : $_wn ?></span>
<span class="wz-label d-none d-sm-inline"><?= htmlspecialchars($_wl) ?></span>
<?php if ($_wHref): ?></a><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</nav>
<style>
.wizard-nav{border-bottom:1px solid var(--bs-border-color,#dee2e6);padding-bottom:.75rem}
.wizard-step{display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .5rem;border-radius:.4rem;font-size:.85rem}
.wz-active{background:#0d6efd;color:#fff;font-weight:600}
.wz-done{color:#198754}.wz-done .wz-link{color:#198754;text-decoration:none}
.wz-upcoming{color:var(--bs-secondary-color,#6c757d)}
.wz-num{display:inline-flex;align-items:center;justify-content:center;width:1.4rem;height:1.4rem;border-radius:50%;border:1.5px solid currentColor;font-size:.75rem;flex-shrink:0}
.wz-active .wz-num{border-color:#fff}
</style>
+207
View File
@@ -0,0 +1,207 @@
<?php
// Attendu : $mode, $step, $totalSteps, $uuid, $formAction, $title, $content (article),
// $existingFiles, $insertUrl, $article, $errors
ob_start();
$_wizUuid = $uuid ?? '';
$_backHref = '/';
$_hasUuid = $_wizUuid !== '';
?>
<div id="vl-page"
data-uuid="<?= htmlspecialchars($_wizUuid) ?>"
data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>"
data-autosave-url="<?= $mode === 'edit'
? '/?action=autosave_draft&uuid=' . rawurlencode($_wizUuid)
: '/?action=autosave&uuid=' . rawurlencode($_wizUuid) ?>"
hidden></div>
<form method="POST" action="<?= htmlspecialchars($formAction) ?>" enctype="multipart/form-data">
<!-- En-tête avec boutons ────────────────────────────────────────────────── -->
<div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
<div>
<h1 class="h4 mb-0" id="wz-page-title"><?= $mode === 'create' ? 'Nouvel article' : 'Modifier' ?></h1>
<?php if ($_hasUuid): ?>
<span id="autosave-indicator" class="text-muted small"></span>
<?php endif; ?>
</div>
<div class="d-flex gap-2 align-items-center">
<a href="<?= htmlspecialchars($_backHref) ?>" class="btn btn-outline-secondary btn-sm">Annuler</a>
<button type="submit" class="btn btn-primary">Suivant →</button>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger mb-3"><ul class="mb-0"><?php foreach ($errors as $_e): ?><li><?= htmlspecialchars($_e) ?></li><?php endforeach; ?></ul></div>
<?php endif; ?>
<?php include __DIR__ . '/nav.php'; ?>
<div class="row g-3 align-items-start">
<div class="col-lg-9">
<!-- Contenu ──────────────────────────────────────────────────────────── -->
<div class="mb-3">
<label for="wz-content" class="form-label fw-semibold">Contenu <small class="text-muted fw-normal">(Markdown)</small></label>
<textarea class="form-control font-monospace" id="wz-content" name="content" rows="18"
style="min-height:320px"
placeholder="# Titre de l'article&#10;&#10;Votre contenu ici…"><?= htmlspecialchars($content ?? '') ?></textarea>
</div>
</div><!-- /col-lg-9 -->
<!-- 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 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: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 -->
<!-- Fichiers / Import ─────────────────────────────────────────────────── -->
<?php if (!$_hasUuid): ?>
<div class="mb-4">
<label for="files" class="form-label fw-semibold">Ajouter des fichiers <small class="text-muted fw-normal">(optionnel)</small></label>
<input type="file" class="form-control" id="files" name="files[]" multiple>
<div class="form-text">Les fichiers seront attachés à l'article après création.</div>
</div>
<?php else: ?>
<div class="d-flex flex-wrap gap-2 mb-4">
<a href="/files/<?= rawurlencode($_wizUuid) ?>/add?back=<?= rawurlencode($formAction) ?>"
class="btn btn-outline-secondary">+ Ajouter des fichiers</a>
<a href="/import/<?= rawurlencode($_wizUuid) ?>?back=<?= rawurlencode($formAction) ?>"
class="btn btn-outline-secondary">+ Importer depuis une URL</a>
</div>
<?php if (!empty($existingFiles)): ?>
<?php $_coverFile = ($article ?? [])['cover'] ?? ''; ?>
<div class="mb-3">
<p class="fw-semibold small mb-2">Fichiers attachés (<?= count($existingFiles) ?>)</p>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($existingFiles as $_fi => $_f):
$_fUrl = '/file?uuid=' . rawurlencode($_wizUuid) . '&name=' . rawurlencode($_f['name']);
$_isCover = ($_f['name'] === $_coverFile);
?>
<div class="border rounded p-1 d-flex align-items-center gap-2" style="max-width:220px">
<?php if ($_f['is_image']): ?>
<img src="<?= htmlspecialchars($_fUrl) ?>" alt="" style="width:36px;height:36px;object-fit:cover;border-radius:3px;flex-shrink:0<?= $_isCover ? ';outline:2px solid #0d6efd' : '' ?>">
<?php else: ?>
<span style="width:36px;text-align:center;font-size:1.1rem;flex-shrink:0"><?= match(true) {
str_starts_with($_f['mime'], 'video/') => '🎬',
str_starts_with($_f['mime'], 'audio/') => '🎵',
$_f['mime'] === 'application/pdf' => '📑',
default => '📄',
} ?></span>
<?php endif; ?>
<div class="overflow-hidden flex-grow-1" style="min-width:0">
<div class="text-truncate small"><?= htmlspecialchars($_f['name']) ?></div>
<div class="d-flex gap-1 mt-1">
<button type="button" class="btn btn-xs btn-outline-secondary"
data-copy-md-name="<?= htmlspecialchars($_f['name']) ?>"
data-copy-md-is-image="<?= $_f['is_image'] ? '1' : '0' ?>"
style="font-size:.65rem;padding:.1rem .35rem">MD</button>
<button type="submit" form="del-file-wz-<?= $_fi ?>"
class="btn btn-xs btn-outline-danger"
data-confirm="Supprimer « <?= htmlspecialchars($_f['name']) ?> » ?"
style="font-size:.65rem;padding:.1rem .35rem">✕</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php $_sidebarImgs = array_filter($existingFiles ?? [], fn ($_f) => $_f['is_image']); ?>
<?php if ($_sidebarImgs): ?>
<div class="mb-3">
<p class="fw-semibold small mb-1">Images <span class="text-muted fw-normal">(clic → insère dans le contenu)</span></p>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($_sidebarImgs as $_img):
$_iUrl = '/file?uuid=' . rawurlencode($_wizUuid) . '&name=' . rawurlencode($_img['name']);
?>
<img src="<?= htmlspecialchars($_iUrl) ?>"
alt="<?= htmlspecialchars($_img['name']) ?>"
title="<?= htmlspecialchars($_img['name']) ?>"
data-insert-ref="<?= htmlspecialchars($_img['name']) ?>"
style="width:64px;height:64px;object-fit:cover;border-radius:5px;cursor:pointer;border:2px solid transparent;transition:border-color .15s">
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php $_extLinks = ($article ?? [])['external_links'] ?? []; ?>
<?php if ($_extLinks): ?>
<div class="mb-3">
<p class="fw-semibold small mb-1">Liens externes</p>
<ul class="list-group list-group-flush" style="max-width:480px">
<?php foreach ($_extLinks as $_el): ?>
<li class="list-group-item px-0 py-1 d-flex align-items-center gap-2 border-0 border-bottom">
<span class="flex-grow-1 text-truncate small"
data-insert-ref="<?= htmlspecialchars($_el['url']) ?>"
style="cursor:pointer;color:#0d6efd;text-decoration:underline dotted"
title="<?= htmlspecialchars($_el['url']) ?>"><?= htmlspecialchars($_el['name']) ?></span>
<form method="POST" action="/?action=delete_external_link&uuid=<?= rawurlencode($_wizUuid) ?>" class="d-inline flex-shrink-0">
<input type="hidden" name="url" value="<?= htmlspecialchars($_el['url']) ?>">
<button type="submit" class="btn btn-link btn-sm text-danger p-0 lh-1"
data-confirm="Supprimer ce lien ?" title="Supprimer">✕</button>
</form>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php endif; ?>
<?php endif; ?>
</form>
<?php if (!empty($existingFiles)): ?>
<?php foreach ($existingFiles as $_fi => $_f): ?>
<form id="del-file-wz-<?= $_fi ?>" method="POST"
action="/?action=delete_file&uuid=<?= rawurlencode($_wizUuid) ?>&_back=<?= rawurlencode($formAction) ?>">
<input type="hidden" name="name" value="<?= htmlspecialchars($_f['name']) ?>">
</form>
<?php endforeach; ?>
<?php endif; ?>
<script src="/assets/js/wizard.js"></script>
<?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';
+58
View File
@@ -0,0 +1,58 @@
<?php
ob_start();
$_dateVal = isset($published_at)
? (str_contains((string)$published_at, ' ')
? date('Y-m-d\TH:i', strtotime((string)$published_at))
: (string)$published_at)
: date('Y-m-d\TH:i');
$_backUrl = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/1' : '/edit/' . rawurlencode($uuid) . '/1';
$_formAction = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/2' : '/edit/' . rawurlencode($uuid) . '/2';
?>
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
<h1 class="h4 mb-0">Publication</h1>
<div class="d-flex gap-2">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<button type="submit" class="btn btn-primary">Suivant →</button>
</div>
</div>
<?php include __DIR__ . '/nav.php'; ?>
<div class="row justify-content-start">
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-body">
<div class="mb-4">
<p class="fw-semibold mb-2">Visibilité</p>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="published" id="pub-yes" value="1"
<?= ($published ?? false) ? 'checked' : '' ?>>
<label class="form-check-label" for="pub-yes">Public</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="published" id="pub-no" value=""
<?= !($published ?? false) ? 'checked' : '' ?>>
<label class="form-check-label" for="pub-no">Brouillon (privé)</label>
</div>
<div class="form-text mt-1">Un brouillon n'est visible que par les utilisateurs authentifiés.</div>
</div>
<div>
<label for="published_at" class="form-label fw-semibold">Date de publication</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at"
value="<?= htmlspecialchars($_dateVal) ?>">
<div class="form-text">Une date future crée une avant-première (visible aux utilisateurs avec la capacité <code>view_previews</code>).</div>
</div>
</div>
</div>
</div>
</div>
</form>
<?php
$content = ob_get_clean();
$title = 'Publication — Étape 2/' . $totalSteps;
include BASE_PATH . '/templates/layout.php';
+66
View File
@@ -0,0 +1,66 @@
<?php
ob_start();
$_backUrl = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/2' : '/edit/' . rawurlencode($uuid) . '/2';
$_formAction = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/3' : '/edit/' . rawurlencode($uuid) . '/3';
?>
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
<h1 class="h4 mb-0">Catégorie</h1>
<div class="d-flex gap-2">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<button type="submit" class="btn btn-primary">Suivant →</button>
</div>
</div>
<?php include __DIR__ . '/nav.php'; ?>
<div class="row justify-content-start">
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-body">
<div class="mb-3">
<label for="category" class="form-label fw-semibold">Catégorie</label>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control" id="category" name="category"
value="<?= htmlspecialchars($category ?? '') ?>"
placeholder="ex : informatique, loisirs, photo…"
autocomplete="off">
<div id="cat-swatch" title="" style="width:40px;height:36px;border-radius:6px;flex-shrink:0;background:#e5e7eb;transition:background .25s"></div>
</div>
<small id="cat-hint" class="text-muted d-block mt-1"></small>
<div id="cat-free-swatches" class="d-flex flex-wrap gap-1 mt-2"></div>
</div>
<?php if (!empty($allCategories)): ?>
<div>
<p class="small text-muted mb-2">Catégories existantes :</p>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($allCategories as $_cat => $_count):
$_isPriv = in_array($_cat, $privateCats ?? [], true);
?>
<button type="button" class="btn btn-sm btn-outline-secondary wz-cat-pick<?= ($_cat === ($category ?? '')) ? ' active' : '' ?>"
data-cat="<?= htmlspecialchars($_cat) ?>">
<?= htmlspecialchars($_cat) ?>
<span class="badge bg-secondary ms-1"><?= $_count ?></span>
<?php if ($_isPriv): ?><span title="Privée">🔒</span><?php endif; ?>
</button>
<?php endforeach; ?>
<button type="button" class="btn btn-sm btn-outline-secondary wz-cat-pick<?= (($category ?? '') === '') ? ' active' : '' ?>"
data-cat=""><em class="text-muted">Aucune</em></button>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</form>
<script src="/assets/js/wizard.js"></script>
<?php
$content = ob_get_clean();
$title = 'Catégorie — Étape 3/' . $totalSteps;
include BASE_PATH . '/templates/layout.php';
+107
View File
@@ -0,0 +1,107 @@
<?php
// Attendu : $mode, $step, $totalSteps, $uuid, $flatTagValues, $flatArticleTags, $draftContent
ob_start();
$_backUrl = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/3' : '/edit/' . rawurlencode($uuid) . '/3';
$_formAction = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/4' : '/edit/' . rawurlencode($uuid) . '/4';
$_tagVal = implode(', ', $flatArticleTags);
$_suggester = new TagSuggester();
$_candidates = $draftContent !== ''
? $_suggester->suggest($draftContent, $flatTagValues, $flatArticleTags)
: [];
$_knownInText = array_keys(array_filter($_candidates, fn ($_c) => $_c['known']));
$_detectedInText = array_keys(array_filter($_candidates, fn ($_c) => !$_c['known']));
?>
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
<h1 class="h4 mb-0">Tags</h1>
<div class="d-flex gap-2">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<button type="submit" class="btn btn-primary">Suivant →</button>
</div>
</div>
<?php include __DIR__ . '/nav.php'; ?>
<div class="card mb-4">
<div class="card-body">
<datalist id="wz-tags-list">
<?php foreach ($flatTagValues as $_v): ?>
<option value="<?= htmlspecialchars($_v) ?>">
<?php endforeach; ?>
</datalist>
<div class="mb-3">
<input type="text" class="form-control"
id="wz-tags-flat"
name="tags_flat"
value="<?= htmlspecialchars($_tagVal) ?>"
placeholder="valeur1, valeur2…"
list="wz-tags-list"
autocomplete="off">
<div class="form-text">Séparer par des virgules.</div>
</div>
<!-- Valeurs existantes ──────────────────────────────────────────────── -->
<?php if (!empty($flatTagValues)): ?>
<div class="mb-3">
<p class="small text-muted mb-2">Valeurs déjà utilisées :</p>
<div class="d-flex flex-wrap gap-1 wz-tag-pills" data-target="wz-tags-flat">
<?php foreach ($flatTagValues as $_v):
$_isActive = in_array($_v, $flatArticleTags, true);
$_inText = in_array($_v, $_knownInText, true);
?>
<button type="button"
class="btn btn-sm <?= $_isActive ? 'btn-secondary' : 'btn-outline-secondary' ?> wz-tag-pill py-0"
data-value="<?= htmlspecialchars($_v) ?>"
style="font-size:.75rem"
title="<?= $_inText ? 'Présent dans le texte' : '' ?>">
<?= htmlspecialchars($_v) ?><?= $_inText ? ' <span class="ms-1" style="opacity:.6;font-size:.65rem">●</span>' : '' ?>
</button>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- Détectés dans le texte ──────────────────────────────────────────── -->
<?php if (!empty($_detectedInText)): ?>
<div>
<p class="small text-muted mb-2">Détectés dans le texte (abréviations, noms propres, mots composés) :</p>
<div class="d-flex flex-wrap gap-1 wz-tag-pills" data-target="wz-tags-flat">
<?php foreach ($_detectedInText as $_v):
$_isActive = in_array($_v, $flatArticleTags, true);
$_meta = $_candidates[$_v];
$_badge = match($_meta['group'] ?? '') {
'abbrev' => 'ABR',
'camel' => 'CC',
'proper' => 'NP',
default => '',
};
?>
<button type="button"
class="btn btn-sm <?= $_isActive ? 'btn-info' : 'btn-outline-info' ?> wz-tag-pill py-0"
data-value="<?= htmlspecialchars($_v) ?>"
style="font-size:.75rem"
title="<?= htmlspecialchars($_meta['count'] . 'x dans le texte — ' . ($_badge ?: 'détecté')) ?>">
<?= htmlspecialchars($_v) ?>
<?php if ($_badge): ?>
<span class="ms-1 text-muted" style="font-size:.6rem"><?= $_badge ?></span>
<?php endif; ?>
</button>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
</form>
<script src="/assets/js/wizard.js"></script>
<?php
$content = ob_get_clean();
$title = 'Tags — Étape 4/' . $totalSteps;
include BASE_PATH . '/templates/layout.php';
+146
View File
@@ -0,0 +1,146 @@
<?php
// Attendu : $mode, $step, $totalSteps, $uuid, $seoTitle, $seoDescription, $autoSeoDesc,
// $postSlug, $published, $published_at, $category, $existingFiles (pour create), $article (edit)
ob_start();
$_backUrl = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/4' : '/edit/' . rawurlencode($uuid) . '/4';
$_formAction = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/5' : '/edit/' . rawurlencode($uuid) . '/5';
$_base = rtrim(APP_URL, '/');
$_effTitle = ($seoTitle !== '') ? $seoTitle : ($title ?? '');
$_effDesc = ($seoDescription !== '') ? $seoDescription : $autoSeoDesc;
$_coverFile = ($article ?? [])['cover'] ?? '';
$_pubTs = strtotime((string)($published_at ?? ''));
$_pubFmt = $_pubTs ? date('d/m/Y H:i', $_pubTs) : '—';
$_catVal = trim($category ?? '');
?>
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
<h1 class="h4 mb-0">SEO<?= $mode === 'create' ? ' & Validation' : '' ?></h1>
<div class="d-flex gap-2">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<?php if ($mode === 'create'): ?>
<button type="submit" class="btn btn-success">✓ Publier l'article</button>
<?php else: ?>
<button type="submit" class="btn btn-primary">Suivant →</button>
<?php endif; ?>
</div>
</div>
<?php include __DIR__ . '/nav.php'; ?>
<div class="row g-4">
<!-- ─── Colonne gauche : aperçu ──────────────────────────────────────────── -->
<div class="col-lg-5">
<div class="card border-secondary mb-3">
<div class="card-header bg-transparent py-2">
<span class="fw-semibold small">Aperçu moteur de recherche</span>
</div>
<div class="card-body p-3">
<div class="seo-preview mb-3">
<div class="seo-preview-url small text-truncate mb-1" id="preview-url">
<?= htmlspecialchars($_base . '/post/' . ($postSlug ?? '')) ?>
</div>
<div class="seo-preview-title mb-1" id="preview-title">
<?= htmlspecialchars($_effTitle) ?>
</div>
<div class="seo-preview-desc small" id="preview-desc">
<?= htmlspecialchars($_effDesc) ?>
</div>
</div>
<table class="table table-sm table-borderless mb-0 small">
<tbody>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Statut</th>
<td><?= ($published ?? false) ? '<span class="text-success">Public</span>' : '<span class="text-warning">Brouillon</span>' ?></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Date</th>
<td><?= htmlspecialchars($_pubFmt) ?></td>
</tr>
<?php if ($_catVal !== ''): ?>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Catégorie</th>
<td><?= htmlspecialchars($_catVal) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- ─── Colonne droite : formulaire SEO ─────────────────────────────────── -->
<div class="col-lg-7">
<div class="card mb-4">
<div class="card-header bg-transparent py-2 fw-semibold small">Métadonnées SEO</div>
<div class="card-body">
<div class="mb-3">
<label for="seo_title" class="form-label">Titre SEO <small class="text-muted">(og:title, &lt;title&gt;)</small></label>
<input type="text" class="form-control" id="seo_title" name="seo_title"
maxlength="70"
value="<?= htmlspecialchars($seoTitle ?? '') ?>"
placeholder="<?= htmlspecialchars($title ?? '') ?>">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 3060 caractères</small>
<small id="seo_title_counter" class="text-muted">0 / 60</small>
</div>
</div>
<div class="mb-3">
<label for="seo_description" class="form-label">Description SEO <small class="text-muted">(meta description)</small></label>
<textarea class="form-control" id="seo_description" name="seo_description"
rows="3" maxlength="200"
placeholder="<?= htmlspecialchars(mb_strimwidth($autoSeoDesc ?? '', 0, 80, '…')) ?>"><?= htmlspecialchars($seoDescription ?? '') ?></textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 120155 caractères</small>
<small id="seo_desc_counter" class="text-muted">0 / 155</small>
</div>
</div>
<?php if (!empty($existingFiles ?? [])): ?>
<?php $_imgFiles = array_filter($existingFiles, fn ($_f) => $_f['is_image']); ?>
<?php if ($_imgFiles): ?>
<div class="mb-0">
<label class="form-label">Image de couverture (og:image)</label>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($_imgFiles as $_f):
$_fUrl = '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($_f['name']);
?>
<label class="position-relative" style="cursor:pointer">
<input type="radio" name="cover_file" value="<?= htmlspecialchars($_f['name']) ?>"
class="position-absolute" style="opacity:0" <?= ($_f['name'] === $_coverFile) ? 'checked' : '' ?>>
<img src="<?= htmlspecialchars($_fUrl) ?>" alt=""
style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:3px solid transparent;transition:border-color .15s"
class="wz-cover-thumb <?= ($_f['name'] === $_coverFile) ? 'wz-cover-selected' : '' ?>">
</label>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</div>
</div><!-- /row -->
</form>
<style>
.seo-preview{border:1px solid #dee2e6;border-radius:6px;padding:10px 12px;background:#fff}
.seo-preview-url{color:#006621;font-size:.78rem}
.seo-preview-title{color:#1a0dab;font-size:1.05rem;font-weight:500;line-height:1.3;word-break:break-word}
.seo-preview-desc{color:#545454;line-height:1.5}
.wz-cover-selected{border-color:#0d6efd !important}
</style>
<div id="pc-data" hidden
data-default-title="<?= htmlspecialchars($_effTitle) ?>"
data-default-desc="<?= htmlspecialchars($_effDesc) ?>"
data-base-url="<?= htmlspecialchars($_base . '/post/') ?>"></div>
<script src="/assets/js/post_confirm.js"></script>
<script src="/assets/js/wizard.js"></script>
<?php
$content = ob_get_clean();
$title = ($mode === 'create' ? 'SEO & Validation' : 'SEO') . ' — Étape 5/' . $totalSteps;
include BASE_PATH . '/templates/layout.php';
+105
View File
@@ -0,0 +1,105 @@
<?php
// Attendu (edit only) : $uuid, $step, $totalSteps, $mode='edit', $article (original),
// $draftData, $diffLines, $changes, $autoRevisionComment,
// $seoTitle, $seoDescription, $autoSeoDesc, $title (draft), $postSlug,
// $titleChanged, $published, $published_at, $category
ob_start();
$_CONTEXT = 3;
$_backUrl = '/edit/' . rawurlencode($uuid) . '/5';
$_formAction = '/edit/' . rawurlencode($uuid) . '/6';
?>
<?php include __DIR__ . '/nav.php'; ?>
<!-- En-tête : titre + boutons à droite ─────────────────────────────────── -->
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
<input type="hidden" name="_confirm" value="1">
<div class="d-flex align-items-start justify-content-between gap-3 mb-4 flex-wrap">
<div>
<h1 class="h4 mb-1">Confirmer les modifications</h1>
<?php if (!empty($changes)): ?>
<p class="text-muted small mb-0"><?= htmlspecialchars(ucfirst(implode(' · ', $changes))) ?></p>
<?php else: ?>
<p class="text-muted small mb-0">Aucune modification détectée.</p>
<?php endif; ?>
</div>
<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"
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>
</div>
</div>
<!-- Commentaire de révision ────────────────────────────────────────────── -->
<div class="mb-4" style="max-width:520px">
<label for="revision_comment" class="form-label fw-semibold">
Commentaire de révision <small class="text-muted fw-normal">(optionnel)</small>
</label>
<input type="text" class="form-control" id="revision_comment" name="revision_comment"
value="<?= htmlspecialchars($autoRevisionComment) ?>"
placeholder="ex. Correction typos, ajout section X…">
</div>
<!-- Diff contenu ────────────────────────────────────────────────────────── -->
<div class="mb-4">
<h2 class="h6 fw-semibold mb-2">Diff du contenu</h2>
<?php if ($diffLines === []): ?>
<div class="text-muted small">Contenu identique.</div>
<?php else:
$total = count($diffLines);
$show = [];
for ($i = 0; $i < $total; $i++) {
if ($diffLines[$i][0] !== '=') {
for ($c = max(0, $i - $_CONTEXT); $c <= min($total - 1, $i + $_CONTEXT); $c++) {
$show[$c] = true;
}
}
}
?>
<div class="d-flex gap-3 mb-1 small">
<span class="diff-del px-2 py-1 rounded"> Supprimé</span>
<span class="diff-ins px-2 py-1 rounded">+ Ajouté</span>
</div>
<div class="diff-view font-monospace small">
<?php $inEllipsis = false;
for ($i = 0; $i < $total; $i++):
[$op, $line] = $diffLines[$i];
?>
<?php if (!isset($show[$i])): ?>
<?php if (!$inEllipsis): $inEllipsis = true; ?>
<div class="diff-ellipsis text-muted px-2">⋯</div>
<?php endif;
continue; ?>
<?php else: $inEllipsis = false; endif; ?>
<?php if ($op === '-'): ?>
<div class="diff-del px-2">&nbsp;<?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '+'): ?>
<div class="diff-ins px-2">+&nbsp;<?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '!'): ?>
<div class="diff-warning text-warning px-2"><?= htmlspecialchars($line) ?></div>
<?php else: ?>
<div class="diff-eq px-2 text-muted">&nbsp;&nbsp;<?= htmlspecialchars($line) ?></div>
<?php endif; ?>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>
</form>
<style>
.diff-view{border:1px solid var(--bs-border-color,#dee2e6);border-radius:6px;overflow-x:auto}
.diff-view > div{padding:1px 8px;white-space:pre;line-height:1.5}
.diff-del{background:#ffeef0;color:#b91c1c}
.diff-ins{background:#e6ffec;color:#15803d}
.diff-ellipsis{background:#f8f9fa;padding:2px 8px;user-select:none}
</style>
<?php
$content = ob_get_clean();
$title = 'Valider les modifications — Étape 6/6';
include BASE_PATH . '/templates/layout.php';