More robust

This commit is contained in:
julien
2026-03-27 20:14:11 +01:00
parent 75ec966435
commit 68c547ddcb
25 changed files with 474 additions and 224 deletions

View File

@@ -23,15 +23,15 @@ project/
├── public/
│ ├── assets/ # Sources CSS/JS (servis minifiés via /min/@file)
│ └── uploads/
│ └── media/ # Images converties en PNG (sans perte)
│ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG)
└── tmp/
├── cache/ # Cache F3 (pages publiques + assets minifiés)
└── uploads/ # Transit Web::receive() — vidé après chaque upload
└── uploads/ # Transit Web::receive() — nettoyé après chaque upload
```
## Philosophie des dossiers runtime
Le projet garde la même logique en local et dans Docker :
Le projet sépare les données persistantes du runtime jetable :
- `tmp/` = runtime temporaire, recréable
- `db/` = base SQLite persistante
@@ -42,14 +42,14 @@ Autrement dit, `tmp/` peut être vidé sans perte métier. Les données à sauve
## Fonctionnalités F3 utilisées
- **Routage nommé** — `config.ini [routes]`, `$f3->alias()`
- **Cache HTTP + serveur** — `$f3->expire()` sur les routes publiques, `Cache::reset('.url')` à la mutation
- **Routage nommé** — `config.ini [routes]`, filtre `alias` dans les templates, `reroute('@route')` dans les contrôleurs
- **Cache HTTP + serveur** — TTL déclarés directement dans `[routes]`, `Cache::reset('.url')` à la mutation
- **Assets minifiés** — `Web::minify()` via `AssetController` (`GET /min/@file`)
- **Upload** — `Web::receive()` avec callback de validation taille
- **Images** — `Image` (chargement, conversion PNG, dimensions), `Base::write()` pour l'écriture
- **Markdown** — `Markdown::instance()->convert()`
- **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle
- **Images** — normalisation des médias via GD (`JPG` conservé, `PNG/WebP` convertis en `PNG` pour préserver la transparence)
- **Markdown** — `Markdown::instance()->convert()` + reconstruction DOM en liste blanche
- **Slugs** — `Web::instance()->slug()`
- **Session** — `$f3->set('JAR', …)`, token CSRF dans `SESSION.csrf_token`
- **Session** — `$f3->set('JAR', …)`, hooks `beforeRoute()` sur les contrôleurs protégés, token CSRF créé seulement sur les vues qui contiennent des formulaires
- **Logging** — `Log` de F3 avec fallback `file_put_contents`
- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`
@@ -115,7 +115,7 @@ cp config.local.ini.example config.local.ini
docker compose up -d --build
```
Docker monte les mêmes dossiers runtime que le développement local. Seuls les répertoires persistants restent séparés de `tmp/`.
Docker ne monte que les dossiers persistants (`db/`, `logs/`, `public/uploads/media/`) et laisse `tmp/` dans le conteneur pour qu'il reste réellement éphémère.
Si `config.local.ini` n'existe pas, le conteneur démarre avec les valeurs par défaut de `app/config.ini`.
@@ -128,6 +128,20 @@ docker compose exec app php scripts/create-admin.php admin
# mot de passe : 10 caractères minimum
```
## Cache public et navigation
Les pages publiques (`/` et `/posts/@slug`) restent cacheables parce que leur rendu n'accède ni à la session, ni au token CSRF. La navigation publique affiche donc un lien statique vers la connexion / l'administration, tandis que les vues d'administration restent session-aware.
## Médias et limites d'upload
- Formats acceptés à l'entrée : `JPG`, `PNG`, `WebP`
- Taille max du fichier reçu : `10 Mo`
- Dimensions max : `8000 × 8000 px`
- Limite de surface : `40 mégapixels`
- Sortie publiée : `JPG` pour les sources JPEG, `PNG` pour les sources PNG/WebP
La médiathèque admin est paginée et le picker dans l'éditeur charge seulement les images les plus récentes pour éviter de charger toute la bibliothèque en mémoire à chaque formulaire.
## Reverse proxy Caddy
### Caddy sur le même hôte

View File

