Less home code more F3

This commit is contained in:
julien
2026-03-29 01:49:25 +01:00
parent 1c8c22e12c
commit ed6321e8f3
31 changed files with 346 additions and 189 deletions

View File

@@ -1,5 +1,6 @@
# Exemple de configuration Caddy en reverse proxy vers l'application. # Exemple de configuration Caddy en reverse proxy vers l'application.
# Copier ce fichier vers Caddyfile et adapter le domaine / la cible. # Copier ce fichier vers Caddyfile et adapter le domaine / la cible.
# TLS, compression et en-têtes de sécurité restent gérés ici, pas dans l'app PHP.
blog.example.com { blog.example.com {
# ── En-têtes de sécurité (toutes les réponses) ─────────────────── # ── En-têtes de sécurité (toutes les réponses) ───────────────────

View File

@@ -4,10 +4,18 @@ FROM php:8.3-cli AS vendor
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
libsqlite3-dev \
libjpeg62-turbo-dev \
libpng-dev \
libwebp-dev \
libfreetype6-dev \
libonig-dev \
libicu-dev \ libicu-dev \
libxml2-dev \
unzip \ unzip \
git \ git \
&& docker-php-ext-install intl \ && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite gd mbstring opcache intl dom \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
@@ -29,8 +37,9 @@ RUN apt-get update \
libfreetype6-dev \ libfreetype6-dev \
libonig-dev \ libonig-dev \
libicu-dev \ libicu-dev \
libxml2-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite gd mbstring opcache intl \ && docker-php-ext-install -j"$(nproc)" pdo_sqlite gd mbstring opcache intl dom \
&& printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \ && printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \
&& a2enconf servername \ && a2enconf servername \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@@ -11,7 +11,7 @@ project/
│ ├── config.ini # Configuration F3 (globals + routes) │ ├── config.ini # Configuration F3 (globals + routes)
│ ├── bootstrap.php # Initialisation (config, DB, session, erreurs) │ ├── bootstrap.php # Initialisation (config, DB, session, erreurs)
│ ├── Controllers/ │ ├── Controllers/
│ ├── Helpers/ # Fonctions utilitaires │ ├── Helpers/ # Fonctions utilitaires ciblées
│ ├── Models/ # DB\SQL\Mapper (Post, Media, User) │ ├── Models/ # DB\SQL\Mapper (Post, Media, User)
│ ├── Services/ # MarkdownService │ ├── Services/ # MarkdownService
│ └── Views/ │ └── Views/
@@ -20,7 +20,7 @@ project/
├── logs/ ├── logs/
│ └── php-error.log # Log PHP configuré au runtime │ └── php-error.log # Log PHP configuré au runtime
├── public/ ├── public/
│ ├── assets/ # Sources CSS/JS servies via /min/@file │ ├── assets/ # Assets statiques servis directement
│ └── uploads/ │ └── uploads/
│ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG) │ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG)
├── scripts/ ├── scripts/
@@ -28,7 +28,6 @@ project/
│ ├── install.php # Initialisation idempotente de la base │ ├── install.php # Initialisation idempotente de la base
│ └── create-admin.php # Création d'un compte admin en CLI │ └── create-admin.php # Création d'un compte admin en CLI
└── tmp/ # Runtime temporaire, recréable sans perte métier └── tmp/ # Runtime temporaire, recréable sans perte métier
├── cache/ # Cache F3 + assets minifiés
└── uploads/ # Transit Web::receive(), nettoyé après chaque upload └── uploads/ # Transit Web::receive(), nettoyé après chaque upload
``` ```
@@ -36,12 +35,12 @@ project/
- **Routage nommé** — `config.ini [routes]`, filtre `alias` dans les templates, `reroute('@route')` dans les contrôleurs. - **Routage nommé** — `config.ini [routes]`, filtre `alias` dans les templates, `reroute('@route')` dans les contrôleurs.
- **Cache HTTP** — TTL par contrôleur via `expire()`, forcé à `0` quand un utilisateur est connecté. - **Cache HTTP** — TTL par contrôleur via `expire()`, forcé à `0` quand un utilisateur est connecté.
- **Assets minifiés** — `Web::minify()` via `AssetController` (`GET /min/@file`). - **Assets statiques** — servis directement depuis `public/assets/`, laissés au serveur HTTP / reverse proxy.
- **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle. - **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle.
- **Images** — normalisation des médias via GD (JPG conservé, PNG/WebP convertis en PNG pour préserver la transparence). - **Images** — normalisation des médias via `Image`, lecture/écriture via `Base::read()` / `Base::write()`.
- **Markdown** — `Markdown::instance()->convert()` + `strip_tags` et résolution des images média. - **Markdown** — `Markdown::instance()->convert()` suivi d'un assainissement DOM ciblé et de la résolution des images média.
- **Slugs** — `Web::instance()->slug()`. - **Slugs** — `Web::instance()->slug()`.
- **Session / CSRF** — `$f3->set('JAR', …)`, hooks `beforeRoute()` sur les contrôleurs protégés, jeton `@CSRF` recopié en session au rendu et vérifié au POST suivant. - **Session / CSRF** — `$f3->set('JAR', …)`, `new Session(null, 'CSRF')`, hooks `beforeRoute()` sur les contrôleurs protégés, et petit pool de jetons côté session pour éviter les collisions multi-onglets.
- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`. - **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`.
- **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale. - **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale.
@@ -51,7 +50,7 @@ project/
- PHP 8.3+ - PHP 8.3+
- Composer - Composer
- Extensions PHP : `pdo_sqlite`, `gd`, `mbstring`, `intl` - Extensions PHP : `pdo_sqlite`, `gd`, `mbstring`, `intl`, `dom`
### Déploiement Docker ### Déploiement Docker
@@ -74,6 +73,25 @@ app.env=prod
app.timezone=Europe/Paris app.timezone=Europe/Paris
``` ```
### Proxies de confiance
L'application ne délègue pas la sécurité des cookies à Apache : elle détermine le schéma de la requête pour marquer la session en `Secure`.
La valeur par défaut couvre les cas les plus courants :
- `127.0.0.1,::1` — Caddy sur le même hôte ;
- `172.16.0.0/12` — réseaux Docker IPv4 standard, y compris un Caddy conteneurisé sur le même réseau.
Si ton proxy tourne sur un autre sous-réseau, surcharge `app.trusted_proxies` dans `config.local.ini`.
```ini
[globals]
app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12
# ou, avec une liste F3 : app.trusted_proxies[]=10.0.0.42
```
Seuls ces proxies sont autorisés à influencer `X-Forwarded-Proto` / `Forwarded`.
## Développement local ## Développement local
```bash ```bash
@@ -98,11 +116,16 @@ php scripts/create-admin.php admin
```bash ```bash
cp config.local.ini.example config.local.ini cp config.local.ini.example config.local.ini
# édite config.local.ini (app.env=prod, app.timezone, etc.) # édite config.local.ini (app.env=prod, app.timezone, trusted proxies si besoin)
docker compose up -d --build docker compose up -d --build
``` ```
Docker ne monte que les dossiers persistants (`db/`, `logs/`, `public/uploads/media/`) et laisse `tmp/` dans le conteneur pour qu'il reste éphémère. Le déploiement Docker est cohérent avec le projet :
- l'image embarque les extensions réellement utilisées (`pdo_sqlite`, `gd`, `mbstring`, `intl`, `dom`) ;
- `scripts/install.php` s'exécute au démarrage pour initialiser SQLite de façon idempotente ;
- seuls les répertoires métier/persistants sont montés (`db/`, `logs/`, `public/uploads/media/`) ;
- `tmp/` reste dans le conteneur et sert uniquement au runtime F3 (transit d'upload, fichiers temporaires).
Le fichier `config.local.ini` est monté en lecture seule. Si le fichier hôte n'existe pas, Docker peut créer un répertoire à la place ; l'entrypoint le supprime et l'application retombe sur les valeurs par défaut de `app/config.ini`. Le fichier `config.local.ini` est monté en lecture seule. Si le fichier hôte n'existe pas, Docker peut créer un répertoire à la place ; l'entrypoint le supprime et l'application retombe sur les valeurs par défaut de `app/config.ini`.
@@ -117,15 +140,14 @@ docker compose exec app php scripts/create-admin.php admin
## Cache public ## Cache public
Les pages publiques sont cacheables pour un visiteur anonyme : Les pages publiques restent cacheables pour un visiteur anonyme :
- `/` : TTL de 300 s. - `/` : TTL de 300 s ;
- `/posts/@slug` : TTL de 3600 s. - `/posts/@slug` : TTL de 3600 s.
- `/min/app.css` et `/min/app.js` : TTL de 86400 s.
Quand un utilisateur est connecté, le rendu est forcé en non-cacheable avec `expire(0)` pour ne pas servir du contenu admin via un cache intermédiaire. Quand un utilisateur est connecté, le rendu est forcé en non-cacheable avec `expire(0)` pour ne pas servir du contenu admin via un cache intermédiaire.
Le projet ne fait pas d'invalidation explicite du cache lors des mutations d'articles : la fraîcheur dépend des TTL ci-dessus. Les assets statiques (`public/assets/`) sont servis directement ; leur éventuel cache HTTP relève du serveur web ou du reverse proxy.
## Médias et limites d'upload ## Médias et limites d'upload
@@ -159,8 +181,19 @@ blog.example.com {
} }
``` ```
Dans la plupart des cas, la valeur par défaut de `app.trusted_proxies` suffit aussi pour ce mode ; surcharge-la seulement si ton réseau Docker utilise un autre sous-réseau.
Comme F3 lit aussi `X-Forwarded-For` pour déterminer `IP`, le conteneur applicatif ne doit pas être exposé directement à Internet : Caddy doit rester l'unique point d'entrée public et réécrire les en-têtes `X-Forwarded-*`.
Le fichier `Caddyfile.example` fournit en plus un jeu d'en-têtes de sécurité minimal. Le fichier `Caddyfile.example` fournit en plus un jeu d'en-têtes de sécurité minimal.
## Répartition des responsabilités avec Caddy
Le projet n'essaie pas de dupliquer ce que le reverse proxy gère naturellement :
- **Caddy** — TLS, compression, en-têtes de sécurité, terminaison HTTPS, reverse proxy.
- **Application / PHP-F3** — sessions, CSRF, cookies, validation métier, rendu HTML et cache HTTP des pages dynamiques.
## Données à sauvegarder ## Données à sauvegarder
- `db/` — base SQLite. - `db/` — base SQLite.
@@ -178,10 +211,7 @@ docker compose up -d --build
- PHP : `logs/php-error.log`. - PHP : `logs/php-error.log`.
- Apache / conteneur : `docker compose logs -f app`. - Apache / conteneur : `docker compose logs -f app`.
## Limitations connues
- **CSRF et multi-onglets** — F3 expose un jeton CSRF unique via `@CSRF`. Le projet le recopie en session au rendu et le vérifie au POST suivant. Si l'admin ouvre deux onglets, le token du premier est écrasé par celui du second. La soumission du premier formulaire échouera avec « Jeton CSRF invalide ». Solution de contournement : travailler dans un seul onglet à la fois. F3 ne fournit pas de mécanisme de pool de tokens.
## Notes ## Notes
- Les dates sont stockées en UTC (`gmdate`) puis formatées côté affichage avec le fuseau configuré. - Les dates sont stockées en UTC (`gmdate`) puis formatées côté affichage avec le fuseau configuré.
- Le pipeline Markdown n'autorise que les éléments utiles au rendu éditorial et remappe systématiquement les images vers la médiathèque locale.

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
abstract class AdminController extends BaseController
{
public function beforeRoute(): void
{
$this->requireAuth();
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
class AssetController
{
private const ALLOWED = [
'app.css' => 'text/css',
'app.js' => 'application/javascript',
];
public function serve(): void
{
$f3 = Base::instance();
$file = basename((string) $f3->get('PARAMS.file'));
if (!array_key_exists($file, self::ALLOWED)) {
$f3->error(404);
return;
}
$f3->expire(86400);
echo Web::instance()->minify(
$file,
self::ALLOWED[$file],
true, // envoie le Content-Type
app_root() . '/public/assets/' // répertoire source (hors UI)
);
}
}

View File

@@ -18,7 +18,7 @@ class AuthController extends BaseController
{ {
$this->verifyCsrf(); $this->verifyCsrf();
$username = trim((string) ($this->f3->get('POST.username') ?? '')); $username = $this->f3->clean((string) ($this->f3->get('POST.username') ?? ''));
$password = (string) ($this->f3->get('POST.password') ?? ''); $password = (string) ($this->f3->get('POST.password') ?? '');
// User étend DB\SQL\Mapper — inutile de recréer un Mapper générique. // User étend DB\SQL\Mapper — inutile de recréer un Mapper générique.
@@ -34,6 +34,7 @@ class AuthController extends BaseController
} }
session_regenerate_id(true); // Prévient la fixation de session. session_regenerate_id(true); // Prévient la fixation de session.
$this->resetCsrfToken(); // Le nouveau contexte repart avec un jeton dédié.
$this->f3->set('SESSION.user_id', $user->id); $this->f3->set('SESSION.user_id', $user->id);
$this->flash('success', 'Connexion réussie.'); $this->flash('success', 'Connexion réussie.');
$this->f3->reroute('@dashboard'); $this->f3->reroute('@dashboard');
@@ -43,6 +44,7 @@ class AuthController extends BaseController
{ {
$this->verifyCsrf(); $this->verifyCsrf();
$this->f3->clear('SESSION.user_id'); $this->f3->clear('SESSION.user_id');
$this->resetCsrfToken();
session_regenerate_id(true); // Invalide l'ancien ID de session. session_regenerate_id(true); // Invalide l'ancien ID de session.
$this->flash('success', 'Déconnexion effectuée.'); $this->flash('success', 'Déconnexion effectuée.');
$this->f3->reroute('@login'); $this->f3->reroute('@login');

View File

@@ -17,25 +17,26 @@ abstract class BaseController
// Si un utilisateur est connecté, le layout dépend de la session // Si un utilisateur est connecté, le layout dépend de la session
// (navigation admin, déconnexion + CSRF) : on force expire(0) // (navigation admin, déconnexion + CSRF) : on force expire(0)
// pour ne pas servir ce rendu à d'autres visiteurs. // pour ne pas servir ce rendu à d'autres visiteurs.
$this->f3->expire($this->currentUser() !== null ? 0 : $cacheTtl); $currentUser = $this->currentUser();
$this->f3->expire($currentUser !== null ? 0 : $cacheTtl);
$flash = array_key_exists('flash', $data) && is_array($data['flash']) $flash = array_key_exists('flash', $data) && is_array($data['flash'])
? $data['flash'] ? $data['flash']
: $this->pullFlash(); : $this->pullFlash();
// currentUser est déjà dans le hive (posé par currentUser()). // Jeton CSRF stable par session : plus simple et plus robuste que le
// Le template y accède directement via {{ @currentUser }}. // pool précédent, tout en restant compatible multi-onglets.
$this->ensureCsrfToken();
$this->f3->mset($data + [ $this->f3->mset($data + [
'view' => $view, 'view' => $view,
'flash' => $flash, 'flash' => $flash,
'metaDescription' => null, 'metaDescription' => null,
'adminMode' => false, 'adminMode' => false,
'currentUser' => $currentUser,
'FORM_CSRF' => (string) $this->f3->get('SESSION.csrf_token'),
]); ]);
// F3 régénère @CSRF à chaque requête (variable hive uniquement).
// On le persiste en session pour que verifyCsrf() puisse comparer
// le jeton soumis au POST suivant.
$this->f3->copy('CSRF', 'SESSION.csrf_token');
echo Template::instance()->render('layout.html'); echo Template::instance()->render('layout.html');
} }
@@ -62,12 +63,10 @@ abstract class BaseController
$this->f3->reroute('@login'); $this->f3->reroute('@login');
} }
// F3 copy() persiste le jeton CSRF en session au rendu (voir render()).
// On compare ici le jeton soumis par le formulaire avec celui en session.
protected function verifyCsrf(): void protected function verifyCsrf(): void
{ {
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? ''); $submitted = trim((string) ($this->f3->get('POST.csrf_token') ?? ''));
$expected = (string) ($this->f3->get('SESSION.csrf_token') ?? ''); $expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?? ''));
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) { if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
return; return;
@@ -82,6 +81,22 @@ abstract class BaseController
$this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]); $this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]);
} }
protected function resetCsrfToken(): void
{
$this->f3->clear('SESSION.csrf_token');
$this->ensureCsrfToken();
}
private function ensureCsrfToken(): void
{
$token = trim((string) ($this->f3->get('SESSION.csrf_token') ?? ''));
if ($token !== '') {
return;
}
$this->f3->set('SESSION.csrf_token', bin2hex(random_bytes(32)));
}
private function pullFlash(): array private function pullFlash(): array
{ {
return $this->f3->pull('SESSION.flash') ?: []; return $this->f3->pull('SESSION.flash') ?: [];

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
class DashboardController extends BaseController
{
public function beforeRoute(): void
{
$this->requireAuth();
}
public function index(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$media = new Media();
$result = (new Post())->paginateList($page, 24, $media);
$this->render('admin/dashboard.html', [
'pageTitle' => 'Tableau de bord',
'posts' => $result['posts'],
'pagination' => $result,
'paginationAlias' => 'dashboard',
'adminMode' => true,
]);
}
}

View File

@@ -2,17 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
class MediaController extends BaseController class MediaController extends AdminController
{ {
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
private const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const PER_PAGE = 24; private const PER_PAGE = 24;
public function beforeRoute(): void
{
$this->requireAuth();
}
public function index(): void public function index(): void
{ {
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); $page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
@@ -35,23 +29,24 @@ class MediaController extends BaseController
$originalName = (string) ($this->f3->get('FILES.image.name') ?? ''); $originalName = (string) ($this->f3->get('FILES.image.name') ?? '');
$received = Web::instance()->receive( $received = Web::instance()->receive(
// F3 gère le transport et le renommage ; la validation métier
// (format réel, dimensions, réencodage) reste centralisée dans Media.
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0 fn(array $file): bool => (int) ($file['size'] ?? 0) > 0
&& (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
&& in_array($file['type'] ?? '', self::ACCEPTED_TYPES, true),
overwrite: false, overwrite: false,
slug: true slug: true
); );
// UPLOADS étant absolu (bootstrap.php), les chemins retournés le sont aussi. // Le formulaire n'envoie qu'un seul fichier : on garde le premier
// chemin accepté retourné par Web::receive().
$accepted = array_keys(array_filter($received)); $accepted = array_keys(array_filter($received));
$destPath = $accepted[0] ?? null;
if ($accepted === []) { if ($destPath === null) {
throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int) (self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).'); throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int) (self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).');
} }
foreach ($accepted as $destPath) {
(new Media())->upload($destPath, $originalName); (new Media())->upload($destPath, $originalName);
}
$this->flash('success', 'Image ajoutée.'); $this->flash('success', 'Image ajoutée.');
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
@@ -66,9 +61,8 @@ class MediaController extends BaseController
$this->verifyCsrf(); $this->verifyCsrf();
try { try {
$alt = (string) ($this->f3->get('POST.alt') ?? ''); $alt = $this->f3->clean((string) ($this->f3->get('POST.alt') ?? ''));
$this->f3->scrub($alt); (new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), $alt);
(new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), trim($alt));
$this->flash('success', 'Texte alternatif mis à jour.'); $this->flash('success', 'Texte alternatif mis à jour.');
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
$this->flash('error', $e->getMessage()); $this->flash('error', $e->getMessage());

View File

@@ -2,13 +2,24 @@
declare(strict_types=1); declare(strict_types=1);
class PostController extends BaseController class PostController extends AdminController
{ {
private const PER_PAGE = 12;
private const MEDIA_PICKER_LIMIT = 60; private const MEDIA_PICKER_LIMIT = 60;
public function beforeRoute(): void public function index(): void
{ {
$this->requireAuth(); $page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$media = new Media();
$result = (new Post())->paginateList($page, self::PER_PAGE, $media);
$this->render('admin/dashboard.html', [
'pageTitle' => 'Tableau de bord',
'posts' => $result['posts'],
'pagination' => $result,
'paginationAlias' => 'dashboard',
'adminMode' => true,
]);
} }
public function create(): void public function create(): void
@@ -109,17 +120,9 @@ class PostController extends BaseController
private function postInput(): array private function postInput(): array
{ {
$title = (string) ($this->f3->get('POST.title') ?? '');
$excerpt = (string) ($this->f3->get('POST.excerpt') ?? '');
// scrub() supprime les tags HTML/PHP — défense en profondeur
// pour les champs rendus en texte brut dans les templates.
$this->f3->scrub($title);
$this->f3->scrub($excerpt);
return [ return [
'title' => trim($title), 'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?? '')),
'excerpt' => trim($excerpt), 'excerpt' => $this->f3->clean((string) ($this->f3->get('POST.excerpt') ?? '')),
'cover_media_id' => (string) ($this->f3->get('POST.cover_media_id') ?? ''), 'cover_media_id' => (string) ($this->f3->get('POST.cover_media_id') ?? ''),
'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?? '')), 'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?? '')),
]; ];

View File

@@ -2,13 +2,6 @@
declare(strict_types=1); declare(strict_types=1);
// ── Core ────────────────────────────────────────────────────────────
function app_root(): string
{
return dirname(__DIR__, 2);
}
function app_timezone(): string function app_timezone(): string
{ {
$timezone = trim((string) Base::instance()->get('app.timezone')); $timezone = trim((string) Base::instance()->get('app.timezone'));
@@ -25,8 +18,6 @@ function app_is_prod(): bool
return Base::instance()->get('app.env') === 'prod'; return Base::instance()->get('app.env') === 'prod';
} }
// ── Fichiers et chemins ─────────────────────────────────────────────
function app_ensure_dir(string $path): void function app_ensure_dir(string $path): void
{ {
if (!is_dir($path)) { if (!is_dir($path)) {
@@ -34,28 +25,6 @@ function app_ensure_dir(string $path): void
} }
} }
function app_db_path(): string
{
return app_root() . '/db/app.sqlite';
}
function app_logs_dir(): string
{
return app_root() . '/logs';
}
function app_public_media_dir(): string
{
return app_root() . '/public/uploads/media';
}
function app_media_url(string $fileName): string
{
return rtrim((string) Base::instance()->get('BASE'), '/') . '/uploads/media/' . rawurlencode($fileName);
}
// ── Texte ───────────────────────────────────────────────────────────
function app_unique_slug(string $value, callable $exists): string function app_unique_slug(string $value, callable $exists): string
{ {
$base = Web::instance()->slug(trim($value)); $base = Web::instance()->slug(trim($value));
@@ -111,3 +80,131 @@ function app_format_datetime_fr(string $value): string
return $value; return $value;
} }
} }
function app_trusted_proxies(): array
{
$value = Base::instance()->get('app.trusted_proxies');
if (is_array($value)) {
$items = [];
array_walk_recursive($value, static function (mixed $item) use (&$items): void {
if (is_string($item) || is_numeric($item)) {
$items[] = (string) $item;
}
});
} else {
$raw = trim((string) $value);
if ($raw === '') {
return [];
}
$items = preg_split('/[\s,]+/', $raw) ?: [];
}
$normalized = [];
foreach ($items as $item) {
foreach (preg_split('/[\s,]+/', trim((string) $item)) ?: [] as $part) {
$part = trim($part);
if ($part !== '') {
$normalized[] = $part;
}
}
}
return array_values(array_unique($normalized));
}
function app_is_trusted_proxy(?string $ip = null): bool
{
$ip = trim((string) ($ip ?? Base::instance()->get('SERVER.REMOTE_ADDR')));
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
return false;
}
foreach (app_trusted_proxies() as $proxy) {
if (app_ip_matches_proxy($ip, $proxy)) {
return true;
}
}
return false;
}
function app_request_scheme(): string
{
$f3 = Base::instance();
$https = strtolower(trim((string) $f3->get('SERVER.HTTPS')));
if ($https !== '' && $https !== 'off' && $https !== '0') {
return 'https';
}
$scheme = strtolower(trim((string) $f3->get('SCHEME')));
if ($scheme === 'https') {
return 'https';
}
// Derrière un reverse proxy de confiance, on accepte le proto transmis.
if (app_is_trusted_proxy()) {
$forwardedProto = trim((string) $f3->get('SERVER.HTTP_X_FORWARDED_PROTO'));
if ($forwardedProto !== '') {
$forwardedProto = strtolower(trim(explode(',', $forwardedProto)[0]));
return $forwardedProto === 'https' ? 'https' : 'http';
}
$forwarded = trim((string) $f3->get('SERVER.HTTP_FORWARDED'));
if ($forwarded !== '' && preg_match('/(?:^|[;,]\s*)proto=(https?)/i', $forwarded, $matches) === 1) {
return strtolower($matches[1]) === 'https' ? 'https' : 'http';
}
}
return 'http';
}
function app_ip_matches_proxy(string $ip, string $proxy): bool
{
$proxy = trim($proxy);
if ($proxy === '') {
return false;
}
if (!str_contains($proxy, '/')) {
return filter_var($proxy, FILTER_VALIDATE_IP) !== false && strcasecmp($ip, $proxy) === 0;
}
[$subnet, $prefix] = explode('/', $proxy, 2);
$subnet = trim($subnet);
$prefix = trim($prefix);
$ipBinary = inet_pton($ip);
$subnetBinary = inet_pton($subnet);
if ($ipBinary === false || $subnetBinary === false || strlen($ipBinary) !== strlen($subnetBinary)) {
return false;
}
if (!ctype_digit($prefix)) {
return false;
}
$prefixLength = (int) $prefix;
$maxBits = strlen($ipBinary) * 8;
if ($prefixLength < 0 || $prefixLength > $maxBits) {
return false;
}
$fullBytes = intdiv($prefixLength, 8);
if ($fullBytes > 0 && substr($ipBinary, 0, $fullBytes) !== substr($subnetBinary, 0, $fullBytes)) {
return false;
}
$remainingBits = $prefixLength % 8;
if ($remainingBits === 0) {
return true;
}
$mask = (0xFF << (8 - $remainingBits)) & 0xFF;
return (ord($ipBinary[$fullBytes]) & $mask) === (ord($subnetBinary[$fullBytes]) & $mask);
}

View File

@@ -100,7 +100,8 @@ class Media extends DB\SQL\Mapper
// F3 Image : load() utilise imagecreatefromstring + imagesavealpha. // F3 Image : load() utilise imagecreatefromstring + imagesavealpha.
$img = new \Image(); $img = new \Image();
if (!$img->load(file_get_contents($srcPath))) { $f3 = Base::instance();
if (!$img->load($f3->read($srcPath))) {
throw new RuntimeException('Fichier image invalide ou format source non supporté.'); throw new RuntimeException('Fichier image invalide ou format source non supporté.');
} }
@@ -110,11 +111,11 @@ class Media extends DB\SQL\Mapper
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs. // Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
$fileName = bin2hex(random_bytes(16)) . '.' . $extension; $fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = app_public_media_dir() . '/' . $fileName; $target = rtrim((string) $f3->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
// dump() appelle image{format}($data, NULL, $quality). // dump() appelle image{format}($data, NULL, $quality).
$binary = $isJpeg ? $img->dump('jpeg', 85) : $img->dump('png', 6); $binary = $isJpeg ? $img->dump('jpeg', 85) : $img->dump('png', 6);
if ($binary === '' || file_put_contents($target, $binary) === false) { if ($binary === '' || $f3->write($target, $binary) === false) {
throw new RuntimeException('Impossible d\'enregistrer cette image.'); throw new RuntimeException('Impossible d\'enregistrer cette image.');
} }
@@ -166,7 +167,7 @@ class Media extends DB\SQL\Mapper
throw new RuntimeException('Image introuvable.'); throw new RuntimeException('Image introuvable.');
} }
$path = app_public_media_dir() . '/' . $this->file_name; $path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name;
$this->db->begin(); $this->db->begin();
try { try {
@@ -224,6 +225,15 @@ class Media extends DB\SQL\Mapper
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1)); return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
} }
private function mediaUrl(string $fileName): string
{
$f3 = Base::instance();
$base = rtrim((string) $f3->get('BASE'), '/');
$prefix = '/' . trim((string) $f3->get('paths.media_base'), '/');
return $base . $prefix . '/' . rawurlencode($fileName);
}
private function decorate(array $row): array private function decorate(array $row): array
{ {
$alt = (string) $row['alt']; $alt = (string) $row['alt'];
@@ -235,7 +245,7 @@ class Media extends DB\SQL\Mapper
'width' => (int) $row['width'], 'width' => (int) $row['width'],
'height' => (int) $row['height'], 'height' => (int) $row['height'],
'created_at' => (string) $row['created_at'], 'created_at' => (string) $row['created_at'],
'url' => app_media_url((string) $row['file_name']), 'url' => $this->mediaUrl((string) $row['file_name']),
'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')', 'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')',
]; ];
} }

