From 55a2120be1c4670fa3d61617d1aa50fc2d322e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Fri, 15 May 2026 00:53:33 +0200 Subject: [PATCH 1/3] chore : CHANGELOG + bump version 1.3.0 (statistiques, permissions, setup.sh) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 16 ++++++++++++++++ public/version.txt | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83086c0..51f01fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.3.0] - 2026-05-15 + +### Ajouté +- Onglet **Statistiques** dans l'admin : pages les plus visitées, livres consultés, répartition par AS (#64) + - `AccessLogParser` : lecture des logs Apache (plain, `.gz`, `.tar.gz`), cache 10 min + - `AsnLookup` : résolution ASN via ip-api.com (batch, cache 30 j), détection LAN automatique + - Filtrage des AS par groupes configurables (motifs case-insensitive, formulaire admin) + - Pattern de log configurable via l'UI (onglet Recherches) avec support glob + +### Corrigé +- Permissions rsync : `--chmod=Fug+rw,Fo-w` assure la lisibilité groupe sur les fichiers déployés +- `saveSiteSettings()` et `saveSmtpSettings()` : retournent un `bool` et affichent une erreur si l'écriture échoue +- `scripts/setup.sh` : script d'initialisation Folio (composer, répertoires, droits, migrations, groupe `adm`) + +--- + ## [1.2.2] - 2026-05-14 ### Corrigé diff --git a/public/version.txt b/public/version.txt index 23aa839..f0bb29e 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.2.2 +1.3.0 From 16965ee8cb32de9bdd1d1d397ffb084c72375f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Fri, 15 May 2026 09:17:55 +0200 Subject: [PATCH 2/3] feat : DATA_PATH configurable, DataGit auto-commit, UpdateChecker branche (v1.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DATA_PATH : chemin /data hors document root, configurable via .env (fallback sur BASE_PATH/data si absent) - DataGit : auto-commit git sur toutes les écritures articles/livres (create, update, delete, meta, tags, fichiers, liens…) sauf autosave - UpdateChecker : getBranch() / getLastChecked() / clearCache(), branche configurable via FOLIO_UPDATE_BRANCH (plus de main hardcodé) - Admin dashboard : affiche la branche suivie, date du dernier contrôle, bouton Vérifier pour forcer le check sans attendre le TTL - CLAUDE.md : architecture DATA_PATH et flux de déploiement documentés Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 +++ CHANGELOG.md | 12 ++++++ CLAUDE.md | 62 +++++++++++++++++++++++++++++++ bootstrap.php | 6 +++ phpstan-bootstrap.php | 1 + public/feed.php | 2 +- public/file.php | 2 +- public/index.php | 38 ++++++++++++------- public/sitemap.php | 2 +- public/version.txt | 2 +- src/ArticleManager.php | 63 ++++++++++++++++++++++++++------ src/BookManager.php | 7 +++- src/DataGit.php | 22 +++++++++++ src/SiteSettings.php | 2 +- src/SmtpSettings.php | 2 +- src/UpdateChecker.php | 30 +++++++++++++-- templates/admin.php | 13 ++++++- templates/import_image_step2.php | 2 +- 18 files changed, 236 insertions(+), 37 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/DataGit.php diff --git a/.env.example b/.env.example index 55820d9..478ec7b 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,11 @@ SMTP_FROM_NAME= CONTACT_EMAIL= CONTACT_FROM_EMAIL= +# Chemin absolu vers le répertoire des articles (data/) +# Par défaut : BASE_PATH/data (dans le répertoire de l'application) +# Recommandé en production : chemin hors du répertoire web, ex. /srv/data/folio +DATA_PATH=/srv/data/folio + # 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/CHANGELOG.md b/CHANGELOG.md index 51f01fc..e217f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.4.0] - 2026-05-15 + +### Ajouté +- **`DATA_PATH`** : chemin des articles configurable via `.env`, indépendant du document root — permet de stocker `/data` hors de l'arborescence web (ex. `/srv/data/folio`) +- **`DataGit`** : auto-commit git sur toutes les écritures articles et livres (création, modification, suppression, métadonnées, tags, fichiers, liens…) sauf `autosave` — no-op silencieux si `DATA_PATH` n'est pas un dépôt git +- **Admin — Moteur Folio** : affiche la branche suivie pour les mises à jour (`FOLIO_UPDATE_BRANCH`, défaut `main`), la date du dernier contrôle, et un bouton **Vérifier** pour forcer la vérification sans attendre le TTL du cache (1 h) + +### Modifié +- `UpdateChecker` : branche cible configurable via `FOLIO_UPDATE_BRANCH` (plus de `main` hardcodé dans l'URL Gitea) + +--- + ## [1.3.0] - 2026-05-15 ### Ajouté diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c68c7ee --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# CLAUDE.md + +## Ce qu'est ce dépôt + +**Folio** est un moteur de blog PHP. +Ce répertoire est la **copie locale du dépôt Git** (`https://git.abonnel.fr/cedricAbonnel/folio`), branche DEV. +Il contient uniquement le code du moteur — pas de données, pas de credentials. + +## Architecture + +| Répertoire local | Site distant | Rôle | +|-----------------|-------------|------| +| `~/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/fr.abonnel.www/` | www.abonnel.fr | Sync bidirectionnelle des articles abonnel.fr. A aussi servi au déploiement initial. | + +**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/`) + +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. + +## Modifier le moteur + +Pour toute correction ou fonctionnalité : **créer un ticket et une PR**. + +1. Coder ici dans `~/Projects/folio/` (branche feature) +2. **Tester sur varlog.a5l.fr** : + ```bash + ~/Projects/varlog/scripts/sync.sh + # puis tester sur http://varlog.acegrp.lan + ``` +3. Une fois validé, ouvrir une PR sur Gitea. Le commit doit inclure : + - `public/version.txt` (bump semver) + - `CHANGELOG.md` (entrée `### Ajouté / Corrigé / Modifié`) +4. Merger la PR → abonnel.fr se met à jour automatiquement. + +## 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`. + +| Environnement | Chemin local | Chemin serveur | +|--------------|-------------|----------------| +| varlog | `~/Projects/varlog-data/` | `/srv/data/folio` | +| abonnel.fr | `~/Projects/fr.abonnel.www-data/` | `/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. + +## Asymétrie de déploiement moteur + +| Site | Mécanisme | Raison | +|------|-----------|--------| +| varlog (test) | rsync depuis `~/Projects/folio/` | Itération rapide, pas de contrainte de stabilité | +| abonnel.fr (prod) | `git pull origin main` sur le serveur | Contrôle via PR/merge, UpdateChecker autonome | + +Pour initialiser git sur un serveur abonnel.fr déployé via rsync : `scripts/git-init-remote.sh` + +## Ne pas mettre ici + +- `.env` (credentials → dans chaque workspace site) +- `data/` (articles → dans chaque workspace site) +- `vendor/` (non versionné) diff --git a/bootstrap.php b/bootstrap.php index 903574b..adc19e1 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -6,6 +6,12 @@ if (!defined('BASE_PATH')) { define('BASE_PATH', __DIR__); } +if (!defined('DATA_PATH')) { + $__dataPath = $_ENV['DATA_PATH'] ?? getenv('DATA_PATH') ?: ''; + define('DATA_PATH', $__dataPath !== '' ? rtrim($__dataPath, '/') : BASE_PATH . '/data'); + unset($__dataPath); +} + if (session_status() === PHP_SESSION_NONE) { $isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; $sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: null); diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php index 647c135..23e30ae 100644 --- a/phpstan-bootstrap.php +++ b/phpstan-bootstrap.php @@ -3,3 +3,4 @@ declare(strict_types=1); define('BASE_PATH', __DIR__); +define('DATA_PATH', BASE_PATH . '/data'); diff --git a/public/feed.php b/public/feed.php index 302820e..2b12f95 100644 --- a/public/feed.php +++ b/public/feed.php @@ -12,7 +12,7 @@ require_once BASE_PATH . '/src/Parsedown.php'; const FEED_PAGE_SIZE = 20; -$articles = new ArticleManager(BASE_PATH . '/data'); +$articles = new ArticleManager(DATA_PATH); $privateCats = $articles->getPrivateCategories(); $Parsedown = new Parsedown(); diff --git a/public/file.php b/public/file.php index 7dbb4ff..b9b947a 100644 --- a/public/file.php +++ b/public/file.php @@ -20,7 +20,7 @@ if ($name === '' || $name[0] === '.') { exit; } -$path = BASE_PATH . '/data/' . $uuid . '/files/' . $name; +$path = DATA_PATH . '/' . $uuid . '/files/' . $name; if (!is_file($path)) { http_response_code(404); diff --git a/public/index.php b/public/index.php index e74687b..4b2b797 100644 --- a/public/index.php +++ b/public/index.php @@ -24,12 +24,14 @@ 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'; +require_once BASE_PATH . '/src/DataGit.php'; -$articles = new ArticleManager(BASE_PATH . '/data'); -$books = new BookManager(BASE_PATH . '/data/books'); +$_dataGit = new DataGit(DATA_PATH); +$articles = new ArticleManager(DATA_PATH, $_dataGit); +$books = new BookManager(DATA_PATH . '/books', $_dataGit); // ─── Mode maintenance ────────────────────────────────────────────────────── -if (file_exists(BASE_PATH . '/data/.maintenance')) { +if (file_exists(DATA_PATH . '/.maintenance')) { http_response_code(503); header('Retry-After: 60'); include BASE_PATH . '/templates/maintenance.php'; @@ -37,7 +39,7 @@ if (file_exists(BASE_PATH . '/data/.maintenance')) { } require_once BASE_PATH . '/src/UpdateChecker.php'; -$_updateChecker = new UpdateChecker(BASE_PATH . '/data', BASE_PATH); +$_updateChecker = new UpdateChecker(DATA_PATH, BASE_PATH); $action = $_GET['action'] ?? 'list'; $uuid = $_GET['uuid'] ?? ''; @@ -78,7 +80,7 @@ function searchAndRedirect(string $rawPath, ArticleManager $articles): void // ─── Pages statiques depuis data/site/ ────────────────────────────────────── function loadSitePageData(string $slug): array { - $base = BASE_PATH . '/data/site'; + $base = DATA_PATH . '/site'; $meta = []; $raw = @file_get_contents($base . '/' . $slug . '.json'); if ($raw !== false) { @@ -1383,7 +1385,7 @@ switch ($action) { case 'flux': require_once BASE_PATH . '/src/FeedFetcher.php'; - $fetcher = new FeedFetcher(BASE_PATH . '/data/_cache/feeds'); + $fetcher = new FeedFetcher(DATA_PATH . '/_cache/feeds'); $fluxItems = []; $pdo = dbPdo(); if ($pdo) { @@ -1535,8 +1537,8 @@ switch ($action) { echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']); exit; } - $cfSrc = BASE_PATH . '/data/' . $cfFrom . '/files/' . $cfName; - $cfDstDir = BASE_PATH . '/data/' . $cfTo . '/files'; + $cfSrc = DATA_PATH . '/' . $cfFrom . '/files/' . $cfName; + $cfDstDir = DATA_PATH . '/' . $cfTo . '/files'; $cfDst = $cfDstDir . '/' . $cfName; if (!file_exists($cfSrc)) { echo json_encode(['ok' => false, 'error' => 'Fichier source introuvable']); @@ -1649,7 +1651,7 @@ switch ($action) { // Capture d'écran pour prévisualisation (pages HTML uniquement, URL externes uniquement) $step2Screenshot = null; if (!$step2IsInternal && str_starts_with($step2Meta['mime'] ?? '', 'text/html')) { - $filesDir = BASE_PATH . '/data/' . $uuid . '/files'; + $filesDir = DATA_PATH . '/' . $uuid . '/files'; if (!is_dir($filesDir)) { mkdir($filesDir, 0755, true); } @@ -1725,7 +1727,7 @@ switch ($action) { header('Location: /import/' . rawurlencode($urlUuid) . '?error=1'); exit; } - $filesDir = BASE_PATH . '/data/' . $urlUuid . '/files'; + $filesDir = DATA_PATH . '/' . $urlUuid . '/files'; $previewPath = $filesDir . '/' . $screenshotFile; if (!file_exists($previewPath)) { header('Location: /import/' . rawurlencode($urlUuid) . '?error=1'); @@ -1744,7 +1746,7 @@ switch ($action) { } if ($mode === 'link') { - $filesDir = BASE_PATH . '/data/' . $urlUuid . '/files'; + $filesDir = DATA_PATH . '/' . $urlUuid . '/files'; if (!is_dir($filesDir)) { mkdir($filesDir, 0755, true); } @@ -1895,7 +1897,7 @@ switch ($action) { $done = $fail = $skip = 0; foreach ($articles->getAll() as $article) { $artUuid = $article['uuid']; - $filesDir = BASE_PATH . '/data/' . $artUuid . '/files'; + $filesDir = DATA_PATH . '/' . $artUuid . '/files'; foreach ($article['external_links'] ?? [] as $link) { $lMeta = $link['meta'] ?? []; $lMime = $lMeta['mime'] ?? 'text/html'; @@ -2784,7 +2786,7 @@ switch ($action) { http_response_code(403); exit; } - $_cmDataDir = BASE_PATH . '/data'; + $_cmDataDir = DATA_PATH; $_cmTrack = $_cmDataDir . '/.content_migrations.json'; $_cmFlag = $_cmDataDir . '/.maintenance'; $_cmApplied = file_exists($_cmTrack) ? (json_decode((string) file_get_contents($_cmTrack), true) ?? []) : []; @@ -2814,6 +2816,16 @@ switch ($action) { header('Location: /admin?tab=dashboard¬ice=' . ($_cmErrors ? 'migration_error' : 'migrated')); exit; + case 'force_update_check': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $_updateChecker->clearCache(); + header('Location: /admin?tab=dashboard'); + exit; + case 'admin_save_site': requireAuth(); if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { diff --git a/public/sitemap.php b/public/sitemap.php index 5ed94f9..a1a4e2d 100644 --- a/public/sitemap.php +++ b/public/sitemap.php @@ -8,7 +8,7 @@ require_once BASE_PATH . '/src/helpers.php'; require_once BASE_PATH . '/config/config.php'; require_once BASE_PATH . '/src/ArticleManager.php'; -$articles = new ArticleManager(BASE_PATH . '/data'); +$articles = new ArticleManager(DATA_PATH); $privateCats = $articles->getPrivateCategories(); $published = array_filter($articles->getAll(true), static function (array $a) use ($privateCats): bool { diff --git a/public/version.txt b/public/version.txt index f0bb29e..88c5fb8 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.3.0 +1.4.0 diff --git a/src/ArticleManager.php b/src/ArticleManager.php index 769e235..a69cbe7 100644 --- a/src/ArticleManager.php +++ b/src/ArticleManager.php @@ -9,7 +9,7 @@ class ArticleManager private ?array $allCache = null; private ?array $searchIndexCache = null; - public function __construct(private string $dataDir) + public function __construct(private string $dataDir, private ?DataGit $git = null) { } @@ -132,11 +132,12 @@ class ArticleManager file_put_contents($dir . '/index.md', ltrim($content)); $this->rebuildSearchIndex(); $this->rebuildBacklinksCache(); + $this->git?->commit("add: $title"); return $uuid; } - public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt, string $revisionComment = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', ?array $tags = null): void + public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt, string $revisionComment = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', ?array $tags = null, bool $skipGit = false): void { $article = $this->getByUuid($uuid); if (!$article) { @@ -199,6 +200,9 @@ class ArticleManager file_put_contents($dir . '/index.md', ltrim($content)); $this->rebuildSearchIndex(); $this->rebuildBacklinksCache(); + if (!$skipGit) { + $this->git?->commit("update: $title"); + } } public function autosave(string $uuid, string $title, string $content, string $slug): bool @@ -247,6 +251,7 @@ class ArticleManager } $meta['updated_at'] = date('Y-m-d H:i:s'); $this->writeMeta($dir, $meta); + $this->git?->commit("meta: " . ($meta['title'] ?? $uuid)); } public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void @@ -269,6 +274,9 @@ class ArticleManager if ($content !== null) { file_put_contents($dir . '/draft_overlay.md', $content); } + $raw2 = @file_get_contents($dir . '/meta.json'); + $title = is_string($raw2) ? (json_decode($raw2, true)['title'] ?? $uuid) : $uuid; + $this->git?->commit("draft: $title"); } public function getDraftOverlay(string $uuid): ?array @@ -315,14 +323,22 @@ class ArticleManager return file_exists($this->dataDir . '/' . $uuid . '/draft_overlay.json'); } - public function discardDraftOverlay(string $uuid): void + public function discardDraftOverlay(string $uuid, bool $skipGit = false): void { if (!$this->isValidUuid($uuid)) { return; } - $dir = $this->dataDir . '/' . $uuid; + $dir = $this->dataDir . '/' . $uuid; + $title = null; + if (!$skipGit && $this->git !== null) { + $raw = @file_get_contents($dir . '/meta.json'); + $title = is_string($raw) ? (json_decode($raw, true)['title'] ?? $uuid) : $uuid; + } @unlink($dir . '/draft_overlay.json'); @unlink($dir . '/draft_overlay.md'); + if ($title !== null) { + $this->git?->commit("discard-draft: $title"); + } } public function commitDraftOverlay(string $uuid, string $revisionComment = ''): void @@ -331,9 +347,10 @@ class ArticleManager if (!$draft) { return; } + $title = $draft['title']; $this->update( $uuid, - $draft['title'], + $title, $draft['content'], (bool)$draft['published'], $draft['slug'] ?? '', @@ -343,12 +360,14 @@ class ArticleManager $draft['seo_description'] ?? '', $draft['og_image'] ?? '', $draft['category'] ?? '', - $draft['tags'] ?? [] + $draft['tags'] ?? [], + true // skipGit — commit unique ci-dessous ); - $this->discardDraftOverlay($uuid); + $this->discardDraftOverlay($uuid, skipGit: true); + $this->git?->commit("publish: $title"); } - public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = []): void + public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = [], bool $skipGit = false): void { if (!$this->isValidUuid($uuid)) { return; @@ -377,6 +396,9 @@ class ArticleManager } $meta['files_meta'][$filename] = $entry; $this->writeMeta($this->dataDir . '/' . $uuid, $meta); + if (!$skipGit) { + $this->git?->commit("file-meta: {$uuid}/{$filename}"); + } } public function setCover(string $uuid, string $filename): void @@ -424,6 +446,7 @@ class ArticleManager } $meta['cover'] = $coverName; $this->writeMeta($this->dataDir . '/' . $uuid, $meta); + $this->git?->commit("cover: " . ($article['title'] ?? $uuid)); } public function addFileFromUrl(string $uuid, string $url, bool $isCover = false, string $author = '', string $sourceUrl = '', string $title = '', array $extraMeta = []): ?string @@ -499,7 +522,7 @@ class ArticleManager rename($tmp, $filesDir . '/' . $filename); if ($author !== '' || $sourceUrl !== '' || $title !== '' || !empty($extraMeta)) { - $this->addFileMeta($uuid, $filename, $author, $sourceUrl, $title, $extraMeta); + $this->addFileMeta($uuid, $filename, $author, $sourceUrl, $title, $extraMeta, skipGit: true); } if ($isCover && $isImage) { @@ -513,6 +536,7 @@ class ArticleManager } } + $this->git?->commit("add-file: {$uuid}/{$filename}"); return $filename; } @@ -553,6 +577,7 @@ class ArticleManager $meta['external_links'][] = $entry; $this->writeMeta($dir, $meta); $this->rebuildBacklinksCache(); + $this->git?->commit("link: {$uuid}"); return true; } @@ -583,6 +608,7 @@ class ArticleManager return false; } $this->writeMeta($dir, $meta); + $this->git?->commit("link-meta: {$uuid}"); return true; } @@ -606,6 +632,7 @@ class ArticleManager )); $this->writeMeta($dir, $meta); $this->rebuildBacklinksCache(); + $this->git?->commit("unlink: {$uuid}"); return true; } @@ -623,7 +650,7 @@ class ArticleManager return $cats; } - public function renameCategory(string $old, string $new): void + public function renameCategory(string $old, string $new, bool $skipGit = false): void { if (!is_dir($this->dataDir)) { return; @@ -647,11 +674,15 @@ class ArticleManager $meta['category'] = $new; $this->writeMeta($this->dataDir . '/' . $entry, $meta); } + if (!$skipGit) { + $this->git?->commit("rename-cat: $old → $new"); + } } public function deleteCategory(string $name): void { - $this->renameCategory($name, ''); + $this->renameCategory($name, '', skipGit: true); + $this->git?->commit("delete-cat: $name"); } public function getPrivateCategories(): array @@ -676,6 +707,7 @@ class ArticleManager $this->dataDir . '/private_cats.json', json_encode(array_values($cats), JSON_UNESCAPED_UNICODE) ); + $this->git?->commit("private-cat: $cat"); } // ─── Tag types ────────────────────────────────────────────────────────────── @@ -701,6 +733,7 @@ class ArticleManager $this->tagTypesPath(), json_encode($types, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n" ); + $this->git?->commit("tag-types"); } /** Enregistre les tags d'un article directement (utile pour les scripts de migration). */ @@ -720,6 +753,7 @@ class ArticleManager $meta['tags'] = $this->normalizeTags($tags); $this->writeMeta($dir, $meta); $this->rebuildSearchIndex(); + $this->git?->commit("tags: " . ($meta['title'] ?? $uuid)); } /** @return list Toutes les valeurs distinctes d'un type de tag, triées. */ @@ -769,6 +803,7 @@ class ArticleManager $this->writeMeta($dir, $meta); $this->allCache = null; @unlink($this->articleCachePath($uuid)); + $this->git?->commit("featured: " . ($meta['title'] ?? $uuid) . " (" . ($featured ? 'on' : 'off') . ")"); } public function delete(string $uuid): void @@ -777,6 +812,11 @@ class ArticleManager return; } $dir = $this->dataDir . '/' . $uuid; + $title = null; + if ($this->git !== null && is_dir($dir)) { + $raw = @file_get_contents($dir . '/meta.json'); + $title = is_string($raw) ? (json_decode($raw, true)['title'] ?? null) : null; + } if (is_dir($dir)) { $this->allCache = null; @unlink($this->articleCachePath($uuid)); @@ -785,6 +825,7 @@ class ArticleManager } $this->rebuildSearchIndex(); $this->rebuildBacklinksCache(); + $this->git?->commit("delete: " . ($title ?? $uuid)); } // ------------------------------------------------------------------ // diff --git a/src/BookManager.php b/src/BookManager.php index d62b2cf..6d6749c 100644 --- a/src/BookManager.php +++ b/src/BookManager.php @@ -4,7 +4,7 @@ declare(strict_types=1); class BookManager { - public function __construct(private string $booksDir) + public function __construct(private string $booksDir, private ?DataGit $git = null) { } @@ -95,14 +95,17 @@ class BookManager $this->bookPath($slug), json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" ); + $this->git?->commit("book: " . ($book['title'] ?? $slug)); } public function delete(string $slug): void { - $path = $this->bookPath($slug); + $title = $this->getBySlug($slug)['title'] ?? $slug; + $path = $this->bookPath($slug); if (file_exists($path)) { @unlink($path); } + $this->git?->commit("delete-book: $title"); } // ------------------------------------------------------------------ // diff --git a/src/DataGit.php b/src/DataGit.php new file mode 100644 index 0000000..c3ab886 --- /dev/null +++ b/src/DataGit.php @@ -0,0 +1,22 @@ +dataDir . '/.git')) { + return; + } + $dir = escapeshellarg($this->dataDir); + $msg = escapeshellarg($message); + shell_exec("git -C $dir add -A 2>/dev/null"); + exec("git -C $dir diff --cached --quiet 2>/dev/null", $_, $rc); + if ($rc !== 0) { + shell_exec("git -C $dir commit -m $msg 2>/dev/null"); + } + } +} diff --git a/src/SiteSettings.php b/src/SiteSettings.php index 13da22a..37d187b 100644 --- a/src/SiteSettings.php +++ b/src/SiteSettings.php @@ -4,7 +4,7 @@ declare(strict_types=1); function siteSettingsPath(): string { - return BASE_PATH . '/data/site_settings.json'; + return DATA_PATH . '/site_settings.json'; } function siteSettings(): array diff --git a/src/SmtpSettings.php b/src/SmtpSettings.php index a4b0765..f0857c3 100644 --- a/src/SmtpSettings.php +++ b/src/SmtpSettings.php @@ -4,7 +4,7 @@ declare(strict_types=1); function smtpSettingsPath(): string { - return BASE_PATH . '/data/smtp_settings.json'; + return DATA_PATH . '/smtp_settings.json'; } function smtpSettings(): array diff --git a/src/UpdateChecker.php b/src/UpdateChecker.php index d46643d..3962b4b 100644 --- a/src/UpdateChecker.php +++ b/src/UpdateChecker.php @@ -89,8 +89,31 @@ class UpdateChecker return version_compare($remoteVer, $deployedVer, '>') ? $remoteVer : null; } + public function getBranch(): string + { + return (string) ($_ENV['FOLIO_UPDATE_BRANCH'] ?? getenv('FOLIO_UPDATE_BRANCH') ?: 'main'); + } + + public function getLastChecked(): ?int + { + $cacheFile = $this->dataDir . '/.version_check_cache.json'; + if (!file_exists($cacheFile)) { + return null; + } + $cache = json_decode((string) file_get_contents($cacheFile), true) ?? []; + return isset($cache['fetched_at']) ? (int) $cache['fetched_at'] : null; + } + + public function clearCache(): void + { + $cacheFile = $this->dataDir . '/.version_check_cache.json'; + if (file_exists($cacheFile)) { + unlink($cacheFile); + } + } + /** - * Récupère `public/version.txt` depuis le dépôt Gitea (branche main). + * 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`. */ private function fetchRemoteVersion(string $repoUrl): ?string @@ -107,8 +130,9 @@ class UpdateChecker } } - // URL du fichier brut : {repo}/raw/branch/main/public/version.txt - $rawUrl = $repoUrl . '/raw/branch/main/public/version.txt'; + $branch = $this->getBranch(); + // URL du fichier brut : {repo}/raw/branch/{branch}/public/version.txt + $rawUrl = $repoUrl . '/raw/branch/' . $branch . '/public/version.txt'; $token = (string) ($_ENV['GITEA_TOKEN'] ?? getenv('GITEA_TOKEN') ?: ''); $opts = [ diff --git a/templates/admin.php b/templates/admin.php index ff5bd77..73c19da 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -104,6 +104,8 @@ function adminStatusBadge(array $a, int $now): string $_deployedVer = trim((string) @file_get_contents(BASE_PATH . '/public/version.txt')); $_deployedLabel = $_deployedVer !== '' ? $_deployedVer : '—'; $_notices = isset($_updateChecker) ? $_updateChecker->adminNotices() : []; + $_branch = isset($_updateChecker) ? $_updateChecker->getBranch() : 'main'; + $_lastChecked = isset($_updateChecker) ? $_updateChecker->getLastChecked() : null; $_remoteLabel = '—'; foreach ($_notices as $_n) { if ($_n['type'] === 'info' && preg_match('/v([\d]+\.[\d]+\.[\d]+)/', $_n['message'], $_m)) { @@ -122,7 +124,16 @@ function adminStatusBadge(array $a, int $now): string Dernière version disponible - Mise à jour disponible' : '' ?> + + Mise à jour disponible' : '' ?> +
+ +
+ + + + Branche suivie + · vérifié le ' . date('d/m/Y à H:i', $_lastChecked) . '' : '' ?> diff --git a/templates/import_image_step2.php b/templates/import_image_step2.php index 29df54d..03cbd3a 100644 --- a/templates/import_image_step2.php +++ b/templates/import_image_step2.php @@ -60,7 +60,7 @@ $preSource = $step2Meta['canonical'] ?? $step2Meta['source'] ?? $step2Url;