@@ -18,8 +18,6 @@ class AssetController extends BaseController
return;
}
$this->f3->expire(86400); // 24 h côté navigateur
echo Web::instance()->minify(
$file,
self::ALLOWED[$file],

View File

@@ -4,15 +4,19 @@ declare(strict_types=1);
class AuthController extends BaseController
{
public function beforeRoute(): void
{
$this->disableCache();
}
public function show(): void
{
if ($this->currentUser() !== null) {
$this->f3->reroute($this->f3->alias('dashboard'));
$this->f3->reroute('@dashboard');
return;
}
$this->f3->expire(0);
$this->render('auth/login.html', ['pageTitle' => 'Connexion']);
$this->renderSession('auth/login.html', ['pageTitle' => 'Connexion'], true);
}
public function login(): void
@@ -26,22 +30,24 @@ class AuthController extends BaseController
if ($user === null || !password_verify($password, $user['password_hash'])) {
usleep(1_500_000); // 1,5 s — ralentit le brute-force
$this->flash('error', 'Identifiants invalides.');
$this->f3->reroute($this->f3->alias('login'));
$this->f3->reroute('@login');
return;
}
session_regenerate_id(true);
$this->f3->set('SESSION.user_id', $user['id']);
$this->refreshCsrfToken();
$this->flash('success', 'Connexion réussie.');
$this->f3->reroute($this->f3->alias('dashboard'));
$this->f3->reroute('@dashboard');
}
public function logout(): void
{
$this->verifyCsrf();
$this->f3->clear('SESSION.user_id');
$this->f3->clear('SESSION.csrf_token');
session_regenerate_id(true);
$this->flash('success', 'Déconnexion effectuée.');
$this->f3->reroute($this->f3->alias('home'));
$this->f3->reroute('@login');
}
}

View File

