diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ba47f..ae53c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,31 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.8] - 2026-05-15 + +### Corrigé +- Tous les scripts inline déplacés vers des fichiers JS statiques (`density-fouc.js`, `density.js`, `trending-home.js`, `admin-stats.js`) — conformité CSP `script-src 'self'` (varlog) +- `onclick` / `onchange` inline dans `admin.php` migrés vers `admin.js` +- Densité M (980 px) définie comme valeur par défaut au lieu de L (pleine largeur) + +--- + +## [1.6.7] - 2026-05-15 + +### Ajouté +- Sélecteur de densité L / M / S sur la page liste : pleine largeur (défaut), normal (980 px), compact (660 px) — préférence persistée dans `localStorage` + +--- + +## [1.6.6] - 2026-05-15 + +### Modifié +- Page d'accueil "Meilleures audiences" : chargement AJAX depuis le flux RSS XML `/trending?period=1h` (DOMParser côté client, plus de rendu PHP) +- `/admin/stats` section "Pages les plus visitées" : chargement AJAX depuis le flux RSS XML `/trending?period=14d` — plus de parsing de logs direct pour cette colonne +- `/admin/stats` : suppression de `topGrouped` pour les pages ; seuls les livres (`/book/`) et l'ASN conservent le parsing log côté serveur + +--- + ## [1.6.5] - 2026-05-15 ### Modifié diff --git a/README.md b/README.md index 4154806..9519714 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..2a5a333 --- /dev/null +++ b/docs/deployment.md @@ -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 `
` 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=` — parse les logs et écrit `DATA_PATH/_cache/trending_.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 +``` diff --git a/public/assets/css/style.css b/public/assets/css/style.css index cbcd4dc..cd62a6c 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -1266,6 +1266,40 @@ footer.mt-5 { margin-top: 0 !important; } letter-spacing: .01em; } +/* ─── Densité d'affichage L / M / S ──────── */ +main { transition: max-width .22s ease; } + +/* Widget fixe haut-droite */ +.density-widget { + position: fixed; + top: 3.6rem; + right: 1rem; + z-index: 1010; + display: flex; + gap: 2px; + background: var(--vl-surface); + border: 1px solid var(--vl-border); + border-radius: 6px; + padding: 3px; + box-shadow: var(--vl-shadow-sm); +} +.density-btn { + background: none; + border: 1px solid transparent; + border-radius: 4px; + color: var(--vl-muted); + cursor: pointer; + font-size: .68rem; + font-weight: 700; + letter-spacing: .06em; + line-height: 1; + padding: 4px 8px; + transition: background .15s, color .15s, border-color .15s; +} +.density-btn:hover { background: rgba(0,0,0,.06); color: var(--vl-text); } +.density-btn.active { background: var(--vl-text); color: var(--vl-bg); } +@media (max-width: 576px) { .density-widget { display: none; } } + /* ─── Page de recherche ───────────────────── */ .search-page { max-width: 780px; margin: 0 auto; } diff --git a/public/assets/js/admin-stats.js b/public/assets/js/admin-stats.js new file mode 100644 index 0000000..588c8e8 --- /dev/null +++ b/public/assets/js/admin-stats.js @@ -0,0 +1,68 @@ +/* Admin stats : groupes AS + chargement pages via flux RSS XML /trending?period=14d */ + +// ── Groupes de réseaux ──────────────────────────────────────────────────────── +(function () { + var addBtn = document.getElementById('as-group-add'); + if (!addBtn) { return; } + + addBtn.addEventListener('click', function () { + var tpl = document.getElementById('as-group-tpl').content.cloneNode(true); + document.getElementById('as-groups-list').appendChild(tpl); + }); + + document.getElementById('as-groups-list').addEventListener('click', function (e) { + if (e.target.classList.contains('as-group-delete')) { + e.target.closest('.as-group-row').remove(); + } + }); +}()); + +// ── Pages les plus visitées (RSS XML) ──────────────────────────────────────── +(function () { + var container = document.getElementById('stats-pages-container'); + var badge = document.getElementById('stats-pages-count'); + if (!container) { return; } + + function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + 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 = '

