commit ced7dbfbf7c9fd90f6c75ad2abaf1a341de14d53 Author: julien Date: Fri Mar 27 14:43:08 2026 +0100 First commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd08f97 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.gitignore +.dockerignore +Dockerfile +README.md +compose.yaml +config.local.ini +config.local.ini.example +vendor +db/* +!db/.gitkeep +logs/* +!logs/.gitkeep +tmp/* +!tmp/.gitkeep +public/uploads/media/* +!public/uploads/media/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f5fd01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +/db/* +!/db/.gitkeep +/logs/* +!/logs/.gitkeep +/vendor/ +/tmp/* +!/tmp/.gitkeep +!/tmp/cache/ +!/tmp/uploads/ +/tmp/cache/* +!/tmp/cache/.gitkeep +/tmp/uploads/* +!/tmp/uploads/.gitkeep +/public/uploads/media/* +!/public/uploads/media/.gitkeep +/config.local.ini diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..362b7f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1.7 + +FROM composer:2 AS vendor +WORKDIR /app +COPY composer.json composer.lock ./ +RUN --mount=type=cache,target=/tmp/composer-cache \ + COMPOSER_CACHE_DIR=/tmp/composer-cache \ + composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader + +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 \ + libxml2-dev \ + libonig-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ + && docker-php-ext-install -j"$(nproc)" pdo_sqlite dom gd mbstring opcache \ + && printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \ + && a2enconf servername \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /var/www/html + +COPY --from=vendor /app/vendor /var/www/html/vendor +COPY --chown=www-data:www-data . /var/www/html +COPY docker/apache-vhost.conf /etc/apache2/sites-available/000-default.conf +COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint +COPY docker/php-prod.ini /usr/local/etc/php/conf.d/zz-prod.ini + +RUN chmod +x /usr/local/bin/app-entrypoint + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD php -r '$fp=@fsockopen("127.0.0.1", 80); if (!$fp) { exit(1); } fclose($fp);' + +ENTRYPOINT ["app-entrypoint"] +CMD ["apache2-foreground"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3ddb0b --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# F3 Simple Blog + +Blog simple avec Fat-Free Framework. + +## Structure + +```text +project/ +├── config.local.ini # Surcharges locales (gitignored) +├── app/ +│ ├── config.ini # Routes et variables F3 +│ ├── bootstrap.php # Initialisation (DB, session, cache, erreurs) +│ ├── Controllers/ +│ ├── Helpers/ # Fonctions utilitaires (App.php, Error.php) +│ ├── Models/ # DB\SQL\Mapper (Post, Media, User) +│ ├── Services/ # MarkdownService +│ └── Views/ +├── db/ +│ └── app.sqlite +├── logs/ +│ ├── app.log +│ └── php-error.log +├── public/ +│ ├── assets/ # Sources CSS/JS (servis minifiés via /min/@file) +│ └── uploads/ +│ └── media/ # Images converties en PNG (sans perte) +└── tmp/ + ├── cache/ # Cache F3 (pages publiques + assets minifiés) + └── uploads/ # Transit Web::receive() — vidé après chaque upload +``` + +## Philosophie des dossiers runtime + +Le projet garde la même logique en local et dans Docker : + +- `tmp/` = runtime temporaire, recréable +- `db/` = base SQLite persistante +- `logs/` = logs persistants +- `public/uploads/media/` = médias publiés et persistants + +Autrement dit, `tmp/` peut être vidé sans perte métier. Les données à sauvegarder restent hors de `tmp/`. + +## Fonctionnalités F3 utilisées + +- **Routage nommé** — `config.ini [routes]`, `$f3->alias()` +- **Cache HTTP + serveur** — `$f3->expire()` sur les routes publiques, `Cache::reset('.url')` à la mutation +- **Assets minifiés** — `Web::minify()` via `AssetController` (`GET /min/@file`) +- **Upload** — `Web::receive()` avec callback de validation taille +- **Images** — `Image` (chargement, conversion PNG, dimensions), `Base::write()` pour l'écriture +- **Markdown** — `Markdown::instance()->convert()` +- **Slugs** — `Web::instance()->slug()` +- **Session** — `$f3->set('JAR', …)`, token CSRF dans `SESSION.csrf_token` +- **Logging** — `Log` de F3 avec fallback `file_put_contents` +- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()` + +## Prérequis + +### Développement local + +- PHP 8.3+ +- Composer +- Extensions PHP : `pdo_sqlite`, `dom`, `gd`, `mbstring` + +### 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 +``` + +Le fichier `config.local.ini` sert uniquement aux surcharges d'environnement. Les chemins runtime restent les mêmes partout : + +- `tmp/cache/` pour le cache F3 et les assets minifiés +- `tmp/uploads/` pour les fichiers temporaires d'upload + +## Développement local + +```bash +composer install +cp config.local.ini.example config.local.ini +php scripts/install.php +php -S 127.0.0.1:8080 -t public +``` + +Ouvre ensuite `http://127.0.0.1:8080`. + +Créer un compte admin : + +```bash +php scripts/create-admin.php admin +# mot de passe : 10 caractères minimum +``` + +## Déploiement avec Docker + +```bash +cp config.local.ini.example config.local.ini +# édite config.local.ini (app.env=prod, app.timezone, etc.) +docker compose up -d --build +``` + +Docker monte les mêmes dossiers runtime que le développement local. Seuls les répertoires persistants restent séparés de `tmp/`. + +Si `config.local.ini` n'existe pas, le conteneur démarre avec les valeurs par défaut de `app/config.ini`. + +Le service écoute sur `http://127.0.0.1:8888`. + +Créer un compte admin : + +```bash +docker compose exec app php scripts/create-admin.php admin +# mot de passe : 10 caractères minimum +``` + +## 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 +} +``` + +## Données à sauvegarder + +- `db/` — base SQLite +- `public/uploads/media/` — images +- `logs/` — optionnel +- `tmp/` — non persistant, recréable + +## Mise à jour + +```bash +docker compose up -d --build +``` + +## Logs + +- Applicatifs : `logs/app.log` +- PHP : `logs/php-error.log` +- Apache (conteneur) : `docker compose logs -f app` diff --git a/app/Controllers/AssetController.php b/app/Controllers/AssetController.php new file mode 100644 index 0000000..f410b79 --- /dev/null +++ b/app/Controllers/AssetController.php @@ -0,0 +1,30 @@ + 'text/css', + 'app.js' => 'application/javascript', + ]; + + public function serve(): void + { + $file = basename((string) $this->f3->get('PARAMS.file')); + + if (!array_key_exists($file, self::ALLOWED)) { + $this->f3->error(404); + return; + } + + $this->f3->expire(86400); // 24 h côté navigateur + + 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 new file mode 100644 index 0000000..1ed2ca4 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,47 @@ +currentUser() !== null) { + $this->f3->reroute($this->f3->alias('dashboard')); + return; + } + + $this->f3->expire(0); + $this->render('auth/login.html', ['pageTitle' => 'Connexion']); + } + + public function login(): void + { + $this->verifyCsrf(); + + $username = trim((string) ($this->f3->get('POST.username') ?? '')); + $password = (string) ($this->f3->get('POST.password') ?? ''); + + $user = (new User($this->db))->findByUsername($username); + if ($user === null || !password_verify($password, $user['password_hash'])) { + usleep(1_500_000); // 1,5 s — ralentit le brute-force + $this->flash('error', 'Identifiants invalides.'); + $this->f3->reroute($this->f3->alias('login')); + return; + } + + session_regenerate_id(true); + $this->f3->set('SESSION.user_id', $user['id']); + $this->flash('success', 'Connexion réussie.'); + $this->f3->reroute($this->f3->alias('dashboard')); + } + + public function logout(): void + { + $this->verifyCsrf(); + $this->f3->clear('SESSION.user_id'); + session_regenerate_id(true); + $this->flash('success', 'Déconnexion effectuée.'); + $this->f3->reroute($this->f3->alias('home')); + } +} diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php new file mode 100644 index 0000000..811c758 --- /dev/null +++ b/app/Controllers/BaseController.php @@ -0,0 +1,79 @@ +f3 = Base::instance(); + $this->db = $this->f3->get('DB'); + } + + protected function render(string $view, array $data = []): void + { + $this->f3->mset($data + [ + 'view' => $view, + 'currentUser' => $this->currentUser(), + 'flash' => $this->pullFlash(), + 'csrfToken' => $this->csrfToken(), + ]); + + echo Template::instance()->render('layout.html'); + } + + protected function currentUser(): ?array + { + $userId = (int) ($this->f3->get('SESSION.user_id') ?? 0); + return $userId > 0 ? (new User($this->db))->findById($userId) : null; + } + + protected function requireAuth(): void + { + if ($this->currentUser() !== null) { + return; + } + + $this->flash('error', 'Connecte-toi pour continuer.'); + $this->f3->reroute($this->f3->alias('login')); + } + + protected function csrfToken(): string + { + // Génère un token CSRF et le stocke en session au premier appel. + $token = (string) ($this->f3->get('SESSION.csrf_token') ?? ''); + if ($token === '') { + $token = bin2hex(random_bytes(32)); + $this->f3->set('SESSION.csrf_token', $token); + } + + return $token; + } + + protected function verifyCsrf(): void + { + $submitted = (string) ($this->f3->get('POST.csrf_token') ?? ''); + $expected = (string) ($this->f3->get('SESSION.csrf_token') ?? ''); + + if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) { + return; + } + + $this->f3->error(400, 'Jeton CSRF invalide.'); + } + + protected function flash(string $type, string $message): void + { + $this->f3->set('SESSION.flash', ['type' => $type, 'message' => $message]); + } + + private function pullFlash(): ?array + { + $flash = $this->f3->get('SESSION.flash'); + $this->f3->clear('SESSION.flash'); + return is_array($flash) ? $flash : null; + } +} diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php new file mode 100644 index 0000000..beefc39 --- /dev/null +++ b/app/Controllers/DashboardController.php @@ -0,0 +1,22 @@ +requireAuth(); + $this->f3->expire(0); + + $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); + $result = (new Post($this->db))->paginateList($page, 24); + + $this->render('admin/dashboard.html', [ + 'pageTitle' => 'Tableau de bord', + 'posts' => $result['posts'], + 'pagination' => $result, + 'paginationBase' => $this->f3->alias('dashboard'), + ]); + } +} diff --git a/app/Controllers/MediaController.php b/app/Controllers/MediaController.php new file mode 100644 index 0000000..e8a0fdd --- /dev/null +++ b/app/Controllers/MediaController.php @@ -0,0 +1,84 @@ +requireAuth(); + $this->f3->expire(0); + + $this->render('admin/media.html', [ + 'pageTitle' => 'Médiathèque', + 'items' => (new Media($this->db))->all(), + ]); + } + + public function upload(): void + { + $this->requireAuth(); + $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( + fn(array $file): bool => $file['size'] <= self::UPLOAD_MAX_BYTES, + overwrite: false, + slug: true + ); + + // UPLOADS étant absolu (bootstrap.php), les chemins retournés le sont aussi. + $accepted = array_keys(array_filter($received)); + + if ($accepted === []) { + 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($this->db))->upload($destPath, $originalName); + } + + $this->flash('success', 'Image ajoutée.'); + } catch (RuntimeException $e) { + $this->flash('error', $e->getMessage()); + } + + $this->f3->reroute($this->f3->alias('media_index')); + } + + public function updateAlt(): void + { + $this->requireAuth(); + $this->verifyCsrf(); + + try { + $alt = trim((string) ($this->f3->get('POST.alt') ?? '')); + (new Media($this->db))->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($this->f3->alias('media_index')); + } + + public function delete(): void + { + $this->requireAuth(); + $this->verifyCsrf(); + + try { + (new Media($this->db))->delete((int) $this->f3->get('PARAMS.id')); + $this->flash('success', 'Image supprimée.'); + } catch (RuntimeException $e) { + $this->flash('error', $e->getMessage()); + } + + $this->f3->reroute($this->f3->alias('media_index')); + } +} diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php new file mode 100644 index 0000000..63390fc --- /dev/null +++ b/app/Controllers/PostController.php @@ -0,0 +1,110 @@ +requireAuth(); + $this->f3->expire(0); + $this->renderForm('Nouvel article', $this->f3->alias('post_store'), Post::emptyForm()); + } + + public function store(): void + { + $this->requireAuth(); + $this->verifyCsrf(); + + $input = $this->postInput(); + + try { + (new Post($this->db))->create($input); + Cache::instance()->reset('.url'); // invalide le cache des pages publiques + $this->flash('success', 'Article créé.'); + $this->f3->reroute($this->f3->alias('dashboard')); + } catch (RuntimeException $e) { + $this->f3->expire(0); + $this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage()); + } + } + + public function edit(): void + { + $this->requireAuth(); + $this->f3->expire(0); + + $post = (new Post($this->db))->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->requireAuth(); + $this->verifyCsrf(); + + $id = (int) $this->f3->get('PARAMS.id'); + $input = $this->postInput() + ['id' => $id]; + + try { + $updated = (new Post($this->db))->updatePost($id, $input); + if (!$updated) { + $this->f3->error(404, 'Article introuvable.'); + return; + } + + Cache::instance()->reset('.url'); // invalide le cache des pages publiques + $this->flash('success', 'Article mis à jour.'); + $this->f3->reroute($this->f3->alias('dashboard')); + } catch (RuntimeException $e) { + $this->f3->expire(0); + $this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage()); + } + } + + public function delete(): void + { + $this->requireAuth(); + $this->verifyCsrf(); + (new Post($this->db))->delete((int) $this->f3->get('PARAMS.id')); + Cache::instance()->reset('.url'); // invalide le cache des pages publiques + $this->flash('success', 'Article supprimé.'); + $this->f3->reroute($this->f3->alias('dashboard')); + } + + private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null): void + { + $coverPreview = null; + if (!empty($post['cover_media_id'])) { + $coverPreview = (new Media($this->db))->findById((int) $post['cover_media_id']); + } + + $flash = $error !== null ? ['type' => 'error', 'message' => $error] : null; + + $this->render('admin/post_form.html', [ + 'pageTitle' => $pageTitle, + 'formAction' => $formAction, + 'post' => $post, + 'coverPreview' => $coverPreview, + 'mediaItems' => (new Media($this->db))->all(), + 'titleMax' => Post::TITLE_MAX_LENGTH, + 'excerptMax' => Post::EXCERPT_MAX_LENGTH, + 'flash' => $flash, + ]); + } + + private function postInput(): array + { + return [ + 'title' => trim((string) ($this->f3->get('POST.title') ?? '')), + 'excerpt' => trim((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 new file mode 100644 index 0000000..c8548e5 --- /dev/null +++ b/app/Controllers/SiteController.php @@ -0,0 +1,37 @@ +f3->expire(300); // 5 min — page publique, contenu peu volatile + + $page = max(1, (int) ($this->f3->get('GET.page') ?? 1)); + $result = (new Post($this->db))->paginateList($page); + + $this->render('site/home.html', [ + 'pageTitle' => 'Accueil', + 'posts' => $result['posts'], + 'pagination' => $result, + 'paginationBase' => $this->f3->alias('home'), + ]); + } + + public function show(): void + { + $this->f3->expire(3600); // 1 h — les articles bougent rarement + + $post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug')); + if ($post === null) { + $this->f3->error(404, 'Article introuvable.'); + return; + } + + $this->render('site/post.html', [ + 'pageTitle' => $post['title'], + 'post' => $post, + ]); + } +} diff --git a/app/Helpers/App.php b/app/Helpers/App.php new file mode 100644 index 0000000..f237d48 --- /dev/null +++ b/app/Helpers/App.php @@ -0,0 +1,132 @@ +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'; +} + +// ── Fichiers et chemins ───────────────────────────────────────────── + +function app_ensure_dir(string $path): void +{ + if (!is_dir($path)) { + mkdir($path, 0775, true); + } +} + +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_slugify(string $value): string +{ + $slug = Web::instance()->slug(trim($value)); + return $slug !== '' ? $slug : 'article'; +} + +function app_unique_slug(string $value, callable $exists): string +{ + $base = app_slugify($value); + 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())); + + if (class_exists('IntlDateFormatter')) { + $formatter ??= new IntlDateFormatter( + 'fr_FR', + IntlDateFormatter::LONG, + IntlDateFormatter::SHORT, + date_default_timezone_get(), + IntlDateFormatter::GREGORIAN, + "d MMMM yyyy 'à' HH:mm" + ); + + $formatted = $formatter->format($date); + if (is_string($formatted) && $formatted !== '') { + return $formatted; + } + } + + $months = [ + 1 => 'janvier', 2 => 'février', 3 => 'mars', 4 => 'avril', + 5 => 'mai', 6 => 'juin', 7 => 'juillet', 8 => 'août', + 9 => 'septembre', 10 => 'octobre', 11 => 'novembre', 12 => 'décembre', + ]; + + return sprintf( + '%d %s %d à %s', + (int) $date->format('j'), + $months[(int) $date->format('n')] ?? $date->format('F'), + (int) $date->format('Y'), + $date->format('H:i') + ); + } catch (Throwable) { + return $value; + } +} diff --git a/app/Helpers/Error.php b/app/Helpers/Error.php new file mode 100644 index 0000000..c06aa63 --- /dev/null +++ b/app/Helpers/Error.php @@ -0,0 +1,162 @@ + ['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_bootstrap_logging(): void +{ + $dir = rtrim((string) Base::instance()->get('LOGS'), '/\\') . DIRECTORY_SEPARATOR; + app_ensure_dir($dir); + ini_set('log_errors', '1'); + ini_set('error_log', $dir . 'php-error.log'); + ini_set('display_errors', app_is_prod() ? '0' : '1'); + error_reporting(E_ALL); +} + +function app_request_summary(): string +{ + $f3 = Base::instance(); + return sprintf( + 'request=%s %s ip=%s', + (string) ($f3->get('VERB') ?? 'CLI'), + (string) ($f3->get('URI') ?? '/'), + (string) ($f3->get('IP') ?? '0.0.0.0') + ); +} + +function app_write_log(string $fileName, string $line): void +{ + (new Log($fileName))->write($line); +} + +function app_log_error(int $code, string $status, string $text, ?Throwable $exception = null): void +{ + if ($code === 404) { + return; + } + + $level = $code >= 500 ? 'error' : ($code >= 400 ? 'warning' : 'info'); + $parts = [ + sprintf('level=%s code=%d status="%s"', $level, $code, $status), + app_request_summary(), + ]; + + if ($text !== '') { + $parts[] = 'message="' . str_replace(["\n", '"'], ['\\n', '\\"'], $text) . '"'; + } + + if ($exception !== null) { + $parts[] = sprintf('exception="%s" file="%s:%d"', $exception::class, $exception->getFile(), $exception->getLine()); + } + + app_write_log('app.log', implode(' | ', $parts)); +} + +function app_render_error_json(int $code): void +{ + $f3 = Base::instance(); + $meta = app_error_meta($code); + + while (ob_get_level() > 0) { + ob_end_clean(); + } + + $f3->status($code); + $f3->expire(0); + + header('Content-Type: application/json; charset=UTF-8'); + echo json_encode( + ['error' => ['code' => $code, 'title' => $meta['title'], 'message' => $meta['message']]], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); +} + +function app_render_error_fallback(int $code): void +{ + $f3 = Base::instance(); + $meta = app_error_meta($code); + $base = rtrim((string) $f3->get('BASE'), '/'); + + while (ob_get_level() > 0) { + ob_end_clean(); + } + + $f3->status($code); + $f3->expire(0); + + if (!headers_sent()) { + 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 '' . $title . '

' . $title . '

' . $message . '

Retour à l\'accueil

'; +} + +function app_bootstrap_errors(Base $f3): void +{ + if (app_is_prod()) { + register_shutdown_function(function (): void { + $error = error_get_last(); + if ($error === null) { + return; + } + + $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; + if (!in_array($error['type'] ?? 0, $fatalTypes, true)) { + return; + } + + app_render_error_fallback(500); + }); + } + + $f3->set('ONERROR', function (Base $f3): void { + $code = (int) ($f3->get('ERROR.code') ?? 500); + $status = (string) ($f3->get('ERROR.status') ?? 'Internal Server Error'); + $text = (string) ($f3->get('ERROR.text') ?? ''); + + if (!app_is_prod() && (int) $f3->get('DEBUG') > 0) { + $f3->status($code > 0 ? $code : 500); + echo $text; + return; + } + + $code = $code > 0 ? $code : 500; + app_log_error($code, $status, $text); + + if ($f3->get('AJAX')) { + app_render_error_json($code); + return; + } + + $f3->expire(0); + $f3->status($code); + + $meta = app_error_meta($code); + $f3->mset([ + 'errorCode' => $code, + 'errorTitle' => $meta['title'], + 'errorMessage' => $meta['message'], + ]); + + try { + echo Template::instance()->render('errors/error.html'); + } catch (Throwable $exception) { + app_log_error(500, 'Internal Server Error', 'Error template rendering failed.', $exception); + app_render_error_fallback($code); + } + }); +} diff --git a/app/Models/Media.php b/app/Models/Media.php new file mode 100644 index 0000000..2196e40 --- /dev/null +++ b/app/Models/Media.php @@ -0,0 +1,155 @@ +exec('CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_name TEXT NOT NULL UNIQUE, + alt TEXT NOT NULL DEFAULT \'\', + width INTEGER NOT NULL, + height INTEGER NOT NULL, + created_at TEXT NOT NULL + )'); + $db->exec('CREATE INDEX IF NOT EXISTS idx_media_created_at ON media(created_at DESC)'); + } + + public function all(): array + { + return array_map( + fn (self $m): array => $this->decorate($m->cast()), + $this->find(null, ['order' => 'created_at DESC, id DESC']) ?: [] + ); + } + + public function findById(int $id): ?array + { + if ($id <= 0) { + return null; + } + + $this->load(['id = ?', $id]); + return $this->dry() ? null : $this->decorate($this->cast()); + } + + public function findByFileName(string $fileName): ?array + { + $this->load(['file_name = ?', $fileName]); + return $this->dry() ? null : $this->decorate($this->cast()); + } + + // Reçoit le chemin absolu déposé par Web::receive() et le nom d'origine + // pour dériver un texte alternatif lisible. + public function upload(string $srcPath, string $originalName = ''): int + { + // Image::dump() gère le chargement, la transparence et la compression. + // $path='' indique à F3 que le chemin est absolu. + try { + $img = new Image($srcPath, false, ''); + } catch (Throwable) { + throw new RuntimeException('Fichier image invalide ou format non supporté (JPG, PNG, WebP).'); + } + + $data = $img->dump('png', 9); // sans perte, compression maximale + + // Supprimer le fichier intermédiaire déposé par Web::receive(). + @unlink($srcPath); + + $fileName = bin2hex(random_bytes(16)) . '.png'; + $target = app_public_media_dir() . '/' . $fileName; + + if (!Base::instance()->write($target, $data)) { + throw new RuntimeException('Impossible d\'enregistrer cette image.'); + } + + $this->reset(); + $this->file_name = $fileName; + $this->alt = $originalName !== '' ? self::altFromFilename($originalName) : ''; + $this->width = $img->width(); + $this->height = $img->height(); + $this->created_at = app_now(); + $this->save(); + + return (int) $this->get('id'); + } + + public function updateAlt(int $id, string $alt): void + { + $this->load(['id = ?', $id]); + if ($this->dry()) { + throw new RuntimeException('Image introuvable.'); + } + + $this->alt = trim($alt); + $this->save(); + } + + public function delete(int $id): void + { + $item = $this->findById($id); + if ($item === null) { + throw new RuntimeException('Image introuvable.'); + } + + if ($this->isUsed($item)) { + throw new RuntimeException('Cette image est encore utilisée par un article.'); + } + + $path = app_public_media_dir() . '/' . $item['file_name']; + + $this->db->begin(); + try { + $this->erase(); + if (is_file($path) && !unlink($path)) { + throw new RuntimeException('Impossible de supprimer le fichier image.'); + } + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollback(); + throw $e instanceof RuntimeException ? $e : new RuntimeException('Suppression impossible.'); + } + } + + // Dérive un texte alternatif lisible depuis le nom de fichier d'origine. + private static function altFromFilename(string $filename): string + { + $name = pathinfo($filename, PATHINFO_FILENAME); + $name = trim((string) preg_replace('/[-_]+/', ' ', $name)); + // mb_ucfirst() n'existe qu'en PHP 8.4 — on l'émule. + return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1)); + } + + // Une seule requête SQL pour les deux cas d'utilisation (couverture et body). + private function isUsed(array $item): bool + { + return $this->db->exec( + 'SELECT 1 FROM posts WHERE cover_media_id = ? OR body_markdown LIKE ? LIMIT 1', + [$item['id'], '%media:' . $item['file_name'] . '%'] + ) !== []; + } + + private function decorate(array $row): array + { + $alt = (string) $row['alt']; + + return [ + 'id' => (int) $row['id'], + 'file_name' => (string) $row['file_name'], + 'alt' => $alt, + 'width' => (int) $row['width'], + 'height' => (int) $row['height'], + 'created_at' => (string) $row['created_at'], + 'created_at_label' => app_format_datetime_fr((string) $row['created_at']), + 'url' => app_media_url((string) $row['file_name']), + 'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')', + ]; + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php new file mode 100644 index 0000000..7cfe01b --- /dev/null +++ b/app/Models/Post.php @@ -0,0 +1,223 @@ +exec('CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + excerpt TEXT NOT NULL, + body_markdown TEXT NOT NULL, + body_html TEXT NOT NULL, + cover_media_id INTEGER DEFAULT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (cover_media_id) REFERENCES media(id) ON DELETE SET NULL + )'); + $db->exec('CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC)'); + } + + public static function emptyForm(): array + { + return [ + 'title' => '', + 'excerpt' => '', + 'cover_media_id' => '', + 'body_markdown' => '', + ]; + } + + public function paginateList(int $page = 1, int $perPage = 12): array + { + $result = $this->paginate( + max(0, $page - 1), + $perPage, + null, + ['order' => 'created_at DESC, id DESC'] + ); + + $posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']); + $covers = $this->loadCovers($posts); + + foreach ($posts as &$post) { + $cover = $covers[$post['cover_media_id']] ?? null; + $post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : ''; + } + + return [ + 'posts' => $posts, + 'page' => max(1, min($page, $result['count'] ?: 1)), + 'pages' => $result['count'] ?: 1, + ]; + } + + public function findBySlug(string $slug): ?array + { + $this->load(['slug = ?', $slug]); + if ($this->dry()) { + return null; + } + + $post = $this->summaryRow($this->cast()); + $covers = $this->loadCovers([$post]); + $cover = $covers[$post['cover_media_id']] ?? null; + $post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : ''; + $post['body_html'] = (string) $this->body_html; + + return $post; + } + + public function findForEdit(int $id): ?array + { + if ($id <= 0) { + return null; + } + + $this->load(['id = ?', $id]); + if ($this->dry()) { + return null; + } + + return [ + 'id' => (int) $this->get('id'), + 'title' => (string) $this->title, + 'excerpt' => (string) $this->excerpt, + 'body_markdown' => (string) $this->body_markdown, + 'cover_media_id' => $this->cover_media_id !== null ? (string) ((int) $this->cover_media_id) : '', + ]; + } + + public function create(array $input): int + { + $payload = $this->payload($input); + $slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->slugExists($candidate)); + $now = app_now(); + + $this->reset(); + $this->copyfrom($payload + [ + 'slug' => $slug, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $this->save(); + + return (int) $this->get('id'); + } + + public function updatePost(int $id, array $input): bool + { + $this->load(['id = ?', $id]); + if ($this->dry()) { + return false; + } + + $payload = $this->payload($input); + $this->copyfrom($payload + ['updated_at' => app_now()]); + $this->save(); + + return true; + } + + public function delete(int $id): void + { + $this->load(['id = ?', $id]); + if (!$this->dry()) { + $this->erase(); + } + } + + private function payload(array $input): array + { + $title = trim((string) ($input['title'] ?? '')); + $excerpt = trim((string) ($input['excerpt'] ?? '')); + $bodyMarkdown = trim((string) ($input['body_markdown'] ?? '')); + $coverMediaId = trim((string) ($input['cover_media_id'] ?? '')); + + if ($title === '') { + throw new RuntimeException('Ajoute un titre.'); + } + if (mb_strlen($title) > self::TITLE_MAX_LENGTH) { + throw new RuntimeException('Le titre est trop long.'); + } + if ($excerpt === '') { + throw new RuntimeException('Ajoute un extrait.'); + } + if (mb_strlen($excerpt) > self::EXCERPT_MAX_LENGTH) { + throw new RuntimeException("L'extrait est trop long."); + } + + $media = new Media($this->db); + + $coverId = null; + if ($coverMediaId !== '') { + $coverId = (int) $coverMediaId; + if ($media->findById($coverId) === null) { + throw new RuntimeException('Image de couverture introuvable.'); + } + } + + $bodyHtml = MarkdownService::compile($bodyMarkdown, $media); + + return [ + 'title' => $title, + 'excerpt' => $excerpt, + 'body_markdown' => $bodyMarkdown, + 'body_html' => $bodyHtml, + 'cover_media_id' => $coverId, + ]; + } + + private function slugExists(string $slug): bool + { + return $this->count(['slug = ?', $slug]) > 0; + } + + private function summaryRow(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'title' => (string) $row['title'], + 'slug' => (string) $row['slug'], + 'excerpt' => (string) $row['excerpt'], + 'cover_media_id' => (int) ($row['cover_media_id'] ?? 0), + 'created_at' => (string) $row['created_at'], + 'created_at_label' => app_format_datetime_fr((string) $row['created_at']), + 'updated_at' => (string) $row['updated_at'], + 'updated_at_label' => app_format_datetime_fr((string) $row['updated_at']), + 'has_updated_at' => (string) $row['updated_at'] !== (string) $row['created_at'], + ]; + } + + private function loadCovers(array $posts): array + { + $ids = array_filter(array_unique(array_column($posts, 'cover_media_id'))); + if ($ids === []) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $rows = $this->db->exec( + "SELECT id, file_name FROM media WHERE id IN ($placeholders)", + array_values($ids) + ); + + $map = []; + foreach ($rows as $row) { + $map[(int) $row['id']] = $row; + } + + return $map; + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..f5d58e4 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,67 @@ +exec('CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + )'); + } + + public function findById(int $id): ?array + { + if ($id <= 0) { + return null; + } + + $this->load(['id = ?', $id]); + if ($this->dry()) { + return null; + } + + $data = $this->cast(); + unset($data['password_hash']); + return $data; + } + + public function findByUsername(string $username): ?array + { + $this->load(['username = ?', $username]); + return $this->dry() ? null : $this->cast(); + } + + public function create(string $username, string $password): int + { + $username = trim($username); + if ($username === '' || $password === '') { + throw new RuntimeException('Nom d’utilisateur et mot de passe obligatoires.'); + } + + if (mb_strlen($password) < 10) { + throw new RuntimeException('Le mot de passe doit contenir au moins 10 caractères.'); + } + + if ($this->findByUsername($username) !== null) { + throw new RuntimeException('Cet utilisateur existe déjà.'); + } + + $this->reset(); + $this->username = $username; + $this->password_hash = password_hash($password, PASSWORD_DEFAULT); + $this->created_at = app_now(); + $this->save(); + + return (int) $this->get('id'); + } +} diff --git a/app/Services/MarkdownService.php b/app/Services/MarkdownService.php new file mode 100644 index 0000000..dde8927 --- /dev/null +++ b/app/Services/MarkdownService.php @@ -0,0 +1,192 @@ + ['href', 'title', 'rel', 'target'], + 'img' => ['src', 'alt', 'title', 'loading', 'decoding'], + ]; + + public static function compile(string $markdown, Media $media): string + { + $markdown = trim($markdown); + if ($markdown === '') { + throw new RuntimeException('Ajoute du contenu avant de publier.'); + } + + $markdown = self::normalizeMarkdown($markdown); + $html = Markdown::instance()->convert($markdown); + $html = self::sanitizeAndResolve($html, $media); + + if (trim(strip_tags($html)) === '' && !preg_match('/<(img|video|audio|figure)[\s>]/i', $html)) { + $fallback = nl2br(htmlspecialchars($markdown, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + $html = '

' . str_replace('
', '

', $fallback) . '

'; + } + + return $html; + } + + // Passe DOM unique : sanitise les balises/attributs et résout les références media:. + private static function sanitizeAndResolve(string $html, Media $media): string + { + $dom = new DOMDocument('1.0', 'UTF-8'); + libxml_use_internal_errors(true); + $dom->loadHTML('' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + + $body = $dom->getElementsByTagName('body')->item(0); + if (!$body instanceof DOMElement) { + return ''; + } + + self::processNode($body, $media); + + $out = ''; + foreach ($body->childNodes as $child) { + $out .= $dom->saveHTML($child); + } + + return trim($out); + } + + private static function processNode(DOMNode $parent, Media $media): void + { + for ($i = $parent->childNodes->length - 1; $i >= 0; $i--) { + $child = $parent->childNodes->item($i); + if ($child === null) { + continue; + } + + if ($child instanceof DOMComment) { + $parent->removeChild($child); + continue; + } + + if ($child instanceof DOMText) { + continue; + } + + if (!$child instanceof DOMElement) { + $parent->removeChild($child); + continue; + } + + if (!in_array($child->tagName, self::ALLOWED_TAGS, true)) { + self::unwrap($child); + continue; + } + + self::sanitizeAttributes($child, $media); + + // img may have been removed by sanitizeAttributes + if ($child->parentNode !== null) { + self::processNode($child, $media); + } + } + } + + private static function sanitizeAttributes(DOMElement $element, Media $media): void + { + $allowed = self::ALLOWED_ATTRS[$element->tagName] ?? []; + $toRemove = []; + foreach ($element->attributes as $attribute) { + if (!in_array($attribute->name, $allowed, true)) { + $toRemove[] = $attribute->name; + } + } + foreach ($toRemove as $name) { + $element->removeAttribute($name); + } + + if ($element->tagName === 'a') { + $href = trim($element->getAttribute('href')); + if ($href === '' || !preg_match('~^(https?:|mailto:|tel:|/)~i', $href)) { + $element->removeAttribute('href'); + } else { + $element->setAttribute('rel', 'noopener noreferrer'); + if (preg_match('~^https?://~i', $href)) { + $element->setAttribute('target', '_blank'); + } + } + } + + if ($element->tagName === 'img') { + $src = trim($element->getAttribute('src')); + if ($src === '' || !str_starts_with($src, 'media:')) { + $element->parentNode?->removeChild($element); + return; + } + + $fileName = substr($src, 6); + $item = $media->findByFileName($fileName); + if ($item === null) { + throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.'); + } + + $element->setAttribute('src', (string) $item['url']); + $element->setAttribute('loading', 'lazy'); + $element->setAttribute('decoding', 'async'); + } + } + + private static function normalizeMarkdown(string $markdown): string + { + $markdown = str_replace(["\r\n", "\r"], "\n", $markdown); + $lines = explode("\n", $markdown); + $normalized = []; + $inFence = false; + + foreach ($lines as $line) { + if (preg_match('/^\s*(```|~~~)/', $line) === 1) { + $inFence = !$inFence; + $normalized[] = $line; + continue; + } + + if ($inFence) { + $normalized[] = $line; + continue; + } + + $isBlank = trim($line) === ''; + $isListItem = preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $line) === 1; + $previous = $normalized[count($normalized) - 1] ?? null; + $previousIsBlank = $previous === null || trim($previous) === ''; + $previousIsListItem = $previous !== null && preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $previous) === 1; + + if ($isListItem && !$previousIsBlank && !$previousIsListItem) { + $normalized[] = ''; + } + + if (!$isBlank && !$isListItem && $previousIsListItem) { + $normalized[] = ''; + } + + $normalized[] = $line; + } + + return trim(implode("\n", $normalized)); + } + + private static function unwrap(DOMElement $element): void + { + $parent = $element->parentNode; + if ($parent === null) { + return; + } + + while ($element->firstChild !== null) { + $parent->insertBefore($element->firstChild, $element); + } + + $parent->removeChild($element); + } +} diff --git a/app/Views/admin/dashboard.html b/app/Views/admin/dashboard.html new file mode 100644 index 0000000..b0e4931 --- /dev/null +++ b/app/Views/admin/dashboard.html @@ -0,0 +1,27 @@ +
+ + + + +
+ + + +
+ +
+ +
+

Aucun article

+

Commence par créer un premier article.

+
+
+
+
diff --git a/app/Views/admin/media.html b/app/Views/admin/media.html new file mode 100644 index 0000000..96a7bef --- /dev/null +++ b/app/Views/admin/media.html @@ -0,0 +1,35 @@ +
+ + +
+ + + +
+ + + +
+ + + +
+
+ +
+

Aucune image

+

Ajoute ta première image.

+
+
+
+
diff --git a/app/Views/admin/post_form.html b/app/Views/admin/post_form.html new file mode 100644 index 0000000..ed57471 --- /dev/null +++ b/app/Views/admin/post_form.html @@ -0,0 +1,107 @@ +
+ + +
+
+ + + + + + + +
+
+
+

Image de couverture

+

Choisis une image si tu veux une couverture.

+
+
+ +
+ + + + + + +
Aucune image
+ +
+
+ +
+ + +
+
+
+ +
+
+
+

Contenu

+

Markdown simple, avec insertion d’image au curseur.

+
+
+ + + + +
+ + +
+ + +
+
diff --git a/app/Views/auth/login.html b/app/Views/auth/login.html new file mode 100644 index 0000000..d3943a5 --- /dev/null +++ b/app/Views/auth/login.html @@ -0,0 +1,21 @@ +
+ + +
+ + + + + + + +
+
diff --git a/app/Views/errors/error.html b/app/Views/errors/error.html new file mode 100644 index 0000000..390417c --- /dev/null +++ b/app/Views/errors/error.html @@ -0,0 +1,23 @@ + + + + + + {{ @errorTitle ?: 'Erreur' }} + + + + +
+
+
+

Erreur {{ @errorCode ?: 500 }}

+

{{ @errorTitle ?: 'Erreur' }}

+

{{ @errorMessage ?: 'Une erreur est survenue.' }}

+

Vérifie l’adresse ou reviens à l’accueil.

+

Retour à l’accueil

+
+
+
+ + diff --git a/app/Views/layout.html b/app/Views/layout.html new file mode 100644 index 0000000..b232eba --- /dev/null +++ b/app/Views/layout.html @@ -0,0 +1,24 @@ + + + + + + {{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }} + + + + + + + + +
+
+ +
{{ @flash.message }}
+
+ +
+
+ + diff --git a/app/Views/partials/media_card.html b/app/Views/partials/media_card.html new file mode 100644 index 0000000..cf121dd --- /dev/null +++ b/app/Views/partials/media_card.html @@ -0,0 +1,23 @@ +
+ {{ @item.alt }} +
+

{{ @item.width }} × {{ @item.height }}
{{ @item.created_at_label }}

+ +
+ + + +
+ +
+ +
+ + +
+
+
+
diff --git a/app/Views/partials/nav_items.html b/app/Views/partials/nav_items.html new file mode 100644 index 0000000..2cfccbf --- /dev/null +++ b/app/Views/partials/nav_items.html @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/app/Views/partials/pagination.html b/app/Views/partials/pagination.html new file mode 100644 index 0000000..dbf186b --- /dev/null +++ b/app/Views/partials/pagination.html @@ -0,0 +1,25 @@ + + + + + diff --git a/app/Views/partials/post_card.html b/app/Views/partials/post_card.html new file mode 100644 index 0000000..6d66b64 --- /dev/null +++ b/app/Views/partials/post_card.html @@ -0,0 +1,20 @@ +
+ + + {{ @post.title }} + +
Aucune image
+
+
+
+
+

{{ @post.title }}

+

+ Publié le + +
Mis à jour le
+
+

+

{{ @post.excerpt }}

+
+
diff --git a/app/Views/partials/post_card_admin.html b/app/Views/partials/post_card_admin.html new file mode 100644 index 0000000..a8544f1 --- /dev/null +++ b/app/Views/partials/post_card_admin.html @@ -0,0 +1,29 @@ +
+ + + {{ @post.title }} + +
Aucune image
+
+
+
+
+

{{ @post.title }}

+

+ Publié le + +
Mis à jour le
+
+

+

{{ @post.excerpt }}

+
+ Voir + Modifier +
+ + +
+
+
+
diff --git a/app/Views/partials/site_brand.html b/app/Views/partials/site_brand.html new file mode 100644 index 0000000..2ab5c8f --- /dev/null +++ b/app/Views/partials/site_brand.html @@ -0,0 +1 @@ +{{ @app.name }} diff --git a/app/Views/partials/site_navigation.html b/app/Views/partials/site_navigation.html new file mode 100644 index 0000000..f6f8936 --- /dev/null +++ b/app/Views/partials/site_navigation.html @@ -0,0 +1,44 @@ + + + + +
+ + +
+
+
+ +
+ + +
+ + +
+
diff --git a/app/Views/site/home.html b/app/Views/site/home.html new file mode 100644 index 0000000..a7e1f10 --- /dev/null +++ b/app/Views/site/home.html @@ -0,0 +1,22 @@ +
+ + + + +
+ + + +
+ +
+ +
+

Aucun article

+

Le premier article arrivera bientôt.

+
+
+
+
diff --git a/app/Views/site/post.html b/app/Views/site/post.html new file mode 100644 index 0000000..f88eb60 --- /dev/null +++ b/app/Views/site/post.html @@ -0,0 +1,24 @@ +
+
+

{{ @post.title }}

+

+ Publié le + +
Mis à jour le
+
+

+
+ + + + {{ @post.title }} + + +
Aucune image +
+
+
+ +
{{ @post.body_html | raw }}
+
\ No newline at end of file diff --git a/app/bootstrap.php b/app/bootstrap.php new file mode 100644 index 0000000..0358436 --- /dev/null +++ b/app/bootstrap.php @@ -0,0 +1,80 @@ +set('AUTOLOAD', app_root() . '/app/Controllers/;' . app_root() . '/app/Models/;' . app_root() . '/app/Services/'); +$f3->set('UI', app_root() . '/app/Views/'); +$f3->set('TEMP', app_root() . '/tmp/'); +$f3->set('LOGS', app_logs_dir() . '/'); + +$f3->config(app_root() . '/app/config.ini'); + +$localConfig = app_root() . '/config.local.ini'; +if (is_file($localConfig)) { + $f3->config($localConfig); +} + +$f3->set('TZ', app_timezone()); +$f3->set('DEBUG', app_is_prod() ? 0 : 3); + +app_ensure_dir((string) $f3->get('TEMP')); +app_ensure_dir((string) $f3->get('LOGS')); +app_ensure_dir(app_public_media_dir()); +// Web::receive() utilise UPLOADS directement — le résoudre en absolu. +$f3->set('UPLOADS', app_root() . '/' . ltrim((string) $f3->get('UPLOADS'), '/')); +app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/')); +app_bootstrap_logging(); + +// ── En-têtes de sécurité ──────────────────────────────────────────── + +if (PHP_SAPI !== 'cli') { + 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'"); + header('Referrer-Policy: same-origin'); + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: SAMEORIGIN'); + header('Cross-Origin-Opener-Policy: same-origin'); + header('Cross-Origin-Resource-Policy: same-origin'); + header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); +} + +// ── Base de données ───────────────────────────────────────────────── + +$dbPath = app_db_path(); +app_ensure_dir(dirname($dbPath)); + +$db = new DB\SQL( + 'sqlite:' . $dbPath, + null, + null, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_TIMEOUT => 5, + ] +); +$db->exec('PRAGMA foreign_keys = ON'); +$f3->set('DB', $db); + +// ── Session ───────────────────────────────────────────────────────── + +session_name((string) $f3->get('app.session_name')); +$f3->set('JAR', [ + 'expire' => 0, + 'path' => '/', + 'secure' => $f3->get('SCHEME') === 'https', + 'httponly' => true, + 'samesite' => 'Lax', +]); + +// ── Erreurs ───────────────────────────────────────────────────────── + +app_bootstrap_errors($f3); + +return $f3; diff --git a/app/config.ini b/app/config.ini new file mode 100644 index 0000000..07fc0da --- /dev/null +++ b/app/config.ini @@ -0,0 +1,32 @@ +[globals] +app.env=dev +app.timezone=UTC +app.session_name=f3-simple-blog + +UPLOADS=tmp/uploads/ +CACHE=folder + +app.name=F3 Simple Blog +app.tagline=Blog simple avec Fat-Free Framework. + +[routes] +GET @asset: /min/@file=AssetController->serve + +GET @home: /=SiteController->home +GET @post_show: /posts/@slug=SiteController->show + +GET @login: /login=AuthController->show +POST @login_submit: /login=AuthController->login +POST @logout: /logout=AuthController->logout + +GET @dashboard: /dashboard=DashboardController->index +GET @post_create: /dashboard/posts/create=PostController->create +POST @post_store: /dashboard/posts=PostController->store +GET @post_edit: /dashboard/posts/@id/edit=PostController->edit +POST @post_update: /dashboard/posts/@id/update=PostController->update +POST @post_delete: /dashboard/posts/@id/delete=PostController->delete + +GET @media_index: /dashboard/media=MediaController->index +POST @media_upload: /dashboard/media=MediaController->upload +POST @media_update_alt: /dashboard/media/@id/alt=MediaController->updateAlt +POST @media_delete: /dashboard/media/@id/delete=MediaController->delete diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..991080b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,14 @@ +services: + app: + build: . + image: f3-simple-blog:latest + init: true + restart: unless-stopped + ports: + - "127.0.0.1:8888:80" + volumes: + - ./config.local.ini:/var/www/html/config.local.ini:ro + - ./db:/var/www/html/db + - ./logs:/var/www/html/logs + - ./tmp:/var/www/html/tmp + - ./public/uploads/media:/var/www/html/public/uploads/media diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b375052 --- /dev/null +++ b/composer.json @@ -0,0 +1,9 @@ +{ + "name": "netig/f3-simple-blog", + "description": "Blog simple avec Fat-Free Framework.", + "type": "project", + "require": { + "php": "^8.3", + "bcosca/fatfree-core": "^3.9" + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..aaf43ff --- /dev/null +++ b/composer.lock @@ -0,0 +1,66 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "b223f9fed1c99d22eabd05b892ca4602", + "packages": [ + { + "name": "bcosca/fatfree-core", + "version": "3.9.2", + "source": { + "type": "git", + "url": "https://github.com/f3-factory/fatfree-core.git", + "reference": "3ba261541e529d20b32615fe5f7b5740ea0951a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/f3-factory/fatfree-core/zipball/3ba261541e529d20b32615fe5f7b5740ea0951a3", + "reference": "3ba261541e529d20b32615fe5f7b5740ea0951a3", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "autoload": { + "classmap": [ + "." + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0" + ], + "description": "A powerful yet easy-to-use PHP micro-framework designed to help you build dynamic and robust Web applications - fast!", + "homepage": "http://fatfreeframework.com/", + "support": { + "issues": "https://github.com/f3-factory/fatfree-core/issues", + "source": "https://github.com/f3-factory/fatfree-core/tree/3.9.2" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/ikkez", + "type": "buy_me_a_coffee" + }, + { + "url": "https://github.com/ikkez", + "type": "github" + } + ], + "time": "2025-12-02T00:44:50+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/config.local.ini.example b/config.local.ini.example new file mode 100644 index 0000000..8eec837 --- /dev/null +++ b/config.local.ini.example @@ -0,0 +1,13 @@ +; Copier ce fichier vers config.local.ini puis décommenter uniquement les valeurs à surcharger. +; Les défauts applicatifs restent dans app/config.ini. +; Les chemins runtime ne changent pas entre local et Docker : +; - tmp/cache pour le cache F3 et les assets minifiés +; - tmp/uploads pour les fichiers temporaires d'upload +; Les données persistantes restent hors de tmp : db/, logs/, public/uploads/media/ + +[globals] +; app.env=prod +; app.timezone=Europe/Paris +; app.session_name=f3-simple-blog +; app.name=F3 Simple Blog +; app.tagline=Blog simple avec Fat-Free Framework. diff --git a/db/.gitkeep b/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/apache-vhost.conf b/docker/apache-vhost.conf new file mode 100644 index 0000000..8a12852 --- /dev/null +++ b/docker/apache-vhost.conf @@ -0,0 +1,15 @@ + + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html/public + + + Options FollowSymLinks + AllowOverride None + Require all granted + DirectoryIndex index.php + FallbackResource /index.php + + + ErrorLog /proc/self/fd/2 + CustomLog /proc/self/fd/1 combined + diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..4b159a7 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +APP_ROOT="/var/www/html" +CONFIG="$APP_ROOT/config.local.ini" + +# Docker creates a directory when bind-mounting a file that doesn't exist on the host. +# Remove it so bootstrap.php falls back to defaults. +if [ -d "$CONFIG" ]; then + rmdir "$CONFIG" 2>/dev/null || true + echo "Warning: config.local.ini was mounted as a directory (file missing on host). Using defaults." +fi + +install -d -m 0775 -o www-data -g www-data \ + "$APP_ROOT/db" \ + "$APP_ROOT/logs" \ + "$APP_ROOT/public/uploads/media" \ + "$APP_ROOT/tmp" \ + "$APP_ROOT/tmp/cache" \ + "$APP_ROOT/tmp/uploads" + +# Bind mounts may keep host-side ownership/permissions. Normalize the writable +# application directories before boot so F3 can write its cache and SQLite files. +chown -R www-data:www-data \ + "$APP_ROOT/db" \ + "$APP_ROOT/logs" \ + "$APP_ROOT/public/uploads/media" \ + "$APP_ROOT/tmp" +chmod -R u+rwX,g+rwX \ + "$APP_ROOT/db" \ + "$APP_ROOT/logs" \ + "$APP_ROOT/public/uploads/media" \ + "$APP_ROOT/tmp" + +# Run installation as the web user so generated files keep consistent ownership. +su -s /bin/sh www-data -c "php $APP_ROOT/scripts/install.php" + +exec "$@" diff --git a/docker/php-prod.ini b/docker/php-prod.ini new file mode 100644 index 0000000..d1bb61a --- /dev/null +++ b/docker/php-prod.ini @@ -0,0 +1,12 @@ +expose_php = Off +log_errors = On +display_errors = Off +error_log = /var/www/html/logs/php-error.log +session.use_strict_mode = 1 + +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.validate_timestamps = 0 +opcache.memory_consumption = 128 +opcache.interned_strings_buffer = 16 +opcache.max_accelerated_files = 10000 diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/app.css b/public/assets/app.css new file mode 100644 index 0000000..d0766d4 --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1,1070 @@ +/* ========================================================= + Design tokens + ========================================================= */ + +:root { + --color-bg: #f6f8fb; + --color-surface: #ffffff; + --color-surface-muted: #eef3f8; + --color-border: #d9e2ec; + --color-text: #122033; + --color-text-soft: #5b6b7f; + --color-accent: #122033; + --color-accent-contrast: #ffffff; + --color-danger: #b42318; + --color-danger-soft: #fef3f2; + --color-success: #146c43; + --color-success-soft: #dcfce7; + + --shadow-sm: 0 4px 12px rgba(15, 23, 42, 0.05); + --shadow-md: 0 16px 40px rgba(15, 23, 42, 0.08); + + --radius-sm: 12px; + --radius-md: 16px; + --radius-lg: 22px; + --radius-pill: 999px; + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + + --container: 1120px; + --container-narrow: 40rem; + --control-height: 2.85rem; + --transition-fast: 160ms ease; + --focus-ring: 0 0 0 4px rgba(18, 32, 51, 0.1); +} + +/* ========================================================= + Base + ========================================================= */ + +* { + box-sizing: border-box; +} + +html { + font-size: 16px; +} + +body { + margin: 0; + min-width: 320px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.6; + color: var(--color-text); + background: linear-gradient(180deg, #f8fafc 0%, var(--color-bg) 100%); +} + +img { + display: block; + max-width: 100%; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +a { + color: inherit; +} + +/* ========================================================= + Layout helpers and utilities + ========================================================= */ + +.container { + width: min(100% - 2rem, var(--container)); + margin-inline: auto; +} + +.page { + padding: var(--space-8) 0 var(--space-12); +} + +.stack > * + * { + margin-top: var(--space-4); +} + +.stack-lg > * + * { + margin-top: var(--space-6); +} + +.meta-text, +.field-help, +.char-counter, +.cover-name { + margin: 0; + color: var(--color-text-soft); + font-size: 0.95rem; +} + +.is-hidden { + display: none !important; +} + +/* ========================================================= + layout.html + ========================================================= */ + +.site-header { + position: sticky; + top: 0; + z-index: 20; + background: rgba(248, 250, 252, 0.84); + backdrop-filter: blur(14px); + border-bottom: 1px solid rgba(217, 226, 236, 0.92); +} + +.site-header__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-4) 0; +} + +.site-header__spacer { + display: none; + width: 3rem; + min-width: 3rem; + height: 3rem; +} + +.site-brand { + min-width: 0; +} + +.site-brand__title { + display: inline-block; + text-decoration: none; + font-weight: 700; + font-size: 1.1rem; +} + +.site-brand--header { + text-align: left; +} + +.site-brand--menu { + padding-right: var(--space-4); +} + +.nav-toggle { + position: fixed; + inline-size: 1px; + block-size: 1px; + inset: 0 auto auto 0; + opacity: 0; + pointer-events: none; +} + +.nav, +.page-actions, +.button-row, +.card-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); +} + +.nav { + justify-content: flex-end; +} + +.nav-items { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.nav-items__item { + display: flex; + align-items: center; +} + +.nav-items__form, +.inline-form { + margin: 0; +} + +.nav-items__link { + text-decoration: none; + color: var(--color-text-soft); +} + +.nav-items__link:hover, +.nav-items__link:focus-visible { + color: var(--color-text); +} + +/* Desktop: rendre Déconnexion visuellement identique à Connexion */ +.nav--desktop .nav-items__button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--color-text-soft); + text-decoration: none; + font-weight: 400; + line-height: inherit; +} + +.nav--desktop .nav-items__button:hover, +.nav--desktop .nav-items__button:focus-visible { + color: var(--color-text); + outline: none; + transform: none; + box-shadow: none; +} + +.nav-toggle-button, +.mobile-menu__close { + position: relative; + display: none; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + min-width: 3rem; + min-height: 3rem; + aspect-ratio: 1 / 1; + border: 1px solid var(--color-border); + border-radius: 50%; + background: rgba(255, 255, 255, 0.92); + box-shadow: var(--shadow-sm); + cursor: pointer; + transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); +} + +.nav-toggle-button:hover, +.nav-toggle-button:focus-visible, +.mobile-menu__close:hover, +.mobile-menu__close:focus-visible { + border-color: #c4d1df; + background: var(--color-surface); +} + +.nav-toggle-button:active, +.mobile-menu__close:active { + transform: scale(0.98); +} + +.nav-toggle-button__line, +.mobile-menu__close-line { + position: absolute; + width: 1.15rem; + height: 2px; + border-radius: 999px; + background: var(--color-text); + transition: transform var(--transition-fast), opacity var(--transition-fast); +} + +.nav-toggle-button__line:nth-child(2) { + transform: translateY(-0.32rem); +} + +.nav-toggle-button__line:nth-child(3) { + transform: translateY(0); +} + +.nav-toggle-button__line:nth-child(4) { + transform: translateY(0.32rem); +} + +.mobile-menu__close-line:nth-child(2) { + transform: rotate(45deg); +} + +.mobile-menu__close-line:nth-child(3) { + transform: rotate(-45deg); +} + +.mobile-menu { + position: fixed; + inset: 0; + z-index: 40; + pointer-events: none; +} + +.mobile-menu__backdrop { + position: absolute; + inset: 0; + background: rgba(18, 32, 51, 0.46); + opacity: 0; + transition: opacity 220ms ease; +} + +.mobile-menu__panel { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: var(--space-8); + width: 100vw; + min-height: 100dvh; + padding: max(1.25rem, env(safe-area-inset-top)) 1.25rem max(1.5rem, env(safe-area-inset-bottom)); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%); + opacity: 0; + transform: translateY(-1.25rem); + transition: opacity 220ms ease, transform 220ms ease; + overflow-y: auto; +} + +.mobile-menu__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.mobile-menu__title { + font-size: 1.25rem; + font-weight: 700; +} + +.mobile-menu__nav { + align-content: start; +} + +.mobile-menu__nav .nav-items { + display: grid; + gap: var(--space-4); +} + +.mobile-menu__nav .nav-items__item, +.mobile-menu__nav .nav-items__form { + width: 100%; +} + +.mobile-menu__nav .nav-items__link, +.mobile-menu__nav .nav-items__button { + display: flex; + align-items: center; + justify-content: center; + min-height: 3.5rem; + width: 100%; + padding: 0.9rem 1.1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--shadow-sm); + color: var(--color-text); + text-align: center; + text-decoration: none; + font-weight: 600; +} + +.mobile-menu__nav .nav-items__link:hover, +.mobile-menu__nav .nav-items__link:focus-visible, +.mobile-menu__nav .nav-items__button:hover, +.mobile-menu__nav .nav-items__button:focus-visible { + border-color: #c4d1df; + background: var(--color-surface); +} + +#nav-toggle:checked ~ .mobile-menu { + pointer-events: auto; +} + +#nav-toggle:checked ~ .mobile-menu .mobile-menu__backdrop, +#nav-toggle:checked ~ .mobile-menu .mobile-menu__panel { + opacity: 1; +} + +#nav-toggle:checked ~ .mobile-menu .mobile-menu__panel { + transform: translateY(0); +} + +#nav-toggle:checked ~ .site-header .nav-toggle-button { + opacity: 0; + pointer-events: none; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.flash { + margin-bottom: var(--space-5); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.flash--success { + color: var(--color-success); + background: var(--color-success-soft); + border-color: #b7ebc6; +} + +.flash--error { + color: var(--color-danger); + background: var(--color-danger-soft); + border-color: #f7c5c0; +} + +/* ========================================================= + Shared sections and components + ========================================================= */ + +.page-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: var(--space-5); + flex-wrap: wrap; + margin-bottom: var(--space-6); +} + +.page-header--compact { + margin-bottom: 0; +} + +.page-title, +.article-title { + margin: 0; + font-size: clamp(2rem, 2vw + 1rem, 3rem); + line-height: 1.1; +} + +.article-excerpt { + margin: 0; + color: var(--color-text-soft); + font-size: 1.05rem; +} + +.card, +.panel, +.prose, +.empty-state, +.media-picker, +.auth-shell, +.error-card { + background: var(--color-surface); + border-radius: var(--radius-lg); +} + +.card, +.panel, +.prose, +.media-picker, +.auth-shell, +.error-card { + border: 1px solid var(--color-border); + box-shadow: var(--shadow-md); +} + +.panel, +.prose, +.media-picker, +.auth-shell, +.error-card { + padding: var(--space-5); +} + +.empty-state { + padding: var(--space-8); + border: 1px dashed var(--color-border); +} + +.card-grid, +.field, +.media-picker__grid { + display: grid; + gap: var(--space-4); +} + +.card-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: stretch; +} + +.field { + gap: var(--space-2); +} + +.field-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} + +.field-label { + margin: 0; + font-weight: 600; +} + +.control { + width: 100%; + min-height: var(--control-height); + padding: 0.85rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: #fff; + color: var(--color-text); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.control:focus { + outline: none; + border-color: #91a4bb; + box-shadow: var(--focus-ring); +} + +textarea.control { + min-height: 10rem; + resize: vertical; +} + +.button, +.tool-button, +.media-picker__item { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + text-decoration: none; + transition: transform var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast); +} + +.button, +.tool-button { + border-radius: var(--radius-pill); +} + +.button { + min-height: var(--control-height); + padding: 0.7rem 1rem; + border: 1px solid var(--color-accent); + background: var(--color-accent); + color: var(--color-accent-contrast); +} + +.button:hover, +.button:focus-visible { + transform: translateY(-1px); +} + +.button--ghost, +.tool-button, +.media-picker__item { + background: #fff; + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.button--danger { + background: var(--color-danger); + border-color: var(--color-danger); + color: #fff; +} + +.button--small { + min-height: 2.35rem; + padding: 0.45rem 0.8rem; +} + +.tool-button { + min-height: 2.5rem; + padding: 0.5rem 0.9rem; +} + +.button--ghost:focus-visible, +.tool-button:hover, +.tool-button:focus-visible, +.media-picker__item:hover, +.media-picker__item:focus-visible { + outline: none; + border-color: #91a4bb; + box-shadow: var(--focus-ring); +} + +.media-frame { + display: block; + width: 100%; + aspect-ratio: 16 / 10; + border-radius: var(--radius-md); + object-fit: cover; +} + +.media-frame--square { + aspect-ratio: 1; +} + +.media-frame--large { + min-height: 18rem; +} + +.media-frame--placeholder { + display: grid; + place-items: center; + padding: var(--space-4); + background: linear-gradient(135deg, #e2e8f0, #f8fafc); + border: 1px dashed var(--color-border); + color: var(--color-text-soft); + text-align: center; +} + +.article-card { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.card-body, +.article-card__body { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-5); +} + +.card-title { + margin: 0; + font-size: 1.3rem; + line-height: 1.25; +} + +.card-title__link, +.card-media-link { + text-decoration: none; +} + +.card-summary, +.card-title__link:hover, +.card-title__link:focus-visible { + color: inherit; +} + +.card-summary { + margin: 0; +} + +.card-actions { + margin-top: auto; +} + +.card-actions > * { + flex: 1 1 auto; +} + +.card-actions form { + margin: 0; +} + +.card-actions .button { + width: 100%; +} + +/* ========================================================= + Public pages + ========================================================= */ + +.article { + max-width: 56rem; + margin-inline: auto; +} + +.article-header { + margin-bottom: var(--space-5); +} + +.article-cover { + margin-bottom: var(--space-5); +} + +.prose :first-child { + margin-top: 0; +} + +.prose :last-child { + margin-bottom: 0; +} + +.prose h1, +.prose h2, +.prose h3, +.prose h4, +.prose h5, +.prose h6 { + margin: 1.4rem 0 0.7rem; + font-size: 1.35rem; + line-height: 1.25; +} + +.prose p, +.prose ul, +.prose ol, +.prose blockquote, +.prose pre { + margin: 0 0 1rem; +} + +.prose ul, +.prose ol { + padding-left: 1.5rem; +} + +.prose ul { + list-style: disc; +} + +.prose ol { + list-style: decimal; +} + +.prose blockquote { + margin-left: 0; + padding-left: var(--space-4); + color: var(--color-text-soft); + border-left: 3px solid var(--color-border); +} + +.prose pre { + overflow: auto; + padding: var(--space-4); + border-radius: var(--radius-md); + background: #122033; + color: #e5edf7; +} + +.prose code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* ========================================================= + Admin pages + ========================================================= */ + +.editor-layout { + display: grid; + gap: var(--space-4); + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + +.editor-layout.is-picker-open { + grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr); +} + +.editor-form { + min-width: 0; +} + +.editor-textarea { + min-height: 22rem; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-muted); +} + +.media-picker { + display: grid; + gap: var(--space-4); + background: var(--color-surface-muted); +} + +.editor-layout.is-picker-open .media-picker { + position: sticky; + top: var(--space-6); +} + +.media-picker__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} + +.media-picker__grid { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} + +.media-picker__item { + display: grid; + gap: var(--space-3); + width: 100%; + padding: var(--space-3); + border-radius: var(--radius-lg); + text-align: left; + align-content: start; +} + +.media-picker__name { + font-size: 0.95rem; +} + +.cover-field { + gap: var(--space-3); +} + +.cover-picker { + display: grid; + gap: var(--space-4); + align-items: start; + min-width: 0; +} + +.cover-picker .button-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.cover-picker .button-row > * { + min-width: 0; +} + +.cover-preview { + width: 100%; +} + +/* ========================================================= + Auth and error pages + ========================================================= */ + +.auth-shell { + width: min(100%, var(--container-narrow)); + margin-inline: auto; +} + +.error-page { + padding: var(--space-8) 0 var(--space-12); +} + +.error-card { + max-width: 42rem; + margin: 0 auto; +} + +.error-page__code { + margin: 0 0 var(--space-3); + color: var(--color-text-soft); + font-size: 0.95rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.error-page__title { + margin: 0 0 var(--space-4); + font-size: clamp(2rem, 2vw + 1rem, 3rem); + line-height: 1.1; +} + +.error-page__message, +.error-page__hint { + margin: 0 0 var(--space-4); + color: var(--color-text-soft); +} + +.error-page__actions { + margin-top: var(--space-5); +} + +/* ========================================================= + Pagination + ========================================================= */ + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + margin-top: var(--space-8); +} + +.pagination__info { + font-size: 0.875rem; + color: var(--color-text-soft); +} + +.pagination__disabled { + opacity: 0.35; + pointer-events: none; +} + +/* ========================================================= + Responsive + ========================================================= */ + +@media (max-width: 1080px) { + .editor-layout, + .editor-layout.is-picker-open { + grid-template-columns: 1fr; + } +} + +@media (max-width: 960px) { + .card-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .cover-picker { + display: block; + } + + .cover-picker > * + * { + margin-top: var(--space-4); + } + + .page-header { + align-items: flex-start; + flex-direction: column; + } + + .nav, + .page-actions { + width: 100%; + justify-content: flex-start; + } + + .card-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .page { + padding: var(--space-6) 0 var(--space-10); + } + + .container { + width: min(100% - 1rem, var(--container)); + } + + .panel, + .prose, + .media-picker, + .empty-state, + .auth-shell, + .error-card { + padding: var(--space-4); + } + + .card-body, + .article-card__body { + padding: var(--space-4); + } + + .button { + width: 100%; + } + + .tool-button, + .button--small { + width: auto; + } + + .nav > *, + .page-actions > *, + .button-row > *, + .card-actions > * { + flex: 1 1 100%; + } +} + +/* ========================================================= + Responsive navigation + ========================================================= */ + +@media (max-width: 720px) { + .site-header { + backdrop-filter: none; + background: rgba(248, 250, 252, 0.98); + } + + .site-header__inner { + display: grid; + grid-template-columns: 3rem minmax(0, 1fr) 3rem; + align-items: center; + gap: var(--space-3); + min-height: 5.25rem; + } + + .site-brand--header { + grid-column: 2; + justify-self: center; + width: 100%; + text-align: center; + } + + .site-brand--header .site-brand__title { + display: block; + } + + .nav--desktop { + display: none; + } + + .nav-toggle-button, + .mobile-menu__close, + .site-header__spacer { + display: inline-flex; + } + + .nav-toggle-button { + grid-column: 1; + justify-self: start; + } + + .site-header__spacer { + grid-column: 3; + justify-self: end; + } + + .page { + padding-top: var(--space-6); + } +} + +@media (min-width: 721px) { + .mobile-menu { + display: none; + } +} diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..61e29c0 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,234 @@ +(() => { + const on = (selector, handler) => document.querySelectorAll(selector).forEach(handler); + + on('[data-copy-text]', (button) => { + button.addEventListener('click', async () => { + const text = button.getAttribute('data-copy-text') || ''; + if (!text) { + return; + } + + try { + await navigator.clipboard.writeText(text); + const previous = button.textContent; + button.textContent = 'Copié'; + window.setTimeout(() => { + button.textContent = previous; + }, 1200); + } catch { + window.prompt('Copie ce texte :', text); + } + }); + }); + + on('[data-confirm]', (form) => { + form.addEventListener('submit', (event) => { + const message = form.getAttribute('data-confirm') || 'Confirmer cette action ?'; + if (!window.confirm(message)) { + event.preventDefault(); + } + }); + }); + + on('[data-char-count]', (field) => { + const counter = field.parentElement?.querySelector('[data-char-count-value]'); + if (!counter) { + return; + } + + const update = () => { + counter.textContent = String(field.value.length); + }; + + field.addEventListener('input', update); + update(); + }); + + const editor = document.querySelector('[data-markdown-editor]'); + const picker = document.querySelector('[data-media-picker]'); + const editorLayout = document.querySelector('[data-editor-layout]'); + const pickerTitle = document.querySelector('[data-media-picker-title]'); + const pickerHelp = document.querySelector('[data-media-picker-help]'); + const pickerClose = document.querySelector('[data-media-picker-close]'); + const coverInput = document.querySelector('[data-cover-input]'); + const coverPreview = document.querySelector('[data-cover-preview]'); + const coverPlaceholder = document.querySelector('[data-cover-placeholder]'); + const coverClear = document.querySelector('[data-cover-clear]'); + let pickerMode = 'markdown'; + + const focusEditor = () => editor?.focus(); + + const updateCoverPreview = (item = null) => { + if (!coverInput || !coverPreview || !coverPlaceholder) { + return; + } + + if (item && item.id && item.url) { + coverInput.value = item.id; + coverPreview.src = item.url; + coverPreview.alt = 'Image de couverture'; + coverPreview.classList.remove('is-hidden'); + coverPlaceholder.classList.add('is-hidden'); + if (coverClear) { + coverClear.disabled = false; + } + return; + } + + coverInput.value = ''; + coverPreview.removeAttribute('src'); + coverPreview.classList.add('is-hidden'); + coverPlaceholder.classList.remove('is-hidden'); + if (coverClear) { + coverClear.disabled = true; + } + }; + + coverClear?.addEventListener('click', () => updateCoverPreview()); + + if (editor) { + const replaceSelection = (before, after = '', placeholder = '') => { + const start = editor.selectionStart; + const end = editor.selectionEnd; + const selected = editor.value.slice(start, end); + const content = selected || placeholder; + const insertion = before + content + after; + + editor.setRangeText(insertion, start, end, 'end'); + + if (!selected && placeholder) { + const cursorStart = start + before.length; + editor.setSelectionRange(cursorStart, cursorStart + placeholder.length); + } + + focusEditor(); + }; + + const prefixLines = (prefix, placeholder) => { + const start = editor.selectionStart; + const end = editor.selectionEnd; + const selected = editor.value.slice(start, end) || placeholder; + const prefixed = selected + .split('\n') + .map((line) => (line ? prefix + line : prefix.trimEnd())) + .join('\n'); + + editor.setRangeText(prefixed, start, end, 'end'); + focusEditor(); + }; + + on('[data-md-action]', (button) => { + button.addEventListener('click', () => { + switch (button.getAttribute('data-md-action')) { + case 'bold': + replaceSelection('**', '**', 'texte'); + break; + case 'italic': + replaceSelection('*', '*', 'texte'); + break; + case 'heading': + prefixLines('## ', 'Titre'); + break; + case 'list': + prefixLines('- ', 'Élément'); + break; + case 'quote': + prefixLines('> ', 'Citation'); + break; + case 'link': + replaceSelection('[', '](https://)', 'texte'); + break; + case 'code': { + const selected = editor.value.slice(editor.selectionStart, editor.selectionEnd); + replaceSelection( + selected.includes('\n') ? '```\n' : '`', + selected.includes('\n') ? '\n```' : '`', + 'code' + ); + break; + } + } + }); + }); + } + + const togglePicker = (open, mode = pickerMode) => { + if (!picker) { + return; + } + + pickerMode = mode; + picker.classList.toggle('is-hidden', !open); + editorLayout?.classList.toggle('is-picker-open', open); + + if (!open) { + focusEditor(); + return; + } + + const isCover = pickerMode === 'cover'; + if (pickerTitle) { + pickerTitle.textContent = isCover ? 'Choisir une couverture' : 'Insérer une image'; + } + if (pickerHelp) { + pickerHelp.textContent = isCover + ? 'Clique sur une image pour l’utiliser comme couverture.' + : 'Clique sur une image pour l’insérer dans l’article.'; + } + + picker.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }; + + on('[data-media-picker-open]', (button) => { + button.addEventListener('click', () => { + togglePicker(true, button.getAttribute('data-media-picker-open') || 'markdown'); + }); + }); + + pickerClose?.addEventListener('click', () => togglePicker(false)); + + on('[data-media-picker-select]', (button) => { + button.addEventListener('click', () => { + const item = { + id: button.getAttribute('data-media-id') || '', + url: button.getAttribute('data-media-url') || '', + markdown: button.getAttribute('data-media-markdown') || '', + }; + + if (pickerMode === 'cover') { + updateCoverPreview(item); + togglePicker(false); + return; + } + + if (!editor || !item.markdown) { + return; + } + + const start = editor.selectionStart; + const end = editor.selectionEnd; + const prefix = start > 0 && !editor.value.slice(0, start).endsWith('\n\n') ? '\n\n' : ''; + const suffix = end < editor.value.length && !editor.value.slice(end).startsWith('\n\n') ? '\n\n' : ''; + editor.setRangeText(prefix + item.markdown + suffix, start, end, 'end'); + togglePicker(false); + }); + }); + + // Synchronise data-copy-text du bouton Markdown quand l'alt est modifié. + on('[data-alt-input]', (input) => { + const card = input.closest('.card'); + const button = card?.querySelector('[data-markdown-template]'); + if (!button) { + return; + } + + const update = () => { + const template = button.getAttribute('data-markdown-template') || ''; + const alt = input.value; + button.setAttribute('data-copy-text', template.replace('![](', '![' + alt + '](')); + }; + + input.addEventListener('input', update); + update(); + }); +})(); diff --git a/public/assets/favicon.svg b/public/assets/favicon.svg new file mode 100644 index 0000000..a106e8d --- /dev/null +++ b/public/assets/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..5f040ae --- /dev/null +++ b/public/index.php @@ -0,0 +1,8 @@ +run(); diff --git a/public/uploads/media/.gitkeep b/public/uploads/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/bootstrap.php b/scripts/bootstrap.php new file mode 100644 index 0000000..96a87e5 --- /dev/null +++ b/scripts/bootstrap.php @@ -0,0 +1,7 @@ +get('DB'); + +$username = trim((string) ($argv[1] ?? '')); +if ($username === '') { + fwrite(STDERR, "Usage: php scripts/create-admin.php \n"); + exit(1); +} + +fwrite(STDOUT, 'Mot de passe (10 caractères minimum): '); +if (stripos(PHP_OS, 'WIN') !== 0) { + system('stty -echo'); +} +$password = trim((string) fgets(STDIN)); +if (stripos(PHP_OS, 'WIN') !== 0) { + system('stty echo'); +} +fwrite(STDOUT, PHP_EOL); + +if ($password === '') { + fwrite(STDERR, "Mot de passe vide.\n"); + exit(1); +} + +User::bootstrap($db); + +try { + $id = (new User($db))->create($username, $password); + fwrite(STDOUT, "Admin créé (ID {$id}).\n"); +} catch (RuntimeException $e) { + fwrite(STDERR, $e->getMessage() . "\n"); + exit(1); +} diff --git a/scripts/install.php b/scripts/install.php new file mode 100644 index 0000000..b9de882 --- /dev/null +++ b/scripts/install.php @@ -0,0 +1,12 @@ +get('DB'); + +User::bootstrap($db); +Post::bootstrap($db); +Media::bootstrap($db); + +fwrite(STDOUT, 'Base initialisée : ' . app_db_path() . "\n"); diff --git a/tmp/.gitkeep b/tmp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/cache/.gitkeep b/tmp/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/uploads/.gitkeep b/tmp/uploads/.gitkeep new file mode 100644 index 0000000..e69de29