Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d329872404 | |||
| 88cc67d945 | |||
| 8a42dfe981 | |||
| 6092cf940d | |||
| 5b16fb465b | |||
| 5203b2c514 | |||
| 996ab3e508 | |||
| 8af2c8e20b | |||
| 04a7713286 | |||
| 3ddfc1dcf3 | |||
| fa00f61ee0 | |||
| 8889110133 | |||
| 3e856dc476 | |||
| 58a110d5b9 | |||
| 5e88d44129 | |||
| a55e22f1f4 | |||
| 5cea473d17 | |||
| 1d05138329 | |||
| ee2b8a4ac7 | |||
| 556c2cfea9 | |||
| e19d20ca17 | |||
| d0b486f11c | |||
| 18b7194069 | |||
| 21f6e75878 | |||
| 2a60790006 | |||
| 3647289f86 | |||
| ea950f2c25 | |||
| af0a0bb9d5 | |||
| 797937340a | |||
| d5bba5e6e5 | |||
| 53dbce5bb0 | |||
| 4e262ddde8 | |||
| 7737edf402 | |||
| 6d159e7dda | |||
| ebf0e2df65 | |||
| 331e9c9ecd | |||
| 0280ef3ca1 | |||
| eddde2165a | |||
| 07d004b3f0 | |||
| 5cb0e854fd | |||
| 8f6c17f0f2 | |||
| 5452fb4927 |
@@ -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
@@ -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é
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"uuid": "a3d8f2c1-7b4e-4f9a-8c3d-2e5a9b6f1d4c",
|
|
||||||
"slug": "about",
|
|
||||||
"title": "À propos",
|
|
||||||
"author": "cedric@abonnel.fr",
|
|
||||||
"published": true,
|
|
||||||
"published_at": "2021-01-16 04:02:40",
|
|
||||||
"created_at": "2021-01-16 04:02:40",
|
|
||||||
"updated_at": "2026-05-13 00:00:00",
|
|
||||||
"revisions": [],
|
|
||||||
"cover": "",
|
|
||||||
"files_meta": [],
|
|
||||||
"external_links": [],
|
|
||||||
"seo_title": "",
|
|
||||||
"seo_description": "",
|
|
||||||
"og_image": "",
|
|
||||||
"category": ""
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# À propos
|
|
||||||
|
|
||||||
Qui se cache derrière varlog ?
|
|
||||||
|
|
||||||
Je m'appelle **Cédric**. Passionné d'informatique depuis longtemps, je gère un **HomeLab** à la maison — un petit laboratoire personnel où je fais tourner des serveurs, expérimente des configs réseau et casse des choses pour mieux les comprendre.
|
|
||||||
|
|
||||||
varlog est mon carnet de bord technique. J'y documente ce que je fais, ce que j'apprends, et parfois ce qui tourne mal — les incidents sont souvent les meilleures leçons.
|
|
||||||
|
|
||||||
Le blog a été lancé publiquement aux **JDLL 2025** (Journées Du Logiciel Libre), à Lyon.
|
|
||||||
|
|
||||||
## Ce dont je parle ici
|
|
||||||
|
|
||||||
### HomeLab & infrastructure
|
|
||||||
|
|
||||||
Proxmox, virtualisation, domotique (Zigbee, MQTT, Home Assistant), supervision avec Uptime Kuma, auto-hébergement de services (Gitea, Keycloak…), incidents réseau et leurs post-mortems.
|
|
||||||
|
|
||||||
### Réseaux & télécom
|
|
||||||
|
|
||||||
Passionné par les réseaux mobiles (3G/4G/5G/6G), la fibre optique (50G-PON), les stratégies des opérateurs et les infrastructures qui font fonctionner tout ça sans qu'on y pense.
|
|
||||||
|
|
||||||
### Linux & développement
|
|
||||||
|
|
||||||
Debian au quotidien, scripts, administration système, et un peu de PHP — dont ce blog lui-même, développé maison sous le nom de code *Folio*.
|
|
||||||
|
|
||||||
### Numérique & société
|
|
||||||
|
|
||||||
Souveraineté numérique, données personnelles, IA et plateformes qui monétisent nos contenus — des sujets qui m'intéressent autant qu'ils m'inquiètent.
|
|
||||||
|
|
||||||
### Le reste
|
|
||||||
|
|
||||||
Bricolage, travaux, anecdotes techniques, lectures, liseuses Kobo, et quelques billets qui n'entrent dans aucune case. La vie ne se range pas en catégories.
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
|
|
||||||
Vous pouvez me joindre via le [formulaire de contact](/contact). Je lis tous les messages, même si je ne réponds pas toujours vite.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Le contenu de ce blog est publié sous licence [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) sauf mention contraire. Le moteur *Folio* est distribué sous [licence MIT](/LICENSE).
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"uuid": "b2c7e1f4-4a3d-4e8b-9f2a-1d6c8e3f5a7b",
|
|
||||||
"slug": "legal",
|
|
||||||
"title": "Mentions légales",
|
|
||||||
"author": "cedric@abonnel.fr",
|
|
||||||
"published": true,
|
|
||||||
"published_at": "2021-01-16 04:02:40",
|
|
||||||
"created_at": "2021-01-16 04:02:40",
|
|
||||||
"updated_at": "2026-05-13 00:00:00",
|
|
||||||
"revisions": [],
|
|
||||||
"cover": "",
|
|
||||||
"files_meta": [],
|
|
||||||
"external_links": [],
|
|
||||||
"seo_title": "",
|
|
||||||
"seo_description": "",
|
|
||||||
"og_image": "",
|
|
||||||
"category": ""
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Mentions légales
|
|
||||||
|
|
||||||
Conformément à la loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économie numérique (LCEN).
|
|
||||||
|
|
||||||
## Éditeur du site
|
|
||||||
|
|
||||||
**Responsable de publication :** Cédric Abonnel
|
|
||||||
**Qualité :** Particulier — site personnel non commercial
|
|
||||||
**Contact :** [formulaire de contact](/contact)
|
|
||||||
|
|
||||||
## Hébergement
|
|
||||||
|
|
||||||
**Type :** Auto-hébergement sur infrastructure personnelle (HomeLab)
|
|
||||||
**Exploitant :** Cédric Abonnel
|
|
||||||
**Fournisseur d'accès à internet :** Infrastructure personnelle auto-hébergée
|
|
||||||
|
|
||||||
## Propriété intellectuelle
|
|
||||||
|
|
||||||
Le **contenu éditorial** de ce site (articles, textes, images produites par l'auteur) est publié sous licence [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/), sauf mention contraire.
|
|
||||||
|
|
||||||
Le **moteur du site** (*Folio*) est un logiciel libre distribué sous [licence MIT](/LICENSE).
|
|
||||||
|
|
||||||
Les composants tiers (Bootstrap, PHPMailer, police Inter…) sont soumis à leurs licences respectives, détaillées sur la [page des licences](/licenses).
|
|
||||||
|
|
||||||
## Données personnelles (RGPD)
|
|
||||||
|
|
||||||
Ce site est un blog personnel **sans publicité, sans pistage, sans système de commentaires** ni inscription publique.
|
|
||||||
|
|
||||||
Les seules données traitées automatiquement sont les **journaux de connexion du serveur web** (adresse IP, horodatage, page demandée), conservés conformément aux obligations légales (article L34-1 du Code des postes et des communications électroniques — durée maximale : 1 an).
|
|
||||||
|
|
||||||
Ces données ne sont ni vendues, ni transmises à des tiers, ni utilisées à des fins commerciales.
|
|
||||||
|
|
||||||
Conformément au RGPD (règlement UE 2016/679), vous disposez d'un droit d'accès, de rectification et de suppression des données vous concernant. Pour exercer ces droits : [formulaire de contact](/contact).
|
|
||||||
|
|
||||||
## Cookies
|
|
||||||
|
|
||||||
Ce site utilise uniquement un **cookie de session technique**, nécessaire au fonctionnement de l'authentification. Il n'est déposé que lors d'une connexion au compte d'administration et n'est pas utilisé à des fins de suivi ou de profilage. Aucun cookie tiers n'est déposé.
|
|
||||||
|
|
||||||
## Responsabilité
|
|
||||||
|
|
||||||
L'éditeur s'efforce de maintenir les informations publiées à jour et exactes, mais ne peut garantir l'exhaustivité ou l'absence d'erreurs du contenu.
|
|
||||||
|
|
||||||
Les liens vers des sites tiers sont fournis à titre informatif. L'éditeur n'est pas responsable du contenu de ces sites externes.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"uuid": "fdff8ad3-d369-4bd7-bbb9-e14d433868d7",
|
|
||||||
"slug": "licenses",
|
|
||||||
"title": "Licences",
|
|
||||||
"author": "cedric@abonnel.fr",
|
|
||||||
"published": true,
|
|
||||||
"published_at": "2021-01-16 04:02:40",
|
|
||||||
"created_at": "2021-01-16 04:02:40",
|
|
||||||
"updated_at": "2021-01-16 04:02:40",
|
|
||||||
"revisions": [],
|
|
||||||
"cover": "",
|
|
||||||
"files_meta": [],
|
|
||||||
"external_links": [],
|
|
||||||
"seo_title": "",
|
|
||||||
"seo_description": "",
|
|
||||||
"og_image": "",
|
|
||||||
"category": ""
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Licences
|
|
||||||
|
|
||||||
Composants logiciels utilisés par ce site et leurs licences.
|
|
||||||
|
|
||||||
## Ce site
|
|
||||||
|
|
||||||
| Composant | Licence | Usage |
|
|
||||||
|-----------|---------|-------|
|
|
||||||
| **Folio** — moteur de blog PHP | MIT | Moteur de ce blog — par Cédric Abonnel ([voir la licence](/LICENSE)) |
|
|
||||||
| **Contenu éditorial** | CC BY 4.0 | Articles et textes du blog — [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) |
|
|
||||||
|
|
||||||
## Bibliothèques (production)
|
|
||||||
|
|
||||||
| Composant | Version | Licence | Usage |
|
|
||||||
|-----------|---------|---------|-------|
|
|
||||||
| **Bootstrap** | 5.3.3 | MIT | Framework CSS/JS — auto-hébergé ([voir la licence](/assets/css/LICENSE-Bootstrap.txt)) |
|
|
||||||
| **PHPMailer** | 6.12.0 | LGPL-2.1 | Envoi d'e-mails SMTP |
|
|
||||||
| **phpdotenv** | 5.6.2 | BSD-3-Clause | Variables d'environnement |
|
|
||||||
| **openid-connect-php** | 1.0.2 | Apache-2.0 | Authentification SSO (OIDC) |
|
|
||||||
| **Police Inter** | v20 | OFL-1.1 | Typographie — auto-hébergée ([voir la licence](/assets/fonts/LICENSE-Inter.txt)) |
|
|
||||||
|
|
||||||
## Outils de développement
|
|
||||||
|
|
||||||
| Composant | Version | Licence | Usage |
|
|
||||||
|-----------|---------|---------|-------|
|
|
||||||
| **PHPStan** | 1.12.32 | MIT | Analyse statique PHP |
|
|
||||||
| **PHP-CS-Fixer** | 3.89.1 | MIT | Formatage du code |
|
|
||||||
| **Claude Code CLI** | — | Commercial | Outil de développement (Anthropic) — [Conditions d'utilisation](https://www.anthropic.com/legal/aup) |
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
| Composant | Licence | Usage |
|
|
||||||
|-----------|---------|-------|
|
|
||||||
| **PHP 8.3** | PHP License v3.01 | Langage côté serveur |
|
|
||||||
| **PostgreSQL** | PostgreSQL License | Base de données relationnelle |
|
|
||||||
| **Apache HTTP Server** | Apache-2.0 | Serveur web |
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Déploiement et mise à jour
|
||||||
|
|
||||||
|
## Mise à jour via le bouton admin
|
||||||
|
|
||||||
|
L'interface d'administration propose un bouton **Mettre à jour** (onglet Dashboard). Il appelle `sudo /usr/local/bin/folio-upgrade.sh` depuis PHP (`www-data`) et exécute la séquence complète :
|
||||||
|
|
||||||
|
1. Sauvegarde du `.env`
|
||||||
|
2. `git clone --depth=1` dans un répertoire temporaire
|
||||||
|
3. Remplacement atomique du répertoire applicatif
|
||||||
|
4. `chown -R www-data:www-data` + `chmod g+rwX,o=`
|
||||||
|
5. Restauration du `.env`
|
||||||
|
6. `composer install --no-dev --optimize-autoloader`
|
||||||
|
7. `php database/migrate.php` (migrations SQL)
|
||||||
|
8. Création de `.sessions/` avec les bons droits
|
||||||
|
9. `git config --system --add safe.directory`
|
||||||
|
|
||||||
|
### Pré-requis serveur (à faire une fois)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installer le script (copié depuis le dépôt)
|
||||||
|
sudo install -o root -m 750 /var/www/mon-site/scripts/server/folio-upgrade.sh \
|
||||||
|
/usr/local/bin/folio-upgrade.sh
|
||||||
|
|
||||||
|
# 2. Adapter APP_DIR et REPO_URL en tête du script
|
||||||
|
sudo nano /usr/local/bin/folio-upgrade.sh
|
||||||
|
|
||||||
|
# 3. Créer la règle sudoers (www-data sans mot de passe)
|
||||||
|
echo "www-data ALL=(root) NOPASSWD: /usr/local/bin/folio-upgrade.sh" \
|
||||||
|
| sudo tee /etc/sudoers.d/folio-upgrade
|
||||||
|
sudo chmod 440 /etc/sudoers.d/folio-upgrade
|
||||||
|
|
||||||
|
# 4. Vérifier la syntaxe sudoers
|
||||||
|
sudo visudo -c
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables à configurer dans le script :
|
||||||
|
|
||||||
|
| Variable | Exemple |
|
||||||
|
|---|---|
|
||||||
|
| `APP_DIR` | `/var/www/lan.acegrp.abonnel-www` |
|
||||||
|
| `REPO_URL` | `https://git.abonnel.fr/cedricAbonnel/folio.git` |
|
||||||
|
|
||||||
|
> **Sans cette configuration**, le bouton retourne :
|
||||||
|
> `sudo: a terminal is required to read the password`
|
||||||
|
|
||||||
|
### Fonctionnement du cache de mise à jour
|
||||||
|
|
||||||
|
Le résultat de la dernière mise à jour est conservé dans `DATA_PATH/.upgrade-log` et affiché en `<details>` dans l'admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mise à jour manuelle
|
||||||
|
|
||||||
|
Si le bouton admin n'est pas configuré ou si une mise à jour d'urgence est nécessaire :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sauvegarde du .env
|
||||||
|
cp /var/www/mon-site/.env /tmp/.env.bak
|
||||||
|
|
||||||
|
# Clone fresh
|
||||||
|
sudo rm -rf /var/www/mon-site
|
||||||
|
sudo git clone --depth=1 https://git.abonnel.fr/cedricAbonnel/folio.git /var/www/mon-site
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
sudo chown -R www-data:www-data /var/www/mon-site
|
||||||
|
sudo chmod -R g+rwX,o= /var/www/mon-site
|
||||||
|
|
||||||
|
# Restaurer .env
|
||||||
|
cp /tmp/.env.bak /var/www/mon-site/.env
|
||||||
|
|
||||||
|
# Dépendances et migrations
|
||||||
|
cd /var/www/mon-site
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
php database/migrate.php
|
||||||
|
|
||||||
|
# Répertoire de sessions
|
||||||
|
sudo mkdir -p /var/www/mon-site/.sessions
|
||||||
|
sudo chown www-data:www-data /var/www/mon-site/.sessions
|
||||||
|
sudo chmod 700 /var/www/mon-site/.sessions
|
||||||
|
|
||||||
|
# Autoriser git (accès multi-utilisateurs)
|
||||||
|
sudo git config --system --add safe.directory /var/www/mon-site
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flux RSS des tendances (`/trending`)
|
||||||
|
|
||||||
|
Le flux RSS des articles les plus consultés est alimenté par `TrendingParser` qui lit les logs Apache.
|
||||||
|
|
||||||
|
- **Source** : `GET /trending?period=<période>` — parse les logs et écrit `DATA_PATH/_cache/trending_<période>.json`
|
||||||
|
- **Consommateurs** (lecture seule du cache) : page d'accueil (rubrique "Meilleures audiences") et `/tendances`
|
||||||
|
|
||||||
|
### Périodes disponibles
|
||||||
|
|
||||||
|
| Paramètre | Fenêtre | Cache TTL |
|
||||||
|
|---|---|---|
|
||||||
|
| `10m` | 10 min | 2 min |
|
||||||
|
| `20m` | 20 min | 4 min |
|
||||||
|
| `30m` | 30 min | 6 min |
|
||||||
|
| `1h` | 1 heure | 12 min |
|
||||||
|
| `8h` | 8 heures | 96 min |
|
||||||
|
| `1d` | 24 heures | 5 h |
|
||||||
|
| `7d` | 7 jours | 8 h |
|
||||||
|
| `14d` | 14 jours | 8 h |
|
||||||
|
| `30d` | 30 jours | 8 h |
|
||||||
|
| `1y` | 1 an | 8 h |
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
`www-data` doit appartenir au groupe `adm` pour lire `/var/log/apache2/` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG adm www-data
|
||||||
|
```
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>';
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -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 = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* Anti-FOUC densité — chargé tôt dans <head> pour appliquer max-width avant rendu de <main> */
|
||||||
|
(function () {
|
||||||
|
var d = localStorage.getItem('folio_density') || 'm';
|
||||||
|
if (d !== 'l') {
|
||||||
|
var mw = d === 'm' ? '980px' : '660px';
|
||||||
|
var s = document.createElement('style');
|
||||||
|
s.id = 'density-fouc';
|
||||||
|
s.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}';
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/* Sélecteur de densité L/M/S — persisté dans localStorage */
|
||||||
|
(function () {
|
||||||
|
var KEY = 'folio_density';
|
||||||
|
var cur = localStorage.getItem(KEY) || 'm';
|
||||||
|
|
||||||
|
function applyDensity(d) {
|
||||||
|
var fouc = document.getElementById('density-fouc');
|
||||||
|
if (d !== 'l') {
|
||||||
|
var mw = d === 'm' ? '980px' : '660px';
|
||||||
|
if (!fouc) {
|
||||||
|
fouc = document.createElement('style');
|
||||||
|
fouc.id = 'density-fouc';
|
||||||
|
document.head.appendChild(fouc);
|
||||||
|
}
|
||||||
|
fouc.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}';
|
||||||
|
} else {
|
||||||
|
if (fouc) { fouc.parentNode.removeChild(fouc); }
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.density-btn').forEach(function (btn) {
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-d') === d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDensity(cur);
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var el = e.target;
|
||||||
|
while (el && el !== document) {
|
||||||
|
if (el.classList && el.classList.contains('density-btn')) {
|
||||||
|
cur = el.getAttribute('data-d') || 'l';
|
||||||
|
try { localStorage.setItem(KEY, cur); } catch (ignore) {}
|
||||||
|
applyDensity(cur);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el = el.parentNode;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/* Chargement AJAX de la section "Meilleures audiences" via le flux RSS XML /trending?period=1h */
|
||||||
|
(function () {
|
||||||
|
var grid = document.getElementById('home-audiences-grid');
|
||||||
|
if (!grid) { return; }
|
||||||
|
|
||||||
|
var gradients = [
|
||||||
|
'linear-gradient(135deg,#667eea 0%,#764ba2 100%)',
|
||||||
|
'linear-gradient(135deg,#f093fb 0%,#f5576c 100%)',
|
||||||
|
'linear-gradient(135deg,#4facfe 0%,#00f2fe 100%)',
|
||||||
|
'linear-gradient(135deg,#43e97b 0%,#38f9d7 100%)',
|
||||||
|
'linear-gradient(135deg,#fa709a 0%,#fee140 100%)',
|
||||||
|
'linear-gradient(135deg,#a18cd1 0%,#fbc2eb 100%)'
|
||||||
|
];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/trending?period=1h')
|
||||||
|
.then(function (r) { return r.ok ? r.text() : Promise.reject(); })
|
||||||
|
.then(function (xml) {
|
||||||
|
var doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||||
|
var items = Array.from(doc.querySelectorAll('item')).slice(0, 6);
|
||||||
|
if (!items.length) { return; }
|
||||||
|
|
||||||
|
grid.innerHTML = items.map(function (item, i) {
|
||||||
|
var raw = (item.querySelector('title') || { textContent: '' }).textContent;
|
||||||
|
var title = raw.replace(/\s*\(\d+\s+visiteurs?\)$/, '');
|
||||||
|
var link = ((item.querySelector('link') || {}).textContent || '#').trim();
|
||||||
|
var pd = (item.querySelector('pubDate') || { textContent: '' }).textContent;
|
||||||
|
var date = '';
|
||||||
|
try { if (pd) { date = new Date(pd).toLocaleDateString('fr-FR'); } } catch (err) {}
|
||||||
|
var grad = gradients[i % gradients.length];
|
||||||
|
|
||||||
|
return '<article class="card">'
|
||||||
|
+ '<div class="card-cover" style="background:' + grad + '"></div>'
|
||||||
|
+ '<div class="card-body d-flex flex-column">'
|
||||||
|
+ '<h2 class="card-title"><a href="' + esc(link) + '">' + esc(title) + '</a></h2>'
|
||||||
|
+ '<div class="post-entry-meta mt-auto">'
|
||||||
|
+ (date ? '<span>' + esc(date) + '</span>' : '')
|
||||||
|
+ '<a href="' + esc(link) + '" class="post-entry-read">→ lire</a>'
|
||||||
|
+ '</div></div>'
|
||||||
|
+ '<a href="' + esc(link) + '" class="stretched-link"></a>'
|
||||||
|
+ '</article>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var section = document.getElementById('home-audiences-section');
|
||||||
|
if (section) { section.hidden = false; }
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}());
|
||||||
+140
-49
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
$adminData['articles'] = $allArticles;
|
$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['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';
|
||||||
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
|
|
||||||
$accessStats = $accessParser->stats();
|
$statsCacheFile = DATA_PATH . '/.stats_cache.json';
|
||||||
$adminData['stats_readable'] = $accessParser->isReadable();
|
$statsRaw = null;
|
||||||
$adminData['stats_pages'] = array_slice($accessStats['pages'], 0, 30, true);
|
if (file_exists($statsCacheFile) && (time() - filemtime($statsCacheFile)) < 60) {
|
||||||
$adminData['stats_books'] = array_slice($accessStats['books'], 0, 20, true);
|
$statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: null;
|
||||||
// Lookup AS pour les top 200 IPs
|
}
|
||||||
$topIps = array_slice($accessStats['ips'], 0, 200, true);
|
if ($statsRaw === null) {
|
||||||
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
|
||||||
$asList = AsnLookup::aggregateByAs($topIps, $asnMap);
|
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||||
$adminData['stats_as'] = $asList;
|
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
|
||||||
$adminData['stats_as_groups'] = AsnLookup::applyGroups($asList, asGroups());
|
$topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
|
||||||
|
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
||||||
|
|
||||||
|
$statsRaw = [
|
||||||
|
'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¬ice=' . ($_cmErrors ? 'migration_error' : 'migrated'));
|
header('Location: /admin?tab=dashboard¬ice=' . ($_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¬ice=upgrade_error');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: /admin?tab=dashboard¬ice=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,38 +3474,64 @@ 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) {
|
||||||
try {
|
if (empty($popularPosts)) {
|
||||||
$_stmt = $_pdo->query("
|
try {
|
||||||
SELECT article_uuid, SUM(score) AS total
|
$_stmt = $_pdo->query("
|
||||||
FROM (
|
SELECT article_uuid, SUM(score) AS total
|
||||||
SELECT article_uuid, 1 AS score FROM article_reactions
|
FROM (
|
||||||
WHERE created_at >= NOW() - INTERVAL '10 days'
|
SELECT article_uuid, 1 AS score FROM article_reactions
|
||||||
UNION ALL
|
WHERE created_at >= NOW() - INTERVAL '10 days'
|
||||||
SELECT article_uuid, 2 AS score FROM article_ratings
|
UNION ALL
|
||||||
WHERE rated_at >= NOW() - INTERVAL '10 days'
|
SELECT article_uuid, 2 AS score FROM article_ratings
|
||||||
UNION ALL
|
WHERE rated_at >= NOW() - INTERVAL '10 days'
|
||||||
SELECT article_uuid, 3 AS score FROM comments
|
UNION ALL
|
||||||
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
|
SELECT article_uuid, 3 AS score FROM comments
|
||||||
) ev
|
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
|
||||||
GROUP BY article_uuid
|
) ev
|
||||||
ORDER BY total DESC
|
GROUP BY article_uuid
|
||||||
LIMIT 20
|
ORDER BY total DESC
|
||||||
");
|
LIMIT 20
|
||||||
foreach ($_stmt->fetchAll(PDO::FETCH_ASSOC) as $_row) {
|
");
|
||||||
if (count($popularPosts) >= 6) {
|
foreach ($_stmt->fetchAll(PDO::FETCH_ASSOC) as $_row) {
|
||||||
break;
|
if (count($popularPosts) >= 6) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$_uuid = $_row['article_uuid'];
|
||||||
|
if (!isset($allPostsMap[$_uuid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$popularPosts[] = $allPostsMap[$_uuid];
|
||||||
}
|
}
|
||||||
$_uuid = $_row['article_uuid'];
|
} catch (Throwable) {
|
||||||
if (!isset($allPostsMap[$_uuid])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$popularPosts[] = $allPostsMap[$_uuid];
|
|
||||||
}
|
}
|
||||||
} catch (Throwable) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redécouvertes : anciens articles (> 30 j) avec activité récente
|
// Redécouvertes : anciens articles (> 30 j) avec activité récente
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||||||
|
|
||||||
|
require_once BASE_PATH . '/src/auth.php';
|
||||||
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
|
require_once BASE_PATH . '/config/config.php';
|
||||||
|
require_once BASE_PATH . '/src/ArticleManager.php';
|
||||||
|
|
||||||
|
const TENDANCES_PERIODS = [
|
||||||
|
'10m' => ['seconds' => 600, 'label' => '10 dernières minutes', 'short' => '10 min'],
|
||||||
|
'20m' => ['seconds' => 1200, 'label' => '20 dernières minutes', 'short' => '20 min'],
|
||||||
|
'30m' => ['seconds' => 1800, 'label' => '30 dernières minutes', 'short' => '30 min'],
|
||||||
|
'1h' => ['seconds' => 3600, 'label' => 'dernière heure', 'short' => '1 h'],
|
||||||
|
'8h' => ['seconds' => 28800, 'label' => '8 dernières heures', 'short' => '8 h'],
|
||||||
|
'1d' => ['seconds' => 86400, 'label' => '24 dernières heures', 'short' => '24 h'],
|
||||||
|
'7d' => ['seconds' => 604800, 'label' => '7 derniers jours', 'short' => '7 j'],
|
||||||
|
'14d' => ['seconds' => 1209600, 'label' => '14 derniers jours', 'short' => '14 j'],
|
||||||
|
'30d' => ['seconds' => 2592000, 'label' => '30 derniers jours', 'short' => '30 j'],
|
||||||
|
'1y' => ['seconds' => 31536000, 'label' => 'dernière année', 'short' => '1 an'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Période active (affichage du top)
|
||||||
|
$period = $_GET['period'] ?? '1d';
|
||||||
|
if (!array_key_exists($period, TENDANCES_PERIODS)) {
|
||||||
|
$period = '1d';
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = TENDANCES_PERIODS[$period]['seconds'];
|
||||||
|
$label = TENDANCES_PERIODS[$period]['label'];
|
||||||
|
$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
|
||||||
|
|
||||||
|
// Lecture seule du cache généré par /trending?period=…
|
||||||
|
$cacheFile = DATA_PATH . '/_cache/trending_' . $period . '.json';
|
||||||
|
$topPaths = null;
|
||||||
|
|
||||||
|
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
||||||
|
$topPaths = json_decode((string) file_get_contents($cacheFile), true) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index slug → article
|
||||||
|
$articleManager = new ArticleManager(DATA_PATH);
|
||||||
|
$now = time();
|
||||||
|
$privateCats = $articleManager->getPrivateCategories();
|
||||||
|
$slugIndex = [];
|
||||||
|
foreach ($articleManager->getAll(publishedOnly: true) as $a) {
|
||||||
|
if (strtotime((string) ($a['published_at'] ?? '')) > $now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cat = trim($a['category'] ?? '');
|
||||||
|
if ($cat !== '' && in_array($cat, $privateCats, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slugIndex[$a['slug']] = $a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top articles pour la période affichée
|
||||||
|
$topItems = [];
|
||||||
|
foreach ($topPaths as $path => $visitors) {
|
||||||
|
if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$article = $slugIndex[rawurldecode($m[1])] ?? null;
|
||||||
|
if ($article === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$topItems[] = ['article' => $article, 'visitors' => (int) $visitors];
|
||||||
|
if (count($topItems) >= 20) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = rtrim(APP_URL, '/');
|
||||||
|
$pageTitle = 'Tendances — ' . siteTitle();
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="container py-4" style="max-width:860px">
|
||||||
|
|
||||||
|
<h1 class="h3 mb-1">Tendances</h1>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Articles les plus consultés, calculés en temps réel depuis les journaux d'accès du serveur.
|
||||||
|
Seuls les visiteurs uniques (une IP = un visiteur) sur des réponses <code>200</code> sont comptabilisés.
|
||||||
|
Aucun cookie, aucun traceur tiers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Sélecteur de période -->
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||||
|
<?php foreach (TENDANCES_PERIODS as $p => $info): ?>
|
||||||
|
<a href="/tendances?period=<?= rawurlencode($p) ?>"
|
||||||
|
class="btn btn-sm <?= $p === $period ? 'btn-primary' : 'btn-outline-secondary' ?>">
|
||||||
|
<?= htmlspecialchars($info['short']) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top articles -->
|
||||||
|
<?php if (empty($topItems)): ?>
|
||||||
|
<p class="text-muted">Aucune donnée disponible pour cette période.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<h2 class="h5 mb-3">Top articles — <?= htmlspecialchars($label) ?></h2>
|
||||||
|
<ol class="list-unstyled">
|
||||||
|
<?php foreach ($topItems as $i => ['article' => $a, 'visitors' => $v]): ?>
|
||||||
|
<li class="d-flex align-items-baseline gap-3 py-2 border-bottom">
|
||||||
|
<span class="text-muted" style="min-width:1.5rem;font-variant-numeric:tabular-nums"><?= $i + 1 ?></span>
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<a href="<?= htmlspecialchars($base . '/post/' . rawurlencode($a['slug'])) ?>"
|
||||||
|
class="text-decoration-none fw-medium text-truncate d-block">
|
||||||
|
<?= htmlspecialchars($a['title'] ?? '') ?>
|
||||||
|
</a>
|
||||||
|
<?php if (!empty($a['category'])): ?>
|
||||||
|
<span class="badge bg-secondary fw-normal small"><?= htmlspecialchars($a['category']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted small text-nowrap"><?= number_format($v, 0, ',', "\u{202F}") ?> vis.</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ol>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Flux RSS -->
|
||||||
|
<div class="card mt-5">
|
||||||
|
<div class="card-header bg-transparent py-2 small fw-semibold">Flux RSS disponibles</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Chaque flux retourne les 50 articles les plus consultés pour la période choisie,
|
||||||
|
mis à jour automatiquement. Abonnez-vous à celui qui correspond à vos besoins.
|
||||||
|
</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0 small">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Période</th>
|
||||||
|
<th>Cache</th>
|
||||||
|
<th>URL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php
|
||||||
|
$cacheTtlLabels = [
|
||||||
|
'10m' => '2 min', '20m' => '4 min', '30m' => '6 min',
|
||||||
|
'1h' => '12 min', '8h' => '96 min', '1d' => '5 h',
|
||||||
|
'7d' => '8 h', '14d' => '8 h', '30d' => '8 h',
|
||||||
|
'1y' => '8 h',
|
||||||
|
];
|
||||||
|
foreach (TENDANCES_PERIODS as $p => $info):
|
||||||
|
$url = $base . '/trending?period=' . rawurlencode($p);
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars($info['label']) ?></td>
|
||||||
|
<td class="text-muted"><?= $cacheTtlLabels[$p] ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="<?= htmlspecialchars($url) ?>" class="font-monospace text-decoration-none small">
|
||||||
|
/trending?period=<?= htmlspecialchars($p) ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Méthodologie -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header bg-transparent py-2 small fw-semibold">Méthodologie</div>
|
||||||
|
<div class="card-body small text-muted">
|
||||||
|
<ul class="mb-0 ps-3">
|
||||||
|
<li>Source : journaux d'accès Apache (<code>access.log</code> et rotations <code>.gz</code>).</li>
|
||||||
|
<li>Seules les requêtes <code>GET</code> sur <code>/post/*</code> avec code <code>HTTP 200</code> sont comptabilisées.</li>
|
||||||
|
<li>Un visiteur = une adresse IP distincte par article sur la fenêtre temporelle.</li>
|
||||||
|
<li>Les IPs ne sont ni stockées ni transmises ; seuls les compteurs agrégés sont conservés en cache.</li>
|
||||||
|
<li>Les articles dans des catégories privées et les avant-premières ne figurent pas dans les résultats.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
$content = ob_get_clean();
|
||||||
|
|
||||||
|
http_response_code(200);
|
||||||
|
header('Cache-Control: public, max-age=' . $cacheTtl);
|
||||||
|
|
||||||
|
$templateVars = [
|
||||||
|
'title' => $pageTitle,
|
||||||
|
'content' => $content,
|
||||||
|
'mainClass' => '',
|
||||||
|
];
|
||||||
|
extract($templateVars);
|
||||||
|
require BASE_PATH . '/templates/layout.php';
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||||||
|
|
||||||
|
require_once BASE_PATH . '/src/auth.php';
|
||||||
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
|
require_once BASE_PATH . '/config/config.php';
|
||||||
|
require_once BASE_PATH . '/src/ArticleManager.php';
|
||||||
|
require_once BASE_PATH . '/src/TrendingParser.php';
|
||||||
|
|
||||||
|
// ── Périodes supportées ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TRENDING_PERIODS = [
|
||||||
|
'10m' => ['seconds' => 600, 'label' => '10 dernières minutes'],
|
||||||
|
'20m' => ['seconds' => 1200, 'label' => '20 dernières minutes'],
|
||||||
|
'30m' => ['seconds' => 1800, 'label' => '30 dernières minutes'],
|
||||||
|
'1h' => ['seconds' => 3600, 'label' => 'dernière heure'],
|
||||||
|
'8h' => ['seconds' => 28800, 'label' => '8 dernières heures'],
|
||||||
|
'1d' => ['seconds' => 86400, 'label' => '24 dernières heures'],
|
||||||
|
'7d' => ['seconds' => 604800, 'label' => '7 derniers jours'],
|
||||||
|
'14d' => ['seconds' => 1209600, 'label' => '14 derniers jours'],
|
||||||
|
'30d' => ['seconds' => 2592000, 'label' => '30 derniers jours'],
|
||||||
|
'1y' => ['seconds' => 31536000, 'label' => 'dernière année'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$period = $_GET['period'] ?? '1d';
|
||||||
|
|
||||||
|
if (!array_key_exists($period, TRENDING_PERIODS)) {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: text/plain; charset=UTF-8');
|
||||||
|
echo 'Période invalide. Valeurs acceptées : ' . implode(', ', array_keys(TRENDING_PERIODS));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = TRENDING_PERIODS[$period]['seconds'];
|
||||||
|
$label = TRENDING_PERIODS[$period]['label'];
|
||||||
|
$cutoff = time() - $seconds;
|
||||||
|
$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
|
||||||
|
|
||||||
|
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mkdir(DATA_PATH . '/_cache', 0755, true);
|
||||||
|
$cacheFile = DATA_PATH . '/_cache/trending_' . $period . '.json';
|
||||||
|
$topPaths = null;
|
||||||
|
|
||||||
|
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
||||||
|
$topPaths = json_decode((string) file_get_contents($cacheFile), true) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($topPaths === null) {
|
||||||
|
$parser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||||
|
$topPaths = $parser->top($cutoff, 50);
|
||||||
|
@file_put_contents($cacheFile, json_encode($topPaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Index slug → article (publiés, non privés) ────────────────────────────────
|
||||||
|
|
||||||
|
$articleManager = new ArticleManager(DATA_PATH);
|
||||||
|
$now = time();
|
||||||
|
$privateCats = $articleManager->getPrivateCategories();
|
||||||
|
$slugIndex = [];
|
||||||
|
|
||||||
|
foreach ($articleManager->getAll(publishedOnly: true) as $a) {
|
||||||
|
if (strtotime((string) ($a['published_at'] ?? '')) > $now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cat = trim($a['category'] ?? '');
|
||||||
|
if ($cat !== '' && in_array($cat, $privateCats, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slugIndex[$a['slug']] = $a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Construction des items ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$base = rtrim(APP_URL, '/');
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
foreach ($topPaths as $path => $visitors) {
|
||||||
|
if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slug = rawurldecode($m[1]);
|
||||||
|
$article = $slugIndex[$slug] ?? null;
|
||||||
|
if ($article === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = ['article' => $article, 'visitors' => (int) $visitors];
|
||||||
|
if (count($items) >= 50) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Réponse RSS ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
header('Content-Type: application/rss+xml; charset=UTF-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Cache-Control: public, max-age=' . $cacheTtl);
|
||||||
|
|
||||||
|
$feedTitle = htmlspecialchars(siteTitle() . ' — Tendances (' . $label . ')', ENT_XML1);
|
||||||
|
$feedUrl = htmlspecialchars($base . '/trending?period=' . rawurlencode($period), ENT_XML1);
|
||||||
|
$baseXml = htmlspecialchars($base, ENT_XML1);
|
||||||
|
$buildDate = htmlspecialchars(date(DATE_RSS));
|
||||||
|
$descXml = htmlspecialchars('Top 50 articles par visiteurs uniques — ' . $label, ENT_XML1);
|
||||||
|
$langXml = htmlspecialchars(siteLang(), ENT_XML1);
|
||||||
|
|
||||||
|
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||||
|
?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title><?= $feedTitle ?></title>
|
||||||
|
<link><?= $baseXml ?></link>
|
||||||
|
<description><?= $descXml ?></description>
|
||||||
|
<language><?= $langXml ?></language>
|
||||||
|
<lastBuildDate><?= $buildDate ?></lastBuildDate>
|
||||||
|
<atom:link href="<?= $feedUrl ?>" rel="self" type="application/rss+xml"/>
|
||||||
|
<?php foreach ($items as ['article' => $a, 'visitors' => $v]):
|
||||||
|
$link = htmlspecialchars($base . '/post/' . rawurlencode($a['slug']), ENT_XML1);
|
||||||
|
$pubDate = htmlspecialchars(date(DATE_RSS, (int) strtotime((string) ($a['published_at'] ?? $a['created_at'] ?? ''))));
|
||||||
|
$title = htmlspecialchars(($a['title'] ?? ''), ENT_XML1);
|
||||||
|
$plural = $v > 1 ? 's' : '';
|
||||||
|
$desc = htmlspecialchars($title . ' — ' . $v . ' visiteur' . $plural . ' unique' . $plural . ' (' . $label . ')', ENT_XML1);
|
||||||
|
?>
|
||||||
|
<item>
|
||||||
|
<title><?= $title ?> (<?= $v ?> visiteur<?= $plural ?>)</title>
|
||||||
|
<link><?= $link ?></link>
|
||||||
|
<description><?= $desc ?></description>
|
||||||
|
<pubDate><?= $pubDate ?></pubDate>
|
||||||
|
<guid isPermaLink="true"><?= $link ?></guid>
|
||||||
|
</item>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.4.0
|
1.6.12
|
||||||
|
|||||||
+12
-3
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
|
|||||||
+71
-42
@@ -103,9 +103,10 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<?php
|
<?php
|
||||||
$_deployedVer = trim((string) @file_get_contents(BASE_PATH . '/public/version.txt'));
|
$_deployedVer = trim((string) @file_get_contents(BASE_PATH . '/public/version.txt'));
|
||||||
$_deployedLabel = $_deployedVer !== '' ? $_deployedVer : '—';
|
$_deployedLabel = $_deployedVer !== '' ? $_deployedVer : '—';
|
||||||
$_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,13 +127,17 @@ 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=force_update_check" class="d-inline">
|
<form method="POST" action="/?action=run_engine_update" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm py-0">Vérifier</button>
|
<button type="submit" class="btn btn-primary btn-sm">Mettre à jour vers v<?= htmlspecialchars($_remoteLabel) ?></button>
|
||||||
</form>
|
</form>
|
||||||
|
<?php elseif ($_repoConfigured): ?>
|
||||||
|
<form method="POST" action="/?action=force_update_check" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm py-0">Vérifier</button>
|
||||||
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="text-muted small">(<code>FOLIO_REPO_URL</code> non configuré)</span>
|
<span class="text-muted small">(<code>FOLIO_REPO_URL</code> non configuré)</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -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><td colspan="2"><div class="alert alert-success py-1 mb-0 small">Moteur mis à jour avec succès.</div></td></tr>
|
||||||
|
<?php elseif (($_GET['notice'] ?? '') === 'upgrade_error'): ?>
|
||||||
|
<tr><td colspan="2">
|
||||||
|
<div class="alert alert-danger py-1 mb-0 small">
|
||||||
|
Erreur lors de la mise à jour.
|
||||||
|
<?php if (!empty($_SESSION['_upgrade_log'])): ?>
|
||||||
|
<pre class="mt-1 mb-0 small"><?= htmlspecialchars($_SESSION['_upgrade_log']) ?></pre>
|
||||||
|
<?php unset($_SESSION['_upgrade_log']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($_upgradeLog !== null): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-muted fw-normal ps-0 pe-2 align-top">Actions requises</th>
|
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap align-top">Journal</th>
|
||||||
<td class="d-flex flex-wrap gap-2 align-items-center">
|
<td>
|
||||||
<?php foreach ($_notices as $_n): ?>
|
<details>
|
||||||
<?php if ($_n['type'] === 'warning'): ?>
|
<summary class="small text-muted" style="cursor:pointer">Dernière mise à jour</summary>
|
||||||
<form method="POST" action="/?action=run_content_migrations">
|
<pre class="mt-1 mb-0 small"><?= htmlspecialchars($_upgradeLog) ?></pre>
|
||||||
<button type="submit" class="btn btn-warning btn-sm">Mettre à jour le contenu</button>
|
</details>
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ $_catVal = trim($category ?? '');
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($mode === 'create' && !empty($existingFiles ?? [])): ?>
|
<?php if (!empty($existingFiles ?? [])): ?>
|
||||||
<?php $_imgFiles = array_filter($existingFiles, fn ($_f) => $_f['is_image']); ?>
|
<?php $_imgFiles = array_filter($existingFiles, fn ($_f) => $_f['is_image']); ?>
|
||||||
<?php if ($_imgFiles): ?>
|
<?php if ($_imgFiles): ?>
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
|
|||||||
@@ -2,19 +2,17 @@
|
|||||||
// Attendu (edit only) : $uuid, $step, $totalSteps, $mode='edit', $article (original),
|
// Attendu (edit only) : $uuid, $step, $totalSteps, $mode='edit', $article (original),
|
||||||
// $draftData, $diffLines, $changes, $autoRevisionComment,
|
// $draftData, $diffLines, $changes, $autoRevisionComment,
|
||||||
// $seoTitle, $seoDescription, $autoSeoDesc, $title (draft), $postSlug,
|
// $seoTitle, $seoDescription, $autoSeoDesc, $title (draft), $postSlug,
|
||||||
// $titleChanged, $autoSlug, $published, $published_at, $category
|
// $titleChanged, $published, $published_at, $category
|
||||||
ob_start();
|
ob_start();
|
||||||
$_CONTEXT = 3;
|
$_CONTEXT = 3;
|
||||||
$_backUrl = '/edit/' . rawurlencode($uuid) . '/5';
|
$_backUrl = '/edit/' . rawurlencode($uuid) . '/5';
|
||||||
$_formAction = '/edit/' . rawurlencode($uuid) . '/6';
|
$_formAction = '/edit/' . rawurlencode($uuid) . '/6';
|
||||||
$_slugFinal = ($titleChanged && $autoSlug !== $postSlug) ? $autoSlug : $postSlug;
|
|
||||||
?>
|
?>
|
||||||
<?php include __DIR__ . '/nav.php'; ?>
|
<?php include __DIR__ . '/nav.php'; ?>
|
||||||
|
|
||||||
<!-- En-tête : titre + boutons à droite ─────────────────────────────────── -->
|
<!-- En-tête : titre + boutons à droite ─────────────────────────────────── -->
|
||||||
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
|
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
|
||||||
<input type="hidden" name="_confirm" value="1">
|
<input type="hidden" name="_confirm" value="1">
|
||||||
<input type="hidden" name="slug" value="<?= htmlspecialchars($_slugFinal) ?>">
|
|
||||||
|
|
||||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-4 flex-wrap">
|
<div class="d-flex align-items-start justify-content-between gap-3 mb-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user