diff --git a/.env.example b/.env.example
index 75db2a2..55820d9 100644
--- a/.env.example
+++ b/.env.example
@@ -39,3 +39,7 @@ SMTP_FROM_NAME=
# Formulaire de contact
CONTACT_EMAIL=
CONTACT_FROM_EMAIL=
+
+# Logs Apache (onglet Recherches dans /admin)
+# Nom du fichier de log d'accès du vhost dans /var/log/apache2/
+APACHE_ACCESS_LOG=lan.acegrp.varlog-access.log
diff --git a/public/index.php b/public/index.php
index 1a0bdcf..092b3b9 100644
--- a/public/index.php
+++ b/public/index.php
@@ -23,8 +23,10 @@ require_once BASE_PATH . '/src/helpers.php';
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/src/ArticleManager.php';
+require_once BASE_PATH . '/src/BookManager.php';
$articles = new ArticleManager(BASE_PATH . '/data');
+$books = new BookManager(BASE_PATH . '/data/books');
// ─── Mode maintenance ──────────────────────────────────────────────────────
if (file_exists(BASE_PATH . '/data/.maintenance')) {
@@ -41,7 +43,7 @@ $action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? '';
$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'];
+$_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'];
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
unset($_noindexActions);
@@ -825,6 +827,13 @@ switch ($action) {
$comments = $commentMgr->forArticle($article['uuid']);
}
+ // Contexte livre (navigation précédent/suivant si l'article fait partie d'un livre)
+ $bookContext = $books->findForArticle($article['slug'] ?? '');
+ if ($bookContext !== null) {
+ $bookContext['prev_article'] = $bookContext['prev'] !== null ? $articles->getBySlug($bookContext['prev']) : null;
+ $bookContext['next_article'] = $bookContext['next'] !== null ? $articles->getBySlug($bookContext['next']) : null;
+ }
+
include BASE_PATH . '/templates/post_view.php';
break;
@@ -2293,6 +2302,7 @@ switch ($action) {
$tab = $_GET['tab'] ?? (isAdmin() ? 'dashboard' : 'articles');
$adminData = [];
$siteSettingsSaved = isset($_GET['saved']);
+ $siteSettingsError = ($_GET['error'] ?? '') === 'write';
if ($tab === 'dashboard') {
if (!isAdmin()) {
@@ -2527,7 +2537,7 @@ switch ($action) {
exit;
}
require_once BASE_PATH . '/src/SearchLogParser.php';
- $parser = new SearchLogParser();
+ $parser = new SearchLogParser('/var/log/apache2', apacheAccessLog());
$adminData['search_terms'] = $parser->topTerms(100);
$adminData['search_log_readable'] = $parser->isReadable();
}
@@ -2538,6 +2548,21 @@ switch ($action) {
$adminData['tagTypes'] = $articles->getTagTypes();
}
+ if ($tab === 'books') {
+ if (!isAdmin()) {
+ http_response_code(403);
+ exit;
+ }
+ $adminData['books'] = $books->getAll();
+ $adminData['edit_book'] = null;
+ $adminData['all_articles'] = $articles->getAll();
+ usort($adminData['all_articles'], static fn ($a, $b) => strcmp($a['title'] ?? '', $b['title'] ?? ''));
+ $editBookSlug = trim($_GET['edit'] ?? '');
+ if ($editBookSlug !== '') {
+ $adminData['edit_book'] = $books->getBySlug($editBookSlug);
+ }
+ }
+
include BASE_PATH . '/templates/admin.php';
break;
@@ -2549,7 +2574,7 @@ switch ($action) {
}
require_once BASE_PATH . '/src/SmtpSettings.php';
- saveSmtpSettings([
+ $ok = saveSmtpSettings([
'host' => $_POST['smtp_host'] ?? '',
'port' => $_POST['smtp_port'] ?? '',
'secure' => $_POST['smtp_secure'] ?? '',
@@ -2558,7 +2583,7 @@ switch ($action) {
'from' => $_POST['smtp_from'] ?? '',
'from_name' => $_POST['smtp_from_name'] ?? '',
]);
- header('Location: /admin/smtp?saved=1');
+ header('Location: /admin/smtp?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_smtp_test':
@@ -2774,7 +2799,7 @@ switch ($action) {
http_response_code(403);
exit;
}
- saveSiteSettings([
+ $ok = saveSiteSettings([
'site_title' => $_POST['site_title'] ?? '',
'site_claim' => $_POST['site_claim'] ?? '',
'site_lang' => $_POST['site_lang'] ?? '',
@@ -2782,7 +2807,17 @@ switch ($action) {
'site_license_label' => $_POST['site_license_label'] ?? '',
'site_license_url' => $_POST['site_license_url'] ?? '',
]);
- header('Location: /admin/site?saved=1');
+ header('Location: /admin/site?' . ($ok ? 'saved=1' : 'error=write'));
+ exit;
+
+ case 'admin_save_searches_config':
+ requireAuth();
+ if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(403);
+ exit;
+ }
+ $ok = saveSiteSettings(['apache_access_log' => $_POST['apache_access_log'] ?? '']);
+ header('Location: /admin/searches?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_create_role':
@@ -3119,6 +3154,69 @@ switch ($action) {
include BASE_PATH . '/templates/search.php';
break;
+ case 'book':
+ $bookSlug = trim($_GET['book_slug'] ?? '');
+ $book = $books->getBySlug($bookSlug);
+ if (!$book) {
+ http_response_code(404);
+ ob_start();
+ ?>
+
+
Livre introuvable
+
Ce livre n'existe pas ou a été supprimé.
+
← Retour à l'accueil
+
+ getBySlug($aSlug);
+ if (!$a) {
+ continue;
+ }
+ if (!$a['published'] && !canDoOnArticle('view_drafts', $a)) {
+ continue;
+ }
+ $bookArticles[] = $a;
+ }
+ $allCats = $articles->getCategories();
+ include BASE_PATH . '/templates/book.php';
+ break;
+
+ case 'book_save':
+ requireAuth();
+ if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(403);
+ exit;
+ }
+ $bSlug = trim($_POST['slug'] ?? '');
+ $bTitle = trim($_POST['title'] ?? '');
+ $bDesc = trim($_POST['description'] ?? '');
+ $bArts = array_values(array_filter(array_map('trim', preg_split('/[\r\n]+/', $_POST['articles'] ?? ''))));
+ if ($bSlug !== '' && $bTitle !== '') {
+ $books->save(['slug' => $bSlug, 'title' => $bTitle, 'description' => $bDesc, 'articles' => $bArts]);
+ }
+ header('Location: /admin/books?saved=1&edit=' . rawurlencode($bSlug));
+ exit;
+
+ case 'book_delete':
+ requireAuth();
+ if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(403);
+ exit;
+ }
+ $bSlug = trim($_POST['slug'] ?? '');
+ if ($bSlug !== '') {
+ $books->delete($bSlug);
+ }
+ header('Location: /admin/books?deleted=1');
+ exit;
+
case 'not_found':
$notFoundPath = trim(
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
diff --git a/scripts/setup.sh b/scripts/setup.sh
new file mode 100755
index 0000000..6b2a80a
--- /dev/null
+++ b/scripts/setup.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# setup.sh — déploiement initial de Folio sur un serveur
+#
+# Usage : sudo ./scripts/setup.sh [--web-group www-data] [--data-dir /chemin/data]
+#
+# Ce script est idempotent : il peut être relancé sans risque.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT="$SCRIPT_DIR/.."
+
+# ─── Paramètres ──────────────────────────────────────────────────────────────
+WEB_GROUP="www-data"
+DATA_DIR="$ROOT/data"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --web-group) WEB_GROUP="$2"; shift 2 ;;
+ --data-dir) DATA_DIR="$2"; shift 2 ;;
+ *) echo "Option inconnue : $1"; exit 1 ;;
+ esac
+done
+
+# ─── Vérifications préalables ─────────────────────────────────────────────────
+if [[ ! -f "$ROOT/.env" ]]; then
+ echo "✗ Fichier .env manquant. Copier .env.example en .env et remplir les valeurs."
+ exit 1
+fi
+
+if ! getent group "$WEB_GROUP" > /dev/null 2>&1; then
+ echo "✗ Groupe '$WEB_GROUP' introuvable. Utiliser --web-group ."
+ exit 1
+fi
+
+# ─── Dépendances PHP ──────────────────────────────────────────────────────────
+echo "→ Installation des dépendances PHP..."
+composer install --no-dev --optimize-autoloader --working-dir="$ROOT"
+
+# ─── Dossiers requis ─────────────────────────────────────────────────────────
+echo "→ Création des dossiers..."
+mkdir -p "$DATA_DIR"
+mkdir -p "$ROOT/public/_cache"
+mkdir -p "$ROOT/.sessions"
+
+# ─── Permissions ─────────────────────────────────────────────────────────────
+echo "→ Configuration des permissions (groupe : $WEB_GROUP)..."
+
+# data/ : setgid pour héritage de groupe + lecture/écriture propriétaire et groupe
+chown -R :"$WEB_GROUP" "$DATA_DIR"
+chmod g+s "$DATA_DIR"
+find "$DATA_DIR" -type d -exec chmod g+s {} +
+find "$DATA_DIR" -exec chmod ug+rw {} +
+
+# public/_cache/ : créé et écrit par PHP
+chown -R :"$WEB_GROUP" "$ROOT/public/_cache"
+chmod g+s "$ROOT/public/_cache"
+find "$ROOT/public/_cache" -exec chmod ug+rw {} +
+
+# .sessions/ : écrit par PHP
+chown -R :"$WEB_GROUP" "$ROOT/.sessions"
+chmod 770 "$ROOT/.sessions"
+
+# ─── Groupe adm pour lecture des logs Apache ─────────────────────────────────
+echo "→ Ajout de $WEB_GROUP au groupe adm (logs Apache)..."
+if getent group adm > /dev/null 2>&1; then
+ usermod -aG adm "$WEB_GROUP"
+ echo " Redémarrer Apache et PHP-FPM pour que le changement prenne effet :"
+ echo " systemctl restart apache2 php8.3-fpm"
+else
+ echo " Groupe adm absent, ignoré."
+fi
+
+# ─── Migrations SQL ───────────────────────────────────────────────────────────
+echo "→ Migrations SQL..."
+php "$ROOT/database/migrate.php"
+
+echo ""
+echo "✓ Folio est prêt."
+echo " Vérifier que APP_URL et ADMIN_EMAIL sont corrects dans .env."
diff --git a/src/SearchLogParser.php b/src/SearchLogParser.php
index cb7c3b5..63b092e 100644
--- a/src/SearchLogParser.php
+++ b/src/SearchLogParser.php
@@ -11,7 +11,7 @@ class SearchLogParser
public function __construct(
string $logDir = '/var/log/apache2',
- string $vhostBase = 'lan.acegrp.varlog-access.log',
+ string $vhostBase = '*-access.log',
string $cacheFile = '',
int $cacheTtl = 600
) {
@@ -47,8 +47,7 @@ class SearchLogParser
public function isReadable(): bool
{
- $f = $this->logDir . '/' . $this->vhostBase;
- return file_exists($f) && is_readable($f);
+ return count($this->logFiles()) > 0;
}
private function cacheValid(): bool
@@ -57,23 +56,32 @@ class SearchLogParser
&& (time() - filemtime($this->cacheFile)) < $this->cacheTtl;
}
- /** @return list */
+ /** @return list type: plain|gz|tgz */
private function logFiles(): array
{
- $base = $this->logDir . '/' . $this->vhostBase;
- $files = [];
+ $pattern = $this->logDir . '/' . $this->vhostBase;
+ $files = [];
- if (file_exists($base) && is_readable($base)) {
- $files[] = ['path' => $base, 'gz' => false];
- }
-
- for ($i = 1; $i <= 14; $i++) {
- $plain = $base . '.' . $i;
- $gz = $plain . '.gz';
- if (file_exists($plain) && is_readable($plain)) {
- $files[] = ['path' => $plain, 'gz' => false];
- } elseif (file_exists($gz) && is_readable($gz)) {
- $files[] = ['path' => $gz, 'gz' => true];
+ // Fichiers correspondant au pattern de base (courants + rotations incluses si glob)
+ $bases = glob($pattern) ?: [];
+ // Ajouter aussi les rotations (.N, .N.gz, .N.tar.gz) pour chaque base trouvée
+ foreach ($bases as $base) {
+ // Exclure les rotations déjà capturées par le pattern glob
+ if (str_ends_with($base, '.gz') || preg_match('/\.\d+$/', $base)) {
+ continue;
+ }
+ $candidates = array_merge([$base], glob($base . '.*') ?: []);
+ foreach ($candidates as $path) {
+ 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'];
+ }
}
}
@@ -82,7 +90,22 @@ class SearchLogParser
private function parseFile(array $file, array &$counts): void
{
- if ($file['gz']) {
+ 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, $counts);
+ }
+ }
+ } catch (\Exception $e) {
+ // archive illisible, on ignore
+ }
+ } elseif ($file['type'] === 'gz') {
$h = @gzopen($file['path'], 'rb');
if (!$h) {
return;
diff --git a/src/SiteSettings.php b/src/SiteSettings.php
index 6c4bbc1..856d0ae 100644
--- a/src/SiteSettings.php
+++ b/src/SiteSettings.php
@@ -59,10 +59,19 @@ function siteLicenseUrl(): string
return siteSettings()['site_license_url'] ?? 'https://creativecommons.org/licenses/by/4.0/';
}
-function saveSiteSettings(array $data): void
+function apacheAccessLog(): string
+{
+ $fromSettings = siteSettings()['apache_access_log'] ?? '';
+ if ($fromSettings !== '') {
+ return $fromSettings;
+ }
+ return (string)($_ENV['APACHE_ACCESS_LOG'] ?? getenv('APACHE_ACCESS_LOG') ?: '*-access.log');
+}
+
+function saveSiteSettings(array $data): bool
{
$current = siteSettings();
- $stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url'];
+ $stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url', 'apache_access_log'];
foreach ($stringKeys as $key) {
if (array_key_exists($key, $data)) {
$val = trim((string)$data[$key]);
@@ -77,8 +86,8 @@ function saveSiteSettings(array $data): void
$current['posts_per_page'] = $val;
}
}
- file_put_contents(
+ return file_put_contents(
siteSettingsPath(),
json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
- );
+ ) !== false;
}
diff --git a/src/SmtpSettings.php b/src/SmtpSettings.php
index a054959..a4b0765 100644
--- a/src/SmtpSettings.php
+++ b/src/SmtpSettings.php
@@ -35,7 +35,7 @@ function smtpCfg(string $key, string $envKey, string $default = ''): string
return ($v !== false && $v !== '') ? (string)$v : $default;
}
-function saveSmtpSettings(array $data): void
+function saveSmtpSettings(array $data): bool
{
$current = smtpSettings();
foreach (['host', 'port', 'secure', 'user', 'from', 'from_name'] as $key) {
@@ -46,8 +46,8 @@ function saveSmtpSettings(array $data): void
if (!empty($data['pass']) && trim((string)$data['pass']) !== '') {
$current['pass'] = trim((string)$data['pass']);
}
- file_put_contents(
+ return file_put_contents(
smtpSettingsPath(),
json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
- );
+ ) !== false;
}
diff --git a/templates/admin.php b/templates/admin.php
index 67716ae..dc4dd46 100644
--- a/templates/admin.php
+++ b/templates/admin.php
@@ -65,6 +65,10 @@ function adminStatusBadge(array $a, int $now): string
Recherches
+
+ Livres
+
@@ -94,11 +98,11 @@ function adminStatusBadge(array $a, int $now): string
adminNotices() : [];
$_remoteLabel = '—';
foreach ($_notices as $_n) {
- if ($_n['type'] === 'info' && preg_match('/publiée le ([^)]+)/', $_n['message'], $_m)) {
+ if ($_n['type'] === 'info' && preg_match('/v([\d]+\.[\d]+\.[\d]+)/', $_n['message'], $_m)) {
$_remoteLabel = $_m[1];
}
}
@@ -464,6 +468,8 @@ function adminStatusBadge(array $a, int $now): string
Paramètres enregistrés.
+
+ Impossible d'enregistrer : le fichier n'est pas accessible en écriture.
@@ -676,6 +682,8 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
Paramètres SMTP enregistrés.
+
+
Impossible d'enregistrer : le fichier n'est pas accessible en écriture.
@@ -1054,6 +1062,29 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
+
+
Configuration enregistrée.
+
+
Impossible d'enregistrer : le fichier n'est pas accessible en écriture.
+
+
+
+
Termes recherchés
= count($adminData['search_terms'] ?? []) ?>
@@ -1110,6 +1141,165 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
+
+
+
+
+
Livre supprimé.
+
+
+
+
+
+
+
+
+ Livres
+ = count($adminData['books']) ?>
+
+
+ Nouveau
+
+
+
+
Aucun livre pour l'instant.
+
+
+
+
+
+
+
+
+
+
Modifier le livre
+
+
+
Livre sauvegardé.
+
+
+
+
+
+
+
+
+
+
+
+
Nouveau livre
+
+
+
+
+
+ Sélectionnez un livre à gauche pour le modifier, ou créez-en un nouveau.
+
+ Cliquez sur + Nouveau pour créer votre premier livre.
+
+
+
+
+
+
+
+
+