@@ -13,13 +13,31 @@ abstract class BaseController
$this->db = $this->f3->get('DB');
}
protected function render(string $view, array $data = []): void
protected function renderPublic(string $view, array $data = []): void
{
$user = $this->currentUser();
$this->f3->mset($data + [
'view' => $view,
'currentUser' => $user,
'flash' => array_key_exists('flash', $data) && is_array($data['flash']) ? $data['flash'] : null,
'csrfToken' => $user !== null ? $this->csrfToken() : null,
]);
echo Template::instance()->render('layout.html');
}
protected function renderSession(string $view, array $data = [], bool $withCsrf = false): void
{
$flash = array_key_exists('flash', $data) && is_array($data['flash'])
? $data['flash']
: $this->pullFlash();
$this->f3->mset($data + [
'view' => $view,
'currentUser' => $this->currentUser(),
'flash' => $this->pullFlash(),
'csrfToken' => $this->csrfToken(),
'flash' => $flash,
'csrfToken' => $withCsrf ? $this->csrfToken() : null,
]);
echo Template::instance()->render('layout.html');
@@ -38,12 +56,16 @@ abstract class BaseController
}
$this->flash('error', 'Connecte-toi pour continuer.');
$this->f3->reroute($this->f3->alias('login'));
$this->f3->reroute('@login');
}
protected function disableCache(): void
{
$this->f3->expire(0);
}
protected function csrfToken(): string
{
// Génère un token CSRF et le stocke en session au premier appel.
$token = (string) ($this->f3->get('SESSION.csrf_token') ?? '');
if ($token === '') {
$token = bin2hex(random_bytes(32));
@@ -53,6 +75,13 @@ abstract class BaseController
return $token;
}
protected function refreshCsrfToken(): string
{
$token = bin2hex(random_bytes(32));
$this->f3->set('SESSION.csrf_token', $token);
return $token;
}
protected function verifyCsrf(): void
{
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');

View File

@@ -4,19 +4,22 @@ declare(strict_types=1);
class DashboardController extends BaseController
{
public function index(): void
public function beforeRoute(): void
{
$this->requireAuth();
$this->f3->expire(0);
$this->disableCache();
}
public function index(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$result = (new Post($this->db))->paginateList($page, 24);
$this->render('admin/dashboard.html', [
$this->renderSession('admin/dashboard.html', [
'pageTitle' => 'Tableau de bord',
'posts' => $result['posts'],
'pagination' => $result,
'paginationBase' => $this->f3->alias('dashboard'),
]);
'paginationAlias' => 'dashboard',
], true);
}
}

View File

@@ -5,21 +5,29 @@ declare(strict_types=1);
class MediaController extends BaseController
{
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
private const PER_PAGE = 24;
public function beforeRoute(): void
{
$this->requireAuth();
$this->disableCache();
}
public function index(): void
{
$this->requireAuth();
$this->f3->expire(0);
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$result = (new Media($this->db))->paginateLibrary($page, self::PER_PAGE);
$this->render('admin/media.html', [
$this->renderSession('admin/media.html', [
'pageTitle' => 'Médiathèque',
'items' => (new Media($this->db))->all(),
]);
'items' => $result['items'],
'pagination' => $result,
'paginationAlias' => 'media_index',
], true);
}
public function upload(): void
{
$this->requireAuth();
$this->verifyCsrf();
try {
@@ -27,7 +35,8 @@ class MediaController extends BaseController
$originalName = (string) ($this->f3->get('FILES.image.name') ?? '');
$received = Web::instance()->receive(
fn(array $file): bool => $file['size'] <= self::UPLOAD_MAX_BYTES,
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0
&& (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
overwrite: false,
slug: true
);
@@ -48,12 +57,11 @@ class MediaController extends BaseController
$this->flash('error', $e->getMessage());
}
$this->f3->reroute($this->f3->alias('media_index'));
$this->f3->reroute('@media_index');
}
public function updateAlt(): void
{
$this->requireAuth();
$this->verifyCsrf();
try {
@@ -64,12 +72,11 @@ class MediaController extends BaseController
$this->flash('error', $e->getMessage());
}
$this->f3->reroute($this->f3->alias('media_index'));
$this->f3->reroute('@media_index');
}
public function delete(): void
{
$this->requireAuth();
$this->verifyCsrf();
try {
@@ -79,6 +86,6 @@ class MediaController extends BaseController
$this->flash('error', $e->getMessage());
}
$this->f3->reroute($this->f3->alias('media_index'));
$this->f3->reroute('@media_index');
}
}

View File

@@ -4,16 +4,21 @@ declare(strict_types=1);
class PostController extends BaseController
{
public function create(): void
private const MEDIA_PICKER_LIMIT = 60;
public function beforeRoute(): void
{
$this->requireAuth();
$this->f3->expire(0);
$this->disableCache();
}
public function create(): void
{
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), Post::emptyForm());
}
public function store(): void
{
$this->requireAuth();
$this->verifyCsrf();
$input = $this->postInput();
@@ -22,18 +27,14 @@ class PostController extends BaseController
(new Post($this->db))->create($input);
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
$this->flash('success', 'Article créé.');
$this->f3->reroute($this->f3->alias('dashboard'));
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->f3->expire(0);
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
}
}
public function edit(): void
{
$this->requireAuth();
$this->f3->expire(0);
$post = (new Post($this->db))->findForEdit((int) $this->f3->get('PARAMS.id'));
if ($post === null) {
$this->f3->error(404, 'Article introuvable.');
@@ -45,7 +46,6 @@ class PostController extends BaseController
public function update(): void
{
$this->requireAuth();
$this->verifyCsrf();
$id = (int) $this->f3->get('PARAMS.id');
@@ -60,21 +60,19 @@ class PostController extends BaseController
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
$this->flash('success', 'Article mis à jour.');
$this->f3->reroute($this->f3->alias('dashboard'));
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->f3->expire(0);
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
}
}
public function delete(): void
{
$this->requireAuth();
$this->verifyCsrf();
(new Post($this->db))->delete((int) $this->f3->get('PARAMS.id'));
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
$this->flash('success', 'Article supprimé.');
$this->f3->reroute($this->f3->alias('dashboard'));
$this->f3->reroute('@dashboard');
}
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null): void
@@ -84,18 +82,24 @@ class PostController extends BaseController
$coverPreview = (new Media($this->db))->findById((int) $post['cover_media_id']);
}
$media = new Media($this->db);
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
$mediaCount = $media->countAll();
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;
$this->render('admin/post_form.html', [
$this->renderSession('admin/post_form.html', [
'pageTitle' => $pageTitle,
'formAction' => $formAction,
'post' => $post,
'coverPreview' => $coverPreview,
'mediaItems' => (new Media($this->db))->all(),
'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,
]);
], true);
}
private function postInput(): array

View File

