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 + @@ -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.
+ + +
+
Configuration des logs
+
+
+
+ + +
Pattern glob dans /var/log/apache2/. Les rotations (.gz, .tar.gz) sont automatiquement incluses.
+
+ +
+
+
+
Termes recherchés @@ -1110,6 +1141,165 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb): + + + + +
Livre supprimé.
+ + +
+ + +
+
+
+ Livres + +
+ + Nouveau +
+ + +

Aucun livre pour l'instant.

+ + + +
+ + +
+ + +
Modifier le livre
+ + +
Livre sauvegardé.
+ + +
+ +
+ + +
Le slug ne peut pas être modifié après création.
+
+
+ + +
+
+ + +
+
+ + +
Un slug par ligne. L'ordre définit la navigation précédent/suivant.
+
+
+ + +
+
+ + Voir le livre ↗ +
+
+ +
+ +
+ + +
+ + + + +
Nouveau livre
+
+
+ + +
Minuscules, chiffres, tirets. Exemple : esp8266
+
+
+ + +
+
+ + +
+ +
+ + Annuler +
+
+ + +

+ + Sélectionnez un livre à gauche pour le modifier, ou créez-en un nouveau. + + Cliquez sur + Nouveau pour créer votre premier livre. + +

+ +
+ +
+ + +