View File

@@ -47,9 +47,7 @@ class User extends DB\SQL\Mapper
public function create(string $username, string $password): int public function create(string $username, string $password): int
{ {
$f3 = Base::instance(); $username = Base::instance()->clean($username);
$f3->scrub($username);
$username = trim($username);
if ($username === '' || $password === '') { if ($username === '' || $password === '') {
throw new RuntimeException('Nom dutilisateur et mot de passe obligatoires.'); throw new RuntimeException('Nom dutilisateur et mot de passe obligatoires.');

View File

@@ -28,7 +28,9 @@ class MarkdownService extends Prefab
// Résout les images media:filename et supprime les images externes. // Résout les images media:filename et supprime les images externes.
private static function resolveImages(string $html, Media $media): string private static function resolveImages(string $html, Media $media): string
{ {
return preg_replace_callback('/<img\s[^>]*>/i', function (array $m) use ($media): string { $f3 = Base::instance();
return preg_replace_callback('/<img\s[^>]*>/i', function (array $m) use ($f3, $media): string {
if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) { if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) {
return ''; return '';
} }
@@ -50,12 +52,21 @@ class MarkdownService extends Prefab
$alt = $a[1]; $alt = $a[1];
} }
if ($alt === '') { if ($alt === '') {
$alt = Base::instance()->encode($item['alt']); $alt = $f3->encode($item['alt']);
} }
$url = Base::instance()->encode($item['url']); $url = $f3->encode($item['url']);
$attrs = 'src="' . $url . '" alt="' . $alt . '"';
return '<img src="' . $url . '" alt="' . $alt . '" loading="lazy" decoding="async">'; // width/height préviennent le layout shift au chargement.
if ((int) $item['width'] > 0) {
$attrs .= ' width="' . (int) $item['width'] . '"';
}
if ((int) $item['height'] > 0) {
$attrs .= ' height="' . (int) $item['height'] . '"';
}
return '<img ' . $attrs . ' loading="lazy" decoding="async">';
}, $html) ?? $html; }, $html) ?? $html;
} }