@@ -6,30 +6,26 @@ class SiteController extends BaseController
{
public function home(): void
{
$this->f3->expire(300); // 5 min — page publique, contenu peu volatile
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$result = (new Post($this->db))->paginateList($page);
$this->render('site/home.html', [
$this->renderPublic('site/home.html', [
'pageTitle' => 'Accueil',
'posts' => $result['posts'],
'pagination' => $result,
'paginationBase' => $this->f3->alias('home'),
'paginationAlias' => 'home',
]);
}
public function show(): void
{
$this->f3->expire(3600); // 1 h — les articles bougent rarement
$post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug'));
if ($post === null) {
$this->f3->error(404, 'Article introuvable.');
return;
}
$this->render('site/post.html', [
$this->renderPublic('site/post.html', [
'pageTitle' => $post['title'],
'post' => $post,
]);

View File

@@ -4,6 +4,10 @@ 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(DB\SQL $db)
{
parent::__construct($db, 'media');
@@ -30,6 +34,35 @@ class Media extends DB\SQL\Mapper
);
}
public function paginateLibrary(int $page = 1, int $perPage = 24): array
{
$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,
];
}
public function latest(int $limit = 60): array
{
return array_map(
fn(self $m): array => $this->decorate($m->cast()),
$this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: []
);
}
public function countAll(): int
{
return $this->count();
}
public function findById(int $id): ?array
{
if ($id <= 0) {
@@ -50,36 +83,50 @@ class Media extends DB\SQL\Mapper
// pour dériver un texte alternatif lisible.
public function upload(string $srcPath, string $originalName = ''): int
{
// Image::dump() gère le chargement et la compression.
// Attention : en JPEG, la transparence n'est pas conservée.
// $path='' indique à F3 que le chemin est absolu.
$target = null;
$image = null;
try {
$img = new Image($srcPath, false, '');
} catch (Throwable) {
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
}
$meta = self::inspectUpload($srcPath);
$image = self::openImageResource($srcPath, $meta['mime']);
$data = $img->dump('jpeg', 85);
$fileName = bin2hex(random_bytes(16)) . '.jpg';
[$format, $extension] = self::targetFormat($meta['mime']);
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = app_public_media_dir() . '/' . $fileName;
if (!Base::instance()->write($target, $data)) {
throw new RuntimeException('Impossible d\'enregistrer cette image.');
}
// Supprimer le fichier intermédiaire déposé par Web::receive().
@unlink($srcPath);
self::writeImage($image, $target, $format);
$this->db->begin();
try {
$this->reset();
$this->file_name = $fileName;
$this->alt = $originalName !== '' ? self::altFromFilename($originalName) : '';
$this->width = $img->width();
$this->height = $img->height();
$this->width = $meta['width'];
$this->height = $meta['height'];
$this->created_at = app_now();
$this->save();
$this->db->commit();
} catch (Throwable $e) {
$this->db->rollback();
if ($target !== null && is_file($target)) {
@unlink($target);
}
throw $e;
}
return (int) $this->get('id');
} catch (RuntimeException $e) {
throw $e;
} catch (Throwable) {
throw new RuntimeException('Impossible d\'enregistrer cette image.');
} finally {
if ($image instanceof GdImage) {
imagedestroy($image);
}
if (is_file($srcPath)) {
@unlink($srcPath);
}
}
}
public function updateAlt(int $id, string $alt): void
@@ -119,6 +166,83 @@ class Media extends DB\SQL\Mapper
}
}
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,
];
}
private static function openImageResource(string $srcPath, string $mime): GdImage
{
$image = match ($mime) {
'image/jpeg' => @imagecreatefromjpeg($srcPath),
'image/png' => @imagecreatefrompng($srcPath),
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($srcPath) : false,
default => false,
};
if (!$image instanceof GdImage) {
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
}
return $image;
}
private static function targetFormat(string $mime): array
{
return match ($mime) {
'image/jpeg' => ['jpeg', 'jpg'],
'image/png', 'image/webp' => ['png', 'png'],
default => throw new RuntimeException('Format non supporté. Utilise JPG, PNG ou WebP.'),
};
}
private static function writeImage(GdImage $image, string $target, string $format): void
{
if ($format === 'png') {
if (function_exists('imagepalettetotruecolor') && !imageistruecolor($image)) {
imagepalettetotruecolor($image);
}
imagealphablending($image, false);
imagesavealpha($image, true);
$written = @imagepng($image, $target, 6);
} else {
$written = @imagejpeg($image, $target, 85);
}
if ($written !== true || !is_file($target)) {
throw new RuntimeException('Impossible d\'enregistrer cette image.');
}
}
// Dérive un texte alternatif lisible depuis le nom de fichier d'origine.
private static function altFromFilename(string $filename): string
{

View File

@@ -10,11 +10,6 @@ class MarkdownService
'strong', 'em', 'a', 'img', 'hr', 'br',
];
private const ALLOWED_ATTRS = [
'a' => ['href', 'title', 'rel', 'target'],
'img' => ['src', 'alt', 'title', 'loading', 'decoding'],
];
public static function compile(string $markdown, Media $media): string
{
$markdown = trim($markdown);
@@ -34,107 +29,188 @@ class MarkdownService
return $html;
}
// Passe DOM unique : sanitise les balises/attributs et résout les références media:.
// Reconstruction en liste blanche : les descendants d'une balise interdite
// sont retraités récursivement avant d'être réinsérés.
private static function sanitizeAndResolve(string $html, Media $media): string
{
$dom = new DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="UTF-8"><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$source = new DOMDocument('1.0', 'UTF-8');
$clean = new DOMDocument('1.0', 'UTF-8');
$cleanBody = $clean->createElement('body');
$clean->appendChild($cleanBody);
$body = $dom->getElementsByTagName('body')->item(0);
if (!$body instanceof DOMElement) {
$previousUseInternalErrors = libxml_use_internal_errors(true);
$source->loadHTML('<?xml encoding="UTF-8"><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previousUseInternalErrors);
$sourceBody = $source->getElementsByTagName('body')->item(0);
if (!$sourceBody instanceof DOMElement) {
return '';
}
self::processNode($body, $media);
self::appendSanitizedChildren($sourceBody, $cleanBody, $clean, $media);
$out = '';
foreach ($body->childNodes as $child) {
$out .= $dom->saveHTML($child);
for ($i = 0; $i < $cleanBody->childNodes->length; $i++) {
$child = $cleanBody->childNodes->item($i);
if ($child !== null) {
$out .= $clean->saveHTML($child);
}
}
return trim($out);
}
private static function processNode(DOMNode $parent, Media $media): void
private static function appendSanitizedChildren(DOMNode $sourceParent, DOMNode $targetParent, DOMDocument $target, Media $media): void
{
for ($i = $parent->childNodes->length - 1; $i >= 0; $i--) {
$child = $parent->childNodes->item($i);
if ($child === null) {
continue;
$children = [];
for ($i = 0; $i < $sourceParent->childNodes->length; $i++) {
$child = $sourceParent->childNodes->item($i);
if ($child !== null) {
$children[] = $child;
}
}
foreach ($children as $child) {
if ($child instanceof DOMComment) {
$parent->removeChild($child);
continue;
}
if ($child instanceof DOMText) {
$targetParent->appendChild($target->createTextNode($child->nodeValue ?? ''));
continue;
}
if (!$child instanceof DOMElement) {
$parent->removeChild($child);
continue;
}
if (!in_array($child->tagName, self::ALLOWED_TAGS, true)) {
self::unwrap($child);
continue;
}
self::sanitizeAttributes($child, $media);
// img may have been removed by sanitizeAttributes
if ($child->parentNode !== null) {
self::processNode($child, $media);
}
self::appendSanitizedElement($child, $targetParent, $target, $media);
}
}
private static function sanitizeAttributes(DOMElement $element, Media $media): void
private static function appendSanitizedElement(DOMElement $sourceElement, DOMNode $targetParent, DOMDocument $target, Media $media): void
{
$allowed = self::ALLOWED_ATTRS[$element->tagName] ?? [];
$toRemove = [];
foreach ($element->attributes as $attribute) {
if (!in_array($attribute->name, $allowed, true)) {
$toRemove[] = $attribute->name;
}
}
foreach ($toRemove as $name) {
$element->removeAttribute($name);
}
if ($element->tagName === 'a') {
$href = trim($element->getAttribute('href'));
if ($href === '' || !preg_match('~^(https?:|mailto:|tel:|/)~i', $href)) {
$element->removeAttribute('href');
} else {
$element->setAttribute('rel', 'noopener noreferrer');
if (preg_match('~^https?://~i', $href)) {
$element->setAttribute('target', '_blank');
}
}
}
if ($element->tagName === 'img') {
$src = trim($element->getAttribute('src'));
if ($src === '' || !str_starts_with($src, 'media:')) {
$element->parentNode?->removeChild($element);
$tag = strtolower($sourceElement->tagName);
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
self::appendSanitizedChildren($sourceElement, $targetParent, $target, $media);
return;
}
if ($tag === 'img') {
$image = self::buildSanitizedImage($sourceElement, $target, $media);
if ($image !== null) {
$targetParent->appendChild($image);
}
return;
}
$cleanElement = $target->createElement($tag);
self::sanitizeAttributes($sourceElement, $cleanElement);
$targetParent->appendChild($cleanElement);
self::appendSanitizedChildren($sourceElement, $cleanElement, $target, $media);
}
private static function sanitizeAttributes(DOMElement $sourceElement, DOMElement $targetElement): void
{
if ($targetElement->tagName !== 'a') {
return;
}
$href = self::sanitizeHref((string) $sourceElement->getAttribute('href'));
if ($href !== null) {
$targetElement->setAttribute('href', $href);
$targetElement->setAttribute('rel', 'noopener noreferrer');
if (preg_match('~^https?://~i', $href) === 1) {
$targetElement->setAttribute('target', '_blank');
}
}
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
if ($title !== null) {
$targetElement->setAttribute('title', $title);
}
}
private static function buildSanitizedImage(DOMElement $sourceElement, DOMDocument $target, Media $media): ?DOMElement
{
$src = trim((string) $sourceElement->getAttribute('src'));
if ($src === '' || !str_starts_with($src, 'media:')) {
return null;
}
$fileName = substr($src, 6);
if ($fileName === '' || preg_match('/[\x00-\x1F\x7F]/u', $fileName) === 1) {
return null;
}
$item = $media->findByFileName($fileName);
if ($item === null) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
$element->setAttribute('src', (string) $item['url']);
$element->setAttribute('loading', 'lazy');
$element->setAttribute('decoding', 'async');
$image = $target->createElement('img');
$image->setAttribute('src', (string) $item['url']);
$image->setAttribute('loading', 'lazy');
$image->setAttribute('decoding', 'async');
if ($sourceElement->hasAttribute('alt')) {
$image->setAttribute('alt', self::sanitizeAttributeValue((string) $sourceElement->getAttribute('alt'), true) ?? '');
} elseif ((string) $item['alt'] !== '') {
$image->setAttribute('alt', (string) $item['alt']);
} else {
$image->setAttribute('alt', '');
}
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
if ($title !== null) {
$image->setAttribute('title', $title);
}
return $image;
}
private static function sanitizeHref(string $href): ?string
{
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($href === '' || preg_match('/[\x00-\x1F\x7F]/u', $href) === 1) {
return null;
}
if (preg_match('~^(https?://|mailto:|tel:)~i', $href) === 1) {
return $href;
}
if (self::isSafeRelativeHref($href)) {
return $href;
}
return null;
}
private static function isSafeRelativeHref(string $href): bool
{
if ($href === '/') {
return true;
}
if (str_starts_with($href, '//')) {
return false;
}
return preg_match('~^(?:/[^/]|\./|\.\./|#|\?)~', $href) === 1;
}
private static function sanitizeAttributeValue(string $value, bool $allowEmpty = false): ?string
{
$value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$value = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value));
if ($value === '' && !$allowEmpty) {
return null;
}
return $value;
}
private static function normalizeMarkdown(string $markdown): string
@@ -175,18 +251,4 @@ class MarkdownService
return trim(implode("\n", $normalized));
}
private static function unwrap(DOMElement $element): void
{
$parent = $element->parentNode;
if ($parent === null) {
return;
}
while ($element->firstChild !== null) {
$parent->insertBefore($element->firstChild, $element);
}
$parent->removeChild($element);
}
}

View File

@@ -3,8 +3,8 @@
<h1 class="page-title" id="dashboard-title">Tableau de bord</h1>
<div class="page-actions">
<a class="button" href="{{ @BASE }}/dashboard/posts/create">Nouvel article</a>
<a class="button button--ghost" href="{{ @BASE }}/dashboard/media">Médiathèque</a>
<a class="button" href="{{ 'post_create' | alias }}">Nouvel article</a>
<a class="button button--ghost" href="{{ 'media_index' | alias }}">Médiathèque</a>
</div>
</header>

View File

@@ -1,18 +1,21 @@
<section class="stack-lg" aria-labelledby="media-title">
<header class="page-header">
<div>
<h1 class="page-title" id="media-title">Médiathèque</h1>
<p class="field-help">Parcourir les images par page évite de charger toute la bibliothèque d'un coup.</p>
</div>
<div class="page-actions">
<a class="button button--ghost" href="{{ @BASE }}/dashboard">Retour</a>
<a class="button button--ghost" href="{{ 'dashboard' | alias }}">Retour</a>
</div>
</header>
<form class="panel stack" method="post" action="{{ @BASE }}/dashboard/media" enctype="multipart/form-data">
<form class="panel stack" method="post" action="{{ 'media_upload' | alias }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
<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.</span>
<span class="field-help">Formats acceptés : JPG, PNG, WebP. Limite : 10 Mo, 8000 × 8000 px et 40 mégapixels.</span>
</label>
<button class="button" type="submit">Envoyer</button>
</form>
@@ -24,6 +27,7 @@
<include href="partials/media_card.html" />
</repeat>
</div>
<include href="partials/pagination.html" />
</true>
<false>
<section class="empty-state" aria-labelledby="media-empty-title">

View File

@@ -3,7 +3,7 @@
<h1 class="page-title" id="post-form-title">{{ @pageTitle }}</h1>
<div class="page-actions">
<a class="button button--ghost" href="{{ @BASE }}/dashboard">Retour</a>
<a class="button button--ghost" href="{{ 'dashboard' | alias }}">Retour</a>
</div>
</header>
@@ -94,6 +94,11 @@
</button>
</repeat>
</div>
<check if="{{ @mediaPickerTruncated }}">
<true>
<p class="field-help">Affichage limité aux {{ @mediaPickerLimit }} images les plus récentes sur {{ @mediaCount }}. Utilise la <a href="{{ 'media_index' | alias }}">médiathèque</a> pour parcourir toute la bibliothèque.</p>
</true>
</check>
</true>
<false>
<section class="empty-state" aria-labelledby="media-picker-empty-title">

View File

@@ -3,7 +3,7 @@
<h1 class="page-title" id="login-title">Connexion</h1>
</header>
<form class="stack" method="post" action="{{ @BASE }}/login">
<form class="stack" method="post" action="{{ 'login_submit' | alias }}">
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
<label class="field">

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ @errorTitle ?: 'Erreur' }}</title>
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="{{ @BASE }}/min/app.css">
<link rel="stylesheet" href="{{ 'asset', 'file=app.css' | alias }}">
</head>
<body>
<main class="page error-page">
@@ -15,7 +15,7 @@
<h1 class="error-page__title">{{ @errorTitle ?: 'Erreur' }}</h1>
<p class="error-page__message">{{ @errorMessage ?: 'Une erreur est survenue.' }}</p>
<p class="error-page__hint">Vérifie ladresse ou reviens à laccueil.</p>
<p class="error-page__actions"><a class="button" href="{{ @BASE }}/">Retour à laccueil</a></p>
<p class="error-page__actions"><a class="button" href="{{ 'home' | alias }}">Retour à laccueil</a></p>
</section>
</div>
</main>

View File

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

View File

@@ -3,7 +3,7 @@
<div class="card-body article-card__body">
<p class="meta-text">{{ @item.width }} × {{ @item.height }}<br>{{ @item.created_at_label }}</p>
<form class="stack" method="post" action="{{ @BASE }}/dashboard/media/{{ @item.id }}/alt">
<form class="stack" method="post" action="{{ 'media_update_alt', 'id='.@item.id | alias }}">
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
<label class="field">
<span class="field-label">Texte alternatif</span>
@@ -14,7 +14,7 @@
<div class="card-actions">
<button class="button button--ghost" type="button" data-copy-text="{{ @item.markdown }}" data-markdown-template="![](media:{{ @item.file_name }})">Copier le Markdown</button>
<form method="post" action="{{ @BASE }}/dashboard/media/{{ @item.id }}/delete" data-confirm="Supprimer cette image ?">
<form method="post" action="{{ 'media_delete', 'id='.@item.id | alias }}" data-confirm="Supprimer cette image ?">
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
<button class="button button--danger" type="submit">Supprimer</button>
</form>

View File

@@ -2,10 +2,10 @@
<check if="{{ @currentUser }}">
<true>
<li class="nav-items__item">
<a class="nav-items__link" href="{{ @BASE }}/dashboard">Dashboard</a>
<a class="nav-items__link" href="{{ 'dashboard' | alias }}">Dashboard</a>
</li>
<li class="nav-items__item">
<form class="nav-items__form" method="post" action="{{ @BASE }}/logout">
<form class="nav-items__form" method="post" action="{{ 'logout' | alias }}">
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
<button class="nav-items__button" type="submit">Déconnexion</button>
</form>
@@ -13,7 +13,7 @@
</true>
<false>
<li class="nav-items__item">
<a class="nav-items__link" href="{{ @BASE }}/login">Connexion</a>
<a class="nav-items__link" href="{{ 'login' | alias }}">Connexion</a>
</li>
</false>
</check>

View File

@@ -3,7 +3,7 @@
<nav class="pagination" aria-label="Pagination">
<check if="{{ @pagination.page > 1 }}">
<true>
<a class="button button--ghost" href="{{ @paginationBase }}?page={{ @pagination.page - 1 }}">Précédent</a>
<a class="button button--ghost" href="{{ @paginationAlias | alias }}?page={{ @pagination.page - 1 }}">Précédent</a>
</true>
<false>
<span class="button button--ghost pagination__disabled">Précédent</span>
@@ -14,7 +14,7 @@
<check if="{{ @pagination.page < @pagination.pages }}">
<true>
<a class="button button--ghost" href="{{ @paginationBase }}?page={{ @pagination.page + 1 }}">Suivant</a>
<a class="button button--ghost" href="{{ @paginationAlias | alias }}?page={{ @pagination.page + 1 }}">Suivant</a>
</true>
<false>
<span class="button button--ghost pagination__disabled">Suivant</span>

View File

@@ -1,5 +1,5 @@
<article class="card article-card">
<a class="card-media-link" href="{{ @BASE }}/posts/{{ @post.slug }}">
<a class="card-media-link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">
<check if="{{ @post.cover_url }}">
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
<false>

View File

@@ -1,5 +1,5 @@
<article class="card article-card">
<a class="card-media-link" href="{{ @BASE }}/dashboard/posts/{{ @post.id }}/edit">
<a class="card-media-link" href="{{ 'post_edit', 'id='.@post.id | alias }}">
<check if="{{ @post.cover_url }}">
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
<false>
@@ -17,10 +17,9 @@
</p>
<p class="card-summary">{{ @post.excerpt }}</p>
<div class="card-actions">
<a class="button button--ghost" href="{{ @BASE }}/posts/{{ @post.slug }}">Voir</a>
<a class="button button--ghost" href="{{ @BASE }}/dashboard/posts/{{ @post.id }}/edit">Modifier</a>
<form method="post" action="{{ @BASE }}/dashboard/posts/{{ @post.id }}/delete"
data-confirm="Supprimer cet article ?">
<a class="button button--ghost" href="{{ 'post_show', 'slug='.@post.slug | alias }}">Voir</a>
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
<button class="button button--danger" type="submit">Supprimer</button>
</form>

View File

@@ -1 +1 @@
<a class="site-brand__title" href="{{ @BASE }}/">{{ @app.name }}</a>
<a class="site-brand__title" href="{{ 'home' | alias }}">{{ @app.name }}</a>

View File

@@ -10,10 +10,10 @@ app.name=F3 Simple Blog
app.tagline=Blog simple avec Fat-Free Framework.
[routes]
GET @asset: /min/@file=AssetController->serve
GET @asset: /min/@file=AssetController->serve, 86400
GET @home: /=SiteController->home
GET @post_show: /posts/@slug=SiteController->show
GET @home: /=SiteController->home, 300
GET @post_show: /posts/@slug=SiteController->show, 3600
GET @login: /login=AuthController->show
POST @login_submit: /login=AuthController->login

View File

@@ -10,5 +10,4 @@ services:
- ./config.local.ini:/var/www/html/config.local.ini:ro
- ./db:/var/www/html/db
- ./logs:/var/www/html/logs
- ./tmp:/var/www/html/tmp
- ./public/uploads/media:/var/www/html/public/uploads/media

2
composer.lock generated
View File

@@ -59,7 +59,7 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.1"
"php": ">=8.3"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"