More robust
This commit is contained in:
34
README.md
34
README.md
@@ -23,15 +23,15 @@ project/
|
||||
├── public/
|
||||
│ ├── assets/ # Sources CSS/JS (servis minifiés via /min/@file)
|
||||
│ └── uploads/
|
||||
│ └── media/ # Images converties en PNG (sans perte)
|
||||
│ └── media/ # Images publiées (JPG conservé, PNG/WebP normalisés en PNG)
|
||||
└── tmp/
|
||||
├── cache/ # Cache F3 (pages publiques + assets minifiés)
|
||||
└── uploads/ # Transit Web::receive() — vidé après chaque upload
|
||||
└── uploads/ # Transit Web::receive() — nettoyé après chaque upload
|
||||
```
|
||||
|
||||
## Philosophie des dossiers runtime
|
||||
|
||||
Le projet garde la même logique en local et dans Docker :
|
||||
Le projet sépare les données persistantes du runtime jetable :
|
||||
|
||||
- `tmp/` = runtime temporaire, recréable
|
||||
- `db/` = base SQLite persistante
|
||||
@@ -42,14 +42,14 @@ Autrement dit, `tmp/` peut être vidé sans perte métier. Les données à sauve
|
||||
|
||||
## 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
|
||||
- **Routage nommé** — `config.ini [routes]`, filtre `alias` dans les templates, `reroute('@route')` dans les contrôleurs
|
||||
- **Cache HTTP + serveur** — TTL déclarés directement dans `[routes]`, `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()`
|
||||
- **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()` + reconstruction DOM en liste blanche
|
||||
- **Slugs** — `Web::instance()->slug()`
|
||||
- **Session** — `$f3->set('JAR', …)`, token CSRF dans `SESSION.csrf_token`
|
||||
- **Session** — `$f3->set('JAR', …)`, hooks `beforeRoute()` sur les contrôleurs protégés, token CSRF créé seulement sur les vues qui contiennent des formulaires
|
||||
- **Logging** — `Log` de F3 avec fallback `file_put_contents`
|
||||
- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`
|
||||
|
||||
@@ -115,7 +115,7 @@ cp config.local.ini.example config.local.ini
|
||||
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/`.
|
||||
Docker ne monte que les dossiers persistants (`db/`, `logs/`, `public/uploads/media/`) et laisse `tmp/` dans le conteneur pour qu'il reste réellement éphémère.
|
||||
|
||||
Si `config.local.ini` n'existe pas, le conteneur démarre avec les valeurs par défaut de `app/config.ini`.
|
||||
|
||||
@@ -128,6 +128,20 @@ docker compose exec app php scripts/create-admin.php admin
|
||||
# mot de passe : 10 caractères minimum
|
||||
```
|
||||
|
||||
## Cache public et navigation
|
||||
|
||||
Les pages publiques (`/` et `/posts/@slug`) restent cacheables parce que leur rendu n'accède ni à la session, ni au token CSRF. La navigation publique affiche donc un lien statique vers la connexion / l'administration, tandis que les vues d'administration restent session-aware.
|
||||
|
||||
## Médias et limites d'upload
|
||||
|
||||
- Formats acceptés à l'entrée : `JPG`, `PNG`, `WebP`
|
||||
- Taille max du fichier reçu : `10 Mo`
|
||||
- Dimensions max : `8000 × 8000 px`
|
||||
- Limite de surface : `40 mégapixels`
|
||||
- Sortie publiée : `JPG` pour les sources JPEG, `PNG` pour les sources PNG/WebP
|
||||
|
||||
La médiathèque admin est paginée et le picker dans l'éditeur charge seulement les images les plus récentes pour éviter de charger toute la bibliothèque en mémoire à chaque formulaire.
|
||||
|
||||
## Reverse proxy Caddy
|
||||
|
||||
### Caddy sur le même hôte
|
||||
|
||||
@@ -18,8 +18,6 @@ class AssetController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
$this->f3->expire(86400); // 24 h côté navigateur
|
||||
|
||||
echo Web::instance()->minify(
|
||||
$file,
|
||||
self::ALLOWED[$file],
|
||||
|
||||
@@ -4,15 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
public function beforeRoute(): void
|
||||
{
|
||||
$this->disableCache();
|
||||
}
|
||||
|
||||
public function show(): void
|
||||
{
|
||||
if ($this->currentUser() !== null) {
|
||||
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||
$this->f3->reroute('@dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->f3->expire(0);
|
||||
$this->render('auth/login.html', ['pageTitle' => 'Connexion']);
|
||||
$this->renderSession('auth/login.html', ['pageTitle' => 'Connexion'], true);
|
||||
}
|
||||
|
||||
public function login(): void
|
||||
@@ -26,22 +30,24 @@ class AuthController extends BaseController
|
||||
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'));
|
||||
$this->f3->reroute('@login');
|
||||
return;
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
$this->f3->set('SESSION.user_id', $user['id']);
|
||||
$this->refreshCsrfToken();
|
||||
$this->flash('success', 'Connexion réussie.');
|
||||
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||
$this->f3->reroute('@dashboard');
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$this->verifyCsrf();
|
||||
$this->f3->clear('SESSION.user_id');
|
||||
$this->f3->clear('SESSION.csrf_token');
|
||||
session_regenerate_id(true);
|
||||
$this->flash('success', 'Déconnexion effectuée.');
|
||||
$this->f3->reroute($this->f3->alias('home'));
|
||||
$this->f3->reroute('@login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,31 @@ abstract class BaseController
|
||||
$this->db = $this->f3->get('DB');
|
||||
}
|
||||
|
||||
protected function render(string $view, array $data = []): void
|
||||
protected function renderPublic(string $view, array $data = []): void
|
||||
{
|
||||
$user = $this->currentUser();
|
||||
|
||||
$this->f3->mset($data + [
|
||||
'view' => $view,
|
||||
'currentUser' => $user,
|
||||
'flash' => array_key_exists('flash', $data) && is_array($data['flash']) ? $data['flash'] : null,
|
||||
'csrfToken' => $user !== null ? $this->csrfToken() : null,
|
||||
]);
|
||||
|
||||
echo Template::instance()->render('layout.html');
|
||||
}
|
||||
|
||||
protected function renderSession(string $view, array $data = [], bool $withCsrf = false): void
|
||||
{
|
||||
$flash = array_key_exists('flash', $data) && is_array($data['flash'])
|
||||
? $data['flash']
|
||||
: $this->pullFlash();
|
||||
|
||||
$this->f3->mset($data + [
|
||||
'view' => $view,
|
||||
'currentUser' => $this->currentUser(),
|
||||
'flash' => $this->pullFlash(),
|
||||
'csrfToken' => $this->csrfToken(),
|
||||
'flash' => $flash,
|
||||
'csrfToken' => $withCsrf ? $this->csrfToken() : null,
|
||||
]);
|
||||
|
||||
echo Template::instance()->render('layout.html');
|
||||
@@ -38,12 +56,16 @@ abstract class BaseController
|
||||
}
|
||||
|
||||
$this->flash('error', 'Connecte-toi pour continuer.');
|
||||
$this->f3->reroute($this->f3->alias('login'));
|
||||
$this->f3->reroute('@login');
|
||||
}
|
||||
|
||||
protected function disableCache(): void
|
||||
{
|
||||
$this->f3->expire(0);
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -53,6 +75,13 @@ abstract class BaseController
|
||||
return $token;
|
||||
}
|
||||
|
||||
protected function refreshCsrfToken(): string
|
||||
{
|
||||
$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') ?? '');
|
||||
|
||||
@@ -4,19 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
class DashboardController extends BaseController
|
||||
{
|
||||
public function index(): void
|
||||
public function beforeRoute(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->f3->expire(0);
|
||||
$this->disableCache();
|
||||
}
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||
$result = (new Post($this->db))->paginateList($page, 24);
|
||||
|
||||
$this->render('admin/dashboard.html', [
|
||||
$this->renderSession('admin/dashboard.html', [
|
||||
'pageTitle' => 'Tableau de bord',
|
||||
'posts' => $result['posts'],
|
||||
'pagination' => $result,
|
||||
'paginationBase' => $this->f3->alias('dashboard'),
|
||||
]);
|
||||
'paginationAlias' => 'dashboard',
|
||||
], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,29 @@ declare(strict_types=1);
|
||||
class MediaController extends BaseController
|
||||
{
|
||||
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
|
||||
private const PER_PAGE = 24;
|
||||
|
||||
public function beforeRoute(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->disableCache();
|
||||
}
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->f3->expire(0);
|
||||
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||
$result = (new Media($this->db))->paginateLibrary($page, self::PER_PAGE);
|
||||
|
||||
$this->render('admin/media.html', [
|
||||
$this->renderSession('admin/media.html', [
|
||||
'pageTitle' => 'Médiathèque',
|
||||
'items' => (new Media($this->db))->all(),
|
||||
]);
|
||||
'items' => $result['items'],
|
||||
'pagination' => $result,
|
||||
'paginationAlias' => 'media_index',
|
||||
], true);
|
||||
}
|
||||
|
||||
public function upload(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
@@ -27,7 +35,8 @@ class MediaController extends BaseController
|
||||
$originalName = (string) ($this->f3->get('FILES.image.name') ?? '');
|
||||
|
||||
$received = Web::instance()->receive(
|
||||
fn(array $file): bool => $file['size'] <= self::UPLOAD_MAX_BYTES,
|
||||
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0
|
||||
&& (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
|
||||
overwrite: false,
|
||||
slug: true
|
||||
);
|
||||
@@ -48,12 +57,11 @@ class MediaController extends BaseController
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute($this->f3->alias('media_index'));
|
||||
$this->f3->reroute('@media_index');
|
||||
}
|
||||
|
||||
public function updateAlt(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
@@ -64,12 +72,11 @@ class MediaController extends BaseController
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute($this->f3->alias('media_index'));
|
||||
$this->f3->reroute('@media_index');
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
|
||||
try {
|
||||
@@ -79,6 +86,6 @@ class MediaController extends BaseController
|
||||
$this->flash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->f3->reroute($this->f3->alias('media_index'));
|
||||
$this->f3->reroute('@media_index');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
class PostController extends BaseController
|
||||
{
|
||||
public function create(): void
|
||||
private const MEDIA_PICKER_LIMIT = 60;
|
||||
|
||||
public function beforeRoute(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->f3->expire(0);
|
||||
$this->disableCache();
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), Post::emptyForm());
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
|
||||
$input = $this->postInput();
|
||||
@@ -22,18 +27,14 @@ class PostController extends BaseController
|
||||
(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'));
|
||||
$this->f3->reroute('@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.');
|
||||
@@ -45,7 +46,6 @@ class PostController extends BaseController
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->verifyCsrf();
|
||||
|
||||
$id = (int) $this->f3->get('PARAMS.id');
|
||||
@@ -60,21 +60,19 @@ class PostController extends BaseController
|
||||
|
||||
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
|
||||
$this->flash('success', 'Article mis à jour.');
|
||||
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||
$this->f3->reroute('@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'));
|
||||
$this->f3->reroute('@dashboard');
|
||||
}
|
||||
|
||||
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null): void
|
||||
@@ -84,18 +82,24 @@ class PostController extends BaseController
|
||||
$coverPreview = (new Media($this->db))->findById((int) $post['cover_media_id']);
|
||||
}
|
||||
|
||||
$media = new Media($this->db);
|
||||
$mediaItems = $media->latest(self::MEDIA_PICKER_LIMIT);
|
||||
$mediaCount = $media->countAll();
|
||||
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;
|
||||
|
||||
$this->render('admin/post_form.html', [
|
||||
$this->renderSession('admin/post_form.html', [
|
||||
'pageTitle' => $pageTitle,
|
||||
'formAction' => $formAction,
|
||||
'post' => $post,
|
||||
'coverPreview' => $coverPreview,
|
||||
'mediaItems' => (new Media($this->db))->all(),
|
||||
'mediaItems' => $mediaItems,
|
||||
'mediaCount' => $mediaCount,
|
||||
'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT,
|
||||
'mediaPickerTruncated' => $mediaCount > count($mediaItems),
|
||||
'titleMax' => Post::TITLE_MAX_LENGTH,
|
||||
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
|
||||
'flash' => $flash,
|
||||
]);
|
||||
], true);
|
||||
}
|
||||
|
||||
private function postInput(): array
|
||||
|
||||
@@ -6,30 +6,26 @@ class SiteController extends BaseController
|
||||
{
|
||||
public function home(): void
|
||||
{
|
||||
$this->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', [
|
||||
$this->renderPublic('site/home.html', [
|
||||
'pageTitle' => 'Accueil',
|
||||
'posts' => $result['posts'],
|
||||
'pagination' => $result,
|
||||
'paginationBase' => $this->f3->alias('home'),
|
||||
'paginationAlias' => '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', [
|
||||
$this->renderPublic('site/post.html', [
|
||||
'pageTitle' => $post['title'],
|
||||
'post' => $post,
|
||||
]);
|
||||
|
||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
class Media extends DB\SQL\Mapper
|
||||
{
|
||||
private const MAX_WIDTH = 8000;
|
||||
private const MAX_HEIGHT = 8000;
|
||||
private const MAX_PIXELS = 40_000_000;
|
||||
|
||||
public function __construct(DB\SQL $db)
|
||||
{
|
||||
parent::__construct($db, 'media');
|
||||
@@ -30,6 +34,35 @@ class Media extends DB\SQL\Mapper
|
||||
);
|
||||
}
|
||||
|
||||
public function paginateLibrary(int $page = 1, int $perPage = 24): array
|
||||
{
|
||||
$result = $this->paginate(
|
||||
max(0, $page - 1),
|
||||
$perPage,
|
||||
null,
|
||||
['order' => 'created_at DESC, id DESC']
|
||||
);
|
||||
|
||||
return [
|
||||
'items' => array_map(fn(self $m): array => $this->decorate($m->cast()), $result['subset']),
|
||||
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||||
'pages' => $result['count'] ?: 1,
|
||||
];
|
||||
}
|
||||
|
||||
public function latest(int $limit = 60): array
|
||||
{
|
||||
return array_map(
|
||||
fn(self $m): array => $this->decorate($m->cast()),
|
||||
$this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: []
|
||||
);
|
||||
}
|
||||
|
||||
public function countAll(): int
|
||||
{
|
||||
return $this->count();
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
@@ -50,36 +83,50 @@ class Media extends DB\SQL\Mapper
|
||||
// pour dériver un texte alternatif lisible.
|
||||
public function upload(string $srcPath, string $originalName = ''): int
|
||||
{
|
||||
// Image::dump() gère le chargement et la compression.
|
||||
// Attention : en JPEG, la transparence n'est pas conservée.
|
||||
// $path='' indique à F3 que le chemin est absolu.
|
||||
$target = null;
|
||||
$image = null;
|
||||
|
||||
try {
|
||||
$img = new Image($srcPath, false, '');
|
||||
} catch (Throwable) {
|
||||
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
|
||||
}
|
||||
$meta = self::inspectUpload($srcPath);
|
||||
$image = self::openImageResource($srcPath, $meta['mime']);
|
||||
|
||||
$data = $img->dump('jpeg', 85);
|
||||
|
||||
$fileName = bin2hex(random_bytes(16)) . '.jpg';
|
||||
[$format, $extension] = self::targetFormat($meta['mime']);
|
||||
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
||||
$target = app_public_media_dir() . '/' . $fileName;
|
||||
|
||||
if (!Base::instance()->write($target, $data)) {
|
||||
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
}
|
||||
|
||||
// Supprimer le fichier intermédiaire déposé par Web::receive().
|
||||
@unlink($srcPath);
|
||||
self::writeImage($image, $target, $format);
|
||||
|
||||
$this->db->begin();
|
||||
try {
|
||||
$this->reset();
|
||||
$this->file_name = $fileName;
|
||||
$this->alt = $originalName !== '' ? self::altFromFilename($originalName) : '';
|
||||
$this->width = $img->width();
|
||||
$this->height = $img->height();
|
||||
$this->width = $meta['width'];
|
||||
$this->height = $meta['height'];
|
||||
$this->created_at = app_now();
|
||||
$this->save();
|
||||
$this->db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->db->rollback();
|
||||
if ($target !== null && is_file($target)) {
|
||||
@unlink($target);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return (int) $this->get('id');
|
||||
} catch (RuntimeException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable) {
|
||||
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
} finally {
|
||||
if ($image instanceof GdImage) {
|
||||
imagedestroy($image);
|
||||
}
|
||||
if (is_file($srcPath)) {
|
||||
@unlink($srcPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function updateAlt(int $id, string $alt): void
|
||||
@@ -119,6 +166,83 @@ class Media extends DB\SQL\Mapper
|
||||
}
|
||||
}
|
||||
|
||||
private static function inspectUpload(string $srcPath): array
|
||||
{
|
||||
if (!is_file($srcPath)) {
|
||||
throw new RuntimeException('Fichier image introuvable.');
|
||||
}
|
||||
|
||||
$info = @getimagesize($srcPath);
|
||||
if (!is_array($info)) {
|
||||
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
|
||||
}
|
||||
|
||||
$width = (int) ($info[0] ?? 0);
|
||||
$height = (int) ($info[1] ?? 0);
|
||||
$mime = strtolower((string) ($info['mime'] ?? ''));
|
||||
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
throw new RuntimeException('Format non supporté. Utilise JPG, PNG ou WebP.');
|
||||
}
|
||||
|
||||
if ($width <= 0 || $height <= 0) {
|
||||
throw new RuntimeException('Dimensions image invalides.');
|
||||
}
|
||||
|
||||
if ($width > self::MAX_WIDTH || $height > self::MAX_HEIGHT || ($width * $height) > self::MAX_PIXELS) {
|
||||
throw new RuntimeException('Image trop grande. Limite : 8000 × 8000 px et 40 mégapixels.');
|
||||
}
|
||||
|
||||
return [
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'mime' => $mime,
|
||||
];
|
||||
}
|
||||
|
||||
private static function openImageResource(string $srcPath, string $mime): GdImage
|
||||
{
|
||||
$image = match ($mime) {
|
||||
'image/jpeg' => @imagecreatefromjpeg($srcPath),
|
||||
'image/png' => @imagecreatefrompng($srcPath),
|
||||
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($srcPath) : false,
|
||||
default => false,
|
||||
};
|
||||
|
||||
if (!$image instanceof GdImage) {
|
||||
throw new RuntimeException('Fichier image invalide ou format source non supporté.');
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
private static function targetFormat(string $mime): array
|
||||
{
|
||||
return match ($mime) {
|
||||
'image/jpeg' => ['jpeg', 'jpg'],
|
||||
'image/png', 'image/webp' => ['png', 'png'],
|
||||
default => throw new RuntimeException('Format non supporté. Utilise JPG, PNG ou WebP.'),
|
||||
};
|
||||
}
|
||||
|
||||
private static function writeImage(GdImage $image, string $target, string $format): void
|
||||
{
|
||||
if ($format === 'png') {
|
||||
if (function_exists('imagepalettetotruecolor') && !imageistruecolor($image)) {
|
||||
imagepalettetotruecolor($image);
|
||||
}
|
||||
imagealphablending($image, false);
|
||||
imagesavealpha($image, true);
|
||||
$written = @imagepng($image, $target, 6);
|
||||
} else {
|
||||
$written = @imagejpeg($image, $target, 85);
|
||||
}
|
||||
|
||||
if ($written !== true || !is_file($target)) {
|
||||
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||
}
|
||||
}
|
||||
|
||||
// Dérive un texte alternatif lisible depuis le nom de fichier d'origine.
|
||||
private static function altFromFilename(string $filename): string
|
||||
{
|
||||
|
||||
@@ -10,11 +10,6 @@ class MarkdownService
|
||||
'strong', 'em', 'a', 'img', 'hr', 'br',
|
||||
];
|
||||
|
||||
private const ALLOWED_ATTRS = [
|
||||
'a' => ['href', 'title', 'rel', 'target'],
|
||||
'img' => ['src', 'alt', 'title', 'loading', 'decoding'],
|
||||
];
|
||||
|
||||
public static function compile(string $markdown, Media $media): string
|
||||
{
|
||||
$markdown = trim($markdown);
|
||||
@@ -34,107 +29,188 @@ class MarkdownService
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Passe DOM unique : sanitise les balises/attributs et résout les références media:.
|
||||
// Reconstruction en liste blanche : les descendants d'une balise interdite
|
||||
// sont retraités récursivement avant d'être réinsérés.
|
||||
private static function sanitizeAndResolve(string $html, Media $media): string
|
||||
{
|
||||
$dom = new DOMDocument('1.0', 'UTF-8');
|
||||
libxml_use_internal_errors(true);
|
||||
$dom->loadHTML('<?xml encoding="UTF-8"><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
$source = new DOMDocument('1.0', 'UTF-8');
|
||||
$clean = new DOMDocument('1.0', 'UTF-8');
|
||||
$cleanBody = $clean->createElement('body');
|
||||
$clean->appendChild($cleanBody);
|
||||
|
||||
$body = $dom->getElementsByTagName('body')->item(0);
|
||||
if (!$body instanceof DOMElement) {
|
||||
$previousUseInternalErrors = libxml_use_internal_errors(true);
|
||||
$source->loadHTML('<?xml encoding="UTF-8"><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousUseInternalErrors);
|
||||
|
||||
$sourceBody = $source->getElementsByTagName('body')->item(0);
|
||||
if (!$sourceBody instanceof DOMElement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
self::processNode($body, $media);
|
||||
self::appendSanitizedChildren($sourceBody, $cleanBody, $clean, $media);
|
||||
|
||||
$out = '';
|
||||
foreach ($body->childNodes as $child) {
|
||||
$out .= $dom->saveHTML($child);
|
||||
for ($i = 0; $i < $cleanBody->childNodes->length; $i++) {
|
||||
$child = $cleanBody->childNodes->item($i);
|
||||
if ($child !== null) {
|
||||
$out .= $clean->saveHTML($child);
|
||||
}
|
||||
}
|
||||
|
||||
return trim($out);
|
||||
}
|
||||
|
||||
private static function processNode(DOMNode $parent, Media $media): void
|
||||
private static function appendSanitizedChildren(DOMNode $sourceParent, DOMNode $targetParent, DOMDocument $target, Media $media): void
|
||||
{
|
||||
for ($i = $parent->childNodes->length - 1; $i >= 0; $i--) {
|
||||
$child = $parent->childNodes->item($i);
|
||||
if ($child === null) {
|
||||
continue;
|
||||
$children = [];
|
||||
for ($i = 0; $i < $sourceParent->childNodes->length; $i++) {
|
||||
$child = $sourceParent->childNodes->item($i);
|
||||
if ($child !== null) {
|
||||
$children[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($children as $child) {
|
||||
if ($child instanceof DOMComment) {
|
||||
$parent->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($child instanceof DOMText) {
|
||||
$targetParent->appendChild($target->createTextNode($child->nodeValue ?? ''));
|
||||
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);
|
||||
}
|
||||
self::appendSanitizedElement($child, $targetParent, $target, $media);
|
||||
}
|
||||
}
|
||||
|
||||
private static function sanitizeAttributes(DOMElement $element, Media $media): void
|
||||
private static function appendSanitizedElement(DOMElement $sourceElement, DOMNode $targetParent, DOMDocument $target, 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);
|
||||
$tag = strtolower($sourceElement->tagName);
|
||||
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
|
||||
self::appendSanitizedChildren($sourceElement, $targetParent, $target, $media);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tag === 'img') {
|
||||
$image = self::buildSanitizedImage($sourceElement, $target, $media);
|
||||
if ($image !== null) {
|
||||
$targetParent->appendChild($image);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$cleanElement = $target->createElement($tag);
|
||||
self::sanitizeAttributes($sourceElement, $cleanElement);
|
||||
$targetParent->appendChild($cleanElement);
|
||||
self::appendSanitizedChildren($sourceElement, $cleanElement, $target, $media);
|
||||
}
|
||||
|
||||
private static function sanitizeAttributes(DOMElement $sourceElement, DOMElement $targetElement): void
|
||||
{
|
||||
if ($targetElement->tagName !== 'a') {
|
||||
return;
|
||||
}
|
||||
|
||||
$href = self::sanitizeHref((string) $sourceElement->getAttribute('href'));
|
||||
if ($href !== null) {
|
||||
$targetElement->setAttribute('href', $href);
|
||||
$targetElement->setAttribute('rel', 'noopener noreferrer');
|
||||
if (preg_match('~^https?://~i', $href) === 1) {
|
||||
$targetElement->setAttribute('target', '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
|
||||
if ($title !== null) {
|
||||
$targetElement->setAttribute('title', $title);
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildSanitizedImage(DOMElement $sourceElement, DOMDocument $target, Media $media): ?DOMElement
|
||||
{
|
||||
$src = trim((string) $sourceElement->getAttribute('src'));
|
||||
if ($src === '' || !str_starts_with($src, 'media:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fileName = substr($src, 6);
|
||||
if ($fileName === '' || preg_match('/[\x00-\x1F\x7F]/u', $fileName) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = $media->findByFileName($fileName);
|
||||
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');
|
||||
$image = $target->createElement('img');
|
||||
$image->setAttribute('src', (string) $item['url']);
|
||||
$image->setAttribute('loading', 'lazy');
|
||||
$image->setAttribute('decoding', 'async');
|
||||
|
||||
if ($sourceElement->hasAttribute('alt')) {
|
||||
$image->setAttribute('alt', self::sanitizeAttributeValue((string) $sourceElement->getAttribute('alt'), true) ?? '');
|
||||
} elseif ((string) $item['alt'] !== '') {
|
||||
$image->setAttribute('alt', (string) $item['alt']);
|
||||
} else {
|
||||
$image->setAttribute('alt', '');
|
||||
}
|
||||
|
||||
$title = self::sanitizeAttributeValue((string) $sourceElement->getAttribute('title'));
|
||||
if ($title !== null) {
|
||||
$image->setAttribute('title', $title);
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
private static function sanitizeHref(string $href): ?string
|
||||
{
|
||||
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
if ($href === '' || preg_match('/[\x00-\x1F\x7F]/u', $href) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('~^(https?://|mailto:|tel:)~i', $href) === 1) {
|
||||
return $href;
|
||||
}
|
||||
|
||||
if (self::isSafeRelativeHref($href)) {
|
||||
return $href;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function isSafeRelativeHref(string $href): bool
|
||||
{
|
||||
if ($href === '/') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_starts_with($href, '//')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('~^(?:/[^/]|\./|\.\./|#|\?)~', $href) === 1;
|
||||
}
|
||||
|
||||
private static function sanitizeAttributeValue(string $value, bool $allowEmpty = false): ?string
|
||||
{
|
||||
$value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$value = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value));
|
||||
|
||||
if ($value === '' && !$allowEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function normalizeMarkdown(string $markdown): string
|
||||
@@ -175,18 +251,4 @@ class MarkdownService
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<h1 class="page-title" id="dashboard-title">Tableau de bord</h1>
|
||||
|
||||
<div class="page-actions">
|
||||
<a class="button" href="{{ @BASE }}/dashboard/posts/create">Nouvel article</a>
|
||||
<a class="button button--ghost" href="{{ @BASE }}/dashboard/media">Médiathèque</a>
|
||||
<a class="button" href="{{ 'post_create' | alias }}">Nouvel article</a>
|
||||
<a class="button button--ghost" href="{{ 'media_index' | alias }}">Médiathèque</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<section class="stack-lg" aria-labelledby="media-title">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title" id="media-title">Médiathèque</h1>
|
||||
<p class="field-help">Parcourir les images par page évite de charger toute la bibliothèque d'un coup.</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<a class="button button--ghost" href="{{ @BASE }}/dashboard">Retour</a>
|
||||
<a class="button button--ghost" href="{{ 'dashboard' | alias }}">Retour</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="panel stack" method="post" action="{{ @BASE }}/dashboard/media" enctype="multipart/form-data">
|
||||
<form class="panel stack" method="post" action="{{ 'media_upload' | alias }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<label class="field">
|
||||
<span class="field-label">Nouvelle image</span>
|
||||
<input class="control" type="file" name="image" accept="image/jpeg,image/png,image/webp" required>
|
||||
<span class="field-help">Formats acceptés : JPG, PNG, WebP.</span>
|
||||
<span class="field-help">Formats acceptés : JPG, PNG, WebP. Limite : 10 Mo, 8000 × 8000 px et 40 mégapixels.</span>
|
||||
</label>
|
||||
<button class="button" type="submit">Envoyer</button>
|
||||
</form>
|
||||
@@ -24,6 +27,7 @@
|
||||
<include href="partials/media_card.html" />
|
||||
</repeat>
|
||||
</div>
|
||||
<include href="partials/pagination.html" />
|
||||
</true>
|
||||
<false>
|
||||
<section class="empty-state" aria-labelledby="media-empty-title">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h1 class="page-title" id="post-form-title">{{ @pageTitle }}</h1>
|
||||
|
||||
<div class="page-actions">
|
||||
<a class="button button--ghost" href="{{ @BASE }}/dashboard">Retour</a>
|
||||
<a class="button button--ghost" href="{{ 'dashboard' | alias }}">Retour</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -94,6 +94,11 @@
|
||||
</button>
|
||||
</repeat>
|
||||
</div>
|
||||
<check if="{{ @mediaPickerTruncated }}">
|
||||
<true>
|
||||
<p class="field-help">Affichage limité aux {{ @mediaPickerLimit }} images les plus récentes sur {{ @mediaCount }}. Utilise la <a href="{{ 'media_index' | alias }}">médiathèque</a> pour parcourir toute la bibliothèque.</p>
|
||||
</true>
|
||||
</check>
|
||||
</true>
|
||||
<false>
|
||||
<section class="empty-state" aria-labelledby="media-picker-empty-title">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h1 class="page-title" id="login-title">Connexion</h1>
|
||||
</header>
|
||||
|
||||
<form class="stack" method="post" action="{{ @BASE }}/login">
|
||||
<form class="stack" method="post" action="{{ 'login_submit' | alias }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
|
||||
<label class="field">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ @errorTitle ?: 'Erreur' }}</title>
|
||||
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ @BASE }}/min/app.css">
|
||||
<link rel="stylesheet" href="{{ 'asset', 'file=app.css' | alias }}">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page error-page">
|
||||
@@ -15,7 +15,7 @@
|
||||
<h1 class="error-page__title">{{ @errorTitle ?: 'Erreur' }}</h1>
|
||||
<p class="error-page__message">{{ @errorMessage ?: 'Une erreur est survenue.' }}</p>
|
||||
<p class="error-page__hint">Vérifie l’adresse ou reviens à l’accueil.</p>
|
||||
<p class="error-page__actions"><a class="button" href="{{ @BASE }}/">Retour à l’accueil</a></p>
|
||||
<p class="error-page__actions"><a class="button" href="{{ 'home' | alias }}">Retour à l’accueil</a></p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<title>{{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }}</title>
|
||||
<meta name="description" content="{{ @app.tagline }}">
|
||||
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ @BASE }}/min/app.css">
|
||||
<script defer src="{{ @BASE }}/min/app.js"></script>
|
||||
<link rel="stylesheet" href="{{ 'asset', 'file=app.css' | alias }}">
|
||||
<script defer src="{{ 'asset', 'file=app.js' | alias }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<include href="partials/site_navigation.html" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="card-body article-card__body">
|
||||
<p class="meta-text">{{ @item.width }} × {{ @item.height }}<br>{{ @item.created_at_label }}</p>
|
||||
|
||||
<form class="stack" method="post" action="{{ @BASE }}/dashboard/media/{{ @item.id }}/alt">
|
||||
<form class="stack" method="post" action="{{ 'media_update_alt', 'id='.@item.id | alias }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<label class="field">
|
||||
<span class="field-label">Texte alternatif</span>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="button button--ghost" type="button" data-copy-text="{{ @item.markdown }}" data-markdown-template="">Copier le Markdown</button>
|
||||
<form method="post" action="{{ @BASE }}/dashboard/media/{{ @item.id }}/delete" data-confirm="Supprimer cette image ?">
|
||||
<form method="post" action="{{ 'media_delete', 'id='.@item.id | alias }}" data-confirm="Supprimer cette image ?">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<button class="button button--danger" type="submit">Supprimer</button>
|
||||
</form>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<check if="{{ @currentUser }}">
|
||||
<true>
|
||||
<li class="nav-items__item">
|
||||
<a class="nav-items__link" href="{{ @BASE }}/dashboard">Dashboard</a>
|
||||
<a class="nav-items__link" href="{{ 'dashboard' | alias }}">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-items__item">
|
||||
<form class="nav-items__form" method="post" action="{{ @BASE }}/logout">
|
||||
<form class="nav-items__form" method="post" action="{{ 'logout' | alias }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<button class="nav-items__button" type="submit">Déconnexion</button>
|
||||
</form>
|
||||
@@ -13,7 +13,7 @@
|
||||
</true>
|
||||
<false>
|
||||
<li class="nav-items__item">
|
||||
<a class="nav-items__link" href="{{ @BASE }}/login">Connexion</a>
|
||||
<a class="nav-items__link" href="{{ 'login' | alias }}">Connexion</a>
|
||||
</li>
|
||||
</false>
|
||||
</check>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
<check if="{{ @pagination.page > 1 }}">
|
||||
<true>
|
||||
<a class="button button--ghost" href="{{ @paginationBase }}?page={{ @pagination.page - 1 }}">Précédent</a>
|
||||
<a class="button button--ghost" href="{{ @paginationAlias | alias }}?page={{ @pagination.page - 1 }}">Précédent</a>
|
||||
</true>
|
||||
<false>
|
||||
<span class="button button--ghost pagination__disabled">Précédent</span>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<check if="{{ @pagination.page < @pagination.pages }}">
|
||||
<true>
|
||||
<a class="button button--ghost" href="{{ @paginationBase }}?page={{ @pagination.page + 1 }}">Suivant</a>
|
||||
<a class="button button--ghost" href="{{ @paginationAlias | alias }}?page={{ @pagination.page + 1 }}">Suivant</a>
|
||||
</true>
|
||||
<false>
|
||||
<span class="button button--ghost pagination__disabled">Suivant</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<article class="card article-card">
|
||||
<a class="card-media-link" href="{{ @BASE }}/posts/{{ @post.slug }}">
|
||||
<a class="card-media-link" href="{{ 'post_show', 'slug='.@post.slug | alias }}">
|
||||
<check if="{{ @post.cover_url }}">
|
||||
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
|
||||
<false>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<article class="card article-card">
|
||||
<a class="card-media-link" href="{{ @BASE }}/dashboard/posts/{{ @post.id }}/edit">
|
||||
<a class="card-media-link" href="{{ 'post_edit', 'id='.@post.id | alias }}">
|
||||
<check if="{{ @post.cover_url }}">
|
||||
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
|
||||
<false>
|
||||
@@ -17,10 +17,9 @@
|
||||
</p>
|
||||
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||
<div class="card-actions">
|
||||
<a class="button button--ghost" href="{{ @BASE }}/posts/{{ @post.slug }}">Voir</a>
|
||||
<a class="button button--ghost" href="{{ @BASE }}/dashboard/posts/{{ @post.id }}/edit">Modifier</a>
|
||||
<form method="post" action="{{ @BASE }}/dashboard/posts/{{ @post.id }}/delete"
|
||||
data-confirm="Supprimer cet article ?">
|
||||
<a class="button button--ghost" href="{{ 'post_show', 'slug='.@post.slug | alias }}">Voir</a>
|
||||
<a class="button button--ghost" href="{{ 'post_edit', 'id='.@post.id | alias }}">Modifier</a>
|
||||
<form method="post" action="{{ 'post_delete', 'id='.@post.id | alias }}" data-confirm="Supprimer cet article ?">
|
||||
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||
<button class="button button--danger" type="submit">Supprimer</button>
|
||||
</form>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<a class="site-brand__title" href="{{ @BASE }}/">{{ @app.name }}</a>
|
||||
<a class="site-brand__title" href="{{ 'home' | alias }}">{{ @app.name }}</a>
|
||||
|
||||
@@ -10,10 +10,10 @@ app.name=F3 Simple Blog
|
||||
app.tagline=Blog simple avec Fat-Free Framework.
|
||||
|
||||
[routes]
|
||||
GET @asset: /min/@file=AssetController->serve
|
||||
GET @asset: /min/@file=AssetController->serve, 86400
|
||||
|
||||
GET @home: /=SiteController->home
|
||||
GET @post_show: /posts/@slug=SiteController->show
|
||||
GET @home: /=SiteController->home, 300
|
||||
GET @post_show: /posts/@slug=SiteController->show, 3600
|
||||
|
||||
GET @login: /login=AuthController->show
|
||||
POST @login_submit: /login=AuthController->login
|
||||
|
||||
@@ -10,5 +10,4 @@ services:
|
||||
- ./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
|
||||
|
||||
2
composer.lock
generated
2
composer.lock
generated
@@ -59,7 +59,7 @@
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": ">=8.1"
|
||||
"php": ">=8.3"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
|
||||
Reference in New Issue
Block a user