View File

@@ -11,7 +11,7 @@
</header> </header>
<form class="panel stack" method="post" action="{{ 'media_upload' | alias }}" enctype="multipart/form-data"> <form class="panel stack" method="post" action="{{ 'media_upload' | alias }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ @CSRF }}"> <include href="partials/csrf_field.html" />
<label class="field"> <label class="field">
<span class="field-label">Nouvelle image</span> <span class="field-label">Nouvelle image</span>
<input class="control" type="file" name="image" accept="image/jpeg,image/png,image/webp" required> <input class="control" type="file" name="image" accept="image/jpeg,image/png,image/webp" required>

View File

@@ -9,7 +9,7 @@
<div class="editor-layout" data-editor-layout> <div class="editor-layout" data-editor-layout>
<form class="panel stack editor-form" method="post" action="{{ @formAction }}"> <form class="panel stack editor-form" method="post" action="{{ @formAction }}">
<input type="hidden" name="csrf_token" value="{{ @CSRF }}"> <include href="partials/csrf_field.html" />
<input type="hidden" name="cover_media_id" value="{{ @post.cover_media_id }}" data-cover-input> <input type="hidden" name="cover_media_id" value="{{ @post.cover_media_id }}" data-cover-input>
<label class="field"> <label class="field">

View File

