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

@@ -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();
$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');

View File

@@ -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') ?: [];

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);
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());

View File

@@ -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') ?? '')),
];

View File

@@ -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);
}

View File

@@ -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' => '![' . $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
{
$f3 = Base::instance();
$f3->scrub($username);
$username = trim($username);
$username = Base::instance()->clean($username);
if ($username === '' || $password === '') {
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.
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;
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 dutilisateur</span>

View File

@@ -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">

View File

@@ -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" />

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>
<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="![](media:{{ @item.file_name }})">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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ────────────────────────────────────────────────────────

View File

@@ -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