First commit
This commit is contained in:
30
app/Controllers/AssetController.php
Normal file
30
app/Controllers/AssetController.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class AssetController extends BaseController
|
||||
{
|
||||
private const ALLOWED = [
|
||||
'app.css' => 'text/css',
|
||||
'app.js' => 'application/javascript',
|
||||
];
|
||||
|
||||
public function serve(): void
|
||||
{
|
||||
$file = basename((string) $this->f3->get('PARAMS.file'));
|
||||
|
||||
if (!array_key_exists($file, self::ALLOWED)) {
|
||||
$this->f3->error(404);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->f3->expire(86400); // 24 h côté navigateur
|
||||
|
||||
echo Web::instance()->minify(
|
||||
$file,
|
||||
self::ALLOWED[$file],
|
||||
true, // envoie le Content-Type
|
||||
app_root() . '/public/assets/' // répertoire source (hors UI)
|
||||
);
|
||||
}
|
||||
}
|
||||
47
app/Controllers/AuthController.php
Normal file
47
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
public function show(): void
|
||||
{
|
||||
if ($this->currentUser() !== null) {
|
||||
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||
return;
|
||||
}
|
||||
|
||||
$this->f3->expire(0);
|
||||
$this->render('auth/login.html', ['pageTitle' => 'Connexion']);
|
||||
}
|
||||
|
||||
public function login(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
|
||||
$username = trim((string) ($this->f3->get('POST.username') ?? ''));
|
||||
$password = (string) ($this->f3->get('POST.password') ?? '');
|
||||
|
||||
$user = (new User($this->db))->findByUsername($username);
|
||||
if ($user === null || !password_verify($password, $user['password_hash'])) {
|
||||
usleep(1_500_000); // 1,5 s — ralentit le brute-force
|
||||
$this->flash('error', 'Identifiants invalides.');
|
||||
$this->f3->reroute($this->f3->alias('login'));
|
||||
return;
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
$this->f3->set('SESSION.user_id', $user['id']);
|
||||
$this->flash('success', 'Connexion réussie.');
|
||||
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
$this->f3->clear('SESSION.user_id');
|
||||
session_regenerate_id(true);
|
||||
$this->flash('success', 'Déconnexion effectuée.');
|
||||
$this->f3->reroute($this->f3->alias('home'));
|
||||
}
|
||||
}
|
||||
79
app/Controllers/BaseController.php
Normal file
79
app/Controllers/BaseController.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
abstract class BaseController
|
||||
{
|
||||
protected Base $f3;
|
||||
protected DB\SQL $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->f3 = Base::instance();
|
||||
$this->db = $this->f3->get('DB');
|
||||
}
|
||||
|
||||
protected function render(string $view, array $data = []): void
|
||||
{
|
||||
$this->f3->mset($data + [
|
||||
'view' => $view,
|
||||
'currentUser' => $this->currentUser(),
|
||||
'flash' => $this->pullFlash(),
|
||||
'csrfToken' => $this->csrfToken(),
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
protected function requireAuth(): void
|
||||
{
|
||||
if ($this->currentUser() !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->flash('error', 'Connecte-toi pour continuer.');
|
||||
$this->f3->reroute($this->f3->alias('login'));
|
||||
}
|
||||
|
||||
protected function csrfToken(): string
|
||||
{
|
||||
// Génère un token CSRF et le stocke en session au premier appel.
|
||||
$token = (string) ($this->f3->get('SESSION.csrf_token') ?? '');
|
||||
if ($token === '') {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$this->f3->set('SESSION.csrf_token', $token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
protected function verifyCsrf(): void
|
||||
{
|
||||
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');
|
||||
$expected = (string) ($this->f3->get('SESSION.csrf_token') ?? '');
|
||||
|
||||
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->f3->error(400, 'Jeton CSRF invalide.');
|
||||
}
|
||||
|
||||
protected function flash(string $type, string $message): void
|
||||
{
|
||||
$this->f3->set('SESSION.flash', ['type' => $type, 'message' => $message]);
|
||||
}
|
||||
|
||||
private function pullFlash(): ?array
|
||||
{
|
||||
$flash = $this->f3->get('SESSION.flash');
|
||||
$this->f3->clear('SESSION.flash');
|
||||
return is_array($flash) ? $flash : null;
|
||||
}
|
||||
}
|
||||
22
app/Controllers/DashboardController.php
Normal file
22
app/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class DashboardController extends BaseController
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->f3->expire(0);
|
||||
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||
$result = (new Post($this->db))->paginateList($page, 24);
|
||||
|
||||
$this->render('admin/dashboard.html', [
|
||||
'pageTitle' => 'Tableau de bord',
|
||||
'posts' => $result['posts'],
|
||||
'pagination' => $result,
|
||||
'paginationBase' => $this->f3->alias('dashboard'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
84
app/Controllers/MediaController.php
Normal file
84
app/Controllers/MediaController.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class MediaController extends BaseController
|
||||
{
|
||||
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->f3->expire(0);
|
||||
|
||||
$this->render('admin/media.html', [
|
||||
'pageTitle' => 'Médiathèque',
|
||||
'items' => (new Media($this->db))->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function upload(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$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(
|
||||
fn(array $file): bool => $file['size'] <= self::UPLOAD_MAX_BYTES,
|
||||
overwrite: false,
|
||||
slug: true
|
||||
);
|
||||
|
||||
// UPLOADS étant absolu (bootstrap.php), les chemins retournés le sont aussi.
|
||||
$accepted = array_keys(array_filter($received));
|
||||
|
||||
if ($accepted === []) {
|
||||
throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int)(self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).');
|
||||
}
|
||||
|
||||
foreach ($accepted as $destPath) {
|
||||
(new Media($this->db))->upload($destPath, $originalName);
|
||||
}
|
||||
|
||||
$this->flash('success', 'Image ajoutée.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute($this->f3->alias('media_index'));
|
||||
}
|
||||
|
||||
public function updateAlt(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
$alt = trim((string) ($this->f3->get('POST.alt') ?? ''));
|
||||
(new Media($this->db))->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($this->f3->alias('media_index'));
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
(new Media($this->db))->delete((int) $this->f3->get('PARAMS.id'));
|
||||
$this->flash('success', 'Image supprimée.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute($this->f3->alias('media_index'));
|
||||
}
|
||||
}
|
||||
110
app/Controllers/PostController.php
Normal file
110
app/Controllers/PostController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class PostController extends BaseController
|
||||
{
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->f3->expire(0);
|
||||
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), Post::emptyForm());
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
|
||||
$input = $this->postInput();
|
||||
|
||||
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($this->f3->alias('dashboard'));
|
||||
} catch (RuntimeException $e) {
|
||||
$this->f3->expire(0);
|
||||
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function edit(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->f3->expire(0);
|
||||
|
||||
$post = (new Post($this->db))->findForEdit((int) $this->f3->get('PARAMS.id'));
|
||||
if ($post === null) {
|
||||
$this->f3->error(404, 'Article introuvable.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
|
||||
$id = (int) $this->f3->get('PARAMS.id');
|
||||
$input = $this->postInput() + ['id' => $id];
|
||||
|
||||
try {
|
||||
$updated = (new Post($this->db))->updatePost($id, $input);
|
||||
if (!$updated) {
|
||||
$this->f3->error(404, 'Article introuvable.');
|
||||
return;
|
||||
}
|
||||
|
||||
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
|
||||
$this->flash('success', 'Article mis à jour.');
|
||||
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||
} catch (RuntimeException $e) {
|
||||
$this->f3->expire(0);
|
||||
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
(new Post($this->db))->delete((int) $this->f3->get('PARAMS.id'));
|
||||
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
|
||||
$this->flash('success', 'Article supprimé.');
|
||||
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||
}
|
||||
|
||||
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null): void
|
||||
{
|
||||
$coverPreview = null;
|
||||
if (!empty($post['cover_media_id'])) {
|
||||
$coverPreview = (new Media($this->db))->findById((int) $post['cover_media_id']);
|
||||
}
|
||||
|
||||
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;
|
||||
|
||||
$this->render('admin/post_form.html', [
|
||||
'pageTitle' => $pageTitle,
|
||||
'formAction' => $formAction,
|
||||
'post' => $post,
|
||||
'coverPreview' => $coverPreview,
|
||||
'mediaItems' => (new Media($this->db))->all(),
|
||||
'titleMax' => Post::TITLE_MAX_LENGTH,
|
||||
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
|
||||
'flash' => $flash,
|
||||
]);
|
||||
}
|
||||
|
||||
private function postInput(): array
|
||||
{
|
||||
return [
|
||||
'title' => trim((string) ($this->f3->get('POST.title') ?? '')),
|
||||
'excerpt' => trim((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') ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Controllers/SiteController.php
Normal file
37
app/Controllers/SiteController.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class SiteController extends BaseController
|
||||
{
|
||||
public function home(): void
|
||||
{
|
||||
$this->f3->expire(300); // 5 min — page publique, contenu peu volatile
|
||||
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||
$result = (new Post($this->db))->paginateList($page);
|
||||
|
||||
$this->render('site/home.html', [
|
||||
'pageTitle' => 'Accueil',
|
||||
'posts' => $result['posts'],
|
||||
'pagination' => $result,
|
||||
'paginationBase' => $this->f3->alias('home'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(): void
|
||||
{
|
||||
$this->f3->expire(3600); // 1 h — les articles bougent rarement
|
||||
|
||||
$post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug'));
|
||||
if ($post === null) {
|
||||
$this->f3->error(404, 'Article introuvable.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->render('site/post.html', [
|
||||
'pageTitle' => $post['title'],
|
||||
'post' => $post,
|
||||
]);
|
||||
}
|
||||
}
|
||||
132
app/Helpers/App.php
Normal file
132
app/Helpers/App.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Core ────────────────────────────────────────────────────────────
|
||||
|
||||
function app_root(): string
|
||||
{
|
||||
return dirname(__DIR__, 2);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// ── Fichiers et chemins ─────────────────────────────────────────────
|
||||
|
||||
function app_ensure_dir(string $path): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0775, true);
|
||||
}
|
||||
}
|
||||
|
||||
function app_db_path(): string
|
||||
{
|
||||
return app_root() . '/db/app.sqlite';
|
||||
}
|
||||
|
||||
function app_logs_dir(): string
|
||||
{
|
||||
return app_root() . '/logs';
|
||||
}
|
||||
|
||||
function app_public_media_dir(): string
|
||||
{
|
||||
return app_root() . '/public/uploads/media';
|
||||
}
|
||||
|
||||
function app_media_url(string $fileName): string
|
||||
{
|
||||
return rtrim((string) Base::instance()->get('BASE'), '/') . '/uploads/media/' . rawurlencode($fileName);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
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()));
|
||||
|
||||
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')
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
162
app/Helpers/Error.php
Normal file
162
app/Helpers/Error.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
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_request_summary(): string
|
||||
{
|
||||
$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
|
||||
);
|
||||
}
|
||||
|
||||
function app_render_error_fallback(int $code): void
|
||||
{
|
||||
$f3 = Base::instance();
|
||||
$meta = app_error_meta($code);
|
||||
$base = rtrim((string) $f3->get('BASE'), '/');
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
$f3->status($code);
|
||||
$f3->expire(0);
|
||||
|
||||
if (!headers_sent()) {
|
||||
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
|
||||
{
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$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 $exception) {
|
||||
app_log_error(500, 'Internal Server Error', 'Error template rendering failed.', $exception);
|
||||
app_render_error_fallback($code);
|
||||
}
|
||||
});
|
||||
}
|
||||
155
app/Models/Media.php
Normal file
155
app/Models/Media.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Media extends DB\SQL\Mapper
|
||||
{
|
||||
public function __construct(DB\SQL $db)
|
||||
{
|
||||
parent::__construct($db, 'media');
|
||||
}
|
||||
|
||||
public static function bootstrap(DB\SQL $db): void
|
||||
{
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_name TEXT NOT NULL UNIQUE,
|
||||
alt TEXT NOT NULL DEFAULT \'\',
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)');
|
||||
$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 findById(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->load(['id = ?', $id]);
|
||||
return $this->dry() ? null : $this->decorate($this->cast());
|
||||
}
|
||||
|
||||
public function findByFileName(string $fileName): ?array
|
||||
{
|
||||
$this->load(['file_name = ?', $fileName]);
|
||||
return $this->dry() ? null : $this->decorate($this->cast());
|
||||
}
|
||||
|
||||
// Reçoit le chemin absolu déposé par Web::receive() et le nom d'origine
|
||||
// pour dériver un texte alternatif lisible.
|
||||
public function upload(string $srcPath, string $originalName = ''): int
|
||||
{
|
||||
// Image::dump() gère le chargement, la transparence et la compression.
|
||||
// $path='' indique à F3 que le chemin est absolu.
|
||||
try {
|
||||
$img = new Image($srcPath, false, '');
|
||||
} catch (Throwable) {
|
||||
throw new RuntimeException('Fichier image invalide ou format non supporté (JPG, PNG, WebP).');
|
||||
}
|
||||
|
||||
$data = $img->dump('png', 9); // sans perte, compression maximale
|
||||
|
||||
// Supprimer le fichier intermédiaire déposé par Web::receive().
|
||||
@unlink($srcPath);
|
||||
|
||||
$fileName = bin2hex(random_bytes(16)) . '.png';
|
||||
$target = app_public_media_dir() . '/' . $fileName;
|
||||
|
||||
if (!Base::instance()->write($target, $data)) {
|
||||
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
}
|
||||
|
||||
$this->reset();
|
||||
$this->file_name = $fileName;
|
||||
$this->alt = $originalName !== '' ? self::altFromFilename($originalName) : '';
|
||||
$this->width = $img->width();
|
||||
$this->height = $img->height();
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
|
||||
return (int) $this->get('id');
|
||||
}
|
||||
|
||||
public function updateAlt(int $id, string $alt): void
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
throw new RuntimeException('Image introuvable.');
|
||||
}
|
||||
|
||||
$this->alt = trim($alt);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$item = $this->findById($id);
|
||||
if ($item === null) {
|
||||
throw new RuntimeException('Image introuvable.');
|
||||
}
|
||||
|
||||
if ($this->isUsed($item)) {
|
||||
throw new RuntimeException('Cette image est encore utilisée par un article.');
|
||||
}
|
||||
|
||||
$path = app_public_media_dir() . '/' . $item['file_name'];
|
||||
|
||||
$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.');
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Une seule requête SQL pour les deux cas d'utilisation (couverture et body).
|
||||
private function isUsed(array $item): bool
|
||||
{
|
||||
return $this->db->exec(
|
||||
'SELECT 1 FROM posts WHERE cover_media_id = ? OR body_markdown LIKE ? LIMIT 1',
|
||||
[$item['id'], '%media:' . $item['file_name'] . '%']
|
||||
) !== [];
|
||||
}
|
||||
|
||||
private function decorate(array $row): array
|
||||
{
|
||||
$alt = (string) $row['alt'];
|
||||
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'file_name' => (string) $row['file_name'],
|
||||
'alt' => $alt,
|
||||
'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' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
223
app/Models/Post.php
Normal file
223
app/Models/Post.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Post extends DB\SQL\Mapper
|
||||
{
|
||||
public const TITLE_MAX_LENGTH = 120;
|
||||
public const EXCERPT_MAX_LENGTH = 240;
|
||||
|
||||
public function __construct(DB\SQL $db)
|
||||
{
|
||||
parent::__construct($db, 'posts');
|
||||
}
|
||||
|
||||
public static function bootstrap(DB\SQL $db): void
|
||||
{
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
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
|
||||
)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC)');
|
||||
}
|
||||
|
||||
public static function emptyForm(): array
|
||||
{
|
||||
return [
|
||||
'title' => '',
|
||||
'excerpt' => '',
|
||||
'cover_media_id' => '',
|
||||
'body_markdown' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function paginateList(int $page = 1, int $perPage = 12): 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']);
|
||||
$covers = $this->loadCovers($posts);
|
||||
|
||||
foreach ($posts as &$post) {
|
||||
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
||||
}
|
||||
|
||||
return [
|
||||
'posts' => $posts,
|
||||
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||||
'pages' => $result['count'] ?: 1,
|
||||
];
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?array
|
||||
{
|
||||
$this->load(['slug = ?', $slug]);
|
||||
if ($this->dry()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$post = $this->summaryRow($this->cast());
|
||||
$covers = $this->loadCovers([$post]);
|
||||
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
||||
$post['body_html'] = (string) $this->body_html;
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function findForEdit(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $this->get('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): int
|
||||
{
|
||||
$payload = $this->payload($input);
|
||||
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->slugExists($candidate));
|
||||
$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): bool
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $this->payload($input);
|
||||
$this->copyfrom($payload + ['updated_at' => app_now()]);
|
||||
$this->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->load(['id = ?', $id]);
|
||||
if (!$this->dry()) {
|
||||
$this->erase();
|
||||
}
|
||||
}
|
||||
|
||||
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'] ?? ''));
|
||||
|
||||
if ($title === '') {
|
||||
throw new RuntimeException('Ajoute un titre.');
|
||||
}
|
||||
if (mb_strlen($title) > self::TITLE_MAX_LENGTH) {
|
||||
throw new RuntimeException('Le titre est trop long.');
|
||||
}
|
||||
if ($excerpt === '') {
|
||||
throw new RuntimeException('Ajoute un extrait.');
|
||||
}
|
||||
if (mb_strlen($excerpt) > self::EXCERPT_MAX_LENGTH) {
|
||||
throw new RuntimeException("L'extrait est trop long.");
|
||||
}
|
||||
|
||||
$media = new Media($this->db);
|
||||
|
||||
$coverId = null;
|
||||
if ($coverMediaId !== '') {
|
||||
$coverId = (int) $coverMediaId;
|
||||
if ($media->findById($coverId) === null) {
|
||||
throw new RuntimeException('Image de couverture introuvable.');
|
||||
}
|
||||
}
|
||||
|
||||
$bodyHtml = MarkdownService::compile($bodyMarkdown, $media);
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'excerpt' => $excerpt,
|
||||
'body_markdown' => $bodyMarkdown,
|
||||
'body_html' => $bodyHtml,
|
||||
'cover_media_id' => $coverId,
|
||||
];
|
||||
}
|
||||
|
||||
private function slugExists(string $slug): bool
|
||||
{
|
||||
return $this->count(['slug = ?', $slug]) > 0;
|
||||
}
|
||||
|
||||
private function summaryRow(array $row): array
|
||||
{
|
||||
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),
|
||||
'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'],
|
||||
];
|
||||
}
|
||||
|
||||
private function loadCovers(array $posts): array
|
||||
{
|
||||
$ids = array_filter(array_unique(array_column($posts, 'cover_media_id')));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$rows = $this->db->exec(
|
||||
"SELECT id, file_name FROM media WHERE id IN ($placeholders)",
|
||||
array_values($ids)
|
||||
);
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int) $row['id']] = $row;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
67
app/Models/User.php
Normal file
67
app/Models/User.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class User extends DB\SQL\Mapper
|
||||
{
|
||||
public function __construct(DB\SQL $db)
|
||||
{
|
||||
parent::__construct($db, 'users');
|
||||
}
|
||||
|
||||
public static function bootstrap(DB\SQL $db): void
|
||||
{
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)');
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->load(['id = ?', $id]);
|
||||
if ($this->dry()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->cast();
|
||||
unset($data['password_hash']);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function findByUsername(string $username): ?array
|
||||
{
|
||||
$this->load(['username = ?', $username]);
|
||||
return $this->dry() ? null : $this->cast();
|
||||
}
|
||||
|
||||
public function create(string $username, string $password): int
|
||||
{
|
||||
$username = trim($username);
|
||||
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) {
|
||||
throw new RuntimeException('Cet utilisateur existe déjà.');
|
||||
}
|
||||
|
||||
$this->reset();
|
||||
$this->username = $username;
|
||||
$this->password_hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
|
||||
return (int) $this->get('id');
|
||||
}
|
||||
}
|
||||
192
app/Services/MarkdownService.php
Normal file
192
app/Services/MarkdownService.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class MarkdownService
|
||||
{
|
||||
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_ATTRS = [
|
||||
'a' => ['href', 'title', 'rel', 'target'],
|
||||
'img' => ['src', 'alt', 'title', 'loading', 'decoding'],
|
||||
];
|
||||
|
||||
public static function compile(string $markdown, Media $media): string
|
||||
{
|
||||
$markdown = trim($markdown);
|
||||
if ($markdown === '') {
|
||||
throw new RuntimeException('Ajoute du contenu avant de publier.');
|
||||
}
|
||||
|
||||
$markdown = self::normalizeMarkdown($markdown);
|
||||
$html = Markdown::instance()->convert($markdown);
|
||||
$html = self::sanitizeAndResolve($html, $media);
|
||||
|
||||
if (trim(strip_tags($html)) === '' && !preg_match('/<(img|video|audio|figure)[\s>]/i', $html)) {
|
||||
$fallback = nl2br(htmlspecialchars($markdown, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||||
$html = '<p>' . str_replace('<br />', '</p><p>', $fallback) . '</p>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Passe DOM unique : sanitise les balises/attributs et résout les références media:.
|
||||
private static function sanitizeAndResolve(string $html, Media $media): string
|
||||
{
|
||||
$dom = new DOMDocument('1.0', 'UTF-8');
|
||||
libxml_use_internal_errors(true);
|
||||
$dom->loadHTML('<?xml encoding="UTF-8"><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$body = $dom->getElementsByTagName('body')->item(0);
|
||||
if (!$body instanceof DOMElement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
self::processNode($body, $media);
|
||||
|
||||
$out = '';
|
||||
foreach ($body->childNodes as $child) {
|
||||
$out .= $dom->saveHTML($child);
|
||||
}
|
||||
|
||||
return trim($out);
|
||||
}
|
||||
|
||||
private static function processNode(DOMNode $parent, Media $media): void
|
||||
{
|
||||
for ($i = $parent->childNodes->length - 1; $i >= 0; $i--) {
|
||||
$child = $parent->childNodes->item($i);
|
||||
if ($child === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($child instanceof DOMComment) {
|
||||
$parent->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($child instanceof DOMText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$child instanceof DOMElement) {
|
||||
$parent->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!in_array($child->tagName, self::ALLOWED_TAGS, true)) {
|
||||
self::unwrap($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
self::sanitizeAttributes($child, $media);
|
||||
|
||||
// img may have been removed by sanitizeAttributes
|
||||
if ($child->parentNode !== null) {
|
||||
self::processNode($child, $media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function sanitizeAttributes(DOMElement $element, Media $media): void
|
||||
{
|
||||
$allowed = self::ALLOWED_ATTRS[$element->tagName] ?? [];
|
||||
$toRemove = [];
|
||||
foreach ($element->attributes as $attribute) {
|
||||
if (!in_array($attribute->name, $allowed, true)) {
|
||||
$toRemove[] = $attribute->name;
|
||||
}
|
||||
}
|
||||
foreach ($toRemove as $name) {
|
||||
$element->removeAttribute($name);
|
||||
}
|
||||
|
||||
if ($element->tagName === 'a') {
|
||||
$href = trim($element->getAttribute('href'));
|
||||
if ($href === '' || !preg_match('~^(https?:|mailto:|tel:|/)~i', $href)) {
|
||||
$element->removeAttribute('href');
|
||||
} else {
|
||||
$element->setAttribute('rel', 'noopener noreferrer');
|
||||
if (preg_match('~^https?://~i', $href)) {
|
||||
$element->setAttribute('target', '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($element->tagName === 'img') {
|
||||
$src = trim($element->getAttribute('src'));
|
||||
if ($src === '' || !str_starts_with($src, 'media:')) {
|
||||
$element->parentNode?->removeChild($element);
|
||||
return;
|
||||
}
|
||||
|
||||
$fileName = substr($src, 6);
|
||||
$item = $media->findByFileName($fileName);
|
||||
if ($item === null) {
|
||||
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
||||
}
|
||||
|
||||
$element->setAttribute('src', (string) $item['url']);
|
||||
$element->setAttribute('loading', 'lazy');
|
||||
$element->setAttribute('decoding', 'async');
|
||||
}
|
||||
}
|
||||
|
||||
private static function normalizeMarkdown(string $markdown): string
|
||||
{
|
||||
$markdown = str_replace(["\r\n", "\r"], "\n", $markdown);
|
||||
$lines = explode("\n", $markdown);
|
||||
$normalized = [];
|
||||
$inFence = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
|
||||
$inFence = !$inFence;
|
||||
$normalized[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inFence) {
|
||||
$normalized[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
$isBlank = trim($line) === '';
|
||||
$isListItem = preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $line) === 1;
|
||||
$previous = $normalized[count($normalized) - 1] ?? null;
|
||||
$previousIsBlank = $previous === null || trim($previous) === '';
|
||||
$previousIsListItem = $previous !== null && preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $previous) === 1;
|
||||
|
||||
if ($isListItem && !$previousIsBlank && !$previousIsListItem) {
|
||||
$normalized[] = '';
|
||||
}
|
||||
|
||||
if (!$isBlank && !$isListItem && $previousIsListItem) {
|
||||
$normalized[] = '';
|
||||
}
|
||||
|
||||
$normalized[] = $line;
|
||||
}
|
||||
|
||||
return trim(implode("\n", $normalized));
|
||||
}
|
||||
|
||||
private static function unwrap(DOMElement $element): void
|
||||
{
|
||||
$parent = $element->parentNode;
|
||||
if ($parent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
while ($element->firstChild !== null) {
|
||||
$parent->insertBefore($element->firstChild, $element);
|
||||
}
|
||||
|
||||
$parent->removeChild($element);
|
||||
}
|
||||
}
|
||||
27
app/Views/admin/dashboard.html
Normal file
27
app/Views/admin/dashboard.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<section class="stack-lg" aria-labelledby="dashboard-title">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title" id="dashboard-title">Tableau de bord</h1>
|
||||
|
||||
<div class="page-actions">
|
||||
<a class="button" href="{{ @BASE }}/dashboard/posts/create">Nouvel article</a>
|
||||
<a class="button button--ghost" href="{{ @BASE }}/dashboard/media">Médiathèque</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<check if="{{ @posts }}">
|
||||
<true>
|
||||
<div class="card-grid">
|
||||
<repeat group="{{ @posts }}" value="{{ @post }}">
|
||||
<include href="partials/post_card_admin.html" />
|
||||
</repeat>
|
||||
</div>
|
||||
<include href="partials/pagination.html" />
|
||||
</true>
|
||||
<false>
|
||||
<section class="empty-state" aria-labelledby="dashboard-empty-title">
|
||||
<h2 class="card-title" id="dashboard-empty-title">Aucun article</h2>
|
||||
<p>Commence par créer un premier article.</p>
|
||||
</section>
|
||||
</false>
|
||||
</check>
|
||||
</section>
|
||||
35
app/Views/admin/media.html
Normal file
35
app/Views/admin/media.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<section class="stack-lg" aria-labelledby="media-title">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title" id="media-title">Médiathèque</h1>
|
||||
|
||||
<div class="page-actions">
|
||||
<a class="button button--ghost" href="{{ @BASE }}/dashboard">Retour</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="panel stack" method="post" action="{{ @BASE }}/dashboard/media" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<label class="field">
|
||||
<span class="field-label">Nouvelle image</span>
|
||||
<input class="control" type="file" name="image" accept="image/jpeg,image/png,image/webp" required>
|
||||
<span class="field-help">Formats acceptés : JPG, PNG, WebP.</span>
|
||||
</label>
|
||||
<button class="button" type="submit">Envoyer</button>
|
||||
</form>
|
||||
|
||||
<check if="{{ @items }}">
|
||||
<true>
|
||||
<div class="card-grid">
|
||||
<repeat group="{{ @items }}" value="{{ @item }}">
|
||||
<include href="partials/media_card.html" />
|
||||
</repeat>
|
||||
</div>
|
||||
</true>
|
||||
<false>
|
||||
<section class="empty-state" aria-labelledby="media-empty-title">
|
||||
<h2 class="card-title" id="media-empty-title">Aucune image</h2>
|
||||
<p>Ajoute ta première image.</p>
|
||||
</section>
|
||||
</false>
|
||||
</check>
|
||||
</section>
|
||||
107
app/Views/admin/post_form.html
Normal file
107
app/Views/admin/post_form.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<section class="stack-lg" aria-labelledby="post-form-title">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title" id="post-form-title">{{ @pageTitle }}</h1>
|
||||
|
||||
<div class="page-actions">
|
||||
<a class="button button--ghost" href="{{ @BASE }}/dashboard">Retour</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="editor-layout" data-editor-layout>
|
||||
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<input type="hidden" name="cover_media_id" value="{{ @post.cover_media_id }}" data-cover-input>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Titre</span>
|
||||
<input class="control" type="text" name="title" value="{{ @post.title }}" maxlength="{{ @titleMax }}" required data-char-count>
|
||||
<span class="char-counter"><span data-char-count-value>0</span> / {{ @titleMax }}</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Extrait</span>
|
||||
<textarea class="control" name="excerpt" rows="3" maxlength="{{ @excerptMax }}" required data-char-count>{{ @post.excerpt }}</textarea>
|
||||
<span class="char-counter"><span data-char-count-value>0</span> / {{ @excerptMax }}</span>
|
||||
</label>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" role="toolbar" aria-label="Outils Markdown">
|
||||
<button class="tool-button" type="button" data-md-action="bold"><strong>Gras</strong></button>
|
||||
<button class="tool-button" type="button" data-md-action="italic"><em>Italique</em></button>
|
||||
<button class="tool-button" type="button" data-md-action="heading">Titre</button>
|
||||
<button class="tool-button" type="button" data-md-action="list">Liste</button>
|
||||
<button class="tool-button" type="button" data-md-action="quote">Citation</button>
|
||||
<button class="tool-button" type="button" data-md-action="link">Lien</button>
|
||||
<button class="tool-button" type="button" data-md-action="code">Code</button>
|
||||
<button class="tool-button" type="button" data-media-picker-open="markdown">Image</button>
|
||||
</div>
|
||||
|
||||
<textarea class="control editor-textarea" name="body_markdown" rows="18" required data-markdown-editor>{{ @post.body_markdown }}</textarea>
|
||||
</section>
|
||||
|
||||
<button class="button" type="submit">Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<button class="button button--ghost button--small" type="button" data-media-picker-close>Fermer</button>
|
||||
</div>
|
||||
|
||||
<check if="{{ @mediaItems }}">
|
||||
<true>
|
||||
<div class="media-picker__grid">
|
||||
<repeat group="{{ @mediaItems }}" value="{{ @item }}">
|
||||
<button class="media-picker__item" type="button" data-media-picker-select data-media-id="{{ @item.id }}" data-media-url="{{ @item.url }}" data-media-markdown="{{ @item.markdown }}">
|
||||
<img class="media-frame media-frame--square" src="{{ @item.url }}" alt="">
|
||||
</button>
|
||||
</repeat>
|
||||
</div>
|
||||
</true>
|
||||
<false>
|
||||
<section class="empty-state" aria-labelledby="media-picker-empty-title">
|
||||
<h2 class="card-title" id="media-picker-empty-title">Aucune image disponible</h2>
|
||||
<p>Ajoute une image depuis la médiathèque.</p>
|
||||
</section>
|
||||
</false>
|
||||
</check>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
21
app/Views/auth/login.html
Normal file
21
app/Views/auth/login.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<section class="auth-shell panel stack" aria-labelledby="login-title">
|
||||
<header class="page-header page-header--compact">
|
||||
<h1 class="page-title" id="login-title">Connexion</h1>
|
||||
</header>
|
||||
|
||||
<form class="stack" method="post" action="{{ @BASE }}/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Nom d’utilisateur</span>
|
||||
<input class="control" type="text" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Mot de passe</span>
|
||||
<input class="control" type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<button class="button" type="submit">Se connecter</button>
|
||||
</form>
|
||||
</section>
|
||||
23
app/Views/errors/error.html
Normal file
23
app/Views/errors/error.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ @errorTitle ?: 'Erreur' }}</title>
|
||||
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ @BASE }}/min/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page error-page">
|
||||
<div class="container">
|
||||
<section class="error-card">
|
||||
<p class="error-page__code">Erreur {{ @errorCode ?: 500 }}</p>
|
||||
<h1 class="error-page__title">{{ @errorTitle ?: 'Erreur' }}</h1>
|
||||
<p class="error-page__message">{{ @errorMessage ?: 'Une erreur est survenue.' }}</p>
|
||||
<p class="error-page__hint">Vérifie l’adresse ou reviens à l’accueil.</p>
|
||||
<p class="error-page__actions"><a class="button" href="{{ @BASE }}/">Retour à l’accueil</a></p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
24
app/Views/layout.html
Normal file
24
app/Views/layout.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }}</title>
|
||||
<meta name="description" content="{{ @app.tagline }}">
|
||||
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ @BASE }}/min/app.css">
|
||||
<script defer src="{{ @BASE }}/min/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<include href="partials/site_navigation.html" />
|
||||
|
||||
<main class="page" id="main-content">
|
||||
<div class="container">
|
||||
<check if="{{ @flash }}">
|
||||
<div class="flash flash--{{ @flash.type }}" role="status">{{ @flash.message }}</div>
|
||||
</check>
|
||||
<include href="{{ @view }}" />
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
23
app/Views/partials/media_card.html
Normal file
23
app/Views/partials/media_card.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<article class="card article-card">
|
||||
<img class="media-frame" src="{{ @item.url }}" alt="{{ @item.alt }}">
|
||||
<div class="card-body article-card__body">
|
||||
<p class="meta-text">{{ @item.width }} × {{ @item.height }}<br>{{ @item.created_at_label }}</p>
|
||||
|
||||
<form class="stack" method="post" action="{{ @BASE }}/dashboard/media/{{ @item.id }}/alt">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<label class="field">
|
||||
<span class="field-label">Texte alternatif</span>
|
||||
<input class="control" type="text" name="alt" value="{{ @item.alt }}" placeholder="Description de l'image" data-alt-input>
|
||||
</label>
|
||||
<button class="button button--ghost button--small" type="submit">Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="button button--ghost" type="button" data-copy-text="{{ @item.markdown }}" data-markdown-template="">Copier le Markdown</button>
|
||||
<form method="post" action="{{ @BASE }}/dashboard/media/{{ @item.id }}/delete" data-confirm="Supprimer cette image ?">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<button class="button button--danger" type="submit">Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
20
app/Views/partials/nav_items.html
Normal file
20
app/Views/partials/nav_items.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<ul class="nav-items">
|
||||
<check if="{{ @currentUser }}">
|
||||
<true>
|
||||
<li class="nav-items__item">
|
||||
<a class="nav-items__link" href="{{ @BASE }}/dashboard">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-items__item">
|
||||
<form class="nav-items__form" method="post" action="{{ @BASE }}/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<button class="nav-items__button" type="submit">Déconnexion</button>
|
||||
</form>
|
||||
</li>
|
||||
</true>
|
||||
<false>
|
||||
<li class="nav-items__item">
|
||||
<a class="nav-items__link" href="{{ @BASE }}/login">Connexion</a>
|
||||
</li>
|
||||
</false>
|
||||
</check>
|
||||
</ul>
|
||||
25
app/Views/partials/pagination.html
Normal file
25
app/Views/partials/pagination.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<check if="{{ @pagination.pages > 1 }}">
|
||||
<true>
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
<check if="{{ @pagination.page > 1 }}">
|
||||
<true>
|
||||
<a class="button button--ghost" href="{{ @paginationBase }}?page={{ @pagination.page - 1 }}">Précédent</a>
|
||||
</true>
|
||||
<false>
|
||||
<span class="button button--ghost pagination__disabled">Précédent</span>
|
||||
</false>
|
||||
</check>
|
||||
|
||||
<span class="pagination__info">Page {{ @pagination.page }} sur {{ @pagination.pages }}</span>
|
||||
|
||||
<check if="{{ @pagination.page < @pagination.pages }}">
|
||||
<true>
|
||||
<a class="button button--ghost" href="{{ @paginationBase }}?page={{ @pagination.page + 1 }}">Suivant</a>
|
||||
</true>
|
||||
<false>
|
||||
<span class="button button--ghost pagination__disabled">Suivant</span>
|
||||
</false>
|
||||
</check>
|
||||
</nav>
|
||||
</true>
|
||||
</check>
|
||||
20
app/Views/partials/post_card.html
Normal file
20
app/Views/partials/post_card.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<article class="card article-card">
|
||||
<a class="card-media-link" href="{{ @BASE }}/posts/{{ @post.slug }}">
|
||||
<check if="{{ @post.cover_url }}">
|
||||
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
|
||||
<false>
|
||||
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
||||
</false>
|
||||
</check>
|
||||
</a>
|
||||
<div class="card-body article-card__body">
|
||||
<h2 class="card-title">{{ @post.title }}</h2>
|
||||
<p class="meta-text">
|
||||
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at_label }}</time>
|
||||
<check if="{{ @post.has_updated_at }}">
|
||||
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at_label }}</time></true>
|
||||
</check>
|
||||
</p>
|
||||
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||
</div>
|
||||
</article>
|
||||
29
app/Views/partials/post_card_admin.html
Normal file
29
app/Views/partials/post_card_admin.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<article class="card article-card">
|
||||
<a class="card-media-link" href="{{ @BASE }}/dashboard/posts/{{ @post.id }}/edit">
|
||||
<check if="{{ @post.cover_url }}">
|
||||
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
|
||||
<false>
|
||||
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
||||
</false>
|
||||
</check>
|
||||
</a>
|
||||
<div class="card-body article-card__body">
|
||||
<h2 class="card-title">{{ @post.title }}</h2>
|
||||
<p class="meta-text">
|
||||
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at_label }}</time>
|
||||
<check if="{{ @post.has_updated_at }}">
|
||||
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at_label }}</time></true>
|
||||
</check>
|
||||
</p>
|
||||
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||
<div class="card-actions">
|
||||
<a class="button button--ghost" href="{{ @BASE }}/posts/{{ @post.slug }}">Voir</a>
|
||||
<a class="button button--ghost" href="{{ @BASE }}/dashboard/posts/{{ @post.id }}/edit">Modifier</a>
|
||||
<form method="post" action="{{ @BASE }}/dashboard/posts/{{ @post.id }}/delete"
|
||||
data-confirm="Supprimer cet article ?">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<button class="button button--danger" type="submit">Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
1
app/Views/partials/site_brand.html
Normal file
1
app/Views/partials/site_brand.html
Normal file
@@ -0,0 +1 @@
|
||||
<a class="site-brand__title" href="{{ @BASE }}/">{{ @app.name }}</a>
|
||||
44
app/Views/partials/site_navigation.html
Normal file
44
app/Views/partials/site_navigation.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<input class="nav-toggle" type="checkbox" id="nav-toggle" aria-hidden="true">
|
||||
|
||||
<header class="site-header">
|
||||
<div class="container site-header__inner">
|
||||
<label class="nav-toggle-button" for="nav-toggle">
|
||||
<span class="sr-only">Ouvrir le menu</span>
|
||||
<span class="nav-toggle-button__line"></span>
|
||||
<span class="nav-toggle-button__line"></span>
|
||||
<span class="nav-toggle-button__line"></span>
|
||||
</label>
|
||||
|
||||
<div class="site-brand site-brand--header">
|
||||
<include href="partials/site_brand.html" />
|
||||
</div>
|
||||
|
||||
<nav class="nav nav--desktop" aria-label="Navigation principale">
|
||||
<include href="partials/nav_items.html" />
|
||||
</nav>
|
||||
|
||||
<span class="site-header__spacer" aria-hidden="true"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mobile-menu">
|
||||
<label class="mobile-menu__backdrop" for="nav-toggle" aria-hidden="true"></label>
|
||||
|
||||
<div class="mobile-menu__panel">
|
||||
<header class="mobile-menu__header">
|
||||
<div class="site-brand site-brand--menu">
|
||||
<include href="partials/site_brand.html" />
|
||||
</div>
|
||||
|
||||
<label class="mobile-menu__close" for="nav-toggle">
|
||||
<span class="sr-only">Fermer le menu</span>
|
||||
<span class="mobile-menu__close-line"></span>
|
||||
<span class="mobile-menu__close-line"></span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<nav class="mobile-menu__nav" aria-label="Navigation principale mobile">
|
||||
<include href="partials/nav_items.html" />
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
22
app/Views/site/home.html
Normal file
22
app/Views/site/home.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<section class="stack-lg" aria-labelledby="home-title">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title" id="home-title">Articles</h1>
|
||||
</header>
|
||||
|
||||
<check if="{{ @posts }}">
|
||||
<true>
|
||||
<div class="card-grid">
|
||||
<repeat group="{{ @posts }}" value="{{ @post }}">
|
||||
<include href="partials/post_card.html" />
|
||||
</repeat>
|
||||
</div>
|
||||
<include href="partials/pagination.html" />
|
||||
</true>
|
||||
<false>
|
||||
<section class="empty-state" aria-labelledby="home-empty-title">
|
||||
<h2 class="card-title" id="home-empty-title">Aucun article</h2>
|
||||
<p>Le premier article arrivera bientôt.</p>
|
||||
</section>
|
||||
</false>
|
||||
</check>
|
||||
</section>
|
||||
24
app/Views/site/post.html
Normal file
24
app/Views/site/post.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<article class="article" aria-labelledby="post-title">
|
||||
<header class="article-header">
|
||||
<h1 class="article-title" id="post-title">{{ @post.title }}</h1>
|
||||
<p class="meta-text">
|
||||
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at_label }}</time>
|
||||
<check if="{{ @post.has_updated_at }}">
|
||||
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at_label }}</time></true>
|
||||
</check>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<check if="{{ @post.cover_url }}">
|
||||
<true>
|
||||
<img class="media-frame media-frame--large article-cover" src="{{ @post.cover_url }}"
|
||||
alt="{{ @post.title }}">
|
||||
</true>
|
||||
<false>
|
||||
<div class="media-frame media-frame--large media-frame--placeholder article-cover">Aucune image
|
||||
</div>
|
||||
</false>
|
||||
</check>
|
||||
|
||||
<div class="prose">{{ @post.body_html | raw }}</div>
|
||||
</article>
|
||||
80
app/bootstrap.php
Normal file
80
app/bootstrap.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/Helpers/App.php';
|
||||
require __DIR__ . '/Helpers/Error.php';
|
||||
|
||||
$f3 = Base::instance();
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────
|
||||
|
||||
$f3->set('AUTOLOAD', app_root() . '/app/Controllers/;' . app_root() . '/app/Models/;' . app_root() . '/app/Services/');
|
||||
$f3->set('UI', app_root() . '/app/Views/');
|
||||
$f3->set('TEMP', app_root() . '/tmp/');
|
||||
$f3->set('LOGS', app_logs_dir() . '/');
|
||||
|
||||
$f3->config(app_root() . '/app/config.ini');
|
||||
|
||||
$localConfig = app_root() . '/config.local.ini';
|
||||
if (is_file($localConfig)) {
|
||||
$f3->config($localConfig);
|
||||
}
|
||||
|
||||
$f3->set('TZ', app_timezone());
|
||||
$f3->set('DEBUG', app_is_prod() ? 0 : 3);
|
||||
|
||||
app_ensure_dir((string) $f3->get('TEMP'));
|
||||
app_ensure_dir((string) $f3->get('LOGS'));
|
||||
app_ensure_dir(app_public_media_dir());
|
||||
// Web::receive() utilise UPLOADS directement — le résoudre en absolu.
|
||||
$f3->set('UPLOADS', app_root() . '/' . ltrim((string) $f3->get('UPLOADS'), '/'));
|
||||
app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/'));
|
||||
app_bootstrap_logging();
|
||||
|
||||
// ── En-têtes de sécurité ────────────────────────────────────────────
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
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'");
|
||||
header('Referrer-Policy: same-origin');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: SAMEORIGIN');
|
||||
header('Cross-Origin-Opener-Policy: same-origin');
|
||||
header('Cross-Origin-Resource-Policy: same-origin');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
}
|
||||
|
||||
// ── Base de données ─────────────────────────────────────────────────
|
||||
|
||||
$dbPath = app_db_path();
|
||||
app_ensure_dir(dirname($dbPath));
|
||||
|
||||
$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->exec('PRAGMA foreign_keys = ON');
|
||||
$f3->set('DB', $db);
|
||||
|
||||
// ── Session ─────────────────────────────────────────────────────────
|
||||
|
||||
session_name((string) $f3->get('app.session_name'));
|
||||
$f3->set('JAR', [
|
||||
'expire' => 0,
|
||||
'path' => '/',
|
||||
'secure' => $f3->get('SCHEME') === 'https',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
// ── Erreurs ─────────────────────────────────────────────────────────
|
||||
|
||||
app_bootstrap_errors($f3);
|
||||
|
||||
return $f3;
|
||||
32
app/config.ini
Normal file
32
app/config.ini
Normal file
@@ -0,0 +1,32 @@
|
||||
[globals]
|
||||
app.env=dev
|
||||
app.timezone=UTC
|
||||
app.session_name=f3-simple-blog
|
||||
|
||||
UPLOADS=tmp/uploads/
|
||||
CACHE=folder
|
||||
|
||||
app.name=F3 Simple Blog
|
||||
app.tagline=Blog simple avec Fat-Free Framework.
|
||||
|
||||
[routes]
|
||||
GET @asset: /min/@file=AssetController->serve
|
||||
|
||||
GET @home: /=SiteController->home
|
||||
GET @post_show: /posts/@slug=SiteController->show
|
||||
|
||||
GET @login: /login=AuthController->show
|
||||
POST @login_submit: /login=AuthController->login
|
||||
POST @logout: /logout=AuthController->logout
|
||||
|
||||
GET @dashboard: /dashboard=DashboardController->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 @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
|
||||
Reference in New Issue
Block a user