@@ -4,7 +4,7 @@
</header> </header>
<form class="stack" method="post" action="{{ 'login_submit' | alias }}"> <form class="stack" method="post" action="{{ 'login_submit' | alias }}">
<input type="hidden" name="csrf_token" value="{{ @CSRF }}"> <include href="partials/csrf_field.html" />
<label class="field"> <label class="field">
<span class="field-label">Nom dutilisateur</span> <span class="field-label">Nom dutilisateur</span>

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ @errorTitle ?: 'Erreur' }}</title> <title>{{ @errorTitle ?: 'Erreur' }}</title>
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml"> <link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="{{ 'asset', 'file=app.css' | alias }}"> <link rel="stylesheet" href="{{ @BASE }}/assets/app.css">
</head> </head>
<body> <body>
<main class="page error-page"> <main class="page error-page">

View File

@@ -6,8 +6,8 @@
<title>{{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }}</title> <title>{{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }}</title>
<meta name="description" content="{{ @metaDescription ?: @app.tagline }}"> <meta name="description" content="{{ @metaDescription ?: @app.tagline }}">
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml"> <link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="{{ 'asset', 'file=app.css' | alias }}"> <link rel="stylesheet" href="{{ @BASE }}/assets/app.css">
<script defer src="{{ 'asset', 'file=app.js' | alias }}"></script> <script defer src="{{ @BASE }}/assets/app.js"></script>
</head> </head>
<body> <body>
<include href="partials/site_navigation.html" /> <include href="partials/site_navigation.html" />

