39 Commits

Author SHA1 Message Date
cedricAbonnel 88cc67d945 feat : image de couverture modifiable en mode édition (v1.6.12)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:08:14 +02:00
cedricAbonnel 6092cf940d docs : consignes déploiement abonnel.fr — sudo, www-data, .sessions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:56:34 +02:00
cedricAbonnel 5b16fb465b fix : slug immuable en édition — suppression de la propagation auto (v1.6.11)
En mode édition, le slug ne doit jamais changer. Suppression du
hidden[slug] dans step6.php et du bloc qui le sauvegardait dans
le draft overlay avant commitDraftOverlay().

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:52:06 +02:00
cedricAbonnel ebf0e2df65 Merge pull request 'release 1.6.0 : bouton Mettre à jour, branche dev, guard git pull' (#69) from dev into main
release 1.6.0
2026-05-15 09:21:51 +00:00
cedricAbonnel 331e9c9ecd chore : version 1.6.0 — bouton Mettre à jour, branche dev, guard git pull
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:16:35 +02:00
cedricAbonnel 0280ef3ca1 docs : architecture articles git (varlog.git + abonnel-www.git), sync bidirectionnelle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:16:35 +02:00
cedricAbonnel eddde2165a fix : run_engine_update vérifie origin == folio_repo_url avant git pull
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:16:35 +02:00
cedricAbonnel 07d004b3f0 feat : bouton unique Mettre à jour (git pull + SQL + contenu), branche dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:16:35 +02:00
cedricAbonnel 5cb0e854fd Merge pull request 'release 1.5.0 : config admin FOLIO_REPO_URL, APP_TIMEZONE, push.sh protégé' (#68) from dev into main
release 1.5.0 : config admin FOLIO_REPO_URL, APP_TIMEZONE, push.sh protégé
2026-05-15 08:07:04 +00:00
cedricAbonnel 8f6c17f0f2 chore : version 1.5.0, push.sh bloque main, CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:55:16 +02:00
cedricAbonnel 5452fb4927 Merge pull request 'feat : FOLIO_REPO_URL et APP_TIMEZONE configurables depuis admin' (#67) from feat/folio-repo-url-admin-config into main
Reviewed-on: #67
2026-05-15 07:52:17 +00:00
40 changed files with 1563 additions and 369 deletions
-1
View File
@@ -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/
+132
View File
@@ -5,10 +5,142 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
--- ---
## [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
### Ajouté
- Admin → Dashboard : bouton unique **Mettre à jour** (git pull + migrations SQL + migrations contenu) remplace les boutons séparés
- Branche `dev` : branche d'intégration permanente pour le développement quotidien
### Corrigé
- `run_engine_update` : vérifie que le remote git `origin` correspond à `FOLIO_REPO_URL` avant tout `git pull` (évite le pull sur le mauvais dépôt)
---
## [1.5.0] - 2026-05-15
### Ajouté
- Admin → Site : configuration de l'URL du dépôt Folio et de la branche suivie pour les mises à jour, sans modifier le `.env` (`folio_repo_url`, `folio_update_branch` dans `site_settings.json`)
- `APP_TIMEZONE` : fuseau horaire configurable via `.env` (défaut `Europe/Paris`), appliqué globalement dans `bootstrap.php`
### Corrigé
- Bouton « Vérifier » masqué avec message explicatif si `FOLIO_REPO_URL` n'est pas configuré (ni dans `.env` ni dans l'admin)
- `FOLIO_REPO_URL` et `FOLIO_UPDATE_BRANCH` documentés dans `.env.example`
- `scripts/push.sh` : ne pousse plus directement sur `main` — pousse sur la branche courante pour forcer le passage par une PR
---
## [1.4.0] - 2026-05-15 ## [1.4.0] - 2026-05-15
### Ajouté ### Ajouté
+39 -15
View File
@@ -11,40 +11,64 @@ 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
Pour toute correction ou fonctionnalité : **créer un ticket et une PR**. ### Branches
1. Coder ici dans `~/Projects/folio/` (branche feature) | Branche | Rôle |
2. **Tester sur varlog.a5l.fr** : |---------|------|
| `dev` | Branche d'intégration permanente. **Tout le développement courant se fait ici.** |
| `main` | Branche de production. **Jamais de commit direct.** |
| `feat/*` | Branches feature optionnelles pour du travail isolé, mergées dans `dev`. |
### Workflow
1. Toujours travailler sur `dev` (ou une branche feature mergée dans `dev`) :
```bash
git checkout dev
```
2. **Tester sur varlog.a5l.fr** à chaque itération (rsync des fichiers locaux, DB persistante) :
```bash ```bash
~/Projects/varlog/scripts/sync.sh ~/Projects/varlog/scripts/sync.sh
# puis tester sur http://varlog.acegrp.lan # puis tester sur http://varlog.acegrp.lan
``` ```
3. Une fois validé, ouvrir une PR sur Gitea. Le commit doit inclure : 3. Quand `dev` est stable et prête pour la production :
- `public/version.txt` (bump semver) - Bumper `public/version.txt` (semver)
- `CHANGELOG.md` (entrée `### Ajouté / Corrigé / Modifié`) - Ajouter une entrée `CHANGELOG.md` (`### Ajouté / Corrigé / Modifié`)
- Ouvrir une **PR `dev` → `main`** sur Gitea
4. Merger la PR → abonnel.fr se met à jour automatiquement. 4. Merger la PR → abonnel.fr se met à jour automatiquement.
**Règle absolue : ne jamais commiter directement sur `main`.** Le script `scripts/push.sh` bloque cette action.
### Pourquoi `dev` et non des branches feature à la volée
- La DB de varlog (test) accumule les migrations au fil du temps — changer de branche ne fait pas reculer les migrations.
- Travailler toujours sur `dev` évite toute désynchronisation entre le code rsyncé et la DB.
## Données articles (`DATA_PATH`) ## Données articles (`DATA_PATH`)
Les articles sont stockés dans un répertoire **hors du dépôt Folio**, configurable via `DATA_PATH` dans `.env`. Les articles sont stockés dans un répertoire **hors du dépôt Folio**, configurable via `DATA_PATH` dans `.env` (défaut production : `/srv/data/folio`).
| Environnement | Chemin local | Chemin serveur | | Environnement | Dépôt local articles | Dépôt Gitea | Serveur |
|--------------|-------------|----------------| |--------------|---------------------|------------|---------|
| varlog | `~/Projects/varlog-data/` | `/srv/data/folio` | | varlog | `~/Projects/varlog-data/` | `cedricAbonnel/varlog` | `varlog:/srv/data/folio` |
| abonnel.fr | `~/Projects/fr.abonnel.www-data/` | `/srv/data/folio` | | abonnel.fr | `~/Projects/fr.abonnel.www-data/` | `cedricAbonnel/abonnel-www` | `abonnel-wiki:/srv/data/folio` |
Les scripts de sync (`pull-data.sh`, `push-data.sh`, `sync.sh`) utilisent `DATA_DIR` (overridable via env) pointant vers ces chemins locaux. Sync bidirectionnelle via **git** (pas rsync). Scripts dans `~/Projects/varlog/scripts/` et `~/Projects/fr.abonnel.www/scripts/` :
- `pull-data.sh` : commit auto serveur + git pull local
- `push-data.sh` : git commit local + git push + git pull serveur
- `sync.sh` : moteur (rsync) + articles (git bidirectionnel)
## Asymétrie de déploiement moteur ## Asymétrie de déploiement moteur
+8
View File
@@ -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
``` ```
+132
View File
@@ -0,0 +1,132 @@
# 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.
-18
View File
@@ -1,18 +0,0 @@
{
"uuid": "a3d8f2c1-7b4e-4f9a-8c3d-2e5a9b6f1d4c",
"slug": "about",
"title": "À propos",
"author": "cedric@abonnel.fr",
"published": true,
"published_at": "2021-01-16 04:02:40",
"created_at": "2021-01-16 04:02:40",
"updated_at": "2026-05-13 00:00:00",
"revisions": [],
"cover": "",
"files_meta": [],
"external_links": [],
"seo_title": "",
"seo_description": "",
"og_image": "",
"category": ""
}
-39
View File
@@ -1,39 +0,0 @@
# À propos
Qui se cache derrière varlog ?
Je m'appelle **Cédric**. Passionné d'informatique depuis longtemps, je gère un **HomeLab** à la maison — un petit laboratoire personnel où je fais tourner des serveurs, expérimente des configs réseau et casse des choses pour mieux les comprendre.
varlog est mon carnet de bord technique. J'y documente ce que je fais, ce que j'apprends, et parfois ce qui tourne mal — les incidents sont souvent les meilleures leçons.
Le blog a été lancé publiquement aux **JDLL 2025** (Journées Du Logiciel Libre), à Lyon.
## Ce dont je parle ici
### HomeLab & infrastructure
Proxmox, virtualisation, domotique (Zigbee, MQTT, Home Assistant), supervision avec Uptime Kuma, auto-hébergement de services (Gitea, Keycloak…), incidents réseau et leurs post-mortems.
### Réseaux & télécom
Passionné par les réseaux mobiles (3G/4G/5G/6G), la fibre optique (50G-PON), les stratégies des opérateurs et les infrastructures qui font fonctionner tout ça sans qu'on y pense.
### Linux & développement
Debian au quotidien, scripts, administration système, et un peu de PHP — dont ce blog lui-même, développé maison sous le nom de code *Folio*.
### Numérique & société
Souveraineté numérique, données personnelles, IA et plateformes qui monétisent nos contenus — des sujets qui m'intéressent autant qu'ils m'inquiètent.
### Le reste
Bricolage, travaux, anecdotes techniques, lectures, liseuses Kobo, et quelques billets qui n'entrent dans aucune case. La vie ne se range pas en catégories.
## Contact
Vous pouvez me joindre via le [formulaire de contact](/contact). Je lis tous les messages, même si je ne réponds pas toujours vite.
---
Le contenu de ce blog est publié sous licence [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) sauf mention contraire. Le moteur *Folio* est distribué sous [licence MIT](/LICENSE).
-18
View File
@@ -1,18 +0,0 @@
{
"uuid": "b2c7e1f4-4a3d-4e8b-9f2a-1d6c8e3f5a7b",
"slug": "legal",
"title": "Mentions légales",
"author": "cedric@abonnel.fr",
"published": true,
"published_at": "2021-01-16 04:02:40",
"created_at": "2021-01-16 04:02:40",
"updated_at": "2026-05-13 00:00:00",
"revisions": [],
"cover": "",
"files_meta": [],
"external_links": [],
"seo_title": "",
"seo_description": "",
"og_image": "",
"category": ""
}
-43
View File
@@ -1,43 +0,0 @@
# Mentions légales
Conformément à la loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économie numérique (LCEN).
## Éditeur du site
**Responsable de publication :** Cédric Abonnel
**Qualité :** Particulier — site personnel non commercial
**Contact :** [formulaire de contact](/contact)
## Hébergement
**Type :** Auto-hébergement sur infrastructure personnelle (HomeLab)
**Exploitant :** Cédric Abonnel
**Fournisseur d'accès à internet :** Infrastructure personnelle auto-hébergée
## Propriété intellectuelle
Le **contenu éditorial** de ce site (articles, textes, images produites par l'auteur) est publié sous licence [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/), sauf mention contraire.
Le **moteur du site** (*Folio*) est un logiciel libre distribué sous [licence MIT](/LICENSE).
Les composants tiers (Bootstrap, PHPMailer, police Inter…) sont soumis à leurs licences respectives, détaillées sur la [page des licences](/licenses).
## Données personnelles (RGPD)
Ce site est un blog personnel **sans publicité, sans pistage, sans système de commentaires** ni inscription publique.
Les seules données traitées automatiquement sont les **journaux de connexion du serveur web** (adresse IP, horodatage, page demandée), conservés conformément aux obligations légales (article L34-1 du Code des postes et des communications électroniques — durée maximale : 1 an).
Ces données ne sont ni vendues, ni transmises à des tiers, ni utilisées à des fins commerciales.
Conformément au RGPD (règlement UE 2016/679), vous disposez d'un droit d'accès, de rectification et de suppression des données vous concernant. Pour exercer ces droits : [formulaire de contact](/contact).
## Cookies
Ce site utilise uniquement un **cookie de session technique**, nécessaire au fonctionnement de l'authentification. Il n'est déposé que lors d'une connexion au compte d'administration et n'est pas utilisé à des fins de suivi ou de profilage. Aucun cookie tiers n'est déposé.
## Responsabilité
L'éditeur s'efforce de maintenir les informations publiées à jour et exactes, mais ne peut garantir l'exhaustivité ou l'absence d'erreurs du contenu.
Les liens vers des sites tiers sont fournis à titre informatif. L'éditeur n'est pas responsable du contenu de ces sites externes.
-18
View File
@@ -1,18 +0,0 @@
{
"uuid": "fdff8ad3-d369-4bd7-bbb9-e14d433868d7",
"slug": "licenses",
"title": "Licences",
"author": "cedric@abonnel.fr",
"published": true,
"published_at": "2021-01-16 04:02:40",
"created_at": "2021-01-16 04:02:40",
"updated_at": "2021-01-16 04:02:40",
"revisions": [],
"cover": "",
"files_meta": [],
"external_links": [],
"seo_title": "",
"seo_description": "",
"og_image": "",
"category": ""
}
-38
View File
@@ -1,38 +0,0 @@
# Licences
Composants logiciels utilisés par ce site et leurs licences.
## Ce site
| Composant | Licence | Usage |
|-----------|---------|-------|
| **Folio** — moteur de blog PHP | MIT | Moteur de ce blog — par Cédric Abonnel ([voir la licence](/LICENSE)) |
| **Contenu éditorial** | CC BY 4.0 | Articles et textes du blog — [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) |
## Bibliothèques (production)
| Composant | Version | Licence | Usage |
|-----------|---------|---------|-------|
| **Bootstrap** | 5.3.3 | MIT | Framework CSS/JS — auto-hébergé ([voir la licence](/assets/css/LICENSE-Bootstrap.txt)) |
| **PHPMailer** | 6.12.0 | LGPL-2.1 | Envoi d'e-mails SMTP |
| **phpdotenv** | 5.6.2 | BSD-3-Clause | Variables d'environnement |
| **openid-connect-php** | 1.0.2 | Apache-2.0 | Authentification SSO (OIDC) |
| **Police Inter** | v20 | OFL-1.1 | Typographie — auto-hébergée ([voir la licence](/assets/fonts/LICENSE-Inter.txt)) |
## Outils de développement
| Composant | Version | Licence | Usage |
|-----------|---------|---------|-------|
| **PHPStan** | 1.12.32 | MIT | Analyse statique PHP |
| **PHP-CS-Fixer** | 3.89.1 | MIT | Formatage du code |
| **Claude Code CLI** | — | Commercial | Outil de développement (Anthropic) — [Conditions d'utilisation](https://www.anthropic.com/legal/aup) |
## Infrastructure
| Composant | Licence | Usage |
|-----------|---------|-------|
| **PHP 8.3** | PHP License v3.01 | Langage côté serveur |
| **PostgreSQL** | PostgreSQL License | Base de données relationnelle |
| **Apache HTTP Server** | Apache-2.0 | Serveur web |
+115
View File
@@ -0,0 +1,115 @@
# Déploiement et mise à jour
## Mise à jour via le bouton admin
L'interface d'administration propose un bouton **Mettre à jour** (onglet Dashboard). Il appelle `sudo /usr/local/bin/folio-upgrade.sh` depuis PHP (`www-data`) et exécute la séquence complète :
1. Sauvegarde du `.env`
2. `git clone --depth=1` dans un répertoire temporaire
3. Remplacement atomique du répertoire applicatif
4. `chown -R www-data:www-data` + `chmod g+rwX,o=`
5. Restauration du `.env`
6. `composer install --no-dev --optimize-autoloader`
7. `php database/migrate.php` (migrations SQL)
8. Création de `.sessions/` avec les bons droits
9. `git config --system --add safe.directory`
### Pré-requis serveur (à faire une fois)
```bash
# 1. Installer le script (copié depuis le dépôt)
sudo install -o root -m 750 /var/www/mon-site/scripts/server/folio-upgrade.sh \
/usr/local/bin/folio-upgrade.sh
# 2. Adapter APP_DIR et REPO_URL en tête du script
sudo nano /usr/local/bin/folio-upgrade.sh
# 3. Créer la règle sudoers (www-data sans mot de passe)
echo "www-data ALL=(root) NOPASSWD: /usr/local/bin/folio-upgrade.sh" \
| sudo tee /etc/sudoers.d/folio-upgrade
sudo chmod 440 /etc/sudoers.d/folio-upgrade
# 4. Vérifier la syntaxe sudoers
sudo visudo -c
```
Variables à configurer dans le script :
| Variable | Exemple |
|---|---|
| `APP_DIR` | `/var/www/lan.acegrp.abonnel-www` |
| `REPO_URL` | `https://git.abonnel.fr/cedricAbonnel/folio.git` |
> **Sans cette configuration**, le bouton retourne :
> `sudo: a terminal is required to read the password`
### Fonctionnement du cache de mise à jour
Le résultat de la dernière mise à jour est conservé dans `DATA_PATH/.upgrade-log` et affiché en `<details>` dans l'admin.
---
## Mise à jour manuelle
Si le bouton admin n'est pas configuré ou si une mise à jour d'urgence est nécessaire :
```bash
# Sauvegarde du .env
cp /var/www/mon-site/.env /tmp/.env.bak
# Clone fresh
sudo rm -rf /var/www/mon-site
sudo git clone --depth=1 https://git.abonnel.fr/cedricAbonnel/folio.git /var/www/mon-site
# Permissions
sudo chown -R www-data:www-data /var/www/mon-site
sudo chmod -R g+rwX,o= /var/www/mon-site
# Restaurer .env
cp /tmp/.env.bak /var/www/mon-site/.env
# Dépendances et migrations
cd /var/www/mon-site
composer install --no-dev --optimize-autoloader
php database/migrate.php
# Répertoire de sessions
sudo mkdir -p /var/www/mon-site/.sessions
sudo chown www-data:www-data /var/www/mon-site/.sessions
sudo chmod 700 /var/www/mon-site/.sessions
# Autoriser git (accès multi-utilisateurs)
sudo git config --system --add safe.directory /var/www/mon-site
```
---
## Flux RSS des tendances (`/trending`)
Le flux RSS des articles les plus consultés est alimenté par `TrendingParser` qui lit les logs Apache.
- **Source** : `GET /trending?period=<période>` — parse les logs et écrit `DATA_PATH/_cache/trending_<période>.json`
- **Consommateurs** (lecture seule du cache) : page d'accueil (rubrique "Meilleures audiences") et `/tendances`
### Périodes disponibles
| Paramètre | Fenêtre | Cache TTL |
|---|---|---|
| `10m` | 10 min | 2 min |
| `20m` | 20 min | 4 min |
| `30m` | 30 min | 6 min |
| `1h` | 1 heure | 12 min |
| `8h` | 8 heures | 96 min |
| `1d` | 24 heures | 5 h |
| `7d` | 7 jours | 8 h |
| `14d` | 14 jours | 8 h |
| `30d` | 30 jours | 8 h |
| `1y` | 1 an | 8 h |
### Prérequis
`www-data` doit appartenir au groupe `adm` pour lire `/var/log/apache2/` :
```bash
sudo usermod -aG adm www-data
```
+1
View File
@@ -57,6 +57,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]
+34
View File
@@ -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; }
+68
View File
@@ -0,0 +1,68 @@
/* Admin stats : groupes AS + chargement pages via flux RSS XML /trending?period=14d */
// ── Groupes de réseaux ────────────────────────────────────────────────────────
(function () {
var addBtn = document.getElementById('as-group-add');
if (!addBtn) { return; }
addBtn.addEventListener('click', function () {
var tpl = document.getElementById('as-group-tpl').content.cloneNode(true);
document.getElementById('as-groups-list').appendChild(tpl);
});
document.getElementById('as-groups-list').addEventListener('click', function (e) {
if (e.target.classList.contains('as-group-delete')) {
e.target.closest('.as-group-row').remove();
}
});
}());
// ── Pages les plus visitées (RSS XML) ────────────────────────────────────────
(function () {
var container = document.getElementById('stats-pages-container');
var badge = document.getElementById('stats-pages-count');
if (!container) { return; }
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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\//, ''));
return { title: title, link: link, slug: slug, vis: vis };
});
var maxV = Math.max.apply(null, rows.map(function (r) { return r.vis; })) || 1;
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0 small"><tbody>';
rows.forEach(function (row, i) {
var pct = Math.round(row.vis / maxV * 100);
var vis = row.vis.toLocaleString('fr-FR');
html += '<tr>'
+ '<td class="text-muted ps-3" style="width:2rem">' + (i + 1) + '</td>'
+ '<td><a href="' + esc(row.link) + '" target="_blank" class="text-decoration-none text-truncate d-block" style="max-width:260px" title="' + esc(row.slug) + '">'
+ esc(row.title || row.slug) + '</a>'
+ '<div class="progress mt-1" style="height:3px"><div class="progress-bar" style="width:' + pct + '%"></div></div></td>'
+ '<td class="text-end fw-semibold pe-3">' + 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>';
});
}());
+24
View File
@@ -45,4 +45,28 @@ 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 = '';
});
}
}); });
+11
View File
@@ -0,0 +1,11 @@
/* Anti-FOUC densité — chargé tôt dans <head> pour appliquer max-width avant rendu de <main> */
(function () {
var d = localStorage.getItem('folio_density') || 'm';
if (d !== 'l') {
var mw = d === 'm' ? '980px' : '660px';
var s = document.createElement('style');
s.id = 'density-fouc';
s.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}';
document.head.appendChild(s);
}
}());
+38
View File
@@ -0,0 +1,38 @@
/* Sélecteur de densité L/M/S — persisté dans localStorage */
(function () {
var KEY = 'folio_density';
var cur = localStorage.getItem(KEY) || 'm';
function applyDensity(d) {
var fouc = document.getElementById('density-fouc');
if (d !== 'l') {
var mw = d === 'm' ? '980px' : '660px';
if (!fouc) {
fouc = document.createElement('style');
fouc.id = 'density-fouc';
document.head.appendChild(fouc);
}
fouc.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}';
} else {
if (fouc) { fouc.parentNode.removeChild(fouc); }
}
document.querySelectorAll('.density-btn').forEach(function (btn) {
btn.classList.toggle('active', btn.getAttribute('data-d') === d);
});
}
applyDensity(cur);
document.addEventListener('click', function (e) {
var el = e.target;
while (el && el !== document) {
if (el.classList && el.classList.contains('density-btn')) {
cur = el.getAttribute('data-d') || 'l';
try { localStorage.setItem(KEY, cur); } catch (ignore) {}
applyDensity(cur);
return;
}
el = el.parentNode;
}
});
}());
+51
View File
@@ -0,0 +1,51 @@
/* Chargement AJAX de la section "Meilleures audiences" via le flux RSS XML /trending?period=1h */
(function () {
var grid = document.getElementById('home-audiences-grid');
if (!grid) { return; }
var gradients = [
'linear-gradient(135deg,#667eea 0%,#764ba2 100%)',
'linear-gradient(135deg,#f093fb 0%,#f5576c 100%)',
'linear-gradient(135deg,#4facfe 0%,#00f2fe 100%)',
'linear-gradient(135deg,#43e97b 0%,#38f9d7 100%)',
'linear-gradient(135deg,#fa709a 0%,#fee140 100%)',
'linear-gradient(135deg,#a18cd1 0%,#fbc2eb 100%)'
];
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
fetch('/trending?period=1h')
.then(function (r) { return r.ok ? r.text() : Promise.reject(); })
.then(function (xml) {
var doc = new DOMParser().parseFromString(xml, 'application/xml');
var items = Array.from(doc.querySelectorAll('item')).slice(0, 6);
if (!items.length) { return; }
grid.innerHTML = items.map(function (item, i) {
var raw = (item.querySelector('title') || { textContent: '' }).textContent;
var title = raw.replace(/\s*\(\d+\s+visiteurs?\)$/, '');
var link = ((item.querySelector('link') || {}).textContent || '#').trim();
var pd = (item.querySelector('pubDate') || { textContent: '' }).textContent;
var date = '';
try { if (pd) { date = new Date(pd).toLocaleDateString('fr-FR'); } } catch (err) {}
var grad = gradients[i % gradients.length];
return '<article class="card">'
+ '<div class="card-cover" style="background:' + grad + '"></div>'
+ '<div class="card-body d-flex flex-column">'
+ '<h2 class="card-title"><a href="' + esc(link) + '">' + esc(title) + '</a></h2>'
+ '<div class="post-entry-meta mt-auto">'
+ (date ? '<span>' + esc(date) + '</span>' : '')
+ '<a href="' + esc(link) + '" class="post-entry-read">→ lire</a>'
+ '</div></div>'
+ '<a href="' + esc(link) + '" class="stretched-link"></a>'
+ '</article>';
}).join('');
var section = document.getElementById('home-audiences-section');
if (section) { section.hidden = false; }
})
.catch(function () {});
}());
+111 -20
View File
@@ -45,7 +45,7 @@ $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']; $_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'];
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null; $metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
unset($_noindexActions); unset($_noindexActions);
@@ -961,10 +961,17 @@ 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;
} }
@@ -979,16 +986,14 @@ 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));
@@ -1001,7 +1006,6 @@ switch ($action) {
unset($_pd); 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 +1023,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'] ?? '';
@@ -1114,7 +1121,13 @@ switch ($action) {
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;
@@ -2333,7 +2346,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'))));
@@ -2362,7 +2374,23 @@ switch ($action) {
$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));
} }
$sortBy = in_array($_GET['sort'] ?? '', ['title', 'published', 'updated']) ? $_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') {
@@ -2549,19 +2577,33 @@ 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';
$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()); $accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
$accessStats = $accessParser->stats(); $topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
$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);
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps)); $asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
$asList = AsnLookup::aggregateByAs($topIps, $asnMap);
$adminData['stats_as'] = $asList; $statsRaw = [
$adminData['stats_as_groups'] = AsnLookup::applyGroups($asList, asGroups()); 'readable' => $accessParser->isReadable(),
'books' => $tParser->top($cutoff14, 20, ['/book/']),
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
];
@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();
} }
@@ -2816,6 +2858,29 @@ switch ($action) {
header('Location: /admin?tab=dashboard&notice=' . ($_cmErrors ? 'migration_error' : 'migrated')); header('Location: /admin?tab=dashboard&notice=' . ($_cmErrors ? 'migration_error' : 'migrated'));
exit; exit;
case 'run_engine_update':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
set_time_limit(0);
ignore_user_abort(true);
exec('sudo /usr/local/bin/folio-upgrade.sh ' . escapeshellarg(folioUpdateBranch()) . ' 2>&1', $_upgradeOut, $_upgradeCode);
$_updateChecker->clearCache();
if ($_upgradeCode !== 0) {
$_SESSION['_upgrade_log'] = implode("\n", $_upgradeOut);
header('Location: /admin?tab=dashboard&notice=upgrade_error');
exit;
}
header('Location: /admin?tab=dashboard&notice=engine_updated');
exit;
case 'force_update_check': case 'force_update_check':
requireAuth(); requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -3409,10 +3474,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
@@ -3442,6 +3532,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 {
+4 -1
View File
@@ -35,9 +35,12 @@ if (!function_exists('url')) {
} }
} }
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';
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';
+4 -1
View File
@@ -5,9 +5,12 @@
// version : 20251011 // 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 // si tu as un service pour ouvrir une session
+3 -5
View File
@@ -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();
+8
View File
@@ -39,7 +39,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;
+7
View File
@@ -9,6 +9,13 @@ 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 (session_status() !== PHP_SESSION_ACTIVE) {
error_log('[OIDC/start] session_start() a échoué — vérifier session.save_path');
http_response_code(500);
echo 'Erreur de session. Contactez l\'administrateur.';
exit;
}
if (!function_exists('env')) { if (!function_exists('env')) {
function env(string $key, ?string $default = null): ?string function env(string $key, ?string $default = null): ?string
{ {
+194
View File
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/ArticleManager.php';
const TENDANCES_PERIODS = [
'10m' => ['seconds' => 600, 'label' => '10 dernières minutes', 'short' => '10 min'],
'20m' => ['seconds' => 1200, 'label' => '20 dernières minutes', 'short' => '20 min'],
'30m' => ['seconds' => 1800, 'label' => '30 dernières minutes', 'short' => '30 min'],
'1h' => ['seconds' => 3600, 'label' => 'dernière heure', 'short' => '1 h'],
'8h' => ['seconds' => 28800, 'label' => '8 dernières heures', 'short' => '8 h'],
'1d' => ['seconds' => 86400, 'label' => '24 dernières heures', 'short' => '24 h'],
'7d' => ['seconds' => 604800, 'label' => '7 derniers jours', 'short' => '7 j'],
'14d' => ['seconds' => 1209600, 'label' => '14 derniers jours', 'short' => '14 j'],
'30d' => ['seconds' => 2592000, 'label' => '30 derniers jours', 'short' => '30 j'],
'1y' => ['seconds' => 31536000, 'label' => 'dernière année', 'short' => '1 an'],
];
// Période active (affichage du top)
$period = $_GET['period'] ?? '1d';
if (!array_key_exists($period, TENDANCES_PERIODS)) {
$period = '1d';
}
$seconds = TENDANCES_PERIODS[$period]['seconds'];
$label = TENDANCES_PERIODS[$period]['label'];
$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
// Lecture seule du cache généré par /trending?period=…
$cacheFile = DATA_PATH . '/_cache/trending_' . $period . '.json';
$topPaths = null;
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
$topPaths = json_decode((string) file_get_contents($cacheFile), true) ?: null;
}
// Index slug → article
$articleManager = new ArticleManager(DATA_PATH);
$now = time();
$privateCats = $articleManager->getPrivateCategories();
$slugIndex = [];
foreach ($articleManager->getAll(publishedOnly: true) as $a) {
if (strtotime((string) ($a['published_at'] ?? '')) > $now) {
continue;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true)) {
continue;
}
$slugIndex[$a['slug']] = $a;
}
// Top articles pour la période affichée
$topItems = [];
foreach ($topPaths as $path => $visitors) {
if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
continue;
}
$article = $slugIndex[rawurldecode($m[1])] ?? null;
if ($article === null) {
continue;
}
$topItems[] = ['article' => $article, 'visitors' => (int) $visitors];
if (count($topItems) >= 20) {
break;
}
}
$base = rtrim(APP_URL, '/');
$pageTitle = 'Tendances — ' . siteTitle();
ob_start();
?>
<div class="container py-4" style="max-width:860px">
<h1 class="h3 mb-1">Tendances</h1>
<p class="text-muted mb-4">
Articles les plus consultés, calculés en temps réel depuis les journaux d'accès du serveur.
Seuls les visiteurs uniques (une IP = un visiteur) sur des réponses <code>200</code> sont comptabilisés.
Aucun cookie, aucun traceur tiers.
</p>
<!-- Sélecteur de période -->
<div class="d-flex flex-wrap gap-2 mb-4">
<?php foreach (TENDANCES_PERIODS as $p => $info): ?>
<a href="/tendances?period=<?= rawurlencode($p) ?>"
class="btn btn-sm <?= $p === $period ? 'btn-primary' : 'btn-outline-secondary' ?>">
<?= htmlspecialchars($info['short']) ?>
</a>
<?php endforeach; ?>
</div>
<!-- Top articles -->
<?php if (empty($topItems)): ?>
<p class="text-muted">Aucune donnée disponible pour cette période.</p>
<?php else: ?>
<h2 class="h5 mb-3">Top articles <?= htmlspecialchars($label) ?></h2>
<ol class="list-unstyled">
<?php foreach ($topItems as $i => ['article' => $a, 'visitors' => $v]): ?>
<li class="d-flex align-items-baseline gap-3 py-2 border-bottom">
<span class="text-muted" style="min-width:1.5rem;font-variant-numeric:tabular-nums"><?= $i + 1 ?></span>
<div class="flex-grow-1 overflow-hidden">
<a href="<?= htmlspecialchars($base . '/post/' . rawurlencode($a['slug'])) ?>"
class="text-decoration-none fw-medium text-truncate d-block">
<?= htmlspecialchars($a['title'] ?? '') ?>
</a>
<?php if (!empty($a['category'])): ?>
<span class="badge bg-secondary fw-normal small"><?= htmlspecialchars($a['category']) ?></span>
<?php endif; ?>
</div>
<span class="text-muted small text-nowrap"><?= number_format($v, 0, ',', "\u{202F}") ?> vis.</span>
</li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
<!-- Flux RSS -->
<div class="card mt-5">
<div class="card-header bg-transparent py-2 small fw-semibold">Flux RSS disponibles</div>
<div class="card-body py-2">
<p class="small text-muted mb-3">
Chaque flux retourne les 50 articles les plus consultés pour la période choisie,
mis à jour automatiquement. Abonnez-vous à celui qui correspond à vos besoins.
</p>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 small">
<thead class="table-light">
<tr>
<th>Période</th>
<th>Cache</th>
<th>URL</th>
</tr>
</thead>
<tbody>
<?php
$cacheTtlLabels = [
'10m' => '2 min', '20m' => '4 min', '30m' => '6 min',
'1h' => '12 min', '8h' => '96 min', '1d' => '5 h',
'7d' => '8 h', '14d' => '8 h', '30d' => '8 h',
'1y' => '8 h',
];
foreach (TENDANCES_PERIODS as $p => $info):
$url = $base . '/trending?period=' . rawurlencode($p);
?>
<tr>
<td><?= htmlspecialchars($info['label']) ?></td>
<td class="text-muted"><?= $cacheTtlLabels[$p] ?></td>
<td>
<a href="<?= htmlspecialchars($url) ?>" class="font-monospace text-decoration-none small">
/trending?period=<?= htmlspecialchars($p) ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Méthodologie -->
<div class="card mt-4">
<div class="card-header bg-transparent py-2 small fw-semibold">Méthodologie</div>
<div class="card-body small text-muted">
<ul class="mb-0 ps-3">
<li>Source : journaux d'accès Apache (<code>access.log</code> et rotations <code>.gz</code>).</li>
<li>Seules les requêtes <code>GET</code> sur <code>/post/*</code> avec code <code>HTTP 200</code> sont comptabilisées.</li>
<li>Un visiteur = une adresse IP distincte par article sur la fenêtre temporelle.</li>
<li>Les IPs ne sont ni stockées ni transmises ; seuls les compteurs agrégés sont conservés en cache.</li>
<li>Les articles dans des catégories privées et les avant-premières ne figurent pas dans les résultats.</li>
</ul>
</div>
</div>
</div>
<?php
$content = ob_get_clean();
http_response_code(200);
header('Cache-Control: public, max-age=' . $cacheTtl);
$templateVars = [
'title' => $pageTitle,
'content' => $content,
'mainClass' => '',
];
extract($templateVars);
require BASE_PATH . '/templates/layout.php';
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/ArticleManager.php';
require_once BASE_PATH . '/src/TrendingParser.php';
// ── Périodes supportées ───────────────────────────────────────────────────────
const TRENDING_PERIODS = [
'10m' => ['seconds' => 600, 'label' => '10 dernières minutes'],
'20m' => ['seconds' => 1200, 'label' => '20 dernières minutes'],
'30m' => ['seconds' => 1800, 'label' => '30 dernières minutes'],
'1h' => ['seconds' => 3600, 'label' => 'dernière heure'],
'8h' => ['seconds' => 28800, 'label' => '8 dernières heures'],
'1d' => ['seconds' => 86400, 'label' => '24 dernières heures'],
'7d' => ['seconds' => 604800, 'label' => '7 derniers jours'],
'14d' => ['seconds' => 1209600, 'label' => '14 derniers jours'],
'30d' => ['seconds' => 2592000, 'label' => '30 derniers jours'],
'1y' => ['seconds' => 31536000, 'label' => 'dernière année'],
];
$period = $_GET['period'] ?? '1d';
if (!array_key_exists($period, TRENDING_PERIODS)) {
http_response_code(400);
header('Content-Type: text/plain; charset=UTF-8');
echo 'Période invalide. Valeurs acceptées : ' . implode(', ', array_keys(TRENDING_PERIODS));
exit;
}
$seconds = TRENDING_PERIODS[$period]['seconds'];
$label = TRENDING_PERIODS[$period]['label'];
$cutoff = time() - $seconds;
$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
// ── Cache ─────────────────────────────────────────────────────────────────────
@mkdir(DATA_PATH . '/_cache', 0755, true);
$cacheFile = DATA_PATH . '/_cache/trending_' . $period . '.json';
$topPaths = null;
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
$topPaths = json_decode((string) file_get_contents($cacheFile), true) ?: null;
}
if ($topPaths === null) {
$parser = new TrendingParser('/var/log/apache2', apacheAccessLog());
$topPaths = $parser->top($cutoff, 50);
@file_put_contents($cacheFile, json_encode($topPaths));
}
// ── Index slug → article (publiés, non privés) ────────────────────────────────
$articleManager = new ArticleManager(DATA_PATH);
$now = time();
$privateCats = $articleManager->getPrivateCategories();
$slugIndex = [];
foreach ($articleManager->getAll(publishedOnly: true) as $a) {
if (strtotime((string) ($a['published_at'] ?? '')) > $now) {
continue;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true)) {
continue;
}
$slugIndex[$a['slug']] = $a;
}
// ── Construction des items ────────────────────────────────────────────────────
$base = rtrim(APP_URL, '/');
$items = [];
foreach ($topPaths as $path => $visitors) {
if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
continue;
}
$slug = rawurldecode($m[1]);
$article = $slugIndex[$slug] ?? null;
if ($article === null) {
continue;
}
$items[] = ['article' => $article, 'visitors' => (int) $visitors];
if (count($items) >= 50) {
break;
}
}
// ── Réponse RSS ───────────────────────────────────────────────────────────────
header('Content-Type: application/rss+xml; charset=UTF-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: public, max-age=' . $cacheTtl);
$feedTitle = htmlspecialchars(siteTitle() . ' — Tendances (' . $label . ')', ENT_XML1);
$feedUrl = htmlspecialchars($base . '/trending?period=' . rawurlencode($period), ENT_XML1);
$baseXml = htmlspecialchars($base, ENT_XML1);
$buildDate = htmlspecialchars(date(DATE_RSS));
$descXml = htmlspecialchars('Top 50 articles par visiteurs uniques — ' . $label, ENT_XML1);
$langXml = htmlspecialchars(siteLang(), ENT_XML1);
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title><?= $feedTitle ?></title>
<link><?= $baseXml ?></link>
<description><?= $descXml ?></description>
<language><?= $langXml ?></language>
<lastBuildDate><?= $buildDate ?></lastBuildDate>
<atom:link href="<?= $feedUrl ?>" rel="self" type="application/rss+xml"/>
<?php foreach ($items as ['article' => $a, 'visitors' => $v]):
$link = htmlspecialchars($base . '/post/' . rawurlencode($a['slug']), ENT_XML1);
$pubDate = htmlspecialchars(date(DATE_RSS, (int) strtotime((string) ($a['published_at'] ?? $a['created_at'] ?? ''))));
$title = htmlspecialchars(($a['title'] ?? ''), ENT_XML1);
$plural = $v > 1 ? 's' : '';
$desc = htmlspecialchars($title . ' — ' . $v . ' visiteur' . $plural . ' unique' . $plural . ' (' . $label . ')', ENT_XML1);
?>
<item>
<title><?= $title ?> (<?= $v ?> visiteur<?= $plural ?>)</title>
<link><?= $link ?></link>
<description><?= $desc ?></description>
<pubDate><?= $pubDate ?></pubDate>
<guid isPermaLink="true"><?= $link ?></guid>
</item>
<?php endforeach; ?>
</channel>
</rss>
+1 -1
View File
@@ -1 +1 @@
1.4.0 1.6.12
+12 -3
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pousse le code Folio vers git.abonnel.fr/cedricAbonnel/folio # Pousse la branche courante vers git.abonnel.fr/cedricAbonnel/folio
# Ne pousse JAMAIS directement sur main — passer par une PR.
# Usage : ./scripts/push.sh "message de commit" # Usage : ./scripts/push.sh "message de commit"
set -euo pipefail set -euo pipefail
@@ -20,6 +21,13 @@ if [ ! -d .git ]; then
echo "→ Dépôt git initialisé" echo "→ Dépôt git initialisé"
fi fi
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$BRANCH" == "main" ]]; then
echo "✗ Refus de pousser directement sur main."
echo " Travailler sur 'dev' ou une branche feature : git checkout dev"
exit 1
fi
# Extraire la version depuis CHANGELOG.md (première entrée ## [X.Y.Z]) # Extraire la version depuis CHANGELOG.md (première entrée ## [X.Y.Z])
FOLIO_VERSION=$(grep -m1 '^\#\# \[[0-9]' CHANGELOG.md | sed 's/.*\[\([^]]*\)\].*/\1/') FOLIO_VERSION=$(grep -m1 '^\#\# \[[0-9]' CHANGELOG.md | sed 's/.*\[\([^]]*\)\].*/\1/')
if [[ -z "$FOLIO_VERSION" ]]; then if [[ -z "$FOLIO_VERSION" ]]; then
@@ -32,5 +40,6 @@ echo "→ Version : $FOLIO_VERSION"
git add -A git add -A
git diff --cached --quiet && echo "(rien à committer)" && exit 0 git diff --cached --quiet && echo "(rien à committer)" && exit 0
git commit -m "$MSG" git commit -m "$MSG"
git push origin main git push origin "$BRANCH"
echo "✓ Poussé vers folio" echo "✓ Poussé vers folio (branche $BRANCH)"
echo " → Ouvrir une PR sur https://git.abonnel.fr/cedricAbonnel/folio/pulls/new/$BRANCH"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# folio-upgrade.sh — déploie Folio à la demande (appelé par PHP via sudo).
#
# Usage : folio-upgrade.sh <branche>
#
# Installation sur chaque serveur :
# sudo install -o root -m 750 folio-upgrade.sh /usr/local/bin/folio-upgrade.sh
#
# Autorisation sudo (sans mot de passe) :
# echo "www-data ALL=(root) NOPASSWD: /usr/local/bin/folio-upgrade.sh" \
# | sudo tee /etc/sudoers.d/folio-upgrade
#
# ── Configuration (à adapter par site) ───────────────────────────────────────
APP_DIR=/var/www/lan.acegrp.abonnel-www
REPO_URL=https://git.abonnel.fr/cedricAbonnel/folio.git
# ──────────────────────────────────────────────────────────────────────────────
BRANCH=${1:-main}
ENV_FILE="$APP_DIR/.env"
[ -f "$ENV_FILE" ] || { echo "ERREUR : $ENV_FILE introuvable"; exit 1; }
DATA_DIR=$(grep -m1 '^DATA_PATH=' "$ENV_FILE" | cut -d= -f2- | tr -d '"'"'" | xargs)
[ -n "$DATA_DIR" ] || { echo "ERREUR : DATA_PATH absent du .env"; exit 1; }
LOG="$DATA_DIR/.upgrade-log"
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
{
echo "=== $(date '+%Y-%m-%d %H:%M:%S') — démarrage ==="
echo "Branche : $BRANCH"
echo ""
# 1. Sauvegarder .env avant de toucher APP_DIR
cp "$ENV_FILE" "$WORK_DIR/.env.bak" || { echo "ERREUR : sauvegarde .env impossible"; exit 1; }
# 2. Cloner dans un répertoire de travail (APP_DIR reste intact en cas d'échec du clone)
git clone --depth=1 --branch "$BRANCH" "$REPO_URL" "$WORK_DIR/app" \
|| { echo "ERREUR : git clone"; exit 1; }
# 3. Déployer
rm -rf "$APP_DIR"
mv "$WORK_DIR/app" "$APP_DIR"
# 4. Permissions (PHP-FPM tourne en www-data)
chown -R www-data:www-data "$APP_DIR"
chmod -R g+rwX,o= "$APP_DIR"
# 5. Restaurer .env
cp "$WORK_DIR/.env.bak" "$APP_DIR/.env"
chown www-data:www-data "$APP_DIR/.env"
chmod 640 "$APP_DIR/.env"
cd "$APP_DIR"
# 6. Dépendances Composer
if command -v composer > /dev/null 2>&1; then
sudo -u www-data composer install --no-dev --optimize-autoloader \
|| echo "AVERTISSEMENT : composer install a échoué"
else
echo "AVERTISSEMENT : composer introuvable — dépendances non installées"
fi
# 7. Migrations SQL
sudo -u www-data php database/migrate.php \
|| echo "AVERTISSEMENT : migrations SQL — vérifier manuellement"
# 8. Répertoire de sessions PHP
mkdir -p "$APP_DIR/.sessions"
chown www-data:www-data "$APP_DIR/.sessions"
chmod 700 "$APP_DIR/.sessions"
# 9. Autoriser git pour ce répertoire (accès multi-utilisateurs)
git config --system --add safe.directory "$APP_DIR" 2>/dev/null || true
echo ""
echo "=== $(date '+%Y-%m-%d %H:%M:%S') — succès ==="
} > "$LOG" 2>&1
+20 -10
View File
@@ -105,8 +105,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,
@@ -155,7 +155,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);
@@ -482,7 +482,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) {
@@ -806,10 +806,10 @@ class ArticleManager
$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;
@@ -823,9 +823,13 @@ class ArticleManager
@unlink($this->slugIndexPath()); @unlink($this->slugIndexPath());
$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;
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -1110,7 +1114,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';
@@ -1370,13 +1374,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);
} }
} }
+193
View File
@@ -0,0 +1,193 @@
<?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);
}
}
}
+9
View File
@@ -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`.
+64 -35
View File
@@ -106,6 +106,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) {
@@ -126,8 +127,12 @@ function adminStatusBadge(array $a, int $now): string
<tr> <tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Dernière version disponible</th> <th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Dernière version disponible</th>
<td class="d-flex align-items-center gap-2 flex-wrap"> <td class="d-flex align-items-center gap-2 flex-wrap">
<span><?= htmlspecialchars($_remoteLabel) ?><?= $_remoteLabel !== '—' && $_remoteLabel !== $_deployedLabel ? ' <span class="badge bg-warning text-dark ms-1">Mise à jour disponible</span>' : '' ?></span> <span><?= htmlspecialchars($_remoteLabel) ?></span>
<?php if ($_repoConfigured): ?> <?php if ($_remoteLabel !== '—' && $_remoteLabel !== $_deployedLabel): ?>
<form method="POST" action="/?action=run_engine_update" class="d-inline">
<button type="submit" class="btn btn-primary btn-sm">Mettre à jour vers v<?= htmlspecialchars($_remoteLabel) ?></button>
</form>
<?php elseif ($_repoConfigured): ?>
<form method="POST" action="/?action=force_update_check" class="d-inline"> <form method="POST" action="/?action=force_update_check" class="d-inline">
<button type="submit" class="btn btn-outline-secondary btn-sm py-0">Vérifier</button> <button type="submit" class="btn btn-outline-secondary btn-sm py-0">Vérifier</button>
</form> </form>
@@ -140,25 +145,30 @@ function adminStatusBadge(array $a, int $now): string
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Branche suivie</th> <th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Branche suivie</th>
<td><code><?= htmlspecialchars($_branch) ?></code><?= $_lastChecked !== null ? ' <span class="text-muted ms-2">· vérifié le ' . date('d/m/Y à H:i', $_lastChecked) . '</span>' : '' ?></td> <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 (!empty($_notices)): ?> <?php if (($_GET['notice'] ?? '') === 'engine_updated'): ?>
<tr> <tr><td colspan="2"><div class="alert alert-success py-1 mb-0 small">Moteur mis à jour avec succès.</div></td></tr>
<th class="text-muted fw-normal ps-0 pe-2 align-top">Actions requises</th> <?php elseif (($_GET['notice'] ?? '') === 'upgrade_error'): ?>
<td class="d-flex flex-wrap gap-2 align-items-center"> <tr><td colspan="2">
<?php foreach ($_notices as $_n): ?> <div class="alert alert-danger py-1 mb-0 small">
<?php if ($_n['type'] === 'warning'): ?> Erreur lors de la mise à jour.
<form method="POST" action="/?action=run_content_migrations"> <?php if (!empty($_SESSION['_upgrade_log'])): ?>
<button type="submit" class="btn btn-warning btn-sm">Mettre à jour le contenu</button> <pre class="mt-1 mb-0 small"><?= htmlspecialchars($_SESSION['_upgrade_log']) ?></pre>
</form> <?php unset($_SESSION['_upgrade_log']); ?>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?> </div>
</td></tr>
<?php endif; ?>
<?php if ($_upgradeLog !== null): ?>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap align-top">Journal</th>
<td>
<details>
<summary class="small text-muted" style="cursor:pointer">Dernière mise à jour</summary>
<pre class="mt-1 mb-0 small"><?= htmlspecialchars($_upgradeLog) ?></pre>
</details>
</td> </td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php if (($_GET['notice'] ?? '') === 'migrated'): ?>
<tr><td colspan="2"><div class="alert alert-success py-1 mb-0 small">Migrations appliquées avec succès.</div></td></tr>
<?php elseif (($_GET['notice'] ?? '') === 'migration_error'): ?>
<tr><td colspan="2"><div class="alert alert-danger py-1 mb-0 small">Une erreur est survenue pendant la migration.</div></td></tr>
<?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -195,8 +205,30 @@ 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'] ?? '',
], 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">
@@ -254,8 +286,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 +295,21 @@ 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>
<a href="<?= htmlspecialchars($_mkSortUrl('published')) ?>"
class="text-decoration-none text-reset">
Date<?= $_sortIcon('published') ?>
</a>
</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -1263,7 +1305,7 @@ 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)"> <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,19 +1336,6 @@ 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>
+7 -50
View File
@@ -2,7 +2,6 @@
$_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'] ?? []; $_asGroups = $adminData['stats_as_groups'] ?? [];
@@ -23,51 +22,19 @@ $_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">14 derniers jours · visiteurs uniques · flux RSS XML</p>
<div class="row g-4"> <div class="row g-4">
<!-- Pages --> <!-- Pages (chargées via le flux RSS XML /trending?period=14d) -->
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card h-100"> <div class="card h-100">
<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>
</td>
<td class="text-end fw-semibold pe-3"><?= number_format($hits, 0, ',', '\u{202F}') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div> </div>
</div> </div>
</div> </div>
@@ -106,7 +73,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>
@@ -228,14 +195,4 @@ $_activeGroup = trim($_GET['group'] ?? '');
</div> </div>
</template> </template>
<script> <script src="/assets/js/admin-stats.js" defer></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
View File
@@ -50,6 +50,7 @@
</head> </head>
<body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>> <body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>>
<script src="/assets/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">
+14 -13
View File
@@ -88,9 +88,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 +153,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)): ?>
@@ -302,6 +295,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();
+6
View File
@@ -173,6 +173,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>
+1 -1
View File
@@ -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">
+1 -3
View File
@@ -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>