Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
||||||
|
|||||||
@@ -9,6 +9,74 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
# 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
|
||||||
|
cp /var/www/lan.acegrp.abonnel-www/.env /tmp/.env.bak
|
||||||
|
sudo rm -rf /var/www/lan.acegrp.abonnel-www
|
||||||
|
git clone --depth=1 https://git.abonnel.fr/cedricAbonnel/folio.git /var/www/lan.acegrp.abonnel-www
|
||||||
|
cp /tmp/.env.bak /var/www/lan.acegrp.abonnel-www/.env
|
||||||
|
cd /var/www/lan.acegrp.abonnel-www && composer install --no-dev --optimize-autoloader
|
||||||
|
php database/migrate.php
|
||||||
|
git -C /var/www/lan.acegrp.abonnel-www config user.email 'cedric@abonnel.fr'
|
||||||
|
git -C /var/www/lan.acegrp.abonnel-www config user.name 'Cédrix'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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]
|
||||||
|
|||||||
+117
-39
@@ -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);
|
||||||
|
|
||||||
@@ -2549,19 +2549,38 @@ 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;
|
$grouped = $tParser->topGrouped($cutoff14, ['/post/' => 30, '/book/' => 20]);
|
||||||
$adminData['stats_as_groups'] = AsnLookup::applyGroups($asList, asGroups());
|
|
||||||
|
// IPs pour le lookup ASN (AccessLogParser conserve le comptage brut par IP)
|
||||||
|
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
|
||||||
|
$topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
|
||||||
|
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
||||||
|
|
||||||
|
$statsRaw = [
|
||||||
|
'readable' => $tParser->isReadable(),
|
||||||
|
'pages' => $grouped['/post/'],
|
||||||
|
'books' => $grouped['/book/'],
|
||||||
|
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
|
||||||
|
];
|
||||||
|
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
||||||
|
}
|
||||||
|
$adminData['stats_readable'] = $statsRaw['readable'];
|
||||||
|
$adminData['stats_pages'] = $statsRaw['pages'];
|
||||||
|
$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 +2835,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 +3451,74 @@ 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 — logs Apache (visiteurs uniques, cache 12 min)
|
||||||
|
require_once BASE_PATH . '/src/TrendingParser.php';
|
||||||
|
$_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 ($_trendPaths === null) {
|
||||||
|
$_tp = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||||
|
if ($_tp->isReadable()) {
|
||||||
|
$_trendPaths = $_tp->top(time() - 3600, 20);
|
||||||
|
@mkdir(DATA_PATH . '/_cache', 0755, true);
|
||||||
|
@file_put_contents($_trendCache, json_encode($_trendPaths));
|
||||||
|
}
|
||||||
|
unset($_tp);
|
||||||
|
}
|
||||||
|
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,201 @@
|
|||||||
|
<?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';
|
||||||
|
|
||||||
|
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)));
|
||||||
|
|
||||||
|
// Cache partagé avec trending.php
|
||||||
|
@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(time() - $seconds, 50);
|
||||||
|
@file_put_contents($cacheFile, json_encode($topPaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.4
|
||||||
|
|||||||
+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
|
||||||
@@ -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`.
|
||||||
|
|||||||
+34
-24
@@ -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>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ $_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 · cache 60 s</p>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
|||||||
<div class="progress-bar" style="width:<?= $pct ?>%"></div>
|
<div class="progress-bar" 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>
|
||||||
@@ -106,7 +106,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>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
|
|||||||
<?php if (!empty($popularPosts)): ?>
|
<?php if (!empty($popularPosts)): ?>
|
||||||
<section class="home-section">
|
<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>
|
Tendances <span class="home-section-title-sub">· 1 heure</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="post-grid">
|
<div class="post-grid">
|
||||||
<?php foreach ($popularPosts as $_pp): ?>
|
<?php foreach ($popularPosts as $_pp): ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user