Clean code
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
.dockerignore
|
.dockerignore
|
||||||
Dockerfile
|
Dockerfile
|
||||||
README.md
|
README.md
|
||||||
|
Caddyfile.example
|
||||||
compose.yaml
|
compose.yaml
|
||||||
config.local.ini
|
config.local.ini
|
||||||
config.local.ini.example
|
config.local.ini.example
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,3 +14,4 @@
|
|||||||
/public/uploads/media/*
|
/public/uploads/media/*
|
||||||
!/public/uploads/media/.gitkeep
|
!/public/uploads/media/.gitkeep
|
||||||
/config.local.ini
|
/config.local.ini
|
||||||
|
/Caddyfile
|
||||||
|
|||||||
19
Caddyfile.example
Normal file
19
Caddyfile.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Exemple de configuration Caddy en reverse proxy vers le conteneur Docker.
|
||||||
|
# Copier ce fichier vers Caddyfile et adapter le domaine.
|
||||||
|
|
||||||
|
blog.example.com {
|
||||||
|
# ── En-têtes de sécurité (toutes les réponses) ───────────────────
|
||||||
|
|
||||||
|
header {
|
||||||
|
Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; img-src 'self' data:; style-src 'self'; script-src 'self'"
|
||||||
|
Referrer-Policy "same-origin"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy localhost:8888
|
||||||
|
}
|
||||||
@@ -18,8 +18,9 @@ RUN apt-get update \
|
|||||||
libfreetype6-dev \
|
libfreetype6-dev \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
libonig-dev \
|
libonig-dev \
|
||||||
|
libicu-dev \
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||||
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite dom gd mbstring opcache \
|
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite dom gd mbstring opcache intl \
|
||||||
&& printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \
|
&& printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \
|
||||||
&& a2enconf servername \
|
&& a2enconf servername \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
class AssetController extends BaseController
|
class AssetController
|
||||||
{
|
{
|
||||||
private const ALLOWED = [
|
private const ALLOWED = [
|
||||||
'app.css' => 'text/css',
|
'app.css' => 'text/css',
|
||||||
@@ -11,13 +11,15 @@ class AssetController extends BaseController
|
|||||||
|
|
||||||
public function serve(): void
|
public function serve(): void
|
||||||
{
|
{
|
||||||
$file = basename((string) $this->f3->get('PARAMS.file'));
|
$f3 = Base::instance();
|
||||||
|
$file = basename((string) $f3->get('PARAMS.file'));
|
||||||
|
|
||||||
if (!array_key_exists($file, self::ALLOWED)) {
|
if (!array_key_exists($file, self::ALLOWED)) {
|
||||||
$this->f3->error(404);
|
$f3->error(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$f3->expire(86400);
|
||||||
echo Web::instance()->minify(
|
echo Web::instance()->minify(
|
||||||
$file,
|
$file,
|
||||||
self::ALLOWED[$file],
|
self::ALLOWED[$file],
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
class AuthController extends BaseController
|
class AuthController extends BaseController
|
||||||
{
|
{
|
||||||
public function beforeRoute(): void
|
|
||||||
{
|
|
||||||
$this->disableCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(): void
|
public function show(): void
|
||||||
{
|
{
|
||||||
if ($this->currentUser() !== null) {
|
if ($this->currentUser() !== null) {
|
||||||
@@ -16,7 +11,7 @@ class AuthController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->renderSession('auth/login.html', ['pageTitle' => 'Connexion'], true);
|
$this->render('auth/login.html', ['pageTitle' => 'Connexion']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function login(): void
|
public function login(): void
|
||||||
@@ -36,7 +31,6 @@ class AuthController extends BaseController
|
|||||||
|
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$this->f3->set('SESSION.user_id', $user['id']);
|
$this->f3->set('SESSION.user_id', $user['id']);
|
||||||
$this->refreshCsrfToken();
|
|
||||||
$this->flash('success', 'Connexion réussie.');
|
$this->flash('success', 'Connexion réussie.');
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
}
|
}
|
||||||
@@ -45,7 +39,6 @@ class AuthController extends BaseController
|
|||||||
{
|
{
|
||||||
$this->verifyCsrf();
|
$this->verifyCsrf();
|
||||||
$this->f3->clear('SESSION.user_id');
|
$this->f3->clear('SESSION.user_id');
|
||||||
$this->f3->clear('SESSION.csrf_token');
|
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$this->flash('success', 'Déconnexion effectuée.');
|
$this->flash('success', 'Déconnexion effectuée.');
|
||||||
$this->f3->reroute('@login');
|
$this->f3->reroute('@login');
|
||||||
|
|||||||
@@ -7,46 +7,51 @@ abstract class BaseController
|
|||||||
protected Base $f3;
|
protected Base $f3;
|
||||||
protected DB\SQL $db;
|
protected DB\SQL $db;
|
||||||
|
|
||||||
|
private ?array $resolvedUser = null;
|
||||||
|
private bool $userResolved = false;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->f3 = Base::instance();
|
$this->f3 = Base::instance();
|
||||||
$this->db = $this->f3->get('DB');
|
$this->db = $this->f3->get('DB');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function renderPublic(string $view, array $data = []): void
|
protected function render(string $view, array $data = [], int $cacheTtl = 0): void
|
||||||
{
|
{
|
||||||
$user = $this->currentUser();
|
$user = $this->currentUser();
|
||||||
|
|
||||||
$this->f3->mset($data + [
|
// Les pages publiques émettent un Cache-Control avec le TTL demandé.
|
||||||
'view' => $view,
|
// Un utilisateur connecté voit un état de session (nav, CSRF) :
|
||||||
'currentUser' => $user,
|
// on force expire(0) pour ne pas servir ce rendu à d'autres visiteurs.
|
||||||
'flash' => array_key_exists('flash', $data) && is_array($data['flash']) ? $data['flash'] : null,
|
$this->f3->expire($user !== null ? 0 : $cacheTtl);
|
||||||
'csrfToken' => $user !== null ? $this->csrfToken() : null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
echo Template::instance()->render('layout.html');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function renderSession(string $view, array $data = [], bool $withCsrf = false): void
|
|
||||||
{
|
|
||||||
$flash = array_key_exists('flash', $data) && is_array($data['flash'])
|
$flash = array_key_exists('flash', $data) && is_array($data['flash'])
|
||||||
? $data['flash']
|
? $data['flash']
|
||||||
: $this->pullFlash();
|
: $this->pullFlash();
|
||||||
|
|
||||||
$this->f3->mset($data + [
|
$this->f3->mset($data + [
|
||||||
'view' => $view,
|
'view' => $view,
|
||||||
'currentUser' => $this->currentUser(),
|
'currentUser' => $user,
|
||||||
'flash' => $flash,
|
'flash' => $flash,
|
||||||
'csrfToken' => $withCsrf ? $this->csrfToken() : null,
|
'metaDescription' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Persister le jeton CSRF courant en session : le formulaire
|
||||||
|
// affiche @CSRF (jeton de cette requête) ; à la soumission,
|
||||||
|
// verifyCsrf() le comparera à SESSION.csrf.
|
||||||
|
$this->f3->copy('CSRF', 'SESSION.csrf');
|
||||||
echo Template::instance()->render('layout.html');
|
echo Template::instance()->render('layout.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function currentUser(): ?array
|
protected function currentUser(): ?array
|
||||||
{
|
{
|
||||||
|
if (!$this->userResolved) {
|
||||||
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
|
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
|
||||||
return $userId > 0 ? (new User($this->db))->findById($userId) : null;
|
$this->resolvedUser = $userId > 0 ? (new User($this->db))->findById($userId) : null;
|
||||||
|
$this->userResolved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolvedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function requireAuth(): void
|
protected function requireAuth(): void
|
||||||
@@ -59,33 +64,14 @@ abstract class BaseController
|
|||||||
$this->f3->reroute('@login');
|
$this->f3->reroute('@login');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function disableCache(): void
|
// Le jeton CSRF est fourni par la classe Session de F3
|
||||||
{
|
// (clé de ruche « CSRF », copiée en SESSION.csrf à chaque requête).
|
||||||
$this->f3->expire(0);
|
// Le formulaire envoie le jeton affiché lors du GET précédent,
|
||||||
}
|
// qu'on compare au jeton sauvegardé en session.
|
||||||
|
|
||||||
protected function csrfToken(): string
|
|
||||||
{
|
|
||||||
$token = (string) ($this->f3->get('SESSION.csrf_token') ?? '');
|
|
||||||
if ($token === '') {
|
|
||||||
$token = bin2hex(random_bytes(32));
|
|
||||||
$this->f3->set('SESSION.csrf_token', $token);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function refreshCsrfToken(): string
|
|
||||||
{
|
|
||||||
$token = bin2hex(random_bytes(32));
|
|
||||||
$this->f3->set('SESSION.csrf_token', $token);
|
|
||||||
return $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function verifyCsrf(): void
|
protected function verifyCsrf(): void
|
||||||
{
|
{
|
||||||
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');
|
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');
|
||||||
$expected = (string) ($this->f3->get('SESSION.csrf_token') ?? '');
|
$expected = (string) ($this->f3->get('SESSION.csrf') ?? '');
|
||||||
|
|
||||||
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
|
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ class DashboardController extends BaseController
|
|||||||
public function beforeRoute(): void
|
public function beforeRoute(): void
|
||||||
{
|
{
|
||||||
$this->requireAuth();
|
$this->requireAuth();
|
||||||
$this->disableCache();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(): void
|
public function index(): void
|
||||||
@@ -15,11 +14,11 @@ class DashboardController extends BaseController
|
|||||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||||
$result = (new Post($this->db))->paginateList($page, 24);
|
$result = (new Post($this->db))->paginateList($page, 24);
|
||||||
|
|
||||||
$this->renderSession('admin/dashboard.html', [
|
$this->render('admin/dashboard.html', [
|
||||||
'pageTitle' => 'Tableau de bord',
|
'pageTitle' => 'Tableau de bord',
|
||||||
'posts' => $result['posts'],
|
'posts' => $result['posts'],
|
||||||
'pagination' => $result,
|
'pagination' => $result,
|
||||||
'paginationAlias' => 'dashboard',
|
'paginationAlias' => 'dashboard',
|
||||||
], true);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ declare(strict_types=1);
|
|||||||
class MediaController extends BaseController
|
class MediaController extends BaseController
|
||||||
{
|
{
|
||||||
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
|
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
|
||||||
|
private const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
private const PER_PAGE = 24;
|
private const PER_PAGE = 24;
|
||||||
|
|
||||||
public function beforeRoute(): void
|
public function beforeRoute(): void
|
||||||
{
|
{
|
||||||
$this->requireAuth();
|
$this->requireAuth();
|
||||||
$this->disableCache();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(): void
|
public function index(): void
|
||||||
@@ -18,12 +18,12 @@ class MediaController extends BaseController
|
|||||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||||
$result = (new Media($this->db))->paginateLibrary($page, self::PER_PAGE);
|
$result = (new Media($this->db))->paginateLibrary($page, self::PER_PAGE);
|
||||||
|
|
||||||
$this->renderSession('admin/media.html', [
|
$this->render('admin/media.html', [
|
||||||
'pageTitle' => 'Médiathèque',
|
'pageTitle' => 'Médiathèque',
|
||||||
'items' => $result['items'],
|
'items' => $result['items'],
|
||||||
'pagination' => $result,
|
'pagination' => $result,
|
||||||
'paginationAlias' => 'media_index',
|
'paginationAlias' => 'media_index',
|
||||||
], true);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function upload(): void
|
public function upload(): void
|
||||||
@@ -36,7 +36,8 @@ class MediaController extends BaseController
|
|||||||
|
|
||||||
$received = Web::instance()->receive(
|
$received = Web::instance()->receive(
|
||||||
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0
|
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0
|
||||||
&& (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
|
&& (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES
|
||||||
|
&& in_array($file['type'] ?? '', self::ACCEPTED_TYPES, true),
|
||||||
overwrite: false,
|
overwrite: false,
|
||||||
slug: true
|
slug: true
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ class PostController extends BaseController
|
|||||||
public function beforeRoute(): void
|
public function beforeRoute(): void
|
||||||
{
|
{
|
||||||
$this->requireAuth();
|
$this->requireAuth();
|
||||||
$this->disableCache();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(): void
|
public function create(): void
|
||||||
@@ -25,7 +24,6 @@ class PostController extends BaseController
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
(new Post($this->db))->create($input);
|
(new Post($this->db))->create($input);
|
||||||
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
|
|
||||||
$this->flash('success', 'Article créé.');
|
$this->flash('success', 'Article créé.');
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
@@ -58,7 +56,6 @@ class PostController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
|
|
||||||
$this->flash('success', 'Article mis à jour.');
|
$this->flash('success', 'Article mis à jour.');
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
@@ -69,9 +66,14 @@ class PostController extends BaseController
|
|||||||
public function delete(): void
|
public function delete(): void
|
||||||
{
|
{
|
||||||
$this->verifyCsrf();
|
$this->verifyCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
(new Post($this->db))->delete((int) $this->f3->get('PARAMS.id'));
|
(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->flash('success', 'Article supprimé.');
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$this->flash('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,10 +86,10 @@ class PostController extends BaseController
|
|||||||
|
|
||||||
$media = new Media($this->db);
|
$media = new Media($this->db);
|
||||||
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
|
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
|
||||||
$mediaCount = $media->countAll();
|
$mediaCount = $media->count();
|
||||||
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;
|
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;
|
||||||
|
|
||||||
$this->renderSession('admin/post_form.html', [
|
$this->render('admin/post_form.html', [
|
||||||
'pageTitle' => $pageTitle,
|
'pageTitle' => $pageTitle,
|
||||||
'formAction' => $formAction,
|
'formAction' => $formAction,
|
||||||
'post' => $post,
|
'post' => $post,
|
||||||
@@ -99,7 +101,7 @@ class PostController extends BaseController
|
|||||||
'titleMax' => Post::TITLE_MAX_LENGTH,
|
'titleMax' => Post::TITLE_MAX_LENGTH,
|
||||||
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
|
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
|
||||||
'flash' => $flash,
|
'flash' => $flash,
|
||||||
], true);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function postInput(): array
|
private function postInput(): array
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ class SiteController extends BaseController
|
|||||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||||
$result = (new Post($this->db))->paginateList($page);
|
$result = (new Post($this->db))->paginateList($page);
|
||||||
|
|
||||||
$this->renderPublic('site/home.html', [
|
$this->render('site/home.html', [
|
||||||
'pageTitle' => 'Accueil',
|
'pageTitle' => 'Accueil',
|
||||||
'posts' => $result['posts'],
|
'posts' => $result['posts'],
|
||||||
'pagination' => $result,
|
'pagination' => $result,
|
||||||
'paginationAlias' => 'home',
|
'paginationAlias' => 'home',
|
||||||
]);
|
], 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(): void
|
public function show(): void
|
||||||
@@ -25,9 +25,10 @@ class SiteController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->renderPublic('site/post.html', [
|
$this->render('site/post.html', [
|
||||||
'pageTitle' => $post['title'],
|
'pageTitle' => $post['title'],
|
||||||
|
'metaDescription' => $post['excerpt'],
|
||||||
'post' => $post,
|
'post' => $post,
|
||||||
]);
|
], 3600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,15 +56,13 @@ function app_media_url(string $fileName): string
|
|||||||
|
|
||||||
// ── Texte ───────────────────────────────────────────────────────────
|
// ── 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
|
function app_unique_slug(string $value, callable $exists): string
|
||||||
{
|
{
|
||||||
$base = app_slugify($value);
|
$base = Web::instance()->slug(trim($value));
|
||||||
|
if ($base === '') {
|
||||||
|
$base = 'article';
|
||||||
|
}
|
||||||
|
|
||||||
if (!$exists($base)) {
|
if (!$exists($base)) {
|
||||||
return $base;
|
return $base;
|
||||||
}
|
}
|
||||||
@@ -97,7 +95,6 @@ function app_format_datetime_fr(string $value): string
|
|||||||
|
|
||||||
$date = $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
|
$date = $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
|
||||||
|
|
||||||
if (class_exists('IntlDateFormatter')) {
|
|
||||||
$formatter ??= new IntlDateFormatter(
|
$formatter ??= new IntlDateFormatter(
|
||||||
'fr_FR',
|
'fr_FR',
|
||||||
IntlDateFormatter::LONG,
|
IntlDateFormatter::LONG,
|
||||||
@@ -108,24 +105,8 @@ function app_format_datetime_fr(string $value): string
|
|||||||
);
|
);
|
||||||
|
|
||||||
$formatted = $formatter->format($date);
|
$formatted = $formatter->format($date);
|
||||||
if (is_string($formatted) && $formatted !== '') {
|
|
||||||
return $formatted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$months = [
|
return is_string($formatted) && $formatted !== '' ? $formatted : $value;
|
||||||
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) {
|
} catch (Throwable) {
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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
|
function app_bootstrap_logging(): void
|
||||||
{
|
{
|
||||||
$dir = rtrim((string) Base::instance()->get('LOGS'), '/\\') . DIRECTORY_SEPARATOR;
|
$dir = rtrim((string) Base::instance()->get('LOGS'), '/\\') . DIRECTORY_SEPARATOR;
|
||||||
@@ -22,78 +12,27 @@ function app_bootstrap_logging(): void
|
|||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
}
|
}
|
||||||
|
|
||||||
function app_request_summary(): string
|
function app_error_meta(int $code): array
|
||||||
{
|
{
|
||||||
$f3 = Base::instance();
|
return match ($code) {
|
||||||
return sprintf(
|
400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'],
|
||||||
'request=%s %s ip=%s',
|
403 => ['title' => 'Accès refusé', 'message' => 'Tu n\u2019as pas accès à cette ressource.'],
|
||||||
(string) ($f3->get('VERB') ?? 'CLI'),
|
404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'],
|
||||||
(string) ($f3->get('URI') ?? '/'),
|
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
|
||||||
(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
|
function app_render_error_fallback(int $code): void
|
||||||
{
|
{
|
||||||
$f3 = Base::instance();
|
|
||||||
$meta = app_error_meta($code);
|
$meta = app_error_meta($code);
|
||||||
$base = rtrim((string) $f3->get('BASE'), '/');
|
$base = rtrim((string) Base::instance()->get('BASE'), '/');
|
||||||
|
|
||||||
while (ob_get_level() > 0) {
|
while (ob_get_level() > 0) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
}
|
}
|
||||||
|
|
||||||
$f3->status($code);
|
|
||||||
$f3->expire(0);
|
|
||||||
|
|
||||||
if (!headers_sent()) {
|
if (!headers_sent()) {
|
||||||
|
http_response_code($code);
|
||||||
header('Content-Type: text/html; charset=UTF-8');
|
header('Content-Type: text/html; charset=UTF-8');
|
||||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||||
}
|
}
|
||||||
@@ -107,7 +46,12 @@ function app_render_error_fallback(int $code): void
|
|||||||
|
|
||||||
function app_bootstrap_errors(Base $f3): void
|
function app_bootstrap_errors(Base $f3): void
|
||||||
{
|
{
|
||||||
if (app_is_prod()) {
|
// 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 {
|
register_shutdown_function(function (): void {
|
||||||
$error = error_get_last();
|
$error = error_get_last();
|
||||||
if ($error === null) {
|
if ($error === null) {
|
||||||
@@ -121,27 +65,9 @@ function app_bootstrap_errors(Base $f3): void
|
|||||||
|
|
||||||
app_render_error_fallback(500);
|
app_render_error_fallback(500);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
$f3->set('ONERROR', function (Base $f3): void {
|
$f3->set('ONERROR', function (Base $f3): void {
|
||||||
$code = (int) ($f3->get('ERROR.code') ?? 500);
|
$code = max((int) ($f3->get('ERROR.code') ?? 500), 1);
|
||||||
$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->expire(0);
|
||||||
$f3->status($code);
|
$f3->status($code);
|
||||||
|
|
||||||
@@ -154,8 +80,7 @@ function app_bootstrap_errors(Base $f3): void
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
echo Template::instance()->render('errors/error.html');
|
echo Template::instance()->render('errors/error.html');
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable) {
|
||||||
app_log_error(500, 'Internal Server Error', 'Error template rendering failed.', $exception);
|
|
||||||
app_render_error_fallback($code);
|
app_render_error_fallback($code);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,14 +26,6 @@ class Media extends DB\SQL\Mapper
|
|||||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_media_created_at ON media(created_at DESC)');
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_media_created_at ON media(created_at DESC)');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function all(): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
fn(self $m): array => $this->decorate($m->cast()),
|
|
||||||
$this->find(null, ['order' => 'created_at DESC, id DESC']) ?: []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paginateLibrary(int $page = 1, int $perPage = 24): array
|
public function paginateLibrary(int $page = 1, int $perPage = 24): array
|
||||||
{
|
{
|
||||||
$result = $this->paginate(
|
$result = $this->paginate(
|
||||||
@@ -58,11 +50,6 @@ class Media extends DB\SQL\Mapper
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countAll(): int
|
|
||||||
{
|
|
||||||
return $this->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findById(int $id): ?array
|
public function findById(int $id): ?array
|
||||||
{
|
{
|
||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
@@ -205,7 +192,7 @@ class Media extends DB\SQL\Mapper
|
|||||||
$image = match ($mime) {
|
$image = match ($mime) {
|
||||||
'image/jpeg' => @imagecreatefromjpeg($srcPath),
|
'image/jpeg' => @imagecreatefromjpeg($srcPath),
|
||||||
'image/png' => @imagecreatefrompng($srcPath),
|
'image/png' => @imagecreatefrompng($srcPath),
|
||||||
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($srcPath) : false,
|
'image/webp' => @imagecreatefromwebp($srcPath),
|
||||||
default => false,
|
default => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,7 +215,7 @@ class Media extends DB\SQL\Mapper
|
|||||||
private static function writeImage(GdImage $image, string $target, string $format): void
|
private static function writeImage(GdImage $image, string $target, string $format): void
|
||||||
{
|
{
|
||||||
if ($format === 'png') {
|
if ($format === 'png') {
|
||||||
if (function_exists('imagepalettetotruecolor') && !imageistruecolor($image)) {
|
if (!imageistruecolor($image)) {
|
||||||
imagepalettetotruecolor($image);
|
imagepalettetotruecolor($image);
|
||||||
}
|
}
|
||||||
imagealphablending($image, false);
|
imagealphablending($image, false);
|
||||||
@@ -272,7 +259,6 @@ class Media extends DB\SQL\Mapper
|
|||||||
'width' => (int) $row['width'],
|
'width' => (int) $row['width'],
|
||||||
'height' => (int) $row['height'],
|
'height' => (int) $row['height'],
|
||||||
'created_at' => (string) $row['created_at'],
|
'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']),
|
'url' => app_media_url((string) $row['file_name']),
|
||||||
'markdown' => '',
|
'markdown' => '',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -133,9 +133,11 @@ class Post extends DB\SQL\Mapper
|
|||||||
public function delete(int $id): void
|
public function delete(int $id): void
|
||||||
{
|
{
|
||||||
$this->load(['id = ?', $id]);
|
$this->load(['id = ?', $id]);
|
||||||
if (!$this->dry()) {
|
if ($this->dry()) {
|
||||||
$this->erase();
|
throw new RuntimeException('Article introuvable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->erase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function payload(array $input): array
|
private function payload(array $input): array
|
||||||
@@ -168,7 +170,7 @@ class Post extends DB\SQL\Mapper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$bodyHtml = MarkdownService::compile($bodyMarkdown, $media);
|
$bodyHtml = MarkdownService::instance()->compile($bodyMarkdown, $media);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
@@ -193,10 +195,7 @@ class Post extends DB\SQL\Mapper
|
|||||||
'excerpt' => (string) $row['excerpt'],
|
'excerpt' => (string) $row['excerpt'],
|
||||||
'cover_media_id' => (int) ($row['cover_media_id'] ?? 0),
|
'cover_media_id' => (int) ($row['cover_media_id'] ?? 0),
|
||||||
'created_at' => (string) $row['created_at'],
|
'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' => (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'],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
class MarkdownService
|
class MarkdownService extends Prefab
|
||||||
{
|
{
|
||||||
private const ALLOWED_TAGS = [
|
private const ALLOWED_TAGS = [
|
||||||
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
@@ -10,7 +10,7 @@ class MarkdownService
|
|||||||
'strong', 'em', 'a', 'img', 'hr', 'br',
|
'strong', 'em', 'a', 'img', 'hr', 'br',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function compile(string $markdown, Media $media): string
|
public function compile(string $markdown, Media $media): string
|
||||||
{
|
{
|
||||||
$markdown = trim($markdown);
|
$markdown = trim($markdown);
|
||||||
if ($markdown === '') {
|
if ($markdown === '') {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="panel stack" method="post" action="{{ 'media_upload' | alias }}" enctype="multipart/form-data">
|
<form class="panel stack" method="post" action="{{ 'media_upload' | alias }}" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Nouvelle image</span>
|
<span class="field-label">Nouvelle image</span>
|
||||||
<input class="control" type="file" name="image" accept="image/jpeg,image/png,image/webp" required>
|
<input class="control" type="file" name="image" accept="image/jpeg,image/png,image/webp" required>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<div class="editor-layout" data-editor-layout>
|
<div class="editor-layout" data-editor-layout>
|
||||||
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
|
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||||
<input type="hidden" name="cover_media_id" value="{{ @post.cover_media_id }}" data-cover-input>
|
<input type="hidden" name="cover_media_id" value="{{ @post.cover_media_id }}" data-cover-input>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="stack" method="post" action="{{ 'login_submit' | alias }}">
|
<form class="stack" method="post" action="{{ 'login_submit' | alias }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Nom d’utilisateur</span>
|
<span class="field-label">Nom d’utilisateur</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }}</title>
|
<title>{{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }}</title>
|
||||||
<meta name="description" content="{{ @app.tagline }}">
|
<meta name="description" content="{{ @metaDescription ?: @app.tagline }}">
|
||||||
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="{{ 'asset', 'file=app.css' | alias }}">
|
<link rel="stylesheet" href="{{ 'asset', 'file=app.css' | alias }}">
|
||||||
<script defer src="{{ 'asset', 'file=app.js' | alias }}"></script>
|
<script defer src="{{ 'asset', 'file=app.js' | alias }}"></script>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<article class="card article-card">
|
<article class="card article-card">
|
||||||
<img class="media-frame" src="{{ @item.url }}" alt="{{ @item.alt }}">
|
<img class="media-frame" src="{{ @item.url }}" alt="{{ @item.alt }}">
|
||||||
<div class="card-body article-card__body">
|
<div class="card-body article-card__body">
|
||||||
<p class="meta-text">{{ @item.width }} × {{ @item.height }}<br>{{ @item.created_at_label }}</p>
|
<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 }}">
|
<form class="stack" method="post" action="{{ 'media_update_alt', 'id='.@item.id | alias }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Texte alternatif</span>
|
<span class="field-label">Texte alternatif</span>
|
||||||
<input class="control" type="text" name="alt" value="{{ @item.alt }}" placeholder="Description de l'image" data-alt-input>
|
<input class="control" type="text" name="alt" value="{{ @item.alt }}" placeholder="Description de l'image" data-alt-input>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="button button--ghost" type="button" data-copy-text="{{ @item.markdown }}" data-markdown-template="">Copier le Markdown</button>
|
<button class="button button--ghost" type="button" data-copy-text="{{ @item.markdown }}" data-markdown-template="">Copier le Markdown</button>
|
||||||
<form method="post" action="{{ 'media_delete', 'id='.@item.id | alias }}" data-confirm="Supprimer cette image ?">
|
<form method="post" action="{{ 'media_delete', 'id='.@item.id | alias }}" data-confirm="Supprimer cette image ?">
|
||||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||||
<button class="button button--danger" type="submit">Supprimer</button>
|
<button class="button button--danger" type="submit">Supprimer</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-items__item">
|
<li class="nav-items__item">
|
||||||
<form class="nav-items__form" method="post" action="{{ 'logout' | alias }}">
|
<form class="nav-items__form" method="post" action="{{ 'logout' | alias }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||||
<button class="nav-items__button" type="submit">Déconnexion</button>
|
<button class="nav-items__button" type="submit">Déconnexion</button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<div class="card-body article-card__body">
|
<div class="card-body article-card__body">
|
||||||
<h2 class="card-title">{{ @post.title }}</h2>
|
<h2 class="card-title">{{ @post.title }}</h2>
|
||||||
<p class="meta-text">
|
<p class="meta-text">
|
||||||
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at_label }}</time>
|
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time>
|
||||||
<check if="{{ @post.has_updated_at }}">
|
<check if="{{ @post.updated_at !== @post.created_at }}">
|
||||||
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at_label }}</time></true>
|
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at | date_fr }}</time></true>
|
||||||
</check>
|
</check>
|
||||||
</p>
|
</p>
|
||||||
<p class="card-summary">{{ @post.excerpt }}</p>
|
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<div class="card-body article-card__body">
|
<div class="card-body article-card__body">
|
||||||
<h2 class="card-title">{{ @post.title }}</h2>
|
<h2 class="card-title">{{ @post.title }}</h2>
|
||||||
<p class="meta-text">
|
<p class="meta-text">
|
||||||
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at_label }}</time>
|
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time>
|
||||||
<check if="{{ @post.has_updated_at }}">
|
<check if="{{ @post.updated_at !== @post.created_at }}">
|
||||||
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at_label }}</time></true>
|
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at | date_fr }}</time></true>
|
||||||
</check>
|
</check>
|
||||||
</p>
|
</p>
|
||||||
<p class="card-summary">{{ @post.excerpt }}</p>
|
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<a class="button button--ghost" href="{{ 'post_show', 'slug='.@post.slug | alias }}">Voir</a>
|
<a class="button button--ghost" href="{{ 'post_show', 'slug='.@post.slug | alias }}">Voir</a>
|
||||||
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
|
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
|
||||||
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
|
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
|
||||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||||
<button class="button button--danger" type="submit">Supprimer</button>
|
<button class="button button--danger" type="submit">Supprimer</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<header class="article-header">
|
<header class="article-header">
|
||||||
<h1 class="article-title" id="post-title">{{ @post.title }}</h1>
|
<h1 class="article-title" id="post-title">{{ @post.title }}</h1>
|
||||||
<p class="meta-text">
|
<p class="meta-text">
|
||||||
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at_label }}</time>
|
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time>
|
||||||
<check if="{{ @post.has_updated_at }}">
|
<check if="{{ @post.updated_at !== @post.created_at }}">
|
||||||
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at_label }}</time></true>
|
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at | date_fr }}</time></true>
|
||||||
</check>
|
</check>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -32,18 +32,6 @@ $f3->set('UPLOADS', app_root() . '/' . ltrim((string) $f3->get('UPLOADS'), '/'))
|
|||||||
app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/'));
|
app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/'));
|
||||||
app_bootstrap_logging();
|
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 ─────────────────────────────────────────────────
|
// ── Base de données ─────────────────────────────────────────────────
|
||||||
|
|
||||||
$dbPath = app_db_path();
|
$dbPath = app_db_path();
|
||||||
@@ -64,6 +52,7 @@ $f3->set('DB', $db);
|
|||||||
|
|
||||||
// ── Session ─────────────────────────────────────────────────────────
|
// ── Session ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ini_set('session.use_strict_mode', '1');
|
||||||
session_name((string) $f3->get('app.session_name'));
|
session_name((string) $f3->get('app.session_name'));
|
||||||
$f3->set('JAR', [
|
$f3->set('JAR', [
|
||||||
'expire' => 0,
|
'expire' => 0,
|
||||||
@@ -73,6 +62,12 @@ $f3->set('JAR', [
|
|||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
new Session(null, 'CSRF');
|
||||||
|
|
||||||
|
// ── Template ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Template::instance()->filter('date_fr', 'app_format_datetime_fr');
|
||||||
|
|
||||||
// ── Erreurs ─────────────────────────────────────────────────────────
|
// ── Erreurs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app_bootstrap_errors($f3);
|
app_bootstrap_errors($f3);
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ app.name=F3 Simple Blog
|
|||||||
app.tagline=Blog simple avec Fat-Free Framework.
|
app.tagline=Blog simple avec Fat-Free Framework.
|
||||||
|
|
||||||
[routes]
|
[routes]
|
||||||
GET @asset: /min/@file=AssetController->serve, 86400
|
GET @asset: /min/@file=AssetController->serve
|
||||||
|
|
||||||
GET @home: /=SiteController->home, 300
|
GET @home: /=SiteController->home
|
||||||
GET @post_show: /posts/@slug=SiteController->show, 3600
|
GET @post_show: /posts/@slug=SiteController->show
|
||||||
|
|
||||||
GET @login: /login=AuthController->show
|
GET @login: /login=AuthController->show
|
||||||
POST @login_submit: /login=AuthController->login
|
POST @login_submit: /login=AuthController->login
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "project",
|
"type": "project",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
|
"ext-intl": "*",
|
||||||
"bcosca/fatfree-core": "^3.9"
|
"bcosca/fatfree-core": "^3.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,9 @@ display_errors = Off
|
|||||||
error_log = /var/www/html/logs/php-error.log
|
error_log = /var/www/html/logs/php-error.log
|
||||||
session.use_strict_mode = 1
|
session.use_strict_mode = 1
|
||||||
|
|
||||||
|
upload_max_filesize = 10M
|
||||||
|
post_max_size = 12M
|
||||||
|
|
||||||
opcache.enable = 1
|
opcache.enable = 1
|
||||||
opcache.enable_cli = 0
|
opcache.enable_cli = 0
|
||||||
opcache.validate_timestamps = 0
|
opcache.validate_timestamps = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user