Aucune donnée.

'; + 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 = '
'; + rows.forEach(function (row, i) { + var pct = Math.round(row.vis / maxV * 100); + var vis = row.vis.toLocaleString('fr-FR'); + html += '' + + '' + + '' + + '' + + ''; + }); + html += '
' + (i + 1) + '' + + esc(row.title || row.slug) + '' + + '
' + vis + ' vis.
'; + if (badge) { badge.textContent = rows.length + ' URLs'; } + container.innerHTML = html; + }) + .catch(function () { + container.innerHTML = '

Impossible de charger le flux.

'; + }); +}()); diff --git a/public/assets/js/admin.js b/public/assets/js/admin.js index 12d6f90..33b859f 100644 --- a/public/assets/js/admin.js +++ b/public/assets/js/admin.js @@ -45,4 +45,28 @@ document.addEventListener('DOMContentLoaded', function () { } }); } + + // Suppression groupée avec confirmation (remplace onclick inline) + var bulkDeleteBtn = document.getElementById('bulk-delete-btn'); + if (bulkDeleteBtn) { + bulkDeleteBtn.addEventListener('click', function (e) { + var checked = document.querySelectorAll('.bulk-check:checked').length; + if (checked === 0) { e.preventDefault(); return; } + var msg = bulkDeleteBtn.getAttribute('data-confirm-bulk') || 'Confirmer ?'; + if (!window.confirm(msg)) { e.preventDefault(); } + }); + } + + // Ajout d'un article à un livre (remplace onchange="bookAddArticle(this)") + var bookArticleSel = document.getElementById('book-article-select'); + if (bookArticleSel) { + bookArticleSel.addEventListener('change', function () { + var slug = bookArticleSel.value; + if (!slug) { return; } + var ta = document.getElementById('book-articles-ta'); + var lines = ta.value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean); + if (lines.indexOf(slug) === -1) { lines.push(slug); ta.value = lines.join('\n'); } + bookArticleSel.value = ''; + }); + } }); diff --git a/public/assets/js/density-fouc.js b/public/assets/js/density-fouc.js new file mode 100644 index 0000000..eba4ecf --- /dev/null +++ b/public/assets/js/density-fouc.js @@ -0,0 +1,11 @@ +/* Anti-FOUC densité — chargé tôt dans pour appliquer max-width avant rendu de
*/ +(function () { + var d = localStorage.getItem('folio_density') || 'm'; + if (d !== 'l') { + var mw = d === 'm' ? '980px' : '660px'; + var s = document.createElement('style'); + s.id = 'density-fouc'; + s.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}'; + document.head.appendChild(s); + } +}()); diff --git a/public/assets/js/density.js b/public/assets/js/density.js new file mode 100644 index 0000000..09b0666 --- /dev/null +++ b/public/assets/js/density.js @@ -0,0 +1,38 @@ +/* Sélecteur de densité L/M/S — persisté dans localStorage */ +(function () { + var KEY = 'folio_density'; + var cur = localStorage.getItem(KEY) || 'm'; + + function applyDensity(d) { + var fouc = document.getElementById('density-fouc'); + if (d !== 'l') { + var mw = d === 'm' ? '980px' : '660px'; + if (!fouc) { + fouc = document.createElement('style'); + fouc.id = 'density-fouc'; + document.head.appendChild(fouc); + } + fouc.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}'; + } else { + if (fouc) { fouc.parentNode.removeChild(fouc); } + } + document.querySelectorAll('.density-btn').forEach(function (btn) { + btn.classList.toggle('active', btn.getAttribute('data-d') === d); + }); + } + + applyDensity(cur); + + document.addEventListener('click', function (e) { + var el = e.target; + while (el && el !== document) { + if (el.classList && el.classList.contains('density-btn')) { + cur = el.getAttribute('data-d') || 'l'; + try { localStorage.setItem(KEY, cur); } catch (ignore) {} + applyDensity(cur); + return; + } + el = el.parentNode; + } + }); +}()); diff --git a/public/assets/js/trending-home.js b/public/assets/js/trending-home.js new file mode 100644 index 0000000..6c37e4a --- /dev/null +++ b/public/assets/js/trending-home.js @@ -0,0 +1,51 @@ +/* Chargement AJAX de la section "Meilleures audiences" via le flux RSS XML /trending?period=1h */ +(function () { + var grid = document.getElementById('home-audiences-grid'); + if (!grid) { return; } + + var gradients = [ + 'linear-gradient(135deg,#667eea 0%,#764ba2 100%)', + 'linear-gradient(135deg,#f093fb 0%,#f5576c 100%)', + 'linear-gradient(135deg,#4facfe 0%,#00f2fe 100%)', + 'linear-gradient(135deg,#43e97b 0%,#38f9d7 100%)', + 'linear-gradient(135deg,#fa709a 0%,#fee140 100%)', + 'linear-gradient(135deg,#a18cd1 0%,#fbc2eb 100%)' + ]; + + function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + fetch('/trending?period=1h') + .then(function (r) { return r.ok ? r.text() : Promise.reject(); }) + .then(function (xml) { + var doc = new DOMParser().parseFromString(xml, 'application/xml'); + var items = Array.from(doc.querySelectorAll('item')).slice(0, 6); + if (!items.length) { return; } + + grid.innerHTML = items.map(function (item, i) { + var raw = (item.querySelector('title') || { textContent: '' }).textContent; + var title = raw.replace(/\s*\(\d+\s+visiteurs?\)$/, ''); + var link = ((item.querySelector('link') || {}).textContent || '#').trim(); + var pd = (item.querySelector('pubDate') || { textContent: '' }).textContent; + var date = ''; + try { if (pd) { date = new Date(pd).toLocaleDateString('fr-FR'); } } catch (err) {} + var grad = gradients[i % gradients.length]; + + return ''; + }).join(''); + + var section = document.getElementById('home-audiences-section'); + if (section) { section.hidden = false; } + }) + .catch(function () {}); +}()); diff --git a/public/index.php b/public/index.php index 41e71df..183f740 100644 --- a/public/index.php +++ b/public/index.php @@ -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()); diff --git a/public/version.txt b/public/version.txt index 9f05f9f..d8c5e72 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.5 +1.6.8 diff --git a/templates/admin.php b/templates/admin.php index 5e531bf..8affe38 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -264,8 +264,8 @@ function adminStatusBadge(array $a, int $now): string - @@ -1273,7 +1273,7 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
- $_rgb): -
Nouveau livre
diff --git a/templates/admin_stats.php b/templates/admin_stats.php index cfaf90b..60174bf 100644 --- a/templates/admin_stats.php +++ b/templates/admin_stats.php @@ -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'] ?? '');
-

14 derniers jours · visiteurs uniques · cache 60 s

+

14 derniers jours · visiteurs uniques · flux RSS XML

- +
Pages les plus visitées - URLs +
-
- -

Aucune donnée.

- -
- - - $hits): - $rankP++; - $slug = rawurldecode(substr($url, 6)); - $pct = round($hits / $maxP * 100); - ?> - - - - - - - -
- - - -
-
-
-
vis.
-
- +
+

Chargement…

@@ -228,14 +195,4 @@ $_activeGroup = trim($_GET['group'] ?? '');
- + diff --git a/templates/layout.php b/templates/layout.php index bc71e05..c96bf79 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -50,6 +50,7 @@ class=""> +
@@ -155,19 +153,14 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown - - -
+ + - + @@ -302,6 +295,14 @@ if (!empty($_tagCats)): + +
+ + + +
+ + +