diff --git a/.gitignore b/.gitignore
index cd7f5d3..36bdd1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,8 @@
/logs/*
!/logs/.gitkeep
/vendor/
-/tmp/
+/tmp/*
+!/tmp/.gitkeep
/public/uploads/media/*
!/public/uploads/media/.gitkeep
/config.local.ini
diff --git a/Caddyfile.example b/Caddyfile.example
index bc57767..bc946f9 100644
--- a/Caddyfile.example
+++ b/Caddyfile.example
@@ -1,9 +1,9 @@
-# Exemple de configuration Caddy en reverse proxy vers l'application.
-# Copier ce fichier vers Caddyfile et adapter le domaine / la cible.
-# TLS, compression et en-têtes de sécurité restent gérés ici, pas dans l'app PHP.
+# Exemple de reverse proxy Caddy.
+# Copier vers Caddyfile et adapter le domaine et la cible.
+# TLS et en-têtes HTTP se gèrent ici.
blog.example.com {
- # ── En-têtes de sécurité (toutes les réponses) ───────────────────
+ # En-têtes de sécurité
header {
Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; img-src 'self' data:; style-src 'self'; script-src 'self'"
diff --git a/Dockerfile b/Dockerfile
index dfdfc32..dcfcd25 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,9 +8,12 @@ RUN apt-get update \
libonig-dev \
libicu-dev \
libxml2-dev \
+ libjpeg62-turbo-dev \
+ libpng-dev \
unzip \
git \
- && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \
+ && docker-php-ext-configure gd --with-jpeg \
+ && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom gd \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
@@ -29,7 +32,10 @@ RUN apt-get update \
libonig-dev \
libicu-dev \
libxml2-dev \
- && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \
+ libjpeg62-turbo-dev \
+ libpng-dev \
+ && docker-php-ext-configure gd --with-jpeg \
+ && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom gd \
&& printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \
&& a2enconf servername \
&& rm -rf /var/lib/apt/lists/*
diff --git a/README.md b/README.md
index 9fcd91d..7ffdd67 100644
--- a/README.md
+++ b/README.md
@@ -1,190 +1,74 @@
# F3 Simple Blog
-Blog simple construit avec Fat-Free Framework et SQLite.
+Blog minimal en **Fat-Free Framework** avec **SQLite**.
-Le projet vise un blog léger, lisible et facile à déployer, avec un petit back-office d’administration, une médiathèque locale et un rendu Markdown sécurisé.
+- rendu côté serveur
+- routes dans `config.ini`
+- modèles `DB\SQL\Mapper`
+- édition en Markdown
+- médiathèque paginée séparée de l’éditeur
+- nettoyage des fichiers orphelins via script CLI
-## Fonctionnalités
+## Prérequis
-- listing public des articles avec pagination ;
-- page article ;
-- authentification admin ;
-- création, modification et suppression d’articles ;
-- médiathèque locale avec upload, texte alternatif, copie de la syntaxe Markdown et suppression ;
-- vignette de carte dérivée de la première image du contenu ;
-- rendu Markdown avec images locales `media:...` ;
-- stockage des images en **JPG/PNG bruts** sans transformation.
+- PHP 8.3+
+- extensions PHP : `pdo_sqlite`, `mbstring`, `intl`, `dom`, `gd`
+- Composer
+
+## Installation
+
+```bash
+composer install
+php scripts/install.php
+php scripts/create-admin.php admin
+```
+
+Lancer ensuite un serveur local :
+
+```bash
+php -S 127.0.0.1:8000 -t public
+```
## Structure
```text
-project/
-├── app/
-│ ├── bootstrap.php
-│ ├── config.ini
-│ ├── helpers.php
-│ ├── Controllers/
-│ ├── Models/
-│ ├── Services/
-│ └── Views/
-├── db/
-├── logs/
-├── public/
-│ ├── assets/
-│ └── uploads/media/
-├── docker/
-├── scripts/
-│ ├── bootstrap.php
-│ ├── install.php
-│ └── create-admin.php
-└── tmp/uploads/
+app/ contrôleurs, modèles, vues et service Markdown
+public/ assets et fichiers médias
+scripts/ installation, création d’admin, nettoyage des orphelins
```
-## Architecture
+## Flux éditorial
-Le backend s’appuie sur un petit noyau :
+- Les articles sont rédigés en Markdown.
+- Les images sont gérées dans la médiathèque.
+- La médiathèque fournit un bouton **Copier le Markdown**.
+- L’éditeur reste simple : textarea, petite toolbar Markdown et lien vers la médiathèque.
-- `SiteController` : pages publiques ;
-- `AuthController` : connexion / déconnexion ;
-- `AdminController` : back-office articles et médiathèque ;
-- `Controller` : rendu, session courante, flash, CSRF ;
-- `Post`, `Media`, `User` : modèles `DB\SQL\Mapper` ;
-- `MarkdownService` : compilation et sanitation du contenu Markdown.
+## Médias
-## Intégration F3
+La table `media` est la référence côté application.
-Le projet utilise directement les briques natives du framework :
+- Supprimer un média retire la ligne SQL.
+- Les fichiers orphelins éventuels sont nettoyés via un script CLI.
-- routes et aliases dans `app/config.ini` ;
-- `DB\SQL\Mapper` pour les tables principales ;
-- `Auth` pour la connexion ;
-- `Session` pour la session ;
-- `Template` pour le rendu ;
-- `Web::receive()` pour la réception des uploads ;
-- `Markdown` pour le parsing Markdown ;
-- `Web::slug()` pour les slugs.
-
-## Contenu et médiathèque
-
-Les articles contiennent leur texte en Markdown. Les images sont insérées dans le corps du contenu, et la première image rendue dans `body_html` sert de vignette dans les cartes d’article.
-
-Les images du contenu utilisent la syntaxe :
-
-```md
-
-```
-
-La médiathèque :
-
-- accepte uniquement **JPG** et **PNG** ;
-- vérifie que le fichier reçu est bien une image ;
-- conserve le fichier tel quel, sans réencodage ;
-- stocke en base le nom du fichier, le texte alternatif, la largeur, la hauteur et la date de création.
-
-## Contrat Markdown
-
-Le rendu Markdown suit les règles suivantes :
-
-- le HTML brut saisi par l’auteur n’est pas rendu ;
-- les liens sont filtrés avant rendu ;
-- seules les images locales en `media:...` sont rendues ;
-- les images externes ne sont pas affichées ;
-- avec le parseur Markdown de F3, il faut laisser une ligne vide entre deux blocs (titre, liste, citation, image, code) pour un rendu fiable.
-
-## Sécurité
-
-Le projet inclut :
-
-- session d’administration ;
-- jeton CSRF sur les formulaires ;
-- rotation du jeton CSRF après connexion et déconnexion ;
-- sanitation du HTML produit à partir du Markdown ;
-- validation des uploads image ;
-- cookies `httponly` et `samesite=Lax`.
-
-## Pré-requis
-
-### Développement local
-
-- PHP 8.3+
-- Composer
-- extensions PHP : `pdo_sqlite`, `mbstring`, `intl`, `dom`
-
-### Déploiement Docker
-
-- Docker
-- Docker Compose
-
-## Démarrage local
+Lister les orphelins :
```bash
-composer install
-cp config.local.ini.example config.local.ini
-php scripts/install.php
-php -S 127.0.0.1:8080 -t public
+php scripts/clean-orphan-media.php list
```
-Puis ouvrir `http://127.0.0.1:8080`.
-
-Créer un compte admin :
+Supprimer les orphelins :
```bash
-php scripts/create-admin.php admin
+php scripts/clean-orphan-media.php delete
```
-## Configuration
+## Déploiement 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
-```
-
-Le paramètre `app.env` doit être défini à `prod` sur un déploiement réel.
-
-## Déploiement Docker derrière Caddy
-
-Déploiement Docker recommandé :
-
-- Apache sert `public/` dans le conteneur ;
-- `compose.yaml` expose l’application 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 persistants.
-
-Exemple :
+Une configuration Docker est fournie :
```bash
-cp config.local.ini.example config.local.ini
-# édite config.local.ini : app.env=prod
-docker compose up -d --build
+docker compose up --build
```
-Créer un compte admin dans le conteneur :
-
-```bash
-docker compose exec app php scripts/create-admin.php admin
-```
-
-La commande demande ensuite le mot de passe du compte.
-
-Le `Caddyfile.example` fournit une base de reverse proxy. En production, il faut exposer publiquement **Caddy uniquement**. L’application Apache/PHP ne doit pas être accessible directement depuis Internet.
-
-## Sauvegarde
-
-Pour sauvegarder le blog, il faut au minimum conserver :
-
-- `db/app.sqlite`
-- `public/uploads/media/`
-
-Les logs peuvent aussi être conservés si tu veux garder l’historique d’erreurs.
-
-## Limites assumées
-
-- base SQLite ;
-- instance applicative unique ;
-- pas de système de migrations automatique ;
-- en cas de changement de schéma, une migration manuelle ou une base recréée sera nécessaire.
+L’application écoute sur `127.0.0.1:8888` par défaut.
diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php
index 5251bf5..0239e04 100644
--- a/app/Controllers/AdminController.php
+++ b/app/Controllers/AdminController.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
class AdminController extends Controller
{
- private const MEDIA_PICKER_LIMIT = 60;
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
public function beforeRoute(): void
@@ -28,52 +27,52 @@ class AdminController extends Controller
public function create(): void
{
- $this->postForm('Nouvel article', $this->f3->alias('post_store'), Post::blank());
+ $this->renderPostForm('Nouvel article', $this->f3->alias('post_store'), Post::blank());
}
public function store(): void
{
- $this->checkCsrf();
- $input = $this->postInput();
+ $this->verifyCsrf();
+ $input = $this->readPostInput();
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());
+ $this->renderPostForm('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) {
+ if ($post === null) {
$this->f3->error(404);
return;
}
- $this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
+ $this->renderPostForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
}
public function update(): void
{
- $this->checkCsrf();
+ $this->verifyCsrf();
$id = (int) $this->f3->get('PARAMS.id');
- $input = $this->postInput() + ['id' => $id];
+ $input = $this->readPostInput() + ['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());
+ $this->renderPostForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
}
}
public function delete(): void
{
- $this->checkCsrf();
+ $this->verifyCsrf();
try {
(new Post())->deleteById((int) $this->f3->get('PARAMS.id'));
@@ -95,25 +94,27 @@ class AdminController extends Controller
'items' => $result['items'],
'pagination' => $result['pagination'],
'paginationAlias' => 'media_index',
+ 'adminMode' => true,
]);
}
public function mediaUpload(): void
{
- $this->checkCsrf();
+ $this->verifyCsrf();
try {
- $original = (string) ($this->f3->get('FILES.image.name') ?: '');
+ $originalName = (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.');
+ throw new RuntimeException('Choisis une image JPG ou PNG valide.');
}
- (new Media())->upload($path, $original);
+ (new Media())->upload($path, $originalName);
$this->flash('success', 'Image ajoutée.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
@@ -124,11 +125,14 @@ class AdminController extends Controller
public function mediaAlt(): void
{
- $this->checkCsrf();
+ $this->verifyCsrf();
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.');
+ (new Media())->updateAlt(
+ (int) $this->f3->get('PARAMS.id'),
+ $this->f3->clean((string) ($this->f3->get('POST.alt') ?: ''))
+ );
+ $this->flash('success', 'Texte alternatif enregistré.');
} catch (RuntimeException $e) {
$this->flash('error', $e->getMessage());
}
@@ -138,17 +142,18 @@ class AdminController extends Controller
public function mediaDelete(): void
{
- $this->checkCsrf();
+ $this->verifyCsrf();
try {
$media = new Media();
$item = $media->findById((int) $this->f3->get('PARAMS.id'));
- if (!$item) {
+ if ($item === null) {
throw new RuntimeException('Image introuvable.');
}
if ((new Post())->usesMedia($item['file_name'])) {
- throw new RuntimeException('Cette image est encore utilisée par un article.');
+ throw new RuntimeException('Cette image est utilisée dans un article.');
}
+
$media->deleteById($item['id']);
$this->flash('success', 'Image supprimée.');
} catch (RuntimeException $e) {
@@ -158,27 +163,22 @@ class AdminController extends Controller
$this->f3->reroute('@media_index');
}
- private function postForm(string $title, string $action, array $post, ?string $error = null): void
+ private function renderPostForm(string $title, string $action, array $post, ?string $error = null): void
{
- $media = new Media();
- $items = $media->recent(self::MEDIA_PICKER_LIMIT);
- $count = $media->count();
+ $flash = $error ? [['type' => 'error', 'message' => $error]] : [];
$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]] : [],
+ 'flash' => $flash,
+ 'adminMode' => true,
]);
}
- private function postInput(): array
+ private function readPostInput(): array
{
return [
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?: '')),
diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php
index e34a4ec..5bbe528 100644
--- a/app/Controllers/AuthController.php
+++ b/app/Controllers/AuthController.php
@@ -6,7 +6,7 @@ class AuthController extends Controller
{
public function show(): void
{
- if ($this->user()) {
+ if ($this->currentUser()) {
$this->f3->reroute('@dashboard');
return;
}
@@ -16,10 +16,11 @@ class AuthController extends Controller
public function login(): void
{
- $this->checkCsrf();
+ $this->verifyCsrf();
$user = new User();
$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') ?: '')
@@ -27,7 +28,7 @@ class AuthController extends Controller
if (!$ok) {
usleep(1000000);
- $this->flash('error', 'Identifiants invalides.');
+ $this->flash('error', 'Identifiants incorrects.');
$this->f3->reroute('@login');
return;
}
@@ -41,11 +42,11 @@ class AuthController extends Controller
public function logout(): void
{
- $this->checkCsrf();
+ $this->verifyCsrf();
$this->f3->clear('SESSION.user_id');
session_regenerate_id(true);
$this->rotateCsrf();
- $this->flash('success', 'Déconnexion effectuée.');
+ $this->flash('success', 'Déconnexion réussie.');
$this->f3->reroute('@login');
}
}
diff --git a/app/Controllers/Controller.php b/app/Controllers/Controller.php
index a6e5b89..288c7f2 100644
--- a/app/Controllers/Controller.php
+++ b/app/Controllers/Controller.php
@@ -13,77 +13,69 @@ abstract class Controller
protected function render(string $view, array $data = [], int $ttl = 0): void
{
- $this->ensureCsrf();
- $user = $this->user();
+ $user = $this->currentUser();
$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 : [],
+ 'CSRF' => $this->csrfToken(),
'currentUser' => $user,
+ 'flash' => is_array($flash) ? $flash : [],
+ 'view' => $view,
'adminMode' => false,
'metaDescription' => null,
- 'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
]);
echo Template::instance()->render('layout.html');
}
- protected function user(): ?array
+ protected function currentUser(): ?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');
+ $id = (int) ($this->f3->get('SESSION.user_id') ?: 0);
+ return $id > 0 ? (new User())->findPublic($id) : null;
}
protected function requireAuth(): void
{
- if ($this->user()) {
+ if ($this->currentUser()) {
return;
}
- $this->flash('error', 'Connecte-toi pour continuer.');
+ $this->flash('error', 'Connecte-toi pour accéder à cette page.');
$this->f3->reroute('@login');
}
- protected function checkCsrf(): void
+ protected function csrfToken(): string
{
- $sent = trim((string) ($this->f3->get('POST.csrf_token') ?: ''));
- $expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?: ''));
+ $token = trim((string) ($this->f3->get('SESSION.csrf') ?: ''));
+ if ($token === '') {
+ $token = bin2hex(random_bytes(32));
+ $this->f3->set('SESSION.csrf', $token);
+ }
- if ($sent !== '' && $expected !== '' && hash_equals($expected, $sent)) {
+ return $token;
+ }
+
+ protected function verifyCsrf(): void
+ {
+ $sent = trim((string) ($this->f3->get('POST.csrf') ?: ''));
+ if ($sent !== '' && hash_equals($this->csrfToken(), $sent)) {
return;
}
$this->f3->error(400);
}
+ protected function rotateCsrf(): void
+ {
+ $this->f3->set('SESSION.csrf', bin2hex(random_bytes(32)));
+ }
+
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') ?: [];
diff --git a/app/Controllers/SiteController.php b/app/Controllers/SiteController.php
index 4ca0f32..75c793e 100644
--- a/app/Controllers/SiteController.php
+++ b/app/Controllers/SiteController.php
@@ -20,7 +20,7 @@ class SiteController extends Controller
public function show(): void
{
$post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'));
- if (!$post) {
+ if ($post === null) {
$this->f3->error(404);
return;
}
diff --git a/app/Models/Media.php b/app/Models/Media.php
index c311fe2..8cae622 100644
--- a/app/Models/Media.php
+++ b/app/Models/Media.php
@@ -29,9 +29,10 @@ class Media extends DB\SQL\Mapper
public function page(int $page, int $perPage): array
{
$result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
+ $items = array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []);
return [
- 'items' => array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []),
+ 'items' => $items,
'pagination' => [
'page' => max(1, min($page, $result['count'] ?: 1)),
'pages' => max(1, (int) ($result['count'] ?: 1)),
@@ -39,14 +40,6 @@ class Media extends DB\SQL\Mapper
];
}
- public function recent(int $limit): array
- {
- return array_map(
- fn(self $row): array => $this->decorate($row->cast()),
- $this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: []
- );
- }
-
public function findById(int $id): ?array
{
if ($id <= 0) {
@@ -63,18 +56,22 @@ class Media extends DB\SQL\Mapper
return $this->dry() ? null : $this->decorate($this->cast());
}
- public function upload(string $path, string $originalName = ''): int
+ public function upload(string $temporaryPath, string $originalName = ''): int
{
- if (!is_file($path)) {
+ $f3 = Base::instance();
+
+ if (!is_file($temporaryPath)) {
throw new RuntimeException('Fichier image introuvable.');
}
- $info = @getimagesize($path);
- if (!is_array($info)) {
- @unlink($path);
+ $binary = $f3->read($temporaryPath);
+ $image = new Image();
+ if ($binary === '' || $image->load($binary) === false) {
+ @unlink($temporaryPath);
throw new RuntimeException('Fichier image invalide.');
}
+ $info = @getimagesizefromstring($binary);
$mime = strtolower((string) ($info['mime'] ?? ''));
$extension = match ($mime) {
'image/jpeg' => 'jpg',
@@ -83,28 +80,39 @@ class Media extends DB\SQL\Mapper
};
if ($extension === null) {
- @unlink($path);
+ @unlink($temporaryPath);
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;
+ $encoded = match ($extension) {
+ 'jpg' => $image->dump('jpeg', 90),
+ 'png' => $image->dump('png'),
+ };
- if (!@rename($path, $target)) {
- if (!@copy($path, $target)) {
- @unlink($path);
- throw new RuntimeException('Impossible d’enregistrer cette image.');
- }
- @unlink($path);
+ $fileName = bin2hex(random_bytes(16)) . '.' . $extension;
+ $target = $this->storagePath($fileName);
+
+ try {
+ $f3->write($target, $encoded);
+ } catch (Throwable $e) {
+ @unlink($temporaryPath);
+ throw new RuntimeException('Impossible d’enregistrer cette image.', 0, $e);
}
- $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();
+ @unlink($temporaryPath);
+
+ try {
+ $this->reset();
+ $this->file_name = $fileName;
+ $this->alt = $this->guessAlt($originalName);
+ $this->width = $image->width();
+ $this->height = $image->height();
+ $this->created_at = app_now();
+ $this->save();
+ } catch (Throwable $e) {
+ @unlink($target);
+ throw new RuntimeException('Impossible de finaliser cette image.', 0, $e);
+ }
return (int) $this->id;
}
@@ -127,34 +135,41 @@ class Media extends DB\SQL\Mapper
throw new RuntimeException('Image introuvable.');
}
- $path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name;
- $this->erase();
- if (is_file($path)) {
- @unlink($path);
+ try {
+ $this->erase();
+ } catch (Throwable $e) {
+ throw new RuntimeException('Impossible de supprimer cette image.', 0, $e);
}
}
private function decorate(array $row): array
{
- $file = (string) $row['file_name'];
+ $fileName = (string) $row['file_name'];
$alt = (string) $row['alt'];
+ $base = rtrim((string) Base::instance()->get('BASE'), '/');
+ $mediaBase = rtrim((string) Base::instance()->get('paths.media_base'), '/');
return [
'id' => (int) $row['id'],
- 'file_name' => $file,
+ 'file_name' => $fileName,
'alt' => $alt,
'width' => (int) $row['width'],
'height' => (int) $row['height'],
'created_at' => (string) $row['created_at'],
- 'url' => rtrim((string) Base::instance()->get('BASE'), '/') . rtrim((string) Base::instance()->get('paths.media_base'), '/') . '/' . rawurlencode($file),
- 'markdown' => '',
+ 'url' => $base . $mediaBase . '/' . rawurlencode($fileName),
+ 'markdown' => '',
];
}
- private function altFromName(string $name): string
+ private function storagePath(string $fileName): string
{
- $name = trim(pathinfo($name, PATHINFO_FILENAME));
- $name = preg_replace('/[-_]+/', ' ', $name) ?: '';
- return trim($name);
+ return rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
+ }
+
+ private function guessAlt(string $originalName): string
+ {
+ $label = trim(pathinfo($originalName, PATHINFO_FILENAME));
+ $label = preg_replace('/[-_]+/', ' ', $label) ?: '';
+ return trim($label);
}
}
diff --git a/app/Models/Post.php b/app/Models/Post.php
index e516b92..2a7dd8e 100644
--- a/app/Models/Post.php
+++ b/app/Models/Post.php
@@ -62,13 +62,16 @@ class Post extends DB\SQL\Mapper
}
$row = $this->cast();
- $post = $this->summary($row) + ['body_html' => (string) $row['body_html']];
- return $post;
+ return $this->summary($row) + ['body_html' => (string) $row['body_html']];
}
public function findForForm(int $id): ?array
{
+ if ($id <= 0) {
+ return null;
+ }
+
$this->load(['id = ?', $id]);
if ($this->dry()) {
return null;
@@ -84,12 +87,12 @@ class Post extends DB\SQL\Mapper
public function savePost(array $input, ?int $id = null): int
{
- $payload = $this->payload($input);
+ $payload = $this->normalizePayload($input);
$now = app_now();
if ($id === null) {
$this->reset();
- $payload['slug'] = $this->uniqueSlug($payload['title']);
+ $payload['slug'] = $this->nextSlug($payload['title']);
$payload['created_at'] = $now;
} else {
$this->load(['id = ?', $id]);
@@ -120,11 +123,11 @@ class Post extends DB\SQL\Mapper
return $this->count(['body_markdown LIKE ?', '%media:' . $fileName . '%']) > 0;
}
- private function payload(array $input): array
+ private function normalizePayload(array $input): array
{
$title = trim((string) ($input['title'] ?? ''));
$excerpt = trim((string) ($input['excerpt'] ?? ''));
- $body = trim((string) ($input['body_markdown'] ?? ''));
+ $bodyMarkdown = trim((string) ($input['body_markdown'] ?? ''));
if ($title === '') {
throw new RuntimeException('Ajoute un titre.');
@@ -142,20 +145,20 @@ class Post extends DB\SQL\Mapper
return [
'title' => $title,
'excerpt' => $excerpt,
- 'body_markdown' => $body,
- 'body_html' => MarkdownService::instance()->compile($body, new Media()),
+ 'body_markdown' => $bodyMarkdown,
+ 'body_html' => MarkdownService::instance()->compile($bodyMarkdown, new Media()),
];
}
- private function uniqueSlug(string $title): string
+ private function nextSlug(string $title): string
{
$base = app_slug($title);
$slug = $base;
- $n = 2;
+ $suffix = 2;
while ($this->count(['slug = ?', $slug]) > 0) {
- $slug = $base . '-' . $n;
- $n++;
+ $slug = $base . '-' . $suffix;
+ $suffix++;
}
return $slug;
@@ -163,7 +166,7 @@ class Post extends DB\SQL\Mapper
private function summary(array $row): array
{
- $thumbnail = $this->firstImage((string) ($row['body_html'] ?? ''));
+ $thumbnail = $this->extractThumbnail((string) ($row['body_html'] ?? ''));
return [
'id' => (int) $row['id'],
@@ -177,20 +180,14 @@ class Post extends DB\SQL\Mapper
];
}
- private function firstImage(string $html): array
+ private function extractThumbnail(string $html): array
{
- if ($html === '') {
+ if ($html === '' || !preg_match('~(]*src="([^"]+)"[^>]*>)~i', $html, $match)) {
return ['url' => '', 'alt' => ''];
}
- if (!preg_match('~(
]*src="([^"]+)"[^>]*>)~i', $html, $match)) {
- return ['url' => '', 'alt' => ''];
- }
-
- $tag = $match[1];
$alt = '';
-
- if (preg_match('~alt="([^"]*)"~i', $tag, $altMatch)) {
+ if (preg_match('~alt="([^"]*)"~i', $match[1], $altMatch)) {
$alt = html_entity_decode($altMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 0b5ea5a..35c4ccf 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -25,6 +25,10 @@ class User extends DB\SQL\Mapper
public function findPublic(int $id): ?array
{
+ if ($id <= 0) {
+ return null;
+ }
+
$this->load(['id = ?', $id]);
if ($this->dry()) {
return null;
diff --git a/app/Services/MarkdownService.php b/app/Services/MarkdownService.php
index ba5aa29..dedc8ab 100644
--- a/app/Services/MarkdownService.php
+++ b/app/Services/MarkdownService.php
@@ -4,8 +4,8 @@ declare(strict_types=1);
class MarkdownService extends Prefab
{
- private const TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br'];
- private const ATTRS = [
+ private const ALLOWED_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br'];
+ private const ALLOWED_ATTRIBUTES = [
'a' => ['href', 'title', 'rel', 'target'],
'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
];
@@ -17,31 +17,30 @@ class MarkdownService extends Prefab
throw new RuntimeException('Ajoute du contenu avant de publier.');
}
- $markdown = $this->neutralizeRawHtml($markdown);
-
$doc = new DOMDocument('1.0', 'UTF-8');
- $html = '
Commence par créer un premier article.
+Crée un article pour commencer.
Parcourir les images par page évite de charger toute la bibliothèque d'un coup.
+Ajoute une image ou copie son Markdown pour l’insérer dans un article.
Ajoute ta première image.
+Ajoute une image pour commencer.