Compare commits
3 Commits
556c2cfea9
...
5cea473d17
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cea473d17 | |||
| 1d05138329 | |||
| ee2b8a4ac7 |
@@ -9,6 +9,15 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
||||
|
||||
---
|
||||
|
||||
## [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é
|
||||
|
||||
@@ -133,12 +133,20 @@ Ou créer directement `$DATA_PATH/site_settings.json` :
|
||||
|
||||
## Mise à jour
|
||||
|
||||
### Manuelle
|
||||
|
||||
```bash
|
||||
git pull
|
||||
composer install --no-dev
|
||||
php database/migrate.php
|
||||
```
|
||||
|
||||
### Via le bouton admin ("Mettre à jour")
|
||||
|
||||
L'interface d'administration propose un bouton **Mettre à jour** qui déclenche un déploiement complet via `sudo /usr/local/bin/folio-upgrade.sh`. Une configuration sudoers est requise une fois par serveur.
|
||||
|
||||
→ Voir **[docs/deployment.md](docs/deployment.md)** pour la procédure complète.
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
+4
-9
@@ -2559,25 +2559,20 @@ switch ($action) {
|
||||
$statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: null;
|
||||
}
|
||||
if ($statsRaw === null) {
|
||||
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
|
||||
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||
$grouped = $tParser->topGrouped($cutoff14, ['/post/' => 30, '/book/' => 20]);
|
||||
|
||||
// IPs pour le lookup ASN (AccessLogParser conserve le comptage brut par IP)
|
||||
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
|
||||
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||
$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/'],
|
||||
'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_pages'] = $statsRaw['pages'];
|
||||
$adminData['stats_books'] = $statsRaw['books'];
|
||||
$adminData['stats_as'] = $statsRaw['as'];
|
||||
$adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.6.5
|
||||
1.6.6
|
||||
|
||||
+51
-38
@@ -2,7 +2,6 @@
|
||||
$_statsSaved = isset($_GET['saved']);
|
||||
$_statsError = ($_GET['error'] ?? '') === 'write';
|
||||
$_readable = $adminData['stats_readable'] ?? false;
|
||||
$_pages = $adminData['stats_pages'] ?? [];
|
||||
$_books = $adminData['stats_books'] ?? [];
|
||||
$_asList = $adminData['stats_as'] ?? [];
|
||||
$_asGroups = $adminData['stats_as_groups'] ?? [];
|
||||
@@ -23,51 +22,19 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
||||
<p class="text-muted small mb-4">14 derniers jours · visiteurs uniques · cache 60 s</p>
|
||||
<p class="text-muted small mb-4">14 derniers jours · visiteurs uniques · flux RSS XML</p>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Pages -->
|
||||
<!-- Pages (chargées via le flux RSS XML /trending?period=14d) -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
|
||||
<span>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 class="card-body p-0">
|
||||
<?php if (empty($_pages)): ?>
|
||||
<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}') ?> <span class="text-muted fw-normal">vis.</span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="card-body p-0" id="stats-pages-container">
|
||||
<p class="text-muted p-3 mb-0">Chargement…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,4 +205,50 @@ document.getElementById('as-groups-list').addEventListener('click', e => {
|
||||
e.target.closest('.as-group-row').remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Chargement des pages via le flux 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>';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
+47
-9
@@ -155,19 +155,57 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* ─── Tendances ───────────────────────────────────────────────────── */ ?>
|
||||
<?php if (!empty($popularPosts)): ?>
|
||||
<section class="home-section">
|
||||
<?php /* ─── Meilleures audiences (AJAX — flux RSS XML /trending?period=1h) ── */ ?>
|
||||
<section class="home-section" id="home-audiences-section" hidden>
|
||||
<h2 class="home-section-title">
|
||||
Meilleures audiences <span class="home-section-title-sub">· 1 heure</span>
|
||||
</h2>
|
||||
<div class="post-grid">
|
||||
<?php foreach ($popularPosts as $_pp): ?>
|
||||
<?php _renderCard($_pp, $privateCats ?? [], $allCats ?? [], $Parsedown); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="post-grid" id="home-audiences-grid"></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<script>
|
||||
(function(){
|
||||
var _g=[
|
||||
'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 _e(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;
|
||||
var grid=document.getElementById('home-audiences-grid');
|
||||
if(!grid)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(e){}
|
||||
var grad=_g[i%_g.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="'+_e(link)+'">'+_e(title)+'</a></h2>'
|
||||
+'<div class="post-entry-meta mt-auto">'
|
||||
+(date?'<span>'+_e(date)+'</span>':'')
|
||||
+'<a href="'+_e(link)+'" class="post-entry-read">→ lire</a>'
|
||||
+'</div></div>'
|
||||
+'<a href="'+_e(link)+'" class="stretched-link"></a>'
|
||||
+'</article>';
|
||||
}).join('');
|
||||
var s=document.getElementById('home-audiences-section');
|
||||
if(s)s.hidden=false;
|
||||
})
|
||||
.catch(function(){});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php /* ─── Récemment mis à jour ──────────────────────────────────────── */ ?>
|
||||
<?php if (!empty($recentlyUpdated)): ?>
|
||||
|
||||
Reference in New Issue
Block a user