Less home code more F3
This commit is contained in:
@@ -27,11 +27,10 @@ RUN apt-get update \
|
|||||||
libpng-dev \
|
libpng-dev \
|
||||||
libwebp-dev \
|
libwebp-dev \
|
||||||
libfreetype6-dev \
|
libfreetype6-dev \
|
||||||
libxml2-dev \
|
|
||||||
libonig-dev \
|
libonig-dev \
|
||||||
libicu-dev \
|
libicu-dev \
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||||
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite dom gd mbstring opcache intl \
|
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite gd mbstring opcache intl \
|
||||||
&& printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \
|
&& printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \
|
||||||
&& a2enconf servername \
|
&& a2enconf servername \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
106
README.md
106
README.md
@@ -1,6 +1,6 @@
|
|||||||
# F3 Simple Blog
|
# F3 Simple Blog
|
||||||
|
|
||||||
Blog simple avec Fat-Free Framework, SQLite et une petite médiathèque d’images.
|
Blog simple avec Fat-Free Framework, SQLite et une petite médiathèque d'images.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -24,39 +24,26 @@ project/
|
|||||||
│ └── uploads/
|
│ └── uploads/
|
||||||
│ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG)
|
│ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
|
│ ├── bootstrap.php # Autoload + bootstrap partagé par les scripts CLI
|
||||||
│ ├── install.php # Initialisation idempotente de la base
|
│ ├── install.php # Initialisation idempotente de la base
|
||||||
│ └── create-admin.php # Création d’un compte admin en CLI
|
│ └── create-admin.php # Création d'un compte admin en CLI
|
||||||
└── tmp/
|
└── tmp/ # Runtime temporaire, recréable sans perte métier
|
||||||
├── cache/ # Cache F3 + assets minifiés
|
├── cache/ # Cache F3 + assets minifiés
|
||||||
└── uploads/ # Transit Web::receive(), nettoyé après chaque upload
|
└── uploads/ # Transit Web::receive(), nettoyé après chaque upload
|
||||||
```
|
```
|
||||||
|
|
||||||
## Philosophie des dossiers runtime
|
|
||||||
|
|
||||||
Le projet sépare les données persistantes du runtime jetable :
|
|
||||||
|
|
||||||
- `tmp/` = runtime temporaire, recréable
|
|
||||||
- `db/` = base SQLite persistante
|
|
||||||
- `logs/` = logs persistants
|
|
||||||
- `public/uploads/media/` = médias publiés et persistants
|
|
||||||
|
|
||||||
Autrement dit, `tmp/` peut être vidé sans perte métier. Les données à sauvegarder restent hors de `tmp/`.
|
|
||||||
|
|
||||||
## Fonctionnalités F3 utilisées
|
## Fonctionnalités F3 utilisées
|
||||||
|
|
||||||
- **Routage nommé** — `config.ini [routes]`, filtre `alias` dans les templates, `reroute('@route')` dans les contrôleurs
|
- **Routage nommé** — `config.ini [routes]`, filtre `alias` dans les templates, `reroute('@route')` dans les contrôleurs.
|
||||||
- **Cache HTTP / F3** — TTL appliqués dans les contrôleurs avec `expire()`
|
- **Cache HTTP** — TTL par contrôleur via `expire()`, forcé à `0` quand un utilisateur est connecté.
|
||||||
- accueil : `300 s`
|
- **Assets minifiés** — `Web::minify()` via `AssetController` (`GET /min/@file`).
|
||||||
- page article : `3600 s`
|
- **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle.
|
||||||
- assets minifiés : `86400 s`
|
- **Images** — normalisation des médias via GD (JPG conservé, PNG/WebP convertis en PNG pour préserver la transparence).
|
||||||
- **Assets minifiés** — `Web::minify()` via `AssetController` (`GET /min/@file`)
|
- **Markdown** — `Markdown::instance()->convert()` + `strip_tags` et résolution des images média.
|
||||||
- **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle
|
- **Slugs** — `Web::instance()->slug()`.
|
||||||
- **Images** — normalisation des médias via GD (`JPG` conservé, `PNG/WebP` convertis en `PNG` pour préserver la transparence)
|
- **Session / CSRF** — `$f3->set('JAR', …)`, hooks `beforeRoute()` sur les contrôleurs protégés, jeton `@CSRF` recopié en session au rendu et vérifié au POST suivant.
|
||||||
- **Markdown** — `Markdown::instance()->convert()` + reconstruction DOM en liste blanche
|
- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`.
|
||||||
- **Slugs** — `Web::instance()->slug()`
|
- **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale.
|
||||||
- **Session / CSRF** — `$f3->set('JAR', …)`, hooks `beforeRoute()` sur les contrôleurs protégés, jeton exposé via `@CSRF` puis recopié en session au rendu pour vérification lors du POST suivant
|
|
||||||
- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`
|
|
||||||
- **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale
|
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
@@ -64,7 +51,7 @@ Autrement dit, `tmp/` peut être vidé sans perte métier. Les données à sauve
|
|||||||
|
|
||||||
- PHP 8.3+
|
- PHP 8.3+
|
||||||
- Composer
|
- Composer
|
||||||
- Extensions PHP : `pdo_sqlite`, `dom`, `gd`, `mbstring`, `intl`
|
- Extensions PHP : `pdo_sqlite`, `gd`, `mbstring`, `intl`
|
||||||
|
|
||||||
### Déploiement Docker
|
### Déploiement Docker
|
||||||
|
|
||||||
@@ -73,9 +60,7 @@ Autrement dit, `tmp/` peut être vidé sans perte métier. Les données à sauve
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Les paramètres par défaut sont dans `app/config.ini`.
|
Les paramètres par défaut sont dans `app/config.ini`. Pour surcharger localement ou en production :
|
||||||
|
|
||||||
Pour surcharger localement ou en production :
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.local.ini.example config.local.ini
|
cp config.local.ini.example config.local.ini
|
||||||
@@ -89,13 +74,6 @@ app.env=prod
|
|||||||
app.timezone=Europe/Paris
|
app.timezone=Europe/Paris
|
||||||
```
|
```
|
||||||
|
|
||||||
Le fichier `config.local.ini` sert uniquement aux surcharges d’environnement. Les chemins runtime restent les mêmes partout :
|
|
||||||
|
|
||||||
- `tmp/cache/` pour le cache F3 et les assets minifiés
|
|
||||||
- `tmp/uploads/` pour les fichiers temporaires d’upload
|
|
||||||
|
|
||||||
Les données persistantes restent hors de `tmp` : `db/`, `logs/`, `public/uploads/media/`.
|
|
||||||
|
|
||||||
## Développement local
|
## Développement local
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -114,6 +92,8 @@ php scripts/create-admin.php admin
|
|||||||
# mot de passe : 10 caractères minimum
|
# 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.
|
||||||
|
|
||||||
## Déploiement avec Docker
|
## Déploiement avec Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -122,9 +102,9 @@ cp config.local.ini.example config.local.ini
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker ne monte que les dossiers persistants (`db/`, `logs/`, `public/uploads/media/`) et laisse `tmp/` dans le conteneur pour qu’il reste réellement éphémère.
|
Docker ne monte que les dossiers persistants (`db/`, `logs/`, `public/uploads/media/`) et laisse `tmp/` dans le conteneur pour qu'il reste éphémère.
|
||||||
|
|
||||||
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 alors sur les valeurs par défaut de `app/config.ini`.
|
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`.
|
||||||
|
|
||||||
Le service écoute sur `http://127.0.0.1:8888`.
|
Le service écoute sur `http://127.0.0.1:8888`.
|
||||||
|
|
||||||
@@ -135,28 +115,27 @@ docker compose exec app php scripts/create-admin.php admin
|
|||||||
# mot de passe : 10 caractères minimum
|
# mot de passe : 10 caractères minimum
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cache public et navigation
|
## Cache public
|
||||||
|
|
||||||
Les pages publiques restent cacheables pour un visiteur anonyme :
|
Les pages publiques sont cacheables pour un visiteur anonyme :
|
||||||
|
|
||||||
- `/` est servie avec un TTL de `300 s`
|
- `/` : TTL de 300 s.
|
||||||
- `/posts/@slug` est servie avec un TTL de `3600 s`
|
- `/posts/@slug` : TTL de 3600 s.
|
||||||
- `/min/app.css` et `/min/app.js` sont servis avec un TTL de `86400 s`
|
- `/min/app.css` et `/min/app.js` : TTL de 86400 s.
|
||||||
|
|
||||||
Quand un utilisateur est connecté, le layout dépend de la session (navigation admin + formulaire de déconnexion avec CSRF). Le rendu est alors forcé en non-cacheable avec `expire(0)`.
|
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.
|
||||||
|
|
||||||
Le projet ne fait pas d’invalidation explicite du cache public lors des mutations d’articles : la fraîcheur dépend donc des TTL ci-dessus.
|
Le projet ne fait pas d'invalidation explicite du cache lors des mutations d'articles : la fraîcheur dépend des TTL ci-dessus.
|
||||||
|
|
||||||
## Médias et limites d’upload
|
## Médias et limites d'upload
|
||||||
|
|
||||||
- Formats acceptés à l’entrée : `JPG`, `PNG`, `WebP`
|
- Formats acceptés à l'entrée : JPG, PNG, WebP.
|
||||||
- Taille max du fichier reçu : `10 Mo`
|
- Taille max du fichier reçu : 10 Mo.
|
||||||
- Dimensions max : `8000 × 8000 px`
|
- Dimensions max : 8000 × 8000 px, 40 mégapixels.
|
||||||
- Limite de surface : `40 mégapixels`
|
- Sortie publiée : JPG pour les sources JPEG, PNG pour les sources PNG/WebP.
|
||||||
- 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.
|
||||||
- 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 pour éviter de charger toute la bibliothèque en mémoire à chaque formulaire.
|
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
|
## Reverse proxy Caddy
|
||||||
|
|
||||||
@@ -180,13 +159,13 @@ blog.example.com {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Le fichier `Caddyfile.example` fournit en plus un jeu d’en-têtes de sécurité minimal.
|
Le fichier `Caddyfile.example` fournit en plus un jeu d'en-têtes de sécurité minimal.
|
||||||
|
|
||||||
## Données à sauvegarder
|
## Données à sauvegarder
|
||||||
|
|
||||||
- `db/` — base SQLite
|
- `db/` — base SQLite.
|
||||||
- `public/uploads/media/` — images
|
- `public/uploads/media/` — images.
|
||||||
- `logs/` — optionnel, utile pour diagnostic
|
- `logs/` — optionnel, utile pour diagnostic.
|
||||||
|
|
||||||
## Mise à jour
|
## Mise à jour
|
||||||
|
|
||||||
@@ -196,10 +175,13 @@ docker compose up -d --build
|
|||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
- PHP : `logs/php-error.log`
|
- PHP : `logs/php-error.log`.
|
||||||
- Apache / conteneur : `docker compose logs -f app`
|
- Apache / conteneur : `docker compose logs -f app`.
|
||||||
|
|
||||||
|
## Limitations connues
|
||||||
|
|
||||||
|
- **CSRF et multi-onglets** — F3 expose un jeton CSRF unique via `@CSRF`. Le projet le recopie en session au rendu et le vérifie au POST suivant. Si l'admin ouvre deux onglets, le token du premier est écrasé par celui du second. La soumission du premier formulaire échouera avec « Jeton CSRF invalide ». Solution de contournement : travailler dans un seul onglet à la fois. F3 ne fournit pas de mécanisme de pool de tokens.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Les dates sont stockées en UTC (`gmdate`) puis formatées côté affichage avec le fuseau configuré.
|
- Les dates sont stockées en UTC (`gmdate`) puis formatées côté affichage avec le fuseau configuré.
|
||||||
- `scripts/install.php` peut être relancé sans danger : il crée les tables si elles n’existent pas.
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class AuthController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true); // Prévient la fixation de session.
|
||||||
$this->f3->set('SESSION.user_id', $user['id']);
|
$this->f3->set('SESSION.user_id', $user['id']);
|
||||||
$this->flash('success', 'Connexion réussie.');
|
$this->flash('success', 'Connexion réussie.');
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
@@ -39,7 +39,7 @@ class AuthController extends BaseController
|
|||||||
{
|
{
|
||||||
$this->verifyCsrf();
|
$this->verifyCsrf();
|
||||||
$this->f3->clear('SESSION.user_id');
|
$this->f3->clear('SESSION.user_id');
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true); // Invalide l'ancien ID de session.
|
||||||
$this->flash('success', 'Déconnexion effectuée.');
|
$this->flash('success', 'Déconnexion effectuée.');
|
||||||
$this->f3->reroute('@login');
|
$this->f3->reroute('@login');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ abstract class BaseController
|
|||||||
protected Base $f3;
|
protected Base $f3;
|
||||||
protected DB\SQL $db;
|
protected DB\SQL $db;
|
||||||
|
|
||||||
private ?array $resolvedUser = null;
|
// false = pas encore résolu, null = résolu sans utilisateur.
|
||||||
private bool $userResolved = false;
|
private array|false|null $resolvedUser = false;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -20,9 +20,9 @@ abstract class BaseController
|
|||||||
{
|
{
|
||||||
$user = $this->currentUser();
|
$user = $this->currentUser();
|
||||||
|
|
||||||
// Les pages publiques peuvent rester cacheables avec le TTL demandé.
|
// Les pages publiques restent cacheables avec le TTL demandé.
|
||||||
// Si un utilisateur est connecté, le layout dépend de la session
|
// Si un utilisateur est connecté, le layout dépend de la session
|
||||||
// (navigation d'admin, déconnexion + CSRF) : on force expire(0)
|
// (navigation admin, déconnexion + CSRF) : on force expire(0)
|
||||||
// pour ne pas servir ce rendu à d'autres visiteurs.
|
// pour ne pas servir ce rendu à d'autres visiteurs.
|
||||||
$this->f3->expire($user !== null ? 0 : $cacheTtl);
|
$this->f3->expire($user !== null ? 0 : $cacheTtl);
|
||||||
|
|
||||||
@@ -37,19 +37,17 @@ abstract class BaseController
|
|||||||
'metaDescription' => null,
|
'metaDescription' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mémoriser en session la valeur exposée à @CSRF pour que les
|
// Recopier @CSRF en session pour que verifyCsrf() puisse
|
||||||
// formulaires rendus pendant cette réponse puissent être vérifiés
|
// vérifier le jeton soumis au POST suivant.
|
||||||
// lors du POST suivant par verifyCsrf().
|
|
||||||
$this->f3->copy('CSRF', 'SESSION.csrf');
|
$this->f3->copy('CSRF', 'SESSION.csrf');
|
||||||
echo Template::instance()->render('layout.html');
|
echo Template::instance()->render('layout.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function currentUser(): ?array
|
protected function currentUser(): ?array
|
||||||
{
|
{
|
||||||
if (!$this->userResolved) {
|
if ($this->resolvedUser === false) {
|
||||||
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
|
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
|
||||||
$this->resolvedUser = $userId > 0 ? (new User($this->db))->findById($userId) : null;
|
$this->resolvedUser = $userId > 0 ? (new User($this->db))->findById($userId) : null;
|
||||||
$this->userResolved = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolvedUser;
|
return $this->resolvedUser;
|
||||||
@@ -65,15 +63,12 @@ abstract class BaseController
|
|||||||
$this->f3->reroute('@login');
|
$this->f3->reroute('@login');
|
||||||
}
|
}
|
||||||
|
|
||||||
// La classe Session de F3 expose la valeur courante via « CSRF ».
|
|
||||||
// Au rendu, on la recopie en SESSION.csrf ; le formulaire renvoie
|
|
||||||
// ensuite le jeton affiché lors du rendu précédent, qu'on compare à
|
|
||||||
// la valeur mémorisée en session.
|
|
||||||
protected function verifyCsrf(): void
|
protected function verifyCsrf(): void
|
||||||
{
|
{
|
||||||
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');
|
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');
|
||||||
$expected = (string) ($this->f3->get('SESSION.csrf') ?? '');
|
$expected = (string) ($this->f3->get('SESSION.csrf') ?? '');
|
||||||
|
|
||||||
|
// hash_equals : comparaison en temps constant contre les attaques temporelles.
|
||||||
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
|
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ class DashboardController extends BaseController
|
|||||||
public function index(): void
|
public function index(): void
|
||||||
{
|
{
|
||||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||||
$result = (new Post($this->db))->paginateList($page, 24);
|
$media = new Media($this->db);
|
||||||
|
$result = (new Post($this->db))->paginateList($page, 24, $media);
|
||||||
|
|
||||||
$this->render('admin/dashboard.html', [
|
$this->render('admin/dashboard.html', [
|
||||||
'pageTitle' => 'Tableau de bord',
|
'pageTitle' => 'Tableau de bord',
|
||||||
'posts' => $result['posts'],
|
'posts' => $result['posts'],
|
||||||
'pagination' => $result,
|
'pagination' => $result,
|
||||||
'paginationAlias' => 'dashboard',
|
'paginationAlias' => 'dashboard',
|
||||||
|
'adminMode' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,19 @@ class MediaController extends BaseController
|
|||||||
$this->verifyCsrf();
|
$this->verifyCsrf();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
(new Media($this->db))->delete((int) $this->f3->get('PARAMS.id'));
|
$id = (int) $this->f3->get('PARAMS.id');
|
||||||
|
$media = new Media($this->db);
|
||||||
|
$item = $media->findById($id);
|
||||||
|
|
||||||
|
if ($item === null) {
|
||||||
|
throw new RuntimeException('Image introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((new Post($this->db))->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.');
|
$this->flash('success', 'Image supprimée.');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
$this->flash('error', $e->getMessage());
|
$this->flash('error', $e->getMessage());
|
||||||
|
|||||||
@@ -20,14 +20,15 @@ class PostController extends BaseController
|
|||||||
{
|
{
|
||||||
$this->verifyCsrf();
|
$this->verifyCsrf();
|
||||||
|
|
||||||
|
$media = new Media($this->db);
|
||||||
$input = $this->postInput();
|
$input = $this->postInput();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
(new Post($this->db))->create($input);
|
(new Post($this->db))->create($input, $media);
|
||||||
$this->flash('success', 'Article créé.');
|
$this->flash('success', 'Article créé.');
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
|
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage(), $media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +47,12 @@ class PostController extends BaseController
|
|||||||
{
|
{
|
||||||
$this->verifyCsrf();
|
$this->verifyCsrf();
|
||||||
|
|
||||||
|
$media = new Media($this->db);
|
||||||
$id = (int) $this->f3->get('PARAMS.id');
|
$id = (int) $this->f3->get('PARAMS.id');
|
||||||
$input = $this->postInput() + ['id' => $id];
|
$input = $this->postInput() + ['id' => $id];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$updated = (new Post($this->db))->updatePost($id, $input);
|
$updated = (new Post($this->db))->updatePost($id, $input, $media);
|
||||||
if (!$updated) {
|
if (!$updated) {
|
||||||
$this->f3->error(404, 'Article introuvable.');
|
$this->f3->error(404, 'Article introuvable.');
|
||||||
return;
|
return;
|
||||||
@@ -59,7 +61,7 @@ class PostController extends BaseController
|
|||||||
$this->flash('success', 'Article mis à jour.');
|
$this->flash('success', 'Article mis à jour.');
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
|
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage(), $media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +79,15 @@ class PostController extends BaseController
|
|||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null): void
|
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null, ?Media $media = null): void
|
||||||
{
|
{
|
||||||
|
$media ??= new Media($this->db);
|
||||||
|
|
||||||
$coverPreview = null;
|
$coverPreview = null;
|
||||||
if (!empty($post['cover_media_id'])) {
|
if (!empty($post['cover_media_id'])) {
|
||||||
$coverPreview = (new Media($this->db))->findById((int) $post['cover_media_id']);
|
$coverPreview = $media->findById((int) $post['cover_media_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$media = new Media($this->db);
|
|
||||||
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
|
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
|
||||||
$mediaCount = $media->count();
|
$mediaCount = $media->count();
|
||||||
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;
|
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ class SiteController extends BaseController
|
|||||||
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));
|
||||||
$result = (new Post($this->db))->paginateList($page);
|
$media = new Media($this->db);
|
||||||
|
$result = (new Post($this->db))->paginateList($page, 12, $media);
|
||||||
|
|
||||||
$this->render('site/home.html', [
|
$this->render('site/home.html', [
|
||||||
'pageTitle' => 'Accueil',
|
'pageTitle' => 'Accueil',
|
||||||
@@ -19,7 +20,8 @@ class SiteController extends BaseController
|
|||||||
|
|
||||||
public function show(): void
|
public function show(): void
|
||||||
{
|
{
|
||||||
$post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug'));
|
$media = new Media($this->db);
|
||||||
|
$post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug'), $media);
|
||||||
if ($post === null) {
|
if ($post === null) {
|
||||||
$this->f3->error(404, 'Article introuvable.');
|
$this->f3->error(404, 'Article introuvable.');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -50,6 +50,25 @@ class Media extends DB\SQL\Mapper
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -66,18 +85,19 @@ class Media extends DB\SQL\Mapper
|
|||||||
return $this->dry() ? null : $this->decorate($this->cast());
|
return $this->dry() ? null : $this->decorate($this->cast());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reçoit le chemin absolu déposé par Web::receive() et le nom d'origine
|
// Traite le fichier temporaire déposé par Web::receive() et publie l'image.
|
||||||
// pour dériver un texte alternatif lisible.
|
|
||||||
public function upload(string $srcPath, string $originalName = ''): int
|
public function upload(string $srcPath, string $originalName = ''): int
|
||||||
{
|
{
|
||||||
$target = null;
|
$target = null;
|
||||||
$image = null;
|
$image = null;
|
||||||
|
$committed = false; // Contrôle le nettoyage de $target dans finally.
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$meta = self::inspectUpload($srcPath);
|
$meta = self::inspectUpload($srcPath);
|
||||||
$image = self::openImageResource($srcPath, $meta['mime']);
|
$image = self::openImageResource($srcPath, $meta['mime']);
|
||||||
|
|
||||||
[$format, $extension] = self::targetFormat($meta['mime']);
|
[$format, $extension] = self::targetFormat($meta['mime']);
|
||||||
|
// Nom aléatoire : empêche le path traversal et la devinabilité des URLs.
|
||||||
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
||||||
$target = app_public_media_dir() . '/' . $fileName;
|
$target = app_public_media_dir() . '/' . $fileName;
|
||||||
|
|
||||||
@@ -93,19 +113,15 @@ class Media extends DB\SQL\Mapper
|
|||||||
$this->created_at = app_now();
|
$this->created_at = app_now();
|
||||||
$this->save();
|
$this->save();
|
||||||
$this->db->commit();
|
$this->db->commit();
|
||||||
|
$committed = true;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->db->rollback();
|
$this->db->rollback();
|
||||||
if ($target !== null && is_file($target)) {
|
|
||||||
@unlink($target);
|
|
||||||
}
|
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) $this->get('id');
|
return (int) $this->get('id');
|
||||||
} catch (RuntimeException $e) {
|
} catch (Throwable $e) {
|
||||||
throw $e;
|
throw $e instanceof RuntimeException ? $e : new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||||
} catch (Throwable) {
|
|
||||||
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
|
||||||
} finally {
|
} finally {
|
||||||
if ($image instanceof GdImage) {
|
if ($image instanceof GdImage) {
|
||||||
imagedestroy($image);
|
imagedestroy($image);
|
||||||
@@ -113,6 +129,9 @@ class Media extends DB\SQL\Mapper
|
|||||||
if (is_file($srcPath)) {
|
if (is_file($srcPath)) {
|
||||||
@unlink($srcPath);
|
@unlink($srcPath);
|
||||||
}
|
}
|
||||||
|
if (!$committed && $target !== null && is_file($target)) {
|
||||||
|
@unlink($target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,16 +148,12 @@ class Media extends DB\SQL\Mapper
|
|||||||
|
|
||||||
public function delete(int $id): void
|
public function delete(int $id): void
|
||||||
{
|
{
|
||||||
$item = $this->findById($id);
|
$this->load(['id = ?', $id]);
|
||||||
if ($item === null) {
|
if ($this->dry()) {
|
||||||
throw new RuntimeException('Image introuvable.');
|
throw new RuntimeException('Image introuvable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isUsed($item)) {
|
$path = app_public_media_dir() . '/' . $this->file_name;
|
||||||
throw new RuntimeException('Cette image est encore utilisée par un article.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = app_public_media_dir() . '/' . $item['file_name'];
|
|
||||||
|
|
||||||
$this->db->begin();
|
$this->db->begin();
|
||||||
try {
|
try {
|
||||||
@@ -203,6 +218,8 @@ class Media extends DB\SQL\Mapper
|
|||||||
return $image;
|
return $image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PNG/WebP → PNG pour préserver la transparence de manière fiable.
|
||||||
|
// JPG reste en JPG (pas de canal alpha).
|
||||||
private static function targetFormat(string $mime): array
|
private static function targetFormat(string $mime): array
|
||||||
{
|
{
|
||||||
return match ($mime) {
|
return match ($mime) {
|
||||||
@@ -239,15 +256,6 @@ class Media extends DB\SQL\Mapper
|
|||||||
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
|
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Une seule requête SQL pour les deux cas d'utilisation (couverture et body).
|
|
||||||
private function isUsed(array $item): bool
|
|
||||||
{
|
|
||||||
return $this->db->exec(
|
|
||||||
'SELECT 1 FROM posts WHERE cover_media_id = ? OR body_markdown LIKE ? LIMIT 1',
|
|
||||||
[$item['id'], '%media:' . $item['file_name'] . '%']
|
|
||||||
) !== [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function decorate(array $row): array
|
private function decorate(array $row): array
|
||||||
{
|
{
|
||||||
$alt = (string) $row['alt'];
|
$alt = (string) $row['alt'];
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class Post extends DB\SQL\Mapper
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function paginateList(int $page = 1, int $perPage = 12): array
|
public function paginateList(int $page, int $perPage, Media $media): array
|
||||||
{
|
{
|
||||||
$result = $this->paginate(
|
$result = $this->paginate(
|
||||||
max(0, $page - 1),
|
max(0, $page - 1),
|
||||||
@@ -49,11 +49,13 @@ class Post extends DB\SQL\Mapper
|
|||||||
);
|
);
|
||||||
|
|
||||||
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
|
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
|
||||||
$covers = $this->loadCovers($posts);
|
$coverIds = array_filter(array_unique(array_column($posts, 'cover_media_id')));
|
||||||
|
$covers = $media->findByIds($coverIds);
|
||||||
|
|
||||||
foreach ($posts as &$post) {
|
foreach ($posts as &$post) {
|
||||||
$cover = $covers[$post['cover_media_id']] ?? null;
|
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||||
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
$post['cover_url'] = $cover['url'] ?? '';
|
||||||
|
$post['cover_alt'] = $cover['alt'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -63,7 +65,7 @@ class Post extends DB\SQL\Mapper
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findBySlug(string $slug): ?array
|
public function findBySlug(string $slug, Media $media): ?array
|
||||||
{
|
{
|
||||||
$this->load(['slug = ?', $slug]);
|
$this->load(['slug = ?', $slug]);
|
||||||
if ($this->dry()) {
|
if ($this->dry()) {
|
||||||
@@ -71,9 +73,9 @@ class Post extends DB\SQL\Mapper
|
|||||||
}
|
}
|
||||||
|
|
||||||
$post = $this->summaryRow($this->cast());
|
$post = $this->summaryRow($this->cast());
|
||||||
$covers = $this->loadCovers([$post]);
|
$cover = $post['cover_media_id'] > 0 ? $media->findById($post['cover_media_id']) : null;
|
||||||
$cover = $covers[$post['cover_media_id']] ?? null;
|
$post['cover_url'] = $cover['url'] ?? '';
|
||||||
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
$post['cover_alt'] = $cover['alt'] ?? '';
|
||||||
$post['body_html'] = (string) $this->body_html;
|
$post['body_html'] = (string) $this->body_html;
|
||||||
|
|
||||||
return $post;
|
return $post;
|
||||||
@@ -99,10 +101,10 @@ class Post extends DB\SQL\Mapper
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $input): int
|
public function create(array $input, Media $media): int
|
||||||
{
|
{
|
||||||
$payload = $this->payload($input);
|
$payload = $this->payload($input, $media);
|
||||||
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->slugExists($candidate));
|
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->count(['slug = ?', $candidate]) > 0);
|
||||||
$now = app_now();
|
$now = app_now();
|
||||||
|
|
||||||
$this->reset();
|
$this->reset();
|
||||||
@@ -116,20 +118,31 @@ class Post extends DB\SQL\Mapper
|
|||||||
return (int) $this->get('id');
|
return (int) $this->get('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatePost(int $id, array $input): bool
|
public function updatePost(int $id, array $input, Media $media): bool
|
||||||
{
|
{
|
||||||
$this->load(['id = ?', $id]);
|
$this->load(['id = ?', $id]);
|
||||||
if ($this->dry()) {
|
if ($this->dry()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = $this->payload($input);
|
$payload = $this->payload($input, $media);
|
||||||
$this->copyfrom($payload + ['updated_at' => app_now()]);
|
$this->copyfrom($payload + ['updated_at' => app_now()]);
|
||||||
$this->save();
|
$this->save();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vérifie les deux usages possibles : couverture (cover_media_id)
|
||||||
|
// et images insérées dans le corps (media:filename dans body_markdown).
|
||||||
|
public function isMediaUsed(int $mediaId, string $fileName): bool
|
||||||
|
{
|
||||||
|
return $this->count([
|
||||||
|
'cover_media_id = ? OR body_markdown LIKE ?',
|
||||||
|
$mediaId,
|
||||||
|
'%media:' . $fileName . '%',
|
||||||
|
]) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public function delete(int $id): void
|
public function delete(int $id): void
|
||||||
{
|
{
|
||||||
$this->load(['id = ?', $id]);
|
$this->load(['id = ?', $id]);
|
||||||
@@ -140,7 +153,7 @@ class Post extends DB\SQL\Mapper
|
|||||||
$this->erase();
|
$this->erase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function payload(array $input): array
|
private function payload(array $input, Media $media): array
|
||||||
{
|
{
|
||||||
$title = trim((string) ($input['title'] ?? ''));
|
$title = trim((string) ($input['title'] ?? ''));
|
||||||
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
||||||
@@ -160,8 +173,6 @@ class Post extends DB\SQL\Mapper
|
|||||||
throw new RuntimeException("L'extrait est trop long.");
|
throw new RuntimeException("L'extrait est trop long.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$media = new Media($this->db);
|
|
||||||
|
|
||||||
$coverId = null;
|
$coverId = null;
|
||||||
if ($coverMediaId !== '') {
|
if ($coverMediaId !== '') {
|
||||||
$coverId = (int) $coverMediaId;
|
$coverId = (int) $coverMediaId;
|
||||||
@@ -181,11 +192,6 @@ class Post extends DB\SQL\Mapper
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function slugExists(string $slug): bool
|
|
||||||
{
|
|
||||||
return $this->count(['slug = ?', $slug]) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function summaryRow(array $row): array
|
private function summaryRow(array $row): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -199,24 +205,4 @@ class Post extends DB\SQL\Mapper
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadCovers(array $posts): array
|
|
||||||
{
|
|
||||||
$ids = array_filter(array_unique(array_column($posts, 'cover_media_id')));
|
|
||||||
if ($ids === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
|
||||||
$rows = $this->db->exec(
|
|
||||||
"SELECT id, file_name FROM media WHERE id IN ($placeholders)",
|
|
||||||
array_values($ids)
|
|
||||||
);
|
|
||||||
|
|
||||||
$map = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$map[(int) $row['id']] = $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $map;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class User extends DB\SQL\Mapper
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = $this->cast();
|
$data = $this->cast();
|
||||||
unset($data['password_hash']);
|
unset($data['password_hash']); // Ne jamais exposer le hash hors de l'authentification.
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,131 +17,25 @@ class MarkdownService extends Prefab
|
|||||||
throw new RuntimeException('Ajoute du contenu avant de publier.');
|
throw new RuntimeException('Ajoute du contenu avant de publier.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$markdown = self::normalizeMarkdown($markdown);
|
|
||||||
$html = Markdown::instance()->convert($markdown);
|
$html = Markdown::instance()->convert($markdown);
|
||||||
$html = self::sanitizeAndResolve($html, $media);
|
$html = strip_tags($html, self::ALLOWED_TAGS);
|
||||||
|
$html = self::resolveImages($html, $media);
|
||||||
|
$html = self::secureLinks($html);
|
||||||
|
|
||||||
if (trim(strip_tags($html)) === '' && !preg_match('/<(img|video|audio|figure)[\s>]/i', $html)) {
|
return trim($html);
|
||||||
$fallback = nl2br(htmlspecialchars($markdown, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
|
||||||
$html = '<p>' . str_replace('<br />', '</p><p>', $fallback) . '</p>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $html;
|
// Résout les images media:filename et supprime les images externes.
|
||||||
}
|
private static function resolveImages(string $html, Media $media): string
|
||||||
|
|
||||||
// Reconstruction en liste blanche : les descendants d'une balise interdite
|
|
||||||
// sont retraités récursivement avant d'être réinsérés.
|
|
||||||
private static function sanitizeAndResolve(string $html, Media $media): string
|
|
||||||
{
|
{
|
||||||
$source = new DOMDocument('1.0', 'UTF-8');
|
return preg_replace_callback('/<img\s[^>]*>/i', function (array $m) use ($media): string {
|
||||||
$clean = new DOMDocument('1.0', 'UTF-8');
|
if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) {
|
||||||
$cleanBody = $clean->createElement('body');
|
|
||||||
$clean->appendChild($cleanBody);
|
|
||||||
|
|
||||||
$previousUseInternalErrors = libxml_use_internal_errors(true);
|
|
||||||
$source->loadHTML('<?xml encoding="UTF-8"><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
|
||||||
libxml_clear_errors();
|
|
||||||
libxml_use_internal_errors($previousUseInternalErrors);
|
|
||||||
|
|
||||||
$sourceBody = $source->getElementsByTagName('body')->item(0);
|
|
||||||
if (!$sourceBody instanceof DOMElement) {
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
self::appendSanitizedChildren($sourceBody, $cleanBody, $clean, $media);
|
$fileName = substr($s[1], 6);
|
||||||
|
if ($fileName === '') {
|
||||||
$out = '';
|
return '';
|
||||||
for ($i = 0; $i < $cleanBody->childNodes->length; $i++) {
|
|
||||||
$child = $cleanBody->childNodes->item($i);
|
|
||||||
if ($child !== null) {
|
|
||||||
$out .= $clean->saveHTML($child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim($out);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function appendSanitizedChildren(DOMNode $sourceParent, DOMNode $targetParent, DOMDocument $target, Media $media): void
|
|
||||||
{
|
|
||||||
$children = [];
|
|
||||||
for ($i = 0; $i < $sourceParent->childNodes->length; $i++) {
|
|
||||||
$child = $sourceParent->childNodes->item($i);
|
|
||||||
if ($child !== null) {
|
|
||||||
$children[] = $child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($children as $child) {
|
|
||||||
if ($child instanceof DOMComment) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($child instanceof DOMText) {
|
|
||||||
$targetParent->appendChild($target->createTextNode($child->nodeValue ?? ''));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$child instanceof DOMElement) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
self::appendSanitizedElement($child, $targetParent, $target, $media);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function appendSanitizedElement(DOMElement $sourceElement, DOMNode $targetParent, DOMDocument $target, Media $media): void
|
|
||||||
{
|
|
||||||
$tag = strtolower($sourceElement->tagName);
|
|
||||||
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
|
|
||||||
self::appendSanitizedChildren($sourceElement, $targetParent, $target, $media);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tag === 'img') {
|
|
||||||
$image = self::buildSanitizedImage($sourceElement, $target, $media);
|
|
||||||
if ($image !== null) {
|
|
||||||
$targetParent->appendChild($image);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cleanElement = $target->createElement($tag);
|
|
||||||
self::sanitizeAttributes($sourceElement, $cleanElement);
|
|
||||||
$targetParent->appendChild($cleanElement);
|
|
||||||
self::appendSanitizedChildren($sourceElement, $cleanElement, $target, $media);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function sanitizeAttributes(DOMElement $sourceElement, DOMElement $targetElement): void
|
|
||||||
{
|
|
||||||
if ($targetElement->tagName !== 'a') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$href = self::sanitizeHref((string) $sourceElement->getAttribute('href'));
|
|
||||||
if ($href !== null) {
|
|
||||||
$targetElement->setAttribute('href', $href);
|
|
||||||
$targetElement->setAttribute('rel', 'noopener noreferrer');
|
|
||||||
if (preg_match('~^https?://~i', $href) === 1) {
|
|
||||||
$targetElement->setAttribute('target', '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
|
|
||||||
if ($title !== null) {
|
|
||||||
$targetElement->setAttribute('title', $title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function buildSanitizedImage(DOMElement $sourceElement, DOMDocument $target, Media $media): ?DOMElement
|
|
||||||
{
|
|
||||||
$src = trim((string) $sourceElement->getAttribute('src'));
|
|
||||||
if ($src === '' || !str_starts_with($src, 'media:')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fileName = substr($src, 6);
|
|
||||||
if ($fileName === '' || preg_match('/[\x00-\x1F\x7F]/u', $fileName) === 1) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$item = $media->findByFileName($fileName);
|
$item = $media->findByFileName($fileName);
|
||||||
@@ -149,106 +43,42 @@ class MarkdownService extends Prefab
|
|||||||
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$image = $target->createElement('img');
|
// L'alt du Markdown est déjà échappé par le parser F3.
|
||||||
$image->setAttribute('src', (string) $item['url']);
|
// Le fallback vers l'alt de la base nécessite un échappement.
|
||||||
$image->setAttribute('loading', 'lazy');
|
$alt = '';
|
||||||
$image->setAttribute('decoding', 'async');
|
if (preg_match('/alt="([^"]*)"/', $m[0], $a)) {
|
||||||
|
$alt = $a[1];
|
||||||
if ($sourceElement->hasAttribute('alt')) {
|
}
|
||||||
$image->setAttribute('alt', self::sanitizeAttributeValue((string) $sourceElement->getAttribute('alt'), true) ?? '');
|
if ($alt === '') {
|
||||||
} elseif ((string) $item['alt'] !== '') {
|
$alt = htmlspecialchars($item['alt'], ENT_QUOTES, 'UTF-8');
|
||||||
$image->setAttribute('alt', (string) $item['alt']);
|
|
||||||
} else {
|
|
||||||
$image->setAttribute('alt', '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
|
$url = htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8');
|
||||||
if ($title !== null) {
|
|
||||||
$image->setAttribute('title', $title);
|
return '<img src="' . $url . '" alt="' . $alt . '" loading="lazy" decoding="async">';
|
||||||
|
}, $html) ?? $html;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $image;
|
// Sécurise les liens : rel="noopener noreferrer" sur tous,
|
||||||
}
|
// target="_blank" sur les liens externes uniquement.
|
||||||
|
private static function secureLinks(string $html): string
|
||||||
private static function sanitizeHref(string $href): ?string
|
|
||||||
{
|
{
|
||||||
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
return preg_replace_callback('/<a\s[^>]*>/i', function (array $m): string {
|
||||||
if ($href === '' || preg_match('/[\x00-\x1F\x7F]/u', $href) === 1) {
|
if (!preg_match('/href="([^"]*)"/', $m[0], $h)) {
|
||||||
return null;
|
return $m[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('~^(https?://|mailto:|tel:)~i', $href) === 1) {
|
$attrs = 'href="' . $h[1] . '" rel="noopener noreferrer"';
|
||||||
return $href;
|
|
||||||
|
if (preg_match('~^https?://~i', $h[1])) {
|
||||||
|
$attrs .= ' target="_blank"';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self::isSafeRelativeHref($href)) {
|
if (preg_match('/title="([^"]*)"/', $m[0], $t)) {
|
||||||
return $href;
|
$attrs .= ' title="' . $t[1] . '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return '<a ' . $attrs . '>';
|
||||||
}
|
}, $html) ?? $html;
|
||||||
|
|
||||||
private static function isSafeRelativeHref(string $href): bool
|
|
||||||
{
|
|
||||||
if ($href === '/') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($href, '//')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return preg_match('~^(?:/[^/]|\./|\.\./|#|\?)~', $href) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function sanitizeAttributeValue(string $value, bool $allowEmpty = false): ?string
|
|
||||||
{
|
|
||||||
$value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
||||||
$value = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value));
|
|
||||||
|
|
||||||
if ($value === '' && !$allowEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function normalizeMarkdown(string $markdown): string
|
|
||||||
{
|
|
||||||
$markdown = str_replace(["\r\n", "\r"], "\n", $markdown);
|
|
||||||
$lines = explode("\n", $markdown);
|
|
||||||
$normalized = [];
|
|
||||||
$inFence = false;
|
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
|
|
||||||
$inFence = !$inFence;
|
|
||||||
$normalized[] = $line;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($inFence) {
|
|
||||||
$normalized[] = $line;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$isBlank = trim($line) === '';
|
|
||||||
$isListItem = preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $line) === 1;
|
|
||||||
$previous = $normalized[count($normalized) - 1] ?? null;
|
|
||||||
$previousIsBlank = $previous === null || trim($previous) === '';
|
|
||||||
$previousIsListItem = $previous !== null && preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $previous) === 1;
|
|
||||||
|
|
||||||
if ($isListItem && !$previousIsBlank && !$previousIsListItem) {
|
|
||||||
$normalized[] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$isBlank && !$isListItem && $previousIsListItem) {
|
|
||||||
$normalized[] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized[] = $line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim(implode("\n", $normalized));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<true>
|
<true>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<repeat group="{{ @posts }}" value="{{ @post }}">
|
<repeat group="{{ @posts }}" value="{{ @post }}">
|
||||||
<include href="partials/post_card_admin.html" />
|
<include href="partials/post_card.html" />
|
||||||
</repeat>
|
</repeat>
|
||||||
</div>
|
</div>
|
||||||
<include href="partials/pagination.html" />
|
<include href="partials/pagination.html" />
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
<article class="card article-card">
|
<article class="card article-card">
|
||||||
<a class="card-media-link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">
|
|
||||||
<check if="{{ @post.cover_url }}">
|
<check if="{{ @post.cover_url }}">
|
||||||
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
|
<true>
|
||||||
|
<a class="card-media-link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">
|
||||||
|
<img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.cover_alt ?: @post.title }}">
|
||||||
|
</a>
|
||||||
|
</true>
|
||||||
<false>
|
<false>
|
||||||
|
<check if="{{ @adminMode }}">
|
||||||
|
<true>
|
||||||
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
||||||
|
</true>
|
||||||
|
</check>
|
||||||
</false>
|
</false>
|
||||||
</check>
|
</check>
|
||||||
</a>
|
|
||||||
<div class="card-body article-card__body">
|
<div class="card-body article-card__body">
|
||||||
<h2 class="card-title">{{ @post.title }}</h2>
|
<h2 class="card-title">
|
||||||
|
<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 }}">
|
||||||
@@ -16,5 +24,16 @@
|
|||||||
</check>
|
</check>
|
||||||
</p>
|
</p>
|
||||||
<p class="card-summary">{{ @post.excerpt }}</p>
|
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||||
|
<check if="{{ @adminMode }}">
|
||||||
|
<true>
|
||||||
|
<div class="card-actions">
|
||||||
|
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
|
||||||
|
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
||||||
|
<button class="button button--danger" type="submit">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</true>
|
||||||
|
</check>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<article class="card article-card">
|
|
||||||
<a class="card-media-link" href="{{ 'post_edit', 'id='.@post.id | alias }}">
|
|
||||||
<check if="{{ @post.cover_url }}">
|
|
||||||
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
|
|
||||||
<false>
|
|
||||||
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
|
||||||
</false>
|
|
||||||
</check>
|
|
||||||
</a>
|
|
||||||
<div class="card-body article-card__body">
|
|
||||||
<h2 class="card-title">{{ @post.title }}</h2>
|
|
||||||
<p class="meta-text">
|
|
||||||
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at | date_fr }}</time>
|
|
||||||
<check if="{{ @post.updated_at !== @post.created_at }}">
|
|
||||||
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at | date_fr }}</time></true>
|
|
||||||
</check>
|
|
||||||
</p>
|
|
||||||
<p class="card-summary">{{ @post.excerpt }}</p>
|
|
||||||
<div class="card-actions">
|
|
||||||
<a class="button button--ghost" href="{{ 'post_show', 'slug='.@post.slug | alias }}">Voir</a>
|
|
||||||
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
|
|
||||||
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ @CSRF }}">
|
|
||||||
<button class="button button--danger" type="submit">Supprimer</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@@ -12,12 +12,8 @@
|
|||||||
<check if="{{ @post.cover_url }}">
|
<check if="{{ @post.cover_url }}">
|
||||||
<true>
|
<true>
|
||||||
<img class="media-frame media-frame--large article-cover" src="{{ @post.cover_url }}"
|
<img class="media-frame media-frame--large article-cover" src="{{ @post.cover_url }}"
|
||||||
alt="{{ @post.title }}">
|
alt="{{ @post.cover_alt ?: @post.title }}">
|
||||||
</true>
|
</true>
|
||||||
<false>
|
|
||||||
<div class="media-frame media-frame--large media-frame--placeholder article-cover">Aucune image
|
|
||||||
</div>
|
|
||||||
</false>
|
|
||||||
</check>
|
</check>
|
||||||
|
|
||||||
<div class="prose">{{ @post.body_html | raw }}</div>
|
<div class="prose">{{ @post.body_html | raw }}</div>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
; Copier ce fichier vers config.local.ini puis décommenter uniquement les valeurs à surcharger.
|
; Copier ce fichier vers config.local.ini puis décommenter les valeurs à surcharger.
|
||||||
; Les défauts applicatifs restent dans app/config.ini.
|
; Les défauts applicatifs restent dans app/config.ini.
|
||||||
; Les chemins runtime ne changent pas entre local et Docker :
|
|
||||||
; - tmp/cache pour le cache F3 et les assets minifiés
|
|
||||||
; - tmp/uploads pour les fichiers temporaires d'upload
|
|
||||||
; Les données persistantes restent hors de tmp : db/, logs/, public/uploads/media/
|
|
||||||
|
|
||||||
[globals]
|
[globals]
|
||||||
; app.env=prod
|
; app.env=prod
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ set -eu
|
|||||||
APP_ROOT="/var/www/html"
|
APP_ROOT="/var/www/html"
|
||||||
CONFIG="$APP_ROOT/config.local.ini"
|
CONFIG="$APP_ROOT/config.local.ini"
|
||||||
|
|
||||||
# Docker creates a directory when bind-mounting a file that doesn't exist on the host.
|
# Docker crée un répertoire si le fichier hôte n'existe pas lors du bind mount.
|
||||||
# Remove it so bootstrap.php falls back to defaults.
|
# Le supprimer pour que bootstrap.php retombe sur les valeurs par défaut.
|
||||||
if [ -d "$CONFIG" ]; then
|
if [ -d "$CONFIG" ]; then
|
||||||
rmdir "$CONFIG" 2>/dev/null || true
|
rmdir "$CONFIG" 2>/dev/null || true
|
||||||
echo "Warning: config.local.ini was mounted as a directory (file missing on host). Using defaults."
|
echo "Warning: config.local.ini monté comme répertoire (fichier absent sur l'hôte). Valeurs par défaut utilisées."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
install -d -m 0775 -o www-data -g www-data \
|
install -d -m 0775 -o www-data -g www-data \
|
||||||
@@ -19,8 +19,8 @@ install -d -m 0775 -o www-data -g www-data \
|
|||||||
"$APP_ROOT/tmp/cache" \
|
"$APP_ROOT/tmp/cache" \
|
||||||
"$APP_ROOT/tmp/uploads"
|
"$APP_ROOT/tmp/uploads"
|
||||||
|
|
||||||
# Bind mounts may keep host-side ownership/permissions. Normalize the writable
|
# Les bind mounts peuvent conserver les permissions de l'hôte.
|
||||||
# application directories before boot so F3 can write its cache and SQLite files.
|
# Normaliser les dossiers inscriptibles avant le démarrage.
|
||||||
chown -R www-data:www-data \
|
chown -R www-data:www-data \
|
||||||
"$APP_ROOT/db" \
|
"$APP_ROOT/db" \
|
||||||
"$APP_ROOT/logs" \
|
"$APP_ROOT/logs" \
|
||||||
@@ -32,7 +32,7 @@ chmod -R u+rwX,g+rwX \
|
|||||||
"$APP_ROOT/public/uploads/media" \
|
"$APP_ROOT/public/uploads/media" \
|
||||||
"$APP_ROOT/tmp"
|
"$APP_ROOT/tmp"
|
||||||
|
|
||||||
# Run installation as the web user so generated files keep consistent ownership.
|
# Exécuter l'installation en tant que www-data pour conserver des permissions cohérentes.
|
||||||
su -s /bin/sh www-data -c "php $APP_ROOT/scripts/install.php"
|
su -s /bin/sh www-data -c "php $APP_ROOT/scripts/install.php"
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -663,16 +663,17 @@ textarea.control {
|
|||||||
|
|
||||||
.card-title__link,
|
.card-title__link,
|
||||||
.card-media-link {
|
.card-media-link {
|
||||||
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-summary,
|
|
||||||
.card-title__link:hover,
|
.card-title__link:hover,
|
||||||
.card-title__link:focus-visible {
|
.card-title__link:focus-visible {
|
||||||
color: inherit;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-summary {
|
.card-summary {
|
||||||
|
color: inherit;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user