Simplification

This commit is contained in:
julien
2026-03-30 15:05:13 +02:00
parent b4a80013d5
commit b4593840a8
30 changed files with 526 additions and 781 deletions

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
class AdminController extends Controller
{
private const MEDIA_PICKER_LIMIT = 60;
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
public function beforeRoute(): void
@@ -28,52 +27,52 @@ class AdminController extends Controller
public function create(): void
{
$this->postForm('Nouvel article', $this->f3->alias('post_store'), Post::blank());
$this->renderPostForm('Nouvel article', $this->f3->alias('post_store'), Post::blank());
}
public function store(): void
{
$this->checkCsrf();
$input = $this->postInput();
$this->verifyCsrf();
$input = $this->readPostInput();
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());
$this->renderPostForm('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) {
if ($post === null) {
$this->f3->error(404);
return;
}
$this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
$this->renderPostForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
}
public function update(): void
{
$this->checkCsrf();
$this->verifyCsrf();
$id = (int) $this->f3->get('PARAMS.id');
$input = $this->postInput() + ['id' => $id];
$input = $this->readPostInput() + ['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());
$this->renderPostForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
}
}
public function delete(): void
{
$this->checkCsrf();
$this->verifyCsrf();
try {
(new Post())->deleteById((int) $this->f3->get('PARAMS.id'));
@@ -95,25 +94,27 @@ class AdminController extends Controller
'items' => $result['items'],
'pagination' => $result['pagination'],
'paginationAlias' => 'media_index',
'adminMode' => true,
]);
}
public function mediaUpload(): void
{
$this->checkCsrf();
$this->verifyCsrf();
try {
$original = (string) ($this->f3->get('FILES.image.name') ?: '');
$originalName = (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.');
throw new RuntimeException('Choisis une image JPG ou PNG valide.');
}
(new Media())->upload($path, $original);
(new Media())->upload($path, $originalName);
$this->flash('success', 'Image ajoutée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
@@ -124,11 +125,14 @@ class AdminController extends Controller
public function mediaAlt(): void
{
$this->checkCsrf();
$this->verifyCsrf();
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.');
(new Media())->updateAlt(
(int) $this->f3->get('PARAMS.id'),
$this->f3->clean((string) ($this->f3->get('POST.alt') ?: ''))
);
$this->flash('success', 'Texte alternatif enregistré.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
@@ -138,17 +142,18 @@ class AdminController extends Controller
public function mediaDelete(): void
{
$this->checkCsrf();
$this->verifyCsrf();
try {
$media = new Media();
$item = $media->findById((int) $this->f3->get('PARAMS.id'));
if (!$item) {
if ($item === null) {
throw new RuntimeException('Image introuvable.');
}
if ((new Post())->usesMedia($item['file_name'])) {
throw new RuntimeException('Cette image est encore utilisée par un article.');
throw new RuntimeException('Cette image est utilisée dans un article.');
}
$media->deleteById($item['id']);
$this->flash('success', 'Image supprimée.');
} catch (RuntimeException $e) {
@@ -158,27 +163,22 @@ class AdminController extends Controller
$this->f3->reroute('@media_index');
}
private function postForm(string $title, string $action, array $post, ?string $error = null): void
private function renderPostForm(string $title, string $action, array $post, ?string $error = null): void
{
$media = new Media();
$items = $media->recent(self::MEDIA_PICKER_LIMIT);
$count = $media->count();
$flash = $error ? [['type' => 'error', 'message' => $error]] : [];
$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]] : [],
'flash' => $flash,
'adminMode' => true,
]);
}
private function postInput(): array
private function readPostInput(): array
{
return [
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?: '')),

View File

@@ -6,7 +6,7 @@ class AuthController extends Controller
{
public function show(): void
{
if ($this->user()) {
if ($this->currentUser()) {
$this->f3->reroute('@dashboard');
return;
}
@@ -16,10 +16,11 @@ class AuthController extends Controller
public function login(): void
{
$this->checkCsrf();
$this->verifyCsrf();
$user = new User();
$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') ?: '')
@@ -27,7 +28,7 @@ class AuthController extends Controller
if (!$ok) {
usleep(1000000);
$this->flash('error', 'Identifiants invalides.');
$this->flash('error', 'Identifiants incorrects.');
$this->f3->reroute('@login');
return;
}
@@ -41,11 +42,11 @@ class AuthController extends Controller
public function logout(): void
{
$this->checkCsrf();
$this->verifyCsrf();
$this->f3->clear('SESSION.user_id');
session_regenerate_id(true);
$this->rotateCsrf();
$this->flash('success', 'Déconnexion effectuée.');
$this->flash('success', 'Déconnexion réussie.');
$this->f3->reroute('@login');
}
}

View File

@@ -13,77 +13,69 @@ abstract class Controller
protected function render(string $view, array $data = [], int $ttl = 0): void
{
$this->ensureCsrf();
$user = $this->user();
$user = $this->currentUser();
$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 : [],
'CSRF' => $this->csrfToken(),
'currentUser' => $user,
'flash' => is_array($flash) ? $flash : [],
'view' => $view,
'adminMode' => false,
'metaDescription' => null,
'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
]);
echo Template::instance()->render('layout.html');
}
protected function user(): ?array
protected function currentUser(): ?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');
$id = (int) ($this->f3->get('SESSION.user_id') ?: 0);
return $id > 0 ? (new User())->findPublic($id) : null;
}
protected function requireAuth(): void
{
if ($this->user()) {
if ($this->currentUser()) {
return;
}
$this->flash('error', 'Connecte-toi pour continuer.');
$this->flash('error', 'Connecte-toi pour accéder à cette page.');
$this->f3->reroute('@login');
}
protected function checkCsrf(): void
protected function csrfToken(): string
{
$sent = trim((string) ($this->f3->get('POST.csrf_token') ?: ''));
$expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?: ''));
$token = trim((string) ($this->f3->get('SESSION.csrf') ?: ''));
if ($token === '') {
$token = bin2hex(random_bytes(32));
$this->f3->set('SESSION.csrf', $token);
}
if ($sent !== '' && $expected !== '' && hash_equals($expected, $sent)) {
return $token;
}
protected function verifyCsrf(): void
{
$sent = trim((string) ($this->f3->get('POST.csrf') ?: ''));
if ($sent !== '' && hash_equals($this->csrfToken(), $sent)) {
return;
}
$this->f3->error(400);
}
protected function rotateCsrf(): void
{
$this->f3->set('SESSION.csrf', bin2hex(random_bytes(32)));
}
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

@@ -20,7 +20,7 @@ class SiteController extends Controller
public function show(): void
{
$post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'));
if (!$post) {
if ($post === null) {
$this->f3->error(404);
return;
}

View File

@@ -29,9 +29,10 @@ class Media extends DB\SQL\Mapper
public function page(int $page, int $perPage): array
{
$result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
$items = array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []);
return [
'items' => array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []),
'items' => $items,
'pagination' => [
'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => max(1, (int) ($result['count'] ?: 1)),
@@ -39,14 +40,6 @@ class Media extends DB\SQL\Mapper
];
}
public function recent(int $limit): array
{
return array_map(
fn(self $row): array => $this->decorate($row->cast()),
$this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: []
);
}
public function findById(int $id): ?array
{
if ($id <= 0) {
@@ -63,18 +56,22 @@ class Media extends DB\SQL\Mapper
return $this->dry() ? null : $this->decorate($this->cast());
}
public function upload(string $path, string $originalName = ''): int
public function upload(string $temporaryPath, string $originalName = ''): int
{
if (!is_file($path)) {
$f3 = Base::instance();
if (!is_file($temporaryPath)) {
throw new RuntimeException('Fichier image introuvable.');
}
$info = @getimagesize($path);
if (!is_array($info)) {
@unlink($path);
$binary = $f3->read($temporaryPath);
$image = new Image();
if ($binary === '' || $image->load($binary) === false) {
@unlink($temporaryPath);
throw new RuntimeException('Fichier image invalide.');
}
$info = @getimagesizefromstring($binary);
$mime = strtolower((string) ($info['mime'] ?? ''));
$extension = match ($mime) {
'image/jpeg' => 'jpg',
@@ -83,28 +80,39 @@ class Media extends DB\SQL\Mapper
};
if ($extension === null) {
@unlink($path);
@unlink($temporaryPath);
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;
$encoded = match ($extension) {
'jpg' => $image->dump('jpeg', 90),
'png' => $image->dump('png'),
};
if (!@rename($path, $target)) {
if (!@copy($path, $target)) {
@unlink($path);
throw new RuntimeException('Impossible denregistrer cette image.');
}
@unlink($path);
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = $this->storagePath($fileName);
try {
$f3->write($target, $encoded);
} catch (Throwable $e) {
@unlink($temporaryPath);
throw new RuntimeException('Impossible denregistrer cette image.', 0, $e);
}
$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();
@unlink($temporaryPath);
try {
$this->reset();
$this->file_name = $fileName;
$this->alt = $this->guessAlt($originalName);
$this->width = $image->width();
$this->height = $image->height();
$this->created_at = app_now();
$this->save();
} catch (Throwable $e) {
@unlink($target);
throw new RuntimeException('Impossible de finaliser cette image.', 0, $e);
}
return (int) $this->id;
}
@@ -127,34 +135,41 @@ class Media extends DB\SQL\Mapper
throw new RuntimeException('Image introuvable.');
}
$path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name;
$this->erase();
if (is_file($path)) {
@unlink($path);
try {
$this->erase();
} catch (Throwable $e) {
throw new RuntimeException('Impossible de supprimer cette image.', 0, $e);
}
}
private function decorate(array $row): array
{
$file = (string) $row['file_name'];
$fileName = (string) $row['file_name'];
$alt = (string) $row['alt'];
$base = rtrim((string) Base::instance()->get('BASE'), '/');
$mediaBase = rtrim((string) Base::instance()->get('paths.media_base'), '/');
return [
'id' => (int) $row['id'],
'file_name' => $file,
'file_name' => $fileName,
'alt' => $alt,
'width' => (int) $row['width'],
'height' => (int) $row['height'],
'created_at' => (string) $row['created_at'],
'url' => rtrim((string) Base::instance()->get('BASE'), '/') . rtrim((string) Base::instance()->get('paths.media_base'), '/') . '/' . rawurlencode($file),
'markdown' => '![' . $alt . '](media:' . $file . ')',
'url' => $base . $mediaBase . '/' . rawurlencode($fileName),
'markdown' => '![' . $alt . '](media:' . $fileName . ')',
];
}
private function altFromName(string $name): string
private function storagePath(string $fileName): string
{
$name = trim(pathinfo($name, PATHINFO_FILENAME));
$name = preg_replace('/[-_]+/', ' ', $name) ?: '';
return trim($name);
return rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
}
private function guessAlt(string $originalName): string
{
$label = trim(pathinfo($originalName, PATHINFO_FILENAME));
$label = preg_replace('/[-_]+/', ' ', $label) ?: '';
return trim($label);
}
}

View File

@@ -62,13 +62,16 @@ class Post extends DB\SQL\Mapper
}
$row = $this->cast();
$post = $this->summary($row) + ['body_html' => (string) $row['body_html']];
return $post;
return $this->summary($row) + ['body_html' => (string) $row['body_html']];
}
public function findForForm(int $id): ?array
{
if ($id <= 0) {
return null;
}
$this->load(['id = ?', $id]);
if ($this->dry()) {
return null;
@@ -84,12 +87,12 @@ class Post extends DB\SQL\Mapper
public function savePost(array $input, ?int $id = null): int
{
$payload = $this->payload($input);
$payload = $this->normalizePayload($input);
$now = app_now();
if ($id === null) {
$this->reset();
$payload['slug'] = $this->uniqueSlug($payload['title']);
$payload['slug'] = $this->nextSlug($payload['title']);
$payload['created_at'] = $now;
} else {
$this->load(['id = ?', $id]);
@@ -120,11 +123,11 @@ class Post extends DB\SQL\Mapper
return $this->count(['body_markdown LIKE ?', '%media:' . $fileName . '%']) > 0;
}
private function payload(array $input): array
private function normalizePayload(array $input): array
{
$title = trim((string) ($input['title'] ?? ''));
$excerpt = trim((string) ($input['excerpt'] ?? ''));
$body = trim((string) ($input['body_markdown'] ?? ''));
$bodyMarkdown = trim((string) ($input['body_markdown'] ?? ''));
if ($title === '') {
throw new RuntimeException('Ajoute un titre.');
@@ -142,20 +145,20 @@ class Post extends DB\SQL\Mapper
return [
'title' => $title,
'excerpt' => $excerpt,
'body_markdown' => $body,
'body_html' => MarkdownService::instance()->compile($body, new Media()),
'body_markdown' => $bodyMarkdown,
'body_html' => MarkdownService::instance()->compile($bodyMarkdown, new Media()),
];
}
private function uniqueSlug(string $title): string
private function nextSlug(string $title): string
{
$base = app_slug($title);
$slug = $base;
$n = 2;
$suffix = 2;
while ($this->count(['slug = ?', $slug]) > 0) {
$slug = $base . '-' . $n;
$n++;
$slug = $base . '-' . $suffix;
$suffix++;
}
return $slug;
@@ -163,7 +166,7 @@ class Post extends DB\SQL\Mapper
private function summary(array $row): array
{
$thumbnail = $this->firstImage((string) ($row['body_html'] ?? ''));
$thumbnail = $this->extractThumbnail((string) ($row['body_html'] ?? ''));
return [
'id' => (int) $row['id'],
@@ -177,20 +180,14 @@ class Post extends DB\SQL\Mapper
];
}
private function firstImage(string $html): array
private function extractThumbnail(string $html): array
{
if ($html === '') {
if ($html === '' || !preg_match('~(<img\s[^>]*src="([^"]+)"[^>]*>)~i', $html, $match)) {
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)) {
if (preg_match('~alt="([^"]*)"~i', $match[1], $altMatch)) {
$alt = html_entity_decode($altMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}

View File

@@ -25,6 +25,10 @@ class User extends DB\SQL\Mapper
public function findPublic(int $id): ?array
{
if ($id <= 0) {
return null;
}
$this->load(['id = ?', $id]);
if ($this->dry()) {
return null;

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
class MarkdownService extends Prefab
{
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 = [
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 = [
'a' => ['href', 'title', 'rel', 'target'],
'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
];
@@ -17,31 +17,30 @@ class MarkdownService extends Prefab
throw new RuntimeException('Ajoute du contenu avant de publier.');
}
$markdown = $this->neutralizeRawHtml($markdown);
$doc = new DOMDocument('1.0', 'UTF-8');
$html = '<div id="content">' . Markdown::instance()->convert($markdown) . '</div>';
$html = '<div id="content">' . Markdown::instance()->convert($this->escapeRawHtml($markdown)) . '</div>';
$previous = libxml_use_internal_errors(true);
$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$root = $doc->getElementById('content');
if (!$root) {
return '';
if (!$root instanceof DOMElement) {
throw new RuntimeException('Impossible de générer le contenu HTML.');
}
$this->sanitizeChildren($root, $media);
$this->sanitizeNode($root, $media);
$out = '';
$output = '';
foreach (iterator_to_array($root->childNodes) as $child) {
$out .= $doc->saveHTML($child);
$output .= $doc->saveHTML($child);
}
return trim($out);
return trim($output);
}
private function neutralizeRawHtml(string $markdown): string
private function escapeRawHtml(string $markdown): string
{
return preg_replace_callback(
'~<!--.*?-->|</?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?/?>~s',
@@ -50,7 +49,7 @@ class MarkdownService extends Prefab
) ?? $markdown;
}
private function sanitizeChildren(DOMNode $parent, Media $media): void
private function sanitizeNode(DOMNode $parent, Media $media): void
{
foreach (iterator_to_array($parent->childNodes) as $child) {
if (!$child instanceof DOMElement) {
@@ -58,60 +57,72 @@ class MarkdownService extends Prefab
}
$tag = strtolower($child->tagName);
if (!in_array($tag, self::TAGS, true)) {
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
$this->unwrap($child);
$this->sanitizeChildren($parent, $media);
$this->sanitizeNode($parent, $media);
continue;
}
foreach (iterator_to_array($child->attributes) as $attr) {
if (!in_array(strtolower($attr->name), self::ATTRS[$tag] ?? [], true)) {
$child->removeAttributeNode($attr);
foreach (iterator_to_array($child->attributes) as $attribute) {
if (!in_array(strtolower($attribute->name), self::ALLOWED_ATTRIBUTES[$tag] ?? [], true)) {
$child->removeAttributeNode($attribute);
}
}
if ($tag === 'a') {
$href = trim((string) $child->getAttribute('href'));
if (!$this->allowedHref($href)) {
$this->unwrap($child);
$this->sanitizeChildren($parent, $media);
continue;
}
$child->setAttribute('href', $href);
$child->setAttribute('rel', 'noopener noreferrer');
if (preg_match('~^https?://~i', $href)) {
$child->setAttribute('target', '_blank');
} else {
$child->removeAttribute('target');
}
$this->sanitizeLink($child);
}
if ($tag === 'img') {
$src = trim((string) $child->getAttribute('src'));
if (!str_starts_with($src, 'media:')) {
$child->parentNode?->removeChild($child);
$this->sanitizeImage($child, $media);
if (!$child->parentNode) {
continue;
}
$item = $media->findByFileName(substr($src, 6));
if (!$item) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
$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');
}
$this->sanitizeChildren($child, $media);
$this->sanitizeNode($child, $media);
}
}
private function sanitizeLink(DOMElement $node): void
{
$href = trim((string) $node->getAttribute('href'));
if (!$this->isAllowedHref($href)) {
$this->unwrap($node);
return;
}
$node->setAttribute('href', $href);
$node->setAttribute('rel', 'noopener noreferrer');
if (preg_match('~^https?://~i', $href)) {
$node->setAttribute('target', '_blank');
return;
}
$node->removeAttribute('target');
}
private function sanitizeImage(DOMElement $node, Media $media): void
{
$src = trim((string) $node->getAttribute('src'));
if (!str_starts_with($src, 'media:')) {
$node->parentNode?->removeChild($node);
return;
}
$item = $media->findByFileName(substr($src, 6));
if ($item === null) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
$node->setAttribute('src', $item['url']);
$node->setAttribute('alt', trim((string) $node->getAttribute('alt')) ?: (string) $item['alt']);
$node->setAttribute('width', (string) $item['width']);
$node->setAttribute('height', (string) $item['height']);
$node->setAttribute('loading', 'lazy');
$node->setAttribute('decoding', 'async');
}
private function unwrap(DOMElement $node): void
{
$parent = $node->parentNode;
@@ -131,16 +142,14 @@ class MarkdownService extends Prefab
$parent->removeChild($node);
}
private function allowedHref(string $href): bool
private function isAllowedHref(string $href): bool
{
if ($href === '') {
if ($href === '' || str_starts_with($href, '//')) {
return false;
}
if (str_starts_with($href, '#') || str_starts_with($href, '/')) {
return true;
}
if (preg_match('~^(?:https?://|mailto:)~i', $href)) {
return true;
}

View File

@@ -19,8 +19,8 @@
</true>
<false>
<section class="empty-state" aria-labelledby="dashboard-empty-title">
<h2 class="card-title" id="dashboard-empty-title">Aucun article</h2>
<p>Commence par créer un premier article.</p>
<h2 class="card-title" id="dashboard-empty-title">Aucun article.</h2>
<p>Crée un article pour commencer.</p>
</section>
</false>
</check>

View File

@@ -2,7 +2,7 @@
<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>
<p class="field-help">Ajoute une image ou copie son Markdown pour linsérer dans un article.</p>
</div>
<div class="page-actions">
@@ -13,11 +13,11 @@
<form class="panel stack" method="post" action="{{ 'media_upload' | alias }}" enctype="multipart/form-data">
<include href="partials/csrf_field.html" />
<label class="field">
<span class="field-label">Nouvelle image</span>
<span class="field-label">Image</span>
<input class="control" type="file" name="image" accept="image/jpeg,image/png" required>
<span class="field-help">Formats : JPG, PNG. Taille maximale : 10 Mo.</span>
</label>
<button class="button" type="submit">Envoyer</button>
<button class="button" type="submit">Ajouter</button>
</form>
<check if="{{ @items }}">
@@ -31,8 +31,8 @@
</true>
<false>
<section class="empty-state" aria-labelledby="media-empty-title">
<h2 class="card-title" id="media-empty-title">Aucune image</h2>
<p>Ajoute ta première image.</p>
<h2 class="card-title" id="media-empty-title">Aucune image.</h2>
<p>Ajoute une image pour commencer.</p>
</section>
</false>
</check>

View File

@@ -7,79 +7,44 @@
</div>
</header>
<div class="editor-layout" data-editor-layout>
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
<include href="partials/csrf_field.html" />
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
<include href="partials/csrf_field.html" />
<label class="field">
<span class="field-label">Titre</span>
<input class="control" type="text" name="title" value="{{ @post.title }}" maxlength="{{ @titleMax }}" required data-char-count>
<span class="char-counter"><span data-char-count-value>0</span> / {{ @titleMax }}</span>
</label>
<label class="field">
<span class="field-label">Titre</span>
<input class="control" type="text" name="title" value="{{ @post.title }}" maxlength="{{ @titleMax }}" required data-char-count>
<span class="char-counter"><span data-char-count-value>0</span> / {{ @titleMax }}</span>
</label>
<label class="field">
<span class="field-label">Extrait</span>
<textarea class="control" name="excerpt" rows="3" maxlength="{{ @excerptMax }}" required data-char-count>{{ @post.excerpt }}</textarea>
<span class="char-counter"><span data-char-count-value>0</span> / {{ @excerptMax }}</span>
</label>
<label class="field">
<span class="field-label">Extrait</span>
<textarea class="control" name="excerpt" rows="3" maxlength="{{ @excerptMax }}" required data-char-count>{{ @post.excerpt }}</textarea>
<span class="char-counter"><span data-char-count-value>0</span> / {{ @excerptMax }}</span>
</label>
<section class="field">
<div class="field-head">
<div>
<h2 class="field-label">Contenu</h2>
<p class="field-help">Markdown avec insertion dimage au curseur. La première image du contenu est utilisée dans les cartes darticle.</p>
</div>
</div>
<div class="toolbar" role="toolbar" aria-label="Outils Markdown">
<button class="tool-button" type="button" data-md-action="bold"><strong>Gras</strong></button>
<button class="tool-button" type="button" data-md-action="italic"><em>Italique</em></button>
<button class="tool-button" type="button" data-md-action="heading">Titre</button>
<button class="tool-button" type="button" data-md-action="list">Liste</button>
<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>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">Laisse une ligne vide entre deux blocs Markdown (titre, liste, citation, image, code).</p>
</section>
<button class="button" type="submit">Enregistrer</button>
</form>
<aside class="media-picker is-hidden" data-media-picker>
<div class="media-picker__head">
<section class="field">
<div class="field-head">
<div>
<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>
<h2 class="field-label">Contenu</h2>
<p class="field-help">Rédige en Markdown. La première image du contenu sert de visuel dans les listes darticles.</p>
</div>
<button class="button button--ghost button--small" type="button" data-media-picker-close>Fermer</button>
</div>
<check if="{{ @mediaItems }}">
<true>
<div class="media-picker__grid">
<repeat group="{{ @mediaItems }}" value="{{ @item }}">
<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>
</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">
<h2 class="card-title" id="media-picker-empty-title">Aucune image disponible</h2>
<p>Ajoute une image depuis la médiathèque.</p>
</section>
</false>
</check>
</aside>
</div>
<div class="toolbar" role="toolbar" aria-label="Outils Markdown">
<button class="button button--ghost button--small" type="button" data-md-action="bold"><strong>Gras</strong></button>
<button class="button button--ghost button--small" type="button" data-md-action="italic"><em>Italique</em></button>
<button class="button button--ghost button--small" type="button" data-md-action="heading">Titre</button>
<button class="button button--ghost button--small" type="button" data-md-action="list">Liste</button>
<button class="button button--ghost button--small" type="button" data-md-action="quote">Citation</button>
<button class="button button--ghost button--small" type="button" data-md-action="link">Lien</button>
<button class="button button--ghost button--small" type="button" data-md-action="code">Code</button>
<a class="button button--ghost button--small" href="{{ 'media_index' | alias }}" target="_blank" rel="noopener">Ouvrir la médiathèque</a>
</div>
<textarea class="control editor-textarea" name="body_markdown" rows="18" required data-markdown-editor>{{ @post.body_markdown }}</textarea>
<p class="field-help">Laisse une ligne vide entre deux blocs. Pour ajouter une image, ouvre la médiathèque, copie le Markdown, puis colle-le ici.</p>
</section>
<button class="button" type="submit">Enregistrer</button>
</form>
</section>

View File

@@ -3,19 +3,18 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ @errorTitle ?: 'Erreur' }}</title>
<title>{{ @errorTitle }} · {{ @app.name }}</title>
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="{{ @BASE }}/assets/app.css">
</head>
<body>
<main class="page error-page">
<main class="page">
<div class="container">
<section class="error-card">
<p class="error-page__code">Erreur {{ @errorCode ?: 500 }}</p>
<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="{{ 'home' | alias }}">Retour à laccueil</a></p>
<section class="empty-state stack" aria-labelledby="error-title">
<p class="meta-text">Erreur {{ @errorCode }}</p>
<h1 class="page-title" id="error-title">{{ @errorTitle }}</h1>
<p>{{ @errorMessage }}</p>
<a class="button button--ghost" href="{{ 'home' | alias }}">Retour à laccueil</a>
</section>
</div>
</main>

View File

@@ -12,7 +12,7 @@
<body>
<include href="partials/site_navigation.html" />
<main class="page" id="main-content">
<main class="page">
<div class="container">
<check if="{{ @flash }}">
<repeat group="{{ @flash }}" value="{{ @msg }}">

View File

@@ -1 +1 @@
<input type="hidden" name="csrf_token" value="{{ @CSRF_TOKEN }}">
<input type="hidden" name="csrf" value="{{ @CSRF }}">

View File

@@ -7,7 +7,7 @@
<include href="partials/csrf_field.html" />
<label class="field">
<span class="field-label">Texte alternatif</span>
<input class="control" type="text" name="alt" value="{{ @item.alt }}" placeholder="Description de l'image" data-alt-input>
<input class="control" type="text" name="alt" value="{{ @item.alt }}" placeholder="Décris limage" data-alt-input>
</label>
<button class="button button--ghost button--small" type="submit">Enregistrer</button>
</form>

View File

@@ -1,13 +1,18 @@
<input class="nav-toggle" type="checkbox" id="nav-toggle" aria-hidden="true">
<header class="site-header">
<div class="container site-header__inner">
<label class="nav-toggle-button" for="nav-toggle">
<button
class="icon-button nav-toggle-button"
type="button"
data-mobile-menu-open
aria-controls="mobile-menu"
aria-expanded="false"
aria-label="Ouvrir le menu"
>
<span class="sr-only">Ouvrir le menu</span>
<span class="nav-toggle-button__line"></span>
<span class="nav-toggle-button__line"></span>
<span class="nav-toggle-button__line"></span>
</label>
</button>
<div class="site-brand site-brand--header">
<include href="partials/site_brand.html" />
@@ -16,25 +21,23 @@
<nav class="nav nav--desktop" aria-label="Navigation principale">
<include href="partials/nav_items.html" />
</nav>
<span class="site-header__spacer" aria-hidden="true"></span>
</div>
</header>
<div class="mobile-menu">
<label class="mobile-menu__backdrop" for="nav-toggle" aria-hidden="true"></label>
<div class="mobile-menu" id="mobile-menu" data-mobile-menu>
<button class="mobile-menu__backdrop" type="button" data-mobile-menu-close aria-label="Fermer le menu"></button>
<div class="mobile-menu__panel">
<div class="mobile-menu__panel" role="dialog" aria-modal="true" aria-label="Navigation principale mobile">
<header class="mobile-menu__header">
<div class="site-brand site-brand--menu">
<include href="partials/site_brand.html" />
</div>
<label class="mobile-menu__close" for="nav-toggle">
<button class="icon-button mobile-menu__close" type="button" data-mobile-menu-close aria-label="Fermer le menu">
<span class="sr-only">Fermer le menu</span>
<span class="mobile-menu__close-line"></span>
<span class="mobile-menu__close-line"></span>
</label>
</button>
</header>
<nav class="mobile-menu__nav" aria-label="Navigation principale mobile">

View File

@@ -14,8 +14,8 @@
</true>
<false>
<section class="empty-state" aria-labelledby="home-empty-title">
<h2 class="card-title" id="home-empty-title">Aucun article</h2>
<p>Le premier article arrivera bientôt.</p>
<h2 class="card-title" id="home-empty-title">Aucun article.</h2>
<p>Reviens bientôt.</p>
</section>
</false>
</check>

View File

@@ -24,20 +24,14 @@ if (is_file($root . '/config.local.ini')) {
$f3->config($root . '/config.local.ini');
}
date_default_timezone_set(app_timezone((string) $f3->get('app.timezone')));
$f3->set('TZ', date_default_timezone_get());
$f3->set('TZ', app_timezone((string) $f3->get('app.timezone')));
$f3->set('DEBUG', $f3->get('app.env') === 'prod' ? 0 : 3);
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);
}
}
$uploadsDir = $root . '/' . trim((string) $f3->get('UPLOADS'), '/');
$f3->set('UPLOADS', $uploadsDir . '/');
$uploads = $root . '/' . trim((string) $f3->get('UPLOADS'), '/');
$f3->set('UPLOADS', $uploads . '/');
if (!is_dir($uploads)) {
mkdir($uploads, 0775, true);
foreach ([(string) $f3->get('TEMP'), (string) $f3->get('LOGS'), dirname((string) $f3->get('paths.db')), (string) $f3->get('paths.media_dir'), $uploadsDir] as $dir) {
app_ensure_dir($dir);
}
ini_set('log_errors', '1');
@@ -49,16 +43,15 @@ $db = new DB\SQL('sqlite:' . $f3->get('paths.db'));
$db->exec('PRAGMA foreign_keys = ON');
$f3->set('DB', $db);
$secure = app_request_is_secure();
session_name((string) $f3->get('app.session_name'));
$f3->set('JAR', [
'expire' => 0,
'path' => '/',
'secure' => $secure,
'secure' => app_request_is_secure(),
'httponly' => true,
'samesite' => 'Lax',
]);
new Session(null, 'CSRF');
new Session();
Template::instance()->filter('date_fr', 'app_date_fr');
@@ -66,6 +59,7 @@ 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([
@@ -73,6 +67,7 @@ if ($f3->get('app.env') === 'prod') {
'errorTitle' => $meta['title'],
'errorMessage' => $meta['message'],
]);
echo Template::instance()->render('errors/error.html');
});
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
function app_timezone(string $value): string
{
$value = trim($value);
return in_array($value, DateTimeZone::listIdentifiers(), true) ? $value : 'UTC';
$timezone = trim($value);
return in_array($timezone, DateTimeZone::listIdentifiers(), true) ? $timezone : 'UTC';
}
function app_now(): string
@@ -52,14 +52,13 @@ function app_date_fr(string $value): string
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.'],
400 => ['title' => 'Requête invalide', 'message' => 'La requête est invalide.'],
403 => ['title' => 'Accès refusé', 'message' => 'Tu nas pas accès à cette page.'],
404 => ['title' => 'Page introuvable', 'message' => 'La page 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') {
@@ -77,3 +76,10 @@ function app_request_is_secure(): bool
return false;
}
function app_ensure_dir(string $path): void
{
if (!is_dir($path)) {
mkdir($path, Base::MODE, true);
}
}