Aperçu de la page

Date: Fri, 15 May 2026 09:18:34 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore=20:=20ajouter=20fichiers=20non=20vers?= =?UTF-8?q?ionn=C3=A9s=20(migrations=20SQL,=20404,=20PROJET.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- PROJET.md | 69 ++++++++++++++++++++ database/migration_000_initial_schema.sql | 49 ++++++++++++++ database/migration_008_user_profiles.sql | 10 +++ database/migration_009_journal_smtp.sql | 16 +++++ database/migration_010_role_capabilities.sql | 5 ++ database/migration_011_user_capabilities.sql | 7 ++ database/migration_012_users.sql | 8 +++ database/migration_013_profiles.sql | 9 +++ database/migration_014_app_config.sql | 13 ++++ database/migration_015_mail_queue.sql | 15 +++++ database/migration_016_dictionary.sql | 44 +++++++++++++ templates/404.php | 19 ++++++ 12 files changed, 264 insertions(+) create mode 100644 PROJET.md create mode 100644 database/migration_000_initial_schema.sql create mode 100644 database/migration_008_user_profiles.sql create mode 100644 database/migration_009_journal_smtp.sql create mode 100644 database/migration_010_role_capabilities.sql create mode 100644 database/migration_011_user_capabilities.sql create mode 100644 database/migration_012_users.sql create mode 100644 database/migration_013_profiles.sql create mode 100644 database/migration_014_app_config.sql create mode 100644 database/migration_015_mail_queue.sql create mode 100644 database/migration_016_dictionary.sql create mode 100644 templates/404.php diff --git a/PROJET.md b/PROJET.md new file mode 100644 index 0000000..4fea8e2 --- /dev/null +++ b/PROJET.md @@ -0,0 +1,69 @@ +# FOLIO + +Moteur de blog PHP — utilisé par plusieurs sites. + +## Dépôt + +`https://git.abonnel.fr/cedricAbonnel/folio` — branche `main` + +## Sites utilisant Folio + +| Site | Workspace local | Serveur | +|---|---|---| +| varlog.a5l.fr | `~/Projects/varlog/` | `ssh varlog` | +| www.abonnel.fr | `~/Projects/fr.abonnel.www/` | `ssh abonnel-wiki` | + +## Structure du moteur + +``` +folio/ +├── src/ Classes PHP (ArticleManager, PostManager, auth…) +├── public/ Point d'entrée web (index.php, route.php, assets/) +├── templates/ Vues PHP (layout, header, footer, post_*) +├── config/ Configuration (config.php) +├── database/ Schéma SQL + migrate.php +├── composer.json +└── CHANGELOG.md +``` + +## Workflow de modification du moteur + +### 1. Développement et test sur varlog.a5l.fr + +Modifier le code ici dans `~/Projects/folio/`, tester sur **varlog.a5l.fr** : + +```bash +# Déployer sur varlog pour test +~/Projects/varlog/scripts/sync.sh + +# Tester sur http://varlog.acegrp.lan (ou https://varlog.a5l.fr) +``` + +### 2. Validation + +Une fois validé sur varlog.a5l.fr : + +```bash +# Commiter sur le serveur varlog (git de déploiement) +~/Projects/varlog/scripts/commit.sh "description du changement" +``` + +### 3. Push vers le dépôt Folio + +Pousser le code validé vers le dépôt canonique Folio : + +```bash +cd ~/Projects/folio +./scripts/push.sh "description du changement" +``` + +### 4. Déployer sur les autres sites si nécessaire + +```bash +~/Projects/fr.abonnel.www/scripts/sync.sh +~/Projects/fr.abonnel.www/scripts/commit.sh "même message" +``` + +## Credentials locaux + +Aucun credential dans folio/ — les `.env` sont dans chaque workspace site. diff --git a/database/migration_000_initial_schema.sql b/database/migration_000_initial_schema.sql new file mode 100644 index 0000000..1ffaf23 --- /dev/null +++ b/database/migration_000_initial_schema.sql @@ -0,0 +1,49 @@ +-- Schéma initial : tables créées avant la mise en place du système de migrations. +-- Remplace tables_create.sql et interactions_create.sql. + +CREATE TABLE IF NOT EXISTS posts ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + is_published BOOLEAN DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS post_files ( + id SERIAL PRIMARY KEY, + post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE, + file_type TEXT, + file_path TEXT, + original_name TEXT, + uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS article_reactions ( + id SERIAL PRIMARY KEY, + article_uuid TEXT NOT NULL, + reaction_type TEXT NOT NULL CHECK (reaction_type IN ('useful', 'important', 'interesting')), + visitor_hash TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (article_uuid, reaction_type, visitor_hash) +); +CREATE INDEX IF NOT EXISTS article_reactions_article_uuid_idx ON article_reactions (article_uuid); + +CREATE TABLE IF NOT EXISTS comments ( + id SERIAL PRIMARY KEY, + article_uuid TEXT NOT NULL, + author_name TEXT NOT NULL, + author_email TEXT NOT NULL, + content TEXT NOT NULL CHECK (LENGTH(content) <= 2000), + verify_token TEXT, + verification_code TEXT, + verify_attempts INTEGER NOT NULL DEFAULT 0, + verified BOOLEAN NOT NULL DEFAULT FALSE, + published BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + ip_address TEXT, + user_agent TEXT +); +CREATE INDEX IF NOT EXISTS comments_article_uuid_idx ON comments (article_uuid, verified, published); +CREATE INDEX IF NOT EXISTS comments_verify_token_idx ON comments (verify_token) + WHERE verified = FALSE AND verify_token IS NOT NULL; diff --git a/database/migration_008_user_profiles.sql b/database/migration_008_user_profiles.sql new file mode 100644 index 0000000..7b77bd1 --- /dev/null +++ b/database/migration_008_user_profiles.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS user_profiles ( + email TEXT NOT NULL PRIMARY KEY, + display_name TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP DEFAULT now(), + profile_url TEXT NOT NULL DEFAULT '', + profile_slug TEXT NOT NULL DEFAULT '', + bio TEXT NOT NULL DEFAULT '' +); +CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_profile_slug_idx + ON user_profiles (profile_slug) WHERE profile_slug <> ''; diff --git a/database/migration_009_journal_smtp.sql b/database/migration_009_journal_smtp.sql new file mode 100644 index 0000000..fa81841 --- /dev/null +++ b/database/migration_009_journal_smtp.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS journal_smtp ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + script_path VARCHAR(512), + to_email VARCHAR(255) NOT NULL, + subject VARCHAR(512), + content_html TEXT, + content_text TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'queued', + ip VARCHAR(128), + user_agent VARCHAR(512), + error_message VARCHAR(1000), + sent_at TIMESTAMP WITH TIME ZONE +); +CREATE INDEX IF NOT EXISTS idx_journal_smtp_created_at ON journal_smtp (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_journal_smtp_to_email ON journal_smtp (to_email); diff --git a/database/migration_010_role_capabilities.sql b/database/migration_010_role_capabilities.sql new file mode 100644 index 0000000..4678b8e --- /dev/null +++ b/database/migration_010_role_capabilities.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS role_capabilities ( + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + capability VARCHAR(50) NOT NULL, + PRIMARY KEY (role_id, capability) +); diff --git a/database/migration_011_user_capabilities.sql b/database/migration_011_user_capabilities.sql new file mode 100644 index 0000000..a1dc52b --- /dev/null +++ b/database/migration_011_user_capabilities.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS user_capabilities ( + user_email TEXT NOT NULL, + capability TEXT NOT NULL, + granted_by TEXT, + granted_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + PRIMARY KEY (user_email, capability) +); diff --git a/database/migration_012_users.sql b/database/migration_012_users.sql new file mode 100644 index 0000000..164997a --- /dev/null +++ b/database/migration_012_users.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMP, + password_changed_at TIMESTAMP +); diff --git a/database/migration_013_profiles.sql b/database/migration_013_profiles.sql new file mode 100644 index 0000000..264ee59 --- /dev/null +++ b/database/migration_013_profiles.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS profiles ( + id SERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + description TEXT, + permissions JSONB NOT NULL DEFAULT '[]', + is_system BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); diff --git a/database/migration_014_app_config.sql b/database/migration_014_app_config.sql new file mode 100644 index 0000000..138a2d6 --- /dev/null +++ b/database/migration_014_app_config.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS app_config ( + id INTEGER PRIMARY KEY DEFAULT 1, + allow_password BOOLEAN NOT NULL DEFAULT TRUE, + allow_oidc BOOLEAN NOT NULL DEFAULT FALSE, + registrations_open BOOLEAN NOT NULL DEFAULT TRUE, + oidc_issuer TEXT, + oidc_name TEXT, + oidc_client_id TEXT, + oidc_client_secret TEXT, + oidc_redirect_uri TEXT, + updated_at TIMESTAMP, + CONSTRAINT app_config_single_row CHECK (id = 1) +); diff --git a/database/migration_015_mail_queue.sql b/database/migration_015_mail_queue.sql new file mode 100644 index 0000000..8b0340c --- /dev/null +++ b/database/migration_015_mail_queue.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS mail_queue ( + id SERIAL PRIMARY KEY, + to_email TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + available_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + locked_at TIMESTAMP WITH TIME ZONE, + last_error TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_mail_queue_pending + ON mail_queue (available_at ASC, id ASC) + WHERE status = 'pending'; diff --git a/database/migration_016_dictionary.sql b/database/migration_016_dictionary.sql new file mode 100644 index 0000000..b07812f --- /dev/null +++ b/database/migration_016_dictionary.sql @@ -0,0 +1,44 @@ +-- Tables du dictionnaire de données (formulaires dynamiques) + +CREATE TABLE IF NOT EXISTS dd_entities ( + id SERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS dd_fields ( + id SERIAL PRIMARY KEY, + entity_id INTEGER NOT NULL REFERENCES dd_entities(id) ON DELETE CASCADE, + code TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + field_type TEXT NOT NULL DEFAULT 'text', + ui_order INTEGER, + is_required BOOLEAN NOT NULL DEFAULT FALSE, + default_val TEXT, + UNIQUE (entity_id, code) +); + +CREATE TABLE IF NOT EXISTS dd_rules ( + id SERIAL PRIMARY KEY, + entity_id INTEGER NOT NULL REFERENCES dd_entities(id) ON DELETE CASCADE, + rule_type TEXT NOT NULL, + expression TEXT, + message TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS dd_enums ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS dd_enum_values ( + id SERIAL PRIMARY KEY, + enum_id INTEGER NOT NULL REFERENCES dd_enums(id) ON DELETE CASCADE, + code TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (enum_id, code) +); diff --git a/templates/404.php b/templates/404.php new file mode 100644 index 0000000..3abf21e --- /dev/null +++ b/templates/404.php @@ -0,0 +1,19 @@ + +
+

404

+

Page introuvable

+

+ Cette adresse ne correspond à aucun contenu.
+ Vous avez peut-être suivi un ancien lien. +

+ ← Retour à l'accueil +
+