Less home code more F3
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# Exemple de configuration Caddy en reverse proxy vers l'application.
|
||||
# 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 {
|
||||
# ── En-têtes de sécurité (toutes les réponses) ───────────────────
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -4,10 +4,18 @@ FROM php:8.3-cli AS vendor
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libsqlite3-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
libpng-dev \
|
||||
libwebp-dev \
|
||||
libfreetype6-dev \
|
||||
libonig-dev \
|
||||
libicu-dev \
|
||||
libxml2-dev \
|
||||
unzip \
|
||||
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/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
@@ -29,8 +37,9 @@ RUN apt-get update \
|
||||
libfreetype6-dev \
|
||||
libonig-dev \
|
||||
libicu-dev \
|
||||
libxml2-dev \
|
||||
&& 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 \
|
||||
&& a2enconf servername \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
66
README.md
66
README.md
@@ -11,7 +11,7 @@ project/
|
||||
│ ├── config.ini # Configuration F3 (globals + routes)
|
||||
│ ├── bootstrap.php # Initialisation (config, DB, session, erreurs)
|
||||
│ ├── Controllers/
|
||||
│ ├── Helpers/ # Fonctions utilitaires
|
||||
│ ├── Helpers/ # Fonctions utilitaires ciblées
|
||||
│ ├── Models/ # DB\SQL\Mapper (Post, Media, User)
|
||||
│ ├── Services/ # MarkdownService
|
||||
│ └── Views/
|
||||
@@ -20,7 +20,7 @@ project/
|
||||
├── logs/
|
||||
│ └── php-error.log # Log PHP configuré au runtime
|
||||
├── public/
|
||||
│ ├── assets/ # Sources CSS/JS servies via /min/@file
|
||||
│ ├── assets/ # Assets statiques servis directement
|
||||
│ └── uploads/
|
||||
│ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG)
|
||||
├── scripts/
|
||||
@@ -28,7 +28,6 @@ project/
|
||||
│ ├── install.php # Initialisation idempotente de la base
|
||||
│ └── create-admin.php # Création d'un compte admin en CLI
|
||||
└── tmp/ # Runtime temporaire, recréable sans perte métier
|
||||
├── cache/ # Cache F3 + assets minifiés
|
||||
└── 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.
|
||||
- **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.
|
||||
- **Images** — normalisation des médias via GD (JPG conservé, PNG/WebP convertis en PNG pour préserver la transparence).
|
||||
- **Markdown** — `Markdown::instance()->convert()` + `strip_tags` et résolution des images média.
|
||||
- **Images** — normalisation des médias via `Image`, lecture/écriture via `Base::read()` / `Base::write()`.
|
||||
- **Markdown** — `Markdown::instance()->convert()` suivi d'un assainissement DOM ciblé et de la résolution des images média.
|
||||
- **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()`.
|
||||
- **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale.
|
||||
|
||||
@@ -51,7 +50,7 @@ project/
|
||||
|
||||
- PHP 8.3+
|
||||
- Composer
|
||||
- Extensions PHP : `pdo_sqlite`, `gd`, `mbstring`, `intl`
|
||||
- Extensions PHP : `pdo_sqlite`, `gd`, `mbstring`, `intl`, `dom`
|
||||
|
||||
### Déploiement Docker
|
||||
|
||||
@@ -74,6 +73,25 @@ app.env=prod
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -98,11 +116,16 @@ php scripts/create-admin.php admin
|
||||
|
||||
```bash
|
||||
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 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`.
|
||||
|
||||
@@ -117,15 +140,14 @@ docker compose exec app php scripts/create-admin.php admin
|
||||
|
||||
## 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.
|
||||
- `/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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- `db/` — base SQLite.
|
||||
@@ -178,10 +211,7 @@ docker compose up -d --build
|
||||
- PHP : `logs/php-error.log`.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
11
app/Controllers/AdminController.php
Normal file
11
app/Controllers/AdminController.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
abstract class AdminController extends BaseController
|
||||
{
|
||||
public function beforeRoute(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class AuthController extends BaseController
|
||||
{
|
||||
$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') ?? '');
|
||||
|
||||
// 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.
|
||||
$this->resetCsrfToken(); // Le nouveau contexte repart avec un jeton dédié.
|
||||
$this->f3->set('SESSION.user_id', $user->id);
|
||||
$this->flash('success', 'Connexion réussie.');
|
||||
$this->f3->reroute('@dashboard');
|
||||
@@ -43,6 +44,7 @@ class AuthController extends BaseController
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
$this->f3->clear('SESSION.user_id');
|
||||
$this->resetCsrfToken();
|
||||
session_regenerate_id(true); // Invalide l'ancien ID de session.
|
||||
$this->flash('success', 'Déconnexion effectuée.');
|
||||
$this->f3->reroute('@login');
|
||||
|
||||
@@ -17,25 +17,26 @@ abstract class BaseController
|
||||
// Si un utilisateur est connecté, le layout dépend de la session
|
||||
// (navigation admin, déconnexion + CSRF) : on force expire(0)
|
||||
// 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'])
|
||||
? $data['flash']
|
||||
: $this->pullFlash();
|
||||
|
||||
// currentUser est déjà dans le hive (posé par currentUser()).
|
||||
// Le template y accède directement via {{ @currentUser }}.
|
||||
// Jeton CSRF stable par session : plus simple et plus robuste que le
|
||||
// pool précédent, tout en restant compatible multi-onglets.
|
||||
$this->ensureCsrfToken();
|
||||
|
||||
$this->f3->mset($data + [
|
||||
'view' => $view,
|
||||
'flash' => $flash,
|
||||
'metaDescription' => null,
|
||||
'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');
|
||||
}
|
||||
|
||||
@@ -62,12 +63,10 @@ abstract class BaseController
|
||||
$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
|
||||
{
|
||||
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');
|
||||
$expected = (string) ($this->f3->get('SESSION.csrf_token') ?? '');
|
||||
$submitted = trim((string) ($this->f3->get('POST.csrf_token') ?? ''));
|
||||
$expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?? ''));
|
||||
|
||||
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
|
||||
return;
|
||||
@@ -82,6 +81,22 @@ abstract class BaseController
|
||||
$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
|
||||
{
|
||||
return $this->f3->pull('SESSION.flash') ?: [];
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,11 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class MediaController extends BaseController
|
||||
class MediaController extends AdminController
|
||||
{
|
||||
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;
|
||||
|
||||
public function beforeRoute(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
}
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$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') ?? '');
|
||||
|
||||
$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
|
||||
&& (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES
|
||||
&& in_array($file['type'] ?? '', self::ACCEPTED_TYPES, true),
|
||||
&& (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
|
||||
overwrite: false,
|
||||
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));
|
||||
$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).');
|
||||
}
|
||||
|
||||
foreach ($accepted as $destPath) {
|
||||
(new Media())->upload($destPath, $originalName);
|
||||
}
|
||||
(new Media())->upload($destPath, $originalName);
|
||||
|
||||
$this->flash('success', 'Image ajoutée.');
|
||||
} catch (RuntimeException $e) {
|
||||
@@ -66,9 +61,8 @@ class MediaController extends BaseController
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
$alt = (string) ($this->f3->get('POST.alt') ?? '');
|
||||
$this->f3->scrub($alt);
|
||||
(new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), trim($alt));
|
||||
$alt = $this->f3->clean((string) ($this->f3->get('POST.alt') ?? ''));
|
||||
(new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), $alt);
|
||||
$this->flash('success', 'Texte alternatif mis à jour.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
|
||||
@@ -2,13 +2,24 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class PostController extends BaseController
|
||||
class PostController extends AdminController
|
||||
{
|
||||
private const PER_PAGE = 12;
|
||||
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
|
||||
@@ -109,17 +120,9 @@ class PostController extends BaseController
|
||||
|
||||
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 [
|
||||
'title' => trim($title),
|
||||
'excerpt' => trim($excerpt),
|
||||
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?? '')),
|
||||
'excerpt' => $this->f3->clean((string) ($this->f3->get('POST.excerpt') ?? '')),
|
||||
'cover_media_id' => (string) ($this->f3->get('POST.cover_media_id') ?? ''),
|
||||
'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?? '')),
|
||||
];
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Core ────────────────────────────────────────────────────────────
|
||||
|
||||
function app_root(): string
|
||||
{
|
||||
return dirname(__DIR__, 2);
|
||||
}
|
||||
|
||||
function app_timezone(): string
|
||||
{
|
||||
$timezone = trim((string) Base::instance()->get('app.timezone'));
|
||||
@@ -25,8 +18,6 @@ function app_is_prod(): bool
|
||||
return Base::instance()->get('app.env') === 'prod';
|
||||
}
|
||||
|
||||
// ── Fichiers et chemins ─────────────────────────────────────────────
|
||||
|
||||
function app_ensure_dir(string $path): void
|
||||
{
|
||||
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
|
||||
{
|
||||
$base = Web::instance()->slug(trim($value));
|
||||
@@ -111,3 +80,131 @@ function app_format_datetime_fr(string $value): string
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ class Media extends DB\SQL\Mapper
|
||||
|
||||
// F3 Image : load() utilise imagecreatefromstring + imagesavealpha.
|
||||
$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é.');
|
||||
}
|
||||
|
||||
@@ -110,11 +111,11 @@ class Media extends DB\SQL\Mapper
|
||||
|
||||
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
|
||||
$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).
|
||||
$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.');
|
||||
}
|
||||
|
||||
@@ -166,7 +167,7 @@ class Media extends DB\SQL\Mapper
|
||||
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();
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$alt = (string) $row['alt'];
|
||||
@@ -235,7 +245,7 @@ class Media extends DB\SQL\Mapper
|
||||
'width' => (int) $row['width'],
|
||||
'height' => (int) $row['height'],
|
||||
'created_at' => (string) $row['created_at'],
|
||||
'url' => app_media_url((string) $row['file_name']),
|
||||
'url' => $this->mediaUrl((string) $row['file_name']),
|
||||
'markdown' => '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -47,9 +47,7 @@ class User extends DB\SQL\Mapper
|
||||
|
||||
public function create(string $username, string $password): int
|
||||
{
|
||||
$f3 = Base::instance();
|
||||
$f3->scrub($username);
|
||||
$username = trim($username);
|
||||
$username = Base::instance()->clean($username);
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
throw new RuntimeException('Nom d’utilisateur et mot de passe obligatoires.');
|
||||
|
||||
@@ -28,7 +28,9 @@ class MarkdownService extends Prefab
|
||||
// Résout les images media:filename et supprime les images externes.
|
||||
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:')) {
|
||||
return '';
|
||||
}
|
||||
@@ -50,12 +52,21 @@ class MarkdownService extends Prefab
|
||||
$alt = $a[1];
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<span class="field-label">Nouvelle image</span>
|
||||
<input class="control" type="file" name="image" accept="image/jpeg,image/png,image/webp" required>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<div class="editor-layout" data-editor-layout>
|
||||
<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>
|
||||
|
||||
<label class="field">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<span class="field-label">Nom d’utilisateur</span>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ @errorTitle ?: 'Erreur' }}</title>
|
||||
<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>
|
||||
<body>
|
||||
<main class="page error-page">
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<title>{{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }}</title>
|
||||
<meta name="description" content="{{ @metaDescription ?: @app.tagline }}">
|
||||
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ 'asset', 'file=app.css' | alias }}">
|
||||
<script defer src="{{ 'asset', 'file=app.js' | alias }}"></script>
|
||||
<link rel="stylesheet" href="{{ @BASE }}/assets/app.css">
|
||||
<script defer src="{{ @BASE }}/assets/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<include href="partials/site_navigation.html" />
|
||||
|
||||
1
app/Views/partials/csrf_field.html
Normal file
1
app/Views/partials/csrf_field.html
Normal file
@@ -0,0 +1 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ @FORM_CSRF }}">
|
||||
@@ -4,7 +4,7 @@
|
||||
<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 }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||
<include href="partials/csrf_field.html" />
|
||||
<label class="field">
|
||||
<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>
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="card-actions">
|
||||
<button class="button button--ghost" type="button" data-copy-text="{{ @item.markdown }}" data-markdown-template="">Copier le Markdown</button>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</li>
|
||||
<li class="nav-items__item">
|
||||
<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>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<div class="card-actions">
|
||||
<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 ?">
|
||||
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||
<include href="partials/csrf_field.html" />
|
||||
<button class="button button--danger" type="submit">Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -9,14 +9,20 @@ $f3 = Base::instance();
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────
|
||||
|
||||
$f3->set('AUTOLOAD', app_root() . '/app/Controllers/;' . app_root() . '/app/Models/;' . app_root() . '/app/Services/');
|
||||
$f3->set('UI', app_root() . '/app/Views/');
|
||||
$f3->set('TEMP', app_root() . '/tmp/');
|
||||
$f3->set('LOGS', app_logs_dir() . '/');
|
||||
$root = dirname(__DIR__);
|
||||
$f3->set('AUTOLOAD', $root . '/app/Controllers/;' . $root . '/app/Models/;' . $root . '/app/Services/');
|
||||
$f3->set('UI', $root . '/app/Views/');
|
||||
$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)) {
|
||||
$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('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.
|
||||
$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_bootstrap_logging();
|
||||
|
||||
// ── Base de données ─────────────────────────────────────────────────
|
||||
|
||||
$dbPath = app_db_path();
|
||||
$dbPath = (string) $f3->get('paths.db');
|
||||
app_ensure_dir(dirname($dbPath));
|
||||
|
||||
$db = new DB\SQL(
|
||||
@@ -52,17 +58,26 @@ $f3->set('DB', $db);
|
||||
|
||||
// ── 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.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'));
|
||||
$f3->set('JAR', [
|
||||
'expire' => 0,
|
||||
'path' => '/',
|
||||
'secure' => $f3->get('SCHEME') === 'https',
|
||||
'secure' => $requestScheme === 'https',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
new Session(null, 'CSRF');
|
||||
new Session();
|
||||
|
||||
// ── Template ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
app.env=dev
|
||||
app.timezone=UTC
|
||||
app.session_name=f3-simple-blog
|
||||
app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12
|
||||
|
||||
UPLOADS=tmp/uploads/
|
||||
CACHE=folder
|
||||
@@ -10,8 +11,6 @@ app.name=F3 Simple Blog
|
||||
app.tagline=Blog simple avec Fat-Free Framework et SQLite.
|
||||
|
||||
[routes]
|
||||
GET @asset: /min/@file=AssetController->serve
|
||||
|
||||
GET @home: /=SiteController->home
|
||||
GET @post_show: /posts/@slug=SiteController->show
|
||||
|
||||
@@ -19,7 +18,7 @@ GET @login: /login=AuthController->show
|
||||
POST @login_submit: /login=AuthController->login
|
||||
POST @logout: /logout=AuthController->logout
|
||||
|
||||
GET @dashboard: /dashboard=DashboardController->index
|
||||
GET @dashboard: /dashboard=PostController->index
|
||||
GET @post_create: /dashboard/posts/create=PostController->create
|
||||
POST @post_store: /dashboard/posts=PostController->store
|
||||
GET @post_edit: /dashboard/posts/@id/edit=PostController->edit
|
||||
|
||||
@@ -4,6 +4,9 @@ services:
|
||||
image: f3-simple-blog:latest
|
||||
init: true
|
||||
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:
|
||||
- "127.0.0.1:8888:80"
|
||||
volumes:
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-pdo_sqlite": "*",
|
||||
"bcosca/fatfree-core": "^3.9"
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,9 @@
|
||||
; app.session_name=f3-simple-blog
|
||||
; app.name=F3 Simple Blog
|
||||
; 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
3
docker/entrypoint.sh
Normal file → Executable file
@@ -16,11 +16,10 @@ install -d -m 0775 -o www-data -g www-data \
|
||||
"$APP_ROOT/logs" \
|
||||
"$APP_ROOT/public/uploads/media" \
|
||||
"$APP_ROOT/tmp" \
|
||||
"$APP_ROOT/tmp/cache" \
|
||||
"$APP_ROOT/tmp/uploads"
|
||||
|
||||
# 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 \
|
||||
"$APP_ROOT/db" \
|
||||
"$APP_ROOT/logs" \
|
||||
|
||||
@@ -774,6 +774,12 @@ textarea.control {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
height: auto;
|
||||
margin: 1rem 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Admin pages
|
||||
========================================================= */
|
||||
|
||||
@@ -9,4 +9,4 @@ User::bootstrap($db);
|
||||
Post::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");
|
||||
|
||||
Reference in New Issue
Block a user