Less home code more F3

This commit is contained in:
julien
2026-03-30 00:00:03 +02:00
parent d71cf304a9
commit fac7f60190
30 changed files with 818 additions and 1552 deletions

View File

@@ -2,10 +2,188 @@
declare(strict_types=1);
abstract class AdminController extends BaseController
class AdminController extends Controller
{
private const MEDIA_PICKER_LIMIT = 60;
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
public function beforeRoute(): void
{
$this->requireAuth();
}
public function index(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?: 1));
$result = (new Post())->page($page, 12);
$this->render('admin/dashboard.html', [
'pageTitle' => 'Tableau de bord',
'posts' => $result['items'],
'pagination' => $result['pagination'],
'paginationAlias' => 'dashboard',
'adminMode' => true,
]);
}
public function create(): void
{
$this->postForm('Nouvel article', $this->f3->alias('post_store'), Post::blank());
}
public function store(): void
{
$this->checkCsrf();
$input = $this->postInput();
try {
(new Post())->savePost($input);
$this->flash('success', 'Article créé.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->postForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
}
}
public function edit(): void
{
$post = (new Post())->findForForm((int) $this->f3->get('PARAMS.id'));
if (!$post) {
$this->f3->error(404);
return;
}
$this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
}
public function update(): void
{
$this->checkCsrf();
$id = (int) $this->f3->get('PARAMS.id');
$input = $this->postInput() + ['id' => $id];
try {
(new Post())->savePost($input, $id);
$this->flash('success', 'Article mis à jour.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
}
}
public function delete(): void
{
$this->checkCsrf();
try {
(new Post())->deleteById((int) $this->f3->get('PARAMS.id'));
$this->flash('success', 'Article supprimé.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@dashboard');
}
public function media(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?: 1));
$result = (new Media())->page($page, 24);
$this->render('admin/media.html', [
'pageTitle' => 'Médiathèque',
'items' => $result['items'],
'pagination' => $result['pagination'],
'paginationAlias' => 'media_index',
]);
}
public function mediaUpload(): void
{
$this->checkCsrf();
try {
$original = (string) ($this->f3->get('FILES.image.name') ?: '');
$received = Web::instance()->receive(
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0 && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
overwrite: false
);
$path = array_key_first(array_filter($received));
if (!$path) {
throw new RuntimeException('Choisis une image valide à envoyer.');
}
(new Media())->upload($path, $original);
$this->flash('success', 'Image ajoutée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
public function mediaAlt(): void
{
$this->checkCsrf();
try {
(new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), $this->f3->clean((string) ($this->f3->get('POST.alt') ?: '')));
$this->flash('success', 'Texte alternatif mis à jour.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
public function mediaDelete(): void
{
$this->checkCsrf();
try {
$media = new Media();
$item = $media->findById((int) $this->f3->get('PARAMS.id'));
if (!$item) {
throw new RuntimeException('Image introuvable.');
}
if ((new Post())->usesMedia($item['file_name'])) {
throw new RuntimeException('Cette image est encore utilisée par un article.');
}
$media->deleteById($item['id']);
$this->flash('success', 'Image supprimée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
private function postForm(string $title, string $action, array $post, ?string $error = null): void
{
$media = new Media();
$items = $media->recent(self::MEDIA_PICKER_LIMIT);
$count = $media->count();
$this->render('admin/post_form.html', [
'pageTitle' => $title,
'formAction' => $action,
'post' => $post,
'mediaItems' => $items,
'mediaCount' => $count,
'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT,
'mediaPickerTruncated' => $count > count($items),
'titleMax' => Post::TITLE_MAX_LENGTH,
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
'flash' => $error ? [['type' => 'error', 'message' => $error]] : [],
]);
}
private function postInput(): array
{
return [
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?: '')),
'excerpt' => $this->f3->clean((string) ($this->f3->get('POST.excerpt') ?: '')),
'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?: '')),
];
}
}

View File

@@ -2,11 +2,11 @@
declare(strict_types=1);
class AuthController extends BaseController
class AuthController extends Controller
{
public function show(): void
{
if ($this->currentUser() !== null) {
if ($this->user()) {
$this->f3->reroute('@dashboard');
return;
}
@@ -16,36 +16,35 @@ class AuthController extends BaseController
public function login(): void
{
$this->verifyCsrf();
$this->checkCsrf();
$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.
// Le 3e argument du constructeur Auth est le callback de comparaison.
$user = new User();
$auth = new \Auth($user, ['id' => 'username', 'pw' => 'password_hash'], 'password_verify');
$auth = new Auth($user, ['id' => 'username', 'pw' => 'password_hash'], 'password_verify');
$ok = $auth->login(
$this->f3->clean((string) ($this->f3->get('POST.username') ?: '')),
(string) ($this->f3->get('POST.password') ?: '')
);
if (!$auth->login($username, $password)) {
usleep(1_500_000); // 1,5 s — ralentit le brute-force
if (!$ok) {
usleep(1000000);
$this->flash('error', 'Identifiants invalides.');
$this->f3->reroute('@login');
return;
}
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);
session_regenerate_id(true);
$this->f3->set('SESSION.user_id', (int) $user->id);
$this->rotateCsrf();
$this->flash('success', 'Connexion réussie.');
$this->f3->reroute('@dashboard');
}
public function logout(): void
{
$this->verifyCsrf();
$this->checkCsrf();
$this->f3->clear('SESSION.user_id');
$this->resetCsrfToken();
session_regenerate_id(true); // Invalide l'ancien ID de session.
session_regenerate_id(true);
$this->rotateCsrf();
$this->flash('success', 'Déconnexion effectuée.');
$this->f3->reroute('@login');
}

View File

@@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
abstract class BaseController
{
protected Base $f3;
public function __construct()
{
$this->f3 = Base::instance();
}
protected function render(string $view, array $data = [], int $cacheTtl = 0): void
{
// Les pages publiques restent cacheables avec le TTL demandé.
// 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.
$currentUser = $this->currentUser();
$this->f3->expire($currentUser !== null ? 0 : $cacheTtl);
$flash = array_key_exists('flash', $data) && is_array($data['flash'])
? $data['flash']
: $this->pullFlash();
// On s'appuie sur Session(..., 'CSRF') pour la génération F3 du
// jeton, mais on le persiste en session pour qu'il reste valide
// entre la requête GET qui rend le formulaire et le POST suivant.
$this->ensureCsrfToken();
$this->f3->mset($data + [
'view' => $view,
'flash' => $flash,
'metaDescription' => null,
'adminMode' => false,
'currentUser' => $currentUser,
'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
]);
echo Template::instance()->render('layout.html');
}
// Résout l'utilisateur courant une seule fois par requête et le
// stocke dans le hive — accessible partout, y compris les templates.
protected function currentUser(): ?array
{
if (!$this->f3->exists('ctx.current_user_loaded')) {
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
$user = $userId > 0 ? (new User())->findById($userId) : null;
$this->f3->set('currentUser', $user);
$this->f3->set('ctx.current_user_loaded', true);
}
return $this->f3->get('currentUser');
}
protected function requireAuth(): void
{
if ($this->currentUser() !== null) {
return;
}
$this->flash('error', 'Connecte-toi pour continuer.');
$this->f3->reroute('@login');
}
protected function verifyCsrf(): void
{
$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;
}
$this->f3->error(400, 'Jeton CSRF invalide.');
}
// Empile un message flash — permet plusieurs messages par requête.
protected function flash(string $type, string $message): void
{
$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;
}
$seed = trim((string) ($this->f3->get('CSRF') ?? ''));
if ($seed === '') {
$seed = bin2hex(random_bytes(32));
}
$this->f3->set('SESSION.csrf_token', $seed);
}
private function pullFlash(): array
{
return $this->f3->pull('SESSION.flash') ?: [];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
abstract class Controller
{
protected Base $f3;
public function __construct()
{
$this->f3 = Base::instance();
}
protected function render(string $view, array $data = [], int $ttl = 0): void
{
$this->ensureCsrf();
$user = $this->user();
$flash = array_key_exists('flash', $data) ? $data['flash'] : $this->pullFlash();
$this->f3->expire($user ? 0 : $ttl);
$this->f3->mset($data + [
'view' => $view,
'flash' => is_array($flash) ? $flash : [],
'currentUser' => $user,
'adminMode' => false,
'metaDescription' => null,
'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
]);
echo Template::instance()->render('layout.html');
}
protected function user(): ?array
{
if (!$this->f3->exists('ctx.user_loaded')) {
$id = (int) ($this->f3->get('SESSION.user_id') ?: 0);
$this->f3->set('currentUser', $id > 0 ? (new User())->findPublic($id) : null);
$this->f3->set('ctx.user_loaded', true);
}
return $this->f3->get('currentUser');
}
protected function requireAuth(): void
{
if ($this->user()) {
return;
}
$this->flash('error', 'Connecte-toi pour continuer.');
$this->f3->reroute('@login');
}
protected function checkCsrf(): void
{
$sent = trim((string) ($this->f3->get('POST.csrf_token') ?: ''));
$expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?: ''));
if ($sent !== '' && $expected !== '' && hash_equals($expected, $sent)) {
return;
}
$this->f3->error(400);
}
protected function flash(string $type, string $message): void
{
$this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]);
}
protected function rotateCsrf(): void
{
$this->f3->clear('SESSION.csrf_token');
$this->ensureCsrf();
}
private function ensureCsrf(): void
{
if ($this->f3->exists('SESSION.csrf_token')) {
return;
}
$seed = trim((string) ($this->f3->get('CSRF') ?: ''));
$this->f3->set('SESSION.csrf_token', $seed !== '' ? $seed : bin2hex(random_bytes(16)));
}
private function pullFlash(): array
{
return $this->f3->pull('SESSION.flash') ?: [];
}
}

View File

@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
class MediaController extends AdminController
{
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
private const PER_PAGE = 24;
public function index(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$result = (new Media())->paginateLibrary($page, self::PER_PAGE);
$this->render('admin/media.html', [
'pageTitle' => 'Médiathèque',
'items' => $result['items'],
'pagination' => $result,
'paginationAlias' => 'media_index',
]);
}
public function upload(): void
{
$this->verifyCsrf();
try {
// Lire le nom d'origine avant que Web::receive() déplace le fichier.
$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,
overwrite: false,
slug: true
);
// 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 ($destPath === null) {
throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int) (self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).');
}
(new Media())->upload($destPath, $originalName);
$this->flash('success', 'Image ajoutée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
public function updateAlt(): void
{
$this->verifyCsrf();
try {
$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());
}
$this->f3->reroute('@media_index');
}
public function delete(): void
{
$this->verifyCsrf();
try {
$id = (int) $this->f3->get('PARAMS.id');
$media = new Media();
$item = $media->findById($id);
if ($item === null) {
throw new RuntimeException('Image introuvable.');
}
if ((new Post())->isMediaUsed($item['id'], $item['file_name'])) {
throw new RuntimeException('Cette image est encore utilisée par un article.');
}
$media->delete($id);
$this->flash('success', 'Image supprimée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
}

View File

@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
class PostController extends AdminController
{
private const PER_PAGE = 12;
private const MEDIA_PICKER_LIMIT = 60;
public function index(): void
{
$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
{
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), Post::emptyForm());
}
public function store(): void
{
$this->verifyCsrf();
$media = new Media();
$input = $this->postInput();
try {
(new Post())->create($input, $media);
$this->flash('success', 'Article créé.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage(), $media);
}
}
public function edit(): void
{
$post = (new Post())->findForEdit((int) $this->f3->get('PARAMS.id'));
if ($post === null) {
$this->f3->error(404, 'Article introuvable.');
return;
}
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
}
public function update(): void
{
$this->verifyCsrf();
$media = new Media();
$id = (int) $this->f3->get('PARAMS.id');
$input = $this->postInput() + ['id' => $id];
try {
$updated = (new Post())->updatePost($id, $input, $media);
if (!$updated) {
$this->f3->error(404, 'Article introuvable.');
return;
}
$this->flash('success', 'Article mis à jour.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage(), $media);
}
}
public function delete(): void
{
$this->verifyCsrf();
try {
(new Post())->delete((int) $this->f3->get('PARAMS.id'));
$this->flash('success', 'Article supprimé.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@dashboard');
}
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null, ?Media $media = null): void
{
$media ??= new Media();
$coverPreview = null;
if (!empty($post['cover_media_id'])) {
$coverPreview = $media->findById((int) $post['cover_media_id']);
}
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
$mediaCount = $media->count();
$flash = $error !== null ? [['type' => 'error', 'message' => $error]] : [];
$this->render('admin/post_form.html', [
'pageTitle' => $pageTitle,
'formAction' => $formAction,
'post' => $post,
'coverPreview' => $coverPreview,
'mediaItems' => $mediaItems,
'mediaCount' => $mediaCount,
'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT,
'mediaPickerTruncated' => $mediaCount > count($mediaItems),
'titleMax' => Post::TITLE_MAX_LENGTH,
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
'flash' => $flash,
]);
}
private function postInput(): array
{
return [
'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,28 +2,26 @@
declare(strict_types=1);
class SiteController extends BaseController
class SiteController extends Controller
{
public function home(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$media = new Media();
$result = (new Post())->paginateList($page, 12, $media);
$page = max(1, (int) ($this->f3->get('GET.page') ?: 1));
$result = (new Post())->page($page, 12);
$this->render('site/home.html', [
'pageTitle' => 'Accueil',
'posts' => $result['posts'],
'pagination' => $result,
'pageTitle' => 'Articles',
'posts' => $result['items'],
'pagination' => $result['pagination'],
'paginationAlias' => 'home',
], 300);
}
public function show(): void
{
$media = new Media();
$post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'), $media);
if ($post === null) {
$this->f3->error(404, 'Article introuvable.');
$post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'));
if (!$post) {
$this->f3->error(404);
return;
}
@@ -31,6 +29,6 @@ class SiteController extends BaseController
'pageTitle' => $post['title'],
'metaDescription' => $post['excerpt'],
'post' => $post,
], 3600);
], 300);
}
}

View File

@@ -1,210 +0,0 @@
<?php
declare(strict_types=1);
function app_timezone(): string
{
$timezone = trim((string) Base::instance()->get('app.timezone'));
return ($timezone !== '' && in_array($timezone, DateTimeZone::listIdentifiers(), true)) ? $timezone : 'UTC';
}
function app_now(): string
{
return gmdate('Y-m-d H:i:s');
}
function app_is_prod(): bool
{
return Base::instance()->get('app.env') === 'prod';
}
function app_ensure_dir(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0775, true);
}
}
function app_unique_slug(string $value, callable $exists): string
{
$base = Web::instance()->slug(trim($value));
if ($base === '') {
$base = 'article';
}
if (!$exists($base)) {
return $base;
}
for ($i = 2; $i <= 1000; $i++) {
$candidate = $base . '-' . $i;
if (!$exists($candidate)) {
return $candidate;
}
}
throw new RuntimeException('Impossible de générer un slug unique.');
}
function app_format_datetime_fr(string $value): string
{
static $utc, $formatter;
$value = trim($value);
if ($value === '') {
return '';
}
try {
$utc ??= new DateTimeZone('UTC');
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $utc);
if (!$date instanceof DateTimeImmutable) {
$date = new DateTimeImmutable($value, $utc);
}
$date = $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
$formatter ??= new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::LONG,
IntlDateFormatter::SHORT,
date_default_timezone_get(),
IntlDateFormatter::GREGORIAN,
"d MMMM yyyy 'à' HH:mm"
);
$formatted = $formatter->format($date);
return is_string($formatted) && $formatted !== '' ? $formatted : $value;
} catch (Throwable) {
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

@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
function app_bootstrap_logging(): void
{
$dir = rtrim((string) Base::instance()->get('LOGS'), '/\\') . DIRECTORY_SEPARATOR;
app_ensure_dir($dir);
ini_set('log_errors', '1');
ini_set('error_log', $dir . 'php-error.log');
ini_set('display_errors', app_is_prod() ? '0' : '1');
error_reporting(E_ALL);
}
function app_error_meta(int $code): array
{
return match ($code) {
400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'],
403 => ['title' => 'Accès refusé', 'message' => 'Tu n\u2019as pas accès à cette ressource.'],
404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'],
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
};
}
function app_render_error_fallback(int $code): void
{
$meta = app_error_meta($code);
$base = rtrim((string) Base::instance()->get('BASE'), '/');
while (ob_get_level() > 0) {
ob_end_clean();
}
if (!headers_sent()) {
http_response_code($code);
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
}
$title = htmlspecialchars((string) $meta['title'], ENT_QUOTES, 'UTF-8');
$message = htmlspecialchars((string) $meta['message'], ENT_QUOTES, 'UTF-8');
$href = htmlspecialchars($base . '/', ENT_QUOTES, 'UTF-8');
echo '<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . $title . '</title></head><body><main><h1>' . $title . '</h1><p>' . $message . '</p><p><a href="' . $href . '">Retour à l\'accueil</a></p></main></body></html>';
}
function app_bootstrap_errors(Base $f3): void
{
// En dev, ne pas poser ONERROR : le handler par défaut de F3
// affiche la stack trace complète quand DEBUG > 0.
if (!app_is_prod()) {
return;
}
register_shutdown_function(function (): void {
$error = error_get_last();
if ($error === null) {
return;
}
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
if (!in_array($error['type'] ?? 0, $fatalTypes, true)) {
return;
}
app_render_error_fallback(500);
});
$f3->set('ONERROR', function (Base $f3): void {
$code = max((int) ($f3->get('ERROR.code') ?? 500), 1);
$f3->expire(0);
$f3->status($code);
$meta = app_error_meta($code);
$f3->mset([
'errorCode' => $code,
'errorTitle' => $meta['title'],
'errorMessage' => $meta['message'],
]);
try {
echo Template::instance()->render('errors/error.html');
} catch (Throwable) {
app_render_error_fallback($code);
}
});
}

View File

@@ -4,10 +4,6 @@ declare(strict_types=1);
class Media extends DB\SQL\Mapper
{
private const MAX_WIDTH = 8000;
private const MAX_HEIGHT = 8000;
private const MAX_PIXELS = 40_000_000;
public function __construct()
{
parent::__construct(Base::instance()->get('DB'), 'media');
@@ -30,49 +26,27 @@ class Media extends DB\SQL\Mapper
$db->exec('CREATE INDEX idx_media_created_at ON media(created_at DESC)');
}
public function paginateLibrary(int $page = 1, int $perPage = 24): array
public function page(int $page, int $perPage): array
{
$result = $this->paginate(
max(0, $page - 1),
$perPage,
null,
['order' => 'created_at DESC, id DESC']
);
$result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
return [
'items' => array_map(fn(self $m): array => $this->decorate($m->cast()), $result['subset']),
'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => $result['count'] ?: 1,
'items' => array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []),
'pagination' => [
'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => max(1, (int) ($result['count'] ?: 1)),
],
];
}
public function latest(int $limit = 60): array
public function recent(int $limit): array
{
return array_map(
fn(self $m): array => $this->decorate($m->cast()),
fn(self $row): array => $this->decorate($row->cast()),
$this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: []
);
}
public function findByIds(array $ids): array
{
$ids = array_filter(array_unique(array_map('intval', $ids)));
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$results = $this->find(["id IN ($placeholders)", ...array_values($ids)]);
$map = [];
foreach ($results ?: [] as $m) {
$row = $this->decorate($m->cast());
$map[$row['id']] = $row;
}
return $map;
}
public function findById(int $id): ?array
{
if ($id <= 0) {
@@ -89,64 +63,50 @@ class Media extends DB\SQL\Mapper
return $this->dry() ? null : $this->decorate($this->cast());
}
// Traite le fichier temporaire déposé par Web::receive() et publie l'image.
public function upload(string $srcPath, string $originalName = ''): int
public function upload(string $path, string $originalName = ''): int
{
$target = null;
$committed = false; // Contrôle le nettoyage de $target dans finally.
try {
$meta = self::inspectUpload($srcPath);
// F3 Image : load() utilise imagecreatefromstring + imagesavealpha.
$img = new \Image();
$f3 = Base::instance();
if (!$img->load($f3->read($srcPath))) {
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
}
// PNG/WebP → PNG (préserve la transparence), JPG → JPG.
$isJpeg = ($meta['mime'] === 'image/jpeg');
$extension = $isJpeg ? 'jpg' : 'png';
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$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 === '' || $f3->write($target, $binary) === false) {
throw new RuntimeException('Impossible d\'enregistrer cette image.');
}
$this->db->begin();
try {
$this->reset();
$this->file_name = $fileName;
$this->alt = $originalName !== '' ? self::altFromFilename($originalName) : '';
$this->width = $meta['width'];
$this->height = $meta['height'];
$this->created_at = app_now();
$this->save();
$this->db->commit();
$committed = true;
} catch (Throwable $e) {
$this->db->rollback();
throw $e;
}
return (int) $this->get('id');
} catch (Throwable $e) {
throw $e instanceof RuntimeException ? $e : new RuntimeException('Impossible d\'enregistrer cette image.');
} finally {
// Le destructeur de F3 Image libère la ressource GD.
if (is_file($srcPath)) {
@unlink($srcPath);
}
if (!$committed && $target !== null && is_file($target)) {
@unlink($target);
}
if (!is_file($path)) {
throw new RuntimeException('Fichier image introuvable.');
}
$info = @getimagesize($path);
if (!is_array($info)) {
@unlink($path);
throw new RuntimeException('Fichier image invalide.');
}
$mime = strtolower((string) ($info['mime'] ?? ''));
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
default => null,
};
if ($extension === null) {
@unlink($path);
throw new RuntimeException('Format non supporté. Utilise JPG ou PNG.');
}
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
if (!@rename($path, $target)) {
if (!@copy($path, $target)) {
@unlink($path);
throw new RuntimeException('Impossible denregistrer cette image.');
}
@unlink($path);
}
$this->reset();
$this->file_name = $fileName;
$this->alt = $this->altFromName($originalName);
$this->width = (int) $info[0];
$this->height = (int) $info[1];
$this->created_at = app_now();
$this->save();
return (int) $this->id;
}
public function updateAlt(int $id, string $alt): void
@@ -160,7 +120,7 @@ class Media extends DB\SQL\Mapper
$this->save();
}
public function delete(int $id): void
public function deleteById(int $id): void
{
$this->load(['id = ?', $id]);
if ($this->dry()) {
@@ -168,85 +128,33 @@ class Media extends DB\SQL\Mapper
}
$path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name;
$this->db->begin();
try {
$this->erase();
if (is_file($path) && !unlink($path)) {
throw new RuntimeException('Impossible de supprimer le fichier image.');
}
$this->db->commit();
} catch (Throwable $e) {
$this->db->rollback();
throw $e instanceof RuntimeException ? $e : new RuntimeException('Suppression impossible.');
$this->erase();
if (is_file($path)) {
@unlink($path);
}
}
private static function inspectUpload(string $srcPath): array
{
if (!is_file($srcPath)) {
throw new RuntimeException('Fichier image introuvable.');
}
$info = @getimagesize($srcPath);
if (!is_array($info)) {
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
}
$width = (int) ($info[0] ?? 0);
$height = (int) ($info[1] ?? 0);
$mime = strtolower((string) ($info['mime'] ?? ''));
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
throw new RuntimeException('Format non supporté. Utilise JPG, PNG ou WebP.');
}
if ($width <= 0 || $height <= 0) {
throw new RuntimeException('Dimensions image invalides.');
}
if ($width > self::MAX_WIDTH || $height > self::MAX_HEIGHT || ($width * $height) > self::MAX_PIXELS) {
throw new RuntimeException('Image trop grande. Limite : 8000 × 8000 px et 40 mégapixels.');
}
return [
'width' => $width,
'height' => $height,
'mime' => $mime,
];
}
// Dérive un texte alternatif lisible depuis le nom de fichier d'origine.
private static function altFromFilename(string $filename): string
{
$name = pathinfo($filename, PATHINFO_FILENAME);
$name = trim((string) preg_replace('/[-_]+/', ' ', $name));
// mb_ucfirst() n'existe qu'en PHP 8.4 — on l'émule.
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
{
$file = (string) $row['file_name'];
$alt = (string) $row['alt'];
return [
'id' => (int) $row['id'],
'file_name' => (string) $row['file_name'],
'file_name' => $file,
'alt' => $alt,
'width' => (int) $row['width'],
'height' => (int) $row['height'],
'created_at' => (string) $row['created_at'],
'url' => $this->mediaUrl((string) $row['file_name']),
'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')',
'url' => rtrim((string) Base::instance()->get('BASE'), '/') . rtrim((string) Base::instance()->get('paths.media_base'), '/') . '/' . rawurlencode($file),
'markdown' => '![' . $alt . '](media:' . $file . ')',
];
}
private function altFromName(string $name): string
{
$name = trim(pathinfo($name, PATHINFO_FILENAME));
$name = preg_replace('/[-_]+/', ' ', $name) ?: '';
return trim($name);
}
}

View File

@@ -25,129 +25,87 @@ class Post extends DB\SQL\Mapper
excerpt TEXT NOT NULL,
body_markdown TEXT NOT NULL,
body_html TEXT NOT NULL,
cover_media_id INTEGER DEFAULT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (cover_media_id) REFERENCES media(id) ON DELETE SET NULL
updated_at TEXT NOT NULL
)');
$db->exec('CREATE INDEX idx_posts_created_at ON posts(created_at DESC)');
}
public static function emptyForm(): array
public static function blank(): array
{
return [
'title' => '',
'excerpt' => '',
'cover_media_id' => '',
'body_markdown' => '',
];
}
public function paginateList(int $page, int $perPage, Media $media): array
public function page(int $page, int $perPage): array
{
$result = $this->paginate(
max(0, $page - 1),
$perPage,
null,
['order' => 'created_at DESC, id DESC']
);
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
$coverIds = array_filter(array_unique(array_column($posts, 'cover_media_id')));
$covers = $media->findByIds($coverIds);
foreach ($posts as &$post) {
$cover = $covers[$post['cover_media_id']] ?? null;
$post['cover_url'] = $cover['url'] ?? '';
$post['cover_alt'] = $cover['alt'] ?? '';
}
$result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
$items = array_map(fn(self $row): array => $this->summary($row->cast()), $result['subset'] ?: []);
return [
'posts' => $posts,
'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => $result['count'] ?: 1,
'items' => $items,
'pagination' => [
'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => max(1, (int) ($result['count'] ?: 1)),
],
];
}
public function findBySlug(string $slug, Media $media): ?array
public function findBySlug(string $slug): ?array
{
$this->load(['slug = ?', $slug]);
if ($this->dry()) {
return null;
}
$post = $this->summaryRow($this->cast());
$cover = $post['cover_media_id'] > 0 ? $media->findById($post['cover_media_id']) : null;
$post['cover_url'] = $cover['url'] ?? '';
$post['cover_alt'] = $cover['alt'] ?? '';
$post['body_html'] = (string) $this->body_html;
$row = $this->cast();
$post = $this->summary($row) + ['body_html' => (string) $row['body_html']];
return $post;
}
public function findForEdit(int $id): ?array
public function findForForm(int $id): ?array
{
if ($id <= 0) {
return null;
}
$this->load(['id = ?', $id]);
if ($this->dry()) {
return null;
}
return [
'id' => (int) $this->get('id'),
'id' => (int) $this->id,
'title' => (string) $this->title,
'excerpt' => (string) $this->excerpt,
'body_markdown' => (string) $this->body_markdown,
'cover_media_id' => $this->cover_media_id !== null ? (string) ((int) $this->cover_media_id) : '',
];
}
public function create(array $input, Media $media): int
public function savePost(array $input, ?int $id = null): int
{
$payload = $this->payload($input, $media);
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->count(['slug = ?', $candidate]) > 0);
$payload = $this->payload($input);
$now = app_now();
$this->reset();
$this->copyfrom($payload + [
'slug' => $slug,
'created_at' => $now,
'updated_at' => $now,
]);
$this->save();
return (int) $this->get('id');
}
public function updatePost(int $id, array $input, Media $media): bool
{
$this->load(['id = ?', $id]);
if ($this->dry()) {
return false;
if ($id === null) {
$this->reset();
$payload['slug'] = $this->uniqueSlug($payload['title']);
$payload['created_at'] = $now;
} else {
$this->load(['id = ?', $id]);
if ($this->dry()) {
throw new RuntimeException('Article introuvable.');
}
}
$payload = $this->payload($input, $media);
$this->copyfrom($payload + ['updated_at' => app_now()]);
$payload['updated_at'] = $now;
$this->copyfrom($payload);
$this->save();
return true;
return (int) $this->id;
}
// Vérifie les deux usages possibles : couverture (cover_media_id)
// et images insérées dans le corps (media:filename dans body_markdown).
public function isMediaUsed(int $mediaId, string $fileName): bool
{
return $this->count([
'cover_media_id = ? OR body_markdown LIKE ?',
$mediaId,
'%media:' . $fileName . '%',
]) > 0;
}
public function delete(int $id): void
public function deleteById(int $id): void
{
$this->load(['id = ?', $id]);
if ($this->dry()) {
@@ -157,12 +115,16 @@ class Post extends DB\SQL\Mapper
$this->erase();
}
private function payload(array $input, Media $media): array
public function usesMedia(string $fileName): bool
{
return $this->count(['body_markdown LIKE ?', '%media:' . $fileName . '%']) > 0;
}
private function payload(array $input): array
{
$title = trim((string) ($input['title'] ?? ''));
$excerpt = trim((string) ($input['excerpt'] ?? ''));
$bodyMarkdown = trim((string) ($input['body_markdown'] ?? ''));
$coverMediaId = trim((string) ($input['cover_media_id'] ?? ''));
$body = trim((string) ($input['body_markdown'] ?? ''));
if ($title === '') {
throw new RuntimeException('Ajoute un titre.');
@@ -174,39 +136,67 @@ class Post extends DB\SQL\Mapper
throw new RuntimeException('Ajoute un extrait.');
}
if (mb_strlen($excerpt) > self::EXCERPT_MAX_LENGTH) {
throw new RuntimeException("L'extrait est trop long.");
throw new RuntimeException('Lextrait est trop long.');
}
$coverId = null;
if ($coverMediaId !== '') {
$coverId = (int) $coverMediaId;
if ($media->findById($coverId) === null) {
throw new RuntimeException('Image de couverture introuvable.');
}
}
$bodyHtml = MarkdownService::instance()->compile($bodyMarkdown, $media);
return [
'title' => $title,
'excerpt' => $excerpt,
'body_markdown' => $bodyMarkdown,
'body_html' => $bodyHtml,
'cover_media_id' => $coverId,
'body_markdown' => $body,
'body_html' => MarkdownService::instance()->compile($body, new Media()),
];
}
private function summaryRow(array $row): array
private function uniqueSlug(string $title): string
{
$base = app_slug($title);
$slug = $base;
$n = 2;
while ($this->count(['slug = ?', $slug]) > 0) {
$slug = $base . '-' . $n;
$n++;
}
return $slug;
}
private function summary(array $row): array
{
$thumbnail = $this->firstImage((string) ($row['body_html'] ?? ''));
return [
'id' => (int) $row['id'],
'title' => (string) $row['title'],
'slug' => (string) $row['slug'],
'excerpt' => (string) $row['excerpt'],
'cover_media_id' => (int) ($row['cover_media_id'] ?? 0),
'thumbnail_url' => $thumbnail['url'],
'thumbnail_alt' => $thumbnail['alt'],
'created_at' => (string) $row['created_at'],
'updated_at' => (string) $row['updated_at'],
];
}
private function firstImage(string $html): array
{
if ($html === '') {
return ['url' => '', 'alt' => ''];
}
if (!preg_match('~(<img\s[^>]*src="([^"]+)"[^>]*>)~i', $html, $match)) {
return ['url' => '', 'alt' => ''];
}
$tag = $match[1];
$alt = '';
if (preg_match('~alt="([^"]*)"~i', $tag, $altMatch)) {
$alt = html_entity_decode($altMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
return [
'url' => html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'alt' => $alt,
];
}
}

View File

@@ -23,41 +23,34 @@ class User extends DB\SQL\Mapper
)');
}
public function findById(int $id): ?array
public function findPublic(int $id): ?array
{
if ($id <= 0) {
return null;
}
$this->load(['id = ?', $id]);
if ($this->dry()) {
return null;
}
$data = $this->cast();
unset($data['password_hash']); // Ne jamais exposer le hash hors de l'authentification.
return $data;
}
public function findByUsername(string $username): ?array
{
$this->load(['username = ?', $username]);
return $this->dry() ? null : $this->cast();
return [
'id' => (int) $this->id,
'username' => (string) $this->username,
'created_at' => (string) $this->created_at,
];
}
public function create(string $username, string $password): int
{
$username = Base::instance()->clean($username);
$username = Base::instance()->clean(trim($username));
$password = trim($password);
if ($username === '' || $password === '') {
throw new RuntimeException('Nom dutilisateur et mot de passe obligatoires.');
}
if (mb_strlen($password) < 10) {
throw new RuntimeException('Le mot de passe doit contenir au moins 10 caractères.');
}
if ($this->findByUsername($username) !== null) {
$this->load(['username = ?', $username]);
if (!$this->dry()) {
throw new RuntimeException('Cet utilisateur existe déjà.');
}
@@ -67,6 +60,6 @@ class User extends DB\SQL\Mapper
$this->created_at = app_now();
$this->save();
return (int) $this->get('id');
return (int) $this->id;
}
}

View File

@@ -4,13 +4,8 @@ declare(strict_types=1);
class MarkdownService extends Prefab
{
private const ALLOWED_TAGS = [
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
'strong', 'em', 'a', 'img', 'hr', 'br',
];
private const ALLOWED_ATTRIBUTES = [
private const TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br'];
private const ATTRS = [
'a' => ['href', 'title', 'rel', 'target'],
'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
];
@@ -22,190 +17,123 @@ class MarkdownService extends Prefab
throw new RuntimeException('Ajoute du contenu avant de publier.');
}
$html = Markdown::instance()->convert($markdown);
$document = $this->parseFragment($html);
$this->sanitizeTree($document, $media);
return trim($this->renderFragment($document));
}
private function parseFragment(string $html): DOMDocument
{
$document = new DOMDocument('1.0', 'UTF-8');
$wrapper = '<div id="markdown-root">' . $html . '</div>';
$markdown = $this->neutralizeRawHtml($markdown);
$doc = new DOMDocument('1.0', 'UTF-8');
$html = '<div id="content">' . Markdown::instance()->convert($markdown) . '</div>';
$previous = libxml_use_internal_errors(true);
$document->loadHTML(
'<?xml encoding="utf-8" ?>' . $wrapper,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
return $document;
}
private function renderFragment(DOMDocument $document): string
{
$root = $document->getElementById('markdown-root');
if ($root === null) {
$root = $doc->getElementById('content');
if (!$root) {
return '';
}
$html = '';
$this->sanitizeChildren($root, $media);
$out = '';
foreach (iterator_to_array($root->childNodes) as $child) {
$html .= $document->saveHTML($child);
$out .= $doc->saveHTML($child);
}
return $html;
return trim($out);
}
private function sanitizeTree(DOMDocument $document, Media $media): void
private function neutralizeRawHtml(string $markdown): string
{
$root = $document->getElementById('markdown-root');
if ($root === null) {
return;
}
$this->sanitizeChildren($root, $media);
return preg_replace_callback(
'~<!--.*?-->|</?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?/?>~s',
static fn(array $match): string => str_replace(['<', '>'], ['&lt;', '&gt;'], $match[0]),
$markdown
) ?? $markdown;
}
private function sanitizeChildren(DOMNode $parent, Media $media): void
{
foreach (iterator_to_array($parent->childNodes) as $child) {
if ($child instanceof DOMElement) {
$tag = strtolower($child->tagName);
if (!$child instanceof DOMElement) {
continue;
}
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
$this->dropDisallowedElement($child);
$tag = strtolower($child->tagName);
if (!in_array($tag, self::TAGS, true)) {
$this->unwrap($child);
$this->sanitizeChildren($parent, $media);
continue;
}
foreach (iterator_to_array($child->attributes) as $attr) {
if (!in_array(strtolower($attr->name), self::ATTRS[$tag] ?? [], true)) {
$child->removeAttributeNode($attr);
}
}
if ($tag === 'a') {
$href = trim((string) $child->getAttribute('href'));
if (!$this->allowedHref($href)) {
$this->unwrap($child);
$this->sanitizeChildren($parent, $media);
continue;
}
$this->sanitizeElement($child, $media);
$this->sanitizeChildren($child, $media);
$child->setAttribute('href', $href);
$child->setAttribute('rel', 'noopener noreferrer');
if (preg_match('~^https?://~i', $href)) {
$child->setAttribute('target', '_blank');
} else {
$child->removeAttribute('target');
}
}
}
}
private function dropDisallowedElement(DOMElement $element): void
{
$parent = $element->parentNode;
if ($parent === null) {
return;
}
if ($tag === 'img') {
$src = trim((string) $child->getAttribute('src'));
if (!str_starts_with($src, 'media:')) {
$child->parentNode?->removeChild($child);
continue;
}
if (in_array(strtolower($element->tagName), ['script', 'style'], true)) {
$parent->removeChild($element);
return;
}
$item = $media->findByFileName(substr($src, 6));
if (!$item) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
while ($element->firstChild !== null) {
$parent->insertBefore($element->firstChild, $element);
}
$parent->removeChild($element);
}
private function sanitizeElement(DOMElement $element, Media $media): void
{
$tag = strtolower($element->tagName);
$allowedAttributes = self::ALLOWED_ATTRIBUTES[$tag] ?? [];
foreach (iterator_to_array($element->attributes) as $attribute) {
if (!in_array(strtolower($attribute->name), $allowedAttributes, true)) {
$element->removeAttributeNode($attribute);
$child->setAttribute('src', $item['url']);
$child->setAttribute('alt', trim((string) $child->getAttribute('alt')) ?: (string) $item['alt']);
$child->setAttribute('width', (string) $item['width']);
$child->setAttribute('height', (string) $item['height']);
$child->setAttribute('loading', 'lazy');
$child->setAttribute('decoding', 'async');
}
}
if ($tag === 'a') {
$this->sanitizeLink($element);
return;
}
if ($tag === 'img') {
$this->sanitizeImage($element, $media);
$this->sanitizeChildren($child, $media);
}
}
private function sanitizeLink(DOMElement $element): void
private function unwrap(DOMElement $node): void
{
$href = trim((string) $element->getAttribute('href'));
if (!$this->isAllowedHref($href)) {
$this->unwrapElement($element);
$parent = $node->parentNode;
if (!$parent) {
return;
}
$element->setAttribute('href', $href);
$element->setAttribute('rel', 'noopener noreferrer');
if ($this->isExternalHttpUrl($href)) {
$element->setAttribute('target', '_blank');
} else {
$element->removeAttribute('target');
if (in_array(strtolower($node->tagName), ['script', 'style'], true)) {
$parent->removeChild($node);
return;
}
while ($node->firstChild) {
$parent->insertBefore($node->firstChild, $node);
}
$parent->removeChild($node);
}
private function sanitizeImage(DOMElement $element, Media $media): void
private function allowedHref(string $href): bool
{
$src = trim((string) $element->getAttribute('src'));
if (!str_starts_with($src, 'media:')) {
$element->parentNode?->removeChild($element);
return;
}
$fileName = substr($src, 6);
if ($fileName === '') {
$element->parentNode?->removeChild($element);
return;
}
$item = $media->findByFileName($fileName);
if ($item === null) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
$alt = trim((string) $element->getAttribute('alt'));
if ($alt === '') {
$alt = (string) ($item['alt'] ?? '');
}
$element->setAttribute('src', (string) $item['url']);
$element->setAttribute('alt', $alt);
$element->setAttribute('loading', 'lazy');
$element->setAttribute('decoding', 'async');
$width = (int) ($item['width'] ?? 0);
if ($width > 0) {
$element->setAttribute('width', (string) $width);
} else {
$element->removeAttribute('width');
}
$height = (int) ($item['height'] ?? 0);
if ($height > 0) {
$element->setAttribute('height', (string) $height);
} else {
$element->removeAttribute('height');
}
}
private function unwrapElement(DOMElement $element): void
{
$parent = $element->parentNode;
if ($parent === null) {
return;
}
while ($element->firstChild !== null) {
$parent->insertBefore($element->firstChild, $element);
}
$parent->removeChild($element);
}
private function isAllowedHref(string $href): bool
{
if ($href == '') {
if ($href === '') {
return false;
}
@@ -219,9 +147,4 @@ class MarkdownService extends Prefab
return !preg_match('~^[a-z][a-z0-9+.-]*:~i', $href);
}
private function isExternalHttpUrl(string $href): bool
{
return (bool) preg_match('~^https?://~i', $href);
}
}

View File

@@ -14,8 +14,8 @@
<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>
<span class="field-help">Formats acceptés : JPG, PNG, WebP. Limite : 10 Mo, 8000 × 8000 px et 40 mégapixels.</span>
<input class="control" type="file" name="image" accept="image/jpeg,image/png" required>
<span class="field-help">Formats acceptés : JPG et PNG. Limite : 10 Mo. Les fichiers sont stockés tels quels, sans transformation.</span>
</label>
<button class="button" type="submit">Envoyer</button>
</form>

View File

@@ -10,7 +10,6 @@
<div class="editor-layout" data-editor-layout>
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
<include href="partials/csrf_field.html" />
<input type="hidden" name="cover_media_id" value="{{ @post.cover_media_id }}" data-cover-input>
<label class="field">
<span class="field-label">Titre</span>
@@ -24,38 +23,11 @@
<span class="char-counter"><span data-char-count-value>0</span> / {{ @excerptMax }}</span>
</label>
<section class="field cover-field">
<div class="field-head">
<div>
<h2 class="field-label">Image de couverture</h2>
<p class="field-help">Choisis une image si tu veux une couverture.</p>
</div>
</div>
<div class="cover-picker">
<check if="{{ @coverPreview }}">
<true>
<img class="media-frame media-frame--large cover-preview" data-cover-preview src="{{ @coverPreview.url }}" alt="">
<div class="media-frame media-frame--large media-frame--placeholder is-hidden" data-cover-placeholder>Aucune image</div>
</true>
<false>
<div class="media-frame media-frame--large media-frame--placeholder" data-cover-placeholder>Aucune image</div>
<img class="media-frame media-frame--large cover-preview is-hidden" data-cover-preview alt="Aperçu couverture">
</false>
</check>
<div class="button-row">
<button class="button button--ghost" type="button" data-media-picker-open="cover">Choisir une image</button>
<button class="button button--ghost" type="button" data-cover-clear {{ @post.cover_media_id ? '' : 'disabled' }}>Retirer</button>
</div>
</div>
</section>
<section class="field">
<div class="field-head">
<div>
<h2 class="field-label">Contenu</h2>
<p class="field-help">Markdown simple, avec insertion dimage au curseur.</p>
<p class="field-help">Markdown simple, avec insertion dimage au curseur. La première image sert aussi de vignette sur les cartes darticle.</p>
</div>
</div>
@@ -67,10 +39,11 @@
<button class="tool-button" type="button" data-md-action="quote">Citation</button>
<button class="tool-button" type="button" data-md-action="link">Lien</button>
<button class="tool-button" type="button" data-md-action="code">Code</button>
<button class="tool-button" type="button" data-media-picker-open="markdown">Image</button>
<button class="tool-button" type="button" data-media-picker-open>Image</button>
</div>
<textarea class="control editor-textarea" name="body_markdown" rows="18" required data-markdown-editor>{{ @post.body_markdown }}</textarea>
<p class="field-help">Astuce : avec le parseur Markdown de F3, laisse une ligne vide entre deux blocs (titre, liste, citation, image, code) pour un rendu fiable.</p>
</section>
<button class="button" type="submit">Enregistrer</button>
@@ -79,8 +52,8 @@
<aside class="media-picker is-hidden" data-media-picker>
<div class="media-picker__head">
<div>
<strong data-media-picker-title>Choisir une image</strong>
<p class="field-help" data-media-picker-help>Choisis une image de la médiathèque.</p>
<strong data-media-picker-title>Insérer une image</strong>
<p class="field-help" data-media-picker-help>Clique sur une image pour linsérer dans larticle.</p>
</div>
<button class="button button--ghost button--small" type="button" data-media-picker-close>Fermer</button>
</div>
@@ -89,7 +62,7 @@
<true>
<div class="media-picker__grid">
<repeat group="{{ @mediaItems }}" value="{{ @item }}">
<button class="media-picker__item" type="button" data-media-picker-select data-media-id="{{ @item.id }}" data-media-url="{{ @item.url }}" data-media-markdown="{{ @item.markdown }}">
<button class="media-picker__item" type="button" data-media-picker-select data-media-markdown="{{ @item.markdown }}">
<img class="media-frame media-frame--square" src="{{ @item.url }}" alt="">
</button>
</repeat>

View File

@@ -1,6 +1,6 @@
<article class="card article-card">
<article class="card card--stack">
<img class="media-frame" src="{{ @item.url }}" alt="{{ @item.alt }}">
<div class="card-body article-card__body">
<div class="card-body">
<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 }}">

View File

@@ -2,7 +2,7 @@
<check if="{{ @currentUser }}">
<true>
<li class="nav-items__item">
<a class="nav-items__link" href="{{ 'dashboard' | alias }}">Dashboard</a>
<a class="nav-items__link" href="{{ 'dashboard' | alias }}">Tableau de bord</a>
</li>
<li class="nav-items__item">
<form class="nav-items__form" method="post" action="{{ 'logout' | alias }}">

View File

@@ -1,9 +1,7 @@
<article class="card article-card">
<check if="{{ @post.cover_url }}">
<article class="card card--stack">
<check if="{{ @post.thumbnail_url }}">
<true>
<a class="card-media-link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">
<img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.cover_alt ?: @post.title }}">
</a>
<img class="media-frame" src="{{ @post.thumbnail_url }}" alt="{{ @post.thumbnail_alt ?: @post.title }}">
</true>
<false>
<check if="{{ @adminMode }}">
@@ -13,10 +11,8 @@
</check>
</false>
</check>
<div class="card-body article-card__body">
<h2 class="card-title">
<a class="card-title__link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">{{ @post.title }}</a>
</h2>
<div class="card-body">
<h2 class="card-title">{{ @post.title }}</h2>
<p class="meta-text">
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time>
<check if="{{ @post.updated_at !== @post.created_at }}">
@@ -34,6 +30,11 @@
</form>
</div>
</true>
<false>
<div class="card-actions">
<a class="button button--ghost" href="{{ 'post_show', 'slug='.@post.slug | alias }}">Lire l'article</a>
</div>
</false>
</check>
</div>
</article>

View File

@@ -9,12 +9,5 @@
</p>
</header>
<check if="{{ @post.cover_url }}">
<true>
<img class="media-frame media-frame--large article-cover" src="{{ @post.cover_url }}"
alt="{{ @post.cover_alt ?: @post.title }}">
</true>
</check>
<div class="prose">{{ @post.body_html | raw }}</div>
</article>
</article>

View File

@@ -2,89 +2,79 @@
declare(strict_types=1);
require __DIR__ . '/Helpers/App.php';
require __DIR__ . '/Helpers/Error.php';
require __DIR__ . '/helpers.php';
$f3 = Base::instance();
// ── Configuration ───────────────────────────────────────────────────
$root = dirname(__DIR__);
$f3->set('AUTOLOAD', $root . '/app/Controllers/;' . $root . '/app/Models/;' . $root . '/app/Services/');
$f3->set('AUTOLOAD', implode(';', [
$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->set('paths.db', $root . '/db/app.sqlite');
$f3->set('paths.media_dir', $root . '/public/uploads/media');
$f3->set('paths.media_base', '/uploads/media');
$f3->config($root . '/app/config.ini');
$localConfig = $root . '/config.local.ini';
if (is_file($localConfig)) {
$f3->config($localConfig);
if (is_file($root . '/config.local.ini')) {
$f3->config($root . '/config.local.ini');
}
$f3->set('TZ', app_timezone());
$f3->set('DEBUG', app_is_prod() ? 0 : 3);
date_default_timezone_set(app_timezone((string) $f3->get('app.timezone')));
$f3->set('TZ', date_default_timezone_get());
$f3->set('DEBUG', $f3->get('app.env') === 'prod' ? 0 : 3);
app_ensure_dir((string) $f3->get('TEMP'));
app_ensure_dir((string) $f3->get('LOGS'));
app_ensure_dir((string) $f3->get('paths.media_dir'));
// Web::receive() utilise UPLOADS directement — le résoudre en absolu.
$f3->set('UPLOADS', $root . '/' . ltrim((string) $f3->get('UPLOADS'), '/'));
app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/'));
app_bootstrap_logging();
foreach ([(string) $f3->get('TEMP'), (string) $f3->get('LOGS'), dirname((string) $f3->get('paths.db')), (string) $f3->get('paths.media_dir')] as $dir) {
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
}
// ── Base de données ─────────────────────────────────────────────────
$uploads = $root . '/' . trim((string) $f3->get('UPLOADS'), '/');
$f3->set('UPLOADS', $uploads . '/');
if (!is_dir($uploads)) {
mkdir($uploads, 0775, true);
}
$dbPath = (string) $f3->get('paths.db');
app_ensure_dir(dirname($dbPath));
ini_set('log_errors', '1');
ini_set('error_log', rtrim((string) $f3->get('LOGS'), '/\\') . '/php-error.log');
ini_set('display_errors', $f3->get('app.env') === 'prod' ? '0' : '1');
error_reporting(E_ALL);
$db = new DB\SQL(
'sqlite:' . $dbPath,
null,
null,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => 5,
]
);
$db = new DB\SQL('sqlite:' . $f3->get('paths.db'));
$db->exec('PRAGMA foreign_keys = ON');
$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');
$secure = app_request_is_secure();
session_name((string) $f3->get('app.session_name'));
$f3->set('JAR', [
'expire' => 0,
'path' => '/',
'secure' => $requestScheme === 'https',
'expire' => 0,
'path' => '/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
new Session(null, 'CSRF');
// ── Template ────────────────────────────────────────────────────────
Template::instance()->filter('date_fr', 'app_date_fr');
Template::instance()->filter('date_fr', 'app_format_datetime_fr');
// ── Erreurs ─────────────────────────────────────────────────────────
app_bootstrap_errors($f3);
if ($f3->get('app.env') === 'prod') {
$f3->set('ONERROR', function (Base $f3): void {
$code = max(1, (int) ($f3->get('ERROR.code') ?: 500));
$meta = app_error_meta($code);
$f3->status($code);
$f3->expire(0);
$f3->mset([
'errorCode' => $code,
'errorTitle' => $meta['title'],
'errorMessage' => $meta['message'],
]);
echo Template::instance()->render('errors/error.html');
});
}
return $f3;

View File

@@ -1,15 +1,13 @@
[globals]
app.env=dev
app.timezone=UTC
app.timezone=Europe/Paris
app.name=F3 Simple Blog
app.tagline=Blog simple avec Fat-Free Framework et SQLite.
app.session_name=f3-simple-blog
app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12
UPLOADS=tmp/uploads/
CACHE=folder
app.name=F3 Simple Blog
app.tagline=Blog simple avec Fat-Free Framework et SQLite.
[routes]
GET @home: /=SiteController->home
GET @post_show: /posts/@slug=SiteController->show
@@ -18,14 +16,14 @@ GET @login: /login=AuthController->show
POST @login_submit: /login=AuthController->login
POST @logout: /logout=AuthController->logout
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
POST @post_update: /dashboard/posts/@id/update=PostController->update
POST @post_delete: /dashboard/posts/@id/delete=PostController->delete
GET @dashboard: /dashboard=AdminController->index
GET @post_create: /dashboard/posts/create=AdminController->create
POST @post_store: /dashboard/posts=AdminController->store
GET @post_edit: /dashboard/posts/@id/edit=AdminController->edit
POST @post_update: /dashboard/posts/@id/update=AdminController->update
POST @post_delete: /dashboard/posts/@id/delete=AdminController->delete
GET @media_index: /dashboard/media=MediaController->index
POST @media_upload: /dashboard/media=MediaController->upload
POST @media_update_alt: /dashboard/media/@id/alt=MediaController->updateAlt
POST @media_delete: /dashboard/media/@id/delete=MediaController->delete
GET @media_index: /dashboard/media=AdminController->media
POST @media_upload: /dashboard/media=AdminController->mediaUpload
POST @media_update_alt: /dashboard/media/@id/alt=AdminController->mediaAlt
POST @media_delete: /dashboard/media/@id/delete=AdminController->mediaDelete

79
app/helpers.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
function app_timezone(string $value): string
{
$value = trim($value);
return in_array($value, DateTimeZone::listIdentifiers(), true) ? $value : 'UTC';
}
function app_now(): string
{
return gmdate('Y-m-d H:i:s');
}
function app_slug(string $value): string
{
$slug = Web::instance()->slug(trim($value));
return $slug !== '' ? $slug : 'article';
}
function app_date_fr(string $value): string
{
static $formatter = null;
$value = trim($value);
if ($value === '') {
return '';
}
try {
$date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
$date = $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
if (!$formatter instanceof IntlDateFormatter) {
$formatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::LONG,
IntlDateFormatter::SHORT,
date_default_timezone_get(),
IntlDateFormatter::GREGORIAN,
"d MMMM yyyy 'à' HH:mm"
);
}
return (string) ($formatter->format($date) ?: $value);
} catch (Throwable) {
return $value;
}
}
function app_error_meta(int $code): array
{
return match ($code) {
400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'],
403 => ['title' => 'Accès refusé', 'message' => 'Tu nas pas accès à cette ressource.'],
404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'],
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
};
}
function app_request_is_secure(): bool
{
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
return true;
}
if (strtolower((string) ($_SERVER['REQUEST_SCHEME'] ?? '')) === 'https') {
return true;
}
$forwardedProto = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
if ($forwardedProto !== '') {
return explode(',', $forwardedProto)[0] === 'https';
}
return false;
}