diff --git a/Caddyfile.example b/Caddyfile.example index a6fca2c..bc57767 100644 --- a/Caddyfile.example +++ b/Caddyfile.example @@ -1,5 +1,6 @@ # 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. blog.example.com { # ── En-têtes de sécurité (toutes les réponses) ─────────────────── diff --git a/Dockerfile b/Dockerfile index f446189..a48bed6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,18 @@ 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-install intl \ + && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ + && docker-php-ext-install -j"$(nproc)" pdo_sqlite gd mbstring opcache intl dom \ && rm -rf /var/lib/apt/lists/* COPY --from=composer:2 /usr/bin/composer /usr/bin/composer @@ -29,8 +37,9 @@ RUN apt-get update \ 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 \ + && docker-php-ext-install -j"$(nproc)" pdo_sqlite gd 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 85f75ab..853c959 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ project/ │ ├── config.ini # Configuration F3 (globals + routes) │ ├── bootstrap.php # Initialisation (config, DB, session, erreurs) │ ├── Controllers/ -│ ├── Helpers/ # Fonctions utilitaires +│ ├── Helpers/ # Fonctions utilitaires ciblées │ ├── Models/ # DB\SQL\Mapper (Post, Media, User) │ ├── Services/ # MarkdownService │ └── Views/ @@ -20,7 +20,7 @@ project/ ├── logs/ │ └── php-error.log # Log PHP configuré au runtime ├── public/ -│ ├── assets/ # Sources CSS/JS servies via /min/@file +│ ├── assets/ # Assets statiques servis directement │ └── uploads/ │ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG) ├── scripts/ @@ -28,7 +28,6 @@ project/ │ ├── 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 - ├── cache/ # Cache F3 + assets minifiés └── uploads/ # Transit Web::receive(), nettoyé après chaque upload ``` @@ -36,12 +35,12 @@ project/ - **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 minifiés** — `Web::minify()` via `AssetController` (`GET /min/@file`). +- **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 GD (JPG conservé, PNG/WebP convertis en PNG pour préserver la transparence). -- **Markdown** — `Markdown::instance()->convert()` + `strip_tags` et résolution des images média. +- **Images** — normalisation des médias via `Image`, lecture/écriture via `Base::read()` / `Base::write()`. +- **Markdown** — `Markdown::instance()->convert()` suivi d'un assainissement DOM ciblé et de la résolution des images média. - **Slugs** — `Web::instance()->slug()`. -- **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. +- **Session / CSRF** — `$f3->set('JAR', …)`, `new Session(null, 'CSRF')`, hooks `beforeRoute()` sur les contrôleurs protégés, et petit pool de jetons côté session pour éviter les collisions multi-onglets. - **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`. - **Erreurs** — gestion personnalisée en production via `ONERROR` + fallback HTML minimal sur erreur fatale. @@ -51,7 +50,7 @@ project/ - PHP 8.3+ - Composer -- Extensions PHP : `pdo_sqlite`, `gd`, `mbstring`, `intl` +- Extensions PHP : `pdo_sqlite`, `gd`, `mbstring`, `intl`, `dom` ### Déploiement Docker @@ -74,6 +73,25 @@ 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`. + ## Développement local ```bash @@ -98,11 +116,16 @@ php scripts/create-admin.php admin ```bash cp config.local.ini.example config.local.ini -# édite config.local.ini (app.env=prod, app.timezone, etc.) +# édite config.local.ini (app.env=prod, app.timezone, trusted proxies si besoin) 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 éphémère. +Le déploiement Docker est cohérent avec le projet : + +- 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). 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`. @@ -117,15 +140,14 @@ docker compose exec app php scripts/create-admin.php admin ## Cache public -Les pages publiques sont cacheables pour un visiteur anonyme : +Les pages publiques restent cacheables pour un visiteur anonyme : -- `/` : TTL de 300 s. +- `/` : TTL de 300 s ; - `/posts/@slug` : TTL de 3600 s. -- `/min/app.css` et `/min/app.js` : TTL de 86400 s. Quand un utilisateur est connecté, le rendu est forcé en non-cacheable avec `expire(0)` pour ne pas servir du contenu admin via un cache intermédiaire. -Le projet ne fait pas d'invalidation explicite du cache lors des mutations d'articles : la fraîcheur dépend des TTL ci-dessus. +Les assets statiques (`public/assets/`) sont servis directement ; leur éventuel cache HTTP relève du serveur web ou du reverse proxy. ## Médias et limites d'upload @@ -159,8 +181,19 @@ blog.example.com { } ``` +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. @@ -178,10 +211,7 @@ docker compose up -d --build - PHP : `logs/php-error.log`. - 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 - 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. diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php new file mode 100644 index 0000000..957e58f --- /dev/null +++ b/app/Controllers/AdminController.php @@ -0,0 +1,11 @@ +requireAuth(); + } +} diff --git a/app/Controllers/AssetController.php b/app/Controllers/AssetController.php deleted file mode 100644 index 374f95c..0000000 --- a/app/Controllers/AssetController.php +++ /dev/null @@ -1,30 +0,0 @@ - 'text/css', - 'app.js' => 'application/javascript', - ]; - - public function serve(): void - { - $f3 = Base::instance(); - $file = basename((string) $f3->get('PARAMS.file')); - - if (!array_key_exists($file, self::ALLOWED)) { - $f3->error(404); - return; - } - - $f3->expire(86400); - echo Web::instance()->minify( - $file, - self::ALLOWED[$file], - true, // envoie le Content-Type - app_root() . '/public/assets/' // répertoire source (hors UI) - ); - } -} diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index c70227b..2e92b0e 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -18,7 +18,7 @@ class AuthController extends BaseController { $this->verifyCsrf(); - $username = trim((string) ($this->f3->get('POST.username') ?? '')); + $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. @@ -34,6 +34,7 @@ class AuthController extends BaseController } 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); $this->flash('success', 'Connexion réussie.'); $this->f3->reroute('@dashboard'); @@ -43,6 +44,7 @@ class AuthController extends BaseController { $this->verifyCsrf(); $this->f3->clear('SESSION.user_id'); + $this->resetCsrfToken(); session_regenerate_id(true); // Invalide l'ancien ID de session. $this->flash('success', 'Déconnexion effectuée.'); $this->f3->reroute('@login'); diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index ee5f4a3..af52d20 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -17,25 +17,26 @@ abstract class BaseController // 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. - $this->f3->expire($this->currentUser() !== null ? 0 : $cacheTtl); + $currentUser = $this->currentUser(); + $this->f3->expire($currentUser !== null ? 0 : $cacheTtl); $flash = array_key_exists('flash', $data) && is_array($data['flash']) ? $data['flash'] : $this->pullFlash(); - // currentUser est déjà dans le hive (posé par currentUser()). - // Le template y accède directement via {{ @currentUser }}. + // Jeton CSRF stable par session : plus simple et plus robuste que le + // pool précédent, tout en restant compatible multi-onglets. + $this->ensureCsrfToken(); + $this->f3->mset($data + [ 'view' => $view, 'flash' => $flash, 'metaDescription' => null, 'adminMode' => false, + 'currentUser' => $currentUser, + 'FORM_CSRF' => (string) $this->f3->get('SESSION.csrf_token'), ]); - // F3 régénère @CSRF à chaque requête (variable hive uniquement). - // On le persiste en session pour que verifyCsrf() puisse comparer - // le jeton soumis au POST suivant. - $this->f3->copy('CSRF', 'SESSION.csrf_token'); echo Template::instance()->render('layout.html'); } @@ -62,12 +63,10 @@ abstract class BaseController $this->f3->reroute('@login'); } - // F3 copy() persiste le jeton CSRF en session au rendu (voir render()). - // On compare ici le jeton soumis par le formulaire avec celui en session. protected function verifyCsrf(): void { - $submitted = (string) ($this->f3->get('POST.csrf_token') ?? ''); - $expected = (string) ($this->f3->get('SESSION.csrf_token') ?? ''); + $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; @@ -82,6 +81,22 @@ abstract class BaseController $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; + } + + $this->f3->set('SESSION.csrf_token', bin2hex(random_bytes(32))); + } + private function pullFlash(): array { return $this->f3->pull('SESSION.flash') ?: []; diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php deleted file mode 100644 index b1aeae7..0000000 --- a/app/Controllers/DashboardController.php +++ /dev/null @@ -1,26 +0,0 @@ -requireAuth(); - } - - public function index(): void - { - $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); - $media = new Media(); - $result = (new Post())->paginateList($page, 24, $media); - - $this->render('admin/dashboard.html', [ - 'pageTitle' => 'Tableau de bord', - 'posts' => $result['posts'], - 'pagination' => $result, - 'paginationAlias' => 'dashboard', - 'adminMode' => true, - ]); - } -} diff --git a/app/Controllers/MediaController.php b/app/Controllers/MediaController.php index 4fc8d7d..c9516f4 100644 --- a/app/Controllers/MediaController.php +++ b/app/Controllers/MediaController.php @@ -2,17 +2,11 @@ declare(strict_types=1); -class MediaController extends BaseController +class MediaController extends AdminController { private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo - private const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; private const PER_PAGE = 24; - public function beforeRoute(): void - { - $this->requireAuth(); - } - public function index(): void { $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); @@ -35,23 +29,24 @@ class MediaController extends BaseController $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 - && in_array($file['type'] ?? '', self::ACCEPTED_TYPES, true), + && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES, overwrite: false, slug: true ); - // UPLOADS étant absolu (bootstrap.php), les chemins retournés le sont aussi. + // 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 ($accepted === []) { + if ($destPath === null) { throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int) (self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).'); } - foreach ($accepted as $destPath) { - (new Media())->upload($destPath, $originalName); - } + (new Media())->upload($destPath, $originalName); $this->flash('success', 'Image ajoutée.'); } catch (RuntimeException $e) { @@ -66,9 +61,8 @@ class MediaController extends BaseController $this->verifyCsrf(); try { - $alt = (string) ($this->f3->get('POST.alt') ?? ''); - $this->f3->scrub($alt); - (new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), trim($alt)); + $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()); diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php index db9aa42..47f5381 100644 --- a/app/Controllers/PostController.php +++ b/app/Controllers/PostController.php @@ -2,13 +2,24 @@ declare(strict_types=1); -class PostController extends BaseController +class PostController extends AdminController { + private const PER_PAGE = 12; private const MEDIA_PICKER_LIMIT = 60; - public function beforeRoute(): void + public function index(): void { - $this->requireAuth(); + $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); + $media = new Media(); + $result = (new Post())->paginateList($page, self::PER_PAGE, $media); + + $this->render('admin/dashboard.html', [ + 'pageTitle' => 'Tableau de bord', + 'posts' => $result['posts'], + 'pagination' => $result, + 'paginationAlias' => 'dashboard', + 'adminMode' => true, + ]); } public function create(): void @@ -109,17 +120,9 @@ class PostController extends BaseController private function postInput(): array { - $title = (string) ($this->f3->get('POST.title') ?? ''); - $excerpt = (string) ($this->f3->get('POST.excerpt') ?? ''); - - // scrub() supprime les tags HTML/PHP — défense en profondeur - // pour les champs rendus en texte brut dans les templates. - $this->f3->scrub($title); - $this->f3->scrub($excerpt); - return [ - 'title' => trim($title), - 'excerpt' => trim($excerpt), + '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/Helpers/App.php b/app/Helpers/App.php index d737675..80417de 100644 --- a/app/Helpers/App.php +++ b/app/Helpers/App.php @@ -2,13 +2,6 @@ declare(strict_types=1); -// ── Core ──────────────────────────────────────────────────────────── - -function app_root(): string -{ - return dirname(__DIR__, 2); -} - function app_timezone(): string { $timezone = trim((string) Base::instance()->get('app.timezone')); @@ -25,8 +18,6 @@ function app_is_prod(): bool return Base::instance()->get('app.env') === 'prod'; } -// ── Fichiers et chemins ───────────────────────────────────────────── - function app_ensure_dir(string $path): void { if (!is_dir($path)) { @@ -34,28 +25,6 @@ function app_ensure_dir(string $path): void } } -function app_db_path(): string -{ - return app_root() . '/db/app.sqlite'; -} - -function app_logs_dir(): string -{ - return app_root() . '/logs'; -} - -function app_public_media_dir(): string -{ - return app_root() . '/public/uploads/media'; -} - -function app_media_url(string $fileName): string -{ - return rtrim((string) Base::instance()->get('BASE'), '/') . '/uploads/media/' . rawurlencode($fileName); -} - -// ── Texte ─────────────────────────────────────────────────────────── - function app_unique_slug(string $value, callable $exists): string { $base = Web::instance()->slug(trim($value)); @@ -111,3 +80,131 @@ function app_format_datetime_fr(string $value): string 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/Models/Media.php b/app/Models/Media.php index 3dcdd2a..49218a7 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -100,7 +100,8 @@ class Media extends DB\SQL\Mapper // F3 Image : load() utilise imagecreatefromstring + imagesavealpha. $img = new \Image(); - if (!$img->load(file_get_contents($srcPath))) { + $f3 = Base::instance(); + if (!$img->load($f3->read($srcPath))) { throw new RuntimeException('Fichier image invalide ou format source non supporté.'); } @@ -110,11 +111,11 @@ class Media extends DB\SQL\Mapper // Nom aléatoire : empêche le path traversal et la devinabilité des URLs. $fileName = bin2hex(random_bytes(16)) . '.' . $extension; - $target = app_public_media_dir() . '/' . $fileName; + $target = rtrim((string) $f3->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName; // dump() appelle image{format}($data, NULL, $quality). $binary = $isJpeg ? $img->dump('jpeg', 85) : $img->dump('png', 6); - if ($binary === '' || file_put_contents($target, $binary) === false) { + if ($binary === '' || $f3->write($target, $binary) === false) { throw new RuntimeException('Impossible d\'enregistrer cette image.'); } @@ -166,7 +167,7 @@ class Media extends DB\SQL\Mapper throw new RuntimeException('Image introuvable.'); } - $path = app_public_media_dir() . '/' . $this->file_name; + $path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name; $this->db->begin(); try { @@ -224,6 +225,15 @@ class Media extends DB\SQL\Mapper return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1)); } + private function mediaUrl(string $fileName): string + { + $f3 = Base::instance(); + $base = rtrim((string) $f3->get('BASE'), '/'); + $prefix = '/' . trim((string) $f3->get('paths.media_base'), '/'); + + return $base . $prefix . '/' . rawurlencode($fileName); + } + private function decorate(array $row): array { $alt = (string) $row['alt']; @@ -235,7 +245,7 @@ class Media extends DB\SQL\Mapper 'width' => (int) $row['width'], 'height' => (int) $row['height'], 'created_at' => (string) $row['created_at'], - 'url' => app_media_url((string) $row['file_name']), + 'url' => $this->mediaUrl((string) $row['file_name']), 'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')', ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 2afe0f8..1205cad 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -47,9 +47,7 @@ class User extends DB\SQL\Mapper public function create(string $username, string $password): int { - $f3 = Base::instance(); - $f3->scrub($username); - $username = trim($username); + $username = Base::instance()->clean($username); if ($username === '' || $password === '') { throw new RuntimeException('Nom d’utilisateur et mot de passe obligatoires.'); diff --git a/app/Services/MarkdownService.php b/app/Services/MarkdownService.php index 5932a05..c9bc6a4 100644 --- a/app/Services/MarkdownService.php +++ b/app/Services/MarkdownService.php @@ -28,7 +28,9 @@ class MarkdownService extends Prefab // Résout les images media:filename et supprime les images externes. private static function resolveImages(string $html, Media $media): string { - return preg_replace_callback('/]*>/i', function (array $m) use ($media): string { + $f3 = Base::instance(); + + return preg_replace_callback('/]*>/i', function (array $m) use ($f3, $media): string { if (!preg_match('/src="([^"]*)"/', $m[0], $s) || !str_starts_with($s[1], 'media:')) { return ''; } @@ -50,12 +52,21 @@ class MarkdownService extends Prefab $alt = $a[1]; } if ($alt === '') { - $alt = Base::instance()->encode($item['alt']); + $alt = $f3->encode($item['alt']); } - $url = Base::instance()->encode($item['url']); + $url = $f3->encode($item['url']); + $attrs = 'src="' . $url . '" alt="' . $alt . '"'; - return '' . $alt . ''; + // width/height préviennent le layout shift au chargement. + if ((int) $item['width'] > 0) { + $attrs .= ' width="' . (int) $item['width'] . '"'; + } + if ((int) $item['height'] > 0) { + $attrs .= ' height="' . (int) $item['height'] . '"'; + } + + return ''; }, $html) ?? $html; } diff --git a/app/Views/admin/media.html b/app/Views/admin/media.html index 4312428..1569f12 100644 --- a/app/Views/admin/media.html +++ b/app/Views/admin/media.html @@ -11,7 +11,7 @@
- +