' . $title . '
' . $message . '
diff --git a/Dockerfile b/Dockerfile index a48bed6..dfdfc32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,17 +5,12 @@ FROM php:8.3-cli AS vendor RUN apt-get update \ && apt-get install -y --no-install-recommends \ libsqlite3-dev \ - libjpeg62-turbo-dev \ - libpng-dev \ - libwebp-dev \ - libfreetype6-dev \ libonig-dev \ libicu-dev \ libxml2-dev \ unzip \ git \ - && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ - && docker-php-ext-install -j"$(nproc)" pdo_sqlite gd mbstring opcache intl dom \ + && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \ && rm -rf /var/lib/apt/lists/* COPY --from=composer:2 /usr/bin/composer /usr/bin/composer @@ -31,15 +26,10 @@ FROM php:8.3-apache RUN apt-get update \ && apt-get install -y --no-install-recommends \ libsqlite3-dev \ - libjpeg62-turbo-dev \ - libpng-dev \ - libwebp-dev \ - libfreetype6-dev \ libonig-dev \ libicu-dev \ libxml2-dev \ - && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ - && docker-php-ext-install -j"$(nproc)" pdo_sqlite gd mbstring opcache intl dom \ + && docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \ && 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 95904d2..5937883 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,88 @@ # 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 ```text project/ -├── config.local.ini # Surcharges locales (gitignored) ├── app/ -│ ├── config.ini # Configuration F3 (globals + routes) -│ ├── bootstrap.php # Initialisation (config, DB, session, erreurs) +│ ├── bootstrap.php +│ ├── config.ini +│ ├── helpers.php │ ├── Controllers/ -│ ├── Helpers/ # Fonctions utilitaires ciblées -│ ├── Models/ # DB\SQL\Mapper (Post, Media, User) -│ ├── Services/ # MarkdownService +│ ├── Models/ +│ ├── Services/ │ └── Views/ ├── db/ -│ └── app.sqlite # Base SQLite persistante ├── logs/ -│ └── php-error.log # Log PHP configuré au runtime ├── public/ -│ ├── assets/ # Assets statiques servis directement -│ └── uploads/ -│ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG) +│ ├── assets/ +│ └── uploads/media/ +├── docker/ ├── scripts/ -│ ├── bootstrap.php # Autoload + bootstrap partagé par les scripts CLI -│ ├── install.php # Initialisation idempotente de la base -│ └── create-admin.php # Création d'un compte admin en CLI -└── tmp/ # Runtime temporaire, recréable sans perte métier - └── uploads/ # Transit Web::receive(), nettoyé après chaque upload +│ ├── bootstrap.php +│ ├── install.php +│ └── create-admin.php +└── tmp/uploads/ ``` -## 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. -- **Cache HTTP** — TTL par contrôleur via `expire()`, forcé à `0` quand un utilisateur est connecté. -- **Assets statiques** — servis directement depuis `public/assets/`, laissés au serveur HTTP / reverse proxy. -- **Upload** — `Web::receive()` avec contrôle de taille, puis validation MIME/dimensions côté modèle. -- **Images** — normalisation des médias via `Image`, lecture/écriture via `Base::read()` / `Base::write()`. -- **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. -- **Slugs** — `Web::instance()->slug()`. -- **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()`. -- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`. -- **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale. +- routes dans `config.ini` +- `DB\SQL\Mapper` pour les trois tables +- `Auth` pour la connexion +- `Session` pour la session et le jeton CSRF de base +- `Template` pour le rendu et le filtre `date_fr` +- `Web::receive()` pour l’upload +- `Markdown` pour le rendu des articles +- `Web::slug()` pour les slugs -## 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 d’image 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 d’article. 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** : l’upload 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 - PHP 8.3+ - Composer -- Extensions PHP : `pdo_sqlite`, `gd`, `mbstring`, `intl`, `dom` +- extensions : `pdo_sqlite`, `mbstring`, `intl`, `dom` ### Déploiement Docker - Docker - Docker Compose -## Configuration - -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 +## Démarrage local ```bash composer install @@ -111,111 +97,69 @@ Créer un compte admin : ```bash 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 l’application 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 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 hôtes. + +Exemple : ```bash 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 ``` -Le déploiement Docker est cohérent avec le projet : +Le `Caddyfile.example` donne une base de reverse proxy. Le point important est de **n’exposer publiquement que Caddy**. L’application 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`) ; -- `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). +### Volumes persistants à sauvegarder -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 l’historique d’erreurs. -```bash -docker compose exec app php scripts/create-admin.php admin -# mot de passe : 10 caractères minimum -``` +## Contrat Markdown -## 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 ; -- `/posts/@slug` : TTL de 3600 s. +## Limite volontaire : pas de migrations -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 n’existent 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 d’une 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 - -- Formats acceptés à l'entrée : JPG, PNG, WebP. -- Taille max du fichier reçu : 10 Mo. -- 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. +- suppression d'une méthode utilitaire inutilisée dans `Media` ; +- suppression d'un paramètre mort dans `Post::usesMedia()` ; +- simplification de l'upload temporaire côté admin ; +- détection `secure` des cookies un peu plus tolérante (`HTTPS`, `REQUEST_SCHEME`, `X-Forwarded-Proto`) sans réintroduire une grosse logique proxy. diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 957e58f..5251bf5 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -2,10 +2,188 @@ 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 { $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') ?: '')), + ]; + } } diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 2e92b0e..e34a4ec 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -2,11 +2,11 @@ declare(strict_types=1); -class AuthController extends BaseController +class AuthController extends Controller { public function show(): void { - if ($this->currentUser() !== null) { + if ($this->user()) { $this->f3->reroute('@dashboard'); return; } @@ -16,36 +16,35 @@ class AuthController extends BaseController 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(); - $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)) { - usleep(1_500_000); // 1,5 s — ralentit le brute-force + if (!$ok) { + usleep(1000000); $this->flash('error', 'Identifiants invalides.'); $this->f3->reroute('@login'); return; } - session_regenerate_id(true); // Prévient la fixation de session. - $this->resetCsrfToken(); // Le nouveau contexte repart avec un jeton dédié. - $this->f3->set('SESSION.user_id', $user->id); + session_regenerate_id(true); + $this->f3->set('SESSION.user_id', (int) $user->id); + $this->rotateCsrf(); $this->flash('success', 'Connexion réussie.'); $this->f3->reroute('@dashboard'); } public function logout(): void { - $this->verifyCsrf(); + $this->checkCsrf(); $this->f3->clear('SESSION.user_id'); - $this->resetCsrfToken(); - session_regenerate_id(true); // Invalide l'ancien ID de session. + session_regenerate_id(true); + $this->rotateCsrf(); $this->flash('success', 'Déconnexion effectuée.'); $this->f3->reroute('@login'); } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php deleted file mode 100644 index 420a3dd..0000000 --- a/app/Controllers/BaseController.php +++ /dev/null @@ -1,112 +0,0 @@ -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') ?: []; - } -} diff --git a/app/Controllers/Controller.php b/app/Controllers/Controller.php new file mode 100644 index 0000000..a6e5b89 --- /dev/null +++ b/app/Controllers/Controller.php @@ -0,0 +1,91 @@ +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') ?: []; + } +} diff --git a/app/Controllers/MediaController.php b/app/Controllers/MediaController.php deleted file mode 100644 index c9516f4..0000000 --- a/app/Controllers/MediaController.php +++ /dev/null @@ -1,99 +0,0 @@ -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'); - } -} diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php deleted file mode 100644 index 47f5381..0000000 --- a/app/Controllers/PostController.php +++ /dev/null @@ -1,130 +0,0 @@ -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') ?? '')), - ]; - } -} diff --git a/app/Controllers/SiteController.php b/app/Controllers/SiteController.php index 039c096..4ca0f32 100644 --- a/app/Controllers/SiteController.php +++ b/app/Controllers/SiteController.php @@ -2,28 +2,26 @@ declare(strict_types=1); -class SiteController extends BaseController +class SiteController extends Controller { public function home(): void { - $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); - $media = new Media(); - $result = (new Post())->paginateList($page, 12, $media); + $page = max(1, (int) ($this->f3->get('GET.page') ?: 1)); + $result = (new Post())->page($page, 12); $this->render('site/home.html', [ - 'pageTitle' => 'Accueil', - 'posts' => $result['posts'], - 'pagination' => $result, + 'pageTitle' => 'Articles', + 'posts' => $result['items'], + 'pagination' => $result['pagination'], 'paginationAlias' => 'home', ], 300); } public function show(): void { - $media = new Media(); - $post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'), $media); - if ($post === null) { - $this->f3->error(404, 'Article introuvable.'); + $post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug')); + if (!$post) { + $this->f3->error(404); return; } @@ -31,6 +29,6 @@ class SiteController extends BaseController 'pageTitle' => $post['title'], 'metaDescription' => $post['excerpt'], 'post' => $post, - ], 3600); + ], 300); } } diff --git a/app/Helpers/App.php b/app/Helpers/App.php deleted file mode 100644 index 80417de..0000000 --- a/app/Helpers/App.php +++ /dev/null @@ -1,210 +0,0 @@ -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); -} diff --git a/app/Helpers/Error.php b/app/Helpers/Error.php deleted file mode 100644 index 3922f74..0000000 --- a/app/Helpers/Error.php +++ /dev/null @@ -1,87 +0,0 @@ -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 '
' . $message . '
Astuce : avec le parseur Markdown de F3, laisse une ligne vide entre deux blocs (titre, liste, citation, image, code) pour un rendu fiable.
@@ -79,8 +52,8 @@