diff --git a/.dockerignore b/.dockerignore index bd08f97..a039959 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ .dockerignore Dockerfile README.md +Caddyfile.example compose.yaml config.local.ini config.local.ini.example diff --git a/.gitignore b/.gitignore index 7f5fd01..809cbb3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ /public/uploads/media/* !/public/uploads/media/.gitkeep /config.local.ini +/Caddyfile diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..33ac4ec --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,19 @@ +# Exemple de configuration Caddy en reverse proxy vers le conteneur Docker. +# Copier ce fichier vers Caddyfile et adapter le domaine. + +blog.example.com { + # ── En-têtes de sécurité (toutes les réponses) ─────────────────── + + header { + Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; img-src 'self' data:; style-src 'self'; script-src 'self'" + Referrer-Policy "same-origin" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Cross-Origin-Opener-Policy "same-origin" + Cross-Origin-Resource-Policy "same-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + -Server + } + + reverse_proxy localhost:8888 +} diff --git a/Dockerfile b/Dockerfile index 362b7f2..c25361c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,8 +18,9 @@ RUN apt-get update \ libfreetype6-dev \ libxml2-dev \ libonig-dev \ + libicu-dev \ && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ - && docker-php-ext-install -j"$(nproc)" pdo_sqlite dom gd mbstring opcache \ + && docker-php-ext-install -j"$(nproc)" pdo_sqlite dom gd mbstring opcache intl \ && printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \ && a2enconf servername \ && rm -rf /var/lib/apt/lists/* diff --git a/app/Controllers/AssetController.php b/app/Controllers/AssetController.php index 32b6567..374f95c 100644 --- a/app/Controllers/AssetController.php +++ b/app/Controllers/AssetController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -class AssetController extends BaseController +class AssetController { private const ALLOWED = [ 'app.css' => 'text/css', @@ -11,13 +11,15 @@ class AssetController extends BaseController public function serve(): void { - $file = basename((string) $this->f3->get('PARAMS.file')); + $f3 = Base::instance(); + $file = basename((string) $f3->get('PARAMS.file')); if (!array_key_exists($file, self::ALLOWED)) { - $this->f3->error(404); + $f3->error(404); return; } + $f3->expire(86400); echo Web::instance()->minify( $file, self::ALLOWED[$file], diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 0044c84..040ce9c 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -4,11 +4,6 @@ declare(strict_types=1); class AuthController extends BaseController { - public function beforeRoute(): void - { - $this->disableCache(); - } - public function show(): void { if ($this->currentUser() !== null) { @@ -16,7 +11,7 @@ class AuthController extends BaseController return; } - $this->renderSession('auth/login.html', ['pageTitle' => 'Connexion'], true); + $this->render('auth/login.html', ['pageTitle' => 'Connexion']); } public function login(): void @@ -36,7 +31,6 @@ class AuthController extends BaseController session_regenerate_id(true); $this->f3->set('SESSION.user_id', $user['id']); - $this->refreshCsrfToken(); $this->flash('success', 'Connexion réussie.'); $this->f3->reroute('@dashboard'); } @@ -45,7 +39,6 @@ class AuthController extends BaseController { $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('@login'); diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 1619281..1bb87f4 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -7,46 +7,51 @@ abstract class BaseController protected Base $f3; protected DB\SQL $db; + private ?array $resolvedUser = null; + private bool $userResolved = false; + public function __construct() { $this->f3 = Base::instance(); $this->db = $this->f3->get('DB'); } - protected function renderPublic(string $view, array $data = []): void + protected function render(string $view, array $data = [], int $cacheTtl = 0): 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, - ]); + // Les pages publiques émettent un Cache-Control avec le TTL demandé. + // Un utilisateur connecté voit un état de session (nav, CSRF) : + // on force expire(0) pour ne pas servir ce rendu à d'autres visiteurs. + $this->f3->expire($user !== null ? 0 : $cacheTtl); - 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(), + 'currentUser' => $user, 'flash' => $flash, - 'csrfToken' => $withCsrf ? $this->csrfToken() : null, + 'metaDescription' => null, ]); + // Persister le jeton CSRF courant en session : le formulaire + // affiche @CSRF (jeton de cette requête) ; à la soumission, + // verifyCsrf() le comparera à SESSION.csrf. + $this->f3->copy('CSRF', 'SESSION.csrf'); echo Template::instance()->render('layout.html'); } protected function currentUser(): ?array { - $userId = (int) ($this->f3->get('SESSION.user_id') ?? 0); - return $userId > 0 ? (new User($this->db))->findById($userId) : null; + if (!$this->userResolved) { + $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; } protected function requireAuth(): void @@ -59,33 +64,14 @@ abstract class BaseController $this->f3->reroute('@login'); } - protected function disableCache(): void - { - $this->f3->expire(0); - } - - protected function csrfToken(): string - { - $token = (string) ($this->f3->get('SESSION.csrf_token') ?? ''); - if ($token === '') { - $token = bin2hex(random_bytes(32)); - $this->f3->set('SESSION.csrf_token', $token); - } - - return $token; - } - - protected function refreshCsrfToken(): string - { - $token = bin2hex(random_bytes(32)); - $this->f3->set('SESSION.csrf_token', $token); - return $token; - } - + // Le jeton CSRF est fourni par la classe Session de F3 + // (clé de ruche « CSRF », copiée en SESSION.csrf à chaque requête). + // Le formulaire envoie le jeton affiché lors du GET précédent, + // qu'on compare au jeton sauvegardé en session. protected function verifyCsrf(): void { $submitted = (string) ($this->f3->get('POST.csrf_token') ?? ''); - $expected = (string) ($this->f3->get('SESSION.csrf_token') ?? ''); + $expected = (string) ($this->f3->get('SESSION.csrf') ?? ''); if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) { return; diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php index 713ef8b..17063df 100644 --- a/app/Controllers/DashboardController.php +++ b/app/Controllers/DashboardController.php @@ -7,7 +7,6 @@ class DashboardController extends BaseController public function beforeRoute(): void { $this->requireAuth(); - $this->disableCache(); } public function index(): void @@ -15,11 +14,11 @@ class DashboardController extends BaseController $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); $result = (new Post($this->db))->paginateList($page, 24); - $this->renderSession('admin/dashboard.html', [ + $this->render('admin/dashboard.html', [ 'pageTitle' => 'Tableau de bord', 'posts' => $result['posts'], 'pagination' => $result, 'paginationAlias' => 'dashboard', - ], true); + ]); } } diff --git a/app/Controllers/MediaController.php b/app/Controllers/MediaController.php index f25b4c7..515721a 100644 --- a/app/Controllers/MediaController.php +++ b/app/Controllers/MediaController.php @@ -5,12 +5,12 @@ declare(strict_types=1); class MediaController extends BaseController { private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo + private const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; private const PER_PAGE = 24; public function beforeRoute(): void { $this->requireAuth(); - $this->disableCache(); } public function index(): void @@ -18,12 +18,12 @@ class MediaController extends BaseController $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); $result = (new Media($this->db))->paginateLibrary($page, self::PER_PAGE); - $this->renderSession('admin/media.html', [ + $this->render('admin/media.html', [ 'pageTitle' => 'Médiathèque', 'items' => $result['items'], 'pagination' => $result, 'paginationAlias' => 'media_index', - ], true); + ]); } public function upload(): void @@ -36,7 +36,8 @@ class MediaController extends BaseController $received = Web::instance()->receive( fn(array $file): bool => (int) ($file['size'] ?? 0) > 0 - && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES, + && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES + && in_array($file['type'] ?? '', self::ACCEPTED_TYPES, true), overwrite: false, slug: true ); diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php index 7ab6380..6881758 100644 --- a/app/Controllers/PostController.php +++ b/app/Controllers/PostController.php @@ -9,7 +9,6 @@ class PostController extends BaseController public function beforeRoute(): void { $this->requireAuth(); - $this->disableCache(); } public function create(): void @@ -25,7 +24,6 @@ class PostController extends BaseController try { (new Post($this->db))->create($input); - Cache::instance()->reset('.url'); // invalide le cache des pages publiques $this->flash('success', 'Article créé.'); $this->f3->reroute('@dashboard'); } catch (RuntimeException $e) { @@ -58,7 +56,6 @@ class PostController extends BaseController return; } - Cache::instance()->reset('.url'); // invalide le cache des pages publiques $this->flash('success', 'Article mis à jour.'); $this->f3->reroute('@dashboard'); } catch (RuntimeException $e) { @@ -69,9 +66,14 @@ class PostController extends BaseController public function delete(): void { $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é.'); + + try { + (new Post($this->db))->delete((int) $this->f3->get('PARAMS.id')); + $this->flash('success', 'Article supprimé.'); + } catch (RuntimeException $e) { + $this->flash('error', $e->getMessage()); + } + $this->f3->reroute('@dashboard'); } @@ -84,10 +86,10 @@ class PostController extends BaseController $media = new Media($this->db); $mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT); - $mediaCount = $media->countAll(); + $mediaCount = $media->count(); $flash = $error !== null ? ['type' => 'error', 'message' => $error] : null; - $this->renderSession('admin/post_form.html', [ + $this->render('admin/post_form.html', [ 'pageTitle' => $pageTitle, 'formAction' => $formAction, 'post' => $post, @@ -99,7 +101,7 @@ class PostController extends BaseController 'titleMax' => Post::TITLE_MAX_LENGTH, 'excerptMax' => Post::EXCERPT_MAX_LENGTH, 'flash' => $flash, - ], true); + ]); } private function postInput(): array diff --git a/app/Controllers/SiteController.php b/app/Controllers/SiteController.php index ed6f491..f8c5e41 100644 --- a/app/Controllers/SiteController.php +++ b/app/Controllers/SiteController.php @@ -9,12 +9,12 @@ class SiteController extends BaseController $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); $result = (new Post($this->db))->paginateList($page); - $this->renderPublic('site/home.html', [ + $this->render('site/home.html', [ 'pageTitle' => 'Accueil', 'posts' => $result['posts'], 'pagination' => $result, 'paginationAlias' => 'home', - ]); + ], 300); } public function show(): void @@ -25,9 +25,10 @@ class SiteController extends BaseController return; } - $this->renderPublic('site/post.html', [ + $this->render('site/post.html', [ 'pageTitle' => $post['title'], + 'metaDescription' => $post['excerpt'], 'post' => $post, - ]); + ], 3600); } } diff --git a/app/Helpers/App.php b/app/Helpers/App.php index f237d48..d737675 100644 --- a/app/Helpers/App.php +++ b/app/Helpers/App.php @@ -56,15 +56,13 @@ function app_media_url(string $fileName): string // ── Texte ─────────────────────────────────────────────────────────── -function app_slugify(string $value): string -{ - $slug = Web::instance()->slug(trim($value)); - return $slug !== '' ? $slug : 'article'; -} - function app_unique_slug(string $value, callable $exists): string { - $base = app_slugify($value); + $base = Web::instance()->slug(trim($value)); + if ($base === '') { + $base = 'article'; + } + if (!$exists($base)) { return $base; } @@ -97,35 +95,18 @@ function app_format_datetime_fr(string $value): string $date = $date->setTimezone(new DateTimeZone(date_default_timezone_get())); - if (class_exists('IntlDateFormatter')) { - $formatter ??= new IntlDateFormatter( - 'fr_FR', - IntlDateFormatter::LONG, - IntlDateFormatter::SHORT, - date_default_timezone_get(), - IntlDateFormatter::GREGORIAN, - "d MMMM yyyy 'à' HH:mm" - ); - - $formatted = $formatter->format($date); - if (is_string($formatted) && $formatted !== '') { - return $formatted; - } - } - - $months = [ - 1 => 'janvier', 2 => 'février', 3 => 'mars', 4 => 'avril', - 5 => 'mai', 6 => 'juin', 7 => 'juillet', 8 => 'août', - 9 => 'septembre', 10 => 'octobre', 11 => 'novembre', 12 => 'décembre', - ]; - - return sprintf( - '%d %s %d à %s', - (int) $date->format('j'), - $months[(int) $date->format('n')] ?? $date->format('F'), - (int) $date->format('Y'), - $date->format('H:i') + $formatter ??= new IntlDateFormatter( + 'fr_FR', + IntlDateFormatter::LONG, + IntlDateFormatter::SHORT, + date_default_timezone_get(), + IntlDateFormatter::GREGORIAN, + "d MMMM yyyy 'à' HH:mm" ); + + $formatted = $formatter->format($date); + + return is_string($formatted) && $formatted !== '' ? $formatted : $value; } catch (Throwable) { return $value; } diff --git a/app/Helpers/Error.php b/app/Helpers/Error.php index c06aa63..3922f74 100644 --- a/app/Helpers/Error.php +++ b/app/Helpers/Error.php @@ -2,16 +2,6 @@ declare(strict_types=1); -function app_error_meta(int $code): array -{ - return match ($code) { - 400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'], - 403 => ['title' => 'Accès refusé', 'message' => 'Tu n\u2019as pas accès à cette ressource.'], - 404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'], - default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'], - }; -} - function app_bootstrap_logging(): void { $dir = rtrim((string) Base::instance()->get('LOGS'), '/\\') . DIRECTORY_SEPARATOR; @@ -22,78 +12,27 @@ function app_bootstrap_logging(): void error_reporting(E_ALL); } -function app_request_summary(): string +function app_error_meta(int $code): array { - $f3 = Base::instance(); - return sprintf( - 'request=%s %s ip=%s', - (string) ($f3->get('VERB') ?? 'CLI'), - (string) ($f3->get('URI') ?? '/'), - (string) ($f3->get('IP') ?? '0.0.0.0') - ); -} - -function app_write_log(string $fileName, string $line): void -{ - (new Log($fileName))->write($line); -} - -function app_log_error(int $code, string $status, string $text, ?Throwable $exception = null): void -{ - if ($code === 404) { - return; - } - - $level = $code >= 500 ? 'error' : ($code >= 400 ? 'warning' : 'info'); - $parts = [ - sprintf('level=%s code=%d status="%s"', $level, $code, $status), - app_request_summary(), - ]; - - if ($text !== '') { - $parts[] = 'message="' . str_replace(["\n", '"'], ['\\n', '\\"'], $text) . '"'; - } - - if ($exception !== null) { - $parts[] = sprintf('exception="%s" file="%s:%d"', $exception::class, $exception->getFile(), $exception->getLine()); - } - - app_write_log('app.log', implode(' | ', $parts)); -} - -function app_render_error_json(int $code): void -{ - $f3 = Base::instance(); - $meta = app_error_meta($code); - - while (ob_get_level() > 0) { - ob_end_clean(); - } - - $f3->status($code); - $f3->expire(0); - - header('Content-Type: application/json; charset=UTF-8'); - echo json_encode( - ['error' => ['code' => $code, 'title' => $meta['title'], 'message' => $meta['message']]], - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES - ); + return match ($code) { + 400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'], + 403 => ['title' => 'Accès refusé', 'message' => 'Tu n\u2019as pas accès à cette ressource.'], + 404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'], + default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'], + }; } function app_render_error_fallback(int $code): void { - $f3 = Base::instance(); $meta = app_error_meta($code); - $base = rtrim((string) $f3->get('BASE'), '/'); + $base = rtrim((string) Base::instance()->get('BASE'), '/'); while (ob_get_level() > 0) { ob_end_clean(); } - $f3->status($code); - $f3->expire(0); - if (!headers_sent()) { + http_response_code($code); header('Content-Type: text/html; charset=UTF-8'); header('Cache-Control: no-cache, no-store, must-revalidate'); } @@ -107,41 +46,28 @@ function app_render_error_fallback(int $code): void function app_bootstrap_errors(Base $f3): void { - if (app_is_prod()) { - register_shutdown_function(function (): void { - $error = error_get_last(); - if ($error === null) { - return; - } - - $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; - if (!in_array($error['type'] ?? 0, $fatalTypes, true)) { - return; - } - - app_render_error_fallback(500); - }); + // En dev, ne pas poser ONERROR : le handler par défaut de F3 + // affiche la stack trace complète quand DEBUG > 0. + if (!app_is_prod()) { + return; } + register_shutdown_function(function (): void { + $error = error_get_last(); + if ($error === null) { + return; + } + + $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; + if (!in_array($error['type'] ?? 0, $fatalTypes, true)) { + return; + } + + app_render_error_fallback(500); + }); + $f3->set('ONERROR', function (Base $f3): void { - $code = (int) ($f3->get('ERROR.code') ?? 500); - $status = (string) ($f3->get('ERROR.status') ?? 'Internal Server Error'); - $text = (string) ($f3->get('ERROR.text') ?? ''); - - if (!app_is_prod() && (int) $f3->get('DEBUG') > 0) { - $f3->status($code > 0 ? $code : 500); - echo $text; - return; - } - - $code = $code > 0 ? $code : 500; - app_log_error($code, $status, $text); - - if ($f3->get('AJAX')) { - app_render_error_json($code); - return; - } - + $code = max((int) ($f3->get('ERROR.code') ?? 500), 1); $f3->expire(0); $f3->status($code); @@ -154,8 +80,7 @@ function app_bootstrap_errors(Base $f3): void try { echo Template::instance()->render('errors/error.html'); - } catch (Throwable $exception) { - app_log_error(500, 'Internal Server Error', 'Error template rendering failed.', $exception); + } catch (Throwable) { app_render_error_fallback($code); } }); diff --git a/app/Models/Media.php b/app/Models/Media.php index f59f373..5dec345 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -26,14 +26,6 @@ class Media extends DB\SQL\Mapper $db->exec('CREATE INDEX IF NOT EXISTS idx_media_created_at ON media(created_at DESC)'); } - public function all(): array - { - return array_map( - fn(self $m): array => $this->decorate($m->cast()), - $this->find(null, ['order' => 'created_at DESC, id DESC']) ?: [] - ); - } - public function paginateLibrary(int $page = 1, int $perPage = 24): array { $result = $this->paginate( @@ -58,11 +50,6 @@ class Media extends DB\SQL\Mapper ); } - public function countAll(): int - { - return $this->count(); - } - public function findById(int $id): ?array { if ($id <= 0) { @@ -205,7 +192,7 @@ class Media extends DB\SQL\Mapper $image = match ($mime) { 'image/jpeg' => @imagecreatefromjpeg($srcPath), 'image/png' => @imagecreatefrompng($srcPath), - 'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($srcPath) : false, + 'image/webp' => @imagecreatefromwebp($srcPath), default => false, }; @@ -228,7 +215,7 @@ class Media extends DB\SQL\Mapper private static function writeImage(GdImage $image, string $target, string $format): void { if ($format === 'png') { - if (function_exists('imagepalettetotruecolor') && !imageistruecolor($image)) { + if (!imageistruecolor($image)) { imagepalettetotruecolor($image); } imagealphablending($image, false); @@ -272,7 +259,6 @@ class Media extends DB\SQL\Mapper 'width' => (int) $row['width'], 'height' => (int) $row['height'], 'created_at' => (string) $row['created_at'], - 'created_at_label' => app_format_datetime_fr((string) $row['created_at']), 'url' => app_media_url((string) $row['file_name']), 'markdown' => '', ]; diff --git a/app/Models/Post.php b/app/Models/Post.php index 7cfe01b..332af9e 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -133,9 +133,11 @@ class Post extends DB\SQL\Mapper public function delete(int $id): void { $this->load(['id = ?', $id]); - if (!$this->dry()) { - $this->erase(); + if ($this->dry()) { + throw new RuntimeException('Article introuvable.'); } + + $this->erase(); } private function payload(array $input): array @@ -168,7 +170,7 @@ class Post extends DB\SQL\Mapper } } - $bodyHtml = MarkdownService::compile($bodyMarkdown, $media); + $bodyHtml = MarkdownService::instance()->compile($bodyMarkdown, $media); return [ 'title' => $title, @@ -193,10 +195,7 @@ class Post extends DB\SQL\Mapper 'excerpt' => (string) $row['excerpt'], 'cover_media_id' => (int) ($row['cover_media_id'] ?? 0), 'created_at' => (string) $row['created_at'], - 'created_at_label' => app_format_datetime_fr((string) $row['created_at']), 'updated_at' => (string) $row['updated_at'], - 'updated_at_label' => app_format_datetime_fr((string) $row['updated_at']), - 'has_updated_at' => (string) $row['updated_at'] !== (string) $row['created_at'], ]; } diff --git a/app/Services/MarkdownService.php b/app/Services/MarkdownService.php index a1ca04a..88c419a 100644 --- a/app/Services/MarkdownService.php +++ b/app/Services/MarkdownService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -class MarkdownService +class MarkdownService extends Prefab { private const ALLOWED_TAGS = [ 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', @@ -10,7 +10,7 @@ class MarkdownService 'strong', 'em', 'a', 'img', 'hr', 'br', ]; - public static function compile(string $markdown, Media $media): string + public function compile(string $markdown, Media $media): string { $markdown = trim($markdown); if ($markdown === '') { diff --git a/app/Views/admin/media.html b/app/Views/admin/media.html index 5e904c5..4312428 100644 --- a/app/Views/admin/media.html +++ b/app/Views/admin/media.html @@ -11,7 +11,7 @@