Compare commits
71 Commits
331e9c9ecd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dbbe60f28e | |||
| 1e41ef207e | |||
| b0f4814bb0 | |||
| d53b5da31a | |||
| 68a44d19d1 | |||
| e3d7e433e0 | |||
| 40656631ba | |||
| d6a7033e9e | |||
| be8a95ac4f | |||
| af169bccc9 | |||
| ddc7607972 | |||
| d729e943a3 | |||
| a578604ec3 | |||
| e8b361e720 | |||
| 868e68fa85 | |||
| ed3f8062da | |||
| 007895d24a | |||
| c2035314fb | |||
| c140ba4069 | |||
| 1eb6ca25f9 | |||
| 84d4b12fb2 | |||
| c979238b0c | |||
| e03594c22e | |||
| 298f18dabe | |||
| fabe5a9f53 | |||
| 430b7ddd6f | |||
| e2d218f364 | |||
| ca6cfa4ebf | |||
| 3b22be94e8 | |||
| 5ce91da06a | |||
| 11399a54a6 | |||
| 51055b7321 | |||
| dc4701d667 | |||
| ae4ac11305 | |||
| 347e4be0b7 | |||
| c17cad9c66 | |||
| d329872404 | |||
| 88cc67d945 | |||
| 8a42dfe981 | |||
| 6092cf940d | |||
| 5b16fb465b | |||
| 5203b2c514 | |||
| 996ab3e508 | |||
| 8af2c8e20b | |||
| 04a7713286 | |||
| 3ddfc1dcf3 | |||
| fa00f61ee0 | |||
| 8889110133 | |||
| 3e856dc476 | |||
| 58a110d5b9 | |||
| 5e88d44129 | |||
| a55e22f1f4 | |||
| 5cea473d17 | |||
| 1d05138329 | |||
| ee2b8a4ac7 | |||
| 556c2cfea9 | |||
| e19d20ca17 | |||
| d0b486f11c | |||
| 18b7194069 | |||
| 21f6e75878 | |||
| 2a60790006 | |||
| 3647289f86 | |||
| ea950f2c25 | |||
| af0a0bb9d5 | |||
| 797937340a | |||
| d5bba5e6e5 | |||
| 53dbce5bb0 | |||
| 4e262ddde8 | |||
| 7737edf402 | |||
| 6d159e7dda | |||
| ebf0e2df65 |
@@ -55,3 +55,11 @@ DATA_PATH=/srv/data/folio
|
|||||||
# Logs Apache (onglet Recherches dans /admin)
|
# Logs Apache (onglet Recherches dans /admin)
|
||||||
# Nom du fichier de log d'accès du vhost dans /var/log/apache2/
|
# Nom du fichier de log d'accès du vhost dans /var/log/apache2/
|
||||||
APACHE_ACCESS_LOG=lan.acegrp.varlog-access.log
|
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
|
||||||
|
|||||||
@@ -11,5 +11,4 @@ Thumbs.db
|
|||||||
# Données des sites (articles, config, cache) — propres à chaque workspace
|
# Données des sites (articles, config, cache) — propres à chaque workspace
|
||||||
data/*
|
data/*
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
!data/site/
|
|
||||||
_cache/
|
_cache/
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+343
@@ -5,10 +5,353 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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 (`&`, ` `…) + 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]
|
## [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
|
## [1.6.0] - 2026-05-15
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ Il contient uniquement le code du moteur — pas de données, pas de credentials
|
|||||||
| Répertoire local | Site distant | Rôle |
|
| Répertoire local | Site distant | Rôle |
|
||||||
|-----------------|-------------|------|
|
|-----------------|-------------|------|
|
||||||
| `~/Projects/folio/` | — | Copie du dépôt Folio (branche DEV). On code ici. |
|
| `~/Projects/folio/` | — | Copie du dépôt Folio (branche DEV). On code ici. |
|
||||||
| `~/Projects/varlog/` | varlog.a5l.fr | Sync bidirectionnelle des articles varlog. Sert de site de test pour le moteur. |
|
| `~/Projects/varlog/` | varlog.a5l.fr | Workspace varlog (scripts de déploiement/sync). Sert de site de test pour le moteur. |
|
||||||
| `~/Projects/fr.abonnel.www/` | www.abonnel.fr | Sync bidirectionnelle des articles abonnel.fr. A aussi servi au déploiement initial. |
|
| `~/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.
|
**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/`)
|
## Articles (`data/`)
|
||||||
|
|
||||||
Les articles ne sont pas versionnés dans ce dépôt. Ils ont leur propre git local dans chaque workspace site (`~/Projects/varlog/data/`, `~/Projects/fr.abonnel.www/data/`), synchronisé de façon bidirectionnelle avec le serveur distant.
|
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
|
## Modifier le moteur
|
||||||
|
|
||||||
|
|||||||
@@ -133,12 +133,20 @@ Ou créer directement `$DATA_PATH/site_settings.json` :
|
|||||||
|
|
||||||
## Mise à jour
|
## Mise à jour
|
||||||
|
|
||||||
|
### Manuelle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
composer install --no-dev
|
composer install --no-dev
|
||||||
php database/migrate.php
|
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
|
## Structure du projet
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -44,3 +44,5 @@ if (!function_exists('url')) {
|
|||||||
return $u;
|
return $u;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_once BASE_PATH . '/src/helpers.php';
|
||||||
|
|||||||
+133
@@ -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.
|
||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
@@ -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).
|
|
||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
@@ -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 |
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
```
|
||||||
+7
-1
@@ -15,6 +15,9 @@ RewriteRule ^ - [L]
|
|||||||
# URL propre pour les articles : /post/<slug>
|
# URL propre pour les articles : /post/<slug>
|
||||||
RewriteRule ^post/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=view&slug=$1 [L,QSA]
|
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>
|
# Livres : /book/<slug>
|
||||||
RewriteRule ^book/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=book&book_slug=$1 [L,QSA]
|
RewriteRule ^book/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=book&book_slug=$1 [L,QSA]
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ RewriteRule ^edit/([0-9a-f-]{36})/?$ /index.php?action=edit&uuid=$1 [L,QSA]
|
|||||||
RewriteRule ^new/([0-9a-f-]{36})/([1-5])/?$ /index.php?action=create&uuid=$1&step=$2 [L,QSA]
|
RewriteRule ^new/([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 ^new/?$ /index.php?action=create [L,QSA]
|
||||||
RewriteRule ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [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
|
# Sources et diff
|
||||||
RewriteRule ^sources/([0-9a-f-]{36})/?$ /index.php?action=sources&uuid=$1 [L,QSA]
|
RewriteRule ^sources/([0-9a-f-]{36})/?$ /index.php?action=sources&uuid=$1 [L,QSA]
|
||||||
@@ -41,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 ^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]
|
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/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/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/([a-z0-9-]+)/?$ /index.php?action=admin&tab=$1 [L,QSA]
|
||||||
RewriteRule ^admin/?$ /index.php?action=admin [L,QSA]
|
RewriteRule ^admin/?$ /index.php?action=admin [L,QSA]
|
||||||
@@ -57,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 ^categories/?$ /index.php?action=categories [L,QSA]
|
||||||
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
|
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
|
||||||
RewriteRule ^search/?$ /index.php?action=search [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 ^flux/?$ /index.php?action=flux [L,QSA]
|
||||||
RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
|
RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
|
||||||
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
|
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
|
||||||
|
|||||||
@@ -1266,6 +1266,40 @@ footer.mt-5 { margin-top: 0 !important; }
|
|||||||
letter-spacing: .01em;
|
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 ───────────────────── */
|
/* ─── Page de recherche ───────────────────── */
|
||||||
.search-page { max-width: 780px; margin: 0 auto; }
|
.search-page { max-width: 780px; margin: 0 auto; }
|
||||||
|
|
||||||
@@ -1810,6 +1844,68 @@ footer.mt-5 { margin-top: 0 !important; }
|
|||||||
|
|
||||||
/* ─── Livres ─────────────────────────────────────────────────────── */
|
/* ─── 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 */
|
/* Bandeau dans un article appartenant à un livre */
|
||||||
.book-article-banner {
|
.book-article-banner {
|
||||||
border-radius: var(--vl-radius);
|
border-radius: var(--vl-radius);
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
/* Admin stats : graphiques, sparklines, accordéon pays/AS/IP, agents */
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 ms-2 text-muted border-0 exclude-as-btn" style="font-size:.65rem" 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-2 py-1"' + toggleAttrs + '>'
|
||||||
|
+ '<div class="small d-flex align-items-center" style="width:9rem;flex-shrink:0">'
|
||||||
|
+ esc(n.name || '?')
|
||||||
|
+ (n.asn ? ' <span class="text-muted">AS' + esc(n.asn) + '</span>' : '')
|
||||||
|
+ chevron
|
||||||
|
+ excludeBtn
|
||||||
|
+ '</div>'
|
||||||
|
+ '<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
|
||||||
|
el.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.exclude-as-btn');
|
||||||
|
if (btn) { excludeAs(btn.getAttribute('data-asn'), btn.getAttribute('data-name')); return; }
|
||||||
|
btn = e.target.closest('.include-as-btn');
|
||||||
|
if (btn) { includeAs(btn.getAttribute('data-asn')); }
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : {};
|
||||||
|
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 + ' vis.</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">Trafic total — 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">'
|
||||||
|
+ '<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;
|
||||||
|
var totals = new Array(nDays).fill(0);
|
||||||
|
Object.values(pagesByDay).forEach(function (arr) {
|
||||||
|
arr.forEach(function (v, i) { if (i < nDays) { totals[i] += v; } });
|
||||||
|
});
|
||||||
|
trendChart(totals);
|
||||||
|
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>';
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -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
|
// Sélection globale articles
|
||||||
var checkAll = document.getElementById('check-all');
|
var checkAll = document.getElementById('check-all');
|
||||||
if (checkAll) {
|
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)
|
// Indicateurs de traitement formulaire SMTP (config + tester connexion)
|
||||||
var smtpForm = document.getElementById('smtp-config-form');
|
var smtpForm = document.getElementById('smtp-config-form');
|
||||||
if (smtpForm) {
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -24,10 +24,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
var seoTitle = document.getElementById('seo_title').value.trim();
|
var seoTitle = document.getElementById('seo_title').value.trim();
|
||||||
var seoDesc = document.getElementById('seo_description').value.trim();
|
var seoDesc = document.getElementById('seo_description').value.trim();
|
||||||
var slug = document.getElementById('confirm-slug').value.trim();
|
var slugEl = document.getElementById('confirm-slug');
|
||||||
document.getElementById('preview-title').textContent = seoTitle || defaultTitle;
|
document.getElementById('preview-title').textContent = seoTitle || defaultTitle;
|
||||||
document.getElementById('preview-desc').textContent = seoDesc || defaultDesc;
|
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) {
|
['seo_title', 'seo_description', 'confirm-slug'].forEach(function (id) {
|
||||||
@@ -38,8 +40,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
var slugInput = document.getElementById('confirm-slug');
|
var slugInput = document.getElementById('confirm-slug');
|
||||||
var slugDisplay = document.getElementById('slug-display');
|
var slugDisplay = document.getElementById('slug-display');
|
||||||
|
|
||||||
|
if (slugInput && slugDisplay) {
|
||||||
|
slugInput.addEventListener('input', function () {
|
||||||
|
slugDisplay.textContent = slugInput.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var btnSuggest = document.getElementById('slug-btn-suggest');
|
var btnSuggest = document.getElementById('slug-btn-suggest');
|
||||||
if (btnSuggest) {
|
if (btnSuggest && slugInput && slugDisplay) {
|
||||||
btnSuggest.addEventListener('click', function () {
|
btnSuggest.addEventListener('click', function () {
|
||||||
var val = btnSuggest.dataset.slugSuggest;
|
var val = btnSuggest.dataset.slugSuggest;
|
||||||
slugInput.value = val;
|
slugInput.value = val;
|
||||||
@@ -49,7 +57,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var btnKeep = document.getElementById('slug-btn-keep');
|
var btnKeep = document.getElementById('slug-btn-keep');
|
||||||
if (btnKeep) {
|
if (btnKeep && slugInput && slugDisplay) {
|
||||||
btnKeep.addEventListener('click', function () {
|
btnKeep.addEventListener('click', function () {
|
||||||
var val = btnKeep.dataset.slugKeep;
|
var val = btnKeep.dataset.slugKeep;
|
||||||
slugInput.value = val;
|
slugInput.value = val;
|
||||||
|
|||||||
@@ -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 () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () {});
|
||||||
|
}());
|
||||||
+33
-8
@@ -18,15 +18,22 @@ $Parsedown = new Parsedown();
|
|||||||
|
|
||||||
$now = time();
|
$now = time();
|
||||||
$base = rtrim(APP_URL, '/');
|
$base = rtrim(APP_URL, '/');
|
||||||
|
$filterCat = trim($_GET['category'] ?? '');
|
||||||
|
|
||||||
$all = array_values(array_filter(
|
$all = array_values(array_filter(
|
||||||
$articles->getAll(publishedOnly: true),
|
$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) {
|
if (strtotime((string)($a['published_at'] ?? '')) > $now) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$cat = trim($a['category'] ?? '');
|
$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;
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -47,8 +54,11 @@ $nextCursor = (count($all) > $offset + FEED_PAGE_SIZE)
|
|||||||
? ($all[$offset + FEED_PAGE_SIZE - 1]['uuid'] ?? null)
|
? ($all[$offset + FEED_PAGE_SIZE - 1]['uuid'] ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$feedUrl = $base . '/feed';
|
$feedUrl = $base . '/feed' . ($filterCat !== '' ? '?category=' . rawurlencode($filterCat) : '');
|
||||||
$feedNextUrl = $nextCursor !== null ? $base . '/feed/' . $nextCursor : null;
|
$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 ───────────────────────────────────────────────────────────
|
// ─── lastBuildDate ───────────────────────────────────────────────────────────
|
||||||
$lastBuild = '';
|
$lastBuild = '';
|
||||||
@@ -69,11 +79,13 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
|||||||
?>
|
?>
|
||||||
<rss version="2.0"
|
<rss version="2.0"
|
||||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
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">
|
xmlns:fh="http://purl.org/syndication/history/1.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title><?= htmlspecialchars(siteTitle()) ?></title>
|
<title><?= htmlspecialchars($channelTitle) ?></title>
|
||||||
<link><?= htmlspecialchars($base) ?></link>
|
<link><?= htmlspecialchars($base) ?></link>
|
||||||
<description><?= htmlspecialchars(siteClaim()) ?></description>
|
<description><?= htmlspecialchars($channelDesc) ?></description>
|
||||||
<language><?= htmlspecialchars(siteLang()) ?></language>
|
<language><?= htmlspecialchars(siteLang()) ?></language>
|
||||||
<lastBuildDate><?= htmlspecialchars($lastBuild) ?></lastBuildDate>
|
<lastBuildDate><?= htmlspecialchars($lastBuild) ?></lastBuildDate>
|
||||||
|
|
||||||
@@ -94,14 +106,27 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
|||||||
$pubDate = date(DATE_RSS, (int)strtotime((string)($article['published_at'] ?? $article['created_at'] ?? '')));
|
$pubDate = date(DATE_RSS, (int)strtotime((string)($article['published_at'] ?? $article['created_at'] ?? '')));
|
||||||
$link = $base . '/post/' . rawurlencode($article['slug'] ?? '');
|
$link = $base . '/post/' . rawurlencode($article['slug'] ?? '');
|
||||||
$title = htmlspecialchars($article['title'] ?? '', ENT_XML1);
|
$title = htmlspecialchars($article['title'] ?? '', ENT_XML1);
|
||||||
$plain = preg_replace('/\s+/', ' ', strip_tags($Parsedown->text($article['content'] ?? '')));
|
$plain = preg_replace('/\s+/', ' ', trim($article['plain'] ?? ''));
|
||||||
$desc = htmlspecialchars(mb_strimwidth(trim((string)$plain), 0, 300, '…'), ENT_XML1);
|
$desc = htmlspecialchars(mb_strimwidth($plain, 0, 300, '…'), ENT_XML1);
|
||||||
$guid = htmlspecialchars($base . '/post/' . rawurlencode($article['slug'] ?? ''), 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>
|
<item>
|
||||||
<title><?= $title ?></title>
|
<title><?= $title ?></title>
|
||||||
<link><?= htmlspecialchars($link) ?></link>
|
<link><?= htmlspecialchars($link) ?></link>
|
||||||
<description><?= $desc ?></description>
|
<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>
|
<pubDate><?= htmlspecialchars($pubDate) ?></pubDate>
|
||||||
<guid isPermaLink="true"><?= $guid ?></guid>
|
<guid isPermaLink="true"><?= $guid ?></guid>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
+606
-124
@@ -45,17 +45,58 @@ $action = $_GET['action'] ?? 'list';
|
|||||||
$uuid = $_GET['uuid'] ?? '';
|
$uuid = $_GET['uuid'] ?? '';
|
||||||
$slug = $_GET['slug'] ?? '';
|
$slug = $_GET['slug'] ?? '';
|
||||||
|
|
||||||
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config', 'run_engine_update'];
|
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config', 'run_engine_update', 'run_content_migrations', 'admin_delete_feed', 'rate', 'admin_save_ai_config'];
|
||||||
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
|
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
|
||||||
unset($_noindexActions);
|
unset($_noindexActions);
|
||||||
|
|
||||||
// ─── Recherche de l'article le plus proche et redirection 301 ────────────────
|
// ─── Recherche de l'article le plus proche et redirection 301 ────────────────
|
||||||
|
function log404(string $url): void
|
||||||
|
{
|
||||||
|
if (!defined('DATA_PATH')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$logDir = DATA_PATH . '/_logs';
|
||||||
|
$logFile = $logDir . '/not_found.jsonl';
|
||||||
|
if (!is_dir($logDir)) {
|
||||||
|
@mkdir($logDir, 0755, true);
|
||||||
|
}
|
||||||
|
$entry = json_encode([
|
||||||
|
'ts' => date('Y-m-d H:i:s'),
|
||||||
|
'url' => $url,
|
||||||
|
'ref' => $_SERVER['HTTP_REFERER'] ?? '',
|
||||||
|
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
||||||
|
@file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAutoSeoDesc(string $content, string $title = ''): string
|
||||||
|
{
|
||||||
|
require_once BASE_PATH . '/src/Parsedown.php';
|
||||||
|
$_pd = new Parsedown();
|
||||||
|
$_plain = trim((string)preg_replace(
|
||||||
|
'/\s+/',
|
||||||
|
' ',
|
||||||
|
html_entity_decode(strip_tags($_pd->text($content)), ENT_QUOTES | ENT_HTML5, 'UTF-8')
|
||||||
|
));
|
||||||
|
if ($title !== '' && stripos($_plain, $title) === 0) {
|
||||||
|
$_plain = ltrim(substr($_plain, strlen($title)));
|
||||||
|
}
|
||||||
|
return mb_strimwidth($_plain, 0, 155, '…');
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugToSearchQuery(string $rawPath): string
|
||||||
|
{
|
||||||
|
return trim((string)preg_replace('/\s{2,}/', ' ', (string)preg_replace(
|
||||||
|
'/[^a-zA-ZÀ-ÿ0-9\s]/u',
|
||||||
|
' ',
|
||||||
|
str_replace(['-', '_', '/'], ' ', $rawPath)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
function searchAndRedirect(string $rawPath, ArticleManager $articles): void
|
function searchAndRedirect(string $rawPath, ArticleManager $articles): void
|
||||||
{
|
{
|
||||||
require_once BASE_PATH . '/src/SearchEngine.php';
|
require_once BASE_PATH . '/src/SearchEngine.php';
|
||||||
$query = (string)preg_replace('/\s{2,}/', ' ', trim(
|
$query = slugToSearchQuery($rawPath);
|
||||||
(string)preg_replace('/[^a-zA-ZÀ-ÿ0-9\s]/u', ' ', str_replace(['-', '_', '/'], ' ', $rawPath))
|
|
||||||
));
|
|
||||||
if ($query === '') {
|
if ($query === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -629,10 +670,7 @@ switch ($action) {
|
|||||||
header('Location: /new');
|
header('Location: /new');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
require_once BASE_PATH . '/src/Parsedown.php';
|
$autoSeoDesc = buildAutoSeoDesc((string)($draft['content'] ?? ''), trim($draft['title'] ?? ''));
|
||||||
$_pd = new Parsedown();
|
|
||||||
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
|
|
||||||
unset($_pd);
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$seoTitle = trim($_POST['seo_title'] ?? '');
|
$seoTitle = trim($_POST['seo_title'] ?? '');
|
||||||
$seoDesc = trim($_POST['seo_description'] ?? '') ?: $autoSeoDesc;
|
$seoDesc = trim($_POST['seo_description'] ?? '') ?: $autoSeoDesc;
|
||||||
@@ -666,16 +704,15 @@ switch ($action) {
|
|||||||
case 'view':
|
case 'view':
|
||||||
$article = $slug !== '' ? $articles->getBySlug($slug) : null;
|
$article = $slug !== '' ? $articles->getBySlug($slug) : null;
|
||||||
if (!$article) {
|
if (!$article) {
|
||||||
searchAndRedirect($slug, $articles);
|
$q = slugToSearchQuery($slug);
|
||||||
http_response_code(404);
|
header('Location: /search' . ($q !== '' ? '?q=' . urlencode($q) : ''), true, 302);
|
||||||
echo 'Article introuvable.';
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$article['published']) {
|
if (!$article['published']) {
|
||||||
if (!canDoOnArticle('view_drafts', $article)) {
|
if (!canDoOnArticle('view_drafts', $article)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo 'Article introuvable.';
|
include BASE_PATH . '/templates/404.php';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -684,7 +721,7 @@ switch ($action) {
|
|||||||
if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) {
|
if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) {
|
||||||
if (!hasCapability('view_previews')) {
|
if (!hasCapability('view_previews')) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo 'Article introuvable.';
|
include BASE_PATH . '/templates/404.php';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,10 +733,33 @@ switch ($action) {
|
|||||||
$isPrivateCat = $articleCat !== '' && in_array($articleCat, $privateCats, true);
|
$isPrivateCat = $articleCat !== '' && in_array($articleCat, $privateCats, true);
|
||||||
if ($isPrivateCat && !isLoggedIn()) {
|
if ($isPrivateCat && !isLoggedIn()) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo 'Article introuvable.';
|
include BASE_PATH . '/templates/404.php';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache HTTP : Last-Modified + 304 pour les articles publiés
|
||||||
|
if ($article['published']) {
|
||||||
|
$_uuid = $article['uuid'] ?? '';
|
||||||
|
$_mdFile = DATA_PATH . '/' . $_uuid . '/index.md';
|
||||||
|
$_mfFile = DATA_PATH . '/' . $_uuid . '/meta.json';
|
||||||
|
$_lm = max(
|
||||||
|
is_file($_mdFile) ? (int)filemtime($_mdFile) : 0,
|
||||||
|
is_file($_mfFile) ? (int)filemtime($_mfFile) : 0
|
||||||
|
);
|
||||||
|
if ($_lm > 0) {
|
||||||
|
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $_lm) . ' GMT');
|
||||||
|
header('Cache-Control: public, max-age=60');
|
||||||
|
$ifModSince = isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])
|
||||||
|
? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE'])
|
||||||
|
: false;
|
||||||
|
if ($ifModSince !== false && $_lm <= $ifModSince) {
|
||||||
|
http_response_code(304);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($_uuid, $_mdFile, $_mfFile, $_lm, $ifModSince);
|
||||||
|
}
|
||||||
|
|
||||||
$files = $articles->getFiles($article['uuid']);
|
$files = $articles->getFiles($article['uuid']);
|
||||||
|
|
||||||
// Résout les chemins de fichiers relatifs dans le contenu
|
// Résout les chemins de fichiers relatifs dans le contenu
|
||||||
@@ -836,6 +896,13 @@ switch ($action) {
|
|||||||
$bookContext['next_article'] = $bookContext['next'] !== null ? $articles->getBySlug($bookContext['next']) : null;
|
$bookContext['next_article'] = $bookContext['next'] !== null ? $articles->getBySlug($bookContext['next']) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$articleVisitors = [];
|
||||||
|
$_visFile = DATA_PATH . '/' . ($article['uuid'] ?? '') . '/visitors.json';
|
||||||
|
if (($article['uuid'] ?? '') !== '' && is_file($_visFile)) {
|
||||||
|
$articleVisitors = json_decode((string) file_get_contents($_visFile), true) ?: [];
|
||||||
|
}
|
||||||
|
unset($_visFile);
|
||||||
|
|
||||||
include BASE_PATH . '/templates/post_view.php';
|
include BASE_PATH . '/templates/post_view.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -961,17 +1028,21 @@ switch ($action) {
|
|||||||
|
|
||||||
case 5:
|
case 5:
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$articles->saveDraftOverlay($uuid, [
|
$overlayFields = [
|
||||||
'seo_title' => trim($_POST['seo_title'] ?? ''),
|
'seo_title' => trim($_POST['seo_title'] ?? ''),
|
||||||
'seo_description' => trim($_POST['seo_description'] ?? ''),
|
'seo_description' => trim($_POST['seo_description'] ?? ''),
|
||||||
]);
|
];
|
||||||
|
$coverFile = trim($_POST['cover_file'] ?? '');
|
||||||
|
if ($coverFile !== '' && $coverFile !== ($draft['cover'] ?? '')) {
|
||||||
|
$articles->setCover($uuid, $coverFile);
|
||||||
|
$updatedCover = $articles->getByUuid($uuid)['cover'] ?? $coverFile;
|
||||||
|
$overlayFields['og_image'] = rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($updatedCover);
|
||||||
|
}
|
||||||
|
$articles->saveDraftOverlay($uuid, $overlayFields);
|
||||||
header('Location: /edit/' . rawurlencode($uuid) . '/6');
|
header('Location: /edit/' . rawurlencode($uuid) . '/6');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
require_once BASE_PATH . '/src/Parsedown.php';
|
$autoSeoDesc = buildAutoSeoDesc((string)($draft['content'] ?? ''), trim($draft['title'] ?? ''));
|
||||||
$_pd = new Parsedown();
|
|
||||||
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
|
|
||||||
unset($_pd);
|
|
||||||
$title = $draft['title'];
|
$title = $draft['title'];
|
||||||
$seoTitle = $draft['seo_title'] ?? '';
|
$seoTitle = $draft['seo_title'] ?? '';
|
||||||
$seoDescription = $draft['seo_description'] ?? '';
|
$seoDescription = $draft['seo_description'] ?? '';
|
||||||
@@ -979,29 +1050,23 @@ switch ($action) {
|
|||||||
$published = (bool)($draft['published'] ?? false);
|
$published = (bool)($draft['published'] ?? false);
|
||||||
$published_at = $draft['published_at'] ?? '';
|
$published_at = $draft['published_at'] ?? '';
|
||||||
$category = $draft['category'] ?? '';
|
$category = $draft['category'] ?? '';
|
||||||
|
$existingFiles = $articles->getFiles($uuid);
|
||||||
|
$article = $draft;
|
||||||
include BASE_PATH . '/templates/wizard/step5.php';
|
include BASE_PATH . '/templates/wizard/step5.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 6:
|
case 6:
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['_confirm'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['_confirm'])) {
|
||||||
$revisionComment = trim($_POST['revision_comment'] ?? '');
|
$revisionComment = trim($_POST['revision_comment'] ?? '');
|
||||||
// Si le slug a été modifié dans le formulaire de confirmation, le propager
|
|
||||||
if (!empty($_POST['slug'])) {
|
|
||||||
$articles->saveDraftOverlay($uuid, ['slug' => trim($_POST['slug'])]);
|
|
||||||
}
|
|
||||||
$articles->commitDraftOverlay($uuid, $revisionComment);
|
$articles->commitDraftOverlay($uuid, $revisionComment);
|
||||||
$final = $articles->getByUuid($uuid);
|
$final = $articles->getByUuid($uuid);
|
||||||
header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid));
|
header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid));
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$draftData = $articles->getDraftOverlay($uuid) ?? $article;
|
$draftData = $articles->getDraftOverlay($uuid) ?? $article;
|
||||||
require_once BASE_PATH . '/src/Parsedown.php';
|
$autoSeoDesc = buildAutoSeoDesc((string)($draftData['content'] ?? ''), trim($draftData['title'] ?? ''));
|
||||||
$_pd = new Parsedown();
|
|
||||||
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draftData['content'] ?? ''))))), 0, 155, '…');
|
|
||||||
unset($_pd);
|
|
||||||
$diffLines = lineDiff((string)($article['content'] ?? ''), (string)($draftData['content'] ?? ''));
|
$diffLines = lineDiff((string)($article['content'] ?? ''), (string)($draftData['content'] ?? ''));
|
||||||
$titleChanged = ($draftData['title'] ?? '') !== ($article['title'] ?? '');
|
$titleChanged = ($draftData['title'] ?? '') !== ($article['title'] ?? '');
|
||||||
$autoSlug = slugify($draftData['title'] ?? '');
|
|
||||||
$postSlug = $draftData['slug'] ?? $article['slug'];
|
$postSlug = $draftData['slug'] ?? $article['slug'];
|
||||||
$changes = [];
|
$changes = [];
|
||||||
if ($titleChanged) {
|
if ($titleChanged) {
|
||||||
@@ -1019,6 +1084,9 @@ switch ($action) {
|
|||||||
if ((bool)($draftData['published'] ?? false) !== (bool)($article['published'] ?? false)) {
|
if ((bool)($draftData['published'] ?? false) !== (bool)($article['published'] ?? false)) {
|
||||||
$changes[] = ($draftData['published'] ?? false) ? 'article publié' : 'article dépublié';
|
$changes[] = ($draftData['published'] ?? false) ? 'article publié' : 'article dépublié';
|
||||||
}
|
}
|
||||||
|
if (($draftData['og_image'] ?? '') !== ($article['og_image'] ?? '')) {
|
||||||
|
$changes[] = 'image de couverture modifiée';
|
||||||
|
}
|
||||||
$autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : '';
|
$autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : '';
|
||||||
$title = $draftData['title'] ?? '';
|
$title = $draftData['title'] ?? '';
|
||||||
$seoTitle = $draftData['seo_title'] ?? '';
|
$seoTitle = $draftData['seo_title'] ?? '';
|
||||||
@@ -1111,10 +1179,37 @@ switch ($action) {
|
|||||||
header('Location: /edit/' . rawurlencode($uuid));
|
header('Location: /edit/' . rawurlencode($uuid));
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
case 'duplicate':
|
||||||
|
requireAuth();
|
||||||
|
if ($uuid !== '' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$srcArticle = $articles->getByUuid($uuid);
|
||||||
|
if (!$srcArticle) {
|
||||||
|
header('Location: /admin/articles');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!isAdmin() && ($srcArticle['author'] ?? '') !== (currentUserEmail() ?? '')) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$newUuid = $articles->duplicate($uuid, currentUserEmail() ?? '');
|
||||||
|
if ($newUuid) {
|
||||||
|
header('Location: /edit/' . rawurlencode($newUuid) . '/1');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: /admin/articles');
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'delete':
|
case 'delete':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if ($uuid !== '') {
|
if ($uuid !== '') {
|
||||||
$articles->delete($uuid);
|
if (!$articles->delete($uuid)) {
|
||||||
|
$failedArt = $articles->getByUuid($uuid);
|
||||||
|
$failedSlug = $failedArt['slug'] ?? '';
|
||||||
|
$back = $failedSlug !== '' ? '/post/' . rawurlencode($failedSlug) : '/';
|
||||||
|
header('Location: ' . $back . '?delete_failed=1');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
header('Location: /');
|
header('Location: /');
|
||||||
exit;
|
exit;
|
||||||
@@ -1387,6 +1482,7 @@ switch ($action) {
|
|||||||
require_once BASE_PATH . '/src/FeedFetcher.php';
|
require_once BASE_PATH . '/src/FeedFetcher.php';
|
||||||
$fetcher = new FeedFetcher(DATA_PATH . '/_cache/feeds');
|
$fetcher = new FeedFetcher(DATA_PATH . '/_cache/feeds');
|
||||||
$fluxItems = [];
|
$fluxItems = [];
|
||||||
|
$fluxErrors = [];
|
||||||
$pdo = dbPdo();
|
$pdo = dbPdo();
|
||||||
if ($pdo) {
|
if ($pdo) {
|
||||||
try {
|
try {
|
||||||
@@ -1400,6 +1496,11 @@ switch ($action) {
|
|||||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $_row) {
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $_row) {
|
||||||
$data = $fetcher->get($_row['feed_url']);
|
$data = $fetcher->get($_row['feed_url']);
|
||||||
if (!$data) {
|
if (!$data) {
|
||||||
|
$fluxErrors[] = [
|
||||||
|
'feed_url' => $_row['feed_url'],
|
||||||
|
'label' => $_row['label'],
|
||||||
|
'user_email' => $_row['user_email'],
|
||||||
|
];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$feedTitle = $_row['label'] !== '' ? $_row['label'] : $data['feed_title'];
|
$feedTitle = $_row['label'] !== '' ? $_row['label'] : $data['feed_title'];
|
||||||
@@ -1598,6 +1699,24 @@ switch ($action) {
|
|||||||
echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? '')));
|
echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? '')));
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
case 'ai_query':
|
||||||
|
requireAuth();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Méthode invalide']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$_aiAction = trim($_POST['action'] ?? '');
|
||||||
|
$_aiTitle = trim($_POST['title'] ?? '');
|
||||||
|
$_aiContent = str_replace("\r\n", "\n", trim($_POST['content'] ?? ''));
|
||||||
|
if (!in_array($_aiAction, ['critique', 'rewrite', 'analyze'], true) || $_aiContent === '') {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
require_once BASE_PATH . '/src/Service/AiService.php';
|
||||||
|
echo json_encode((new AiService())->query($_aiAction, $_aiTitle, $_aiContent));
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'import_image_step2':
|
case 'import_image_step2':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
@@ -2148,10 +2267,43 @@ switch ($action) {
|
|||||||
if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) {
|
if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) {
|
||||||
require_once BASE_PATH . '/src/CommentManager.php';
|
require_once BASE_PATH . '/src/CommentManager.php';
|
||||||
$cm = new CommentManager($pdo);
|
$cm = new CommentManager($pdo);
|
||||||
|
|
||||||
|
// Récupère les données du commentaire avant vérification (le token est effacé après)
|
||||||
|
$vcPreSt = $pdo->prepare(
|
||||||
|
'SELECT author_name, content FROM comments WHERE verify_token = :t AND verified = FALSE LIMIT 1'
|
||||||
|
);
|
||||||
|
$vcPreSt->execute([':t' => $vcToken]);
|
||||||
|
$vcPreInfo = $vcPreSt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||||
|
|
||||||
$result = $cm->verify($vcToken, $vcCode);
|
$result = $cm->verify($vcToken, $vcCode);
|
||||||
if (is_string($result)) {
|
if (is_string($result)) {
|
||||||
$vcArticle = $articles->getByUuid($result);
|
$vcArticle = $articles->getByUuid($result);
|
||||||
$vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $result) : $result;
|
$vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $result) : $result;
|
||||||
|
|
||||||
|
// Notification email à l'auteur de l'article
|
||||||
|
$vcAuthorEmail = $vcArticle['author'] ?? '';
|
||||||
|
if ($vcAuthorEmail !== '' && $vcPreInfo) {
|
||||||
|
require_once BASE_PATH . '/src/mailer.php';
|
||||||
|
$vcPostUrl = rtrim(APP_URL, '/') . '/post/' . rawurlencode($vcSlug) . '#comments';
|
||||||
|
$vcAdminUrl = rtrim(APP_URL, '/') . '/admin/comments';
|
||||||
|
$vcExcerpt = mb_strimwidth(trim((string)$vcPreInfo['content']), 0, 200, '…');
|
||||||
|
$vcSubject = '[' . siteTitle() . '] Nouveau commentaire sur « ' . ($vcArticle['title'] ?? '') . ' »';
|
||||||
|
$vcHtml = '<!DOCTYPE html><html><body style="font-family:sans-serif;max-width:560px;margin:0 auto">'
|
||||||
|
. '<p>Bonjour,</p>'
|
||||||
|
. '<p><strong>' . htmlspecialchars((string)$vcPreInfo['author_name']) . '</strong>'
|
||||||
|
. ' a commenté votre article <em>' . htmlspecialchars($vcArticle['title'] ?? '') . '</em> :</p>'
|
||||||
|
. '<blockquote style="border-left:3px solid #ddd;margin:0;padding:0 1em;color:#555">'
|
||||||
|
. nl2br(htmlspecialchars($vcExcerpt)) . '</blockquote>'
|
||||||
|
. '<p><a href="' . htmlspecialchars($vcPostUrl) . '">Voir le commentaire</a>'
|
||||||
|
. ' · <a href="' . htmlspecialchars($vcAdminUrl) . '">Modérer</a></p>'
|
||||||
|
. '</body></html>';
|
||||||
|
try {
|
||||||
|
envoyer_mail_smtp($vcAuthorEmail, $vcSubject, $vcHtml);
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
// Taux limité ou SMTP indisponible, on ne bloque pas le visiteur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments');
|
header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -2333,7 +2485,6 @@ switch ($action) {
|
|||||||
$me = currentUserEmail() ?? '';
|
$me = currentUserEmail() ?? '';
|
||||||
$allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $me));
|
$allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $me));
|
||||||
}
|
}
|
||||||
usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? ''));
|
|
||||||
|
|
||||||
$adminData['filter_authors'] = array_values(array_unique(array_filter(array_column($allArticles, 'author'))));
|
$adminData['filter_authors'] = array_values(array_unique(array_filter(array_column($allArticles, 'author'))));
|
||||||
$adminData['filter_categories'] = array_values(array_unique(array_filter(array_column($allArticles, 'category'))));
|
$adminData['filter_categories'] = array_values(array_unique(array_filter(array_column($allArticles, 'category'))));
|
||||||
@@ -2343,9 +2494,13 @@ switch ($action) {
|
|||||||
$filterAuthor = trim($_GET['filter_author'] ?? '');
|
$filterAuthor = trim($_GET['filter_author'] ?? '');
|
||||||
$filterCategory = trim($_GET['filter_category'] ?? '');
|
$filterCategory = trim($_GET['filter_category'] ?? '');
|
||||||
$filterStatus = trim($_GET['filter_status'] ?? '');
|
$filterStatus = trim($_GET['filter_status'] ?? '');
|
||||||
|
$filterSearch = trim($_GET['filter_search'] ?? '');
|
||||||
|
$filterFeatured = trim($_GET['filter_featured'] ?? '');
|
||||||
$adminData['filter_author'] = $filterAuthor;
|
$adminData['filter_author'] = $filterAuthor;
|
||||||
$adminData['filter_category'] = $filterCategory;
|
$adminData['filter_category'] = $filterCategory;
|
||||||
$adminData['filter_status'] = $filterStatus;
|
$adminData['filter_status'] = $filterStatus;
|
||||||
|
$adminData['filter_search'] = $filterSearch;
|
||||||
|
$adminData['filter_featured'] = $filterFeatured;
|
||||||
|
|
||||||
$nowTs = time();
|
$nowTs = time();
|
||||||
if ($filterAuthor !== '') {
|
if ($filterAuthor !== '') {
|
||||||
@@ -2361,8 +2516,30 @@ switch ($action) {
|
|||||||
} elseif ($filterStatus === 'preview') {
|
} elseif ($filterStatus === 'preview') {
|
||||||
$allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $nowTs));
|
$allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $nowTs));
|
||||||
}
|
}
|
||||||
|
if ($filterSearch !== '') {
|
||||||
|
$allArticles = array_values(array_filter($allArticles, fn ($a) => mb_stripos($a['title'] ?? '', $filterSearch) !== false));
|
||||||
|
}
|
||||||
|
if ($filterFeatured === 'yes') {
|
||||||
|
$allArticles = array_values(array_filter($allArticles, fn ($a) => !empty($a['featured'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortBy = in_array($_GET['sort'] ?? '', ['title', 'published', 'updated'], true) ? $_GET['sort'] : 'updated';
|
||||||
|
$sortDir = ($_GET['dir'] ?? '') === 'asc' ? 'asc' : 'desc';
|
||||||
|
usort($allArticles, function ($a, $b) use ($sortBy, $sortDir) {
|
||||||
|
$cmp = match ($sortBy) {
|
||||||
|
'title' => strcmp($a['title'] ?? '', $b['title'] ?? ''),
|
||||||
|
'published' => strcmp(
|
||||||
|
$a['published_at'] ?? $a['created_at'] ?? '',
|
||||||
|
$b['published_at'] ?? $b['created_at'] ?? ''
|
||||||
|
),
|
||||||
|
default => strcmp($a['updated_at'] ?? '', $b['updated_at'] ?? ''),
|
||||||
|
};
|
||||||
|
return $sortDir === 'asc' ? $cmp : -$cmp;
|
||||||
|
});
|
||||||
|
|
||||||
$adminData['articles'] = $allArticles;
|
$adminData['articles'] = $allArticles;
|
||||||
|
$adminData['sort_by'] = $sortBy;
|
||||||
|
$adminData['sort_dir'] = $sortDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tab === 'roles') {
|
if ($tab === 'roles') {
|
||||||
@@ -2499,7 +2676,7 @@ switch ($action) {
|
|||||||
'queued' => (int)($row['queued'] ?? 0),
|
'queued' => (int)($row['queued'] ?? 0),
|
||||||
];
|
];
|
||||||
$adminData['emails'] = $pdo->query(
|
$adminData['emails'] = $pdo->query(
|
||||||
"SELECT id, created_at, to_email, subject, status, error_message, content_text, sent_at
|
"SELECT id, created_at, to_email, subject, status, error_message, content_text, content_html, sent_at
|
||||||
FROM journal_smtp $whereEml
|
FROM journal_smtp $whereEml
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $emlLimit OFFSET $emlOffset"
|
LIMIT $emlLimit OFFSET $emlOffset"
|
||||||
@@ -2539,9 +2716,11 @@ switch ($action) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
require_once BASE_PATH . '/src/SearchLogParser.php';
|
require_once BASE_PATH . '/src/SearchLogParser.php';
|
||||||
$parser = new SearchLogParser('/var/log/apache2', apacheAccessLog());
|
$days = in_array((int)($_GET['days'] ?? 14), [7, 14], true) ? (int)$_GET['days'] : 14;
|
||||||
|
$parser = new SearchLogParser('/var/log/apache2', apacheAccessLog(), '', 600, $days);
|
||||||
$adminData['search_terms'] = $parser->topTerms(100);
|
$adminData['search_terms'] = $parser->topTerms(100);
|
||||||
$adminData['search_log_readable'] = $parser->isReadable();
|
$adminData['search_log_readable'] = $parser->isReadable();
|
||||||
|
$adminData['search_days'] = $days;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tab === 'stats') {
|
if ($tab === 'stats') {
|
||||||
@@ -2549,20 +2728,112 @@ switch ($action) {
|
|||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
require_once BASE_PATH . '/src/TrendingParser.php';
|
||||||
require_once BASE_PATH . '/src/AccessLogParser.php';
|
require_once BASE_PATH . '/src/AccessLogParser.php';
|
||||||
require_once BASE_PATH . '/src/AsnLookup.php';
|
require_once BASE_PATH . '/src/AsnLookup.php';
|
||||||
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
|
|
||||||
|
// Patterns de bots — initialisation si absent
|
||||||
|
$botsFile = DATA_PATH . '/bots.json';
|
||||||
|
if (!file_exists($botsFile)) {
|
||||||
|
$defaultBots = [
|
||||||
|
'Googlebot', 'Googlebot-Image', 'Google-InspectionTool', 'Google-Extended',
|
||||||
|
'bingbot', 'BingPreview', 'msnbot',
|
||||||
|
'DuckDuckBot', 'DuckDuckGo-Favicons-Bot',
|
||||||
|
'Baiduspider', 'YandexBot', 'YandexImages', 'YandexMetrika',
|
||||||
|
'Applebot',
|
||||||
|
'facebookexternalhit', 'facebot',
|
||||||
|
'Twitterbot', 'LinkedInBot', 'Slackbot', 'TelegramBot', 'WhatsApp', 'Discordbot',
|
||||||
|
'PetalBot', 'Bytespider', 'SogouSpider', 'SeznamBot', 'Exabot',
|
||||||
|
'AhrefsBot', 'SemrushBot', 'MJ12bot', 'DotBot', 'rogerbot', 'BLEXBot', 'DataForSeoBot',
|
||||||
|
'Screaming Frog SEO Spider',
|
||||||
|
'ClaudeBot', 'GPTBot', 'PerplexityBot', 'cohere-ai', 'anthropic-ai',
|
||||||
|
'meta-externalagent', 'OAI-SearchBot', 'Amazonbot',
|
||||||
|
'CCBot', 'ia_archiver', 'archive.org_bot',
|
||||||
|
'NetcraftSurveyAgent',
|
||||||
|
'python-requests', 'python-urllib', 'Python/',
|
||||||
|
'curl/', 'wget/', 'Wget/',
|
||||||
|
'Go-http-client/1', 'Java/', 'Apache-HttpClient', 'okhttp/',
|
||||||
|
'Scrapy', 'HeadlessChrome', 'PhantomJS', 'Puppeteer', 'Playwright', 'Selenium',
|
||||||
|
'UptimeRobot', 'Pingdom', 'StatusCake', 'Site24x7', 'GTmetrix',
|
||||||
|
'Chrome-Lighthouse', 'PageSpeed', 'Zabbix', 'check_http',
|
||||||
|
'libwww-perl', 'GuzzleHttp', 'masscan', 'zgrab', 'nuclei',
|
||||||
|
];
|
||||||
|
@mkdir(dirname($botsFile), 0755, true);
|
||||||
|
@file_put_contents($botsFile, json_encode($defaultBots, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
$botPatterns = json_decode((string) file_get_contents($botsFile), true) ?: [];
|
||||||
|
$adminData['bot_patterns'] = $botPatterns;
|
||||||
|
|
||||||
|
$statsCacheFile = DATA_PATH . '/.stats_cache.json';
|
||||||
|
$statsRaw = null;
|
||||||
|
if (file_exists($statsCacheFile) && (time() - filemtime($statsCacheFile)) < 60) {
|
||||||
|
$statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: null;
|
||||||
|
}
|
||||||
|
if ($statsRaw === null) {
|
||||||
|
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
|
||||||
|
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||||
|
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog(), '', 600, 30, $botPatterns);
|
||||||
$accessStats = $accessParser->stats();
|
$accessStats = $accessParser->stats();
|
||||||
$adminData['stats_readable'] = $accessParser->isReadable();
|
|
||||||
$adminData['stats_pages'] = array_slice($accessStats['pages'], 0, 30, true);
|
|
||||||
$adminData['stats_books'] = array_slice($accessStats['books'], 0, 20, true);
|
|
||||||
// Lookup AS pour les top 200 IPs
|
|
||||||
$topIps = array_slice($accessStats['ips'], 0, 200, true);
|
$topIps = array_slice($accessStats['ips'], 0, 200, true);
|
||||||
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
||||||
$asList = AsnLookup::aggregateByAs($topIps, $asnMap);
|
|
||||||
$adminData['stats_as'] = $asList;
|
$ipData = [];
|
||||||
$adminData['stats_as_groups'] = AsnLookup::applyGroups($asList, asGroups());
|
foreach ($accessStats['ips_by_day'] ?? [] as $ip => $daily) {
|
||||||
|
$info = $asnMap[$ip] ?? ['asn' => '', 'name' => '?', 'country' => ''];
|
||||||
|
$ipData[$ip] = [
|
||||||
|
'hits' => $topIps[$ip] ?? (int) array_sum($daily),
|
||||||
|
'asn' => $info['asn'],
|
||||||
|
'name' => $info['name'],
|
||||||
|
'country' => $info['country'],
|
||||||
|
'daily' => $daily,
|
||||||
|
'paths' => $accessStats['ip_top_paths'][$ip] ?? [],
|
||||||
|
'agents' => $accessStats['ip_agents'][$ip] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$statsRaw = [
|
||||||
|
'readable' => $accessParser->isReadable(),
|
||||||
|
'books' => $tParser->top($cutoff14, 20, ['/book/']),
|
||||||
|
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
|
||||||
|
'pages_by_day' => $accessStats['pages_by_day'] ?? [],
|
||||||
|
'ip_data' => $ipData,
|
||||||
|
'all_uas' => $accessStats['all_uas'] ?? [],
|
||||||
|
'unique_visitors' => $accessStats['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0],
|
||||||
|
'article_unique_visitors' => $accessStats['article_unique_visitors'] ?? [],
|
||||||
|
];
|
||||||
|
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
||||||
|
}
|
||||||
|
$adminData['stats_readable'] = $statsRaw['readable'];
|
||||||
|
$adminData['stats_books'] = $statsRaw['books'];
|
||||||
|
$adminData['stats_as'] = $statsRaw['as'];
|
||||||
|
$adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
|
||||||
$adminData['as_groups'] = asGroups();
|
$adminData['as_groups'] = asGroups();
|
||||||
|
$adminData['stats_pages_by_day'] = $statsRaw['pages_by_day'] ?? [];
|
||||||
|
$adminData['stats_ip_data'] = $statsRaw['ip_data'] ?? [];
|
||||||
|
$adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
|
||||||
|
$adminData['stats_unique_visitors'] = $statsRaw['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0];
|
||||||
|
|
||||||
|
// Écriture des visitors.json par article (slug → UUID via l'index)
|
||||||
|
$_slugIndex = is_file(DATA_PATH . '/_cache/slug_index.json')
|
||||||
|
? (json_decode((string) file_get_contents(DATA_PATH . '/_cache/slug_index.json'), true) ?: [])
|
||||||
|
: [];
|
||||||
|
foreach (($statsRaw['article_unique_visitors'] ?? []) as $_artPath => $_artCounts) {
|
||||||
|
$_artSlug = rawurldecode(substr((string) $_artPath, 6));
|
||||||
|
$_artUuid = $_slugIndex[$_artSlug] ?? null;
|
||||||
|
if ($_artUuid !== null && preg_match('/^[0-9a-f\-]{36}$/i', $_artUuid)) {
|
||||||
|
@file_put_contents(
|
||||||
|
DATA_PATH . '/' . $_artUuid . '/visitors.json',
|
||||||
|
json_encode(array_merge($_artCounts, ['updated' => time()]), JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($_slugIndex, $_artPath, $_artCounts, $_artSlug, $_artUuid);
|
||||||
|
|
||||||
|
// AS exclus (chargé en direct, pas mis en cache)
|
||||||
|
$excludedAsFile = DATA_PATH . '/excluded_as.json';
|
||||||
|
$adminData['excluded_as'] = is_file($excludedAsFile)
|
||||||
|
? (json_decode((string) file_get_contents($excludedAsFile), true) ?: [])
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tab === 'categories') {
|
if ($tab === 'categories') {
|
||||||
@@ -2571,6 +2842,22 @@ switch ($action) {
|
|||||||
$adminData['tagTypes'] = $articles->getTagTypes();
|
$adminData['tagTypes'] = $articles->getTagTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($tab === 'flux') {
|
||||||
|
if (!isAdmin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$pdo = dbPdo();
|
||||||
|
$adminData['flux_feeds'] = [];
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->query('SELECT id, user_email, feed_url, label, created_at FROM rss_feeds ORDER BY created_at DESC');
|
||||||
|
$adminData['flux_feeds'] = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($tab === 'books') {
|
if ($tab === 'books') {
|
||||||
if (!isAdmin()) {
|
if (!isAdmin()) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
@@ -2586,9 +2873,41 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($tab === 'ia') {
|
||||||
|
if (!isAdmin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
|
require_once BASE_PATH . '/src/Service/AiService.php';
|
||||||
|
$adminData['ai_provider'] = aiProvider();
|
||||||
|
$adminData['ai_model'] = aiModel();
|
||||||
|
$adminData['anthropic_key_set'] = (($_ENV['ANTHROPIC_API_KEY'] ?? getenv('ANTHROPIC_API_KEY') ?: '') !== '');
|
||||||
|
$adminData['claude_cli_found'] = is_executable('/usr/local/bin/claude');
|
||||||
|
$adminData['ai_notice'] = $_GET['notice'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
include BASE_PATH . '/templates/admin.php';
|
include BASE_PATH . '/templates/admin.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'admin_save_ai_config':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
|
$allowedProviders = ['anthropic', 'claude_code'];
|
||||||
|
$aiProvider = in_array($_POST['ai_provider'] ?? '', $allowedProviders, true)
|
||||||
|
? $_POST['ai_provider']
|
||||||
|
: 'anthropic';
|
||||||
|
$ok = saveSiteSettings([
|
||||||
|
'ai_provider' => $aiProvider,
|
||||||
|
'ai_model' => trim($_POST['ai_model'] ?? ''),
|
||||||
|
]);
|
||||||
|
header('Location: /admin/ia?notice=' . ($ok ? 'saved' : 'error'));
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'admin_smtp_save':
|
case 'admin_smtp_save':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if (!isAdmin()) {
|
if (!isAdmin()) {
|
||||||
@@ -2699,6 +3018,45 @@ switch ($action) {
|
|||||||
header('Location: /admin/smtp');
|
header('Location: /admin/smtp');
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
case 'admin_email_preview':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$previewId = (int)($_GET['id'] ?? 0);
|
||||||
|
$pdo = dbPdo();
|
||||||
|
$emailRow = null;
|
||||||
|
if ($pdo && $previewId > 0) {
|
||||||
|
$st = $pdo->prepare('SELECT subject, content_html, content_text FROM journal_smtp WHERE id = :id');
|
||||||
|
$st->execute([':id' => $previewId]);
|
||||||
|
$emailRow = $st->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||||
|
}
|
||||||
|
if (!$emailRow) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo 'Email introuvable.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
header('Content-Type: text/html; charset=UTF-8');
|
||||||
|
$previewHtml = !empty($emailRow['content_html']) ? $emailRow['content_html'] : nl2br(htmlspecialchars((string)$emailRow['content_text']));
|
||||||
|
echo '<!doctype html><html lang="fr"><head><meta charset="utf-8"><title>' . htmlspecialchars((string)$emailRow['subject']) . '</title></head><body>' . $previewHtml . '</body></html>';
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'admin_toggle_featured':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$uid = trim((string)($_POST['uuid'] ?? ''));
|
||||||
|
$art = $uid !== '' ? $articles->getByUuid($uid) : null;
|
||||||
|
if ($art) {
|
||||||
|
$articles->setFeatured($uid, !((bool)($art['featured'] ?? false)));
|
||||||
|
}
|
||||||
|
$back = $_POST['_back'] ?? '/admin/articles';
|
||||||
|
header('Location: ' . $back);
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'admin_bulk_delete':
|
case 'admin_bulk_delete':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
@@ -2823,74 +3181,20 @@ switch ($action) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. git pull — vérifier que origin pointe vers le dépôt folio configuré
|
set_time_limit(0);
|
||||||
$_folioRepo = rtrim(folioRepoUrl(), '/');
|
ignore_user_abort(true);
|
||||||
exec('git -C ' . escapeshellarg(BASE_PATH) . ' remote get-url origin 2>&1', $_originOut, $_originCode);
|
|
||||||
$_originUrl = rtrim(trim(implode('', $_originOut)), '/');
|
|
||||||
// Normaliser : supprimer les credentials éventuels de l'URL (token@host → host)
|
|
||||||
$_originNorm = preg_replace('#https?://[^@]+@#', 'https://', $_originUrl);
|
|
||||||
$_repoNorm = preg_replace('#https?://[^@]+@#', 'https://', $_folioRepo);
|
|
||||||
if ($_originCode !== 0 || $_originNorm !== $_repoNorm) {
|
|
||||||
$_SESSION['_update_log'] = "Le remote git 'origin' (" . $_originUrl . ") ne correspond pas à FOLIO_REPO_URL (" . $_folioRepo . "). git pull annulé.";
|
|
||||||
header('Location: /admin?tab=dashboard¬ice=update_git_error');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
exec('cd ' . escapeshellarg(BASE_PATH) . ' && git pull origin main 2>&1', $_gitOut, $_gitCode);
|
|
||||||
if ($_gitCode !== 0) {
|
|
||||||
$_SESSION['_update_log'] = implode("\n", $_gitOut);
|
|
||||||
header('Location: /admin?tab=dashboard¬ice=update_git_error');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. composer install (non-bloquant si absent)
|
exec('sudo /usr/local/bin/folio-upgrade.sh ' . escapeshellarg(folioUpdateBranch()) . ' 2>&1', $_upgradeOut, $_upgradeCode);
|
||||||
exec('which composer 2>/dev/null', $_composerPath);
|
|
||||||
if (!empty($_composerPath)) {
|
|
||||||
exec('cd ' . escapeshellarg(BASE_PATH) . ' && composer install --no-dev --optimize-autoloader -q 2>&1');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Migrations SQL
|
|
||||||
$pdo->exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT NOT NULL PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT NOW())');
|
|
||||||
$_sqlApplied = array_flip($pdo->query('SELECT name FROM schema_migrations ORDER BY name')->fetchAll(PDO::FETCH_COLUMN));
|
|
||||||
$_sqlFiles = glob(BASE_PATH . '/database/migration_*.sql') ?: [];
|
|
||||||
sort($_sqlFiles);
|
|
||||||
foreach ($_sqlFiles as $_sqlFile) {
|
|
||||||
$_sqlName = basename($_sqlFile);
|
|
||||||
if (isset($_sqlApplied[$_sqlName])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$pdo->exec((string) file_get_contents($_sqlFile));
|
|
||||||
$pdo->prepare('INSERT INTO schema_migrations (name) VALUES (:n)')->execute([':n' => $_sqlName]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Migrations de contenu
|
|
||||||
$_cmDataDir = DATA_PATH;
|
|
||||||
$_cmTrack = $_cmDataDir . '/.content_migrations.json';
|
|
||||||
$_cmFlag = $_cmDataDir . '/.maintenance';
|
|
||||||
$_cmApplied = file_exists($_cmTrack) ? (json_decode((string) file_get_contents($_cmTrack), true) ?? []) : [];
|
|
||||||
$_cmFiles = glob(BASE_PATH . '/scripts/content/migration_*.php') ?: [];
|
|
||||||
sort($_cmFiles);
|
|
||||||
$_cmPending = array_values(array_filter($_cmFiles, fn ($f) => !isset($_cmApplied[basename($f)])));
|
|
||||||
$_cmErrors = 0;
|
|
||||||
if (!empty($_cmPending)) {
|
|
||||||
file_put_contents($_cmFlag, date('Y-m-d H:i:s'));
|
|
||||||
$dataDir = $_cmDataDir;
|
|
||||||
foreach ($_cmPending as $_cmFile) {
|
|
||||||
try {
|
|
||||||
require $_cmFile;
|
|
||||||
$_cmApplied[basename($_cmFile)] = date('Y-m-d H:i:s');
|
|
||||||
file_put_contents($_cmTrack, json_encode($_cmApplied, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n");
|
|
||||||
} catch (Throwable $_cmEx) {
|
|
||||||
$_cmErrors++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (file_exists($_cmFlag)) {
|
|
||||||
unlink($_cmFlag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$_updateChecker->clearCache();
|
$_updateChecker->clearCache();
|
||||||
header('Location: /admin?tab=dashboard¬ice=' . ($_cmErrors ? 'update_content_error' : 'engine_updated'));
|
|
||||||
|
if ($_upgradeCode !== 0) {
|
||||||
|
$_SESSION['_upgrade_log'] = implode("\n", $_upgradeOut);
|
||||||
|
header('Location: /admin?tab=dashboard¬ice=upgrade_error');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: /admin?tab=dashboard¬ice=engine_updated');
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
case 'force_update_check':
|
case 'force_update_check':
|
||||||
@@ -2968,6 +3272,108 @@ switch ($action) {
|
|||||||
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
|
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
case 'admin_save_bots':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$botsFile = DATA_PATH . '/bots.json';
|
||||||
|
$patterns = array_values(array_unique(array_filter(
|
||||||
|
array_map('trim', explode("\n", (string) ($_POST['bot_patterns'] ?? '')))
|
||||||
|
)));
|
||||||
|
$ok = @file_put_contents($botsFile, json_encode($patterns, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false;
|
||||||
|
if ($ok) {
|
||||||
|
@unlink(DATA_PATH . '/.stats_cache.json');
|
||||||
|
@unlink(BASE_PATH . '/_cache/access_stats.json');
|
||||||
|
}
|
||||||
|
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'admin_add_bot':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$csrf = $_POST['_csrf'] ?? '';
|
||||||
|
if ($csrf !== ($_session['csrf'] ?? '')) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'csrf']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$addPattern = trim((string) ($_POST['pattern'] ?? ''));
|
||||||
|
if ($addPattern === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'empty']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$botsFile = DATA_PATH . '/bots.json';
|
||||||
|
$botPatterns = is_file($botsFile) ? (json_decode((string) file_get_contents($botsFile), true) ?: []) : [];
|
||||||
|
if (!in_array($addPattern, $botPatterns, true)) {
|
||||||
|
$botPatterns[] = $addPattern;
|
||||||
|
@file_put_contents($botsFile, json_encode($botPatterns, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
@unlink(DATA_PATH . '/.stats_cache.json');
|
||||||
|
@unlink(BASE_PATH . '/_cache/access_stats.json');
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => true, 'pattern' => $addPattern]);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'admin_add_excluded_as':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$csrf = $_POST['_csrf'] ?? '';
|
||||||
|
if ($csrf !== ($_session['csrf'] ?? '')) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'csrf']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$asn = trim((string) ($_POST['asn'] ?? ''));
|
||||||
|
if ($asn === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'empty']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$excludedAsFile = DATA_PATH . '/excluded_as.json';
|
||||||
|
$excludedAs = is_file($excludedAsFile) ? (json_decode((string) file_get_contents($excludedAsFile), true) ?: []) : [];
|
||||||
|
if (!in_array($asn, $excludedAs, true)) {
|
||||||
|
$excludedAs[] = $asn;
|
||||||
|
@file_put_contents($excludedAsFile, json_encode($excludedAs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => true, 'asn' => $asn]);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'admin_remove_excluded_as':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$csrf = $_POST['_csrf'] ?? '';
|
||||||
|
if ($csrf !== ($_session['csrf'] ?? '')) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'csrf']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$asn = trim((string) ($_POST['asn'] ?? ''));
|
||||||
|
$excludedAsFile = DATA_PATH . '/excluded_as.json';
|
||||||
|
$excludedAs = is_file($excludedAsFile) ? (json_decode((string) file_get_contents($excludedAsFile), true) ?: []) : [];
|
||||||
|
$excludedAs = array_values(array_filter($excludedAs, static fn ($a) => $a !== $asn));
|
||||||
|
@file_put_contents($excludedAsFile, json_encode($excludedAs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => true, 'asn' => $asn]);
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'admin_create_role':
|
case 'admin_create_role':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
@@ -3217,6 +3623,25 @@ switch ($action) {
|
|||||||
header('Location: /profile#feeds');
|
header('Location: /profile#feeds');
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
case 'admin_delete_feed':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$feedId = (int)($_POST['id'] ?? 0);
|
||||||
|
if ($feedId > 0) {
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$pdo->prepare('DELETE FROM rss_feeds WHERE id = :id')->execute([':id' => $feedId]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: /admin/flux?deleted=1');
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'search_files':
|
case 'search_files':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -3302,6 +3727,27 @@ switch ($action) {
|
|||||||
include BASE_PATH . '/templates/search.php';
|
include BASE_PATH . '/templates/search.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'books_list':
|
||||||
|
$allCats = $articles->getCategories();
|
||||||
|
$booksData = [];
|
||||||
|
foreach ($books->getAll() as $_bk) {
|
||||||
|
$_published = [];
|
||||||
|
foreach ($_bk['articles'] ?? [] as $_aSlug) {
|
||||||
|
$_a = $articles->getBySlug($_aSlug);
|
||||||
|
if ($_a && $_a['published'] && strtotime((string)($_a['published_at'] ?? '')) <= time()) {
|
||||||
|
$_published[] = $_a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($_published)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$booksData[] = ['book' => $_bk, 'count' => count($_published), 'first' => $_published[0]];
|
||||||
|
}
|
||||||
|
unset($_bk, $_published, $_aSlug, $_a);
|
||||||
|
$seoDescription = 'Retrouvez tous les livres et séries d\'articles publiés sur ' . siteTitle() . '.';
|
||||||
|
include BASE_PATH . '/templates/books_list.php';
|
||||||
|
break;
|
||||||
|
|
||||||
case 'book':
|
case 'book':
|
||||||
$bookSlug = trim($_GET['book_slug'] ?? '');
|
$bookSlug = trim($_GET['book_slug'] ?? '');
|
||||||
$book = $books->getBySlug($bookSlug);
|
$book = $books->getBySlug($bookSlug);
|
||||||
@@ -3346,6 +3792,9 @@ switch ($action) {
|
|||||||
$bTitle = trim($_POST['title'] ?? '');
|
$bTitle = trim($_POST['title'] ?? '');
|
||||||
$bDesc = trim($_POST['description'] ?? '');
|
$bDesc = trim($_POST['description'] ?? '');
|
||||||
$bArts = array_values(array_filter(array_map('trim', preg_split('/[\r\n]+/', $_POST['articles'] ?? ''))));
|
$bArts = array_values(array_filter(array_map('trim', preg_split('/[\r\n]+/', $_POST['articles'] ?? ''))));
|
||||||
|
if ($bSlug === '' && $bTitle !== '') {
|
||||||
|
$bSlug = $books->sanitizeSlug($bTitle);
|
||||||
|
}
|
||||||
if ($bSlug !== '' && $bTitle !== '') {
|
if ($bSlug !== '' && $bTitle !== '') {
|
||||||
$books->save(['slug' => $bSlug, 'title' => $bTitle, 'description' => $bDesc, 'articles' => $bArts]);
|
$books->save(['slug' => $bSlug, 'title' => $bTitle, 'description' => $bDesc, 'articles' => $bArts]);
|
||||||
}
|
}
|
||||||
@@ -3370,23 +3819,10 @@ switch ($action) {
|
|||||||
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
|
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
|
||||||
'/'
|
'/'
|
||||||
);
|
);
|
||||||
if ($notFoundPath !== '') {
|
log404('/' . $notFoundPath);
|
||||||
searchAndRedirect(basename($notFoundPath), $articles);
|
$q = slugToSearchQuery($notFoundPath);
|
||||||
}
|
header('Location: /search' . ($q !== '' ? '?q=' . urlencode($q) : ''), true, 302);
|
||||||
http_response_code(404);
|
exit;
|
||||||
ob_start();
|
|
||||||
?>
|
|
||||||
<div class="container py-5 text-center">
|
|
||||||
<h1 class="h2 mb-3">Page introuvable</h1>
|
|
||||||
<p class="text-muted mb-4">Cette adresse ne correspond à aucun article.<br>Vous avez peut-être suivi un ancien lien.</p>
|
|
||||||
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
$content = ob_get_clean();
|
|
||||||
$title = '404 — ' . siteTitle();
|
|
||||||
$metaRobots = 'noindex, nofollow';
|
|
||||||
include BASE_PATH . '/templates/layout.php';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'list':
|
case 'list':
|
||||||
default:
|
default:
|
||||||
@@ -3486,10 +3922,35 @@ switch ($action) {
|
|||||||
unset($_heroUuid, $_count, $_hp);
|
unset($_heroUuid, $_count, $_hp);
|
||||||
|
|
||||||
$allPostsMap = array_column($allPosts, null, 'uuid');
|
$allPostsMap = array_column($allPosts, null, 'uuid');
|
||||||
|
$_slugMap = array_column($allPosts, null, 'slug');
|
||||||
|
|
||||||
// Articles populaires (10 derniers jours) — score pondéré
|
// Tendances 1 h — lecture seule du cache généré par /trending?period=1h
|
||||||
|
$_trendCache = DATA_PATH . '/_cache/trending_1h.json';
|
||||||
|
$_trendPaths = null;
|
||||||
|
if (file_exists($_trendCache) && (time() - filemtime($_trendCache)) < 720) {
|
||||||
|
$_trendPaths = json_decode((string) file_get_contents($_trendCache), true) ?: null;
|
||||||
|
}
|
||||||
|
if (!empty($_trendPaths)) {
|
||||||
|
foreach ($_trendPaths as $_path => $_cnt) {
|
||||||
|
if (count($popularPosts) >= 6) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!preg_match('#^/post/([^/]+)$#', $_path, $_m)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$_a = $_slugMap[rawurldecode($_m[1])] ?? null;
|
||||||
|
if ($_a !== null) {
|
||||||
|
$popularPosts[] = $_a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($_path, $_cnt, $_m, $_a);
|
||||||
|
}
|
||||||
|
unset($_trendCache, $_trendPaths, $_slugMap);
|
||||||
|
|
||||||
|
// Fallback : score pondéré DB (réactions, notes, commentaires sur 10 j)
|
||||||
$_pdo = dbPdo();
|
$_pdo = dbPdo();
|
||||||
if ($_pdo) {
|
if ($_pdo) {
|
||||||
|
if (empty($popularPosts)) {
|
||||||
try {
|
try {
|
||||||
$_stmt = $_pdo->query("
|
$_stmt = $_pdo->query("
|
||||||
SELECT article_uuid, SUM(score) AS total
|
SELECT article_uuid, SUM(score) AS total
|
||||||
@@ -3519,6 +3980,7 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redécouvertes : anciens articles (> 30 j) avec activité récente
|
// Redécouvertes : anciens articles (> 30 j) avec activité récente
|
||||||
try {
|
try {
|
||||||
@@ -3590,6 +4052,26 @@ switch ($action) {
|
|||||||
$recentlyUpdated[] = $_a;
|
$recentlyUpdated[] = $_a;
|
||||||
}
|
}
|
||||||
unset($_sevenDaysAgo, $_latestUuids, $_popularUuids, $_heroUuid, $_a, $allPostsMap);
|
unset($_sevenDaysAgo, $_latestUuids, $_popularUuids, $_heroUuid, $_a, $allPostsMap);
|
||||||
|
|
||||||
|
// Books à mettre en avant (max 6, ayant ≥ 1 article publié)
|
||||||
|
$homeBooks = [];
|
||||||
|
foreach ($books->getAll() as $_bk) {
|
||||||
|
$_published = [];
|
||||||
|
foreach ($_bk['articles'] ?? [] as $_aSlug) {
|
||||||
|
$_a = $articles->getBySlug($_aSlug);
|
||||||
|
if ($_a && $_a['published'] && strtotime((string)($_a['published_at'] ?? '')) <= time()) {
|
||||||
|
$_published[] = $_a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($_published)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$homeBooks[] = ['book' => $_bk, 'count' => count($_published), 'first' => $_published[0]];
|
||||||
|
if (count($homeBooks) >= 6) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($_bk, $_published, $_aSlug, $_a);
|
||||||
}
|
}
|
||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+13
-29
@@ -6,38 +6,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use App\Http\Csrf;
|
use App\Http\Csrf;
|
||||||
|
|
||||||
// --- Helpers AVANT tout usage ---
|
if (!defined('BASE_PATH')) {
|
||||||
if (!function_exists('env')) {
|
define('BASE_PATH', dirname(__DIR__, 2));
|
||||||
function env(string $key, ?string $default = null): ?string
|
|
||||||
{
|
|
||||||
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
|
|
||||||
return (string)$_ENV[$key];
|
|
||||||
}
|
}
|
||||||
$v = getenv($key);
|
|
||||||
if ($v !== false && $v !== '') {
|
|
||||||
return (string)$v;
|
|
||||||
}
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!function_exists('db')) {
|
|
||||||
function db(): \PDO
|
|
||||||
{
|
|
||||||
return \App\Infrastructure\Database::get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!function_exists('url')) {
|
|
||||||
function url(string $path = '/'): string
|
|
||||||
{
|
|
||||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
|
||||||
return $scheme . '://' . $host . $path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
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) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__, 2) . '/src/SiteSettings.php';
|
require_once dirname(__DIR__, 2) . '/src/SiteSettings.php';
|
||||||
require_once dirname(__DIR__, 2) . '/src/mailer.php';
|
require_once dirname(__DIR__, 2) . '/src/mailer.php';
|
||||||
|
|
||||||
@@ -46,6 +20,7 @@ $ttlMin = (int) env('MAGIC_LINK_TTL_MINUTES', '30');
|
|||||||
$coolMin = (int) env('MAGIC_COOLDOWN_MINUTES', '5');
|
$coolMin = (int) env('MAGIC_COOLDOWN_MINUTES', '5');
|
||||||
$winHours = (int) env('MAGIC_WINDOW_HOURS', '12');
|
$winHours = (int) env('MAGIC_WINDOW_HOURS', '12');
|
||||||
$maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5');
|
$maxPerWin = (int) env('MAGIC_MAX_PER_WINDOW', '5');
|
||||||
|
$maxPerIpHour = (int) env('MAGIC_MAX_PER_IP_HOUR', '10');
|
||||||
|
|
||||||
// --- return_to ---
|
// --- return_to ---
|
||||||
$defaultReturn = '/';
|
$defaultReturn = '/';
|
||||||
@@ -120,6 +95,15 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
|||||||
throw new RuntimeException('Quota atteint. Réessayez plus tard.');
|
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
|
// Génère et enregistre le lien avec TTL ttlMin
|
||||||
$raw = random_bytes(32);
|
$raw = random_bytes(32);
|
||||||
$token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
$token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||||
|
|||||||
+47
-27
@@ -1,42 +1,64 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// projet : mug.a5l.fr
|
|
||||||
// fichier : pages/login/magic.php
|
|
||||||
// version : 20251011
|
|
||||||
declare(strict_types=1);
|
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) . '/vendor/autoload.php';
|
||||||
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
|
||||||
require_once dirname(__DIR__, 2) . '/config/config.php';
|
require_once dirname(__DIR__, 2) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||||
// si tu as un service pour ouvrir une session
|
|
||||||
|
|
||||||
if (!function_exists('db')) {
|
|
||||||
function db(): PDO
|
|
||||||
{
|
|
||||||
return \App\Infrastructure\Database::get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!function_exists('url')) {
|
|
||||||
function url(string $path = '/'): string
|
|
||||||
{
|
|
||||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
|
||||||
return $scheme . '://' . $host . $path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = (string)($_GET['token'] ?? '');
|
$token = (string)($_GET['token'] ?? '');
|
||||||
if ($token === '' || preg_match('/[^A-Za-z0-9\-\_]/', $token)) {
|
if ($token === '' || preg_match('/[^A-Za-z0-9\-\_]/', $token)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
exit('Lien invalide.');
|
exit(renderMagicPage('Lien invalide', '<p>Ce lien de connexion est invalide.</p>', null));
|
||||||
}
|
}
|
||||||
|
|
||||||
$pdo = db();
|
$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();
|
$pdo->beginTransaction();
|
||||||
try {
|
try {
|
||||||
// récupère lien non consommé et non expiré
|
$sql = 'SELECT id, email, expires_at, consumed_at, return_to
|
||||||
$sql = 'SELECT id, email, token, created_at, expires_at, consumed_at, return_to
|
|
||||||
FROM auth_magic_links
|
FROM auth_magic_links
|
||||||
WHERE token = :t
|
WHERE token = :t
|
||||||
FOR UPDATE';
|
FOR UPDATE';
|
||||||
@@ -54,7 +76,6 @@ try {
|
|||||||
throw new RuntimeException('Lien expiré.');
|
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->prepare('UPDATE auth_magic_links SET consumed_at = NOW() WHERE id = :id')->execute([':id' => $row['id']]);
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
|
|
||||||
@@ -65,7 +86,6 @@ try {
|
|||||||
$_SESSION['user_email'] = strtolower(trim((string)$row['email']));
|
$_SESSION['user_email'] = strtolower(trim((string)$row['email']));
|
||||||
|
|
||||||
$dest = $row['return_to'] ?? '/';
|
$dest = $row['return_to'] ?? '/';
|
||||||
// sécurité: ne renvoyer que des chemins relatifs
|
|
||||||
if (!is_string($dest) || !str_starts_with($dest, '/')) {
|
if (!is_string($dest) || !str_starts_with($dest, '/')) {
|
||||||
$dest = '/';
|
$dest = '/';
|
||||||
}
|
}
|
||||||
@@ -76,5 +96,5 @@ try {
|
|||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
}
|
}
|
||||||
http_response_code(400);
|
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
@@ -4,12 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||||||
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
require_once BASE_PATH . '/vendor/autoload.php';
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
require_once BASE_PATH . '/src/auth.php';
|
|
||||||
require_once BASE_PATH . '/config/config.php';
|
require_once BASE_PATH . '/config/config.php';
|
||||||
|
require_once BASE_PATH . '/bootstrap.php';
|
||||||
|
require_once BASE_PATH . '/src/auth.php';
|
||||||
|
|
||||||
$logoutUrl = ssoLogoutUrl();
|
$logoutUrl = ssoLogoutUrl();
|
||||||
|
|
||||||
|
|||||||
@@ -9,20 +9,6 @@ require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
|||||||
require_once dirname(__DIR__, 2) . '/config/config.php';
|
require_once dirname(__DIR__, 2) . '/config/config.php';
|
||||||
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||||
|
|
||||||
if (!function_exists('env')) {
|
|
||||||
function env(string $key, ?string $default = null): ?string
|
|
||||||
{
|
|
||||||
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
|
|
||||||
return (string)$_ENV[$key];
|
|
||||||
}
|
|
||||||
$v = getenv($key);
|
|
||||||
if ($v !== false && $v !== '') {
|
|
||||||
return (string)$v;
|
|
||||||
}
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$debug = (env('APP_DEBUG', '0') === '1');
|
$debug = (env('APP_DEBUG', '0') === '1');
|
||||||
|
|
||||||
$OIDC_ISSUER = rtrim((string)(env('OIDC_ISSUER') ?? ''), '/');
|
$OIDC_ISSUER = rtrim((string)(env('OIDC_ISSUER') ?? ''), '/');
|
||||||
@@ -39,7 +25,15 @@ if (!$OIDC_ISSUER || !$OIDC_CLIENT_ID || !$OIDC_REDIRECT_URI) {
|
|||||||
$tokenEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/token';
|
$tokenEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/token';
|
||||||
$userInfoEndpoint = $OIDC_ISSUER . '/protocol/openid-connect/userinfo';
|
$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'])) {
|
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);
|
http_response_code(400);
|
||||||
echo $debug ? 'State invalide.' : 'Requête invalide.';
|
echo $debug ? 'State invalide.' : 'Requête invalide.';
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
+5
-12
@@ -9,18 +9,11 @@ require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
|||||||
require_once dirname(__DIR__, 2) . '/config/config.php';
|
require_once dirname(__DIR__, 2) . '/config/config.php';
|
||||||
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
require_once dirname(__DIR__, 2) . '/bootstrap.php';
|
||||||
|
|
||||||
if (!function_exists('env')) {
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
function env(string $key, ?string $default = null): ?string
|
error_log('[OIDC/start] session_start() a échoué — vérifier session.save_path');
|
||||||
{
|
http_response_code(500);
|
||||||
if (array_key_exists($key, $_ENV) && $_ENV[$key] !== '') {
|
echo 'Erreur de session. Contactez l\'administrateur.';
|
||||||
return (string)$_ENV[$key];
|
exit;
|
||||||
}
|
|
||||||
$v = getenv($key);
|
|
||||||
if ($v !== false && $v !== '') {
|
|
||||||
return (string)$v;
|
|
||||||
}
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$flow = $_GET['flow'] ?? 'login'; // 'login' ou 'register'
|
$flow = $_GET['flow'] ?? 'login'; // 'login' ou 'register'
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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
-1
@@ -1 +1 @@
|
|||||||
1.6.0
|
1.6.34
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ foreach ($pending as $file) {
|
|||||||
echo "✓\n";
|
echo "✓\n";
|
||||||
$count++;
|
$count++;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
echo "✗ " . $e->getMessage() . "\n";
|
echo '✗ ' . $e->getMessage() . "\n";
|
||||||
$errors++;
|
$errors++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+224
-19
@@ -9,28 +9,53 @@ class AccessLogParser
|
|||||||
private string $cacheFile;
|
private string $cacheFile;
|
||||||
private int $cacheTtl;
|
private int $cacheTtl;
|
||||||
private int $days;
|
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;
|
private static ?array $memo = null;
|
||||||
|
|
||||||
// Apache COMBINED : IP - - [timestamp] "METHOD /path HTTP/x" STATUS bytes "ref" "ua"
|
// 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}) /';
|
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(
|
public function __construct(
|
||||||
string $logDir = '/var/log/apache2',
|
string $logDir = '/var/log/apache2',
|
||||||
string $pattern = '*-access.log',
|
string $pattern = '*-access.log',
|
||||||
string $cacheFile = '',
|
string $cacheFile = '',
|
||||||
int $cacheTtl = 600,
|
int $cacheTtl = 600,
|
||||||
int $days = 14
|
int $days = 30,
|
||||||
|
array $botPatterns = []
|
||||||
) {
|
) {
|
||||||
$this->logDir = rtrim($logDir, '/');
|
$this->logDir = rtrim($logDir, '/');
|
||||||
$this->pattern = $pattern;
|
$this->pattern = $pattern;
|
||||||
$this->cacheFile = $cacheFile !== '' ? $cacheFile : dirname(__DIR__) . '/_cache/access_stats.json';
|
$this->cacheFile = $cacheFile !== '' ? $cacheFile : dirname(__DIR__) . '/_cache/access_stats.json';
|
||||||
$this->cacheTtl = $cacheTtl;
|
$this->cacheTtl = $cacheTtl;
|
||||||
$this->days = $days;
|
$this->days = $days;
|
||||||
|
$this->botPatterns = array_map('strtolower', $botPatterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{pages:array<string,int>,books:array<string,int>,ips:array<string,int>}
|
* @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
|
public function stats(): array
|
||||||
{
|
{
|
||||||
@@ -47,19 +72,118 @@ class AccessLogParser
|
|||||||
$cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400);
|
$cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400);
|
||||||
$pages = [];
|
$pages = [];
|
||||||
$books = [];
|
$books = [];
|
||||||
$ips = [];
|
$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) {
|
foreach ($this->logFiles() as $file) {
|
||||||
$this->parseFile($file, $cutoff, $pages, $books, $ips);
|
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||||
}
|
}
|
||||||
|
|
||||||
arsort($pages);
|
arsort($pages);
|
||||||
arsort($books);
|
arsort($books);
|
||||||
arsort($ips);
|
arsort($ips);
|
||||||
|
arsort($allUas);
|
||||||
|
|
||||||
$result = compact('pages', 'books', 'ips');
|
$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 => $ips) {
|
||||||
|
$articleUv[$path] = [
|
||||||
|
'7' => count($this->artIp7[$path] ?? []),
|
||||||
|
'14' => count($this->artIp14[$path] ?? []),
|
||||||
|
'30' => count($ips),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
@mkdir(dirname($this->cacheFile), 0755, true);
|
||||||
@file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
@file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
return self::$memo = $result;
|
return self::$memo = $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +198,21 @@ class AccessLogParser
|
|||||||
&& (time() - filemtime($this->cacheFile)) < $this->cacheTtl;
|
&& (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}> */
|
/** @return list<array{path:string,type:string}> */
|
||||||
private function logFiles(): array
|
private function logFiles(): array
|
||||||
{
|
{
|
||||||
@@ -110,34 +249,100 @@ class AccessLogParser
|
|||||||
if (!preg_match('/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4})/', $raw, $m)) {
|
if (!preg_match('/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4})/', $raw, $m)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}");
|
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): void
|
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)) {
|
if (!preg_match(self::RE, $line, $m)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
[, $ip, $ts, $path, $status] = $m;
|
[, $ip, $ts, $path, $status, $ua] = $m;
|
||||||
|
|
||||||
if ($status !== '200') {
|
$tsVal = self::parseTimestamp($ts);
|
||||||
|
if ($tsVal < $cutoff) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (self::parseTimestamp($ts) < $cutoff) {
|
|
||||||
|
$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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($path, '/post/') && strlen($path) > 6) {
|
if (str_starts_with($path, '/post/') && strlen($path) > 6) {
|
||||||
$pages[$path] = ($pages[$path] ?? 0) + 1;
|
$pages[$path] = ($pages[$path] ?? 0) + 1;
|
||||||
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
|
$dayPages[$path][$dayOffset] = ($dayPages[$path][$dayOffset] ?? 0) + 1;
|
||||||
} elseif (str_starts_with($path, '/book/') && strlen($path) > 6) {
|
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;
|
$books[$path] = ($books[$path] ?? 0) + 1;
|
||||||
$ips[$ip] = ($ips[$ip] ?? 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): void
|
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') {
|
if ($file['type'] === 'tgz') {
|
||||||
try {
|
try {
|
||||||
$phar = new PharData($file['path']);
|
$phar = new PharData($file['path']);
|
||||||
@@ -147,7 +352,7 @@ class AccessLogParser
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach (explode("\n", $content) as $line) {
|
foreach (explode("\n", $content) as $line) {
|
||||||
$this->parseLine($line, $cutoff, $pages, $books, $ips);
|
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -160,7 +365,7 @@ class AccessLogParser
|
|||||||
while (!gzeof($h)) {
|
while (!gzeof($h)) {
|
||||||
$line = gzgets($h, 8192);
|
$line = gzgets($h, 8192);
|
||||||
if ($line !== false) {
|
if ($line !== false) {
|
||||||
$this->parseLine($line, $cutoff, $pages, $books, $ips);
|
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gzclose($h);
|
gzclose($h);
|
||||||
@@ -170,7 +375,7 @@ class AccessLogParser
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
while (($line = fgets($h)) !== false) {
|
while (($line = fgets($h)) !== false) {
|
||||||
$this->parseLine($line, $cutoff, $pages, $books, $ips);
|
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||||
}
|
}
|
||||||
fclose($h);
|
fclose($h);
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-28
@@ -30,6 +30,14 @@ class ArticleManager
|
|||||||
|
|
||||||
private function loadAll(): array
|
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 = [];
|
$articles = [];
|
||||||
if (!is_dir($this->dataDir)) {
|
if (!is_dir($this->dataDir)) {
|
||||||
return $articles;
|
return $articles;
|
||||||
@@ -44,7 +52,7 @@ class ArticleManager
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$article = $this->loadArticle($dir);
|
$article = $this->loadArticle($dir, false);
|
||||||
if (!$article) {
|
if (!$article) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -53,6 +61,25 @@ class ArticleManager
|
|||||||
|
|
||||||
usort($articles, static fn ($a, $b) => strcmp($b['published_at'] ?? '', $a['published_at'] ?? ''));
|
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;
|
return $articles;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +132,8 @@ class ArticleManager
|
|||||||
$publishedAt = $publishedAt !== '' ? $publishedAt : $now;
|
$publishedAt = $publishedAt !== '' ? $publishedAt : $now;
|
||||||
|
|
||||||
$dir = $this->dataDir . '/' . $uuid;
|
$dir = $this->dataDir . '/' . $uuid;
|
||||||
mkdir($dir, 0755, true);
|
$this->mkArticleDir($dir);
|
||||||
mkdir($dir . '/files', 0755, true);
|
$this->mkArticleDir($dir . '/files');
|
||||||
|
|
||||||
$meta = [
|
$meta = [
|
||||||
'uuid' => $uuid,
|
'uuid' => $uuid,
|
||||||
@@ -137,6 +164,21 @@ class ArticleManager
|
|||||||
return $uuid;
|
return $uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Crée un brouillon en copiant titre, contenu, catégorie et tags d'un article existant. */
|
||||||
|
public function duplicate(string $sourceUuid, string $author = ''): ?string
|
||||||
|
{
|
||||||
|
$source = $this->getByUuid($sourceUuid);
|
||||||
|
if (!$source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$newTitle = 'Copie de ' . ($source['title'] ?? '');
|
||||||
|
$content = $source['content'] ?? '';
|
||||||
|
$category = $source['category'] ?? '';
|
||||||
|
$tags = $source['tags'] ?? [];
|
||||||
|
$newAuthor = $author !== '' ? $author : ($source['author'] ?? '');
|
||||||
|
return $this->create($newTitle, $content, false, '', '', $newAuthor, '', '', '', $category, $tags);
|
||||||
|
}
|
||||||
|
|
||||||
public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt, string $revisionComment = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', ?array $tags = null, bool $skipGit = false): void
|
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);
|
$article = $this->getByUuid($uuid);
|
||||||
@@ -155,7 +197,7 @@ class ArticleManager
|
|||||||
if ($contentChanged || $titleChanged) {
|
if ($contentChanged || $titleChanged) {
|
||||||
$revDir = $this->dataDir . '/' . $uuid . '/revisions';
|
$revDir = $this->dataDir . '/' . $uuid . '/revisions';
|
||||||
if (!is_dir($revDir)) {
|
if (!is_dir($revDir)) {
|
||||||
mkdir($revDir, 0755, true);
|
$this->mkArticleDir($revDir);
|
||||||
}
|
}
|
||||||
$n = count($revisions) + 1;
|
$n = count($revisions) + 1;
|
||||||
$revFile = sprintf('%s/%04d.md', $revDir, $n);
|
$revFile = sprintf('%s/%04d.md', $revDir, $n);
|
||||||
@@ -251,7 +293,7 @@ class ArticleManager
|
|||||||
}
|
}
|
||||||
$meta['updated_at'] = date('Y-m-d H:i:s');
|
$meta['updated_at'] = date('Y-m-d H:i:s');
|
||||||
$this->writeMeta($dir, $meta);
|
$this->writeMeta($dir, $meta);
|
||||||
$this->git?->commit("meta: " . ($meta['title'] ?? $uuid));
|
$this->git?->commit('meta: ' . ($meta['title'] ?? $uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void
|
public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void
|
||||||
@@ -337,7 +379,7 @@ class ArticleManager
|
|||||||
@unlink($dir . '/draft_overlay.json');
|
@unlink($dir . '/draft_overlay.json');
|
||||||
@unlink($dir . '/draft_overlay.md');
|
@unlink($dir . '/draft_overlay.md');
|
||||||
if ($title !== null) {
|
if ($title !== null) {
|
||||||
$this->git?->commit("discard-draft: $title");
|
$this->git->commit("discard-draft: $title");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +488,7 @@ class ArticleManager
|
|||||||
}
|
}
|
||||||
$meta['cover'] = $coverName;
|
$meta['cover'] = $coverName;
|
||||||
$this->writeMeta($this->dataDir . '/' . $uuid, $meta);
|
$this->writeMeta($this->dataDir . '/' . $uuid, $meta);
|
||||||
$this->git?->commit("cover: " . ($article['title'] ?? $uuid));
|
$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
|
public function addFileFromUrl(string $uuid, string $url, bool $isCover = false, string $author = '', string $sourceUrl = '', string $title = '', array $extraMeta = []): ?string
|
||||||
@@ -482,7 +524,7 @@ class ArticleManager
|
|||||||
$isImage = str_starts_with($mime, 'image/');
|
$isImage = str_starts_with($mime, 'image/');
|
||||||
$filesDir = $this->dataDir . '/' . $uuid . '/files';
|
$filesDir = $this->dataDir . '/' . $uuid . '/files';
|
||||||
if (!is_dir($filesDir)) {
|
if (!is_dir($filesDir)) {
|
||||||
mkdir($filesDir, 0755, true);
|
$this->mkArticleDir($filesDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($isImage) {
|
if ($isImage) {
|
||||||
@@ -733,7 +775,7 @@ class ArticleManager
|
|||||||
$this->tagTypesPath(),
|
$this->tagTypesPath(),
|
||||||
json_encode($types, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"
|
json_encode($types, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"
|
||||||
);
|
);
|
||||||
$this->git?->commit("tag-types");
|
$this->git?->commit('tag-types');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enregistre les tags d'un article directement (utile pour les scripts de migration). */
|
/** Enregistre les tags d'un article directement (utile pour les scripts de migration). */
|
||||||
@@ -753,7 +795,7 @@ class ArticleManager
|
|||||||
$meta['tags'] = $this->normalizeTags($tags);
|
$meta['tags'] = $this->normalizeTags($tags);
|
||||||
$this->writeMeta($dir, $meta);
|
$this->writeMeta($dir, $meta);
|
||||||
$this->rebuildSearchIndex();
|
$this->rebuildSearchIndex();
|
||||||
$this->git?->commit("tags: " . ($meta['title'] ?? $uuid));
|
$this->git?->commit('tags: ' . ($meta['title'] ?? $uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> Toutes les valeurs distinctes d'un type de tag, triées. */
|
/** @return list<string> Toutes les valeurs distinctes d'un type de tag, triées. */
|
||||||
@@ -803,13 +845,13 @@ class ArticleManager
|
|||||||
$this->writeMeta($dir, $meta);
|
$this->writeMeta($dir, $meta);
|
||||||
$this->allCache = null;
|
$this->allCache = null;
|
||||||
@unlink($this->articleCachePath($uuid));
|
@unlink($this->articleCachePath($uuid));
|
||||||
$this->git?->commit("featured: " . ($meta['title'] ?? $uuid) . " (" . ($featured ? 'on' : 'off') . ")");
|
$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)) {
|
if (!$this->isValidUuid($uuid)) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
$dir = $this->dataDir . '/' . $uuid;
|
$dir = $this->dataDir . '/' . $uuid;
|
||||||
$title = null;
|
$title = null;
|
||||||
@@ -821,11 +863,16 @@ class ArticleManager
|
|||||||
$this->allCache = null;
|
$this->allCache = null;
|
||||||
@unlink($this->articleCachePath($uuid));
|
@unlink($this->articleCachePath($uuid));
|
||||||
@unlink($this->slugIndexPath());
|
@unlink($this->slugIndexPath());
|
||||||
|
@unlink($this->allListCachePath());
|
||||||
$this->removeDir($dir);
|
$this->removeDir($dir);
|
||||||
}
|
}
|
||||||
|
if (is_dir($dir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$this->rebuildSearchIndex();
|
$this->rebuildSearchIndex();
|
||||||
$this->rebuildBacklinksCache();
|
$this->rebuildBacklinksCache();
|
||||||
$this->git?->commit("delete: " . ($title ?? $uuid));
|
$this->git?->commit('delete: ' . ($title ?? $uuid));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -847,6 +894,11 @@ class ArticleManager
|
|||||||
return $this->dataDir . '/_cache/slug_index.json';
|
return $this->dataDir . '/_cache/slug_index.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function allListCachePath(): string
|
||||||
|
{
|
||||||
|
return $this->dataDir . '/_cache/articles_list.json';
|
||||||
|
}
|
||||||
|
|
||||||
private function buildSlugIndex(): void
|
private function buildSlugIndex(): void
|
||||||
{
|
{
|
||||||
$cacheDir = $this->dataDir . '/_cache';
|
$cacheDir = $this->dataDir . '/_cache';
|
||||||
@@ -940,19 +992,25 @@ class ArticleManager
|
|||||||
{
|
{
|
||||||
$index = [];
|
$index = [];
|
||||||
foreach ($this->getAll() as $article) {
|
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[] = [
|
$index[] = [
|
||||||
'uuid' => $article['uuid'],
|
'uuid' => $uuid,
|
||||||
'slug' => $article['slug'] ?? '',
|
'slug' => $article['slug'] ?? '',
|
||||||
'title' => $article['title'] ?? '',
|
'title' => $article['title'] ?? '',
|
||||||
'category' => $article['category'] ?? '',
|
'category' => $article['category'] ?? '',
|
||||||
'author' => $article['author'] ?? '',
|
'author' => $article['author'] ?? '',
|
||||||
'cover' => $article['cover'] ?? '',
|
'cover' => $article['cover'] ?? '',
|
||||||
|
'featured' => (bool)($article['featured'] ?? false),
|
||||||
'published' => $article['published'],
|
'published' => $article['published'],
|
||||||
'published_at' => $article['published_at'] ?? '',
|
'published_at' => $article['published_at'] ?? '',
|
||||||
'created_at' => $article['created_at'] ?? '',
|
'created_at' => $article['created_at'] ?? '',
|
||||||
'updated_at' => $article['updated_at'] ?? '',
|
'updated_at' => $article['updated_at'] ?? '',
|
||||||
'tags' => $article['tags'] ?? [],
|
'tags' => $article['tags'] ?? [],
|
||||||
'plain' => $this->stripForIndex($article['content'] ?? ''),
|
'plain' => $this->stripForIndex($content),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
@@ -1023,8 +1081,8 @@ class ArticleManager
|
|||||||
if (!is_array($data) || empty($data)) {
|
if (!is_array($data) || empty($data)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Rebuild automatique si le format est obsolète (champs cover/created_at absents)
|
// Rebuild automatique si le format est obsolète (champs manquants)
|
||||||
if (!array_key_exists('cover', $data[0])) {
|
if (!array_key_exists('cover', $data[0]) || !array_key_exists('featured', $data[0])) {
|
||||||
$this->rebuildSearchIndex();
|
$this->rebuildSearchIndex();
|
||||||
return $this->searchIndexCache;
|
return $this->searchIndexCache;
|
||||||
}
|
}
|
||||||
@@ -1110,7 +1168,7 @@ class ArticleManager
|
|||||||
}
|
}
|
||||||
$dir = $this->dataDir . '/' . $uuid . '/files';
|
$dir = $this->dataDir . '/' . $uuid . '/files';
|
||||||
if (!is_dir($dir)) {
|
if (!is_dir($dir)) {
|
||||||
mkdir($dir, 0755, true);
|
$this->mkArticleDir($dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
$mime = mime_content_type($uploadedFile['tmp_name']) ?: 'application/octet-stream';
|
$mime = mime_content_type($uploadedFile['tmp_name']) ?: 'application/octet-stream';
|
||||||
@@ -1139,7 +1197,16 @@ class ArticleManager
|
|||||||
$size = filesize($uploadedFile['tmp_name']);
|
$size = filesize($uploadedFile['tmp_name']);
|
||||||
$name = "{$hash}-{$size}.{$ext}";
|
$name = "{$hash}-{$size}.{$ext}";
|
||||||
$dest = $dir . '/' . $name;
|
$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 null;
|
||||||
}
|
}
|
||||||
return $name;
|
return $name;
|
||||||
@@ -1196,16 +1263,16 @@ 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)) {
|
if (!file_exists($metaPath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($withContent) {
|
||||||
$uuid = basename($dir);
|
$uuid = basename($dir);
|
||||||
$cachePath = $this->articleCachePath($uuid);
|
$cachePath = $this->articleCachePath($uuid);
|
||||||
|
|
||||||
// Utiliser le cache si plus récent que meta.json ET index.md
|
|
||||||
$contentMtime = file_exists($dir . '/index.md') ? filemtime($dir . '/index.md') : 0;
|
$contentMtime = file_exists($dir . '/index.md') ? filemtime($dir . '/index.md') : 0;
|
||||||
if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath) && filemtime($cachePath) >= $contentMtime) {
|
if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath) && filemtime($cachePath) >= $contentMtime) {
|
||||||
$cached = json_decode((string) file_get_contents($cachePath), true);
|
$cached = json_decode((string) file_get_contents($cachePath), true);
|
||||||
@@ -1213,6 +1280,7 @@ class ArticleManager
|
|||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$raw = file_get_contents($metaPath);
|
$raw = file_get_contents($metaPath);
|
||||||
if ($raw === false) {
|
if ($raw === false) {
|
||||||
@@ -1223,8 +1291,11 @@ class ArticleManager
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($withContent) {
|
||||||
$contentPath = $dir . '/index.md';
|
$contentPath = $dir . '/index.md';
|
||||||
$meta['content'] = file_exists($contentPath) ? (string)file_get_contents($contentPath) : '';
|
$meta['content'] = file_exists($contentPath) ? (string)file_get_contents($contentPath) : '';
|
||||||
|
}
|
||||||
|
|
||||||
$meta['published'] = (bool)($meta['published'] ?? false);
|
$meta['published'] = (bool)($meta['published'] ?? false);
|
||||||
$meta['featured'] = (bool)($meta['featured'] ?? false);
|
$meta['featured'] = (bool)($meta['featured'] ?? false);
|
||||||
$meta['files_meta'] = $meta['files_meta'] ?? [];
|
$meta['files_meta'] = $meta['files_meta'] ?? [];
|
||||||
@@ -1238,12 +1309,15 @@ class ArticleManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Écrire le cache
|
if ($withContent) {
|
||||||
|
$uuid = $meta['uuid'];
|
||||||
|
$cachePath = $this->articleCachePath($uuid);
|
||||||
$cacheDir = dirname($cachePath);
|
$cacheDir = dirname($cachePath);
|
||||||
if (!is_dir($cacheDir)) {
|
if (!is_dir($cacheDir)) {
|
||||||
mkdir($cacheDir, 0755, true);
|
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;
|
return $meta;
|
||||||
}
|
}
|
||||||
@@ -1298,9 +1372,10 @@ class ArticleManager
|
|||||||
$this->searchIndexCache = null;
|
$this->searchIndexCache = null;
|
||||||
$uuid = $meta['uuid'] ?? basename($dir);
|
$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->articleCachePath($uuid));
|
||||||
@unlink($this->slugIndexPath());
|
@unlink($this->slugIndexPath());
|
||||||
|
@unlink($this->allListCachePath());
|
||||||
|
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
$dir . '/meta.json',
|
$dir . '/meta.json',
|
||||||
@@ -1370,13 +1445,19 @@ class ArticleManager
|
|||||||
*/
|
*/
|
||||||
private function removeDir(string $dir): void
|
private function removeDir(string $dir): void
|
||||||
{
|
{
|
||||||
foreach (scandir($dir) as $entry) {
|
foreach (@scandir($dir) ?: [] as $entry) {
|
||||||
if ($entry === '.' || $entry === '..') {
|
if ($entry === '.' || $entry === '..') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$path = $dir . '/' . $entry;
|
$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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -95,7 +95,7 @@ class BookManager
|
|||||||
$this->bookPath($slug),
|
$this->bookPath($slug),
|
||||||
json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
|
json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
|
||||||
);
|
);
|
||||||
$this->git?->commit("book: " . ($book['title'] ?? $slug));
|
$this->git?->commit('book: ' . ($book['title'] ?? $slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(string $slug): void
|
public function delete(string $slug): void
|
||||||
@@ -117,7 +117,7 @@ class BookManager
|
|||||||
return $this->booksDir . '/' . $slug . '.json';
|
return $this->booksDir . '/' . $slug . '.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sanitizeSlug(string $slug): string
|
public function sanitizeSlug(string $slug): string
|
||||||
{
|
{
|
||||||
$map = [
|
$map = [
|
||||||
'à' => 'a', 'â' => 'a', 'ä' => 'a',
|
'à' => 'a', 'â' => 'a', 'ä' => 'a',
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
class DataGit
|
class DataGit
|
||||||
{
|
{
|
||||||
public function __construct(private string $dataDir) {}
|
public function __construct(private string $dataDir)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public function commit(string $message): void
|
public function commit(string $message): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 l’id (race condition)
|
|
||||||
if ($e->getCode() === '23505') {
|
|
||||||
$st = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
|
||||||
$st->execute([':email' => $email]);
|
|
||||||
$id = $st->fetchColumn();
|
|
||||||
if ($id !== false && $id !== null) {
|
|
||||||
return (string)$id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByEmail(string $email): ?User
|
|
||||||
{
|
|
||||||
$sql = 'SELECT id, email, password_hash, is_active FROM users WHERE email = :email LIMIT 1';
|
|
||||||
$st = $this->pdo->prepare($sql);
|
|
||||||
$st->execute([':email' => $email]);
|
|
||||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
|
||||||
if (!$row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$isActive = $this->toBool($row['is_active']);
|
|
||||||
|
|
||||||
return new User(
|
|
||||||
(string)$row['id'],
|
|
||||||
(string)$row['email'],
|
|
||||||
(string)$row['password_hash'],
|
|
||||||
$isActive
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(string $email, string $passwordHash): string
|
|
||||||
{
|
|
||||||
// PostgreSQL
|
|
||||||
$sql = 'INSERT INTO users (email, password_hash) VALUES (:email, :hash) RETURNING id';
|
|
||||||
$st = $this->pdo->prepare($sql);
|
|
||||||
$st->execute([':email' => $email, ':hash' => $passwordHash]);
|
|
||||||
return (string)$st->fetchColumn();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatePassword(string $userId, string $newHash): void
|
|
||||||
{
|
|
||||||
$sql = <<<SQL
|
|
||||||
UPDATE users
|
|
||||||
SET password_hash = :h,
|
|
||||||
updated_at = NOW(),
|
|
||||||
password_changed_at = NOW()
|
|
||||||
WHERE id = :id
|
|
||||||
SQL;
|
|
||||||
$st = $this->pdo->prepare($sql);
|
|
||||||
$st->execute([':h' => $newHash, ':id' => $userId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalise un bool venant de PDO/pgsql ('t','f',1,0,true,false,'1','0','true','false')
|
|
||||||
*/
|
|
||||||
private function toBool(mixed $v): bool
|
|
||||||
{
|
|
||||||
if (is_bool($v)) {
|
|
||||||
return $v;
|
|
||||||
}
|
|
||||||
if (is_int($v)) {
|
|
||||||
return $v === 1;
|
|
||||||
}
|
|
||||||
if (is_string($v)) {
|
|
||||||
$v = strtolower($v);
|
|
||||||
return in_array($v, ['t', '1', 'true', 'on', 'yes'], true);
|
|
||||||
}
|
|
||||||
return (bool)$v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+26
-13
@@ -8,22 +8,25 @@ class SearchLogParser
|
|||||||
private string $vhostBase;
|
private string $vhostBase;
|
||||||
private string $cacheFile;
|
private string $cacheFile;
|
||||||
private int $cacheTtl;
|
private int $cacheTtl;
|
||||||
|
private int $days;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $logDir = '/var/log/apache2',
|
string $logDir = '/var/log/apache2',
|
||||||
string $vhostBase = '*-access.log',
|
string $vhostBase = '*-access.log',
|
||||||
string $cacheFile = '',
|
string $cacheFile = '',
|
||||||
int $cacheTtl = 600
|
int $cacheTtl = 600,
|
||||||
|
int $days = 14
|
||||||
) {
|
) {
|
||||||
$this->logDir = rtrim($logDir, '/');
|
$this->logDir = rtrim($logDir, '/');
|
||||||
$this->vhostBase = $vhostBase;
|
$this->vhostBase = $vhostBase;
|
||||||
|
$this->days = max(1, min(30, $days));
|
||||||
$this->cacheFile = $cacheFile !== ''
|
$this->cacheFile = $cacheFile !== ''
|
||||||
? $cacheFile
|
? $cacheFile
|
||||||
: dirname(__DIR__) . '/_cache/search_terms.json';
|
: dirname(__DIR__) . '/_cache/search_terms_' . $this->days . 'd.json';
|
||||||
$this->cacheTtl = $cacheTtl;
|
$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
|
public function topTerms(int $limit = 100): array
|
||||||
{
|
{
|
||||||
if ($this->cacheValid()) {
|
if ($this->cacheValid()) {
|
||||||
@@ -33,9 +36,14 @@ class SearchLogParser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$counts = [];
|
$visitors = []; // terme => [ip => true]
|
||||||
foreach ($this->logFiles() as $file) {
|
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);
|
arsort($counts);
|
||||||
|
|
||||||
@@ -61,6 +69,7 @@ class SearchLogParser
|
|||||||
{
|
{
|
||||||
$pattern = $this->logDir . '/' . $this->vhostBase;
|
$pattern = $this->logDir . '/' . $this->vhostBase;
|
||||||
$files = [];
|
$files = [];
|
||||||
|
$cutoff = time() - $this->days * 86400;
|
||||||
|
|
||||||
// Fichiers correspondant au pattern de base (courants + rotations incluses si glob)
|
// Fichiers correspondant au pattern de base (courants + rotations incluses si glob)
|
||||||
$bases = glob($pattern) ?: [];
|
$bases = glob($pattern) ?: [];
|
||||||
@@ -75,6 +84,9 @@ class SearchLogParser
|
|||||||
if (!is_readable($path)) {
|
if (!is_readable($path)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (@filemtime($path) < $cutoff) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (str_ends_with($path, '.tar.gz')) {
|
if (str_ends_with($path, '.tar.gz')) {
|
||||||
$files[] = ['path' => $path, 'type' => 'tgz'];
|
$files[] = ['path' => $path, 'type' => 'tgz'];
|
||||||
} elseif (str_ends_with($path, '.gz')) {
|
} elseif (str_ends_with($path, '.gz')) {
|
||||||
@@ -88,7 +100,7 @@ class SearchLogParser
|
|||||||
return $files;
|
return $files;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseFile(array $file, array &$counts): void
|
private function parseFile(array $file, array &$visitors): void
|
||||||
{
|
{
|
||||||
if ($file['type'] === 'tgz') {
|
if ($file['type'] === 'tgz') {
|
||||||
try {
|
try {
|
||||||
@@ -99,7 +111,7 @@ class SearchLogParser
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach (explode("\n", $content) as $line) {
|
foreach (explode("\n", $content) as $line) {
|
||||||
$this->parseLine($line, $counts);
|
$this->parseLine($line, $visitors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -113,7 +125,7 @@ class SearchLogParser
|
|||||||
while (!gzeof($h)) {
|
while (!gzeof($h)) {
|
||||||
$line = gzgets($h, 8192);
|
$line = gzgets($h, 8192);
|
||||||
if ($line !== false) {
|
if ($line !== false) {
|
||||||
$this->parseLine($line, $counts);
|
$this->parseLine($line, $visitors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gzclose($h);
|
gzclose($h);
|
||||||
@@ -123,28 +135,29 @@ class SearchLogParser
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
while (($line = fgets($h)) !== false) {
|
while (($line = fgets($h)) !== false) {
|
||||||
$this->parseLine($line, $counts);
|
$this->parseLine($line, $visitors);
|
||||||
}
|
}
|
||||||
fclose($h);
|
fclose($h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseLine(string $line, array &$counts): void
|
private function parseLine(string $line, array &$visitors): void
|
||||||
{
|
{
|
||||||
if (!str_contains($line, 'GET /search?')) {
|
if (!str_contains($line, 'GET /search?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!preg_match('/"GET \/search\?([^"]*) HTTP\//', $line, $m)) {
|
if (!preg_match('/^(\S+) \S+ \S+ \[[^\]]+\] "GET \/search\?([^"]*) HTTP\//', $line, $m)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_str($m[1], $params);
|
$ip = $m[1];
|
||||||
|
parse_str($m[2], $params);
|
||||||
$q = trim(urldecode($params['q'] ?? ''));
|
$q = trim(urldecode($params['q'] ?? ''));
|
||||||
|
|
||||||
if ($q === '' || mb_strlen($q) > 200) {
|
if ($q === '' || mb_strlen($q) > 200) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$q = mb_strtolower($q);
|
$q = mb_strtolower($q);
|
||||||
$counts[$q] = ($counts[$q] ?? 0) + 1;
|
$visitors[$q][$ip] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 l’utilisateur (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 l’ancien 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-1
@@ -93,10 +93,28 @@ function asGroups(): array
|
|||||||
return is_array($raw) ? $raw : [];
|
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
|
function saveSiteSettings(array $data): bool
|
||||||
{
|
{
|
||||||
$current = siteSettings();
|
$current = siteSettings();
|
||||||
$stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url', 'apache_access_log', 'folio_repo_url', 'folio_update_branch'];
|
$stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url', 'apache_access_log', 'folio_repo_url', 'folio_update_branch', 'ai_provider', 'ai_model'];
|
||||||
foreach ($stringKeys as $key) {
|
foreach ($stringKeys as $key) {
|
||||||
if (array_key_exists($key, $data)) {
|
if (array_key_exists($key, $data)) {
|
||||||
$val = trim((string)$data[$key]);
|
$val = trim((string)$data[$key]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,15 @@ class UpdateChecker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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écupère `public/version.txt` depuis le dépôt Gitea.
|
||||||
* Résultat mis en cache 1 h dans `data/.version_check_cache.json`.
|
* Résultat mis en cache 1 h dans `data/.version_check_cache.json`.
|
||||||
|
|||||||
+72
-2
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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)
|
function vd($var, ...$moreVars)
|
||||||
{
|
{
|
||||||
ob_start();
|
ob_start();
|
||||||
@@ -45,8 +66,12 @@ function lineDiff(string $old, string $new): array
|
|||||||
|
|
||||||
if ($n * $m > 2_000_000) {
|
if ($n * $m > 2_000_000) {
|
||||||
$diff = [['!', "Diff trop grand ({$n}×{$m} lignes) — affichage simplifié."]];
|
$diff = [['!', "Diff trop grand ({$n}×{$m} lignes) — affichage simplifié."]];
|
||||||
foreach ($a as $line) { $diff[] = ['-', $line]; }
|
foreach ($a as $line) {
|
||||||
foreach ($b as $line) { $diff[] = ['+', $line]; }
|
$diff[] = ['-', $line];
|
||||||
|
}
|
||||||
|
foreach ($b as $line) {
|
||||||
|
$diff[] = ['+', $line];
|
||||||
|
}
|
||||||
return $diff;
|
return $diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,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%)";
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+312
-45
@@ -69,6 +69,14 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<a class="nav-link <?= $tab === 'books' ? 'active' : '' ?>"
|
<a class="nav-link <?= $tab === 'books' ? 'active' : '' ?>"
|
||||||
href="/admin/books">Livres</a>
|
href="/admin/books">Livres</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link <?= $tab === 'stats' ? 'active' : '' ?>"
|
<a class="nav-link <?= $tab === 'stats' ? 'active' : '' ?>"
|
||||||
href="/admin/stats">Statistiques</a>
|
href="/admin/stats">Statistiques</a>
|
||||||
@@ -106,6 +114,7 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
$_notices = isset($_updateChecker) ? $_updateChecker->adminNotices() : [];
|
$_notices = isset($_updateChecker) ? $_updateChecker->adminNotices() : [];
|
||||||
$_branch = isset($_updateChecker) ? $_updateChecker->getBranch() : 'main';
|
$_branch = isset($_updateChecker) ? $_updateChecker->getBranch() : 'main';
|
||||||
$_lastChecked = isset($_updateChecker) ? $_updateChecker->getLastChecked() : null;
|
$_lastChecked = isset($_updateChecker) ? $_updateChecker->getLastChecked() : null;
|
||||||
|
$_upgradeLog = isset($_updateChecker) ? $_updateChecker->getLastUpgradeLog() : null;
|
||||||
$_repoConfigured = folioRepoUrl() !== '';
|
$_repoConfigured = folioRepoUrl() !== '';
|
||||||
$_remoteLabel = '—';
|
$_remoteLabel = '—';
|
||||||
foreach ($_notices as $_n) {
|
foreach ($_notices as $_n) {
|
||||||
@@ -145,19 +154,28 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<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>
|
<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>
|
</tr>
|
||||||
<?php if (($_GET['notice'] ?? '') === 'engine_updated'): ?>
|
<?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 (code, base de données, contenu).</div></td></tr>
|
<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'] ?? '') === 'update_git_error'): ?>
|
<?php elseif (($_GET['notice'] ?? '') === 'upgrade_error'): ?>
|
||||||
<tr><td colspan="2">
|
<tr><td colspan="2">
|
||||||
<div class="alert alert-danger py-1 mb-0 small">
|
<div class="alert alert-danger py-1 mb-0 small">
|
||||||
Erreur git pull — vérifiez les droits d'accès au dépôt.
|
Erreur lors de la mise à jour.
|
||||||
<?php if (!empty($_SESSION['_update_log'])): ?>
|
<?php if (!empty($_SESSION['_upgrade_log'])): ?>
|
||||||
<pre class="mt-1 mb-0 small"><?= htmlspecialchars($_SESSION['_update_log']) ?></pre>
|
<pre class="mt-1 mb-0 small"><?= htmlspecialchars($_SESSION['_upgrade_log']) ?></pre>
|
||||||
<?php unset($_SESSION['_update_log']); ?>
|
<?php unset($_SESSION['_upgrade_log']); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<?php elseif (($_GET['notice'] ?? '') === 'update_content_error'): ?>
|
<?php endif; ?>
|
||||||
<tr><td colspan="2"><div class="alert alert-warning py-1 mb-0 small">Code et base de données mis à jour, mais une migration de contenu a échoué.</div></td></tr>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -195,8 +213,34 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<!-- ─────────────────────────── ARTICLES ─────────────────────────── -->
|
<!-- ─────────────────────────── ARTICLES ─────────────────────────── -->
|
||||||
<?php elseif ($tab === '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 -->
|
<!-- Filtres -->
|
||||||
<form class="row g-2 align-items-center mb-3" method="get" action="/admin/articles">
|
<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'])): ?>
|
<?php if (isAdmin() && !empty($adminData['filter_authors'])): ?>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<select name="filter_author" class="form-select form-select-sm">
|
<select name="filter_author" class="form-select form-select-sm">
|
||||||
@@ -231,9 +275,19 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<option value="preview" <?= ($adminData['filter_status'] ?? '') === 'preview' ? 'selected' : '' ?>>Avant-première</option>
|
<option value="preview" <?= ($adminData['filter_status'] ?? '') === 'preview' ? 'selected' : '' ?>>Avant-première</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="col-auto d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-secondary btn-sm">Filtrer</button>
|
<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): ?>
|
<?php if ($hasFilter): ?>
|
||||||
<a href="/admin/articles" class="btn btn-link btn-sm p-0">Réinitialiser</a>
|
<a href="/admin/articles" class="btn btn-link btn-sm p-0">Réinitialiser</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -254,8 +308,8 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<input class="form-check-input" type="checkbox" id="check-all">
|
<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>
|
<label class="form-check-label small text-muted" for="check-all">Tout sélectionner</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-danger btn-sm"
|
<button type="submit" id="bulk-delete-btn" 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.')">
|
data-confirm-bulk="Supprimer les articles sélectionnés ? Cette action est irréversible.">
|
||||||
Supprimer la sélection
|
Supprimer la sélection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,11 +317,22 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:2rem"></th>
|
<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; ?>
|
<?php if (isAdmin()): ?><th>Auteur</th><?php endif; ?>
|
||||||
<th>Catégorie</th>
|
<th>Catégorie</th>
|
||||||
<th>Statut</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>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -288,18 +353,53 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<td class="text-muted small"><?= htmlspecialchars($a['category'] ?? '–') ?></td>
|
<td class="text-muted small"><?= htmlspecialchars($a['category'] ?? '–') ?></td>
|
||||||
<td><?= adminStatusBadge($a, $now) ?></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">
|
<td class="text-muted small text-nowrap">
|
||||||
<?= htmlspecialchars(date('d/m/Y', strtotime((string)($a['published_at'] ?? $a['created_at'] ?? '')))) ?>
|
<?= htmlspecialchars(date('d/m/Y', strtotime((string)($a['published_at'] ?? $a['created_at'] ?? '')))) ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end text-nowrap">
|
<td class="text-end text-nowrap">
|
||||||
<a href="/edit/<?= htmlspecialchars($a['uuid']) ?>"
|
<a href="/edit/<?= htmlspecialchars($a['uuid']) ?>"
|
||||||
class="btn btn-outline-secondary btn-sm">Modifier</a>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</form>
|
</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>
|
<script src="/assets/js/admin.js" defer></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -934,17 +1034,13 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
<td class="small"><?= htmlspecialchars((string)$em['subject']) ?></td>
|
<td class="small"><?= htmlspecialchars((string)$em['subject']) ?></td>
|
||||||
<td><?= $emBadge ?></td>
|
<td><?= $emBadge ?></td>
|
||||||
<td>
|
<td>
|
||||||
<details>
|
<?php if (!empty($em['content_html']) || !empty($em['content_text'])): ?>
|
||||||
<summary class="btn btn-outline-secondary btn-sm" style="display:inline;cursor:pointer">Voir</summary>
|
<a href="/admin/email-preview/<?= (int)$em['id'] ?>" target="_blank" rel="noopener"
|
||||||
<div class="mt-2 p-2 border rounded bg-light" style="max-width:600px">
|
class="btn btn-outline-secondary btn-sm">Voir ↗</a>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if (!empty($em['error_message'])): ?>
|
<?php if (!empty($em['error_message'])): ?>
|
||||||
<p class="text-danger small mb-2"><strong>Erreur :</strong> <?= htmlspecialchars((string)$em['error_message']) ?></p>
|
<span class="text-danger small d-block mt-1" title="<?= htmlspecialchars((string)$em['error_message']) ?>">⚠ Erreur</span>
|
||||||
<?php endif; ?>
|
<?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>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -1136,7 +1232,15 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
<h5 class="mb-0">Termes recherchés
|
<h5 class="mb-0">Termes recherchés
|
||||||
<span class="badge bg-secondary ms-1"><?= count($adminData['search_terms'] ?? []) ?></span>
|
<span class="badge bg-secondary ms-1"><?= count($adminData['search_terms'] ?? []) ?></span>
|
||||||
</h5>
|
</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>
|
</div>
|
||||||
|
|
||||||
<?php if (!($adminData['search_log_readable'] ?? false)): ?>
|
<?php if (!($adminData['search_log_readable'] ?? false)): ?>
|
||||||
@@ -1157,7 +1261,7 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
<tr>
|
<tr>
|
||||||
<th style="width:3rem">#</th>
|
<th style="width:3rem">#</th>
|
||||||
<th>Terme recherché</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>
|
<th style="width:12rem"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1188,6 +1292,57 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
|
|
||||||
<?php endif; ?>
|
<?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 ─────────────────────────── -->
|
<!-- ─────────────────────────── LIVRES ─────────────────────────── -->
|
||||||
<?php if ($tab === 'books' && isAdmin()): ?>
|
<?php if ($tab === 'books' && isAdmin()): ?>
|
||||||
|
|
||||||
@@ -1263,7 +1418,9 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small fw-medium">Ajouter une page existante</label>
|
<label class="form-label small fw-medium">Ajouter une page existante</label>
|
||||||
<select class="form-select" onchange="bookAddArticle(this)">
|
<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>
|
<option value="">— Choisir un article —</option>
|
||||||
<?php
|
<?php
|
||||||
$alreadyIn = $eb['articles'] ?? [];
|
$alreadyIn = $eb['articles'] ?? [];
|
||||||
@@ -1294,32 +1451,20 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
<button type="submit" class="btn btn-outline-danger btn-sm">🗑 Supprimer ce livre</button>
|
<button type="submit" class="btn btn-outline-danger btn-sm">🗑 Supprimer ce livre</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
|
||||||
function bookAddArticle(sel) {
|
|
||||||
var slug = sel.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');
|
|
||||||
}
|
|
||||||
sel.value = '';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<?php elseif (isset($_GET['new'])): ?>
|
<?php elseif (isset($_GET['new'])): ?>
|
||||||
<h5>Nouveau livre</h5>
|
<h5>Nouveau livre</h5>
|
||||||
<form method="POST" action="/?action=book_save">
|
<form method="POST" action="/?action=book_save">
|
||||||
<div class="mb-3">
|
<input type="hidden" name="slug" id="new-book-slug-hidden">
|
||||||
<label class="form-label small fw-medium">Slug (identifiant URL)</label>
|
|
||||||
<input type="text" name="slug" class="form-control" required
|
|
||||||
placeholder="mon-livre" pattern="[a-z0-9][a-z0-9-]*">
|
|
||||||
<div class="form-text">Minuscules, chiffres, tirets. Exemple : <code>esp8266</code></div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small fw-medium">Titre</label>
|
<label class="form-label small fw-medium">Titre</label>
|
||||||
<input type="text" name="title" class="form-control" required placeholder="Titre du livre">
|
<input type="text" name="title" id="new-book-title" class="form-control" required placeholder="Titre du livre">
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small fw-medium">Slug (généré automatiquement)</label>
|
||||||
|
<input type="text" id="new-book-slug-preview" class="form-control form-control-sm bg-light font-monospace"
|
||||||
|
readonly placeholder="slug-du-livre">
|
||||||
|
<div class="form-text">Minuscules, chiffres, tirets — basé sur le titre.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small fw-medium">Description (optionnelle)</label>
|
<label class="form-label small fw-medium">Description (optionnelle)</label>
|
||||||
@@ -1344,6 +1489,128 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/assets/js/admin.js" defer></script>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($tab === 'ia' && isAdmin()): ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$_aiNotice = $adminData['ai_notice'] ?? '';
|
||||||
|
$_aiProvider = $adminData['ai_provider'] ?? 'anthropic';
|
||||||
|
$_aiModel = $adminData['ai_model'] ?? '';
|
||||||
|
$_anthropicOk = $adminData['anthropic_key_set'] ?? false;
|
||||||
|
$_cliOk = $adminData['claude_cli_found'] ?? false;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($_aiNotice === 'saved'): ?>
|
||||||
|
<div class="alert alert-success py-2 small">Configuration IA enregistrée.</div>
|
||||||
|
<?php elseif ($_aiNotice === 'error'): ?>
|
||||||
|
<div class="alert alert-danger py-2 small">Erreur lors de l'enregistrement.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h5 class="mb-3">Intelligence artificielle</h5>
|
||||||
|
|
||||||
|
<!-- Section 1 — Statut -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-semibold small">Statut</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3" scope="row">Clé Anthropic (<code>ANTHROPIC_API_KEY</code>)</th>
|
||||||
|
<td><?= $_anthropicOk ? '<span class="text-success">✓ Configurée</span>' : '<span class="text-danger">✗ Absente</span>' ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3" scope="row">Claude Code CLI (<code>/usr/local/bin/claude</code>)</th>
|
||||||
|
<td><?= $_cliOk ? '<span class="text-success">✓ Trouvé</span>' : '<span class="text-danger">✗ Introuvable</span>' ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3" scope="row">Provider actif</th>
|
||||||
|
<td><code><?= htmlspecialchars($_aiProvider) ?></code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3" scope="row">Modèle actif</th>
|
||||||
|
<td><code><?= htmlspecialchars($_aiModel ?: 'claude-haiku-4-5-20251001 (défaut)') ?></code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 2 — Configuration -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-semibold small">Configuration</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/?action=admin_save_ai_config">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Provider</label>
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="ai_provider" id="ai_provider_anthropic"
|
||||||
|
value="anthropic" <?= $_aiProvider === 'anthropic' ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="ai_provider_anthropic">Anthropic (API)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="ai_provider" id="ai_provider_claude_code"
|
||||||
|
value="claude_code" <?= $_aiProvider === 'claude_code' ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="ai_provider_claude_code">Claude Code CLI</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="ai_model" class="form-label fw-semibold small">Modèle Anthropic</label>
|
||||||
|
<input type="text" class="form-control form-control-sm font-monospace" id="ai_model" name="ai_model"
|
||||||
|
value="<?= htmlspecialchars($_aiModel) ?>"
|
||||||
|
placeholder="claude-haiku-4-5-20251001">
|
||||||
|
<div class="form-text">Laisser vide pour utiliser le défaut (<code>claude-haiku-4-5-20251001</code>). Ignoré si le provider est Claude Code CLI.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 3 — Clé Anthropic -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-semibold small">Clé API Anthropic</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning py-2 small mb-0">
|
||||||
|
<strong>La clé API Anthropic ne peut pas être saisie ici.</strong><br>
|
||||||
|
Elle doit être définie dans le fichier <code>.env</code> du serveur :
|
||||||
|
<pre class="mt-2 mb-0 small"><code>ANTHROPIC_API_KEY=sk-ant-...</code></pre>
|
||||||
|
<div class="mt-2">Statut actuel : <?= $_anthropicOk ? '<span class="text-success">✓ Configurée</span>' : '<span class="text-danger">✗ Absente</span>' ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 4 — Procédure Claude Code CLI -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-semibold small">Procédure d'installation de Claude Code CLI</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if ($_cliOk): ?>
|
||||||
|
<div class="alert alert-success py-2 small mb-3">✓ <code>/usr/local/bin/claude</code> détecté.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="alert alert-secondary py-2 small mb-3">✗ <code>/usr/local/bin/claude</code> introuvable — suivez les étapes ci-dessous.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="small text-muted">À exécuter en SSH sur le serveur (en root ou via sudo) :</p>
|
||||||
|
<pre class="bg-dark text-light p-3 rounded small"><code># 1. Installer Claude Code CLI (en root)
|
||||||
|
sudo npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
|
# Vérifier l'installation
|
||||||
|
/usr/local/bin/claude --version
|
||||||
|
|
||||||
|
# 2. Créer le répertoire HOME de www-data pour Claude
|
||||||
|
sudo mkdir -p /var/lib/claude-www
|
||||||
|
sudo chown www-data:www-data /var/lib/claude-www
|
||||||
|
|
||||||
|
# 3. Authentifier Claude en tant que www-data
|
||||||
|
sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude auth login
|
||||||
|
# → Suivre les instructions (OAuth navigateur ou clé API)
|
||||||
|
|
||||||
|
# 4. Vérifier que ça fonctionne
|
||||||
|
sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude --print "Réponds juste OK"</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|||||||
+53
-156
@@ -2,12 +2,14 @@
|
|||||||
$_statsSaved = isset($_GET['saved']);
|
$_statsSaved = isset($_GET['saved']);
|
||||||
$_statsError = ($_GET['error'] ?? '') === 'write';
|
$_statsError = ($_GET['error'] ?? '') === 'write';
|
||||||
$_readable = $adminData['stats_readable'] ?? false;
|
$_readable = $adminData['stats_readable'] ?? false;
|
||||||
$_pages = $adminData['stats_pages'] ?? [];
|
|
||||||
$_books = $adminData['stats_books'] ?? [];
|
$_books = $adminData['stats_books'] ?? [];
|
||||||
$_asList = $adminData['stats_as'] ?? [];
|
$_asList = $adminData['stats_as'] ?? [];
|
||||||
$_asGroups = $adminData['stats_as_groups'] ?? [];
|
$_pagesByDay = $adminData['stats_pages_by_day'] ?? [];
|
||||||
$_groups = $adminData['as_groups'] ?? [];
|
$_ipData = $adminData['stats_ip_data'] ?? [];
|
||||||
$_activeGroup = trim($_GET['group'] ?? '');
|
$_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): ?>
|
<?php if ($_statsSaved): ?>
|
||||||
@@ -23,55 +25,41 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
|||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
||||||
<p class="text-muted small mb-4">14 derniers jours · cache 10 min</p>
|
<p class="text-muted small mb-4">30 derniers jours · tous les chemins · flux RSS XML</p>
|
||||||
|
|
||||||
<div class="row g-4">
|
<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>
|
||||||
|
|
||||||
<!-- Pages -->
|
<div id="stats-summary-container"></div>
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card h-100">
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
|
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
|
||||||
<span>Pages les plus visitées</span>
|
<span>Pages les plus visitées</span>
|
||||||
<span class="text-muted"><?= count($_pages) ?> URLs</span>
|
<span class="text-muted" id="stats-pages-count"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0" id="stats-pages-container">
|
||||||
<?php if (empty($_pages)): ?>
|
<p class="text-muted p-3 mb-0">Chargement…</p>
|
||||||
<p class="text-muted p-3 mb-0">Aucune donnée.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm table-hover mb-0 small">
|
|
||||||
<tbody>
|
|
||||||
<?php
|
|
||||||
$maxP = max($_pages) ?: 1;
|
|
||||||
$rankP = 0;
|
|
||||||
foreach ($_pages as $url => $hits):
|
|
||||||
$rankP++;
|
|
||||||
$slug = rawurldecode(substr($url, 6));
|
|
||||||
$pct = round($hits / $maxP * 100);
|
|
||||||
?>
|
|
||||||
<tr>
|
|
||||||
<td class="text-muted ps-3" style="width:2rem"><?= $rankP ?></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" style="width:<?= $pct ?>%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div class="card-footer bg-transparent border-top px-3 pt-3 pb-2" id="stats-trend-container"></div>
|
||||||
<td class="text-end fw-semibold pe-3"><?= number_format($hits, 0, ',', '\u{202F}') ?></td>
|
<div class="card-footer bg-transparent border-top px-3 pt-3 pb-2" id="stats-multiline-container"></div>
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
<!-- Livres -->
|
<!-- Livres -->
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
@@ -106,7 +94,7 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
|||||||
<div class="progress-bar bg-success" style="width:<?= $pct ?>%"></div>
|
<div class="progress-bar bg-success" style="width:<?= $pct ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end fw-semibold pe-3"><?= number_format($hits, 0, ',', '\u{202F}') ?></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>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -116,126 +104,35 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /row -->
|
</div><!-- /row -->
|
||||||
|
|
||||||
<!-- Répartition par réseau -->
|
<!-- Agents détectés -->
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header bg-transparent py-2 small fw-semibold d-flex align-items-center gap-3 flex-wrap">
|
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between align-items-center">
|
||||||
<span>Répartition par réseau</span>
|
<span>Agents détectés <span class="text-muted fw-normal" id="agents-count"></span></span>
|
||||||
<?php if (!empty($_groups)): ?>
|
<button class="btn btn-sm btn-outline-secondary py-0" type="button"
|
||||||
<div class="d-flex gap-1 flex-wrap">
|
data-bs-toggle="collapse" data-bs-target="#agents-edit-panel">
|
||||||
<a href="/admin/stats" class="badge <?= $_activeGroup === '' ? 'bg-primary' : 'bg-secondary' ?> text-decoration-none">Tous</a>
|
Gérer les patterns
|
||||||
<?php foreach ($_groups as $g): ?>
|
</button>
|
||||||
<a href="/admin/stats?group=<?= rawurlencode($g['label']) ?>"
|
|
||||||
class="badge <?= $_activeGroup === $g['label'] ? 'bg-primary' : 'bg-secondary' ?> text-decoration-none">
|
|
||||||
<?= htmlspecialchars($g['label']) ?>
|
|
||||||
</a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<a href="/admin/stats?group=Autres"
|
|
||||||
class="badge <?= $_activeGroup === 'Autres' ? 'bg-primary' : 'bg-secondary' ?> text-decoration-none">Autres</a>
|
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<div class="card-body p-0" id="stats-agents-container">
|
||||||
|
<p class="text-muted p-3 mb-0">Chargement…</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
|
||||||
<?php
|
<!-- Panneau d'édition des patterns bots -->
|
||||||
// Sélectionner les AS à afficher
|
<div id="agents-edit-panel" class="collapse">
|
||||||
if ($_activeGroup !== '' && isset($_asGroups[$_activeGroup])) {
|
<div class="card-footer bg-transparent border-top p-3">
|
||||||
$displayAs = $_asGroups[$_activeGroup];
|
<p class="small text-muted mb-2">Un pattern par ligne (correspondance insensible à la casse, recherche partielle dans le User-Agent).</p>
|
||||||
} else {
|
<form method="post" action="/?action=admin_save_bots">
|
||||||
$displayAs = $_asList;
|
<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>
|
||||||
<?php if (empty($displayAs)): ?>
|
<button type="submit" class="btn btn-sm btn-primary">Enregistrer les patterns</button>
|
||||||
<p class="text-muted p-3 mb-0">
|
</form>
|
||||||
<?= empty($_asList) ? 'Aucune IP résolue (LAN ou logs vides).' : 'Aucun AS dans ce groupe.' ?>
|
|
||||||
</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php $maxAS = max(array_column($displayAs, 'hits')) ?: 1; ?>
|
|
||||||
<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:2rem">#</th>
|
|
||||||
<th>Réseau</th>
|
|
||||||
<th style="width:3rem">Pays</th>
|
|
||||||
<th style="width:5rem" class="text-end pe-3">Visites</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($displayAs as $i => $as): ?>
|
|
||||||
<tr>
|
|
||||||
<td class="text-muted ps-3"><?= $i + 1 ?></td>
|
|
||||||
<td>
|
|
||||||
<span class="fw-medium"><?= htmlspecialchars($as['name'] ?: '?') ?></span>
|
|
||||||
<?php if ($as['asn'] !== ''): ?>
|
|
||||||
<span class="text-muted ms-1">AS<?= htmlspecialchars($as['asn']) ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div class="progress mt-1" style="height:3px">
|
|
||||||
<div class="progress-bar bg-info" style="width:<?= round($as['hits'] / $maxAS * 100) ?>%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td class="text-muted"><?= htmlspecialchars($as['country']) ?></td>
|
|
||||||
<td class="text-end fw-semibold pe-3"><?= number_format($as['hits'], 0, ',', '\u{202F}') ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php endif; // readable?>
|
<?php endif; // readable?>
|
||||||
|
|
||||||
<!-- Groupes de réseaux -->
|
<script src="/assets/js/admin-stats.js" defer></script>
|
||||||
<div class="card mt-4" style="max-width:600px">
|
|
||||||
<div class="card-header bg-transparent py-2 small fw-semibold">Groupes de réseaux</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small">Regroupez plusieurs réseaux sous un label. Chaque ligne est un motif cherché dans le nom du réseau (insensible à la casse).</p>
|
|
||||||
<form method="post" action="/?action=admin_save_as_groups" id="as-groups-form">
|
|
||||||
<div id="as-groups-list">
|
|
||||||
<?php foreach ($_groups as $gi => $g): ?>
|
|
||||||
<div class="as-group-row border rounded p-3 mb-3">
|
|
||||||
<div class="d-flex align-items-center gap-2 mb-2">
|
|
||||||
<input type="text" name="as_group_label[]" class="form-control form-control-sm"
|
|
||||||
placeholder="Label (ex : Opérateurs FR)"
|
|
||||||
value="<?= htmlspecialchars($g['label']) ?>" required>
|
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm as-group-delete" title="Supprimer">✕</button>
|
|
||||||
</div>
|
|
||||||
<textarea name="as_group_patterns[]" class="form-control form-control-sm font-monospace"
|
|
||||||
rows="3" placeholder="Un motif par ligne ex : Free SAS Orange SFR"><?= htmlspecialchars(implode("\n", $g['patterns'])) ?></textarea>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 mt-2">
|
|
||||||
<button type="button" id="as-group-add" class="btn btn-outline-secondary btn-sm">+ Ajouter un groupe</button>
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template id="as-group-tpl">
|
|
||||||
<div class="as-group-row border rounded p-3 mb-3">
|
|
||||||
<div class="d-flex align-items-center gap-2 mb-2">
|
|
||||||
<input type="text" name="as_group_label[]" class="form-control form-control-sm"
|
|
||||||
placeholder="Label (ex : Moteurs de recherche)" required>
|
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm as-group-delete" title="Supprimer">✕</button>
|
|
||||||
</div>
|
|
||||||
<textarea name="as_group_patterns[]" class="form-control form-control-sm font-monospace"
|
|
||||||
rows="3" placeholder="Un motif par ligne ex : Googlebot Bingbot"></textarea>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.getElementById('as-group-add').addEventListener('click', () => {
|
|
||||||
const tpl = document.getElementById('as-group-tpl').content.cloneNode(true);
|
|
||||||
document.getElementById('as-groups-list').appendChild(tpl);
|
|
||||||
});
|
|
||||||
document.getElementById('as-groups-list').addEventListener('click', e => {
|
|
||||||
if (e.target.classList.contains('as-group-delete')) {
|
|
||||||
e.target.closest('.as-group-row').remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once BASE_PATH . '/src/Parsedown.php';
|
|
||||||
$Parsedown = new Parsedown();
|
|
||||||
|
|
||||||
$_apName = $authorRow['display_name'] ?? '';
|
$_apName = $authorRow['display_name'] ?? '';
|
||||||
$_apSlug = $authorRow['profile_slug'] ?? '';
|
$_apSlug = $authorRow['profile_slug'] ?? '';
|
||||||
@@ -19,8 +17,7 @@ ob_start();
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="post-grid">
|
<div class="post-grid">
|
||||||
<?php foreach ($posts as $post):
|
<?php foreach ($posts as $post):
|
||||||
$html = $Parsedown->text($post['content']);
|
$preview = mb_strimwidth($post['plain'] ?? '', 0, 120, '…');
|
||||||
$preview = mb_strimwidth(strip_tags($html), 0, 120, '…');
|
|
||||||
$category = trim((string)($post['category'] ?? ''));
|
$category = trim((string)($post['category'] ?? ''));
|
||||||
$gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []);
|
$gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []);
|
||||||
$postUrl = '/post/' . rawurlencode($post['slug']);
|
$postUrl = '/post/' . rawurlencode($post['slug']);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once BASE_PATH . '/src/Parsedown.php';
|
|
||||||
$Parsedown = new Parsedown();
|
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|
||||||
@@ -36,8 +34,7 @@ $_initials = mb_strtoupper(mb_substr($_apName, 0, 1, 'UTF-8'), 'UTF-8');
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="post-grid">
|
<div class="post-grid">
|
||||||
<?php foreach (array_slice($authorArticles, 0, 6) as $post):
|
<?php foreach (array_slice($authorArticles, 0, 6) as $post):
|
||||||
$html = $Parsedown->text($post['content']);
|
$preview = mb_strimwidth($post['plain'] ?? '', 0, 120, '…');
|
||||||
$preview = mb_strimwidth(strip_tags($html), 0, 120, '…');
|
|
||||||
$category = trim((string)($post['category'] ?? ''));
|
$category = trim((string)($post['category'] ?? ''));
|
||||||
$gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []);
|
$gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []);
|
||||||
$postUrl = '/post/' . rawurlencode($post['slug']);
|
$postUrl = '/post/' . rawurlencode($post['slug']);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -10,8 +10,6 @@
|
|||||||
|
|
||||||
$_reactionDefs = [
|
$_reactionDefs = [
|
||||||
'useful' => ['👍', 'Utile'],
|
'useful' => ['👍', 'Utile'],
|
||||||
'important' => ['🔥', 'Important'],
|
|
||||||
'interesting' => ['🤔', 'À creuser'],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$_csrfToken = bin2hex(random_bytes(16));
|
$_csrfToken = bin2hex(random_bytes(16));
|
||||||
@@ -142,3 +140,4 @@ setcookie('_csrf_c', $_csrfToken, [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/assets/js/comments.js" defer></script>
|
||||||
|
|||||||
@@ -97,8 +97,10 @@ $_typeLabel = $isCatField ? 'Catégorie' : ($tagTypes[$tagType] ?? ucfirst($tag
|
|||||||
|
|
||||||
<?php renderTagGroup('Déjà taggués', $_current, true); ?>
|
<?php renderTagGroup('Déjà taggués', $_current, true); ?>
|
||||||
<?php renderTagGroup('Valeurs connues dans d\'autres articles', $_known, false); ?>
|
<?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('Abréviations détectées', $_abbrevs, false, true); ?>
|
||||||
<?php renderTagGroup('Noms composés détectés', $_camel + $_proper, false, true); ?>
|
<?php renderTagGroup('Noms composés détectés', $_camel + $_proper, false, true); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (empty($suggestions)): ?>
|
<?php if (empty($suggestions)): ?>
|
||||||
<p class="text-muted">Aucun terme détecté dans cet article.</p>
|
<p class="text-muted">Aucun terme détecté dans cet article.</p>
|
||||||
|
|||||||
@@ -4,6 +4,21 @@
|
|||||||
<h1 class="h4 mb-0">Flux agrégés</h1>
|
<h1 class="h4 mb-0">Flux agrégés</h1>
|
||||||
</div>
|
</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)): ?>
|
<?php if (empty($fluxItems)): ?>
|
||||||
<p class="text-muted">Aucun article disponible pour l'instant.</p>
|
<p class="text-muted">Aucun article disponible pour l'instant.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|||||||
+20
-3
@@ -46,10 +46,21 @@
|
|||||||
|
|
||||||
<!-- CSS -->
|
<!-- CSS -->
|
||||||
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>>
|
<body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>>
|
||||||
|
<script src="<?= _av($_pub, 'js/density-fouc.js') ?>"></script>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark mb-0" role="navigation" aria-label="Navigation principale">
|
<nav class="navbar navbar-expand-lg navbar-dark mb-0" role="navigation" aria-label="Navigation principale">
|
||||||
@@ -149,9 +160,15 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? '');
|
|||||||
|
|
||||||
<!-- JS -->
|
<!-- JS -->
|
||||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
<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)): ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -115,8 +115,7 @@ $slugOriginal = $postSlug;
|
|||||||
</label>
|
</label>
|
||||||
<input type="text" class="form-control form-control-sm font-monospace" id="confirm-slug" name="slug"
|
<input type="text" class="form-control form-control-sm font-monospace" id="confirm-slug" name="slug"
|
||||||
value="<?= htmlspecialchars($slugDefault) ?>"
|
value="<?= htmlspecialchars($slugDefault) ?>"
|
||||||
pattern="[a-z0-9][a-z0-9\-]*"
|
pattern="[a-z0-9][a-z0-9\-]*">
|
||||||
oninput="document.getElementById('slug-display').textContent=this.value">
|
|
||||||
<?php if ($titleChanged && $autoSlug !== $slugOriginal): ?>
|
<?php if ($titleChanged && $autoSlug !== $slugOriginal): ?>
|
||||||
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
|
<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>
|
<small class="text-muted">Slug recalculé depuis le nouveau titre. Slug initial :</small>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ $dateValue = isset($published_at)
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if ($action === 'edit'): ?>
|
<?php if ($action === 'edit'): ?>
|
||||||
|
<?php $aiEditor = true; ?>
|
||||||
<div id="vl-page"
|
<div id="vl-page"
|
||||||
data-uuid="<?= htmlspecialchars($uuid) ?>"
|
data-uuid="<?= htmlspecialchars($uuid) ?>"
|
||||||
data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>"
|
data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>"
|
||||||
@@ -221,6 +222,38 @@ $dateValue = isset($published_at)
|
|||||||
|
|
||||||
<hr class="my-3">
|
<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 if (!empty($existingFiles)): ?>
|
||||||
<?php $coverFile = $article['cover'] ?? ''; ?>
|
<?php $coverFile = $article['cover'] ?? ''; ?>
|
||||||
<?php $filesMeta = $article['files_meta'] ?? []; ?>
|
<?php $filesMeta = $article['files_meta'] ?? []; ?>
|
||||||
|
|||||||
+52
-13
@@ -17,8 +17,14 @@ function _cardCoverStyle(array $post, array $allCats): string
|
|||||||
|
|
||||||
function _cardExcerpt(array $post, \Parsedown $pd, int $len = 120): string
|
function _cardExcerpt(array $post, \Parsedown $pd, int $len = 120): string
|
||||||
{
|
{
|
||||||
|
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 mb_strimwidth(strip_tags($pd->text($post['content'])), 0, $len, '…');
|
||||||
}
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown $pd): void
|
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>
|
</form>
|
||||||
<p class="hero-search-stats">
|
<p class="hero-search-stats">
|
||||||
<?= $totalPublished ?> article<?= $totalPublished > 1 ? 's' : '' ?>
|
<?= $totalPublished ?> article<?= $totalPublished > 1 ? 's' : '' ?>
|
||||||
<?php if ($totalUpcoming > 0): ?>
|
<?php if ($totalUpcoming > 0): ?>· <?= $totalUpcoming ?> à venir<?php endif; ?>
|
||||||
· <?= $totalUpcoming ?> à venir
|
|
||||||
<?php endif; ?>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,19 +159,14 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php /* ─── Tendances ───────────────────────────────────────────────────── */ ?>
|
<?php /* ─── Meilleures audiences (AJAX — flux RSS XML /trending?period=1h) ── */ ?>
|
||||||
<?php if (!empty($popularPosts)): ?>
|
<section class="home-section" id="home-audiences-section" hidden>
|
||||||
<section class="home-section">
|
|
||||||
<h2 class="home-section-title">
|
<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>
|
</h2>
|
||||||
<div class="post-grid">
|
<div class="post-grid" id="home-audiences-grid"></div>
|
||||||
<?php foreach ($popularPosts as $_pp): ?>
|
|
||||||
<?php _renderCard($_pp, $privateCats ?? [], $allCats ?? [], $Parsedown); ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<script src="/assets/js/trending-home.js"></script>
|
||||||
|
|
||||||
<?php /* ─── Récemment mis à jour ──────────────────────────────────────── */ ?>
|
<?php /* ─── Récemment mis à jour ──────────────────────────────────────── */ ?>
|
||||||
<?php if (!empty($recentlyUpdated)): ?>
|
<?php if (!empty($recentlyUpdated)): ?>
|
||||||
@@ -215,6 +214,38 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?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 else: /* ─── VUE PAGINÉE / FILTRÉE ─────────────────────────────── */ ?>
|
||||||
|
|
||||||
<?php if ($cursor === '' && $filterCat === ''): ?>
|
<?php if ($cursor === '' && $filterCat === ''): ?>
|
||||||
@@ -302,6 +333,14 @@ if (!empty($_tagCats)):
|
|||||||
<a href="/new" class="fab-new" title="Nouvel article" aria-label="Nouvel article">+</a>
|
<a href="/new" class="fab-new" title="Nouvel article" aria-label="Nouvel article">+</a>
|
||||||
<?php endif; ?>
|
<?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
|
<?php
|
||||||
$content = ob_get_clean();
|
$content = ob_get_clean();
|
||||||
$title = siteTitle();
|
$title = siteTitle();
|
||||||
|
|||||||
+133
-2
@@ -9,6 +9,23 @@ $_accentMap = [
|
|||||||
];
|
];
|
||||||
$_tocItems = [];
|
$_tocItems = [];
|
||||||
$_tocSeen = [];
|
$_tocSeen = [];
|
||||||
|
|
||||||
|
// 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.
|
// Le titre H1 est déjà affiché par le template ; on le retire du rendu.
|
||||||
$_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent);
|
$_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent);
|
||||||
$_renderedContent = preg_replace_callback(
|
$_renderedContent = preg_replace_callback(
|
||||||
@@ -35,6 +52,19 @@ $_renderedContent = preg_replace_callback(
|
|||||||
},
|
},
|
||||||
$Parsedown->text($_rawForRender)
|
$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();
|
ob_start();
|
||||||
|
|
||||||
@@ -95,6 +125,14 @@ $authorName = ($authorEmail !== '' && function_exists('authorDisplayName')
|
|||||||
$authorProfileUrl = ($authorEmail !== '' && function_exists('authorProfileUrl')) ? authorProfileUrl($authorEmail) : '';
|
$authorProfileUrl = ($authorEmail !== '' && function_exists('authorProfileUrl')) ? authorProfileUrl($authorEmail) : '';
|
||||||
$authorSlugVal = ($authorEmail !== '' && function_exists('authorSlug')) ? authorSlug($authorEmail) : '';
|
$authorSlugVal = ($authorEmail !== '' && function_exists('authorSlug')) ? authorSlug($authorEmail) : '';
|
||||||
$pubDate = htmlspecialchars(date('d/m/Y', strtotime((string)($article['published_at'] ?? $article['created_at'] ?? ''))));
|
$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 !== '';
|
$hasCover = $coverFile !== '';
|
||||||
$heroExtraClass = $hasCover ? '' : ' article-cover--gradient';
|
$heroExtraClass = $hasCover ? '' : ' article-cover--gradient';
|
||||||
$heroStyle = $hasCover ? '' : ' style="background:' . htmlspecialchars($gradient) . '"';
|
$heroStyle = $hasCover ? '' : ' style="background:' . htmlspecialchars($gradient) . '"';
|
||||||
@@ -136,7 +174,24 @@ $hasSources = (!empty($externalLinks) || !empty($files))
|
|||||||
<span class="mx-1 opacity-50">·</span>
|
<span class="mx-1 opacity-50">·</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?= $pubDate ?>
|
<?= $pubDate ?>
|
||||||
|
<?php if ($modDate !== ''): ?>
|
||||||
|
<br><small class="opacity-75"><?= htmlspecialchars($modDate) ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
|
<?php
|
||||||
|
$_v30 = (int) ($articleVisitors['30'] ?? 0);
|
||||||
|
$_v14 = (int) ($articleVisitors['14'] ?? 0);
|
||||||
|
$_v7 = (int) ($articleVisitors['7'] ?? 0);
|
||||||
|
if ($_v30 > 0):
|
||||||
|
?>
|
||||||
|
<p class="article-hero-visitors" title="Visiteurs uniques (IPs non-bot) · <?= $_v7 ?> / 7 j · <?= $_v14 ?> / 14 j · <?= $_v30 ?> / 30 j">
|
||||||
|
<span class="opacity-75" style="font-size:.8rem">
|
||||||
|
<?= number_format($_v30, 0, ',', "\xE2\x80\xAF") ?> lecteurs · 30 j
|
||||||
|
<span class="opacity-50"> (<?= $_v14 ?> / 14 j · <?= $_v7 ?> / 7 j)</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<?php endif;
|
||||||
|
unset($_v30, $_v14, $_v7); ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="article-hero-right">
|
<div class="article-hero-right">
|
||||||
<?php if ($hasSources): ?>
|
<?php if ($hasSources): ?>
|
||||||
@@ -145,8 +200,6 @@ $hasSources = (!empty($externalLinks) || !empty($files))
|
|||||||
<?php
|
<?php
|
||||||
$_heroReactionDefs = [
|
$_heroReactionDefs = [
|
||||||
'useful' => ['👍', 'Utile'],
|
'useful' => ['👍', 'Utile'],
|
||||||
'important' => ['🔥', 'Important'],
|
|
||||||
'interesting' => ['🤔', 'À creuser'],
|
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
<div class="hero-reactions" id="reactions">
|
<div class="hero-reactions" id="reactions">
|
||||||
@@ -173,6 +226,12 @@ $hasSources = (!empty($externalLinks) || !empty($files))
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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">
|
<div class="card-text post-content">
|
||||||
<?= $_renderedContent ?>
|
<?= $_renderedContent ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,6 +273,59 @@ $hasSources = (!empty($externalLinks) || !empty($files))
|
|||||||
</div>
|
</div>
|
||||||
</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) ?>&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) ?>&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'; ?>
|
<?php include __DIR__ . '/comments_section.php'; ?>
|
||||||
|
|
||||||
@@ -340,6 +452,24 @@ $hasSources = (!empty($externalLinks) || !empty($files))
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,6 +480,7 @@ $hasSources = (!empty($externalLinks) || !empty($files))
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
$content = ob_get_clean();
|
$content = ob_get_clean();
|
||||||
|
$shareBar = (bool)($article['published'] ?? false);
|
||||||
$title = htmlspecialchars($article['title']);
|
$title = htmlspecialchars($article['title']);
|
||||||
$seoTitle = ($article['seo_title'] ?? '') ?: $article['title'];
|
$seoTitle = ($article['seo_title'] ?? '') ?: $article['title'];
|
||||||
$ogType = 'article';
|
$ogType = 'article';
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function renderMetaCell(string $key, mixed $val, array $row = []): string
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-3 mb-1">
|
<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 & médias</h1>
|
<h1 class="h4 mb-0">Sources & médias</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small mb-4"><?= htmlspecialchars($article['title']) ?></p>
|
<p class="text-muted small mb-4"><?= htmlspecialchars($article['title']) ?></p>
|
||||||
|
|||||||
@@ -49,15 +49,44 @@ $_hasUuid = $_wizUuid !== '';
|
|||||||
|
|
||||||
</div><!-- /col-lg-9 -->
|
</div><!-- /col-lg-9 -->
|
||||||
|
|
||||||
<!-- Plan (TOC dynamique) ───────────────────────────────────────────────── -->
|
<!-- Sidebar droite : TOC + IA ──────────────────────────────────────────── -->
|
||||||
<div class="col-lg-3 d-none d-lg-block">
|
<div class="col-lg-3 d-none d-lg-block">
|
||||||
<div class="position-sticky" style="top:1rem">
|
<div class="position-sticky" style="top:1rem">
|
||||||
<div class="card border-secondary-subtle">
|
<div class="card border-secondary-subtle mb-3">
|
||||||
<div class="card-header bg-transparent py-2 small fw-semibold text-muted">Plan</div>
|
<div class="card-header bg-transparent py-2 small fw-semibold text-muted">Plan</div>
|
||||||
<div class="card-body p-2" style="max-height:80vh;overflow-y:auto">
|
<div class="card-body p-2" style="max-height:40vh;overflow-y:auto">
|
||||||
<ul id="wz-toc-list" class="list-unstyled mb-0"></ul>
|
<ul id="wz-toc-list" class="list-unstyled mb-0"></ul>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div><!-- /row -->
|
</div><!-- /row -->
|
||||||
@@ -172,4 +201,7 @@ $_hasUuid = $_wizUuid !== '';
|
|||||||
<?php
|
<?php
|
||||||
$content = ob_get_clean();
|
$content = ob_get_clean();
|
||||||
$title = ($mode === 'create' ? 'Nouvel article' : 'Modifier') . ' — Étape 1/' . $totalSteps;
|
$title = ($mode === 'create' ? 'Nouvel article' : 'Modifier') . ' — Étape 1/' . $totalSteps;
|
||||||
|
if ($mode === 'edit') {
|
||||||
|
$aiEditor = true;
|
||||||
|
}
|
||||||
include BASE_PATH . '/templates/layout.php';
|
include BASE_PATH . '/templates/layout.php';
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ $_catVal = trim($category ?? '');
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($mode === 'create' && !empty($existingFiles ?? [])): ?>
|
<?php if (!empty($existingFiles ?? [])): ?>
|
||||||
<?php $_imgFiles = array_filter($existingFiles, fn ($_f) => $_f['is_image']); ?>
|
<?php $_imgFiles = array_filter($existingFiles, fn ($_f) => $_f['is_image']); ?>
|
||||||
<?php if ($_imgFiles): ?>
|
<?php if ($_imgFiles): ?>
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
|
|||||||
@@ -2,19 +2,17 @@
|
|||||||
// Attendu (edit only) : $uuid, $step, $totalSteps, $mode='edit', $article (original),
|
// Attendu (edit only) : $uuid, $step, $totalSteps, $mode='edit', $article (original),
|
||||||
// $draftData, $diffLines, $changes, $autoRevisionComment,
|
// $draftData, $diffLines, $changes, $autoRevisionComment,
|
||||||
// $seoTitle, $seoDescription, $autoSeoDesc, $title (draft), $postSlug,
|
// $seoTitle, $seoDescription, $autoSeoDesc, $title (draft), $postSlug,
|
||||||
// $titleChanged, $autoSlug, $published, $published_at, $category
|
// $titleChanged, $published, $published_at, $category
|
||||||
ob_start();
|
ob_start();
|
||||||
$_CONTEXT = 3;
|
$_CONTEXT = 3;
|
||||||
$_backUrl = '/edit/' . rawurlencode($uuid) . '/5';
|
$_backUrl = '/edit/' . rawurlencode($uuid) . '/5';
|
||||||
$_formAction = '/edit/' . rawurlencode($uuid) . '/6';
|
$_formAction = '/edit/' . rawurlencode($uuid) . '/6';
|
||||||
$_slugFinal = ($titleChanged && $autoSlug !== $postSlug) ? $autoSlug : $postSlug;
|
|
||||||
?>
|
?>
|
||||||
<?php include __DIR__ . '/nav.php'; ?>
|
<?php include __DIR__ . '/nav.php'; ?>
|
||||||
|
|
||||||
<!-- En-tête : titre + boutons à droite ─────────────────────────────────── -->
|
<!-- En-tête : titre + boutons à droite ─────────────────────────────────── -->
|
||||||
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
|
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
|
||||||
<input type="hidden" name="_confirm" value="1">
|
<input type="hidden" name="_confirm" value="1">
|
||||||
<input type="hidden" name="slug" value="<?= htmlspecialchars($_slugFinal) ?>">
|
|
||||||
|
|
||||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-4 flex-wrap">
|
<div class="d-flex align-items-start justify-content-between gap-3 mb-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
@@ -28,7 +26,9 @@ $_slugFinal = ($titleChanged && $autoSlug !== $postSlug) ? $autoSlug : $postSlu
|
|||||||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||||
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
|
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||||
onclick="if(confirm('Abandonner les modifications et supprimer ce brouillon ?')) window.location='/edit/<?= rawurlencode($uuid) ?>/discard'">
|
data-confirm-discard
|
||||||
|
data-discard-url="/edit/<?= rawurlencode($uuid) ?>/discard"
|
||||||
|
data-confirm-msg="Abandonner les modifications et supprimer ce brouillon ?">
|
||||||
Abandonner
|
Abandonner
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-success">✓ Confirmer et enregistrer</button>
|
<button type="submit" class="btn btn-success">✓ Confirmer et enregistrer</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user