Less home code more F3
This commit is contained in:
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
|
||||
|
||||
Reference in New Issue
Block a user