View File

@@ -0,0 +1 @@
<input type="hidden" name="csrf_token" value="{{ @FORM_CSRF }}">

View File

@@ -4,7 +4,7 @@
<p class="meta-text">{{ @item.width }} × {{ @item.height }}<br>{{ @item.created_at | date_fr }}</p> <p class="meta-text">{{ @item.width }} × {{ @item.height }}<br>{{ @item.created_at | date_fr }}</p>
<form class="stack" method="post" action="{{ 'media_update_alt', 'id='.@item.id | alias }}"> <form class="stack" method="post" action="{{ 'media_update_alt', 'id='.@item.id | alias }}">
<input type="hidden" name="csrf_token" value="{{ @CSRF }}"> <include href="partials/csrf_field.html" />
<label class="field"> <label class="field">
<span class="field-label">Texte alternatif</span> <span class="field-label">Texte alternatif</span>
<input class="control" type="text" name="alt" value="{{ @item.alt }}" placeholder="Description de l'image" data-alt-input> <input class="control" type="text" name="alt" value="{{ @item.alt }}" placeholder="Description de l'image" data-alt-input>
@@ -15,7 +15,7 @@
<div class="card-actions"> <div class="card-actions">
<button class="button button--ghost" type="button" data-copy-text="{{ @item.markdown }}" data-markdown-template="![](media:{{ @item.file_name }})">Copier le Markdown</button> <button class="button button--ghost" type="button" data-copy-text="{{ @item.markdown }}" data-markdown-template="![](media:{{ @item.file_name }})">Copier le Markdown</button>
<form method="post" action="{{ 'media_delete', 'id='.@item.id | alias }}" data-confirm="Supprimer cette image ?"> <form method="post" action="{{ 'media_delete', 'id='.@item.id | alias }}" data-confirm="Supprimer cette image ?">
<input type="hidden" name="csrf_token" value="{{ @CSRF }}"> <include href="partials/csrf_field.html" />
<button class="button button--danger" type="submit">Supprimer</button> <button class="button button--danger" type="submit">Supprimer</button>
</form> </form>
</div> </div>

