Less home code more F3
This commit is contained in:
@@ -2,10 +2,188 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
abstract class AdminController extends BaseController
|
||||
class AdminController extends Controller
|
||||
{
|
||||
private const MEDIA_PICKER_LIMIT = 60;
|
||||
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
public function beforeRoute(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
}
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?: 1));
|
||||
$result = (new Post())->page($page, 12);
|
||||
|
||||
$this->render('admin/dashboard.html', [
|
||||
'pageTitle' => 'Tableau de bord',
|
||||
'posts' => $result['items'],
|
||||
'pagination' => $result['pagination'],
|
||||
'paginationAlias' => 'dashboard',
|
||||
'adminMode' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->postForm('Nouvel article', $this->f3->alias('post_store'), Post::blank());
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->checkCsrf();
|
||||
$input = $this->postInput();
|
||||
|
||||
try {
|
||||
(new Post())->savePost($input);
|
||||
$this->flash('success', 'Article créé.');
|
||||
$this->f3->reroute('@dashboard');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->postForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$post = (new Post())->findForForm((int) $this->f3->get('PARAMS.id'));
|
||||
if (!$post) {
|
||||
$this->f3->error(404);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
$this->checkCsrf();
|
||||
$id = (int) $this->f3->get('PARAMS.id');
|
||||
$input = $this->postInput() + ['id' => $id];
|
||||
|
||||
try {
|
||||
(new Post())->savePost($input, $id);
|
||||
$this->flash('success', 'Article mis à jour.');
|
||||
$this->f3->reroute('@dashboard');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->checkCsrf();
|
||||
|
||||
try {
|
||||
(new Post())->deleteById((int) $this->f3->get('PARAMS.id'));
|
||||
$this->flash('success', 'Article supprimé.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute('@dashboard');
|
||||
}
|
||||
|
||||
public function media(): void
|
||||
{
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?: 1));
|
||||
$result = (new Media())->page($page, 24);
|
||||
|
||||
$this->render('admin/media.html', [
|
||||
'pageTitle' => 'Médiathèque',
|
||||
'items' => $result['items'],
|
||||
'pagination' => $result['pagination'],
|
||||
'paginationAlias' => 'media_index',
|
||||
]);
|
||||
}
|
||||
|
||||
public function mediaUpload(): void
|
||||
{
|
||||
$this->checkCsrf();
|
||||
|
||||
try {
|
||||
$original = (string) ($this->f3->get('FILES.image.name') ?: '');
|
||||
$received = Web::instance()->receive(
|
||||
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0 && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
|
||||
overwrite: false
|
||||
);
|
||||
$path = array_key_first(array_filter($received));
|
||||
if (!$path) {
|
||||
throw new RuntimeException('Choisis une image valide à envoyer.');
|
||||
}
|
||||
|
||||
(new Media())->upload($path, $original);
|
||||
$this->flash('success', 'Image ajoutée.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute('@media_index');
|
||||
}
|
||||
|
||||
public function mediaAlt(): void
|
||||
{
|
||||
$this->checkCsrf();
|
||||
|
||||
try {
|
||||
(new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), $this->f3->clean((string) ($this->f3->get('POST.alt') ?: '')));
|
||||
$this->flash('success', 'Texte alternatif mis à jour.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute('@media_index');
|
||||
}
|
||||
|
||||
public function mediaDelete(): void
|
||||
{
|
||||
$this->checkCsrf();
|
||||
|
||||
try {
|
||||
$media = new Media();
|
||||
$item = $media->findById((int) $this->f3->get('PARAMS.id'));
|
||||
if (!$item) {
|
||||
throw new RuntimeException('Image introuvable.');
|
||||
}
|
||||
if ((new Post())->usesMedia($item['file_name'])) {
|
||||
throw new RuntimeException('Cette image est encore utilisée par un article.');
|
||||
}
|
||||
$media->deleteById($item['id']);
|
||||
$this->flash('success', 'Image supprimée.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute('@media_index');
|
||||
}
|
||||
|
||||
private function postForm(string $title, string $action, array $post, ?string $error = null): void
|
||||
{
|
||||
$media = new Media();
|
||||
$items = $media->recent(self::MEDIA_PICKER_LIMIT);
|
||||
$count = $media->count();
|
||||
|
||||
$this->render('admin/post_form.html', [
|
||||
'pageTitle' => $title,
|
||||
'formAction' => $action,
|
||||
'post' => $post,
|
||||
'mediaItems' => $items,
|
||||
'mediaCount' => $count,
|
||||
'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT,
|
||||
'mediaPickerTruncated' => $count > count($items),
|
||||
'titleMax' => Post::TITLE_MAX_LENGTH,
|
||||
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
|
||||
'flash' => $error ? [['type' => 'error', 'message' => $error]] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
private function postInput(): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?: '')),
|
||||
'excerpt' => $this->f3->clean((string) ($this->f3->get('POST.excerpt') ?: '')),
|
||||
'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?: '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class AuthController extends BaseController
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function show(): void
|
||||
{
|
||||
if ($this->currentUser() !== null) {
|
||||
if ($this->user()) {
|
||||
$this->f3->reroute('@dashboard');
|
||||
return;
|
||||
}
|
||||
@@ -16,36 +16,35 @@ class AuthController extends BaseController
|
||||
|
||||
public function login(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
$this->checkCsrf();
|
||||
|
||||
$username = $this->f3->clean((string) ($this->f3->get('POST.username') ?? ''));
|
||||
$password = (string) ($this->f3->get('POST.password') ?? '');
|
||||
|
||||
// User étend DB\SQL\Mapper — inutile de recréer un Mapper générique.
|
||||
// Le 3e argument du constructeur Auth est le callback de comparaison.
|
||||
$user = new User();
|
||||
$auth = new \Auth($user, ['id' => 'username', 'pw' => 'password_hash'], 'password_verify');
|
||||
$auth = new Auth($user, ['id' => 'username', 'pw' => 'password_hash'], 'password_verify');
|
||||
$ok = $auth->login(
|
||||
$this->f3->clean((string) ($this->f3->get('POST.username') ?: '')),
|
||||
(string) ($this->f3->get('POST.password') ?: '')
|
||||
);
|
||||
|
||||
if (!$auth->login($username, $password)) {
|
||||
usleep(1_500_000); // 1,5 s — ralentit le brute-force
|
||||
if (!$ok) {
|
||||
usleep(1000000);
|
||||
$this->flash('error', 'Identifiants invalides.');
|
||||
$this->f3->reroute('@login');
|
||||
return;
|
||||
}
|
||||
|
||||
session_regenerate_id(true); // Prévient la fixation de session.
|
||||
$this->resetCsrfToken(); // Le nouveau contexte repart avec un jeton dédié.
|
||||
$this->f3->set('SESSION.user_id', $user->id);
|
||||
session_regenerate_id(true);
|
||||
$this->f3->set('SESSION.user_id', (int) $user->id);
|
||||
$this->rotateCsrf();
|
||||
$this->flash('success', 'Connexion réussie.');
|
||||
$this->f3->reroute('@dashboard');
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
$this->checkCsrf();
|
||||
$this->f3->clear('SESSION.user_id');
|
||||
$this->resetCsrfToken();
|
||||
session_regenerate_id(true); // Invalide l'ancien ID de session.
|
||||
session_regenerate_id(true);
|
||||
$this->rotateCsrf();
|
||||
$this->flash('success', 'Déconnexion effectuée.');
|
||||
$this->f3->reroute('@login');
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
abstract class BaseController
|
||||
{
|
||||
protected Base $f3;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->f3 = Base::instance();
|
||||
}
|
||||
|
||||
protected function render(string $view, array $data = [], int $cacheTtl = 0): void
|
||||
{
|
||||
// Les pages publiques restent cacheables avec le TTL demandé.
|
||||
// Si un utilisateur est connecté, le layout dépend de la session
|
||||
// (navigation admin, déconnexion + CSRF) : on force expire(0)
|
||||
// pour ne pas servir ce rendu à d'autres visiteurs.
|
||||
$currentUser = $this->currentUser();
|
||||
$this->f3->expire($currentUser !== null ? 0 : $cacheTtl);
|
||||
|
||||
$flash = array_key_exists('flash', $data) && is_array($data['flash'])
|
||||
? $data['flash']
|
||||
: $this->pullFlash();
|
||||
|
||||
// On s'appuie sur Session(..., 'CSRF') pour la génération F3 du
|
||||
// jeton, mais on le persiste en session pour qu'il reste valide
|
||||
// entre la requête GET qui rend le formulaire et le POST suivant.
|
||||
$this->ensureCsrfToken();
|
||||
|
||||
$this->f3->mset($data + [
|
||||
'view' => $view,
|
||||
'flash' => $flash,
|
||||
'metaDescription' => null,
|
||||
'adminMode' => false,
|
||||
'currentUser' => $currentUser,
|
||||
'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
|
||||
]);
|
||||
|
||||
echo Template::instance()->render('layout.html');
|
||||
}
|
||||
|
||||
// Résout l'utilisateur courant une seule fois par requête et le
|
||||
// stocke dans le hive — accessible partout, y compris les templates.
|
||||
protected function currentUser(): ?array
|
||||
{
|
||||
if (!$this->f3->exists('ctx.current_user_loaded')) {
|
||||
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
|
||||
$user = $userId > 0 ? (new User())->findById($userId) : null;
|
||||
|
||||
$this->f3->set('currentUser', $user);
|
||||
$this->f3->set('ctx.current_user_loaded', true);
|
||||
}
|
||||
|
||||
return $this->f3->get('currentUser');
|
||||
}
|
||||
|
||||
protected function requireAuth(): void
|
||||
{
|
||||
if ($this->currentUser() !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->flash('error', 'Connecte-toi pour continuer.');
|
||||
$this->f3->reroute('@login');
|
||||
}
|
||||
|
||||
protected function verifyCsrf(): void
|
||||
{
|
||||
$submitted = trim((string) ($this->f3->get('POST.csrf_token') ?? ''));
|
||||
$expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?? ''));
|
||||
|
||||
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->f3->error(400, 'Jeton CSRF invalide.');
|
||||
}
|
||||
|
||||
// Empile un message flash — permet plusieurs messages par requête.
|
||||
protected function flash(string $type, string $message): void
|
||||
{
|
||||
$this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]);
|
||||
}
|
||||
|
||||
protected function resetCsrfToken(): void
|
||||
{
|
||||
$this->f3->clear('SESSION.csrf_token');
|
||||
$this->ensureCsrfToken();
|
||||
}
|
||||
|
||||
private function ensureCsrfToken(): void
|
||||
{
|
||||
$token = trim((string) ($this->f3->get('SESSION.csrf_token') ?? ''));
|
||||
if ($token !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$seed = trim((string) ($this->f3->get('CSRF') ?? ''));
|
||||
if ($seed === '') {
|
||||
$seed = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
$this->f3->set('SESSION.csrf_token', $seed);
|
||||
}
|
||||
|
||||
private function pullFlash(): array
|
||||
{
|
||||
return $this->f3->pull('SESSION.flash') ?: [];
|
||||
}
|
||||
}
|
||||
91
app/Controllers/Controller.php
Normal file
91
app/Controllers/Controller.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
protected Base $f3;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->f3 = Base::instance();
|
||||
}
|
||||
|
||||
protected function render(string $view, array $data = [], int $ttl = 0): void
|
||||
{
|
||||
$this->ensureCsrf();
|
||||
$user = $this->user();
|
||||
$flash = array_key_exists('flash', $data) ? $data['flash'] : $this->pullFlash();
|
||||
|
||||
$this->f3->expire($user ? 0 : $ttl);
|
||||
$this->f3->mset($data + [
|
||||
'view' => $view,
|
||||
'flash' => is_array($flash) ? $flash : [],
|
||||
'currentUser' => $user,
|
||||
'adminMode' => false,
|
||||
'metaDescription' => null,
|
||||
'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
|
||||
]);
|
||||
|
||||
echo Template::instance()->render('layout.html');
|
||||
}
|
||||
|
||||
protected function user(): ?array
|
||||
{
|
||||
if (!$this->f3->exists('ctx.user_loaded')) {
|
||||
$id = (int) ($this->f3->get('SESSION.user_id') ?: 0);
|
||||
$this->f3->set('currentUser', $id > 0 ? (new User())->findPublic($id) : null);
|
||||
$this->f3->set('ctx.user_loaded', true);
|
||||
}
|
||||
|
||||
return $this->f3->get('currentUser');
|
||||
}
|
||||
|
||||
protected function requireAuth(): void
|
||||
{
|
||||
if ($this->user()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->flash('error', 'Connecte-toi pour continuer.');
|
||||
$this->f3->reroute('@login');
|
||||
}
|
||||
|
||||
protected function checkCsrf(): void
|
||||
{
|
||||
$sent = trim((string) ($this->f3->get('POST.csrf_token') ?: ''));
|
||||
$expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?: ''));
|
||||
|
||||
if ($sent !== '' && $expected !== '' && hash_equals($expected, $sent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->f3->error(400);
|
||||
}
|
||||
|
||||
protected function flash(string $type, string $message): void
|
||||
{
|
||||
$this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]);
|
||||
}
|
||||
|
||||
protected function rotateCsrf(): void
|
||||
{
|
||||
$this->f3->clear('SESSION.csrf_token');
|
||||
$this->ensureCsrf();
|
||||
}
|
||||
|
||||
private function ensureCsrf(): void
|
||||
{
|
||||
if ($this->f3->exists('SESSION.csrf_token')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seed = trim((string) ($this->f3->get('CSRF') ?: ''));
|
||||
$this->f3->set('SESSION.csrf_token', $seed !== '' ? $seed : bin2hex(random_bytes(16)));
|
||||
}
|
||||
|
||||
private function pullFlash(): array
|
||||
{
|
||||
return $this->f3->pull('SESSION.flash') ?: [];
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class MediaController extends AdminController
|
||||
{
|
||||
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
|
||||
private const PER_PAGE = 24;
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||
$result = (new Media())->paginateLibrary($page, self::PER_PAGE);
|
||||
|
||||
$this->render('admin/media.html', [
|
||||
'pageTitle' => 'Médiathèque',
|
||||
'items' => $result['items'],
|
||||
'pagination' => $result,
|
||||
'paginationAlias' => 'media_index',
|
||||
]);
|
||||
}
|
||||
|
||||
public function upload(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
// Lire le nom d'origine avant que Web::receive() déplace le fichier.
|
||||
$originalName = (string) ($this->f3->get('FILES.image.name') ?? '');
|
||||
|
||||
$received = Web::instance()->receive(
|
||||
// F3 gère le transport et le renommage ; la validation métier
|
||||
// (format réel, dimensions, réencodage) reste centralisée dans Media.
|
||||
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0
|
||||
&& (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
|
||||
overwrite: false,
|
||||
slug: true
|
||||
);
|
||||
|
||||
// Le formulaire n'envoie qu'un seul fichier : on garde le premier
|
||||
// chemin accepté retourné par Web::receive().
|
||||
$accepted = array_keys(array_filter($received));
|
||||
$destPath = $accepted[0] ?? null;
|
||||
|
||||
if ($destPath === null) {
|
||||
throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int) (self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).');
|
||||
}
|
||||
|
||||
(new Media())->upload($destPath, $originalName);
|
||||
|
||||
$this->flash('success', 'Image ajoutée.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute('@media_index');
|
||||
}
|
||||
|
||||
public function updateAlt(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
$alt = $this->f3->clean((string) ($this->f3->get('POST.alt') ?? ''));
|
||||
(new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), $alt);
|
||||
$this->flash('success', 'Texte alternatif mis à jour.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute('@media_index');
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
$id = (int) $this->f3->get('PARAMS.id');
|
||||
$media = new Media();
|
||||
$item = $media->findById($id);
|
||||
|
||||
if ($item === null) {
|
||||
throw new RuntimeException('Image introuvable.');
|
||||
}
|
||||
|
||||
if ((new Post())->isMediaUsed($item['id'], $item['file_name'])) {
|
||||
throw new RuntimeException('Cette image est encore utilisée par un article.');
|
||||
}
|
||||
|
||||
$media->delete($id);
|
||||
$this->flash('success', 'Image supprimée.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute('@media_index');
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class PostController extends AdminController
|
||||
{
|
||||
private const PER_PAGE = 12;
|
||||
private const MEDIA_PICKER_LIMIT = 60;
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||
$media = new Media();
|
||||
$result = (new Post())->paginateList($page, self::PER_PAGE, $media);
|
||||
|
||||
$this->render('admin/dashboard.html', [
|
||||
'pageTitle' => 'Tableau de bord',
|
||||
'posts' => $result['posts'],
|
||||
'pagination' => $result,
|
||||
'paginationAlias' => 'dashboard',
|
||||
'adminMode' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), Post::emptyForm());
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
|
||||
$media = new Media();
|
||||
$input = $this->postInput();
|
||||
|
||||
try {
|
||||
(new Post())->create($input, $media);
|
||||
$this->flash('success', 'Article créé.');
|
||||
$this->f3->reroute('@dashboard');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage(), $media);
|
||||
}
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$post = (new Post())->findForEdit((int) $this->f3->get('PARAMS.id'));
|
||||
if ($post === null) {
|
||||
$this->f3->error(404, 'Article introuvable.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
|
||||
$media = new Media();
|
||||
$id = (int) $this->f3->get('PARAMS.id');
|
||||
$input = $this->postInput() + ['id' => $id];
|
||||
|
||||
try {
|
||||
$updated = (new Post())->updatePost($id, $input, $media);
|
||||
if (!$updated) {
|
||||
$this->f3->error(404, 'Article introuvable.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->flash('success', 'Article mis à jour.');
|
||||
$this->f3->reroute('@dashboard');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage(), $media);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
(new Post())->delete((int) $this->f3->get('PARAMS.id'));
|
||||
$this->flash('success', 'Article supprimé.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute('@dashboard');
|
||||
}
|
||||
|
||||
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null, ?Media $media = null): void
|
||||
{
|
||||
$media ??= new Media();
|
||||
|
||||
$coverPreview = null;
|
||||
if (!empty($post['cover_media_id'])) {
|
||||
$coverPreview = $media->findById((int) $post['cover_media_id']);
|
||||
}
|
||||
|
||||
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
|
||||
$mediaCount = $media->count();
|
||||
$flash = $error !== null ? [['type' => 'error', 'message' => $error]] : [];
|
||||
|
||||
$this->render('admin/post_form.html', [
|
||||
'pageTitle' => $pageTitle,
|
||||
'formAction' => $formAction,
|
||||
'post' => $post,
|
||||
'coverPreview' => $coverPreview,
|
||||
'mediaItems' => $mediaItems,
|
||||
'mediaCount' => $mediaCount,
|
||||
'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT,
|
||||
'mediaPickerTruncated' => $mediaCount > count($mediaItems),
|
||||
'titleMax' => Post::TITLE_MAX_LENGTH,
|
||||
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
|
||||
'flash' => $flash,
|
||||
]);
|
||||
}
|
||||
|
||||
private function postInput(): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?? '')),
|
||||
'excerpt' => $this->f3->clean((string) ($this->f3->get('POST.excerpt') ?? '')),
|
||||
'cover_media_id' => (string) ($this->f3->get('POST.cover_media_id') ?? ''),
|
||||
'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,26 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class SiteController extends BaseController
|
||||
class SiteController extends Controller
|
||||
{
|
||||
public function home(): void
|
||||
{
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||
$media = new Media();
|
||||
$result = (new Post())->paginateList($page, 12, $media);
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?: 1));
|
||||
$result = (new Post())->page($page, 12);
|
||||
|
||||
$this->render('site/home.html', [
|
||||
'pageTitle' => 'Accueil',
|
||||
'posts' => $result['posts'],
|
||||
'pagination' => $result,
|
||||
'pageTitle' => 'Articles',
|
||||
'posts' => $result['items'],
|
||||
'pagination' => $result['pagination'],
|
||||
'paginationAlias' => 'home',
|
||||
], 300);
|
||||
}
|
||||
|
||||
public function show(): void
|
||||
{
|
||||
$media = new Media();
|
||||
$post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'), $media);
|
||||
if ($post === null) {
|
||||
$this->f3->error(404, 'Article introuvable.');
|
||||
$post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'));
|
||||
if (!$post) {
|
||||
$this->f3->error(404);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,6 +29,6 @@ class SiteController extends BaseController
|
||||
'pageTitle' => $post['title'],
|
||||
'metaDescription' => $post['excerpt'],
|
||||
'post' => $post,
|
||||
], 3600);
|
||||
], 300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function app_timezone(): string
|
||||
{
|
||||
$timezone = trim((string) Base::instance()->get('app.timezone'));
|
||||
return ($timezone !== '' && in_array($timezone, DateTimeZone::listIdentifiers(), true)) ? $timezone : 'UTC';
|
||||
}
|
||||
|
||||
function app_now(): string
|
||||
{
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
function app_is_prod(): bool
|
||||
{
|
||||
return Base::instance()->get('app.env') === 'prod';
|
||||
}
|
||||
|
||||
function app_ensure_dir(string $path): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0775, true);
|
||||
}
|
||||
}
|
||||
|
||||
function app_unique_slug(string $value, callable $exists): string
|
||||
{
|
||||
$base = Web::instance()->slug(trim($value));
|
||||
if ($base === '') {
|
||||
$base = 'article';
|
||||
}
|
||||
|
||||
if (!$exists($base)) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
for ($i = 2; $i <= 1000; $i++) {
|
||||
$candidate = $base . '-' . $i;
|
||||
if (!$exists($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Impossible de générer un slug unique.');
|
||||
}
|
||||
|
||||
function app_format_datetime_fr(string $value): string
|
||||
{
|
||||
static $utc, $formatter;
|
||||
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$utc ??= new DateTimeZone('UTC');
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $utc);
|
||||
if (!$date instanceof DateTimeImmutable) {
|
||||
$date = new DateTimeImmutable($value, $utc);
|
||||
}
|
||||
|
||||
$date = $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
|
||||
|
||||
$formatter ??= new IntlDateFormatter(
|
||||
'fr_FR',
|
||||
IntlDateFormatter::LONG,
|
||||
IntlDateFormatter::SHORT,
|
||||
date_default_timezone_get(),
|
||||
IntlDateFormatter::GREGORIAN,
|
||||
"d MMMM yyyy 'à' HH:mm"
|
||||
);
|
||||
|
||||
$formatted = $formatter->format($date);
|
||||
|
||||
return is_string($formatted) && $formatted !== '' ? $formatted : $value;
|
||||
} catch (Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
function app_trusted_proxies(): array
|
||||
{
|
||||
$value = Base::instance()->get('app.trusted_proxies');
|
||||
|
||||
if (is_array($value)) {
|
||||
$items = [];
|
||||
|
||||
array_walk_recursive($value, static function (mixed $item) use (&$items): void {
|
||||
if (is_string($item) || is_numeric($item)) {
|
||||
$items[] = (string) $item;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = preg_split('/[\s,]+/', $raw) ?: [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($items as $item) {
|
||||
foreach (preg_split('/[\s,]+/', trim((string) $item)) ?: [] as $part) {
|
||||
$part = trim($part);
|
||||
if ($part !== '') {
|
||||
$normalized[] = $part;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
function app_is_trusted_proxy(?string $ip = null): bool
|
||||
{
|
||||
$ip = trim((string) ($ip ?? Base::instance()->get('SERVER.REMOTE_ADDR')));
|
||||
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (app_trusted_proxies() as $proxy) {
|
||||
if (app_ip_matches_proxy($ip, $proxy)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function app_request_scheme(): string
|
||||
{
|
||||
$f3 = Base::instance();
|
||||
|
||||
$https = strtolower(trim((string) $f3->get('SERVER.HTTPS')));
|
||||
if ($https !== '' && $https !== 'off' && $https !== '0') {
|
||||
return 'https';
|
||||
}
|
||||
|
||||
$scheme = strtolower(trim((string) $f3->get('SCHEME')));
|
||||
if ($scheme === 'https') {
|
||||
return 'https';
|
||||
}
|
||||
|
||||
// Derrière un reverse proxy de confiance, on accepte le proto transmis.
|
||||
if (app_is_trusted_proxy()) {
|
||||
$forwardedProto = trim((string) $f3->get('SERVER.HTTP_X_FORWARDED_PROTO'));
|
||||
if ($forwardedProto !== '') {
|
||||
$forwardedProto = strtolower(trim(explode(',', $forwardedProto)[0]));
|
||||
return $forwardedProto === 'https' ? 'https' : 'http';
|
||||
}
|
||||
|
||||
$forwarded = trim((string) $f3->get('SERVER.HTTP_FORWARDED'));
|
||||
if ($forwarded !== '' && preg_match('/(?:^|[;,]\s*)proto=(https?)/i', $forwarded, $matches) === 1) {
|
||||
return strtolower($matches[1]) === 'https' ? 'https' : 'http';
|
||||
}
|
||||
}
|
||||
|
||||
return 'http';
|
||||
}
|
||||
|
||||
function app_ip_matches_proxy(string $ip, string $proxy): bool
|
||||
{
|
||||
$proxy = trim($proxy);
|
||||
if ($proxy === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!str_contains($proxy, '/')) {
|
||||
return filter_var($proxy, FILTER_VALIDATE_IP) !== false && strcasecmp($ip, $proxy) === 0;
|
||||
}
|
||||
|
||||
[$subnet, $prefix] = explode('/', $proxy, 2);
|
||||
$subnet = trim($subnet);
|
||||
$prefix = trim($prefix);
|
||||
|
||||
$ipBinary = inet_pton($ip);
|
||||
$subnetBinary = inet_pton($subnet);
|
||||
|
||||
if ($ipBinary === false || $subnetBinary === false || strlen($ipBinary) !== strlen($subnetBinary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ctype_digit($prefix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$prefixLength = (int) $prefix;
|
||||
$maxBits = strlen($ipBinary) * 8;
|
||||
if ($prefixLength < 0 || $prefixLength > $maxBits) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fullBytes = intdiv($prefixLength, 8);
|
||||
if ($fullBytes > 0 && substr($ipBinary, 0, $fullBytes) !== substr($subnetBinary, 0, $fullBytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$remainingBits = $prefixLength % 8;
|
||||
if ($remainingBits === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$mask = (0xFF << (8 - $remainingBits)) & 0xFF;
|
||||
|
||||
return (ord($ipBinary[$fullBytes]) & $mask) === (ord($subnetBinary[$fullBytes]) & $mask);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function app_bootstrap_logging(): void
|
||||
{
|
||||
$dir = rtrim((string) Base::instance()->get('LOGS'), '/\\') . DIRECTORY_SEPARATOR;
|
||||
app_ensure_dir($dir);
|
||||
ini_set('log_errors', '1');
|
||||
ini_set('error_log', $dir . 'php-error.log');
|
||||
ini_set('display_errors', app_is_prod() ? '0' : '1');
|
||||
error_reporting(E_ALL);
|
||||
}
|
||||
|
||||
function app_error_meta(int $code): array
|
||||
{
|
||||
return match ($code) {
|
||||
400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'],
|
||||
403 => ['title' => 'Accès refusé', 'message' => 'Tu n\u2019as pas accès à cette ressource.'],
|
||||
404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'],
|
||||
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
|
||||
};
|
||||
}
|
||||
|
||||
function app_render_error_fallback(int $code): void
|
||||
{
|
||||
$meta = app_error_meta($code);
|
||||
$base = rtrim((string) Base::instance()->get('BASE'), '/');
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
if (!headers_sent()) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
}
|
||||
|
||||
$title = htmlspecialchars((string) $meta['title'], ENT_QUOTES, 'UTF-8');
|
||||
$message = htmlspecialchars((string) $meta['message'], ENT_QUOTES, 'UTF-8');
|
||||
$href = htmlspecialchars($base . '/', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
echo '<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . $title . '</title></head><body><main><h1>' . $title . '</h1><p>' . $message . '</p><p><a href="' . $href . '">Retour à l\'accueil</a></p></main></body></html>';
|
||||
}
|
||||
|
||||
function app_bootstrap_errors(Base $f3): void
|
||||
{
|
||||
// En dev, ne pas poser ONERROR : le handler par défaut de F3
|
||||
// affiche la stack trace complète quand DEBUG > 0.
|
||||
if (!app_is_prod()) {
|
||||
return;
|
||||
}
|
||||
|
||||
register_shutdown_function(function (): void {
|
||||
$error = error_get_last();
|
||||
if ($error === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
|
||||
if (!in_array($error['type'] ?? 0, $fatalTypes, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app_render_error_fallback(500);
|
||||
});
|
||||
|
||||
$f3->set('ONERROR', function (Base $f3): void {
|
||||
$code = max((int) ($f3->get('ERROR.code') ?? 500), 1);
|
||||
$f3->expire(0);
|
||||
$f3->status($code);
|
||||
|
||||
$meta = app_error_meta($code);
|
||||
$f3->mset([
|
||||
'errorCode' => $code,
|
||||
'errorTitle' => $meta['title'],
|
||||
'errorMessage' => $meta['message'],
|
||||
]);
|
||||
|
||||
try {
|
||||
echo Template::instance()->render('errors/error.html');
|
||||
} catch (Throwable) {
|
||||
app_render_error_fallback($code);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,10 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
class Media extends DB\SQL\Mapper
|
||||
{
|
||||
private const MAX_WIDTH = 8000;
|
||||
private const MAX_HEIGHT = 8000;
|
||||
private const MAX_PIXELS = 40_000_000;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(Base::instance()->get('DB'), 'media');
|
||||
@@ -30,49 +26,27 @@ class Media extends DB\SQL\Mapper
|
||||
$db->exec('CREATE INDEX idx_media_created_at ON media(created_at DESC)');
|
||||
}
|
||||
|
||||
public function paginateLibrary(int $page = 1, int $perPage = 24): array
|
||||
public function page(int $page, int $perPage): array
|
||||
{
|
||||
$result = $this->paginate(
|
||||
max(0, $page - 1),
|
||||
$perPage,
|
||||
null,
|
||||
['order' => 'created_at DESC, id DESC']
|
||||
);
|
||||
$result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
|
||||
|
||||
return [
|
||||
'items' => array_map(fn(self $m): array => $this->decorate($m->cast()), $result['subset']),
|
||||
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||||
'pages' => $result['count'] ?: 1,
|
||||
'items' => array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []),
|
||||
'pagination' => [
|
||||
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||||
'pages' => max(1, (int) ($result['count'] ?: 1)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function latest(int $limit = 60): array
|
||||
public function recent(int $limit): array
|
||||
{
|
||||
return array_map(
|
||||
fn(self $m): array => $this->decorate($m->cast()),
|
||||
fn(self $row): array => $this->decorate($row->cast()),
|
||||
$this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: []
|
||||
);
|
||||
}
|
||||
|
||||
public function findByIds(array $ids): array
|
||||
{
|
||||
$ids = array_filter(array_unique(array_map('intval', $ids)));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$results = $this->find(["id IN ($placeholders)", ...array_values($ids)]);
|
||||
|
||||
$map = [];
|
||||
foreach ($results ?: [] as $m) {
|
||||
$row = $this->decorate($m->cast());
|
||||
$map[$row['id']] = $row;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
@@ -89,64 +63,50 @@ class Media extends DB\SQL\Mapper
|
||||
return $this->dry() ? null : $this->decorate($this->cast());
|
||||
}
|
||||
|
||||
// Traite le fichier temporaire déposé par Web::receive() et publie l'image.
|
||||
public function upload(string $srcPath, string $originalName = ''): int
|
||||
public function upload(string $path, string $originalName = ''): int
|
||||
{
|
||||
$target = null;
|
||||
$committed = false; // Contrôle le nettoyage de $target dans finally.
|
||||
|
||||
try {
|
||||
$meta = self::inspectUpload($srcPath);
|
||||
|
||||
// F3 Image : load() utilise imagecreatefromstring + imagesavealpha.
|
||||
$img = new \Image();
|
||||
$f3 = Base::instance();
|
||||
if (!$img->load($f3->read($srcPath))) {
|
||||
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
|
||||
}
|
||||
|
||||
// PNG/WebP → PNG (préserve la transparence), JPG → JPG.
|
||||
$isJpeg = ($meta['mime'] === 'image/jpeg');
|
||||
$extension = $isJpeg ? 'jpg' : 'png';
|
||||
|
||||
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
|
||||
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
||||
$target = rtrim((string) $f3->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
|
||||
|
||||
// dump() appelle image{format}($data, NULL, $quality).
|
||||
$binary = $isJpeg ? $img->dump('jpeg', 85) : $img->dump('png', 6);
|
||||
if ($binary === '' || $f3->write($target, $binary) === false) {
|
||||
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
}
|
||||
|
||||
$this->db->begin();
|
||||
try {
|
||||
$this->reset();
|
||||
$this->file_name = $fileName;
|
||||
$this->alt = $originalName !== '' ? self::altFromFilename($originalName) : '';
|
||||
$this->width = $meta['width'];
|
||||
$this->height = $meta['height'];
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
$this->db->commit();
|
||||
$committed = true;
|
||||
} catch (Throwable $e) {
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return (int) $this->get('id');
|
||||
} catch (Throwable $e) {
|
||||
throw $e instanceof RuntimeException ? $e : new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
} finally {
|
||||
// Le destructeur de F3 Image libère la ressource GD.
|
||||
if (is_file($srcPath)) {
|
||||
@unlink($srcPath);
|
||||
}
|
||||
if (!$committed && $target !== null && is_file($target)) {
|
||||
@unlink($target);
|
||||
}
|
||||
if (!is_file($path)) {
|
||||
throw new RuntimeException('Fichier image introuvable.');
|
||||
}
|
||||
|
||||
$info = @getimagesize($path);
|
||||
if (!is_array($info)) {
|
||||
@unlink($path);
|
||||
throw new RuntimeException('Fichier image invalide.');
|
||||
}
|
||||
|
||||
$mime = strtolower((string) ($info['mime'] ?? ''));
|
||||
$extension = match ($mime) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($extension === null) {
|
||||
@unlink($path);
|
||||
throw new RuntimeException('Format non supporté. Utilise JPG ou PNG.');
|
||||
}
|
||||
|
||||
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
||||
$target = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
|
||||
|
||||
if (!@rename($path, $target)) {
|
||||
if (!@copy($path, $target)) {
|
||||
@unlink($path);
|
||||
throw new RuntimeException('Impossible d’enregistrer cette image.');
|
||||
}
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
$this->reset();
|
||||
$this->file_name = $fileName;
|
||||
$this->alt = $this->altFromName($originalName);
|
||||
$this->width = (int) $info[0];
|
||||
$this->height = (int) $info[1];
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
|
||||
return (int) $this->id;
|
||||
}
|
||||
|
||||
public function updateAlt(int $id, string $alt): void
|
||||
@@ -160,7 +120,7 @@ class Media extends DB\SQL\Mapper
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
public function deleteById(int $id): void
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
@@ -168,85 +128,33 @@ class Media extends DB\SQL\Mapper
|
||||
}
|
||||
|
||||
$path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name;
|
||||
|
||||
$this->db->begin();
|
||||
try {
|
||||
$this->erase();
|
||||
if (is_file($path) && !unlink($path)) {
|
||||
throw new RuntimeException('Impossible de supprimer le fichier image.');
|
||||
}
|
||||
$this->db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->db->rollback();
|
||||
throw $e instanceof RuntimeException ? $e : new RuntimeException('Suppression impossible.');
|
||||
$this->erase();
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private static function inspectUpload(string $srcPath): array
|
||||
{
|
||||
if (!is_file($srcPath)) {
|
||||
throw new RuntimeException('Fichier image introuvable.');
|
||||
}
|
||||
|
||||
$info = @getimagesize($srcPath);
|
||||
if (!is_array($info)) {
|
||||
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
|
||||
}
|
||||
|
||||
$width = (int) ($info[0] ?? 0);
|
||||
$height = (int) ($info[1] ?? 0);
|
||||
$mime = strtolower((string) ($info['mime'] ?? ''));
|
||||
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
throw new RuntimeException('Format non supporté. Utilise JPG, PNG ou WebP.');
|
||||
}
|
||||
|
||||
if ($width <= 0 || $height <= 0) {
|
||||
throw new RuntimeException('Dimensions image invalides.');
|
||||
}
|
||||
|
||||
if ($width > self::MAX_WIDTH || $height > self::MAX_HEIGHT || ($width * $height) > self::MAX_PIXELS) {
|
||||
throw new RuntimeException('Image trop grande. Limite : 8000 × 8000 px et 40 mégapixels.');
|
||||
}
|
||||
|
||||
return [
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'mime' => $mime,
|
||||
];
|
||||
}
|
||||
|
||||
// Dérive un texte alternatif lisible depuis le nom de fichier d'origine.
|
||||
private static function altFromFilename(string $filename): string
|
||||
{
|
||||
$name = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$name = trim((string) preg_replace('/[-_]+/', ' ', $name));
|
||||
// mb_ucfirst() n'existe qu'en PHP 8.4 — on l'émule.
|
||||
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
|
||||
}
|
||||
|
||||
private function mediaUrl(string $fileName): string
|
||||
{
|
||||
$f3 = Base::instance();
|
||||
$base = rtrim((string) $f3->get('BASE'), '/');
|
||||
$prefix = '/' . trim((string) $f3->get('paths.media_base'), '/');
|
||||
|
||||
return $base . $prefix . '/' . rawurlencode($fileName);
|
||||
}
|
||||
|
||||
private function decorate(array $row): array
|
||||
{
|
||||
$file = (string) $row['file_name'];
|
||||
$alt = (string) $row['alt'];
|
||||
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'file_name' => (string) $row['file_name'],
|
||||
'file_name' => $file,
|
||||
'alt' => $alt,
|
||||
'width' => (int) $row['width'],
|
||||
'height' => (int) $row['height'],
|
||||
'created_at' => (string) $row['created_at'],
|
||||
'url' => $this->mediaUrl((string) $row['file_name']),
|
||||
'markdown' => '',
|
||||
'url' => rtrim((string) Base::instance()->get('BASE'), '/') . rtrim((string) Base::instance()->get('paths.media_base'), '/') . '/' . rawurlencode($file),
|
||||
'markdown' => '',
|
||||
];
|
||||
}
|
||||
|
||||
private function altFromName(string $name): string
|
||||
{
|
||||
$name = trim(pathinfo($name, PATHINFO_FILENAME));
|
||||
$name = preg_replace('/[-_]+/', ' ', $name) ?: '';
|
||||
return trim($name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,129 +25,87 @@ class Post extends DB\SQL\Mapper
|
||||
excerpt TEXT NOT NULL,
|
||||
body_markdown TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL,
|
||||
cover_media_id INTEGER DEFAULT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (cover_media_id) REFERENCES media(id) ON DELETE SET NULL
|
||||
updated_at TEXT NOT NULL
|
||||
)');
|
||||
$db->exec('CREATE INDEX idx_posts_created_at ON posts(created_at DESC)');
|
||||
}
|
||||
|
||||
public static function emptyForm(): array
|
||||
public static function blank(): array
|
||||
{
|
||||
return [
|
||||
'title' => '',
|
||||
'excerpt' => '',
|
||||
'cover_media_id' => '',
|
||||
'body_markdown' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function paginateList(int $page, int $perPage, Media $media): array
|
||||
public function page(int $page, int $perPage): array
|
||||
{
|
||||
$result = $this->paginate(
|
||||
max(0, $page - 1),
|
||||
$perPage,
|
||||
null,
|
||||
['order' => 'created_at DESC, id DESC']
|
||||
);
|
||||
|
||||
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
|
||||
$coverIds = array_filter(array_unique(array_column($posts, 'cover_media_id')));
|
||||
$covers = $media->findByIds($coverIds);
|
||||
|
||||
foreach ($posts as &$post) {
|
||||
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||
$post['cover_url'] = $cover['url'] ?? '';
|
||||
$post['cover_alt'] = $cover['alt'] ?? '';
|
||||
}
|
||||
$result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
|
||||
$items = array_map(fn(self $row): array => $this->summary($row->cast()), $result['subset'] ?: []);
|
||||
|
||||
return [
|
||||
'posts' => $posts,
|
||||
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||||
'pages' => $result['count'] ?: 1,
|
||||
'items' => $items,
|
||||
'pagination' => [
|
||||
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||||
'pages' => max(1, (int) ($result['count'] ?: 1)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug, Media $media): ?array
|
||||
public function findBySlug(string $slug): ?array
|
||||
{
|
||||
$this->load(['slug = ?', $slug]);
|
||||
if ($this->dry()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$post = $this->summaryRow($this->cast());
|
||||
$cover = $post['cover_media_id'] > 0 ? $media->findById($post['cover_media_id']) : null;
|
||||
$post['cover_url'] = $cover['url'] ?? '';
|
||||
$post['cover_alt'] = $cover['alt'] ?? '';
|
||||
$post['body_html'] = (string) $this->body_html;
|
||||
$row = $this->cast();
|
||||
$post = $this->summary($row) + ['body_html' => (string) $row['body_html']];
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function findForEdit(int $id): ?array
|
||||
public function findForForm(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $this->get('id'),
|
||||
'id' => (int) $this->id,
|
||||
'title' => (string) $this->title,
|
||||
'excerpt' => (string) $this->excerpt,
|
||||
'body_markdown' => (string) $this->body_markdown,
|
||||
'cover_media_id' => $this->cover_media_id !== null ? (string) ((int) $this->cover_media_id) : '',
|
||||
];
|
||||
}
|
||||
|
||||
public function create(array $input, Media $media): int
|
||||
public function savePost(array $input, ?int $id = null): int
|
||||
{
|
||||
$payload = $this->payload($input, $media);
|
||||
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->count(['slug = ?', $candidate]) > 0);
|
||||
$payload = $this->payload($input);
|
||||
$now = app_now();
|
||||
|
||||
$this->reset();
|
||||
$this->copyfrom($payload + [
|
||||
'slug' => $slug,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->save();
|
||||
|
||||
return (int) $this->get('id');
|
||||
}
|
||||
|
||||
public function updatePost(int $id, array $input, Media $media): bool
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return false;
|
||||
if ($id === null) {
|
||||
$this->reset();
|
||||
$payload['slug'] = $this->uniqueSlug($payload['title']);
|
||||
$payload['created_at'] = $now;
|
||||
} else {
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
throw new RuntimeException('Article introuvable.');
|
||||
}
|
||||
}
|
||||
|
||||
$payload = $this->payload($input, $media);
|
||||
$this->copyfrom($payload + ['updated_at' => app_now()]);
|
||||
$payload['updated_at'] = $now;
|
||||
$this->copyfrom($payload);
|
||||
$this->save();
|
||||
|
||||
return true;
|
||||
return (int) $this->id;
|
||||
}
|
||||
|
||||
// Vérifie les deux usages possibles : couverture (cover_media_id)
|
||||
// et images insérées dans le corps (media:filename dans body_markdown).
|
||||
public function isMediaUsed(int $mediaId, string $fileName): bool
|
||||
{
|
||||
return $this->count([
|
||||
'cover_media_id = ? OR body_markdown LIKE ?',
|
||||
$mediaId,
|
||||
'%media:' . $fileName . '%',
|
||||
]) > 0;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
public function deleteById(int $id): void
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
@@ -157,12 +115,16 @@ class Post extends DB\SQL\Mapper
|
||||
$this->erase();
|
||||
}
|
||||
|
||||
private function payload(array $input, Media $media): array
|
||||
public function usesMedia(string $fileName): bool
|
||||
{
|
||||
return $this->count(['body_markdown LIKE ?', '%media:' . $fileName . '%']) > 0;
|
||||
}
|
||||
|
||||
private function payload(array $input): array
|
||||
{
|
||||
$title = trim((string) ($input['title'] ?? ''));
|
||||
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
||||
$bodyMarkdown = trim((string) ($input['body_markdown'] ?? ''));
|
||||
$coverMediaId = trim((string) ($input['cover_media_id'] ?? ''));
|
||||
$body = trim((string) ($input['body_markdown'] ?? ''));
|
||||
|
||||
if ($title === '') {
|
||||
throw new RuntimeException('Ajoute un titre.');
|
||||
@@ -174,39 +136,67 @@ class Post extends DB\SQL\Mapper
|
||||
throw new RuntimeException('Ajoute un extrait.');
|
||||
}
|
||||
if (mb_strlen($excerpt) > self::EXCERPT_MAX_LENGTH) {
|
||||
throw new RuntimeException("L'extrait est trop long.");
|
||||
throw new RuntimeException('L’extrait est trop long.');
|
||||
}
|
||||
|
||||
$coverId = null;
|
||||
if ($coverMediaId !== '') {
|
||||
$coverId = (int) $coverMediaId;
|
||||
if ($media->findById($coverId) === null) {
|
||||
throw new RuntimeException('Image de couverture introuvable.');
|
||||
}
|
||||
}
|
||||
|
||||
$bodyHtml = MarkdownService::instance()->compile($bodyMarkdown, $media);
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'excerpt' => $excerpt,
|
||||
'body_markdown' => $bodyMarkdown,
|
||||
'body_html' => $bodyHtml,
|
||||
'cover_media_id' => $coverId,
|
||||
'body_markdown' => $body,
|
||||
'body_html' => MarkdownService::instance()->compile($body, new Media()),
|
||||
];
|
||||
}
|
||||
|
||||
private function summaryRow(array $row): array
|
||||
private function uniqueSlug(string $title): string
|
||||
{
|
||||
$base = app_slug($title);
|
||||
$slug = $base;
|
||||
$n = 2;
|
||||
|
||||
while ($this->count(['slug = ?', $slug]) > 0) {
|
||||
$slug = $base . '-' . $n;
|
||||
$n++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function summary(array $row): array
|
||||
{
|
||||
$thumbnail = $this->firstImage((string) ($row['body_html'] ?? ''));
|
||||
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'title' => (string) $row['title'],
|
||||
'slug' => (string) $row['slug'],
|
||||
'excerpt' => (string) $row['excerpt'],
|
||||
'cover_media_id' => (int) ($row['cover_media_id'] ?? 0),
|
||||
'thumbnail_url' => $thumbnail['url'],
|
||||
'thumbnail_alt' => $thumbnail['alt'],
|
||||
'created_at' => (string) $row['created_at'],
|
||||
'updated_at' => (string) $row['updated_at'],
|
||||
];
|
||||
}
|
||||
|
||||
private function firstImage(string $html): array
|
||||
{
|
||||
if ($html === '') {
|
||||
return ['url' => '', 'alt' => ''];
|
||||
}
|
||||
|
||||
if (!preg_match('~(<img\s[^>]*src="([^"]+)"[^>]*>)~i', $html, $match)) {
|
||||
return ['url' => '', 'alt' => ''];
|
||||
}
|
||||
|
||||
$tag = $match[1];
|
||||
$alt = '';
|
||||
|
||||
if (preg_match('~alt="([^"]*)"~i', $tag, $altMatch)) {
|
||||
$alt = html_entity_decode($altMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'alt' => $alt,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,41 +23,34 @@ class User extends DB\SQL\Mapper
|
||||
)');
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
public function findPublic(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->cast();
|
||||
unset($data['password_hash']); // Ne jamais exposer le hash hors de l'authentification.
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function findByUsername(string $username): ?array
|
||||
{
|
||||
$this->load(['username = ?', $username]);
|
||||
return $this->dry() ? null : $this->cast();
|
||||
return [
|
||||
'id' => (int) $this->id,
|
||||
'username' => (string) $this->username,
|
||||
'created_at' => (string) $this->created_at,
|
||||
];
|
||||
}
|
||||
|
||||
public function create(string $username, string $password): int
|
||||
{
|
||||
$username = Base::instance()->clean($username);
|
||||
$username = Base::instance()->clean(trim($username));
|
||||
$password = trim($password);
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
throw new RuntimeException('Nom d’utilisateur et mot de passe obligatoires.');
|
||||
}
|
||||
|
||||
if (mb_strlen($password) < 10) {
|
||||
throw new RuntimeException('Le mot de passe doit contenir au moins 10 caractères.');
|
||||
}
|
||||
|
||||
if ($this->findByUsername($username) !== null) {
|
||||
$this->load(['username = ?', $username]);
|
||||
if (!$this->dry()) {
|
||||
throw new RuntimeException('Cet utilisateur existe déjà.');
|
||||
}
|
||||
|
||||
@@ -67,6 +60,6 @@ class User extends DB\SQL\Mapper
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
|
||||
return (int) $this->get('id');
|
||||
return (int) $this->id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
class MarkdownService extends Prefab
|
||||
{
|
||||
private const ALLOWED_TAGS = [
|
||||
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
|
||||
'strong', 'em', 'a', 'img', 'hr', 'br',
|
||||
];
|
||||
|
||||
private const ALLOWED_ATTRIBUTES = [
|
||||
private const TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br'];
|
||||
private const ATTRS = [
|
||||
'a' => ['href', 'title', 'rel', 'target'],
|
||||
'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
|
||||
];
|
||||
@@ -22,190 +17,123 @@ class MarkdownService extends Prefab
|
||||
throw new RuntimeException('Ajoute du contenu avant de publier.');
|
||||
}
|
||||
|
||||
$html = Markdown::instance()->convert($markdown);
|
||||
$document = $this->parseFragment($html);
|
||||
$this->sanitizeTree($document, $media);
|
||||
|
||||
return trim($this->renderFragment($document));
|
||||
}
|
||||
|
||||
private function parseFragment(string $html): DOMDocument
|
||||
{
|
||||
$document = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapper = '<div id="markdown-root">' . $html . '</div>';
|
||||
$markdown = $this->neutralizeRawHtml($markdown);
|
||||
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$html = '<div id="content">' . Markdown::instance()->convert($markdown) . '</div>';
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$document->loadHTML(
|
||||
'<?xml encoding="utf-8" ?>' . $wrapper,
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function renderFragment(DOMDocument $document): string
|
||||
{
|
||||
$root = $document->getElementById('markdown-root');
|
||||
if ($root === null) {
|
||||
$root = $doc->getElementById('content');
|
||||
if (!$root) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '';
|
||||
$this->sanitizeChildren($root, $media);
|
||||
|
||||
$out = '';
|
||||
foreach (iterator_to_array($root->childNodes) as $child) {
|
||||
$html .= $document->saveHTML($child);
|
||||
$out .= $doc->saveHTML($child);
|
||||
}
|
||||
|
||||
return $html;
|
||||
return trim($out);
|
||||
}
|
||||
|
||||
private function sanitizeTree(DOMDocument $document, Media $media): void
|
||||
private function neutralizeRawHtml(string $markdown): string
|
||||
{
|
||||
$root = $document->getElementById('markdown-root');
|
||||
if ($root === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sanitizeChildren($root, $media);
|
||||
return preg_replace_callback(
|
||||
'~<!--.*?-->|</?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?/?>~s',
|
||||
static fn(array $match): string => str_replace(['<', '>'], ['<', '>'], $match[0]),
|
||||
$markdown
|
||||
) ?? $markdown;
|
||||
}
|
||||
|
||||
private function sanitizeChildren(DOMNode $parent, Media $media): void
|
||||
{
|
||||
foreach (iterator_to_array($parent->childNodes) as $child) {
|
||||
if ($child instanceof DOMElement) {
|
||||
$tag = strtolower($child->tagName);
|
||||
if (!$child instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
|
||||
$this->dropDisallowedElement($child);
|
||||
$tag = strtolower($child->tagName);
|
||||
if (!in_array($tag, self::TAGS, true)) {
|
||||
$this->unwrap($child);
|
||||
$this->sanitizeChildren($parent, $media);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (iterator_to_array($child->attributes) as $attr) {
|
||||
if (!in_array(strtolower($attr->name), self::ATTRS[$tag] ?? [], true)) {
|
||||
$child->removeAttributeNode($attr);
|
||||
}
|
||||
}
|
||||
|
||||
if ($tag === 'a') {
|
||||
$href = trim((string) $child->getAttribute('href'));
|
||||
if (!$this->allowedHref($href)) {
|
||||
$this->unwrap($child);
|
||||
$this->sanitizeChildren($parent, $media);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->sanitizeElement($child, $media);
|
||||
$this->sanitizeChildren($child, $media);
|
||||
$child->setAttribute('href', $href);
|
||||
$child->setAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
if (preg_match('~^https?://~i', $href)) {
|
||||
$child->setAttribute('target', '_blank');
|
||||
} else {
|
||||
$child->removeAttribute('target');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function dropDisallowedElement(DOMElement $element): void
|
||||
{
|
||||
$parent = $element->parentNode;
|
||||
if ($parent === null) {
|
||||
return;
|
||||
}
|
||||
if ($tag === 'img') {
|
||||
$src = trim((string) $child->getAttribute('src'));
|
||||
if (!str_starts_with($src, 'media:')) {
|
||||
$child->parentNode?->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array(strtolower($element->tagName), ['script', 'style'], true)) {
|
||||
$parent->removeChild($element);
|
||||
return;
|
||||
}
|
||||
$item = $media->findByFileName(substr($src, 6));
|
||||
if (!$item) {
|
||||
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
||||
}
|
||||
|
||||
while ($element->firstChild !== null) {
|
||||
$parent->insertBefore($element->firstChild, $element);
|
||||
}
|
||||
|
||||
$parent->removeChild($element);
|
||||
}
|
||||
|
||||
private function sanitizeElement(DOMElement $element, Media $media): void
|
||||
{
|
||||
$tag = strtolower($element->tagName);
|
||||
$allowedAttributes = self::ALLOWED_ATTRIBUTES[$tag] ?? [];
|
||||
|
||||
foreach (iterator_to_array($element->attributes) as $attribute) {
|
||||
if (!in_array(strtolower($attribute->name), $allowedAttributes, true)) {
|
||||
$element->removeAttributeNode($attribute);
|
||||
$child->setAttribute('src', $item['url']);
|
||||
$child->setAttribute('alt', trim((string) $child->getAttribute('alt')) ?: (string) $item['alt']);
|
||||
$child->setAttribute('width', (string) $item['width']);
|
||||
$child->setAttribute('height', (string) $item['height']);
|
||||
$child->setAttribute('loading', 'lazy');
|
||||
$child->setAttribute('decoding', 'async');
|
||||
}
|
||||
}
|
||||
|
||||
if ($tag === 'a') {
|
||||
$this->sanitizeLink($element);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tag === 'img') {
|
||||
$this->sanitizeImage($element, $media);
|
||||
$this->sanitizeChildren($child, $media);
|
||||
}
|
||||
}
|
||||
|
||||
private function sanitizeLink(DOMElement $element): void
|
||||
private function unwrap(DOMElement $node): void
|
||||
{
|
||||
$href = trim((string) $element->getAttribute('href'));
|
||||
if (!$this->isAllowedHref($href)) {
|
||||
$this->unwrapElement($element);
|
||||
$parent = $node->parentNode;
|
||||
if (!$parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$element->setAttribute('href', $href);
|
||||
$element->setAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
if ($this->isExternalHttpUrl($href)) {
|
||||
$element->setAttribute('target', '_blank');
|
||||
} else {
|
||||
$element->removeAttribute('target');
|
||||
if (in_array(strtolower($node->tagName), ['script', 'style'], true)) {
|
||||
$parent->removeChild($node);
|
||||
return;
|
||||
}
|
||||
|
||||
while ($node->firstChild) {
|
||||
$parent->insertBefore($node->firstChild, $node);
|
||||
}
|
||||
|
||||
$parent->removeChild($node);
|
||||
}
|
||||
|
||||
private function sanitizeImage(DOMElement $element, Media $media): void
|
||||
private function allowedHref(string $href): bool
|
||||
{
|
||||
$src = trim((string) $element->getAttribute('src'));
|
||||
if (!str_starts_with($src, 'media:')) {
|
||||
$element->parentNode?->removeChild($element);
|
||||
return;
|
||||
}
|
||||
|
||||
$fileName = substr($src, 6);
|
||||
if ($fileName === '') {
|
||||
$element->parentNode?->removeChild($element);
|
||||
return;
|
||||
}
|
||||
|
||||
$item = $media->findByFileName($fileName);
|
||||
if ($item === null) {
|
||||
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
||||
}
|
||||
|
||||
$alt = trim((string) $element->getAttribute('alt'));
|
||||
if ($alt === '') {
|
||||
$alt = (string) ($item['alt'] ?? '');
|
||||
}
|
||||
|
||||
$element->setAttribute('src', (string) $item['url']);
|
||||
$element->setAttribute('alt', $alt);
|
||||
$element->setAttribute('loading', 'lazy');
|
||||
$element->setAttribute('decoding', 'async');
|
||||
|
||||
$width = (int) ($item['width'] ?? 0);
|
||||
if ($width > 0) {
|
||||
$element->setAttribute('width', (string) $width);
|
||||
} else {
|
||||
$element->removeAttribute('width');
|
||||
}
|
||||
|
||||
$height = (int) ($item['height'] ?? 0);
|
||||
if ($height > 0) {
|
||||
$element->setAttribute('height', (string) $height);
|
||||
} else {
|
||||
$element->removeAttribute('height');
|
||||
}
|
||||
}
|
||||
|
||||
private function unwrapElement(DOMElement $element): void
|
||||
{
|
||||
$parent = $element->parentNode;
|
||||
if ($parent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
while ($element->firstChild !== null) {
|
||||
$parent->insertBefore($element->firstChild, $element);
|
||||
}
|
||||
|
||||
$parent->removeChild($element);
|
||||
}
|
||||
|
||||
private function isAllowedHref(string $href): bool
|
||||
{
|
||||
if ($href == '') {
|
||||
if ($href === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -219,9 +147,4 @@ class MarkdownService extends Prefab
|
||||
|
||||
return !preg_match('~^[a-z][a-z0-9+.-]*:~i', $href);
|
||||
}
|
||||
|
||||
private function isExternalHttpUrl(string $href): bool
|
||||
{
|
||||
return (bool) preg_match('~^https?://~i', $href);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<include href="partials/csrf_field.html" />
|
||||
<label class="field">
|
||||
<span class="field-label">Nouvelle image</span>
|
||||
<input class="control" type="file" name="image" accept="image/jpeg,image/png,image/webp" required>
|
||||
<span class="field-help">Formats acceptés : JPG, PNG, WebP. Limite : 10 Mo, 8000 × 8000 px et 40 mégapixels.</span>
|
||||
<input class="control" type="file" name="image" accept="image/jpeg,image/png" required>
|
||||
<span class="field-help">Formats acceptés : JPG et PNG. Limite : 10 Mo. Les fichiers sont stockés tels quels, sans transformation.</span>
|
||||
</label>
|
||||
<button class="button" type="submit">Envoyer</button>
|
||||
</form>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<div class="editor-layout" data-editor-layout>
|
||||
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
|
||||
<include href="partials/csrf_field.html" />
|
||||
<input type="hidden" name="cover_media_id" value="{{ @post.cover_media_id }}" data-cover-input>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Titre</span>
|
||||
@@ -24,38 +23,11 @@
|
||||
<span class="char-counter"><span data-char-count-value>0</span> / {{ @excerptMax }}</span>
|
||||
</label>
|
||||
|
||||
<section class="field cover-field">
|
||||
<div class="field-head">
|
||||
<div>
|
||||
<h2 class="field-label">Image de couverture</h2>
|
||||
<p class="field-help">Choisis une image si tu veux une couverture.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-picker">
|
||||
<check if="{{ @coverPreview }}">
|
||||
<true>
|
||||
<img class="media-frame media-frame--large cover-preview" data-cover-preview src="{{ @coverPreview.url }}" alt="">
|
||||
<div class="media-frame media-frame--large media-frame--placeholder is-hidden" data-cover-placeholder>Aucune image</div>
|
||||
</true>
|
||||
<false>
|
||||
<div class="media-frame media-frame--large media-frame--placeholder" data-cover-placeholder>Aucune image</div>
|
||||
<img class="media-frame media-frame--large cover-preview is-hidden" data-cover-preview alt="Aperçu couverture">
|
||||
</false>
|
||||
</check>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="button button--ghost" type="button" data-media-picker-open="cover">Choisir une image</button>
|
||||
<button class="button button--ghost" type="button" data-cover-clear {{ @post.cover_media_id ? '' : 'disabled' }}>Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="field">
|
||||
<div class="field-head">
|
||||
<div>
|
||||
<h2 class="field-label">Contenu</h2>
|
||||
<p class="field-help">Markdown simple, avec insertion d’image au curseur.</p>
|
||||
<p class="field-help">Markdown simple, avec insertion d’image au curseur. La première image sert aussi de vignette sur les cartes d’article.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,10 +39,11 @@
|
||||
<button class="tool-button" type="button" data-md-action="quote">Citation</button>
|
||||
<button class="tool-button" type="button" data-md-action="link">Lien</button>
|
||||
<button class="tool-button" type="button" data-md-action="code">Code</button>
|
||||
<button class="tool-button" type="button" data-media-picker-open="markdown">Image</button>
|
||||
<button class="tool-button" type="button" data-media-picker-open>Image</button>
|
||||
</div>
|
||||
|
||||
<textarea class="control editor-textarea" name="body_markdown" rows="18" required data-markdown-editor>{{ @post.body_markdown }}</textarea>
|
||||
<p class="field-help">Astuce : avec le parseur Markdown de F3, laisse une ligne vide entre deux blocs (titre, liste, citation, image, code) pour un rendu fiable.</p>
|
||||
</section>
|
||||
|
||||
<button class="button" type="submit">Enregistrer</button>
|
||||
@@ -79,8 +52,8 @@
|
||||
<aside class="media-picker is-hidden" data-media-picker>
|
||||
<div class="media-picker__head">
|
||||
<div>
|
||||
<strong data-media-picker-title>Choisir une image</strong>
|
||||
<p class="field-help" data-media-picker-help>Choisis une image de la médiathèque.</p>
|
||||
<strong data-media-picker-title>Insérer une image</strong>
|
||||
<p class="field-help" data-media-picker-help>Clique sur une image pour l’insérer dans l’article.</p>
|
||||
</div>
|
||||
<button class="button button--ghost button--small" type="button" data-media-picker-close>Fermer</button>
|
||||
</div>
|
||||
@@ -89,7 +62,7 @@
|
||||
<true>
|
||||
<div class="media-picker__grid">
|
||||
<repeat group="{{ @mediaItems }}" value="{{ @item }}">
|
||||
<button class="media-picker__item" type="button" data-media-picker-select data-media-id="{{ @item.id }}" data-media-url="{{ @item.url }}" data-media-markdown="{{ @item.markdown }}">
|
||||
<button class="media-picker__item" type="button" data-media-picker-select data-media-markdown="{{ @item.markdown }}">
|
||||
<img class="media-frame media-frame--square" src="{{ @item.url }}" alt="">
|
||||
</button>
|
||||
</repeat>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<article class="card article-card">
|
||||
<article class="card card--stack">
|
||||
<img class="media-frame" src="{{ @item.url }}" alt="{{ @item.alt }}">
|
||||
<div class="card-body article-card__body">
|
||||
<div class="card-body">
|
||||
<p class="meta-text">{{ @item.width }} × {{ @item.height }}<br>{{ @item.created_at | date_fr }}</p>
|
||||
|
||||
<form class="stack" method="post" action="{{ 'media_update_alt', 'id='.@item.id | alias }}">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<check if="{{ @currentUser }}">
|
||||
<true>
|
||||
<li class="nav-items__item">
|
||||
<a class="nav-items__link" href="{{ 'dashboard' | alias }}">Dashboard</a>
|
||||
<a class="nav-items__link" href="{{ 'dashboard' | alias }}">Tableau de bord</a>
|
||||
</li>
|
||||
<li class="nav-items__item">
|
||||
<form class="nav-items__form" method="post" action="{{ 'logout' | alias }}">
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<article class="card article-card">
|
||||
<check if="{{ @post.cover_url }}">
|
||||
<article class="card card--stack">
|
||||
<check if="{{ @post.thumbnail_url }}">
|
||||
<true>
|
||||
<a class="card-media-link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">
|
||||
<img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.cover_alt ?: @post.title }}">
|
||||
</a>
|
||||
<img class="media-frame" src="{{ @post.thumbnail_url }}" alt="{{ @post.thumbnail_alt ?: @post.title }}">
|
||||
</true>
|
||||
<false>
|
||||
<check if="{{ @adminMode }}">
|
||||
@@ -13,10 +11,8 @@
|
||||
</check>
|
||||
</false>
|
||||
</check>
|
||||
<div class="card-body article-card__body">
|
||||
<h2 class="card-title">
|
||||
<a class="card-title__link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">{{ @post.title }}</a>
|
||||
</h2>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ @post.title }}</h2>
|
||||
<p class="meta-text">
|
||||
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time>
|
||||
<check if="{{ @post.updated_at !== @post.created_at }}">
|
||||
@@ -34,6 +30,11 @@
|
||||
</form>
|
||||
</div>
|
||||
</true>
|
||||
<false>
|
||||
<div class="card-actions">
|
||||
<a class="button button--ghost" href="{{ 'post_show', 'slug='.@post.slug | alias }}">Lire l'article</a>
|
||||
</div>
|
||||
</false>
|
||||
</check>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -9,12 +9,5 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<check if="{{ @post.cover_url }}">
|
||||
<true>
|
||||
<img class="media-frame media-frame--large article-cover" src="{{ @post.cover_url }}"
|
||||
alt="{{ @post.cover_alt ?: @post.title }}">
|
||||
</true>
|
||||
</check>
|
||||
|
||||
<div class="prose">{{ @post.body_html | raw }}</div>
|
||||
</article>
|
||||
</article>
|
||||
|
||||
@@ -2,89 +2,79 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/Helpers/App.php';
|
||||
require __DIR__ . '/Helpers/Error.php';
|
||||
require __DIR__ . '/helpers.php';
|
||||
|
||||
$f3 = Base::instance();
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$f3->set('AUTOLOAD', $root . '/app/Controllers/;' . $root . '/app/Models/;' . $root . '/app/Services/');
|
||||
|
||||
$f3->set('AUTOLOAD', implode(';', [
|
||||
$root . '/app/Controllers/',
|
||||
$root . '/app/Models/',
|
||||
$root . '/app/Services/',
|
||||
]));
|
||||
$f3->set('UI', $root . '/app/Views/');
|
||||
$f3->set('TEMP', $root . '/tmp/');
|
||||
$f3->set('LOGS', $root . '/logs/');
|
||||
$f3->mset([
|
||||
'paths.db' => $root . '/db/app.sqlite',
|
||||
'paths.media_dir' => $root . '/public/uploads/media',
|
||||
'paths.media_base' => '/uploads/media/',
|
||||
]);
|
||||
$f3->set('paths.db', $root . '/db/app.sqlite');
|
||||
$f3->set('paths.media_dir', $root . '/public/uploads/media');
|
||||
$f3->set('paths.media_base', '/uploads/media');
|
||||
|
||||
$f3->config($root . '/app/config.ini');
|
||||
|
||||
$localConfig = $root . '/config.local.ini';
|
||||
if (is_file($localConfig)) {
|
||||
$f3->config($localConfig);
|
||||
if (is_file($root . '/config.local.ini')) {
|
||||
$f3->config($root . '/config.local.ini');
|
||||
}
|
||||
|
||||
$f3->set('TZ', app_timezone());
|
||||
$f3->set('DEBUG', app_is_prod() ? 0 : 3);
|
||||
date_default_timezone_set(app_timezone((string) $f3->get('app.timezone')));
|
||||
$f3->set('TZ', date_default_timezone_get());
|
||||
$f3->set('DEBUG', $f3->get('app.env') === 'prod' ? 0 : 3);
|
||||
|
||||
app_ensure_dir((string) $f3->get('TEMP'));
|
||||
app_ensure_dir((string) $f3->get('LOGS'));
|
||||
app_ensure_dir((string) $f3->get('paths.media_dir'));
|
||||
// Web::receive() utilise UPLOADS directement — le résoudre en absolu.
|
||||
$f3->set('UPLOADS', $root . '/' . ltrim((string) $f3->get('UPLOADS'), '/'));
|
||||
app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/'));
|
||||
app_bootstrap_logging();
|
||||
foreach ([(string) $f3->get('TEMP'), (string) $f3->get('LOGS'), dirname((string) $f3->get('paths.db')), (string) $f3->get('paths.media_dir')] as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Base de données ─────────────────────────────────────────────────
|
||||
$uploads = $root . '/' . trim((string) $f3->get('UPLOADS'), '/');
|
||||
$f3->set('UPLOADS', $uploads . '/');
|
||||
if (!is_dir($uploads)) {
|
||||
mkdir($uploads, 0775, true);
|
||||
}
|
||||
|
||||
$dbPath = (string) $f3->get('paths.db');
|
||||
app_ensure_dir(dirname($dbPath));
|
||||
ini_set('log_errors', '1');
|
||||
ini_set('error_log', rtrim((string) $f3->get('LOGS'), '/\\') . '/php-error.log');
|
||||
ini_set('display_errors', $f3->get('app.env') === 'prod' ? '0' : '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
$db = new DB\SQL(
|
||||
'sqlite:' . $dbPath,
|
||||
null,
|
||||
null,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_TIMEOUT => 5,
|
||||
]
|
||||
);
|
||||
$db = new DB\SQL('sqlite:' . $f3->get('paths.db'));
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$f3->set('DB', $db);
|
||||
|
||||
// ── Session ─────────────────────────────────────────────────────────
|
||||
|
||||
// Derrière Caddy, Apache voit souvent du HTTP interne.
|
||||
// On normalise donc le schéma depuis X-Forwarded-Proto uniquement si
|
||||
// la requête provient d'un proxy explicitement approuvé.
|
||||
$requestScheme = app_request_scheme();
|
||||
$f3->set('SCHEME', $requestScheme);
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
ini_set('session.cookie_secure', $requestScheme === 'https' ? '1' : '0');
|
||||
$secure = app_request_is_secure();
|
||||
session_name((string) $f3->get('app.session_name'));
|
||||
$f3->set('JAR', [
|
||||
'expire' => 0,
|
||||
'path' => '/',
|
||||
'secure' => $requestScheme === 'https',
|
||||
'expire' => 0,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
new Session(null, 'CSRF');
|
||||
|
||||
// ── Template ────────────────────────────────────────────────────────
|
||||
Template::instance()->filter('date_fr', 'app_date_fr');
|
||||
|
||||
Template::instance()->filter('date_fr', 'app_format_datetime_fr');
|
||||
|
||||
// ── Erreurs ─────────────────────────────────────────────────────────
|
||||
|
||||
app_bootstrap_errors($f3);
|
||||
if ($f3->get('app.env') === 'prod') {
|
||||
$f3->set('ONERROR', function (Base $f3): void {
|
||||
$code = max(1, (int) ($f3->get('ERROR.code') ?: 500));
|
||||
$meta = app_error_meta($code);
|
||||
$f3->status($code);
|
||||
$f3->expire(0);
|
||||
$f3->mset([
|
||||
'errorCode' => $code,
|
||||
'errorTitle' => $meta['title'],
|
||||
'errorMessage' => $meta['message'],
|
||||
]);
|
||||
echo Template::instance()->render('errors/error.html');
|
||||
});
|
||||
}
|
||||
|
||||
return $f3;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
[globals]
|
||||
app.env=dev
|
||||
app.timezone=UTC
|
||||
app.timezone=Europe/Paris
|
||||
app.name=F3 Simple Blog
|
||||
app.tagline=Blog simple avec Fat-Free Framework et SQLite.
|
||||
app.session_name=f3-simple-blog
|
||||
app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12
|
||||
|
||||
UPLOADS=tmp/uploads/
|
||||
CACHE=folder
|
||||
|
||||
app.name=F3 Simple Blog
|
||||
app.tagline=Blog simple avec Fat-Free Framework et SQLite.
|
||||
|
||||
[routes]
|
||||
GET @home: /=SiteController->home
|
||||
GET @post_show: /posts/@slug=SiteController->show
|
||||
@@ -18,14 +16,14 @@ GET @login: /login=AuthController->show
|
||||
POST @login_submit: /login=AuthController->login
|
||||
POST @logout: /logout=AuthController->logout
|
||||
|
||||
GET @dashboard: /dashboard=PostController->index
|
||||
GET @post_create: /dashboard/posts/create=PostController->create
|
||||
POST @post_store: /dashboard/posts=PostController->store
|
||||
GET @post_edit: /dashboard/posts/@id/edit=PostController->edit
|
||||
POST @post_update: /dashboard/posts/@id/update=PostController->update
|
||||
POST @post_delete: /dashboard/posts/@id/delete=PostController->delete
|
||||
GET @dashboard: /dashboard=AdminController->index
|
||||
GET @post_create: /dashboard/posts/create=AdminController->create
|
||||
POST @post_store: /dashboard/posts=AdminController->store
|
||||
GET @post_edit: /dashboard/posts/@id/edit=AdminController->edit
|
||||
POST @post_update: /dashboard/posts/@id/update=AdminController->update
|
||||
POST @post_delete: /dashboard/posts/@id/delete=AdminController->delete
|
||||
|
||||
GET @media_index: /dashboard/media=MediaController->index
|
||||
POST @media_upload: /dashboard/media=MediaController->upload
|
||||
POST @media_update_alt: /dashboard/media/@id/alt=MediaController->updateAlt
|
||||
POST @media_delete: /dashboard/media/@id/delete=MediaController->delete
|
||||
GET @media_index: /dashboard/media=AdminController->media
|
||||
POST @media_upload: /dashboard/media=AdminController->mediaUpload
|
||||
POST @media_update_alt: /dashboard/media/@id/alt=AdminController->mediaAlt
|
||||
POST @media_delete: /dashboard/media/@id/delete=AdminController->mediaDelete
|
||||
|
||||
79
app/helpers.php
Normal file
79
app/helpers.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function app_timezone(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
return in_array($value, DateTimeZone::listIdentifiers(), true) ? $value : 'UTC';
|
||||
}
|
||||
|
||||
function app_now(): string
|
||||
{
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
function app_slug(string $value): string
|
||||
{
|
||||
$slug = Web::instance()->slug(trim($value));
|
||||
return $slug !== '' ? $slug : 'article';
|
||||
}
|
||||
|
||||
function app_date_fr(string $value): string
|
||||
{
|
||||
static $formatter = null;
|
||||
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||||
$date = $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
|
||||
|
||||
if (!$formatter instanceof IntlDateFormatter) {
|
||||
$formatter = new IntlDateFormatter(
|
||||
'fr_FR',
|
||||
IntlDateFormatter::LONG,
|
||||
IntlDateFormatter::SHORT,
|
||||
date_default_timezone_get(),
|
||||
IntlDateFormatter::GREGORIAN,
|
||||
"d MMMM yyyy 'à' HH:mm"
|
||||
);
|
||||
}
|
||||
|
||||
return (string) ($formatter->format($date) ?: $value);
|
||||
} catch (Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
function app_error_meta(int $code): array
|
||||
{
|
||||
return match ($code) {
|
||||
400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'],
|
||||
403 => ['title' => 'Accès refusé', 'message' => 'Tu n’as pas accès à cette ressource.'],
|
||||
404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'],
|
||||
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function app_request_is_secure(): bool
|
||||
{
|
||||
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strtolower((string) ($_SERVER['REQUEST_SCHEME'] ?? '')) === 'https') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$forwardedProto = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
|
||||
if ($forwardedProto !== '') {
|
||||
return explode(',', $forwardedProto)[0] === 'https';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user