Less home code more F3

This commit is contained in:
julien
2026-03-30 00:00:03 +02:00
parent d71cf304a9
commit fac7f60190
30 changed files with 818 additions and 1552 deletions

View File

@@ -5,17 +5,12 @@ FROM php:8.3-cli AS vendor
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
libsqlite3-dev \ libsqlite3-dev \
libjpeg62-turbo-dev \
libpng-dev \
libwebp-dev \
libfreetype6-dev \
libonig-dev \ libonig-dev \
libicu-dev \ libicu-dev \
libxml2-dev \ libxml2-dev \
unzip \ unzip \
git \ git \
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite gd mbstring opcache intl dom \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
@@ -31,15 +26,10 @@ FROM php:8.3-apache
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
libsqlite3-dev \ libsqlite3-dev \
libjpeg62-turbo-dev \
libpng-dev \
libwebp-dev \
libfreetype6-dev \
libonig-dev \ libonig-dev \
libicu-dev \ libicu-dev \
libxml2-dev \ libxml2-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite gd mbstring opcache intl dom \
&& 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/*

260
README.md
View File

@@ -1,102 +1,88 @@
# F3 Simple Blog # F3 Simple Blog
Blog simple avec Fat-Free Framework et SQLite. Blog simple construit autour de deux objectifs :
- **simplicité conceptuelle** ;
- **fidélité à Fat-Free Framework**.
Le rendu visuel est volontairement soigné, mais le backend reste petit et lisible :
- **3 contrôleurs** : `SiteController`, `AuthController`, `AdminController`
- **1 contrôleur de base** : `Controller`
- **3 modèles** : `Post`, `Media`, `User`
- **1 service dédié** : `MarkdownService`
- **1 bootstrap F3**
- **2 scripts CLI** : installation et création d'admin
## Structure ## Structure
```text ```text
project/ project/
├── config.local.ini # Surcharges locales (gitignored)
├── app/ ├── app/
│ ├── config.ini # Configuration F3 (globals + routes) │ ├── bootstrap.php
│ ├── bootstrap.php # Initialisation (config, DB, session, erreurs) │ ├── config.ini
│ ├── helpers.php
│ ├── Controllers/ │ ├── Controllers/
│ ├── Helpers/ # Fonctions utilitaires ciblées │ ├── Models/
│ ├── Models/ # DB\SQL\Mapper (Post, Media, User) │ ├── Services/
│ ├── Services/ # MarkdownService
│ └── Views/ │ └── Views/
├── db/ ├── db/
│ └── app.sqlite # Base SQLite persistante
├── logs/ ├── logs/
│ └── php-error.log # Log PHP configuré au runtime
├── public/ ├── public/
│ ├── assets/ # Assets statiques servis directement │ ├── assets/
│ └── uploads/ │ └── uploads/media/
│ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG) ├── docker/
├── scripts/ ├── scripts/
│ ├── bootstrap.php # Autoload + bootstrap partagé par les scripts CLI │ ├── bootstrap.php
│ ├── install.php # Initialisation idempotente de la base │ ├── install.php
│ └── create-admin.php # Création d'un compte admin en CLI │ └── create-admin.php
└── tmp/ # Runtime temporaire, recréable sans perte métier └── tmp/uploads/
└── uploads/ # Transit Web::receive(), nettoyé après chaque upload
``` ```
## Fonctionnalités F3 utilisées ## Ce qui est utilisé côté F3
- **Routage nommé** — `config.ini [routes]`, filtre `alias` dans les templates, `reroute('@route')` dans les contrôleurs. - routes dans `config.ini`
- **Cache HTTP** — TTL par contrôleur via `expire()`, forcé à `0` quand un utilisateur est connecté. - `DB\SQL\Mapper` pour les trois tables
- **Assets statiques** — servis directement depuis `public/assets/`, laissés au serveur HTTP / reverse proxy. - `Auth` pour la connexion
- **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle. - `Session` pour la session et le jeton CSRF de base
- **Images** — normalisation des médias via `Image`, lecture/écriture via `Base::read()` / `Base::write()`. - `Template` pour le rendu et le filtre `date_fr`
- **Markdown** — `Markdown::instance()->convert()` suivi d'un assainissement DOM strict (liste blanche de balises, attributs et protocoles) et de la résolution locale des images média. - `Web::receive()` pour lupload
- **Slugs** — `Web::instance()->slug()`. - `Markdown` pour le rendu des articles
- **Session / CSRF** — `$f3->set('JAR', …)`, `new Session(null, 'CSRF')` pour s'appuyer sur la génération native F3, puis persistance d'un jeton unique en session pour qu'il reste valide entre le GET du formulaire et le POST suivant ; validation centralisée via `verifyCsrf()`. - `Web::slug()` pour les slugs
- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`.
- **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale.
## Prérequis ## Choix de simplicité
- pas de namespaces ;
- pas de couche repository ;
- pas de système de migrations ;
- pas de container DI ;
- pas de logique de proxy avancée ;
- un seul contrôleur admin pour les articles et la médiathèque ;
- pas dimage de couverture dédiée : la première image du corps sert de vignette de carte.
## Choix éditorial simplifié
Les articles n'ont plus de champ “image de couverture”. Les images vivent uniquement dans le Markdown, et la première image rendue dans `body_html` est réutilisée comme vignette dans les cartes darticle. Cela réduit le nombre de champs, supprime un cas métier entier, et garde un rendu visuel cohérent.
## Simplifications supplémentaires
- **zéro HTML brut** : le HTML saisi dans le Markdown est neutralisé avant le passage dans le parseur Markdown ; seuls les éléments générés par Markdown puis validés par le sanitizer sont rendus ;
- **images stockées brutes** : lupload accepte uniquement JPG et PNG, vérifie que le fichier est bien une image, puis le stocke tel quel sans réencodage ni transformation ; la dépendance à GD est supprimée.
## Pré-requis
### Développement local ### Développement local
- PHP 8.3+ - PHP 8.3+
- Composer - Composer
- Extensions PHP : `pdo_sqlite`, `gd`, `mbstring`, `intl`, `dom` - extensions : `pdo_sqlite`, `mbstring`, `intl`, `dom`
### Déploiement Docker ### Déploiement Docker
- Docker - Docker
- Docker Compose - Docker Compose
## Configuration ## Démarrage local
Les paramètres par défaut sont dans `app/config.ini`. Pour surcharger localement ou en production :
```bash
cp config.local.ini.example config.local.ini
```
Réglages minimums conseillés en production :
```ini
[globals]
app.env=prod
app.timezone=Europe/Paris
```
### Proxies de confiance
L'application ne délègue pas la sécurité des cookies à Apache : elle détermine le schéma de la requête pour marquer la session en `Secure`.
La valeur par défaut couvre les cas les plus courants :
- `127.0.0.1,::1` — Caddy sur le même hôte ;
- `172.16.0.0/12` — réseaux Docker IPv4 standard, y compris un Caddy conteneurisé sur le même réseau.
Si ton proxy tourne sur un autre sous-réseau, surcharge `app.trusted_proxies` dans `config.local.ini`.
```ini
[globals]
app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12
# ou, avec une liste F3 : app.trusted_proxies[]=10.0.0.42
```
Seuls ces proxies sont autorisés à influencer `X-Forwarded-Proto` / `Forwarded`.
### À propos du CSRF F3
F3 sait générer un jeton via `new Session(null, 'CSRF')`, mais ce jeton est produit à l'instanciation de la requête. Le projet s'en sert donc comme source native F3, puis le persiste explicitement en session pour garder un jeton stable entre l'affichage d'un formulaire et sa soumission.
## Développement local
```bash ```bash
composer install composer install
@@ -111,111 +97,69 @@ Créer un compte admin :
```bash ```bash
php scripts/create-admin.php admin php scripts/create-admin.php admin
# mot de passe : 10 caractères minimum
``` ```
`scripts/install.php` peut être relancé sans danger : il crée les tables si elles n'existent pas. ## Configuration
## Déploiement avec Docker Le fichier `app/config.ini` contient les valeurs par défaut. Tu peux les surcharger dans `config.local.ini`.
Exemple minimal en production :
```ini
[globals]
app.env=prod
app.timezone=Europe/Paris
```
### Important sur le mode prod
Par défaut, `app/config.ini` laisse `app.env=dev`. Pour un vrai déploiement, il faut donc fournir un `config.local.ini` avec `app.env=prod`, sinon lapplication gardera un comportement de développement.
## Déploiement Docker derrière Caddy
Le projet est cohérent pour un déploiement simple :
- Apache sert `public/` dans le conteneur ;
- `compose.yaml` expose lapplication sur `127.0.0.1:8888` par défaut ;
- Caddy termine TLS et reverse-proxy vers cette cible ;
- SQLite, les logs et les uploads sont montés sur des volumes hôtes.
Exemple :
```bash ```bash
cp config.local.ini.example config.local.ini cp config.local.ini.example config.local.ini
# édite config.local.ini (app.env=prod, app.timezone, trusted proxies si besoin) # édite config.local.ini : app.env=prod
docker compose up -d --build docker compose up -d --build
``` ```
Le déploiement Docker est cohérent avec le projet : Le `Caddyfile.example` donne une base de reverse proxy. Le point important est de **nexposer publiquement que Caddy**. Lapplication Apache/PHP ne doit pas être accessible directement depuis Internet.
- l'image embarque les extensions réellement utilisées (`pdo_sqlite`, `gd`, `mbstring`, `intl`, `dom`) ; ### Volumes persistants à sauvegarder
- `scripts/install.php` s'exécute au démarrage pour initialiser SQLite de façon idempotente ;
- seuls les répertoires métier/persistants sont montés (`db/`, `logs/`, `public/uploads/media/`) ;
- `tmp/` reste dans le conteneur et sert uniquement au runtime F3 (transit d'upload, fichiers temporaires).
Le fichier `config.local.ini` est monté en lecture seule. Si le fichier hôte n'existe pas, Docker peut créer un répertoire à la place ; l'entrypoint le supprime et l'application retombe sur les valeurs par défaut de `app/config.ini`. Pour sauvegarder le blog, il faut au minimum conserver :
Le service écoute sur `http://127.0.0.1:8888`. - `db/app.sqlite`
- `public/uploads/media/`
Créer un compte admin : Les logs peuvent aussi être conservés si tu veux garder lhistorique derreurs.
```bash ## Contrat Markdown
docker compose exec app php scripts/create-admin.php admin
# mot de passe : 10 caractères minimum
```
## Cache public Le projet assume un Markdown simple :
Les pages publiques restent cacheables pour un visiteur anonyme : - titres, listes, citations, code, liens et images Markdown ;
- pas de HTML brut auteur ;
- les images du contenu utilisent la syntaxe `media:nom-de-fichier.ext` ;
- les images externes ne sont pas rendues ;
- avec le parseur Markdown de F3, laisse une ligne vide entre deux blocs pour un rendu fiable.
- `/` : TTL de 300 s ; ## Limite volontaire : pas de migrations
- `/posts/@slug` : TTL de 3600 s.
Quand un utilisateur est connecté, le rendu est forcé en non-cacheable avec `expire(0)` pour ne pas servir du contenu admin via un cache intermédiaire. `scripts/install.php` crée les tables si elles nexistent pas. En revanche, il ne gère pas les évolutions de schéma. Si le modèle de données change dans le futur, il faudra soit repartir dune base propre, soit appliquer une migration manuelle.
Les assets statiques (`public/assets/`) sont servis directement ; leur éventuel cache HTTP relève du serveur web ou du reverse proxy. ## Nettoyage de confort appliqué
## Médias et limites d'upload - suppression d'une méthode utilitaire inutilisée dans `Media` ;
- suppression d'un paramètre mort dans `Post::usesMedia()` ;
- Formats acceptés à l'entrée : JPG, PNG, WebP. - simplification de l'upload temporaire côté admin ;
- Taille max du fichier reçu : 10 Mo. - détection `secure` des cookies un peu plus tolérante (`HTTPS`, `REQUEST_SCHEME`, `X-Forwarded-Proto`) sans réintroduire une grosse logique proxy.
- Dimensions max : 8000 × 8000 px, 40 mégapixels.
- Sortie publiée : JPG pour les sources JPEG, PNG pour les sources PNG/WebP.
- Texte alternatif initial dérivé du nom de fichier d'origine.
La médiathèque admin est paginée et le picker dans l'éditeur charge seulement les 60 images les plus récentes.
## Reverse proxy Caddy
### Caddy sur le même hôte
```caddy
blog.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8888
}
```
### Caddy dans Docker
Si Caddy tourne aussi dans Docker, place-le sur le même réseau que `app` et cible directement le service :
```caddy
blog.example.com {
encode zstd gzip
reverse_proxy app:80
}
```
Dans la plupart des cas, la valeur par défaut de `app.trusted_proxies` suffit aussi pour ce mode ; surcharge-la seulement si ton réseau Docker utilise un autre sous-réseau.
Comme F3 lit aussi `X-Forwarded-For` pour déterminer `IP`, le conteneur applicatif ne doit pas être exposé directement à Internet : Caddy doit rester l'unique point d'entrée public et réécrire les en-têtes `X-Forwarded-*`.
Le fichier `Caddyfile.example` fournit en plus un jeu d'en-têtes de sécurité minimal.
## Répartition des responsabilités avec Caddy
Le projet n'essaie pas de dupliquer ce que le reverse proxy gère naturellement :
- **Caddy** — TLS, compression, en-têtes de sécurité, terminaison HTTPS, reverse proxy.
- **Application / PHP-F3** — sessions, CSRF, cookies, validation métier, rendu HTML et cache HTTP des pages dynamiques.
## Données à sauvegarder
- `db/` — base SQLite.
- `public/uploads/media/` — images.
- `logs/` — optionnel, utile pour diagnostic.
## Mise à jour
```bash
docker compose up -d --build
```
## Logs
- PHP : `logs/php-error.log`.
- Apache / conteneur : `docker compose logs -f app`.
## Notes
- Les dates sont stockées en UTC (`gmdate`) puis formatées côté affichage avec le fuseau configuré.
- Le pipeline Markdown n'autorise que les éléments utiles au rendu éditorial et remappe systématiquement les images vers la médiathèque locale.

View File

@@ -2,10 +2,188 @@
declare(strict_types=1); declare(strict_types=1);
abstract class AdminController extends BaseController class AdminController extends Controller
{ {
private const MEDIA_PICKER_LIMIT = 60;
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
public function beforeRoute(): void public function beforeRoute(): void
{ {
$this->requireAuth(); $this->requireAuth();
} }
public function index(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?: 1));
$result = (new Post())->page($page, 12);
$this->render('admin/dashboard.html', [
'pageTitle' => 'Tableau de bord',
'posts' => $result['items'],
'pagination' => $result['pagination'],
'paginationAlias' => 'dashboard',
'adminMode' => true,
]);
}
public function create(): void
{
$this->postForm('Nouvel article', $this->f3->alias('post_store'), Post::blank());
}
public function store(): void
{
$this->checkCsrf();
$input = $this->postInput();
try {
(new Post())->savePost($input);
$this->flash('success', 'Article créé.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->postForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
}
}
public function edit(): void
{
$post = (new Post())->findForForm((int) $this->f3->get('PARAMS.id'));
if (!$post) {
$this->f3->error(404);
return;
}
$this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
}
public function update(): void
{
$this->checkCsrf();
$id = (int) $this->f3->get('PARAMS.id');
$input = $this->postInput() + ['id' => $id];
try {
(new Post())->savePost($input, $id);
$this->flash('success', 'Article mis à jour.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
}
}
public function delete(): void
{
$this->checkCsrf();
try {
(new Post())->deleteById((int) $this->f3->get('PARAMS.id'));
$this->flash('success', 'Article supprimé.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@dashboard');
}
public function media(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?: 1));
$result = (new Media())->page($page, 24);
$this->render('admin/media.html', [
'pageTitle' => 'Médiathèque',
'items' => $result['items'],
'pagination' => $result['pagination'],
'paginationAlias' => 'media_index',
]);
}
public function mediaUpload(): void
{
$this->checkCsrf();
try {
$original = (string) ($this->f3->get('FILES.image.name') ?: '');
$received = Web::instance()->receive(
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0 && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
overwrite: false
);
$path = array_key_first(array_filter($received));
if (!$path) {
throw new RuntimeException('Choisis une image valide à envoyer.');
}
(new Media())->upload($path, $original);
$this->flash('success', 'Image ajoutée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
public function mediaAlt(): void
{
$this->checkCsrf();
try {
(new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), $this->f3->clean((string) ($this->f3->get('POST.alt') ?: '')));
$this->flash('success', 'Texte alternatif mis à jour.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
public function mediaDelete(): void
{
$this->checkCsrf();
try {
$media = new Media();
$item = $media->findById((int) $this->f3->get('PARAMS.id'));
if (!$item) {
throw new RuntimeException('Image introuvable.');
}
if ((new Post())->usesMedia($item['file_name'])) {
throw new RuntimeException('Cette image est encore utilisée par un article.');
}
$media->deleteById($item['id']);
$this->flash('success', 'Image supprimée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
private function postForm(string $title, string $action, array $post, ?string $error = null): void
{
$media = new Media();
$items = $media->recent(self::MEDIA_PICKER_LIMIT);
$count = $media->count();
$this->render('admin/post_form.html', [
'pageTitle' => $title,
'formAction' => $action,
'post' => $post,
'mediaItems' => $items,
'mediaCount' => $count,
'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT,
'mediaPickerTruncated' => $count > count($items),
'titleMax' => Post::TITLE_MAX_LENGTH,
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
'flash' => $error ? [['type' => 'error', 'message' => $error]] : [],
]);
}
private function postInput(): array
{
return [
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?: '')),
'excerpt' => $this->f3->clean((string) ($this->f3->get('POST.excerpt') ?: '')),
'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?: '')),
];
}
} }

View File

@@ -2,11 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
class AuthController extends BaseController class AuthController extends Controller
{ {
public function show(): void public function show(): void
{ {
if ($this->currentUser() !== null) { if ($this->user()) {
$this->f3->reroute('@dashboard'); $this->f3->reroute('@dashboard');
return; return;
} }
@@ -16,36 +16,35 @@ class AuthController extends BaseController
public function login(): void public function login(): void
{ {
$this->verifyCsrf(); $this->checkCsrf();
$username = $this->f3->clean((string) ($this->f3->get('POST.username') ?? ''));
$password = (string) ($this->f3->get('POST.password') ?? '');
// User étend DB\SQL\Mapper — inutile de recréer un Mapper générique.
// Le 3e argument du constructeur Auth est le callback de comparaison.
$user = new User(); $user = new User();
$auth = new \Auth($user, ['id' => 'username', 'pw' => 'password_hash'], 'password_verify'); $auth = new Auth($user, ['id' => 'username', 'pw' => 'password_hash'], 'password_verify');
$ok = $auth->login(
$this->f3->clean((string) ($this->f3->get('POST.username') ?: '')),
(string) ($this->f3->get('POST.password') ?: '')
);
if (!$auth->login($username, $password)) { if (!$ok) {
usleep(1_500_000); // 1,5 s — ralentit le brute-force usleep(1000000);
$this->flash('error', 'Identifiants invalides.'); $this->flash('error', 'Identifiants invalides.');
$this->f3->reroute('@login'); $this->f3->reroute('@login');
return; return;
} }
session_regenerate_id(true); // Prévient la fixation de session. session_regenerate_id(true);
$this->resetCsrfToken(); // Le nouveau contexte repart avec un jeton dédié. $this->f3->set('SESSION.user_id', (int) $user->id);
$this->f3->set('SESSION.user_id', $user->id); $this->rotateCsrf();
$this->flash('success', 'Connexion réussie.'); $this->flash('success', 'Connexion réussie.');
$this->f3->reroute('@dashboard'); $this->f3->reroute('@dashboard');
} }
public function logout(): void public function logout(): void
{ {
$this->verifyCsrf(); $this->checkCsrf();
$this->f3->clear('SESSION.user_id'); $this->f3->clear('SESSION.user_id');
$this->resetCsrfToken(); session_regenerate_id(true);
session_regenerate_id(true); // Invalide l'ancien ID de session. $this->rotateCsrf();
$this->flash('success', 'Déconnexion effectuée.'); $this->flash('success', 'Déconnexion effectuée.');
$this->f3->reroute('@login'); $this->f3->reroute('@login');
} }

View File

@@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
abstract class BaseController
{
protected Base $f3;
public function __construct()
{
$this->f3 = Base::instance();
}
protected function render(string $view, array $data = [], int $cacheTtl = 0): void
{
// Les pages publiques restent cacheables avec le TTL demandé.
// Si un utilisateur est connecté, le layout dépend de la session
// (navigation admin, déconnexion + CSRF) : on force expire(0)
// pour ne pas servir ce rendu à d'autres visiteurs.
$currentUser = $this->currentUser();
$this->f3->expire($currentUser !== null ? 0 : $cacheTtl);
$flash = array_key_exists('flash', $data) && is_array($data['flash'])
? $data['flash']
: $this->pullFlash();
// On s'appuie sur Session(..., 'CSRF') pour la génération F3 du
// jeton, mais on le persiste en session pour qu'il reste valide
// entre la requête GET qui rend le formulaire et le POST suivant.
$this->ensureCsrfToken();
$this->f3->mset($data + [
'view' => $view,
'flash' => $flash,
'metaDescription' => null,
'adminMode' => false,
'currentUser' => $currentUser,
'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
]);
echo Template::instance()->render('layout.html');
}
// Résout l'utilisateur courant une seule fois par requête et le
// stocke dans le hive — accessible partout, y compris les templates.
protected function currentUser(): ?array
{
if (!$this->f3->exists('ctx.current_user_loaded')) {
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
$user = $userId > 0 ? (new User())->findById($userId) : null;
$this->f3->set('currentUser', $user);
$this->f3->set('ctx.current_user_loaded', true);
}
return $this->f3->get('currentUser');
}
protected function requireAuth(): void
{
if ($this->currentUser() !== null) {
return;
}
$this->flash('error', 'Connecte-toi pour continuer.');
$this->f3->reroute('@login');
}
protected function verifyCsrf(): void
{
$submitted = trim((string) ($this->f3->get('POST.csrf_token') ?? ''));
$expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?? ''));
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
return;
}
$this->f3->error(400, 'Jeton CSRF invalide.');
}
// Empile un message flash — permet plusieurs messages par requête.
protected function flash(string $type, string $message): void
{
$this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]);
}
protected function resetCsrfToken(): void
{
$this->f3->clear('SESSION.csrf_token');
$this->ensureCsrfToken();
}
private function ensureCsrfToken(): void
{
$token = trim((string) ($this->f3->get('SESSION.csrf_token') ?? ''));
if ($token !== '') {
return;
}
$seed = trim((string) ($this->f3->get('CSRF') ?? ''));
if ($seed === '') {
$seed = bin2hex(random_bytes(32));
}
$this->f3->set('SESSION.csrf_token', $seed);
}
private function pullFlash(): array
{
return $this->f3->pull('SESSION.flash') ?: [];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
abstract class Controller
{
protected Base $f3;
public function __construct()
{
$this->f3 = Base::instance();
}
protected function render(string $view, array $data = [], int $ttl = 0): void
{
$this->ensureCsrf();
$user = $this->user();
$flash = array_key_exists('flash', $data) ? $data['flash'] : $this->pullFlash();
$this->f3->expire($user ? 0 : $ttl);
$this->f3->mset($data + [
'view' => $view,
'flash' => is_array($flash) ? $flash : [],
'currentUser' => $user,
'adminMode' => false,
'metaDescription' => null,
'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
]);
echo Template::instance()->render('layout.html');
}
protected function user(): ?array
{
if (!$this->f3->exists('ctx.user_loaded')) {
$id = (int) ($this->f3->get('SESSION.user_id') ?: 0);
$this->f3->set('currentUser', $id > 0 ? (new User())->findPublic($id) : null);
$this->f3->set('ctx.user_loaded', true);
}
return $this->f3->get('currentUser');
}
protected function requireAuth(): void
{
if ($this->user()) {
return;
}
$this->flash('error', 'Connecte-toi pour continuer.');
$this->f3->reroute('@login');
}
protected function checkCsrf(): void
{
$sent = trim((string) ($this->f3->get('POST.csrf_token') ?: ''));
$expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?: ''));
if ($sent !== '' && $expected !== '' && hash_equals($expected, $sent)) {
return;
}
$this->f3->error(400);
}
protected function flash(string $type, string $message): void
{
$this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]);
}
protected function rotateCsrf(): void
{
$this->f3->clear('SESSION.csrf_token');
$this->ensureCsrf();
}
private function ensureCsrf(): void
{
if ($this->f3->exists('SESSION.csrf_token')) {
return;
}
$seed = trim((string) ($this->f3->get('CSRF') ?: ''));
$this->f3->set('SESSION.csrf_token', $seed !== '' ? $seed : bin2hex(random_bytes(16)));
}
private function pullFlash(): array
{
return $this->f3->pull('SESSION.flash') ?: [];
}
}

View File

@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
class MediaController extends AdminController
{
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
private const PER_PAGE = 24;
public function index(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$result = (new Media())->paginateLibrary($page, self::PER_PAGE);
$this->render('admin/media.html', [
'pageTitle' => 'Médiathèque',
'items' => $result['items'],
'pagination' => $result,
'paginationAlias' => 'media_index',
]);
}
public function upload(): void
{
$this->verifyCsrf();
try {
// Lire le nom d'origine avant que Web::receive() déplace le fichier.
$originalName = (string) ($this->f3->get('FILES.image.name') ?? '');
$received = Web::instance()->receive(
// F3 gère le transport et le renommage ; la validation métier
// (format réel, dimensions, réencodage) reste centralisée dans Media.
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0
&& (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
overwrite: false,
slug: true
);
// Le formulaire n'envoie qu'un seul fichier : on garde le premier
// chemin accepté retourné par Web::receive().
$accepted = array_keys(array_filter($received));
$destPath = $accepted[0] ?? null;
if ($destPath === null) {
throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int) (self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).');
}
(new Media())->upload($destPath, $originalName);
$this->flash('success', 'Image ajoutée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
public function updateAlt(): void
{
$this->verifyCsrf();
try {
$alt = $this->f3->clean((string) ($this->f3->get('POST.alt') ?? ''));
(new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), $alt);
$this->flash('success', 'Texte alternatif mis à jour.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
public function delete(): void
{
$this->verifyCsrf();
try {
$id = (int) $this->f3->get('PARAMS.id');
$media = new Media();
$item = $media->findById($id);
if ($item === null) {
throw new RuntimeException('Image introuvable.');
}
if ((new Post())->isMediaUsed($item['id'], $item['file_name'])) {
throw new RuntimeException('Cette image est encore utilisée par un article.');
}
$media->delete($id);
$this->flash('success', 'Image supprimée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@media_index');
}
}

View File

@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
class PostController extends AdminController
{
private const PER_PAGE = 12;
private const MEDIA_PICKER_LIMIT = 60;
public function index(): void
{
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
$media = new Media();
$result = (new Post())->paginateList($page, self::PER_PAGE, $media);
$this->render('admin/dashboard.html', [
'pageTitle' => 'Tableau de bord',
'posts' => $result['posts'],
'pagination' => $result,
'paginationAlias' => 'dashboard',
'adminMode' => true,
]);
}
public function create(): void
{
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), Post::emptyForm());
}
public function store(): void
{
$this->verifyCsrf();
$media = new Media();
$input = $this->postInput();
try {
(new Post())->create($input, $media);
$this->flash('success', 'Article créé.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage(), $media);
}
}
public function edit(): void
{
$post = (new Post())->findForEdit((int) $this->f3->get('PARAMS.id'));
if ($post === null) {
$this->f3->error(404, 'Article introuvable.');
return;
}
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
}
public function update(): void
{
$this->verifyCsrf();
$media = new Media();
$id = (int) $this->f3->get('PARAMS.id');
$input = $this->postInput() + ['id' => $id];
try {
$updated = (new Post())->updatePost($id, $input, $media);
if (!$updated) {
$this->f3->error(404, 'Article introuvable.');
return;
}
$this->flash('success', 'Article mis à jour.');
$this->f3->reroute('@dashboard');
} catch (RuntimeException $e) {
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage(), $media);
}
}
public function delete(): void
{
$this->verifyCsrf();
try {
(new Post())->delete((int) $this->f3->get('PARAMS.id'));
$this->flash('success', 'Article supprimé.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
$this->f3->reroute('@dashboard');
}
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null, ?Media $media = null): void
{
$media ??= new Media();
$coverPreview = null;
if (!empty($post['cover_media_id'])) {
$coverPreview = $media->findById((int) $post['cover_media_id']);
}
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
$mediaCount = $media->count();
$flash = $error !== null ? [['type' => 'error', 'message' => $error]] : [];
$this->render('admin/post_form.html', [
'pageTitle' => $pageTitle,
'formAction' => $formAction,
'post' => $post,
'coverPreview' => $coverPreview,
'mediaItems' => $mediaItems,
'mediaCount' => $mediaCount,
'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT,
'mediaPickerTruncated' => $mediaCount > count($mediaItems),
'titleMax' => Post::TITLE_MAX_LENGTH,
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
'flash' => $flash,
]);
}
private function postInput(): array
{
return [
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?? '')),
'excerpt' => $this->f3->clean((string) ($this->f3->get('POST.excerpt') ?? '')),
'cover_media_id' => (string) ($this->f3->get('POST.cover_media_id') ?? ''),
'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?? '')),
];
}
}

View File

@@ -2,28 +2,26 @@
declare(strict_types=1); declare(strict_types=1);
class SiteController extends BaseController class SiteController extends Controller
{ {
public function home(): void public function home(): void
{ {
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); $page = max(1, (int) ($this->f3->get('GET.page') ?: 1));
$media = new Media(); $result = (new Post())->page($page, 12);
$result = (new Post())->paginateList($page, 12, $media);
$this->render('site/home.html', [ $this->render('site/home.html', [
'pageTitle' => 'Accueil', 'pageTitle' => 'Articles',
'posts' => $result['posts'], 'posts' => $result['items'],
'pagination' => $result, 'pagination' => $result['pagination'],
'paginationAlias' => 'home', 'paginationAlias' => 'home',
], 300); ], 300);
} }
public function show(): void public function show(): void
{ {
$media = new Media(); $post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'));
$post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'), $media); if (!$post) {
if ($post === null) { $this->f3->error(404);
$this->f3->error(404, 'Article introuvable.');
return; return;
} }
@@ -31,6 +29,6 @@ class SiteController extends BaseController
'pageTitle' => $post['title'], 'pageTitle' => $post['title'],
'metaDescription' => $post['excerpt'], 'metaDescription' => $post['excerpt'],
'post' => $post, 'post' => $post,
], 3600); ], 300);
} }
} }

View File

@@ -1,210 +0,0 @@
<?php
declare(strict_types=1);
function app_timezone(): string
{
$timezone = trim((string) Base::instance()->get('app.timezone'));
return ($timezone !== '' && in_array($timezone, DateTimeZone::listIdentifiers(), true)) ? $timezone : 'UTC';
}
function app_now(): string
{
return gmdate('Y-m-d H:i:s');
}
function app_is_prod(): bool
{
return Base::instance()->get('app.env') === 'prod';
}
function app_ensure_dir(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0775, true);
}
}
function app_unique_slug(string $value, callable $exists): string
{
$base = Web::instance()->slug(trim($value));
if ($base === '') {
$base = 'article';
}
if (!$exists($base)) {
return $base;
}
for ($i = 2; $i <= 1000; $i++) {
$candidate = $base . '-' . $i;
if (!$exists($candidate)) {
return $candidate;
}
}
throw new RuntimeException('Impossible de générer un slug unique.');
}
function app_format_datetime_fr(string $value): string
{
static $utc, $formatter;
$value = trim($value);
if ($value === '') {
return '';
}
try {
$utc ??= new DateTimeZone('UTC');
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $utc);
if (!$date instanceof DateTimeImmutable) {
$date = new DateTimeImmutable($value, $utc);
}
$date = $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
$formatter ??= new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::LONG,
IntlDateFormatter::SHORT,
date_default_timezone_get(),
IntlDateFormatter::GREGORIAN,
"d MMMM yyyy 'à' HH:mm"
);
$formatted = $formatter->format($date);
return is_string($formatted) && $formatted !== '' ? $formatted : $value;
} catch (Throwable) {
return $value;
}
}
function app_trusted_proxies(): array
{
$value = Base::instance()->get('app.trusted_proxies');
if (is_array($value)) {
$items = [];
array_walk_recursive($value, static function (mixed $item) use (&$items): void {
if (is_string($item) || is_numeric($item)) {
$items[] = (string) $item;
}
});
} else {
$raw = trim((string) $value);
if ($raw === '') {
return [];
}
$items = preg_split('/[\s,]+/', $raw) ?: [];
}
$normalized = [];
foreach ($items as $item) {
foreach (preg_split('/[\s,]+/', trim((string) $item)) ?: [] as $part) {
$part = trim($part);
if ($part !== '') {
$normalized[] = $part;
}
}
}
return array_values(array_unique($normalized));
}
function app_is_trusted_proxy(?string $ip = null): bool
{
$ip = trim((string) ($ip ?? Base::instance()->get('SERVER.REMOTE_ADDR')));
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
return false;
}
foreach (app_trusted_proxies() as $proxy) {
if (app_ip_matches_proxy($ip, $proxy)) {
return true;
}
}
return false;
}
function app_request_scheme(): string
{
$f3 = Base::instance();
$https = strtolower(trim((string) $f3->get('SERVER.HTTPS')));
if ($https !== '' && $https !== 'off' && $https !== '0') {
return 'https';
}
$scheme = strtolower(trim((string) $f3->get('SCHEME')));
if ($scheme === 'https') {
return 'https';
}
// Derrière un reverse proxy de confiance, on accepte le proto transmis.
if (app_is_trusted_proxy()) {
$forwardedProto = trim((string) $f3->get('SERVER.HTTP_X_FORWARDED_PROTO'));
if ($forwardedProto !== '') {
$forwardedProto = strtolower(trim(explode(',', $forwardedProto)[0]));
return $forwardedProto === 'https' ? 'https' : 'http';
}
$forwarded = trim((string) $f3->get('SERVER.HTTP_FORWARDED'));
if ($forwarded !== '' && preg_match('/(?:^|[;,]\s*)proto=(https?)/i', $forwarded, $matches) === 1) {
return strtolower($matches[1]) === 'https' ? 'https' : 'http';
}
}
return 'http';
}
function app_ip_matches_proxy(string $ip, string $proxy): bool
{
$proxy = trim($proxy);
if ($proxy === '') {
return false;
}
if (!str_contains($proxy, '/')) {
return filter_var($proxy, FILTER_VALIDATE_IP) !== false && strcasecmp($ip, $proxy) === 0;
}
[$subnet, $prefix] = explode('/', $proxy, 2);
$subnet = trim($subnet);
$prefix = trim($prefix);
$ipBinary = inet_pton($ip);
$subnetBinary = inet_pton($subnet);
if ($ipBinary === false || $subnetBinary === false || strlen($ipBinary) !== strlen($subnetBinary)) {
return false;
}
if (!ctype_digit($prefix)) {
return false;
}
$prefixLength = (int) $prefix;
$maxBits = strlen($ipBinary) * 8;
if ($prefixLength < 0 || $prefixLength > $maxBits) {
return false;
}
$fullBytes = intdiv($prefixLength, 8);
if ($fullBytes > 0 && substr($ipBinary, 0, $fullBytes) !== substr($subnetBinary, 0, $fullBytes)) {
return false;
}
$remainingBits = $prefixLength % 8;
if ($remainingBits === 0) {
return true;
}
$mask = (0xFF << (8 - $remainingBits)) & 0xFF;
return (ord($ipBinary[$fullBytes]) & $mask) === (ord($subnetBinary[$fullBytes]) & $mask);
}

View File

@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
function app_bootstrap_logging(): void
{
$dir = rtrim((string) Base::instance()->get('LOGS'), '/\\') . DIRECTORY_SEPARATOR;
app_ensure_dir($dir);
ini_set('log_errors', '1');
ini_set('error_log', $dir . 'php-error.log');
ini_set('display_errors', app_is_prod() ? '0' : '1');
error_reporting(E_ALL);
}
function app_error_meta(int $code): array
{
return match ($code) {
400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'],
403 => ['title' => 'Accès refusé', 'message' => 'Tu n\u2019as pas accès à cette ressource.'],
404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'],
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
};
}
function app_render_error_fallback(int $code): void
{
$meta = app_error_meta($code);
$base = rtrim((string) Base::instance()->get('BASE'), '/');
while (ob_get_level() > 0) {
ob_end_clean();
}
if (!headers_sent()) {
http_response_code($code);
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
}
$title = htmlspecialchars((string) $meta['title'], ENT_QUOTES, 'UTF-8');
$message = htmlspecialchars((string) $meta['message'], ENT_QUOTES, 'UTF-8');
$href = htmlspecialchars($base . '/', ENT_QUOTES, 'UTF-8');
echo '<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . $title . '</title></head><body><main><h1>' . $title . '</h1><p>' . $message . '</p><p><a href="' . $href . '">Retour à l\'accueil</a></p></main></body></html>';
}
function app_bootstrap_errors(Base $f3): void
{
// En dev, ne pas poser ONERROR : le handler par défaut de F3
// affiche la stack trace complète quand DEBUG > 0.
if (!app_is_prod()) {
return;
}
register_shutdown_function(function (): void {
$error = error_get_last();
if ($error === null) {
return;
}
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
if (!in_array($error['type'] ?? 0, $fatalTypes, true)) {
return;
}
app_render_error_fallback(500);
});
$f3->set('ONERROR', function (Base $f3): void {
$code = max((int) ($f3->get('ERROR.code') ?? 500), 1);
$f3->expire(0);
$f3->status($code);
$meta = app_error_meta($code);
$f3->mset([
'errorCode' => $code,
'errorTitle' => $meta['title'],
'errorMessage' => $meta['message'],
]);
try {
echo Template::instance()->render('errors/error.html');
} catch (Throwable) {
app_render_error_fallback($code);
}
});
}

View File

@@ -4,10 +4,6 @@ declare(strict_types=1);
class Media extends DB\SQL\Mapper class Media extends DB\SQL\Mapper
{ {
private const MAX_WIDTH = 8000;
private const MAX_HEIGHT = 8000;
private const MAX_PIXELS = 40_000_000;
public function __construct() public function __construct()
{ {
parent::__construct(Base::instance()->get('DB'), 'media'); parent::__construct(Base::instance()->get('DB'), 'media');
@@ -30,49 +26,27 @@ class Media extends DB\SQL\Mapper
$db->exec('CREATE INDEX idx_media_created_at ON media(created_at DESC)'); $db->exec('CREATE INDEX idx_media_created_at ON media(created_at DESC)');
} }
public function paginateLibrary(int $page = 1, int $perPage = 24): array public function page(int $page, int $perPage): array
{ {
$result = $this->paginate( $result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
max(0, $page - 1),
$perPage,
null,
['order' => 'created_at DESC, id DESC']
);
return [ return [
'items' => array_map(fn(self $m): array => $this->decorate($m->cast()), $result['subset']), 'items' => array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []),
'page' => max(1, min($page, $result['count'] ?: 1)), 'pagination' => [
'pages' => $result['count'] ?: 1, 'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => max(1, (int) ($result['count'] ?: 1)),
],
]; ];
} }
public function latest(int $limit = 60): array public function recent(int $limit): array
{ {
return array_map( return array_map(
fn(self $m): array => $this->decorate($m->cast()), fn(self $row): array => $this->decorate($row->cast()),
$this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: [] $this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: []
); );
} }
public function findByIds(array $ids): array
{
$ids = array_filter(array_unique(array_map('intval', $ids)));
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$results = $this->find(["id IN ($placeholders)", ...array_values($ids)]);
$map = [];
foreach ($results ?: [] as $m) {
$row = $this->decorate($m->cast());
$map[$row['id']] = $row;
}
return $map;
}
public function findById(int $id): ?array public function findById(int $id): ?array
{ {
if ($id <= 0) { if ($id <= 0) {
@@ -89,64 +63,50 @@ class Media extends DB\SQL\Mapper
return $this->dry() ? null : $this->decorate($this->cast()); return $this->dry() ? null : $this->decorate($this->cast());
} }
// Traite le fichier temporaire déposé par Web::receive() et publie l'image. public function upload(string $path, string $originalName = ''): int
public function upload(string $srcPath, string $originalName = ''): int
{ {
$target = null; if (!is_file($path)) {
$committed = false; // Contrôle le nettoyage de $target dans finally. throw new RuntimeException('Fichier image introuvable.');
try {
$meta = self::inspectUpload($srcPath);
// F3 Image : load() utilise imagecreatefromstring + imagesavealpha.
$img = new \Image();
$f3 = Base::instance();
if (!$img->load($f3->read($srcPath))) {
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
}
// PNG/WebP → PNG (préserve la transparence), JPG → JPG.
$isJpeg = ($meta['mime'] === 'image/jpeg');
$extension = $isJpeg ? 'jpg' : 'png';
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = rtrim((string) $f3->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
// dump() appelle image{format}($data, NULL, $quality).
$binary = $isJpeg ? $img->dump('jpeg', 85) : $img->dump('png', 6);
if ($binary === '' || $f3->write($target, $binary) === false) {
throw new RuntimeException('Impossible d\'enregistrer cette image.');
}
$this->db->begin();
try {
$this->reset();
$this->file_name = $fileName;
$this->alt = $originalName !== '' ? self::altFromFilename($originalName) : '';
$this->width = $meta['width'];
$this->height = $meta['height'];
$this->created_at = app_now();
$this->save();
$this->db->commit();
$committed = true;
} catch (Throwable $e) {
$this->db->rollback();
throw $e;
}
return (int) $this->get('id');
} catch (Throwable $e) {
throw $e instanceof RuntimeException ? $e : new RuntimeException('Impossible d\'enregistrer cette image.');
} finally {
// Le destructeur de F3 Image libère la ressource GD.
if (is_file($srcPath)) {
@unlink($srcPath);
}
if (!$committed && $target !== null && is_file($target)) {
@unlink($target);
}
} }
$info = @getimagesize($path);
if (!is_array($info)) {
@unlink($path);
throw new RuntimeException('Fichier image invalide.');
}
$mime = strtolower((string) ($info['mime'] ?? ''));
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
default => null,
};
if ($extension === null) {
@unlink($path);
throw new RuntimeException('Format non supporté. Utilise JPG ou PNG.');
}
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
if (!@rename($path, $target)) {
if (!@copy($path, $target)) {
@unlink($path);
throw new RuntimeException('Impossible denregistrer cette image.');
}
@unlink($path);
}
$this->reset();
$this->file_name = $fileName;
$this->alt = $this->altFromName($originalName);
$this->width = (int) $info[0];
$this->height = (int) $info[1];
$this->created_at = app_now();
$this->save();
return (int) $this->id;
} }
public function updateAlt(int $id, string $alt): void public function updateAlt(int $id, string $alt): void
@@ -160,7 +120,7 @@ class Media extends DB\SQL\Mapper
$this->save(); $this->save();
} }
public function delete(int $id): void public function deleteById(int $id): void
{ {
$this->load(['id = ?', $id]); $this->load(['id = ?', $id]);
if ($this->dry()) { if ($this->dry()) {
@@ -168,85 +128,33 @@ class Media extends DB\SQL\Mapper
} }
$path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name; $path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name;
$this->erase();
$this->db->begin(); if (is_file($path)) {
try { @unlink($path);
$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.');
} }
} }
private static function inspectUpload(string $srcPath): array
{
if (!is_file($srcPath)) {
throw new RuntimeException('Fichier image introuvable.');
}
$info = @getimagesize($srcPath);
if (!is_array($info)) {
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
}
$width = (int) ($info[0] ?? 0);
$height = (int) ($info[1] ?? 0);
$mime = strtolower((string) ($info['mime'] ?? ''));
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
throw new RuntimeException('Format non supporté. Utilise JPG, PNG ou WebP.');
}
if ($width <= 0 || $height <= 0) {
throw new RuntimeException('Dimensions image invalides.');
}
if ($width > self::MAX_WIDTH || $height > self::MAX_HEIGHT || ($width * $height) > self::MAX_PIXELS) {
throw new RuntimeException('Image trop grande. Limite : 8000 × 8000 px et 40 mégapixels.');
}
return [
'width' => $width,
'height' => $height,
'mime' => $mime,
];
}
// Dérive un texte alternatif lisible depuis le nom de fichier d'origine.
private static function altFromFilename(string $filename): string
{
$name = pathinfo($filename, PATHINFO_FILENAME);
$name = trim((string) preg_replace('/[-_]+/', ' ', $name));
// mb_ucfirst() n'existe qu'en PHP 8.4 — on l'émule.
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
}
private function mediaUrl(string $fileName): string
{
$f3 = Base::instance();
$base = rtrim((string) $f3->get('BASE'), '/');
$prefix = '/' . trim((string) $f3->get('paths.media_base'), '/');
return $base . $prefix . '/' . rawurlencode($fileName);
}
private function decorate(array $row): array private function decorate(array $row): array
{ {
$file = (string) $row['file_name'];
$alt = (string) $row['alt']; $alt = (string) $row['alt'];
return [ return [
'id' => (int) $row['id'], 'id' => (int) $row['id'],
'file_name' => (string) $row['file_name'], 'file_name' => $file,
'alt' => $alt, 'alt' => $alt,
'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'],
'url' => $this->mediaUrl((string) $row['file_name']), 'url' => rtrim((string) Base::instance()->get('BASE'), '/') . rtrim((string) Base::instance()->get('paths.media_base'), '/') . '/' . rawurlencode($file),
'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')', 'markdown' => '![' . $alt . '](media:' . $file . ')',
]; ];
} }
private function altFromName(string $name): string
{
$name = trim(pathinfo($name, PATHINFO_FILENAME));
$name = preg_replace('/[-_]+/', ' ', $name) ?: '';
return trim($name);
}
} }

View File

@@ -25,129 +25,87 @@ class Post extends DB\SQL\Mapper
excerpt TEXT NOT NULL, excerpt TEXT NOT NULL,
body_markdown TEXT NOT NULL, body_markdown TEXT NOT NULL,
body_html TEXT NOT NULL, body_html TEXT NOT NULL,
cover_media_id INTEGER DEFAULT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_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 idx_posts_created_at ON posts(created_at DESC)'); $db->exec('CREATE INDEX idx_posts_created_at ON posts(created_at DESC)');
} }
public static function emptyForm(): array public static function blank(): array
{ {
return [ return [
'title' => '', 'title' => '',
'excerpt' => '', 'excerpt' => '',
'cover_media_id' => '',
'body_markdown' => '', 'body_markdown' => '',
]; ];
} }
public function paginateList(int $page, int $perPage, Media $media): array public function page(int $page, int $perPage): array
{ {
$result = $this->paginate( $result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
max(0, $page - 1), $items = array_map(fn(self $row): array => $this->summary($row->cast()), $result['subset'] ?: []);
$perPage,
null,
['order' => 'created_at DESC, id DESC']
);
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
$coverIds = array_filter(array_unique(array_column($posts, 'cover_media_id')));
$covers = $media->findByIds($coverIds);
foreach ($posts as &$post) {
$cover = $covers[$post['cover_media_id']] ?? null;
$post['cover_url'] = $cover['url'] ?? '';
$post['cover_alt'] = $cover['alt'] ?? '';
}
return [ return [
'posts' => $posts, 'items' => $items,
'page' => max(1, min($page, $result['count'] ?: 1)), 'pagination' => [
'pages' => $result['count'] ?: 1, 'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => max(1, (int) ($result['count'] ?: 1)),
],
]; ];
} }
public function findBySlug(string $slug, Media $media): ?array public function findBySlug(string $slug): ?array
{ {
$this->load(['slug = ?', $slug]); $this->load(['slug = ?', $slug]);
if ($this->dry()) { if ($this->dry()) {
return null; return null;
} }
$post = $this->summaryRow($this->cast()); $row = $this->cast();
$cover = $post['cover_media_id'] > 0 ? $media->findById($post['cover_media_id']) : null; $post = $this->summary($row) + ['body_html' => (string) $row['body_html']];
$post['cover_url'] = $cover['url'] ?? '';
$post['cover_alt'] = $cover['alt'] ?? '';
$post['body_html'] = (string) $this->body_html;
return $post; return $post;
} }
public function findForEdit(int $id): ?array public function findForForm(int $id): ?array
{ {
if ($id <= 0) {
return null;
}
$this->load(['id = ?', $id]); $this->load(['id = ?', $id]);
if ($this->dry()) { if ($this->dry()) {
return null; return null;
} }
return [ return [
'id' => (int) $this->get('id'), 'id' => (int) $this->id,
'title' => (string) $this->title, 'title' => (string) $this->title,
'excerpt' => (string) $this->excerpt, 'excerpt' => (string) $this->excerpt,
'body_markdown' => (string) $this->body_markdown, 'body_markdown' => (string) $this->body_markdown,
'cover_media_id' => $this->cover_media_id !== null ? (string) ((int) $this->cover_media_id) : '',
]; ];
} }
public function create(array $input, Media $media): int public function savePost(array $input, ?int $id = null): int
{ {
$payload = $this->payload($input, $media); $payload = $this->payload($input);
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->count(['slug = ?', $candidate]) > 0);
$now = app_now(); $now = app_now();
$this->reset(); if ($id === null) {
$this->copyfrom($payload + [ $this->reset();
'slug' => $slug, $payload['slug'] = $this->uniqueSlug($payload['title']);
'created_at' => $now, $payload['created_at'] = $now;
'updated_at' => $now, } else {
]); $this->load(['id = ?', $id]);
$this->save(); if ($this->dry()) {
throw new RuntimeException('Article introuvable.');
return (int) $this->get('id'); }
}
public function updatePost(int $id, array $input, Media $media): bool
{
$this->load(['id = ?', $id]);
if ($this->dry()) {
return false;
} }
$payload = $this->payload($input, $media); $payload['updated_at'] = $now;
$this->copyfrom($payload + ['updated_at' => app_now()]); $this->copyfrom($payload);
$this->save(); $this->save();
return true; return (int) $this->id;
} }
// Vérifie les deux usages possibles : couverture (cover_media_id) public function deleteById(int $id): void
// et images insérées dans le corps (media:filename dans body_markdown).
public function isMediaUsed(int $mediaId, string $fileName): bool
{
return $this->count([
'cover_media_id = ? OR body_markdown LIKE ?',
$mediaId,
'%media:' . $fileName . '%',
]) > 0;
}
public function delete(int $id): void
{ {
$this->load(['id = ?', $id]); $this->load(['id = ?', $id]);
if ($this->dry()) { if ($this->dry()) {
@@ -157,12 +115,16 @@ class Post extends DB\SQL\Mapper
$this->erase(); $this->erase();
} }
private function payload(array $input, Media $media): array public function usesMedia(string $fileName): bool
{
return $this->count(['body_markdown LIKE ?', '%media:' . $fileName . '%']) > 0;
}
private function payload(array $input): array
{ {
$title = trim((string) ($input['title'] ?? '')); $title = trim((string) ($input['title'] ?? ''));
$excerpt = trim((string) ($input['excerpt'] ?? '')); $excerpt = trim((string) ($input['excerpt'] ?? ''));
$bodyMarkdown = trim((string) ($input['body_markdown'] ?? '')); $body = trim((string) ($input['body_markdown'] ?? ''));
$coverMediaId = trim((string) ($input['cover_media_id'] ?? ''));
if ($title === '') { if ($title === '') {
throw new RuntimeException('Ajoute un titre.'); throw new RuntimeException('Ajoute un titre.');
@@ -174,39 +136,67 @@ class Post extends DB\SQL\Mapper
throw new RuntimeException('Ajoute un extrait.'); throw new RuntimeException('Ajoute un extrait.');
} }
if (mb_strlen($excerpt) > self::EXCERPT_MAX_LENGTH) { if (mb_strlen($excerpt) > self::EXCERPT_MAX_LENGTH) {
throw new RuntimeException("L'extrait est trop long."); throw new RuntimeException('Lextrait est trop long.');
} }
$coverId = null;
if ($coverMediaId !== '') {
$coverId = (int) $coverMediaId;
if ($media->findById($coverId) === null) {
throw new RuntimeException('Image de couverture introuvable.');
}
}
$bodyHtml = MarkdownService::instance()->compile($bodyMarkdown, $media);
return [ return [
'title' => $title, 'title' => $title,
'excerpt' => $excerpt, 'excerpt' => $excerpt,
'body_markdown' => $bodyMarkdown, 'body_markdown' => $body,
'body_html' => $bodyHtml, 'body_html' => MarkdownService::instance()->compile($body, new Media()),
'cover_media_id' => $coverId,
]; ];
} }
private function summaryRow(array $row): array private function uniqueSlug(string $title): string
{ {
$base = app_slug($title);
$slug = $base;
$n = 2;
while ($this->count(['slug = ?', $slug]) > 0) {
$slug = $base . '-' . $n;
$n++;
}
return $slug;
}
private function summary(array $row): array
{
$thumbnail = $this->firstImage((string) ($row['body_html'] ?? ''));
return [ return [
'id' => (int) $row['id'], 'id' => (int) $row['id'],
'title' => (string) $row['title'], 'title' => (string) $row['title'],
'slug' => (string) $row['slug'], 'slug' => (string) $row['slug'],
'excerpt' => (string) $row['excerpt'], 'excerpt' => (string) $row['excerpt'],
'cover_media_id' => (int) ($row['cover_media_id'] ?? 0), 'thumbnail_url' => $thumbnail['url'],
'thumbnail_alt' => $thumbnail['alt'],
'created_at' => (string) $row['created_at'], 'created_at' => (string) $row['created_at'],
'updated_at' => (string) $row['updated_at'], 'updated_at' => (string) $row['updated_at'],
]; ];
} }
private function firstImage(string $html): array
{
if ($html === '') {
return ['url' => '', 'alt' => ''];
}
if (!preg_match('~(<img\s[^>]*src="([^"]+)"[^>]*>)~i', $html, $match)) {
return ['url' => '', 'alt' => ''];
}
$tag = $match[1];
$alt = '';
if (preg_match('~alt="([^"]*)"~i', $tag, $altMatch)) {
$alt = html_entity_decode($altMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
return [
'url' => html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'alt' => $alt,
];
}
} }

View File

@@ -23,41 +23,34 @@ class User extends DB\SQL\Mapper
)'); )');
} }
public function findById(int $id): ?array public function findPublic(int $id): ?array
{ {
if ($id <= 0) {
return null;
}
$this->load(['id = ?', $id]); $this->load(['id = ?', $id]);
if ($this->dry()) { if ($this->dry()) {
return null; return null;
} }
$data = $this->cast(); return [
unset($data['password_hash']); // Ne jamais exposer le hash hors de l'authentification. 'id' => (int) $this->id,
return $data; 'username' => (string) $this->username,
} 'created_at' => (string) $this->created_at,
];
public function findByUsername(string $username): ?array
{
$this->load(['username = ?', $username]);
return $this->dry() ? null : $this->cast();
} }
public function create(string $username, string $password): int public function create(string $username, string $password): int
{ {
$username = Base::instance()->clean($username); $username = Base::instance()->clean(trim($username));
$password = trim($password);
if ($username === '' || $password === '') { if ($username === '' || $password === '') {
throw new RuntimeException('Nom dutilisateur et mot de passe obligatoires.'); throw new RuntimeException('Nom dutilisateur et mot de passe obligatoires.');
} }
if (mb_strlen($password) < 10) { if (mb_strlen($password) < 10) {
throw new RuntimeException('Le mot de passe doit contenir au moins 10 caractères.'); throw new RuntimeException('Le mot de passe doit contenir au moins 10 caractères.');
} }
if ($this->findByUsername($username) !== null) { $this->load(['username = ?', $username]);
if (!$this->dry()) {
throw new RuntimeException('Cet utilisateur existe déjà.'); throw new RuntimeException('Cet utilisateur existe déjà.');
} }
@@ -67,6 +60,6 @@ class User extends DB\SQL\Mapper
$this->created_at = app_now(); $this->created_at = app_now();
$this->save(); $this->save();
return (int) $this->get('id'); return (int) $this->id;
} }
} }

View File

@@ -4,13 +4,8 @@ declare(strict_types=1);
class MarkdownService extends Prefab class MarkdownService extends Prefab
{ {
private const ALLOWED_TAGS = [ private const TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br'];
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', private const ATTRS = [
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
'strong', 'em', 'a', 'img', 'hr', 'br',
];
private const ALLOWED_ATTRIBUTES = [
'a' => ['href', 'title', 'rel', 'target'], 'a' => ['href', 'title', 'rel', 'target'],
'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'], 'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
]; ];
@@ -22,190 +17,123 @@ class MarkdownService extends Prefab
throw new RuntimeException('Ajoute du contenu avant de publier.'); throw new RuntimeException('Ajoute du contenu avant de publier.');
} }
$html = Markdown::instance()->convert($markdown); $markdown = $this->neutralizeRawHtml($markdown);
$document = $this->parseFragment($html);
$this->sanitizeTree($document, $media);
return trim($this->renderFragment($document));
}
private function parseFragment(string $html): DOMDocument
{
$document = new DOMDocument('1.0', 'UTF-8');
$wrapper = '<div id="markdown-root">' . $html . '</div>';
$doc = new DOMDocument('1.0', 'UTF-8');
$html = '<div id="content">' . Markdown::instance()->convert($markdown) . '</div>';
$previous = libxml_use_internal_errors(true); $previous = libxml_use_internal_errors(true);
$document->loadHTML( $doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
'<?xml encoding="utf-8" ?>' . $wrapper,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors(); libxml_clear_errors();
libxml_use_internal_errors($previous); libxml_use_internal_errors($previous);
return $document; $root = $doc->getElementById('content');
} if (!$root) {
private function renderFragment(DOMDocument $document): string
{
$root = $document->getElementById('markdown-root');
if ($root === null) {
return ''; return '';
} }
$html = ''; $this->sanitizeChildren($root, $media);
$out = '';
foreach (iterator_to_array($root->childNodes) as $child) { foreach (iterator_to_array($root->childNodes) as $child) {
$html .= $document->saveHTML($child); $out .= $doc->saveHTML($child);
} }
return $html; return trim($out);
} }
private function sanitizeTree(DOMDocument $document, Media $media): void private function neutralizeRawHtml(string $markdown): string
{ {
$root = $document->getElementById('markdown-root'); return preg_replace_callback(
if ($root === null) { '~<!--.*?-->|</?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?/?>~s',
return; static fn(array $match): string => str_replace(['<', '>'], ['&lt;', '&gt;'], $match[0]),
} $markdown
) ?? $markdown;
$this->sanitizeChildren($root, $media);
} }
private function sanitizeChildren(DOMNode $parent, Media $media): void private function sanitizeChildren(DOMNode $parent, Media $media): void
{ {
foreach (iterator_to_array($parent->childNodes) as $child) { foreach (iterator_to_array($parent->childNodes) as $child) {
if ($child instanceof DOMElement) { if (!$child instanceof DOMElement) {
$tag = strtolower($child->tagName); continue;
}
if (!in_array($tag, self::ALLOWED_TAGS, true)) { $tag = strtolower($child->tagName);
$this->dropDisallowedElement($child); if (!in_array($tag, self::TAGS, true)) {
$this->unwrap($child);
$this->sanitizeChildren($parent, $media);
continue;
}
foreach (iterator_to_array($child->attributes) as $attr) {
if (!in_array(strtolower($attr->name), self::ATTRS[$tag] ?? [], true)) {
$child->removeAttributeNode($attr);
}
}
if ($tag === 'a') {
$href = trim((string) $child->getAttribute('href'));
if (!$this->allowedHref($href)) {
$this->unwrap($child);
$this->sanitizeChildren($parent, $media);
continue; continue;
} }
$this->sanitizeElement($child, $media); $child->setAttribute('href', $href);
$this->sanitizeChildren($child, $media); $child->setAttribute('rel', 'noopener noreferrer');
if (preg_match('~^https?://~i', $href)) {
$child->setAttribute('target', '_blank');
} else {
$child->removeAttribute('target');
}
} }
}
}
private function dropDisallowedElement(DOMElement $element): void if ($tag === 'img') {
{ $src = trim((string) $child->getAttribute('src'));
$parent = $element->parentNode; if (!str_starts_with($src, 'media:')) {
if ($parent === null) { $child->parentNode?->removeChild($child);
return; continue;
} }
if (in_array(strtolower($element->tagName), ['script', 'style'], true)) { $item = $media->findByFileName(substr($src, 6));
$parent->removeChild($element); if (!$item) {
return; throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
} }
while ($element->firstChild !== null) { $child->setAttribute('src', $item['url']);
$parent->insertBefore($element->firstChild, $element); $child->setAttribute('alt', trim((string) $child->getAttribute('alt')) ?: (string) $item['alt']);
} $child->setAttribute('width', (string) $item['width']);
$child->setAttribute('height', (string) $item['height']);
$parent->removeChild($element); $child->setAttribute('loading', 'lazy');
} $child->setAttribute('decoding', 'async');
private function sanitizeElement(DOMElement $element, Media $media): void
{
$tag = strtolower($element->tagName);
$allowedAttributes = self::ALLOWED_ATTRIBUTES[$tag] ?? [];
foreach (iterator_to_array($element->attributes) as $attribute) {
if (!in_array(strtolower($attribute->name), $allowedAttributes, true)) {
$element->removeAttributeNode($attribute);
} }
}
if ($tag === 'a') { $this->sanitizeChildren($child, $media);
$this->sanitizeLink($element);
return;
}
if ($tag === 'img') {
$this->sanitizeImage($element, $media);
} }
} }
private function sanitizeLink(DOMElement $element): void private function unwrap(DOMElement $node): void
{ {
$href = trim((string) $element->getAttribute('href')); $parent = $node->parentNode;
if (!$this->isAllowedHref($href)) { if (!$parent) {
$this->unwrapElement($element);
return; return;
} }
$element->setAttribute('href', $href); if (in_array(strtolower($node->tagName), ['script', 'style'], true)) {
$element->setAttribute('rel', 'noopener noreferrer'); $parent->removeChild($node);
return;
if ($this->isExternalHttpUrl($href)) {
$element->setAttribute('target', '_blank');
} else {
$element->removeAttribute('target');
} }
while ($node->firstChild) {
$parent->insertBefore($node->firstChild, $node);
}
$parent->removeChild($node);
} }
private function sanitizeImage(DOMElement $element, Media $media): void private function allowedHref(string $href): bool
{ {
$src = trim((string) $element->getAttribute('src')); if ($href === '') {
if (!str_starts_with($src, 'media:')) {
$element->parentNode?->removeChild($element);
return;
}
$fileName = substr($src, 6);
if ($fileName === '') {
$element->parentNode?->removeChild($element);
return;
}
$item = $media->findByFileName($fileName);
if ($item === null) {
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
}
$alt = trim((string) $element->getAttribute('alt'));
if ($alt === '') {
$alt = (string) ($item['alt'] ?? '');
}
$element->setAttribute('src', (string) $item['url']);
$element->setAttribute('alt', $alt);
$element->setAttribute('loading', 'lazy');
$element->setAttribute('decoding', 'async');
$width = (int) ($item['width'] ?? 0);
if ($width > 0) {
$element->setAttribute('width', (string) $width);
} else {
$element->removeAttribute('width');
}
$height = (int) ($item['height'] ?? 0);
if ($height > 0) {
$element->setAttribute('height', (string) $height);
} else {
$element->removeAttribute('height');
}
}
private function unwrapElement(DOMElement $element): void
{
$parent = $element->parentNode;
if ($parent === null) {
return;
}
while ($element->firstChild !== null) {
$parent->insertBefore($element->firstChild, $element);
}
$parent->removeChild($element);
}
private function isAllowedHref(string $href): bool
{
if ($href == '') {
return false; return false;
} }
@@ -219,9 +147,4 @@ class MarkdownService extends Prefab
return !preg_match('~^[a-z][a-z0-9+.-]*:~i', $href); return !preg_match('~^[a-z][a-z0-9+.-]*:~i', $href);
} }
private function isExternalHttpUrl(string $href): bool
{
return (bool) preg_match('~^https?://~i', $href);
}
} }

View File

@@ -14,8 +14,8 @@
<include href="partials/csrf_field.html" /> <include href="partials/csrf_field.html" />
<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" required>
<span class="field-help">Formats acceptés : JPG, PNG, WebP. Limite : 10 Mo, 8000 × 8000 px et 40 mégapixels.</span> <span class="field-help">Formats acceptés : JPG et PNG. Limite : 10 Mo. Les fichiers sont stockés tels quels, sans transformation.</span>
</label> </label>
<button class="button" type="submit">Envoyer</button> <button class="button" type="submit">Envoyer</button>
</form> </form>

View File

@@ -10,7 +10,6 @@
<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 }}">
<include href="partials/csrf_field.html" /> <include href="partials/csrf_field.html" />
<input type="hidden" name="cover_media_id" value="{{ @post.cover_media_id }}" data-cover-input>
<label class="field"> <label class="field">
<span class="field-label">Titre</span> <span class="field-label">Titre</span>
@@ -24,38 +23,11 @@
<span class="char-counter"><span data-char-count-value>0</span> / {{ @excerptMax }}</span> <span class="char-counter"><span data-char-count-value>0</span> / {{ @excerptMax }}</span>
</label> </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"> <section class="field">
<div class="field-head"> <div class="field-head">
<div> <div>
<h2 class="field-label">Contenu</h2> <h2 class="field-label">Contenu</h2>
<p class="field-help">Markdown simple, avec insertion dimage au curseur.</p> <p class="field-help">Markdown simple, avec insertion dimage au curseur. La première image sert aussi de vignette sur les cartes darticle.</p>
</div> </div>
</div> </div>
@@ -67,10 +39,11 @@
<button class="tool-button" type="button" data-md-action="quote">Citation</button> <button class="tool-button" type="button" data-md-action="quote">Citation</button>
<button class="tool-button" type="button" data-md-action="link">Lien</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-md-action="code">Code</button>
<button class="tool-button" type="button" data-media-picker-open="markdown">Image</button> <button class="tool-button" type="button" data-media-picker-open>Image</button>
</div> </div>
<textarea class="control editor-textarea" name="body_markdown" rows="18" required data-markdown-editor>{{ @post.body_markdown }}</textarea> <textarea class="control editor-textarea" name="body_markdown" rows="18" required data-markdown-editor>{{ @post.body_markdown }}</textarea>
<p class="field-help">Astuce : avec le parseur Markdown de F3, laisse une ligne vide entre deux blocs (titre, liste, citation, image, code) pour un rendu fiable.</p>
</section> </section>
<button class="button" type="submit">Enregistrer</button> <button class="button" type="submit">Enregistrer</button>
@@ -79,8 +52,8 @@
<aside class="media-picker is-hidden" data-media-picker> <aside class="media-picker is-hidden" data-media-picker>
<div class="media-picker__head"> <div class="media-picker__head">
<div> <div>
<strong data-media-picker-title>Choisir une image</strong> <strong data-media-picker-title>Insérer une image</strong>
<p class="field-help" data-media-picker-help>Choisis une image de la médiathèque.</p> <p class="field-help" data-media-picker-help>Clique sur une image pour linsérer dans larticle.</p>
</div> </div>
<button class="button button--ghost button--small" type="button" data-media-picker-close>Fermer</button> <button class="button button--ghost button--small" type="button" data-media-picker-close>Fermer</button>
</div> </div>
@@ -89,7 +62,7 @@
<true> <true>
<div class="media-picker__grid"> <div class="media-picker__grid">
<repeat group="{{ @mediaItems }}" value="{{ @item }}"> <repeat group="{{ @mediaItems }}" value="{{ @item }}">
<button class="media-picker__item" type="button" data-media-picker-select data-media-id="{{ @item.id }}" data-media-url="{{ @item.url }}" data-media-markdown="{{ @item.markdown }}"> <button class="media-picker__item" type="button" data-media-picker-select data-media-markdown="{{ @item.markdown }}">
<img class="media-frame media-frame--square" src="{{ @item.url }}" alt=""> <img class="media-frame media-frame--square" src="{{ @item.url }}" alt="">
</button> </button>
</repeat> </repeat>

View File

@@ -1,6 +1,6 @@
<article class="card article-card"> <article class="card card--stack">
<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">
<p class="meta-text">{{ @item.width }} × {{ @item.height }}<br>{{ @item.created_at | date_fr }}</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 }}">

View File

@@ -2,7 +2,7 @@
<check if="{{ @currentUser }}"> <check if="{{ @currentUser }}">
<true> <true>
<li class="nav-items__item"> <li class="nav-items__item">
<a class="nav-items__link" href="{{ 'dashboard' | alias }}">Dashboard</a> <a class="nav-items__link" href="{{ 'dashboard' | alias }}">Tableau de bord</a>
</li> </li>
<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 }}">

View File

@@ -1,9 +1,7 @@
<article class="card article-card"> <article class="card card--stack">
<check if="{{ @post.cover_url }}"> <check if="{{ @post.thumbnail_url }}">
<true> <true>
<a class="card-media-link" href="{{ 'post_show', 'slug='.@post.slug | alias }}"> <img class="media-frame" src="{{ @post.thumbnail_url }}" alt="{{ @post.thumbnail_alt ?: @post.title }}">
<img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.cover_alt ?: @post.title }}">
</a>
</true> </true>
<false> <false>
<check if="{{ @adminMode }}"> <check if="{{ @adminMode }}">
@@ -13,10 +11,8 @@
</check> </check>
</false> </false>
</check> </check>
<div class="card-body article-card__body"> <div class="card-body">
<h2 class="card-title"> <h2 class="card-title">{{ @post.title }}</h2>
<a class="card-title__link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">{{ @post.title }}</a>
</h2>
<p class="meta-text"> <p class="meta-text">
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time> Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time>
<check if="{{ @post.updated_at !== @post.created_at }}"> <check if="{{ @post.updated_at !== @post.created_at }}">
@@ -34,6 +30,11 @@
</form> </form>
</div> </div>
</true> </true>
<false>
<div class="card-actions">
<a class="button button--ghost" href="{{ 'post_show', 'slug='.@post.slug | alias }}">Lire l'article</a>
</div>
</false>
</check> </check>
</div> </div>
</article> </article>

View File

@@ -9,12 +9,5 @@
</p> </p>
</header> </header>
<check if="{{ @post.cover_url }}">
<true>
<img class="media-frame media-frame--large article-cover" src="{{ @post.cover_url }}"
alt="{{ @post.cover_alt ?: @post.title }}">
</true>
</check>
<div class="prose">{{ @post.body_html | raw }}</div> <div class="prose">{{ @post.body_html | raw }}</div>
</article> </article>

View File

@@ -2,89 +2,79 @@
declare(strict_types=1); declare(strict_types=1);
require __DIR__ . '/Helpers/App.php'; require __DIR__ . '/helpers.php';
require __DIR__ . '/Helpers/Error.php';
$f3 = Base::instance(); $f3 = Base::instance();
// ── Configuration ───────────────────────────────────────────────────
$root = dirname(__DIR__); $root = dirname(__DIR__);
$f3->set('AUTOLOAD', $root . '/app/Controllers/;' . $root . '/app/Models/;' . $root . '/app/Services/');
$f3->set('AUTOLOAD', implode(';', [
$root . '/app/Controllers/',
$root . '/app/Models/',
$root . '/app/Services/',
]));
$f3->set('UI', $root . '/app/Views/'); $f3->set('UI', $root . '/app/Views/');
$f3->set('TEMP', $root . '/tmp/'); $f3->set('TEMP', $root . '/tmp/');
$f3->set('LOGS', $root . '/logs/'); $f3->set('LOGS', $root . '/logs/');
$f3->mset([ $f3->set('paths.db', $root . '/db/app.sqlite');
'paths.db' => $root . '/db/app.sqlite', $f3->set('paths.media_dir', $root . '/public/uploads/media');
'paths.media_dir' => $root . '/public/uploads/media', $f3->set('paths.media_base', '/uploads/media');
'paths.media_base' => '/uploads/media/',
]);
$f3->config($root . '/app/config.ini'); $f3->config($root . '/app/config.ini');
if (is_file($root . '/config.local.ini')) {
$localConfig = $root . '/config.local.ini'; $f3->config($root . '/config.local.ini');
if (is_file($localConfig)) {
$f3->config($localConfig);
} }
$f3->set('TZ', app_timezone()); date_default_timezone_set(app_timezone((string) $f3->get('app.timezone')));
$f3->set('DEBUG', app_is_prod() ? 0 : 3); $f3->set('TZ', date_default_timezone_get());
$f3->set('DEBUG', $f3->get('app.env') === 'prod' ? 0 : 3);
app_ensure_dir((string) $f3->get('TEMP')); foreach ([(string) $f3->get('TEMP'), (string) $f3->get('LOGS'), dirname((string) $f3->get('paths.db')), (string) $f3->get('paths.media_dir')] as $dir) {
app_ensure_dir((string) $f3->get('LOGS')); if (!is_dir($dir)) {
app_ensure_dir((string) $f3->get('paths.media_dir')); mkdir($dir, 0775, true);
// Web::receive() utilise UPLOADS directement — le résoudre en absolu. }
$f3->set('UPLOADS', $root . '/' . ltrim((string) $f3->get('UPLOADS'), '/')); }
app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/'));
app_bootstrap_logging();
// ── Base de données ───────────────────────────────────────────────── $uploads = $root . '/' . trim((string) $f3->get('UPLOADS'), '/');
$f3->set('UPLOADS', $uploads . '/');
if (!is_dir($uploads)) {
mkdir($uploads, 0775, true);
}
$dbPath = (string) $f3->get('paths.db'); ini_set('log_errors', '1');
app_ensure_dir(dirname($dbPath)); ini_set('error_log', rtrim((string) $f3->get('LOGS'), '/\\') . '/php-error.log');
ini_set('display_errors', $f3->get('app.env') === 'prod' ? '0' : '1');
error_reporting(E_ALL);
$db = new DB\SQL( $db = new DB\SQL('sqlite:' . $f3->get('paths.db'));
'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'); $db->exec('PRAGMA foreign_keys = ON');
$f3->set('DB', $db); $f3->set('DB', $db);
// ── Session ───────────────────────────────────────────────────────── $secure = app_request_is_secure();
// Derrière Caddy, Apache voit souvent du HTTP interne.
// On normalise donc le schéma depuis X-Forwarded-Proto uniquement si
// la requête provient d'un proxy explicitement approuvé.
$requestScheme = app_request_scheme();
$f3->set('SCHEME', $requestScheme);
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.cookie_secure', $requestScheme === 'https' ? '1' : '0');
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,
'path' => '/', 'path' => '/',
'secure' => $requestScheme === 'https', 'secure' => $secure,
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax', 'samesite' => 'Lax',
]); ]);
new Session(null, 'CSRF'); new Session(null, 'CSRF');
// ── Template ──────────────────────────────────────────────────────── Template::instance()->filter('date_fr', 'app_date_fr');
Template::instance()->filter('date_fr', 'app_format_datetime_fr'); if ($f3->get('app.env') === 'prod') {
$f3->set('ONERROR', function (Base $f3): void {
// ── Erreurs ───────────────────────────────────────────────────────── $code = max(1, (int) ($f3->get('ERROR.code') ?: 500));
$meta = app_error_meta($code);
app_bootstrap_errors($f3); $f3->status($code);
$f3->expire(0);
$f3->mset([
'errorCode' => $code,
'errorTitle' => $meta['title'],
'errorMessage' => $meta['message'],
]);
echo Template::instance()->render('errors/error.html');
});
}
return $f3; return $f3;

View File

@@ -1,15 +1,13 @@
[globals] [globals]
app.env=dev app.env=dev
app.timezone=UTC app.timezone=Europe/Paris
app.name=F3 Simple Blog
app.tagline=Blog simple avec Fat-Free Framework et SQLite.
app.session_name=f3-simple-blog app.session_name=f3-simple-blog
app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12
UPLOADS=tmp/uploads/ UPLOADS=tmp/uploads/
CACHE=folder CACHE=folder
app.name=F3 Simple Blog
app.tagline=Blog simple avec Fat-Free Framework et SQLite.
[routes] [routes]
GET @home: /=SiteController->home GET @home: /=SiteController->home
GET @post_show: /posts/@slug=SiteController->show GET @post_show: /posts/@slug=SiteController->show
@@ -18,14 +16,14 @@ GET @login: /login=AuthController->show
POST @login_submit: /login=AuthController->login POST @login_submit: /login=AuthController->login
POST @logout: /logout=AuthController->logout POST @logout: /logout=AuthController->logout
GET @dashboard: /dashboard=PostController->index GET @dashboard: /dashboard=AdminController->index
GET @post_create: /dashboard/posts/create=PostController->create GET @post_create: /dashboard/posts/create=AdminController->create
POST @post_store: /dashboard/posts=PostController->store POST @post_store: /dashboard/posts=AdminController->store
GET @post_edit: /dashboard/posts/@id/edit=PostController->edit GET @post_edit: /dashboard/posts/@id/edit=AdminController->edit
POST @post_update: /dashboard/posts/@id/update=PostController->update POST @post_update: /dashboard/posts/@id/update=AdminController->update
POST @post_delete: /dashboard/posts/@id/delete=PostController->delete POST @post_delete: /dashboard/posts/@id/delete=AdminController->delete
GET @media_index: /dashboard/media=MediaController->index GET @media_index: /dashboard/media=AdminController->media
POST @media_upload: /dashboard/media=MediaController->upload POST @media_upload: /dashboard/media=AdminController->mediaUpload
POST @media_update_alt: /dashboard/media/@id/alt=MediaController->updateAlt POST @media_update_alt: /dashboard/media/@id/alt=AdminController->mediaAlt
POST @media_delete: /dashboard/media/@id/delete=MediaController->delete POST @media_delete: /dashboard/media/@id/delete=AdminController->mediaDelete

79
app/helpers.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
function app_timezone(string $value): string
{
$value = trim($value);
return in_array($value, DateTimeZone::listIdentifiers(), true) ? $value : 'UTC';
}
function app_now(): string
{
return gmdate('Y-m-d H:i:s');
}
function app_slug(string $value): string
{
$slug = Web::instance()->slug(trim($value));
return $slug !== '' ? $slug : 'article';
}
function app_date_fr(string $value): string
{
static $formatter = null;
$value = trim($value);
if ($value === '') {
return '';
}
try {
$date = new DateTimeImmutable($value, new DateTimeZone('UTC'));
$date = $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
if (!$formatter instanceof IntlDateFormatter) {
$formatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::LONG,
IntlDateFormatter::SHORT,
date_default_timezone_get(),
IntlDateFormatter::GREGORIAN,
"d MMMM yyyy 'à' HH:mm"
);
}
return (string) ($formatter->format($date) ?: $value);
} catch (Throwable) {
return $value;
}
}
function app_error_meta(int $code): array
{
return match ($code) {
400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'],
403 => ['title' => 'Accès refusé', 'message' => 'Tu nas pas accès à cette ressource.'],
404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'],
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
};
}
function app_request_is_secure(): bool
{
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
return true;
}
if (strtolower((string) ($_SERVER['REQUEST_SCHEME'] ?? '')) === 'https') {
return true;
}
$forwardedProto = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
if ($forwardedProto !== '') {
return explode(',', $forwardedProto)[0] === 'https';
}
return false;
}

View File

@@ -4,7 +4,7 @@
"type": "project", "type": "project",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"ext-gd": "*", "ext-dom": "*",
"ext-intl": "*", "ext-intl": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-pdo_sqlite": "*", "ext-pdo_sqlite": "*",

View File

@@ -1,15 +1,4 @@
; Copier ce fichier vers config.local.ini puis décommenter les valeurs à surcharger.
; Les défauts applicatifs restent dans app/config.ini.
[globals] [globals]
; app.env=prod ; Exemple local minimal. En production, préfère app.env=prod.
; app.timezone=Europe/Paris app.env=dev
; app.session_name=f3-simple-blog app.timezone=Europe/Paris
; app.name=F3 Simple Blog
; app.tagline=Blog simple avec Fat-Free Framework et SQLite.
;
; Proxies de confiance autorisés à transmettre X-Forwarded-Proto.
; Valeur par défaut : 127.0.0.1,::1,172.16.0.0/12
; (Caddy sur le même hôte ou sur un réseau Docker IPv4 standard).
; Si le proxy tourne sur un autre sous-réseau, surcharge la valeur ici.
; app.trusted_proxies=127.0.0.1,::1,172.16.0.0/12

View File

@@ -110,8 +110,7 @@ a {
.meta-text, .meta-text,
.field-help, .field-help,
.char-counter, .char-counter {
.cover-name {
margin: 0; margin: 0;
color: var(--color-text-soft); color: var(--color-text-soft);
font-size: 0.95rem; font-size: 0.95rem;
@@ -179,7 +178,6 @@ a {
.nav, .nav,
.page-actions, .page-actions,
.button-row,
.card-actions { .card-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -206,8 +204,7 @@ a {
align-items: center; align-items: center;
} }
.nav-items__form, .nav-items__form {
.inline-form {
margin: 0; margin: 0;
} }
@@ -465,12 +462,6 @@ a {
line-height: 1.1; line-height: 1.1;
} }
.article-excerpt {
margin: 0;
color: var(--color-text-soft);
font-size: 1.05rem;
}
.card, .card,
.panel, .panel,
.prose, .prose,
@@ -631,10 +622,6 @@ textarea.control {
aspect-ratio: 1; aspect-ratio: 1;
} }
.media-frame--large {
min-height: 18rem;
}
.media-frame--placeholder { .media-frame--placeholder {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -645,14 +632,13 @@ textarea.control {
text-align: center; text-align: center;
} }
.article-card { .card--stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.card-body, .card-body {
.article-card__body {
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
@@ -666,17 +652,6 @@ textarea.control {
line-height: 1.25; line-height: 1.25;
} }
.card-title__link,
.card-media-link {
color: inherit;
text-decoration: none;
}
.card-title__link:hover,
.card-title__link:focus-visible {
text-decoration: underline;
}
.card-summary { .card-summary {
color: inherit; color: inherit;
margin: 0; margin: 0;
@@ -711,10 +686,6 @@ textarea.control {
margin-bottom: var(--space-5); margin-bottom: var(--space-5);
} }
.article-cover {
margin-bottom: var(--space-5);
}
.prose :first-child { .prose :first-child {
margin-top: 0; margin-top: 0;
} }
@@ -846,34 +817,6 @@ textarea.control {
align-content: start; align-content: start;
} }
.media-picker__name {
font-size: 0.95rem;
}
.cover-field {
gap: var(--space-3);
}
.cover-picker {
display: grid;
gap: var(--space-4);
align-items: start;
min-width: 0;
}
.cover-picker .button-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.cover-picker .button-row > * {
min-width: 0;
}
.cover-preview {
width: 100%;
}
/* ========================================================= /* =========================================================
Auth and error pages Auth and error pages
========================================================= */ ========================================================= */
@@ -957,14 +900,6 @@ textarea.control {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.cover-picker {
display: block;
}
.cover-picker > * + * {
margin-top: var(--space-4);
}
.page-header { .page-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
@@ -999,8 +934,7 @@ textarea.control {
padding: var(--space-4); padding: var(--space-4);
} }
.card-body, .card-body {
.article-card__body {
padding: var(--space-4); padding: var(--space-4);
} }
@@ -1015,7 +949,6 @@ textarea.control {
.nav > *, .nav > *,
.page-actions > *, .page-actions > *,
.button-row > *,
.card-actions > * { .card-actions > * {
flex: 1 1 100%; flex: 1 1 100%;
} }

View File

@@ -47,45 +47,10 @@
const editor = document.querySelector('[data-markdown-editor]'); const editor = document.querySelector('[data-markdown-editor]');
const picker = document.querySelector('[data-media-picker]'); const picker = document.querySelector('[data-media-picker]');
const editorLayout = document.querySelector('[data-editor-layout]'); const editorLayout = document.querySelector('[data-editor-layout]');
const pickerTitle = document.querySelector('[data-media-picker-title]');
const pickerHelp = document.querySelector('[data-media-picker-help]');
const pickerClose = document.querySelector('[data-media-picker-close]'); const pickerClose = document.querySelector('[data-media-picker-close]');
const coverInput = document.querySelector('[data-cover-input]');
const coverPreview = document.querySelector('[data-cover-preview]');
const coverPlaceholder = document.querySelector('[data-cover-placeholder]');
const coverClear = document.querySelector('[data-cover-clear]');
let pickerMode = 'markdown';
const focusEditor = () => editor?.focus(); const focusEditor = () => editor?.focus();
const updateCoverPreview = (item = null) => {
if (!coverInput || !coverPreview || !coverPlaceholder) {
return;
}
if (item && item.id && item.url) {
coverInput.value = item.id;
coverPreview.src = item.url;
coverPreview.alt = 'Image de couverture';
coverPreview.classList.remove('is-hidden');
coverPlaceholder.classList.add('is-hidden');
if (coverClear) {
coverClear.disabled = false;
}
return;
}
coverInput.value = '';
coverPreview.removeAttribute('src');
coverPreview.classList.add('is-hidden');
coverPlaceholder.classList.remove('is-hidden');
if (coverClear) {
coverClear.disabled = true;
}
};
coverClear?.addEventListener('click', () => updateCoverPreview());
if (editor) { if (editor) {
const replaceSelection = (before, after = '', placeholder = '') => { const replaceSelection = (before, after = '', placeholder = '') => {
const start = editor.selectionStart; const start = editor.selectionStart;
@@ -152,12 +117,11 @@
}); });
} }
const togglePicker = (open, mode = pickerMode) => { const togglePicker = (open) => {
if (!picker) { if (!picker) {
return; return;
} }
pickerMode = mode;
picker.classList.toggle('is-hidden', !open); picker.classList.toggle('is-hidden', !open);
editorLayout?.classList.toggle('is-picker-open', open); editorLayout?.classList.toggle('is-picker-open', open);
@@ -166,42 +130,19 @@
return; return;
} }
const isCover = pickerMode === 'cover';
if (pickerTitle) {
pickerTitle.textContent = isCover ? 'Choisir une couverture' : 'Insérer une image';
}
if (pickerHelp) {
pickerHelp.textContent = isCover
? 'Clique sur une image pour lutiliser comme couverture.'
: 'Clique sur une image pour linsérer dans larticle.';
}
picker.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); picker.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}; };
on('[data-media-picker-open]', (button) => { on('[data-media-picker-open]', (button) => {
button.addEventListener('click', () => { button.addEventListener('click', () => togglePicker(true));
togglePicker(true, button.getAttribute('data-media-picker-open') || 'markdown');
});
}); });
pickerClose?.addEventListener('click', () => togglePicker(false)); pickerClose?.addEventListener('click', () => togglePicker(false));
on('[data-media-picker-select]', (button) => { on('[data-media-picker-select]', (button) => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
const item = { const markdown = button.getAttribute('data-media-markdown') || '';
id: button.getAttribute('data-media-id') || '', if (!editor || !markdown) {
url: button.getAttribute('data-media-url') || '',
markdown: button.getAttribute('data-media-markdown') || '',
};
if (pickerMode === 'cover') {
updateCoverPreview(item);
togglePicker(false);
return;
}
if (!editor || !item.markdown) {
return; return;
} }
@@ -209,12 +150,11 @@
const end = editor.selectionEnd; const end = editor.selectionEnd;
const prefix = start > 0 && !editor.value.slice(0, start).endsWith('\n\n') ? '\n\n' : ''; const prefix = start > 0 && !editor.value.slice(0, start).endsWith('\n\n') ? '\n\n' : '';
const suffix = end < editor.value.length && !editor.value.slice(end).startsWith('\n\n') ? '\n\n' : ''; const suffix = end < editor.value.length && !editor.value.slice(end).startsWith('\n\n') ? '\n\n' : '';
editor.setRangeText(prefix + item.markdown + suffix, start, end, 'end'); editor.setRangeText(prefix + markdown + suffix, start, end, 'end');
togglePicker(false); togglePicker(false);
}); });
}); });
// Synchronise data-copy-text du bouton Markdown quand l'alt est modifié.
on('[data-alt-input]', (input) => { on('[data-alt-input]', (input) => {
const card = input.closest('.card'); const card = input.closest('.card');
const button = card?.querySelector('[data-markdown-template]'); const button = card?.querySelector('[data-markdown-template]');

View File

@@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
$f3 = require __DIR__ . '/bootstrap.php'; $f3 = require __DIR__ . '/bootstrap.php';
User::bootstrap($f3->get('DB'));
$username = trim((string) ($argv[1] ?? '')); $username = trim((string) ($argv[1] ?? ''));
if ($username === '') { if ($username === '') {
@@ -20,17 +21,10 @@ if (stripos(PHP_OS, 'WIN') !== 0) {
} }
fwrite(STDOUT, PHP_EOL); fwrite(STDOUT, PHP_EOL);
if ($password === '') {
fwrite(STDERR, "Mot de passe vide.\n");
exit(1);
}
User::bootstrap($f3->get('DB'));
try { try {
$id = (new User())->create($username, $password); $id = (new User())->create($username, $password);
fwrite(STDOUT, "Admin créé (ID {$id}).\n"); fwrite(STDOUT, "Admin créé (ID {$id}).\n");
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
fwrite(STDERR, $e->getMessage() . "\n"); fwrite(STDERR, $e->getMessage() . PHP_EOL);
exit(1); exit(1);
} }

View File

@@ -6,7 +6,7 @@ $f3 = require __DIR__ . '/bootstrap.php';
$db = $f3->get('DB'); $db = $f3->get('DB');
User::bootstrap($db); User::bootstrap($db);
Post::bootstrap($db);
Media::bootstrap($db); Media::bootstrap($db);
Post::bootstrap($db);
fwrite(STDOUT, 'Base initialisée : ' . Base::instance()->get('paths.db') . "\n"); fwrite(STDOUT, 'Base initialisée : ' . $f3->get('paths.db') . PHP_EOL);