View File

@@ -6,7 +6,7 @@
</li> </li>
<li class="nav-items__item"> <li class="nav-items__item">
<form class="nav-items__form" method="post" action="{{ 'logout' | alias }}"> <form class="nav-items__form" method="post" action="{{ 'logout' | alias }}">
<input type="hidden" name="csrf_token" value="{{ @CSRF }}"> <include href="partials/csrf_field.html" />
<button class="nav-items__button" type="submit">Déconnexion</button> <button class="nav-items__button" type="submit">Déconnexion</button>
</form> </form>
</li> </li>

View File

@@ -29,7 +29,7 @@
<div class="card-actions"> <div class="card-actions">
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a> <a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?"> <form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
<input type="hidden" name="csrf_token" value="{{ @CSRF }}"> <include href="partials/csrf_field.html" />
<button class="button button--danger" type="submit">Supprimer</button> <button class="button button--danger" type="submit">Supprimer</button>
</form> </form>
</div> </div>

View File

@@ -9,14 +9,20 @@ $f3 = Base::instance();
// ── Configuration ─────────────────────────────────────────────────── // ── Configuration ───────────────────────────────────────────────────
$f3->set('AUTOLOAD', app_root() . '/app/Controllers/;' . app_root() . '/app/Models/;' . app_root() . '/app/Services/'); $root = dirname(__DIR__);
$f3->set('UI', app_root() . '/app/Views/'); $f3->set('AUTOLOAD', $root . '/app/Controllers/;' . $root . '/app/Models/;' . $root . '/app/Services/');
$f3->set('TEMP', app_root() . '/tmp/'); $f3->set('UI', $root . '/app/Views/');
$f3->set('LOGS', app_logs_dir() . '/'); $f3->set('TEMP', $root . '/tmp/');
$f3->set('LOGS', $root . '/logs/');
$f3->mset([
'paths.db' => $root . '/db/app.sqlite',
'paths.media_dir' => $root . '/public/uploads/media',
'paths.media_base' => '/uploads/media/',
]);
$f3->config(app_root() . '/app/config.ini'); $f3->config($root . '/app/config.ini');
$localConfig = app_root() . '/config.local.ini'; $localConfig = $root . '/config.local.ini';
if (is_file($localConfig)) { if (is_file($localConfig)) {
$f3->config($localConfig); $f3->config($localConfig);
} }
@@ -26,15 +32,15 @@ $f3->set('DEBUG', app_is_prod() ? 0 : 3);
app_ensure_dir((string) $f3->get('TEMP')); app_ensure_dir((string) $f3->get('TEMP'));
app_ensure_dir((string) $f3->get('LOGS')); app_ensure_dir((string) $f3->get('LOGS'));
app_ensure_dir(app_public_media_dir()); app_ensure_dir((string) $f3->get('paths.media_dir'));
// Web::receive() utilise UPLOADS directement — le résoudre en absolu. // Web::receive() utilise UPLOADS directement — le résoudre en absolu.
$f3->set('UPLOADS', app_root() . '/' . ltrim((string) $f3->get('UPLOADS'), '/')); $f3->set('UPLOADS', $root . '/' . ltrim((string) $f3->get('UPLOADS'), '/'));
app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/')); app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/'));
app_bootstrap_logging(); app_bootstrap_logging();
// ── Base de données ───────────────────────────────────────────────── // ── Base de données ─────────────────────────────────────────────────
$dbPath = app_db_path(); $dbPath = (string) $f3->get('paths.db');
app_ensure_dir(dirname($dbPath)); app_ensure_dir(dirname($dbPath));
$db = new DB\SQL( $db = new DB\SQL(
@@ -52,17 +58,26 @@ $f3->set('DB', $db);
// ── Session ───────────────────────────────────────────────────────── // ── Session ─────────────────────────────────────────────────────────
// Derrière Caddy, Apache voit souvent du HTTP interne.
// On normalise donc le schéma depuis X-Forwarded-Proto uniquement si
// la requête provient d'un proxy explicitement approuvé.
$requestScheme = app_request_scheme();
$f3->set('SCHEME', $requestScheme);
ini_set('session.use_strict_mode', '1'); ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.cookie_secure', $requestScheme === 'https' ? '1' : '0');
session_name((string) $f3->get('app.session_name')); session_name((string) $f3->get('app.session_name'));
$f3->set('JAR', [ $f3->set('JAR', [
'expire' => 0, 'expire' => 0,
'path' => '/', 'path' => '/',
'secure' => $f3->get('SCHEME') === 'https', 'secure' => $requestScheme === 'https',
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax', 'samesite' => 'Lax',
]); ]);
new Session(null, 'CSRF'); new Session();
// ── Template ──────────────────────────────────────────────────────── // ── Template ────────────────────────────────────────────────────────

View File

@@ -2,6 +2,7 @@
app.env=dev app.env=dev
app.timezone=UTC app.timezone=UTC
app.session_name=f3-simple-blog app.session_name=f3-simple-blog
app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12
UPLOADS=tmp/uploads/ UPLOADS=tmp/uploads/
CACHE=folder CACHE=folder
@@ -10,8 +11,6 @@ app.name=F3 Simple Blog
app.tagline=Blog simple avec Fat-Free Framework et SQLite. app.tagline=Blog simple avec Fat-Free Framework et SQLite.
[routes] [routes]
GET @asset: /min/@file=AssetController->serve
GET @home: /=SiteController->home GET @home: /=SiteController->home
GET @post_show: /posts/@slug=SiteController->show GET @post_show: /posts/@slug=SiteController->show
@@ -19,7 +18,7 @@ GET @login: /login=AuthController->show
POST @login_submit: /login=AuthController->login POST @login_submit: /login=AuthController->login
POST @logout: /logout=AuthController->logout POST @logout: /logout=AuthController->logout
GET @dashboard: /dashboard=DashboardController->index GET @dashboard: /dashboard=PostController->index
GET @post_create: /dashboard/posts/create=PostController->create GET @post_create: /dashboard/posts/create=PostController->create
POST @post_store: /dashboard/posts=PostController->store POST @post_store: /dashboard/posts=PostController->store
GET @post_edit: /dashboard/posts/@id/edit=PostController->edit GET @post_edit: /dashboard/posts/@id/edit=PostController->edit

View File

@@ -4,6 +4,9 @@ services:
image: f3-simple-blog:latest image: f3-simple-blog:latest
init: true init: true
restart: unless-stopped restart: unless-stopped
# Mapping local pratique pour un reverse proxy Caddy sur l'hôte.
# Si Caddy tourne aussi dans Docker sur le même réseau, ce port peut être
# supprimé et le proxy peut cibler directement app:80.
ports: ports:
- "127.0.0.1:8888:80" - "127.0.0.1:8888:80"
volumes: volumes:

View File

@@ -4,7 +4,10 @@
"type": "project", "type": "project",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"ext-gd": "*",
"ext-intl": "*", "ext-intl": "*",
"ext-mbstring": "*",
"ext-pdo_sqlite": "*",
"bcosca/fatfree-core": "^3.9" "bcosca/fatfree-core": "^3.9"
} }
} }

