Less home code more F3
This commit is contained in:
@@ -29,7 +29,7 @@ class AuthController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
session_regenerate_id(true); // Prévient la fixation de session.
|
||||
$this->f3->set('SESSION.user_id', $user['id']);
|
||||
$this->flash('success', 'Connexion réussie.');
|
||||
$this->f3->reroute('@dashboard');
|
||||
@@ -39,7 +39,7 @@ class AuthController extends BaseController
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
$this->f3->clear('SESSION.user_id');
|
||||
session_regenerate_id(true);
|
||||
session_regenerate_id(true); // Invalide l'ancien ID de session.
|
||||
$this->flash('success', 'Déconnexion effectuée.');
|
||||
$this->f3->reroute('@login');
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ abstract class BaseController
|
||||
protected Base $f3;
|
||||
protected DB\SQL $db;
|
||||
|
||||
private ?array $resolvedUser = null;
|
||||
private bool $userResolved = false;
|
||||
// false = pas encore résolu, null = résolu sans utilisateur.
|
||||
private array|false|null $resolvedUser = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -20,9 +20,9 @@ abstract class BaseController
|
||||
{
|
||||
$user = $this->currentUser();
|
||||
|
||||
// Les pages publiques peuvent rester cacheables avec le TTL demandé.
|
||||
// Les pages publiques restent cacheables avec le TTL demandé.
|
||||
// Si un utilisateur est connecté, le layout dépend de la session
|
||||
// (navigation d'admin, déconnexion + CSRF) : on force expire(0)
|
||||
// (navigation admin, déconnexion + CSRF) : on force expire(0)
|
||||
// pour ne pas servir ce rendu à d'autres visiteurs.
|
||||
$this->f3->expire($user !== null ? 0 : $cacheTtl);
|
||||
|
||||
@@ -37,19 +37,17 @@ abstract class BaseController
|
||||
'metaDescription' => null,
|
||||
]);
|
||||
|
||||
// Mémoriser en session la valeur exposée à @CSRF pour que les
|
||||
// formulaires rendus pendant cette réponse puissent être vérifiés
|
||||
// lors du POST suivant par verifyCsrf().
|
||||
// Recopier @CSRF en session pour que verifyCsrf() puisse
|
||||
// vérifier le jeton soumis au POST suivant.
|
||||
$this->f3->copy('CSRF', 'SESSION.csrf');
|
||||
echo Template::instance()->render('layout.html');
|
||||
}
|
||||
|
||||
protected function currentUser(): ?array
|
||||
{
|
||||
if (!$this->userResolved) {
|
||||
if ($this->resolvedUser === false) {
|
||||
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
|
||||
$this->resolvedUser = $userId > 0 ? (new User($this->db))->findById($userId) : null;
|
||||
$this->userResolved = true;
|
||||
}
|
||||
|
||||
return $this->resolvedUser;
|
||||
@@ -65,15 +63,12 @@ abstract class BaseController
|
||||
$this->f3->reroute('@login');
|
||||
}
|
||||
|
||||
// La classe Session de F3 expose la valeur courante via « CSRF ».
|
||||
// Au rendu, on la recopie en SESSION.csrf ; le formulaire renvoie
|
||||
// ensuite le jeton affiché lors du rendu précédent, qu'on compare à
|
||||
// la valeur mémorisée en session.
|
||||
protected function verifyCsrf(): void
|
||||
{
|
||||
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');
|
||||
$expected = (string) ($this->f3->get('SESSION.csrf') ?? '');
|
||||
|
||||
// hash_equals : comparaison en temps constant contre les attaques temporelles.
|
||||
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,13 +12,15 @@ class DashboardController extends BaseController
|
||||
public function index(): void
|
||||
{
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||
$result = (new Post($this->db))->paginateList($page, 24);
|
||||
$media = new Media($this->db);
|
||||
$result = (new Post($this->db))->paginateList($page, 24, $media);
|
||||
|
||||
$this->render('admin/dashboard.html', [
|
||||
'pageTitle' => 'Tableau de bord',
|
||||
'posts' => $result['posts'],
|
||||
'pagination' => $result,
|
||||
'paginationAlias' => 'dashboard',
|
||||
'adminMode' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,19 @@ class MediaController extends BaseController
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
(new Media($this->db))->delete((int) $this->f3->get('PARAMS.id'));
|
||||
$id = (int) $this->f3->get('PARAMS.id');
|
||||
$media = new Media($this->db);
|
||||
$item = $media->findById($id);
|
||||
|
||||
if ($item === null) {
|
||||
throw new RuntimeException('Image introuvable.');
|
||||
}
|
||||
|
||||
if ((new Post($this->db))->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());
|
||||
|
||||
@@ -20,14 +20,15 @@ class PostController extends BaseController
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
|
||||
$media = new Media($this->db);
|
||||
$input = $this->postInput();
|
||||
|
||||
try {
|
||||
(new Post($this->db))->create($input);
|
||||
(new Post($this->db))->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());
|
||||
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage(), $media);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +47,12 @@ class PostController extends BaseController
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
|
||||
$media = new Media($this->db);
|
||||
$id = (int) $this->f3->get('PARAMS.id');
|
||||
$input = $this->postInput() + ['id' => $id];
|
||||
|
||||
try {
|
||||
$updated = (new Post($this->db))->updatePost($id, $input);
|
||||
$updated = (new Post($this->db))->updatePost($id, $input, $media);
|
||||
if (!$updated) {
|
||||
$this->f3->error(404, 'Article introuvable.');
|
||||
return;
|
||||
@@ -59,7 +61,7 @@ class PostController extends BaseController
|
||||
$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());
|
||||
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage(), $media);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,14 +79,15 @@ class PostController extends BaseController
|
||||
$this->f3->reroute('@dashboard');
|
||||
}
|
||||
|
||||
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null): void
|
||||
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null, ?Media $media = null): void
|
||||
{
|
||||
$media ??= new Media($this->db);
|
||||
|
||||
$coverPreview = null;
|
||||
if (!empty($post['cover_media_id'])) {
|
||||
$coverPreview = (new Media($this->db))->findById((int) $post['cover_media_id']);
|
||||
$coverPreview = $media->findById((int) $post['cover_media_id']);
|
||||
}
|
||||
|
||||
$media = new Media($this->db);
|
||||
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
|
||||
$mediaCount = $media->count();
|
||||
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;
|
||||
|
||||
@@ -7,7 +7,8 @@ class SiteController extends BaseController
|
||||
public function home(): void
|
||||
{
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||
$result = (new Post($this->db))->paginateList($page);
|
||||
$media = new Media($this->db);
|
||||
$result = (new Post($this->db))->paginateList($page, 12, $media);
|
||||
|
||||
$this->render('site/home.html', [
|
||||
'pageTitle' => 'Accueil',
|
||||
@@ -19,7 +20,8 @@ class SiteController extends BaseController
|
||||
|
||||
public function show(): void
|
||||
{
|
||||
$post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug'));
|
||||
$media = new Media($this->db);
|
||||
$post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug'), $media);
|
||||
if ($post === null) {
|
||||
$this->f3->error(404, 'Article introuvable.');
|
||||
return;
|
||||
|
||||
@@ -50,6 +50,25 @@ class Media extends DB\SQL\Mapper
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -66,18 +85,19 @@ class Media extends DB\SQL\Mapper
|
||||
return $this->dry() ? null : $this->decorate($this->cast());
|
||||
}
|
||||
|
||||
// Reçoit le chemin absolu déposé par Web::receive() et le nom d'origine
|
||||
// pour dériver un texte alternatif lisible.
|
||||
// Traite le fichier temporaire déposé par Web::receive() et publie l'image.
|
||||
public function upload(string $srcPath, string $originalName = ''): int
|
||||
{
|
||||
$target = null;
|
||||
$image = null;
|
||||
$committed = false; // Contrôle le nettoyage de $target dans finally.
|
||||
|
||||
try {
|
||||
$meta = self::inspectUpload($srcPath);
|
||||
$image = self::openImageResource($srcPath, $meta['mime']);
|
||||
|
||||
[$format, $extension] = self::targetFormat($meta['mime']);
|
||||
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
|
||||
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
||||
$target = app_public_media_dir() . '/' . $fileName;
|
||||
|
||||
@@ -93,19 +113,15 @@ class Media extends DB\SQL\Mapper
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
$this->db->commit();
|
||||
$committed = true;
|
||||
} 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.');
|
||||
} catch (Throwable $e) {
|
||||
throw $e instanceof RuntimeException ? $e : new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
} finally {
|
||||
if ($image instanceof GdImage) {
|
||||
imagedestroy($image);
|
||||
@@ -113,6 +129,9 @@ class Media extends DB\SQL\Mapper
|
||||
if (is_file($srcPath)) {
|
||||
@unlink($srcPath);
|
||||
}
|
||||
if (!$committed && $target !== null && is_file($target)) {
|
||||
@unlink($target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,16 +148,12 @@ class Media extends DB\SQL\Mapper
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$item = $this->findById($id);
|
||||
if ($item === null) {
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
throw new RuntimeException('Image introuvable.');
|
||||
}
|
||||
|
||||
if ($this->isUsed($item)) {
|
||||
throw new RuntimeException('Cette image est encore utilisée par un article.');
|
||||
}
|
||||
|
||||
$path = app_public_media_dir() . '/' . $item['file_name'];
|
||||
$path = app_public_media_dir() . '/' . $this->file_name;
|
||||
|
||||
$this->db->begin();
|
||||
try {
|
||||
@@ -203,6 +218,8 @@ class Media extends DB\SQL\Mapper
|
||||
return $image;
|
||||
}
|
||||
|
||||
// PNG/WebP → PNG pour préserver la transparence de manière fiable.
|
||||
// JPG reste en JPG (pas de canal alpha).
|
||||
private static function targetFormat(string $mime): array
|
||||
{
|
||||
return match ($mime) {
|
||||
@@ -239,15 +256,6 @@ class Media extends DB\SQL\Mapper
|
||||
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
|
||||
}
|
||||
|
||||
// Une seule requête SQL pour les deux cas d'utilisation (couverture et body).
|
||||
private function isUsed(array $item): bool
|
||||
{
|
||||
return $this->db->exec(
|
||||
'SELECT 1 FROM posts WHERE cover_media_id = ? OR body_markdown LIKE ? LIMIT 1',
|
||||
[$item['id'], '%media:' . $item['file_name'] . '%']
|
||||
) !== [];
|
||||
}
|
||||
|
||||
private function decorate(array $row): array
|
||||
{
|
||||
$alt = (string) $row['alt'];
|
||||
|
||||
@@ -39,7 +39,7 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
public function paginateList(int $page = 1, int $perPage = 12): array
|
||||
public function paginateList(int $page, int $perPage, Media $media): array
|
||||
{
|
||||
$result = $this->paginate(
|
||||
max(0, $page - 1),
|
||||
@@ -49,11 +49,13 @@ class Post extends DB\SQL\Mapper
|
||||
);
|
||||
|
||||
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
|
||||
$covers = $this->loadCovers($posts);
|
||||
$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 ? app_media_url((string) $cover['file_name']) : '';
|
||||
$post['cover_url'] = $cover['url'] ?? '';
|
||||
$post['cover_alt'] = $cover['alt'] ?? '';
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -63,7 +65,7 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?array
|
||||
public function findBySlug(string $slug, Media $media): ?array
|
||||
{
|
||||
$this->load(['slug = ?', $slug]);
|
||||
if ($this->dry()) {
|
||||
@@ -71,9 +73,9 @@ class Post extends DB\SQL\Mapper
|
||||
}
|
||||
|
||||
$post = $this->summaryRow($this->cast());
|
||||
$covers = $this->loadCovers([$post]);
|
||||
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
||||
$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;
|
||||
|
||||
return $post;
|
||||
@@ -99,10 +101,10 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
public function create(array $input): int
|
||||
public function create(array $input, Media $media): int
|
||||
{
|
||||
$payload = $this->payload($input);
|
||||
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->slugExists($candidate));
|
||||
$payload = $this->payload($input, $media);
|
||||
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->count(['slug = ?', $candidate]) > 0);
|
||||
$now = app_now();
|
||||
|
||||
$this->reset();
|
||||
@@ -116,20 +118,31 @@ class Post extends DB\SQL\Mapper
|
||||
return (int) $this->get('id');
|
||||
}
|
||||
|
||||
public function updatePost(int $id, array $input): bool
|
||||
public function updatePost(int $id, array $input, Media $media): bool
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $this->payload($input);
|
||||
$payload = $this->payload($input, $media);
|
||||
$this->copyfrom($payload + ['updated_at' => app_now()]);
|
||||
$this->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
@@ -140,7 +153,7 @@ class Post extends DB\SQL\Mapper
|
||||
$this->erase();
|
||||
}
|
||||
|
||||
private function payload(array $input): array
|
||||
private function payload(array $input, Media $media): array
|
||||
{
|
||||
$title = trim((string) ($input['title'] ?? ''));
|
||||
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
||||
@@ -160,8 +173,6 @@ class Post extends DB\SQL\Mapper
|
||||
throw new RuntimeException("L'extrait est trop long.");
|
||||
}
|
||||
|
||||
$media = new Media($this->db);
|
||||
|
||||
$coverId = null;
|
||||
if ($coverMediaId !== '') {
|
||||
$coverId = (int) $coverMediaId;
|
||||
@@ -181,11 +192,6 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
private function slugExists(string $slug): bool
|
||||
{
|
||||
return $this->count(['slug = ?', $slug]) > 0;
|
||||
}
|
||||
|
||||
private function summaryRow(array $row): array
|
||||
{
|
||||
return [
|
||||
@@ -199,24 +205,4 @@ class Post extends DB\SQL\Mapper
|
||||
];
|
||||
}
|
||||
|
||||
private function loadCovers(array $posts): array
|
||||
{
|
||||
$ids = array_filter(array_unique(array_column($posts, 'cover_media_id')));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$rows = $this->db->exec(
|
||||
"SELECT id, file_name FROM media WHERE id IN ($placeholders)",
|
||||
array_values($ids)
|
||||
);
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int) $row['id']] = $row;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class User extends DB\SQL\Mapper
|
||||
}
|
||||
|
||||
$data = $this->cast();
|
||||
unset($data['password_hash']);
|
||||
unset($data['password_hash']); // Ne jamais exposer le hash hors de l'authentification.
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,238 +17,68 @@ class MarkdownService extends Prefab
|
||||
throw new RuntimeException('Ajoute du contenu avant de publier.');
|
||||
}
|
||||
|
||||
$markdown = self::normalizeMarkdown($markdown);
|
||||
$html = Markdown::instance()->convert($markdown);
|
||||
$html = self::sanitizeAndResolve($html, $media);
|
||||
$html = strip_tags($html, self::ALLOWED_TAGS);
|
||||
$html = self::resolveImages($html, $media);
|
||||
$html = self::secureLinks($html);
|
||||
|
||||
if (trim(strip_tags($html)) === '' && !preg_match('/<(img|video|audio|figure)[\s>]/i', $html)) {
|
||||
$fallback = nl2br(htmlspecialchars($markdown, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||||
$html = '<p>' . str_replace('<br />', '</p><p>', $fallback) . '</p>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
return trim($html);
|
||||
}
|
||||
|
||||
// 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
|
||||
// Résout les images media:filename et supprime les images externes.
|
||||
private static function resolveImages(string $html, Media $media): string
|
||||
{
|
||||
$source = new DOMDocument('1.0', 'UTF-8');
|
||||
$clean = new DOMDocument('1.0', 'UTF-8');
|
||||
$cleanBody = $clean->createElement('body');
|
||||
$clean->appendChild($cleanBody);
|
||||
|
||||
$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::appendSanitizedChildren($sourceBody, $cleanBody, $clean, $media);
|
||||
|
||||
$out = '';
|
||||
for ($i = 0; $i < $cleanBody->childNodes->length; $i++) {
|
||||
$child = $cleanBody->childNodes->item($i);
|
||||
if ($child !== null) {
|
||||
$out .= $clean->saveHTML($child);
|
||||
return preg_replace_callback('/<img\s[^>]*>/i', function (array $m) use ($media): string {
|
||||
if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return trim($out);
|
||||
$fileName = substr($s[1], 6);
|
||||
if ($fileName === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$item = $media->findByFileName($fileName);
|
||||
if ($item === null) {
|
||||
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
||||
}
|
||||
|
||||
// L'alt du Markdown est déjà échappé par le parser F3.
|
||||
// Le fallback vers l'alt de la base nécessite un échappement.
|
||||
$alt = '';
|
||||
if (preg_match('/alt="([^"]*)"/', $m[0], $a)) {
|
||||
$alt = $a[1];
|
||||
}
|
||||
if ($alt === '') {
|
||||
$alt = htmlspecialchars($item['alt'], ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
$url = htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return '<img src="' . $url . '" alt="' . $alt . '" loading="lazy" decoding="async">';
|
||||
}, $html) ?? $html;
|
||||
}
|
||||
|
||||
private static function appendSanitizedChildren(DOMNode $sourceParent, DOMNode $targetParent, DOMDocument $target, Media $media): void
|
||||
// Sécurise les liens : rel="noopener noreferrer" sur tous,
|
||||
// target="_blank" sur les liens externes uniquement.
|
||||
private static function secureLinks(string $html): string
|
||||
{
|
||||
$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) {
|
||||
continue;
|
||||
return preg_replace_callback('/<a\s[^>]*>/i', function (array $m): string {
|
||||
if (!preg_match('/href="([^"]*)"/', $m[0], $h)) {
|
||||
return $m[0];
|
||||
}
|
||||
|
||||
if ($child instanceof DOMText) {
|
||||
$targetParent->appendChild($target->createTextNode($child->nodeValue ?? ''));
|
||||
continue;
|
||||
$attrs = 'href="' . $h[1] . '" rel="noopener noreferrer"';
|
||||
|
||||
if (preg_match('~^https?://~i', $h[1])) {
|
||||
$attrs .= ' target="_blank"';
|
||||
}
|
||||
|
||||
if (!$child instanceof DOMElement) {
|
||||
continue;
|
||||
if (preg_match('/title="([^"]*)"/', $m[0], $t)) {
|
||||
$attrs .= ' title="' . $t[1] . '"';
|
||||
}
|
||||
|
||||
self::appendSanitizedElement($child, $targetParent, $target, $media);
|
||||
}
|
||||
}
|
||||
|
||||
private static function appendSanitizedElement(DOMElement $sourceElement, DOMNode $targetParent, DOMDocument $target, Media $media): void
|
||||
{
|
||||
$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.');
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
$markdown = str_replace(["\r\n", "\r"], "\n", $markdown);
|
||||
$lines = explode("\n", $markdown);
|
||||
$normalized = [];
|
||||
$inFence = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
|
||||
$inFence = !$inFence;
|
||||
$normalized[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inFence) {
|
||||
$normalized[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
$isBlank = trim($line) === '';
|
||||
$isListItem = preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $line) === 1;
|
||||
$previous = $normalized[count($normalized) - 1] ?? null;
|
||||
$previousIsBlank = $previous === null || trim($previous) === '';
|
||||
$previousIsListItem = $previous !== null && preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $previous) === 1;
|
||||
|
||||
if ($isListItem && !$previousIsBlank && !$previousIsListItem) {
|
||||
$normalized[] = '';
|
||||
}
|
||||
|
||||
if (!$isBlank && !$isListItem && $previousIsListItem) {
|
||||
$normalized[] = '';
|
||||
}
|
||||
|
||||
$normalized[] = $line;
|
||||
}
|
||||
|
||||
return trim(implode("\n", $normalized));
|
||||
return '<a ' . $attrs . '>';
|
||||
}, $html) ?? $html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<true>
|
||||
<div class="card-grid">
|
||||
<repeat group="{{ @posts }}" value="{{ @post }}">
|
||||
<include href="partials/post_card_admin.html" />
|
||||
<include href="partials/post_card.html" />
|
||||
</repeat>
|
||||
</div>
|
||||
<include href="partials/pagination.html" />
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
<article class="card article-card">
|
||||
<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>
|
||||
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
||||
</false>
|
||||
</check>
|
||||
</a>
|
||||
<check if="{{ @post.cover_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>
|
||||
</true>
|
||||
<false>
|
||||
<check if="{{ @adminMode }}">
|
||||
<true>
|
||||
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
||||
</true>
|
||||
</check>
|
||||
</false>
|
||||
</check>
|
||||
<div class="card-body article-card__body">
|
||||
<h2 class="card-title">{{ @post.title }}</h2>
|
||||
<h2 class="card-title">
|
||||
<a class="card-title__link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">{{ @post.title }}</a>
|
||||
</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 }}">
|
||||
@@ -16,5 +24,16 @@
|
||||
</check>
|
||||
</p>
|
||||
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||
<check if="{{ @adminMode }}">
|
||||
<true>
|
||||
<div class="card-actions">
|
||||
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
|
||||
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
|
||||
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||
<button class="button button--danger" type="submit">Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
</true>
|
||||
</check>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<article class="card article-card">
|
||||
<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>
|
||||
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
||||
</false>
|
||||
</check>
|
||||
</a>
|
||||
<div class="card-body article-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 }}">
|
||||
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at | date_fr }}</time></true>
|
||||
</check>
|
||||
</p>
|
||||
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||
<div class="card-actions">
|
||||
<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="{{ @CSRF }}">
|
||||
<button class="button button--danger" type="submit">Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -12,12 +12,8 @@
|
||||
<check if="{{ @post.cover_url }}">
|
||||
<true>
|
||||
<img class="media-frame media-frame--large article-cover" src="{{ @post.cover_url }}"
|
||||
alt="{{ @post.title }}">
|
||||
alt="{{ @post.cover_alt ?: @post.title }}">
|
||||
</true>
|
||||
<false>
|
||||
<div class="media-frame media-frame--large media-frame--placeholder article-cover">Aucune image
|
||||
</div>
|
||||
</false>
|
||||
</check>
|
||||
|
||||
<div class="prose">{{ @post.body_html | raw }}</div>
|
||||
|
||||
Reference in New Issue
Block a user