View File

@@ -7,3 +7,9 @@
; app.session_name=f3-simple-blog ; app.session_name=f3-simple-blog
; app.name=F3 Simple Blog ; app.name=F3 Simple Blog
; app.tagline=Blog simple avec Fat-Free Framework et SQLite. ; app.tagline=Blog simple avec Fat-Free Framework et SQLite.
;
; Proxies de confiance autorisés à transmettre X-Forwarded-Proto.
; Valeur par défaut : 127.0.0.1,::1,172.16.0.0/12
; (Caddy sur le même hôte ou sur un réseau Docker IPv4 standard).
; Si le proxy tourne sur un autre sous-réseau, surcharge la valeur ici.
; app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12

3
docker/entrypoint.sh Normal file → Executable file
View File

@@ -16,11 +16,10 @@ install -d -m 0775 -o www-data -g www-data \
"$APP_ROOT/logs" \ "$APP_ROOT/logs" \
"$APP_ROOT/public/uploads/media" \ "$APP_ROOT/public/uploads/media" \
"$APP_ROOT/tmp" \ "$APP_ROOT/tmp" \
"$APP_ROOT/tmp/cache" \
"$APP_ROOT/tmp/uploads" "$APP_ROOT/tmp/uploads"
# Les bind mounts peuvent conserver les permissions de l'hôte. # Les bind mounts peuvent conserver les permissions de l'hôte.
# Normaliser les dossiers inscriptibles avant le démarrage. # Normaliser les dossiers persistants et le runtime éphémère avant le démarrage.
chown -R www-data:www-data \ chown -R www-data:www-data \
"$APP_ROOT/db" \ "$APP_ROOT/db" \
"$APP_ROOT/logs" \ "$APP_ROOT/logs" \

View File

@@ -774,6 +774,12 @@ textarea.control {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
} }
.prose img {
height: auto;
margin: 1rem 0;
border-radius: var(--radius-md);
}
/* ========================================================= /* =========================================================
Admin pages Admin pages
========================================================= */ ========================================================= */

View File

@@ -9,4 +9,4 @@ User::bootstrap($db);
Post::bootstrap($db); Post::bootstrap($db);
Media::bootstrap($db); Media::bootstrap($db);
fwrite(STDOUT, 'Base initialisée : ' . app_db_path() . "\n"); fwrite(STDOUT, 'Base initialisée : ' . Base::instance()->get('paths.db') . "\n");