first commit
This commit is contained in:
61
.dockerignore
Normal file
61
.dockerignore
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ============================================
|
||||||
|
# Environnement & Configuration
|
||||||
|
# ============================================
|
||||||
|
.env
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Dépendances Composer
|
||||||
|
# ============================================
|
||||||
|
# Reconstruites dans l'image par composer install
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Dépendances NPM
|
||||||
|
# ============================================
|
||||||
|
# Reconstruites dans l'image par npm install
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Assets générés
|
||||||
|
# ============================================
|
||||||
|
# Reconstruits dans l'image par npm run build
|
||||||
|
public/assets/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Données persistantes (bind mounts)
|
||||||
|
# ============================================
|
||||||
|
# Montés depuis l'hôte au démarrage — ne pas inclure dans l'image
|
||||||
|
data/
|
||||||
|
public/media/
|
||||||
|
database/*.sqlite
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Cache & Logs
|
||||||
|
# ============================================
|
||||||
|
var/
|
||||||
|
coverage/
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan/
|
||||||
|
.phpunit.result.cache
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Tests & Documentation
|
||||||
|
# ============================================
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Versioning
|
||||||
|
# ============================================
|
||||||
|
.git/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# IDE & OS
|
||||||
|
# ============================================
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
45
.env.example
Normal file
45
.env.example
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Général
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Environnement de l'application (development, test ou production)
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
|
# Nom du blog (utilisé dans le flux RSS et les emails)
|
||||||
|
APP_NAME="Slim Blog"
|
||||||
|
|
||||||
|
# URL de base de l'application (utilisée pour les liens dans les emails et le flux RSS).
|
||||||
|
# Développement : APP_URL=http://localhost:8080
|
||||||
|
# Production : APP_URL=https://blog.exemple.com
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# Fuseau horaire
|
||||||
|
TIMEZONE=Europe/Paris
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Administration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Compte administrateur (créé automatiquement au premier démarrage)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=changeme123
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Email
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
MAIL_HOST=smtp.example.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=noreply@example.com
|
||||||
|
MAIL_PASSWORD=your_smtp_password
|
||||||
|
MAIL_ENCRYPTION=tls
|
||||||
|
MAIL_FROM=noreply@example.com
|
||||||
|
MAIL_FROM_NAME="Slim Blog"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Uploads
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Taille maximale en octets (doit être < upload_max_filesize dans docker/php/php.ini en production)
|
||||||
|
UPLOAD_MAX_SIZE=5242880
|
||||||
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# ============================================
|
||||||
|
# Environnement & Configuration
|
||||||
|
# ============================================
|
||||||
|
.env
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Dépendances Composer
|
||||||
|
# ============================================
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Dépendances NPM
|
||||||
|
# ============================================
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Assets générés
|
||||||
|
# ============================================
|
||||||
|
public/assets/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Base de données
|
||||||
|
# ============================================
|
||||||
|
database/*.sqlite
|
||||||
|
database/*.sqlite-shm
|
||||||
|
database/*.sqlite-wal
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Cache & Logs
|
||||||
|
# ============================================
|
||||||
|
coverage/
|
||||||
|
var/
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan/
|
||||||
|
.phpunit.result.cache
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Media
|
||||||
|
# ============================================
|
||||||
|
public/media/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Docker volumes
|
||||||
|
# ============================================
|
||||||
|
data/
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# IDE & OS
|
||||||
|
# ============================================
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
116
CONTRIBUTING.md
Normal file
116
CONTRIBUTING.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Contribuer au projet
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
Les mêmes que pour le développement (voir [README](README.md)), plus :
|
||||||
|
|
||||||
|
- PHP 8.1+ avec l'extension `dom` (requise par HTMLPurifier et PHPUnit)
|
||||||
|
|
||||||
|
## Lancer les tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
Avec rapport de couverture (nécessite Xdebug ou PCOV) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/phpunit --coverage-text
|
||||||
|
```
|
||||||
|
|
||||||
|
## Analyse statique (PHPStan)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/phpstan analyse
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette commande utilise `phpstan.neon` qui définit le niveau 8 et les chemins analysés. PDO est nativement connu de PHPStan — aucun stub supplémentaire n'est nécessaire.
|
||||||
|
|
||||||
|
PHPStan est configuré au niveau 8, le plus strict. L'exécuter avant toute Pull Request garantit la cohérence des types et détecte les erreurs avant l'exécution.
|
||||||
|
|
||||||
|
## Suite de tests
|
||||||
|
|
||||||
|
Les tests sont dans `tests/`, organisés en miroir de `src/`.
|
||||||
|
|
||||||
|
### `tests/Auth/`
|
||||||
|
|
||||||
|
| Fichier | Classe testée | Ce qui est vérifié |
|
||||||
|
|--------------------------------|---------------------------|---------------------|
|
||||||
|
| `AuthServiceTest` | `AuthService` | `createUser()` (normalisation, unicité via exceptions métier, longueur mdp), `authenticate()`, `changePassword()`, `login/logout/isLoggedIn()` |
|
||||||
|
| `AuthServiceRateLimitTest` | `AuthService` | `checkRateLimit()` (IP libre, verrouillée, expirée, minimum 1 minute, `deleteExpired()`), `recordFailure()` (constantes MAX_ATTEMPTS/LOCK_MINUTES), `resetRateLimit()` |
|
||||||
|
| `LoginAttemptRepositoryTest` | `LoginAttemptRepository` | `findByIp()`, `recordFailure()` (INSERT vs UPDATE, compteur, seuil exact, fenêtre temporelle), `resetForIp()`, `deleteExpired()` |
|
||||||
|
| `PasswordResetServiceTest` | `PasswordResetService` | `requestReset()` (email inconnu silencieux, invalidation, création, envoi, URL), `validateToken()` (inexistant, expiré, valide), `resetPassword()` (token invalide, mdp trop court via `WeakPasswordException`, mise à jour + consommation) |
|
||||||
|
| `PasswordResetRepositoryTest` | `PasswordResetRepository` | `create()`, `findActiveByHash()` (filtre `used_at = null`), `invalidateByUserId()` et `markAsUsed()` (jamais de `delete`) |
|
||||||
|
|
||||||
|
### `tests/User/`
|
||||||
|
|
||||||
|
| Fichier | Classe testée | Ce qui est vérifié |
|
||||||
|
|-----------------------|------------------|---------------------|
|
||||||
|
| `UserTest` | `User` | Construction, validation (username, email, hash, rôle), limites min/max, `fromArray()` |
|
||||||
|
| `UserRepositoryTest` | `UserRepository` | `findAll/ById/ByUsername/ByEmail()`, `create()`, `updatePassword()`, `delete()` |
|
||||||
|
|
||||||
|
### `tests/Shared/`
|
||||||
|
|
||||||
|
| Fichier | Classe testée | Ce qui est vérifié |
|
||||||
|
|-----------------------|------------------|---------------------|
|
||||||
|
| `SessionManagerTest` | `SessionManager` / `SessionManagerInterface` | `isAuthenticated()`, `getUserId()`, rôles, écriture `$_SESSION`, `destroy()` |
|
||||||
|
| `HtmlSanitizerTest` | `HtmlSanitizer` | Balises autorisées conservées, XSS supprimé (`<script>`, handlers JS, `javascript:`, `data:`, `<iframe>`, `<form>`), CSS (`text-align` conservé, reste supprimé) |
|
||||||
|
| `MigratorTest` | `Migrator` | Création de la table `migrations`, idempotence de `run()`, non-rejeu des migrations déjà appliquées, enregistrement en table, `syncFtsIndex()` (indexation des articles absents, absence de doublons) |
|
||||||
|
| `SeederTest` | `Seeder` | Insertion du compte admin quand absent, idempotence (pas d'INSERT si le compte existe), normalisation username/email, hachage bcrypt, format `created_at` |
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Mocks
|
||||||
|
|
||||||
|
Les services métier (`AuthService`, `PasswordResetService`, `PostService`) utilisent les **interfaces** comme type des dépendances. Mocker l'interface plutôt que la classe concrète :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Correct
|
||||||
|
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||||
|
|
||||||
|
// ❌ À éviter
|
||||||
|
$repo = $this->createMock(UserRepository::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests de repositories (`UserRepositoryTest`, etc.) testent l'implémentation concrète avec un mock PDO — c'est intentionnel.
|
||||||
|
|
||||||
|
### Exceptions métier
|
||||||
|
|
||||||
|
Les erreurs métier doivent lever l'exception la plus spécifique disponible :
|
||||||
|
|
||||||
|
| Situation | Exception | Namespace |
|
||||||
|
|------------------------------------|-----------------------------------|------------------------------|
|
||||||
|
| Nom d'utilisateur déjà pris | `DuplicateUsernameException` | `App\User\Exception` |
|
||||||
|
| Email déjà utilisé | `DuplicateEmailException` | `App\User\Exception` |
|
||||||
|
| Mot de passe trop court | `WeakPasswordException` | `App\User\Exception` |
|
||||||
|
| Entité introuvable en base | `NotFoundException` | `App\Shared\Exception` |
|
||||||
|
|
||||||
|
### Ajouter un test
|
||||||
|
|
||||||
|
- **Nommage** : `test` + description camelCase français (`testCreateUserNomDejaUtilise`)
|
||||||
|
- **Structure** : Arrange / Act / Assert, une assertion logique par test
|
||||||
|
- **Isolation** : dépendances toujours mockées via leur interface
|
||||||
|
- **PHPDoc** : chaque méthode de test est documentée avec une ligne décrivant le comportement attendu
|
||||||
|
|
||||||
|
### Ajouter un domaine
|
||||||
|
|
||||||
|
Voir la section *Domaines PHP* dans [Architecture](docs/ARCHITECTURE.md). Lors de l'ajout d'un domaine, créer systématiquement :
|
||||||
|
|
||||||
|
1. L'entité (`NomDomaine/NomEntite.php`)
|
||||||
|
2. L'interface du dépôt (`NomDomaine/NomEntiteRepositoryInterface.php`)
|
||||||
|
3. L'implémentation du dépôt (`NomDomaine/NomEntiteRepository.php implements NomEntiteRepositoryInterface`)
|
||||||
|
4. Les exceptions métier si nécessaires (`NomDomaine/Exception/`)
|
||||||
|
5. Les tests dans `tests/NomDomaine/`
|
||||||
|
|
||||||
|
## Formatage du code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/php-cs-fixer fix
|
||||||
|
```
|
||||||
|
|
||||||
|
Prévisualiser sans appliquer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/php-cs-fixer fix --dry-run
|
||||||
|
```
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 NETig
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
177
README.md
Normal file
177
README.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Slim Blog
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
Blog multi-utilisateurs modulaire développé avec Slim 4. Les domaines `Auth`, `Category`, `Media`, `User`
|
||||||
|
et `Shared` sont indépendants du domaine métier et réutilisables sans modification pour d'autres
|
||||||
|
projets (boutique, portfolio…).
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- **Articles** — création, édition, suppression avec éditeur WYSIWYG, slugs stables
|
||||||
|
- **Catégories** — filtrage sur la page d'accueil et dans l'interface admin
|
||||||
|
- **Médias** — upload WebP avec déduplication SHA-256
|
||||||
|
- **Recherche** — full-text FTS5 cumulable avec le filtre catégorie
|
||||||
|
- **Comptes** — trois rôles (`user`, `editor`, `admin`), réinitialisation de mot de passe par email
|
||||||
|
- **RSS** — flux 2.0 des 20 derniers articles (`/rss.xml`)
|
||||||
|
- **Protection brute-force** — verrouillage par IP après trop de tentatives échouées sur la connexion et la réinitialisation de mot de passe
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Rôle | Librairie |
|
||||||
|
|-------------------|------------------------------------------------------------|
|
||||||
|
| Framework HTTP | [Slim 4](https://www.slimframework.com) |
|
||||||
|
| Base de données | [SQLite](https://sqlite.org) via PDO natif |
|
||||||
|
| Templates | [Twig](https://twig.symfony.com) |
|
||||||
|
| CSS | [Sass](https://sass-lang.com) (7-1, BEM) |
|
||||||
|
| Éditeur WYSIWYG | [Trumbowyg](https://alex-d.github.io/Trumbowyg/) |
|
||||||
|
| Emails | [PHPMailer](https://github.com/PHPMailer/PHPMailer) |
|
||||||
|
| Logging | [Monolog](https://github.com/Seldaek/monolog) |
|
||||||
|
| Sanitisation HTML | [HTMLPurifier](http://htmlpurifier.org) |
|
||||||
|
| Protection CSRF | [Slim CSRF](https://github.com/slimphp/Slim-Csrf) |
|
||||||
|
| Injection de dépendances | [PHP-DI](https://php-di.org) (autowiring) |
|
||||||
|
| Analyse statique | [PHPStan](https://phpstan.org) (niveau 8) |
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
**Prérequis :** PHP 8.1+ avec `pdo_sqlite`, `intl`, `fileinfo`, `gd` (WebP), `xml` (dom), Composer, Node.js 18+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.netig.net/netig/slim-blog
|
||||||
|
cd slim-blog
|
||||||
|
composer install
|
||||||
|
npm install && npm run build
|
||||||
|
cp .env.example .env
|
||||||
|
php -S localhost:8080 -t public
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour surveiller les modifications SCSS et recompiler automatiquement en développement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run watch
|
||||||
|
```
|
||||||
|
|
||||||
|
> `npm run watch` est réservé au développement local. En production, le CSS est compilé une fois lors du build Docker.
|
||||||
|
|
||||||
|
Pour tester l'upload de fichiers, augmenter les limites PHP :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -S localhost:8080 -t public -d upload_max_filesize=6M -d post_max_size=8M
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si `upload_max_filesize` est trop basse, PHP abandonne l'intégralité du corps POST — fichier
|
||||||
|
> **et** tokens CSRF — ce qui provoque une erreur générique sans message explicite.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
**Prérequis :** Docker, Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.netig.net/netig/slim-blog
|
||||||
|
cd slim-blog
|
||||||
|
cp .env.example .env
|
||||||
|
# Définir APP_ENV=production, APP_URL, ADMIN_PASSWORD et la configuration SMTP
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
> Le démarrage en production avec `ADMIN_PASSWORD=changeme123` est bloqué intentionnellement.
|
||||||
|
|
||||||
|
> `--build` est superflu sur une machine vierge mais garantit une image à jour dans tous les cas.
|
||||||
|
|
||||||
|
**Note sur le `.env` :** `docker-compose.yml` monte le fichier `.env` physiquement dans le conteneur
|
||||||
|
(`- ./.env:/var/www/app/.env:ro`). Ce mount est nécessaire car `phpdotenv` lit un fichier sur le
|
||||||
|
disque via `file_get_contents` — `env_file` seul ne suffit pas.
|
||||||
|
|
||||||
|
Au premier démarrage, l'entrypoint initialise automatiquement `./data/` sur l'hôte :
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── public/ # assets compilés, index.php — servis par Nginx
|
||||||
|
│ └── media/ # uploads utilisateurs — persistés entre redéploiements
|
||||||
|
├── database/ # migrations + app.sqlite
|
||||||
|
└── var/ # cache Twig/HTMLPurifier, logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour mettre à jour le site après un changement de code :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs Docker
|
||||||
|
|
||||||
|
Les erreurs PHP remontent dans `docker compose logs` grâce à `error_log = /dev/stderr` dans
|
||||||
|
`docker/php/php.ini`. Sans ce réglage, les erreurs des workers FPM ne sont pas transmises au
|
||||||
|
process principal et n'apparaissent pas dans les logs Docker.
|
||||||
|
|
||||||
|
### Durcissement HTTP
|
||||||
|
|
||||||
|
`docker/php/php.ini` désactive l'en-tête `X-Powered-By` (`expose_php = Off`) et renomme le cookie de session de `PHPSESSID` en `sid` pour ne pas exposer la stack technique.
|
||||||
|
|
||||||
|
`docker/nginx/default.conf` ajoute quatre en-têtes de sécurité sur toutes les réponses :
|
||||||
|
|
||||||
|
| En-tête | Valeur | Protection |
|
||||||
|
|---------|--------|------------|
|
||||||
|
| `X-Frame-Options` | `SAMEORIGIN` | Clickjacking |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` | Sniffing MIME |
|
||||||
|
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Fuite d'URL |
|
||||||
|
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | APIs navigateur |
|
||||||
|
|
||||||
|
### Derrière un reverse proxy (Caddy, Nginx…)
|
||||||
|
|
||||||
|
Nginx écoute sur `127.0.0.1:8888` (câblé dans `docker-compose.yml`).
|
||||||
|
|
||||||
|
Exemple de configuration Caddy :
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
https://blog.exemple.com {
|
||||||
|
header Strict-Transport-Security max-age=31536000;
|
||||||
|
reverse_proxy localhost:8888
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
| Variable | Description | Exemple |
|
||||||
|
|-------------------|--------------------------------------------------------------------------|----------------------------|
|
||||||
|
| `APP_ENV` | `development` ou `production` | `production` |
|
||||||
|
| `APP_URL` | URL de base (liens emails, flux RSS) — inclure le port en développement | `http://localhost:8080` |
|
||||||
|
| `APP_NAME` | Nom du blog (flux RSS, emails) | `Slim Blog` |
|
||||||
|
| `TIMEZONE` | Fuseau horaire PHP | `Europe/Paris` |
|
||||||
|
| `ADMIN_USERNAME` | Nom d'utilisateur du compte admin | `admin` |
|
||||||
|
| `ADMIN_EMAIL` | Email du compte admin | `admin@example.com` |
|
||||||
|
| `ADMIN_PASSWORD` | Mot de passe admin (obligatoire en production) | *(à changer)* |
|
||||||
|
| `MAIL_HOST` | Serveur SMTP | `smtp.example.com` |
|
||||||
|
| `MAIL_PORT` | Port SMTP (`587` TLS, `465` SSL) | `587` |
|
||||||
|
| `MAIL_USERNAME` | Identifiant SMTP | `noreply@example.com` |
|
||||||
|
| `MAIL_PASSWORD` | Mot de passe SMTP | *(à renseigner)* |
|
||||||
|
| `MAIL_ENCRYPTION` | `tls` ou `ssl` | `tls` |
|
||||||
|
| `MAIL_FROM` | Adresse expéditeur | `noreply@example.com` |
|
||||||
|
| `MAIL_FROM_NAME` | Nom expéditeur | `Slim Blog` |
|
||||||
|
| `UPLOAD_MAX_SIZE` | Taille max upload en octets | `5242880` |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Guide technique](docs/GUIDE.md) — bases PHP, architecture en domaines, faire évoluer le projet
|
||||||
|
- [Architecture](docs/ARCHITECTURE.md) — référence concise : domaines, schéma BDD, routes, arborescence
|
||||||
|
- [Installation — développement](#développement) — prérequis, lancement local, upload, SCSS
|
||||||
|
- [Installation — production](#production) — Docker, reverse proxy, logs
|
||||||
|
- [Variables d'environnement](#variables-denvironnement)
|
||||||
|
- [Contribuer](CONTRIBUTING.md) — tests, conventions
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Le code source est distribué sous licence [MIT](LICENSE).
|
||||||
|
Le contenu du blog (articles publiés) est soumis à [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/deed.fr).
|
||||||
|
|
||||||
|
|
||||||
|
## Provisioning
|
||||||
|
|
||||||
|
Le provisionnement (migrations + seed admin) peut etre execute explicitement via `php bin/provision.php`.
|
||||||
|
En developpement, il reste activable automatiquement via `APP_AUTO_PROVISION=true`.
|
||||||
|
En production, il est recommande de le lancer separement du runtime HTTP.
|
||||||
13
assets/scss/abstracts/_mixins.scss
Normal file
13
assets/scss/abstracts/_mixins.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@use 'variables' as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Mixins
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// Breakpoint mobile — au-dessous de cette largeur, les composants
|
||||||
|
// basculent en layout colonne (ex: .card)
|
||||||
|
@mixin mobile {
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
assets/scss/abstracts/_variables.scss
Normal file
73
assets/scss/abstracts/_variables.scss
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// =============================================================
|
||||||
|
// Variables — design tokens du projet
|
||||||
|
// =============================================================
|
||||||
|
// Ce fichier est la source de vérité pour toutes les valeurs
|
||||||
|
// de couleur, typographie et espacement utilisées dans le projet.
|
||||||
|
// Modifier une valeur ici la propage à l'ensemble des composants.
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Couleurs principales
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
$color-primary: #007bff;
|
||||||
|
$color-primary-bg: #cce5ff;
|
||||||
|
$color-primary-bg-hover: #b8daff;
|
||||||
|
$color-danger: #dc3545;
|
||||||
|
$color-success-bg: #d4edda;
|
||||||
|
$color-success-border: #c3e6cb;
|
||||||
|
$color-success-text: #155724;
|
||||||
|
$color-danger-bg: #f8d7da;
|
||||||
|
$color-danger-border: #f5c6cb;
|
||||||
|
$color-danger-text: #721c24;
|
||||||
|
$color-warning-bg: #fff3cd;
|
||||||
|
$color-warning-text: #856404;
|
||||||
|
$color-info-bg: #d1ecf1;
|
||||||
|
$color-info-text: #0c5460;
|
||||||
|
$color-card-shadow: rgba(0, 0, 0, 0.07);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Couleurs neutres
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
$color-text: #212529;
|
||||||
|
$color-text-muted: #6c757d;
|
||||||
|
$color-text-subtle: #aaa;
|
||||||
|
$color-bg-light: #f8f9fa;
|
||||||
|
$color-bg-white: #ffffff;
|
||||||
|
$color-bg-initials: #e9ecef;
|
||||||
|
$color-border: #dee2e6;
|
||||||
|
$color-border-light: #ccc;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Typographie
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
$font-family-base: Arial, sans-serif;
|
||||||
|
$font-size-base: 1rem;
|
||||||
|
$font-size-sm: 0.875rem;
|
||||||
|
$font-size-footer: 0.9rem;
|
||||||
|
$font-size-xs: 0.85em;
|
||||||
|
$line-height-base: 1.5;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Espacements
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
$spacing-xs: 0.25rem;
|
||||||
|
$spacing-sm: 0.5rem;
|
||||||
|
$spacing-md: 1rem;
|
||||||
|
$spacing-lg: 1.5rem;
|
||||||
|
$spacing-xl: 2rem;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Bordures
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
$border-radius: 4px;
|
||||||
|
$border-radius-sm: 3px;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Responsive
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
|
$breakpoint-mobile: 600px;
|
||||||
16
assets/scss/base/_reset.scss
Normal file
16
assets/scss/base/_reset.scss
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Reset / base globale
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
color: $color-text;
|
||||||
|
margin: $spacing-xl;
|
||||||
|
}
|
||||||
105
assets/scss/base/_typography.scss
Normal file
105
assets/scss/base/_typography.scss
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Typographie — styles globaux du texte
|
||||||
|
// =============================================================
|
||||||
|
// Échelle typographique de référence pour les éléments HTML sémantiques.
|
||||||
|
// Les composants surchargent ces valeurs si leur contexte l'exige
|
||||||
|
// (ex: .card__title, .error-page__code définissent leur propre taille).
|
||||||
|
// Les styles de contenu éditeur (Trumbowyg) sont dans components/_post.scss.
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Titres
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// h1 : titre d'article (detail.twig) et logo du site (site-header__logo)
|
||||||
|
// h2 : titres de pages et de sections (toutes les vues admin et auth)
|
||||||
|
// h3 : sous-titres de blocs (category-create__title)
|
||||||
|
// h4-h6 : non utilisés dans les templates, définis en filet de sécurité
|
||||||
|
// pour le contenu HTML inséré via Trumbowyg
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0 0 $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-size: $font-size-base;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: $line-height-base;
|
||||||
|
margin: 0 0 $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Liens
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Liens nus sans classe BEM : navigation intra-page, liens de retour,
|
||||||
|
// "Mot de passe oublié ?", liens du footer.
|
||||||
|
// Les liens dans les composants (.card__title-link, .btn…) surchargent.
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $color-primary;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Éléments inline
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// small : hints de formulaire ("Minimum 8 caractères"), métadonnées d'articles
|
||||||
|
small {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// code : slugs dans l'admin (categories/index.twig), URLs dans les médias
|
||||||
|
code {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
background: $color-bg-light;
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pre : blocs de code dans le contenu Trumbowyg
|
||||||
|
pre {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
background: $color-bg-light;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: $line-height-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Séparateurs
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// hr : séparateur dans detail.twig (après l'article) et form.twig (avant les métadonnées)
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid $color-border;
|
||||||
|
margin: $spacing-lg 0;
|
||||||
|
}
|
||||||
24
assets/scss/components/_alerts.scss
Normal file
24
assets/scss/components/_alerts.scss
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Alertes / messages flash
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: $spacing-md;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background: $color-danger-bg;
|
||||||
|
border-color: $color-danger-border;
|
||||||
|
color: $color-danger-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background: $color-success-bg;
|
||||||
|
border-color: $color-success-border;
|
||||||
|
color: $color-success-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
assets/scss/components/_badges.scss
Normal file
38
assets/scss/components/_badges.scss
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Badges — bloc composant .badge pour rôles utilisateur et catégories
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.2rem $spacing-sm;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
|
||||||
|
&--admin {
|
||||||
|
color: $color-warning-text;
|
||||||
|
background: $color-warning-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--editor {
|
||||||
|
color: $color-info-text;
|
||||||
|
background: $color-info-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--user {
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge catégorie — affiché sur les cartes d'articles et les pages de détail
|
||||||
|
// Cliquable : redirige vers la liste filtrée par catégorie
|
||||||
|
&--category {
|
||||||
|
color: $color-primary;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
text-decoration: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-primary-bg-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
assets/scss/components/_buttons.scss
Normal file
57
assets/scss/components/_buttons.scss
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Boutons — bloc composant .btn
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: $color-primary;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background: $color-text-muted;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
background: $color-danger;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--sm {
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modificateur taille — boutons principaux dans les formulaires centrés
|
||||||
|
.btn--lg {
|
||||||
|
padding: 0.75rem $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modificateur largeur — occupe toute la largeur de son conteneur
|
||||||
|
.btn--full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variante autonome du bloc bouton : lien d'action textuel
|
||||||
|
// Utilisé sans la classe .btn (pas un modificateur BEM)
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: $color-primary;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
182
assets/scss/components/_card.scss
Normal file
182
assets/scss/components/_card.scss
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
@use "../abstracts/mixins" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Composant carte — générique
|
||||||
|
// =============================================================
|
||||||
|
// Définit deux blocs BEM :
|
||||||
|
// .card-list — conteneur de liste de cartes
|
||||||
|
// .card — carte individuelle (vignette + contenu)
|
||||||
|
// Aucune référence au domaine métier : les contenus spécifiques
|
||||||
|
// (extrait d'article, prix produit…) sont surchargés dans les
|
||||||
|
// fichiers de page (pages/_home.scss, pages/_shop.scss…).
|
||||||
|
|
||||||
|
// Conteneur de liste de cartes
|
||||||
|
.card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modificateur : fond grisé encadrant les cartes (ex: page d'accueil)
|
||||||
|
// Rend les box-shadow perceptibles en créant un contraste avec le fond blanc des cartes
|
||||||
|
.card-list--contained {
|
||||||
|
background: $color-bg-light;
|
||||||
|
padding: $spacing-md;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carte : vignette à gauche, corps à droite
|
||||||
|
// La vignette est toujours présente (image ou initiales)
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
background: $color-bg-white;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
box-shadow: 0 1px 4px $color-card-shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lien englobant la vignette — pas de décoration, tabindex=-1 pour
|
||||||
|
// éviter la double tabulation (le titre est déjà un lien cliquable)
|
||||||
|
.card__thumb-link {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vignette image
|
||||||
|
.card__thumb {
|
||||||
|
width: 180px;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vignette initiales (affiché quand l'entité n'a pas d'image)
|
||||||
|
// Mêmes dimensions que .card__thumb pour un alignement cohérent
|
||||||
|
.card__initials {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 180px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
background: $color-bg-initials;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper externe : prend la place à droite de la vignette
|
||||||
|
// Organisé en flex colonne pour empiler .card__body et .card__actions
|
||||||
|
.card__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partie textuelle de la carte
|
||||||
|
// max-height contraint le titre + métadonnées + aperçu à la hauteur de la vignette.
|
||||||
|
// overflow:hidden clip l'aperçu si le contenu dépasse — .card__actions est en dehors
|
||||||
|
// de ce conteneur et reste donc toujours visible.
|
||||||
|
.card__body {
|
||||||
|
max-height: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titre de la carte
|
||||||
|
.card__title {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lien du titre — élément BEM dédié plutôt qu'un sélecteur descendant sur <a>
|
||||||
|
.card__title-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Métadonnées (date, auteur, prix…)
|
||||||
|
.card__meta {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Texte court de présentation — clipé par overflow:hidden du parent .card__body.
|
||||||
|
// word-break empêche le débordement de mots longs ou d'URLs sans espace.
|
||||||
|
.card__excerpt {
|
||||||
|
margin: $spacing-sm 0;
|
||||||
|
color: $color-text;
|
||||||
|
line-height: $line-height-base;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
|
// Styles pour le HTML formaté retourné par post_excerpt()
|
||||||
|
// Les listes à puces/numérotées sont compactées pour rester dans l'aperçu
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin: $spacing-xs 0 0 $spacing-sm;
|
||||||
|
padding-left: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: $line-height-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong,
|
||||||
|
b {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
em,
|
||||||
|
i {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zone d'actions (liens, boutons) — aspect défini dans le fichier de page
|
||||||
|
.card__actions {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptation mobile : vignette au-dessus du corps, pleine largeur
|
||||||
|
// max-height est annulé sur .card__body — en disposition colonne l'aperçu
|
||||||
|
// peut s'étendre librement sous la vignette.
|
||||||
|
@include mobile {
|
||||||
|
.card {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__thumb-link {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__thumb,
|
||||||
|
.card__initials {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// En mobile (disposition colonne), le corps s'étend librement
|
||||||
|
.card__body {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titre légèrement réduit pour éviter qu'il soit trop imposant sur petit écran
|
||||||
|
.card__title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
200
assets/scss/components/_forms.scss
Normal file
200
assets/scss/components/_forms.scss
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
@use "../abstracts/mixins" as *;
|
||||||
|
@use "sass:color";
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Formulaires
|
||||||
|
// =============================================================
|
||||||
|
// Convention retenue :
|
||||||
|
// - .form-container est un vrai bloc BEM réutilisable
|
||||||
|
// - .btn et .badge sont aussi des blocs composants
|
||||||
|
// - .u-inline-form et .u-inline-actions sont des utilitaires
|
||||||
|
// de mise en page ponctuelle, volontairement préfixés en u-
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
&__panel {
|
||||||
|
background: $color-bg-white;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__intro {
|
||||||
|
margin: $spacing-sm 0 0;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
margin: $spacing-xs 0 0;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input,
|
||||||
|
&__select,
|
||||||
|
&__textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
background: $color-bg-white;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $color-primary;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__textarea {
|
||||||
|
min-height: 14rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
margin-top: $spacing-lg;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input--disabled {
|
||||||
|
background: $color-bg-light;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container--narrow {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: $spacing-xl auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilitaires de mise en page
|
||||||
|
.u-inline-form {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-inline-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Barre de recherche (.search-bar)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar__input {
|
||||||
|
flex: 1;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
font-family: $font-family-base;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $color-primary;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar__btn {
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
background: $color-primary;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color.scale($color-primary, $lightness: -16%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar__reset {
|
||||||
|
color: $color-text-muted;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
padding: $spacing-xs;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar__info {
|
||||||
|
margin: (-$spacing-sm) 0 $spacing-md;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.search-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar__btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container__panel {
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container__actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
assets/scss/components/_post.scss
Normal file
39
assets/scss/components/_post.scss
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Composant article — page de détail
|
||||||
|
// =============================================================
|
||||||
|
// Styles spécifiques à l'affichage d'un article en pleine page.
|
||||||
|
// Les styles de la carte article en liste sont dans _card.scss.
|
||||||
|
|
||||||
|
// Bloc article — page de détail
|
||||||
|
.post {
|
||||||
|
padding: $spacing-md 0;
|
||||||
|
|
||||||
|
// Métadonnées (date, auteur) — même apparence que .card__meta
|
||||||
|
&__meta {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mention de mise à jour
|
||||||
|
&__updated {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contenu HTML de l'article (généré par Trumbowyg, sanitisé par HTMLPurifier)
|
||||||
|
// word-break empêche le débordement d'URLs ou de mots longs sans espace sur mobile
|
||||||
|
.post__content {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images insérées dans le contenu via Trumbowyg — sélecteur descendant sur <img>
|
||||||
|
// accepté ici car le contenu est généré par un éditeur WYSIWYG et non par des templates
|
||||||
|
.post__content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
margin: $spacing-sm 0;
|
||||||
|
}
|
||||||
39
assets/scss/components/_upload.scss
Normal file
39
assets/scss/components/_upload.scss
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Bloc upload — aperçu et actions sur les médias uploadés
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
.upload {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload__thumb-link {
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload__thumb {
|
||||||
|
display: block;
|
||||||
|
width: 96px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
background: $color-bg-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload__url {
|
||||||
|
display: block;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
13
assets/scss/layout/_footer.scss
Normal file
13
assets/scss/layout/_footer.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Footer
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: $spacing-xl;
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
border-top: 1px solid $color-border-light;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-footer;
|
||||||
|
}
|
||||||
79
assets/scss/layout/_header.scss
Normal file
79
assets/scss/layout/_header.scss
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
@use "../abstracts/mixins" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Header
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
border-bottom: 1px solid $color-border-light;
|
||||||
|
padding: $spacing-md 0;
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__logo {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lien englobant le titre du blog — élément BEM dédié plutôt qu'un sélecteur descendant sur <a>
|
||||||
|
.site-header__logo-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nom d'utilisateur connecté dans le header
|
||||||
|
.site-header__user {
|
||||||
|
margin-right: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Élément d'action cliquable dans le header (lien ou bouton)
|
||||||
|
.site-header__action {
|
||||||
|
text-decoration: underline;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $color-primary;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
margin-right: $spacing-md;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.site-header__inner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__nav {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__action {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer les marges droites — remplacées par le gap de la nav
|
||||||
|
.site-header__user {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
assets/scss/main.scss
Normal file
31
assets/scss/main.scss
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// =============================================================
|
||||||
|
// Point d'entrée — importe tous les partiels dans l'ordre
|
||||||
|
// =============================================================
|
||||||
|
// Les variables sont importées directement par chaque partiel
|
||||||
|
// qui en a besoin via @use '../abstracts/variables' as *.
|
||||||
|
// Ce fichier orchestre uniquement l'ordre de compilation.
|
||||||
|
|
||||||
|
// Abstracts — aucun CSS généré, disponibles via @use dans chaque partiel
|
||||||
|
@use "abstracts/variables" as *;
|
||||||
|
@use "abstracts/mixins" as *;
|
||||||
|
|
||||||
|
// Base — reset et typographie globale
|
||||||
|
@use "base/reset";
|
||||||
|
@use "base/typography";
|
||||||
|
|
||||||
|
// Composants — éléments réutilisables
|
||||||
|
@use "components/buttons";
|
||||||
|
@use "components/alerts";
|
||||||
|
@use "components/badges";
|
||||||
|
@use "components/card";
|
||||||
|
@use "components/forms";
|
||||||
|
@use "components/post";
|
||||||
|
@use "components/upload";
|
||||||
|
|
||||||
|
// Layout — zones structurelles
|
||||||
|
@use "layout/header";
|
||||||
|
@use "layout/footer";
|
||||||
|
|
||||||
|
// Pages — styles spécifiques à chaque vue
|
||||||
|
@use "pages/home";
|
||||||
|
@use "pages/admin";
|
||||||
234
assets/scss/pages/_admin.scss
Normal file
234
assets/scss/pages/_admin.scss
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
@use "../abstracts/mixins" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Pages d'administration
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// Barre de navigation entre les sections admin
|
||||||
|
.admin-nav {
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cellule d'actions dans les tableaux admin (liste d'articles, médias, etc.)
|
||||||
|
// display:flex permet d'utiliser gap en mobile et d'aligner les boutons en desktop.
|
||||||
|
.admin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tableau de gestion (articles, utilisateurs)
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: $spacing-sm;
|
||||||
|
border-bottom: 1px solid $color-border;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: $color-bg-light;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indicateur "(vous)" dans la liste des utilisateurs
|
||||||
|
&__self {
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ligne d'action indisponible (tiret)
|
||||||
|
&__muted {
|
||||||
|
color: $color-text-subtle;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sélecteur de rôle dans la liste des utilisateurs
|
||||||
|
&__role-select {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
background: $color-bg-white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Bloc de création de catégorie (admin)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// Conteneur du formulaire de création
|
||||||
|
.category-create {
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
padding: $spacing-md;
|
||||||
|
background: $color-bg-light;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titre du bloc de création
|
||||||
|
.category-create__title {
|
||||||
|
margin: 0 0 $spacing-md;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disposition horizontale : champ + bouton sur la même ligne
|
||||||
|
.category-create__form {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label englobant le champ texte
|
||||||
|
.category-create__label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champ de saisie du nom
|
||||||
|
.category-create__input {
|
||||||
|
min-width: 260px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indication de génération automatique du slug
|
||||||
|
.category-create__hint {
|
||||||
|
margin: $spacing-sm 0 0;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Responsive — admin-table en mobile
|
||||||
|
// =============================================================
|
||||||
|
// En dessous du breakpoint mobile, le tableau est transformé en liste
|
||||||
|
// de blocs empilés. Chaque cellule affiche son en-tête via data-label.
|
||||||
|
// Les <thead> sont masqués car les labels remplacent les en-têtes.
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.admin-table {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody,
|
||||||
|
tr {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
padding: $spacing-sm;
|
||||||
|
background: $color-bg-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-xs 0;
|
||||||
|
border-bottom: 1px solid $color-border;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label de colonne injecté via data-label
|
||||||
|
&::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// En mobile, les boutons d'actions s'empilent verticalement
|
||||||
|
.admin-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Page d'erreur (404, 500…)
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
.error-page {
|
||||||
|
text-align: center;
|
||||||
|
padding: $spacing-xl 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page__code {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page__message {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Éditeur Trumbowyg
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// Hauteur minimale de la zone de saisie
|
||||||
|
.trumbowyg-box,
|
||||||
|
.trumbowyg-editor {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Largeur identique aux autres champs du formulaire
|
||||||
|
.trumbowyg-box {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Bloc upload — galerie médias
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// Lien englobant la miniature d'aperçu
|
||||||
|
.upload__thumb-link {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Miniature d'aperçu
|
||||||
|
.upload__thumb {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL affichée en police monospace
|
||||||
|
.upload__url {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-text-muted;
|
||||||
|
margin-bottom: $spacing-xs;
|
||||||
|
word-break: break-all;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
58
assets/scss/pages/_home.scss
Normal file
58
assets/scss/pages/_home.scss
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
@use "../abstracts/variables" as *;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Page d'accueil — liste des articles
|
||||||
|
// =============================================================
|
||||||
|
// Les styles de structure des cartes sont dans components/_card.scss.
|
||||||
|
// Ce fichier surcharge uniquement les éléments spécifiques au contexte blog.
|
||||||
|
|
||||||
|
// Lien d'action "Lire la suite →" — élément BEM dédié plutôt qu'un sélecteur
|
||||||
|
// descendant sur <a>, pour respecter BEM et éviter les collisions de styles
|
||||||
|
.card__actions-link {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $color-primary;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Barre de filtre par catégorie
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// Conteneur de la liste de liens de filtre
|
||||||
|
.category-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
padding-bottom: $spacing-md;
|
||||||
|
border-bottom: 1px solid $color-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lien de filtre individuel
|
||||||
|
.category-filter__item {
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
text-decoration: none;
|
||||||
|
color: $color-text-muted;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-primary;
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// État actif — catégorie sélectionnée
|
||||||
|
.category-filter__item--active {
|
||||||
|
color: $color-primary;
|
||||||
|
border-color: $color-primary;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
19
bin/provision.php
Normal file
19
bin/provision.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Shared\Bootstrap;
|
||||||
|
use App\Shared\Database\Provisioner;
|
||||||
|
|
||||||
|
$_ENV['APP_AUTO_PROVISION'] = '0';
|
||||||
|
|
||||||
|
session_status() === PHP_SESSION_ACTIVE || session_start();
|
||||||
|
|
||||||
|
$bootstrap = Bootstrap::create();
|
||||||
|
$container = $bootstrap->initializeInfrastructure();
|
||||||
|
Provisioner::run($container->get(\PDO::class));
|
||||||
|
|
||||||
|
fwrite(STDOUT, "Provisioning termine.\n");
|
||||||
30
composer.json
Normal file
30
composer.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"ezyang/htmlpurifier": "^4.19",
|
||||||
|
"monolog/monolog": "^3.0",
|
||||||
|
"php-di/php-di": "^6.4",
|
||||||
|
"phpmailer/phpmailer": "^6.9",
|
||||||
|
"slim/csrf": "^1.5",
|
||||||
|
"slim/psr7": "^1.0",
|
||||||
|
"slim/slim": "4.*",
|
||||||
|
"slim/twig-view": "^3.4",
|
||||||
|
"twig/twig": "^3.11",
|
||||||
|
"vlucas/phpdotenv": "^5.6"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.94",
|
||||||
|
"phpstan/phpstan": "^1.0",
|
||||||
|
"phpunit/phpunit": "^11.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5908
composer.lock
generated
Normal file
5908
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
195
config/container.php
Normal file
195
config/container.php
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Définitions PHP-DI.
|
||||||
|
*
|
||||||
|
* Ce fichier déclare uniquement ce que l'autowiring ne peut pas résoudre seul :
|
||||||
|
* - Bindings interface → classe concrète
|
||||||
|
* - Classes nécessitant des paramètres scalaires issus de .env ou de chemins filesystem
|
||||||
|
*
|
||||||
|
* Tout le reste (services, repositories, contrôleurs dont toutes les dépendances
|
||||||
|
* sont typées sur des interfaces) est résolu automatiquement par l'autowiring.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Auth\AuthService;
|
||||||
|
use App\Auth\AuthServiceInterface;
|
||||||
|
use App\Auth\LoginAttemptRepository;
|
||||||
|
use App\Auth\LoginAttemptRepositoryInterface;
|
||||||
|
use App\Auth\PasswordResetController;
|
||||||
|
use App\Auth\PasswordResetRepository;
|
||||||
|
use App\Auth\PasswordResetRepositoryInterface;
|
||||||
|
use App\Auth\PasswordResetService;
|
||||||
|
use App\Auth\PasswordResetServiceInterface;
|
||||||
|
use App\Category\CategoryRepository;
|
||||||
|
use App\Category\CategoryRepositoryInterface;
|
||||||
|
use App\Category\CategoryService;
|
||||||
|
use App\Category\CategoryServiceInterface;
|
||||||
|
use App\Media\MediaRepository;
|
||||||
|
use App\Media\MediaRepositoryInterface;
|
||||||
|
use App\Media\MediaService;
|
||||||
|
use App\Media\MediaServiceInterface;
|
||||||
|
use App\Post\PostRepository;
|
||||||
|
use App\Post\PostRepositoryInterface;
|
||||||
|
use App\Post\PostService;
|
||||||
|
use App\Post\PostServiceInterface;
|
||||||
|
use App\Post\RssController;
|
||||||
|
use App\Shared\Config;
|
||||||
|
use App\Shared\Extension\AppExtension;
|
||||||
|
use App\Shared\Html\HtmlPurifierFactory;
|
||||||
|
use App\Shared\Html\HtmlSanitizer;
|
||||||
|
use App\Shared\Html\HtmlSanitizerInterface;
|
||||||
|
use App\Shared\Http\ClientIpResolver;
|
||||||
|
use App\Shared\Http\FlashService;
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use App\Shared\Http\SessionManager;
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use App\Shared\Mail\MailService;
|
||||||
|
use App\Shared\Mail\MailServiceInterface;
|
||||||
|
use App\User\UserRepository;
|
||||||
|
use App\User\UserRepositoryInterface;
|
||||||
|
use App\User\UserService;
|
||||||
|
use App\User\UserServiceInterface;
|
||||||
|
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Level;
|
||||||
|
use Monolog\Logger;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
use function DI\autowire;
|
||||||
|
use function DI\factory;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// ── Bindings interface → implémentation ──────────────────────────────────
|
||||||
|
|
||||||
|
AuthServiceInterface::class => autowire(AuthService::class),
|
||||||
|
PostServiceInterface::class => autowire(PostService::class),
|
||||||
|
UserServiceInterface::class => autowire(UserService::class),
|
||||||
|
CategoryServiceInterface::class => autowire(CategoryService::class),
|
||||||
|
CategoryRepositoryInterface::class => autowire(CategoryRepository::class),
|
||||||
|
MediaRepositoryInterface::class => autowire(MediaRepository::class),
|
||||||
|
PostRepositoryInterface::class => autowire(PostRepository::class),
|
||||||
|
UserRepositoryInterface::class => autowire(UserRepository::class),
|
||||||
|
LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class),
|
||||||
|
PasswordResetRepositoryInterface::class => autowire(PasswordResetRepository::class),
|
||||||
|
PasswordResetServiceInterface::class => autowire(PasswordResetService::class),
|
||||||
|
FlashServiceInterface::class => autowire(FlashService::class),
|
||||||
|
SessionManagerInterface::class => autowire(SessionManager::class),
|
||||||
|
HtmlSanitizerInterface::class => autowire(HtmlSanitizer::class),
|
||||||
|
|
||||||
|
// ── Infrastructure ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
LoggerInterface::class => factory(function (): LoggerInterface {
|
||||||
|
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
|
||||||
|
$logger = new Logger('slim-blog');
|
||||||
|
$level = $isDev ? Level::Debug : Level::Warning;
|
||||||
|
|
||||||
|
$logger->pushHandler(
|
||||||
|
new StreamHandler(dirname(__DIR__) . '/var/logs/app.log', $level)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $logger;
|
||||||
|
}),
|
||||||
|
|
||||||
|
PDO::class => factory(function (): PDO {
|
||||||
|
$pdo = new PDO('sqlite:' . Config::getDatabasePath(), options: [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// strip_tags() PHP exposée comme fonction SQLite pour les triggers FTS5
|
||||||
|
$pdo->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
|
||||||
|
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||||
|
// Attend jusqu'à 3s avant d'échouer sur une contention en écriture
|
||||||
|
$pdo->exec('PRAGMA busy_timeout=3000');
|
||||||
|
// Réduit les fsync sans sacrifier la cohérence en WAL mode
|
||||||
|
$pdo->exec('PRAGMA synchronous=NORMAL');
|
||||||
|
// Active l'application réelle des contraintes de clé étrangère.
|
||||||
|
// Sans ce pragma, les ON DELETE SET NULL / CASCADE déclarés dans les
|
||||||
|
// migrations sont enregistrés dans le schéma mais silencieusement ignorés
|
||||||
|
// à l'exécution — SQLite désactive les FK par défaut pour la compatibilité.
|
||||||
|
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}),
|
||||||
|
|
||||||
|
Twig::class => factory(function (): Twig {
|
||||||
|
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
|
||||||
|
|
||||||
|
return Twig::create(
|
||||||
|
dirname(__DIR__) . '/views',
|
||||||
|
['cache' => Config::getTwigCache($isDev)],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
\HTMLPurifier::class => factory(function (): \HTMLPurifier {
|
||||||
|
return HtmlPurifierFactory::create(dirname(__DIR__) . '/var/cache/htmlpurifier');
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ── Services nécessitant une configuration .env ───────────────────────────
|
||||||
|
|
||||||
|
MailServiceInterface::class => factory(function (Twig $twig): MailServiceInterface {
|
||||||
|
return new MailService(
|
||||||
|
twig: $twig,
|
||||||
|
host: $_ENV['MAIL_HOST'] ?? '',
|
||||||
|
port: (int) ($_ENV['MAIL_PORT'] ?? 587),
|
||||||
|
username: $_ENV['MAIL_USERNAME'] ?? '',
|
||||||
|
password: $_ENV['MAIL_PASSWORD'] ?? '',
|
||||||
|
encryption: strtolower($_ENV['MAIL_ENCRYPTION'] ?? 'tls'),
|
||||||
|
from: $_ENV['MAIL_FROM'] ?? '',
|
||||||
|
fromName: $_ENV['MAIL_FROM_NAME'] ?? 'Slim Blog',
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
MediaServiceInterface::class => factory(
|
||||||
|
function (MediaRepositoryInterface $mediaRepository): MediaServiceInterface {
|
||||||
|
return new MediaService(
|
||||||
|
mediaRepository: $mediaRepository,
|
||||||
|
uploadDir: dirname(__DIR__) . '/public/media',
|
||||||
|
uploadUrl: '/media',
|
||||||
|
maxSize: (int) ($_ENV['UPLOAD_MAX_SIZE'] ?? 5 * 1024 * 1024),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Contrôleurs nécessitant des paramètres scalaires ─────────────────────
|
||||||
|
|
||||||
|
RssController::class => factory(
|
||||||
|
function (PostServiceInterface $postService): RssController {
|
||||||
|
return new RssController(
|
||||||
|
$postService,
|
||||||
|
rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'),
|
||||||
|
$_ENV['APP_NAME'] ?? 'Slim Blog',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
PasswordResetController::class => factory(
|
||||||
|
function (
|
||||||
|
Twig $twig,
|
||||||
|
PasswordResetServiceInterface $passwordResetService,
|
||||||
|
AuthServiceInterface $authService,
|
||||||
|
FlashServiceInterface $flash,
|
||||||
|
): PasswordResetController {
|
||||||
|
return new PasswordResetController(
|
||||||
|
$twig,
|
||||||
|
$passwordResetService,
|
||||||
|
$authService,
|
||||||
|
$flash,
|
||||||
|
rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
ClientIpResolver::class => factory(function (): ClientIpResolver {
|
||||||
|
$trusted = array_filter(array_map('trim', explode(',', (string) ($_ENV['TRUSTED_PROXIES'] ?? ''))));
|
||||||
|
|
||||||
|
return new ClientIpResolver($trusted);
|
||||||
|
}),
|
||||||
|
|
||||||
|
AppExtension::class => factory(function (): AppExtension {
|
||||||
|
return new AppExtension(rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'));
|
||||||
|
}),
|
||||||
|
];
|
||||||
19
database/migrations/001_create_users.php
Normal file
19
database/migrations/001_create_users.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration 001 — Création de la table des utilisateurs.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'up' => "
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
",
|
||||||
|
|
||||||
|
'down' => 'DROP TABLE IF EXISTS users',
|
||||||
|
];
|
||||||
23
database/migrations/002_create_categories.php
Normal file
23
database/migrations/002_create_categories.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration 002 — Création de la table des catégories.
|
||||||
|
*
|
||||||
|
* category_id est une clé étrangère nullable vers category(id).
|
||||||
|
* SET NULL garantit que les articles sont conservés si une catégorie est supprimée.
|
||||||
|
*
|
||||||
|
* SQLite ne supportant pas l'ajout de contrainte FK via ALTER TABLE,
|
||||||
|
* la référence est déclarée inline dans le ADD COLUMN — SQLite l'enregistre
|
||||||
|
* dans le schéma sans l'appliquer strictement à moins que PRAGMA foreign_keys = ON.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'up' => "
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL
|
||||||
|
);
|
||||||
|
",
|
||||||
|
|
||||||
|
'down' => 'DROP TABLE IF EXISTS categories',
|
||||||
|
];
|
||||||
31
database/migrations/003_create_posts.php
Normal file
31
database/migrations/003_create_posts.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration 003 — Création de la table des articles.
|
||||||
|
*
|
||||||
|
* author_id est une clé étrangère nullable vers users(id).
|
||||||
|
* SET NULL garantit que les articles sont conservés si l'auteur est supprimé.
|
||||||
|
*
|
||||||
|
* Index explicite sur author_id : SQLite n'indexe pas automatiquement les FK.
|
||||||
|
* Utilisé par findByUserId() (liste admin) et par les filtres de recherche FTS.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'up' => "
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL DEFAULT '',
|
||||||
|
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
|
||||||
|
",
|
||||||
|
|
||||||
|
'down' => "
|
||||||
|
DROP INDEX IF EXISTS idx_posts_author_id;
|
||||||
|
DROP TABLE IF EXISTS posts;
|
||||||
|
",
|
||||||
|
];
|
||||||
29
database/migrations/004_create_media.php
Normal file
29
database/migrations/004_create_media.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration 004 — Création de la table des médias uploadés.
|
||||||
|
*
|
||||||
|
* La colonne hash (SHA-256) est unique et permet la détection des doublons à l'upload.
|
||||||
|
* user_id est nullable : SET NULL si le compte auteur est supprimé.
|
||||||
|
*
|
||||||
|
* Index explicite sur user_id : SQLite n'indexe pas automatiquement les FK.
|
||||||
|
* Utilisé par findByUserId() dans la galerie média.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'up' => "
|
||||||
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
hash TEXT NOT NULL UNIQUE,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id);
|
||||||
|
",
|
||||||
|
|
||||||
|
'down' => "
|
||||||
|
DROP INDEX IF EXISTS idx_media_user_id;
|
||||||
|
DROP TABLE IF EXISTS media;
|
||||||
|
",
|
||||||
|
];
|
||||||
26
database/migrations/005_create_password_resets.php
Normal file
26
database/migrations/005_create_password_resets.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration 005 — Création de la table de réinitialisation de mot de passe.
|
||||||
|
*
|
||||||
|
* token_hash : hash SHA-256 du token envoyé par email — le token brut
|
||||||
|
* n'est jamais stocké en base pour limiter l'impact d'une fuite.
|
||||||
|
* expires_at : timestamp d'expiration (1 heure après création).
|
||||||
|
* used_at : timestamp de consommation — le token est invalidé après usage
|
||||||
|
* sans être supprimé immédiatement (traçabilité).
|
||||||
|
* user_id : CASCADE — supprime les tokens si l'utilisateur est supprimé.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'up' => "
|
||||||
|
CREATE TABLE IF NOT EXISTS password_resets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
used_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
",
|
||||||
|
|
||||||
|
'down' => 'DROP TABLE IF EXISTS password_resets',
|
||||||
|
];
|
||||||
68
database/migrations/006_create_posts_fts.php
Normal file
68
database/migrations/006_create_posts_fts.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration 006 — Création de l'index FTS5 pour la recherche plein texte.
|
||||||
|
*
|
||||||
|
* Crée une table virtuelle FTS5 `posts_fts` indexant le titre, le contenu
|
||||||
|
* et le nom de l'auteur de chaque article. Le rowid de la table FTS correspond
|
||||||
|
* à l'id de l'article dans `posts`.
|
||||||
|
*
|
||||||
|
* Trois triggers maintiennent la synchronisation automatique entre `posts`
|
||||||
|
* et `posts_fts` à chaque INSERT, UPDATE et DELETE.
|
||||||
|
*
|
||||||
|
* La colonne `author_username` est résolue par sous-requête lors de l'écriture
|
||||||
|
* dans le trigger, ce qui évite de stocker une valeur dénormalisée en dehors
|
||||||
|
* de l'index FTS (dont le seul rôle est la recherche).
|
||||||
|
*
|
||||||
|
* Le contenu HTML est passé par `strip_tags()` (fonction PHP enregistrée sur la
|
||||||
|
* connexion PDO via `sqliteCreateFunction`) avant indexation, afin d'éviter que
|
||||||
|
* les balises et attributs HTML ne polluent l'index et ne génèrent du bruit.
|
||||||
|
*
|
||||||
|
* Tokenizer utilisé : unicode61 (défaut FTS5) — gère les diacritiques et les
|
||||||
|
* caractères non-ASCII, insensible à la casse.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'up' => "
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
author_username,
|
||||||
|
tokenize = 'unicode61 remove_diacritics 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS posts_fts_insert
|
||||||
|
AFTER INSERT ON posts BEGIN
|
||||||
|
INSERT INTO posts_fts(rowid, title, content, author_username)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
NEW.title,
|
||||||
|
COALESCE(strip_tags(NEW.content), ''),
|
||||||
|
COALESCE((SELECT username FROM users WHERE id = NEW.author_id), '')
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS posts_fts_update
|
||||||
|
AFTER UPDATE ON posts BEGIN
|
||||||
|
DELETE FROM posts_fts WHERE rowid = OLD.id;
|
||||||
|
INSERT INTO posts_fts(rowid, title, content, author_username)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
NEW.title,
|
||||||
|
COALESCE(strip_tags(NEW.content), ''),
|
||||||
|
COALESCE((SELECT username FROM users WHERE id = NEW.author_id), '')
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS posts_fts_delete
|
||||||
|
AFTER DELETE ON posts BEGIN
|
||||||
|
DELETE FROM posts_fts WHERE rowid = OLD.id;
|
||||||
|
END;
|
||||||
|
",
|
||||||
|
|
||||||
|
'down' => "
|
||||||
|
DROP TRIGGER IF EXISTS posts_fts_delete;
|
||||||
|
DROP TRIGGER IF EXISTS posts_fts_update;
|
||||||
|
DROP TRIGGER IF EXISTS posts_fts_insert;
|
||||||
|
DROP TABLE IF EXISTS posts_fts;
|
||||||
|
",
|
||||||
|
];
|
||||||
27
database/migrations/007_create_login_attempts.php
Normal file
27
database/migrations/007_create_login_attempts.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration 007 — Table de protection contre le brute-force.
|
||||||
|
*
|
||||||
|
* Stocke les tentatives de connexion échouées par adresse IP.
|
||||||
|
* Le champ `locked_until` est NULL tant que le seuil n'est pas atteint,
|
||||||
|
* puis contient la date/heure ISO 8601 de fin de verrouillage.
|
||||||
|
*
|
||||||
|
* Le nettoyage des entrées expirées est effectué par LoginAttemptRepository
|
||||||
|
* à chaque tentative pour éviter l'accumulation de lignes obsolètes,
|
||||||
|
* sans nécessiter de tâche planifiée.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'up' => "
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
ip TEXT NOT NULL PRIMARY KEY,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
locked_until TEXT DEFAULT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
",
|
||||||
|
|
||||||
|
'down' => "
|
||||||
|
DROP TABLE IF EXISTS login_attempts;
|
||||||
|
",
|
||||||
|
];
|
||||||
25
database/migrations/008_sync_posts_fts_when_users_change.php
Normal file
25
database/migrations/008_sync_posts_fts_when_users_change.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'up' => "
|
||||||
|
DROP TRIGGER IF EXISTS posts_fts_users_delete;
|
||||||
|
DROP TRIGGER IF EXISTS posts_fts_users_update;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS posts_fts_users_update
|
||||||
|
AFTER UPDATE OF username ON users BEGIN
|
||||||
|
DELETE FROM posts_fts
|
||||||
|
WHERE rowid IN (SELECT id FROM posts WHERE author_id = NEW.id);
|
||||||
|
|
||||||
|
INSERT INTO posts_fts(rowid, title, content, author_username)
|
||||||
|
SELECT p.id,
|
||||||
|
p.title,
|
||||||
|
COALESCE(strip_tags(p.content), ''),
|
||||||
|
NEW.username
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.author_id = NEW.id;
|
||||||
|
END;
|
||||||
|
",
|
||||||
|
'down' => "
|
||||||
|
DROP TRIGGER IF EXISTS posts_fts_users_update;
|
||||||
|
",
|
||||||
|
];
|
||||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/php/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# Répertoire de travail de l'entrypoint : reçoit public/ compilé,
|
||||||
|
# puis partagé avec Nginx via le mount ci-dessous.
|
||||||
|
- ./data:/data
|
||||||
|
|
||||||
|
# Base SQLite et migrations : persistés entre redéploiements.
|
||||||
|
- ./data/database:/var/www/app/database
|
||||||
|
|
||||||
|
# Cache Twig/HTMLPurifier et logs : persistés entre redémarrages.
|
||||||
|
- ./data/var:/var/www/app/var
|
||||||
|
|
||||||
|
# Uploads : PHP écrit dans public/media/, Nginx le sert en lecture seule.
|
||||||
|
- ./data/public/media:/var/www/app/public/media
|
||||||
|
|
||||||
|
# phpdotenv requiert un fichier physique sur le disque (Dotenv::createImmutable) ;
|
||||||
|
# `env_file` seul ne suffit pas car il injecte uniquement dans l'environnement
|
||||||
|
# du process sans créer de fichier accessible via file_get_contents.
|
||||||
|
- ./.env:/var/www/app/.env:ro
|
||||||
|
|
||||||
|
# Rend les variables accessibles via getenv() / $_SERVER en dehors de phpdotenv
|
||||||
|
# (scripts CLI, healthchecks…).
|
||||||
|
env_file: .env
|
||||||
|
|
||||||
|
# Vérifie que PHP-FPM écoute sur le port 9000 avant de déclarer le service sain.
|
||||||
|
# bash /dev/tcp est disponible sur l'image Debian php:8.3-fpm sans dépendance
|
||||||
|
# supplémentaire. start_period laisse le temps à entrypoint.sh de terminer
|
||||||
|
# (sync public/, migrations, seed) avant que les échecs ne comptent.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/9000'"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:stable
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8888:80"
|
||||||
|
volumes:
|
||||||
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
# Fichiers statiques servis directement par Nginx sans passer par PHP.
|
||||||
|
- ./data/public:/var/www/app/public:ro
|
||||||
54
docker/nginx/default.conf
Normal file
54
docker/nginx/default.conf
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /var/www/app/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
# ── En-têtes de sécurité HTTP ────────────────────────────────────────────
|
||||||
|
# Empêche le chargement de la page dans une iframe (clickjacking)
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
# Désactive le sniffing MIME : le navigateur respecte le Content-Type déclaré
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
# Limite les informations transmises dans le Referer aux pages externes
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
# Désactive les fonctionnalités navigateur non utilisées
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
# Fichiers statiques servis directement par Nginx, sans passer par PHP.
|
||||||
|
# expires 1y active le cache navigateur longue durée.
|
||||||
|
location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|woff2)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 1y;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bloquer l'exécution de PHP dans le répertoire des uploads.
|
||||||
|
location ~* /media/.*\.php$ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Front controller Slim : toute URL sans fichier correspondant
|
||||||
|
# est renvoyée vers index.php pour être traitée par le routeur.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHP-FPM via réseau Docker interne (port 9000 par défaut).
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass app:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
|
||||||
|
# Transmet les en-têtes du reverse proxy amont (Caddy hôte, etc.)
|
||||||
|
# pour que l'application connaisse l'IP réelle du client et le protocole.
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bloquer l'accès aux fichiers sensibles.
|
||||||
|
location ~ /\.(env|git|htaccess) {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
docker/php/Dockerfile
Normal file
44
docker/php/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# ── Stage 1 : compilation des assets ────────────────────────────────────────
|
||||||
|
FROM node:20-slim AS assets
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# Layer séparé : npm ci n'est relancé que si package-lock.json change.
|
||||||
|
# npm run build est dans un layer distinct : toute modification dans assets/
|
||||||
|
# invalide uniquement ce layer, sans réinstaller les packages.
|
||||||
|
RUN npm ci
|
||||||
|
COPY assets/ assets/
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2 : image PHP de production ───────────────────────────────────────
|
||||||
|
FROM php:8.3-fpm
|
||||||
|
|
||||||
|
# Extensions système et PHP dans un seul layer
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libsqlite3-dev libxml2-dev libonig-dev \
|
||||||
|
libpng-dev libjpeg62-turbo-dev libwebp-dev unzip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& docker-php-ext-configure gd --with-webp --with-jpeg \
|
||||||
|
&& docker-php-ext-install pdo_sqlite mbstring dom fileinfo gd
|
||||||
|
|
||||||
|
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /var/www/app
|
||||||
|
|
||||||
|
# Dépendances Composer — layer mis en cache tant que les lock files n'ont pas changé
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||||
|
|
||||||
|
# Code source + assets compilés depuis le stage 1
|
||||||
|
COPY . .
|
||||||
|
COPY --from=assets /build/public/assets/ public/assets/
|
||||||
|
|
||||||
|
# Archive les migrations hors du WORKDIR : le bind mount ./data/database/
|
||||||
|
# écrase /var/www/app/database/ au démarrage — l'entrypoint recopie depuis ici.
|
||||||
|
RUN cp -r database /database.baked
|
||||||
|
|
||||||
|
COPY --chmod=755 docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
|
CMD ["php-fpm"]
|
||||||
42
docker/php/entrypoint.sh
Normal file
42
docker/php/entrypoint.sh
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# public/ → assets compilés, index.php
|
||||||
|
# Synchronisé à chaque démarrage pour déployer les nouveaux assets.
|
||||||
|
# media/ est exclu : c'est un bind mount séparé contenant les uploads
|
||||||
|
# utilisateurs — le copier dans sa propre destination causerait une erreur.
|
||||||
|
for item in /var/www/app/public/*; do
|
||||||
|
name=$(basename "$item")
|
||||||
|
[ "$name" = "media" ] && continue
|
||||||
|
cp -a "$item" /data/public/
|
||||||
|
done
|
||||||
|
|
||||||
|
# database/ → migrations depuis l'archive baked.
|
||||||
|
# /var/www/app/database/ est un bind mount vide au premier démarrage :
|
||||||
|
# les migrations sont copiées depuis /database.baked/ qui n'est pas monté.
|
||||||
|
# cp -rn préserve app.sqlite sur les déploiements suivants.
|
||||||
|
cp -rn /database.baked/. /var/www/app/database/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Pré-création de public/media/ pour que les permissions soient fixées
|
||||||
|
# ici, en même temps que les autres répertoires persistants, plutôt qu'à
|
||||||
|
# la première requête par Bootstrap::checkDirectories().
|
||||||
|
mkdir -p /var/www/app/public/media
|
||||||
|
|
||||||
|
# Permissions sur les bind mounts : doit s'exécuter en root avant
|
||||||
|
# le démarrage de PHP-FPM. Bootstrap.php crée ensuite les sous-répertoires
|
||||||
|
# (var/cache/twig, var/cache/htmlpurifier, var/logs) à la première requête
|
||||||
|
# avec les bonnes permissions.
|
||||||
|
chown -R www-data:www-data /data /var/www/app/database /var/www/app/var /var/www/app/public/media
|
||||||
|
|
||||||
|
# Invalider les caches compilés à chaque déploiement.
|
||||||
|
# - Twig : les templates compilés peuvent être obsolètes après modification d'une vue
|
||||||
|
# ou d'une extension Twig.
|
||||||
|
# - DI : le container PHP-DI compilé doit être regénéré après tout changement
|
||||||
|
# dans config/container.php. Sans cette ligne, la première requête compile
|
||||||
|
# un container incohérent et toutes les suivantes échouent avec une erreur 500.
|
||||||
|
rm -rf /var/www/app/var/cache/twig/*
|
||||||
|
rm -rf /var/www/app/var/cache/di/*
|
||||||
|
|
||||||
|
# Passe la main à la commande principale (php-fpm) en remplaçant le processus
|
||||||
|
# entrypoint par php-fpm PID 1 — requis pour la gestion des signaux Docker.
|
||||||
|
exec "$@"
|
||||||
14
docker/php/php.ini
Normal file
14
docker/php/php.ini
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
; Limites upload — doivent être cohérentes avec UPLOAD_MAX_SIZE dans .env.
|
||||||
|
upload_max_filesize = 6M
|
||||||
|
post_max_size = 8M
|
||||||
|
|
||||||
|
; Ne pas exposer la version PHP dans l'en-tête X-Powered-By
|
||||||
|
expose_php = Off
|
||||||
|
|
||||||
|
; Remonte les erreurs PHP vers Docker
|
||||||
|
log_errors = On
|
||||||
|
error_log = /dev/stderr
|
||||||
|
|
||||||
|
; Renommer le cookie de session pour éviter le fingerprint PHP (PHPSESSID par défaut)
|
||||||
|
; La valeur doit être synchronisée avec session_name() dans public/index.php si modifiée.
|
||||||
|
session.name = sid
|
||||||
442
docs/ARCHITECTURE.md
Normal file
442
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Domaines PHP
|
||||||
|
|
||||||
|
Chaque domaine dans `src/` est autonome : modèle, interface de dépôt, implémentation du dépôt,
|
||||||
|
contrôleur. Les dépendances inter-domaines sont explicites, minimales et toujours unidirectionnelles
|
||||||
|
(voir § Dépendances inter-domaines ci-dessous).
|
||||||
|
|
||||||
|
| Domaine | Réutilisable | Notes |
|
||||||
|
|-------------|:---:|------------------------------------------------------------------------------|
|
||||||
|
| `User/` | ✅ | Modèle utilisateur, persistance, création de comptes |
|
||||||
|
| `Auth/` | ✅ | Sessions, authentification, reset mot de passe — dépend de `User/` en lecture |
|
||||||
|
| `Category/` | ✅ | Générique — deux lignes à adapter si la table cible change |
|
||||||
|
| `Media/` | ✅ | Upload, déduplication, gestion des fichiers |
|
||||||
|
| `Shared/` | ✅ | Infrastructure complète — rien de métier |
|
||||||
|
| `Post/` | ➡ | Spécifique au blog — dépend de `Category/` en présentation. Remplacer par `Product/` pour une boutique |
|
||||||
|
|
||||||
|
### Dépendances inter-domaines
|
||||||
|
|
||||||
|
Le projet compte deux dépendances explicites entre domaines métier :
|
||||||
|
|
||||||
|
**`Auth/ → User/`** — `AuthService` et `PasswordResetService` consomment `UserRepositoryInterface`
|
||||||
|
pour lire les comptes lors de l'authentification et de la réinitialisation de mot de passe.
|
||||||
|
Unidirectionnelle : `User/` n'importe rien de `Auth/`.
|
||||||
|
|
||||||
|
**`Post/ → Category/`** — `PostController` injecte `CategoryServiceInterface` pour alimenter
|
||||||
|
la liste des catégories dans le formulaire de création/édition d'article. Dépendance de
|
||||||
|
présentation uniquement : `PostService` et `PostRepository` ne connaissent pas `Category/`.
|
||||||
|
Unidirectionnelle : `Category/` n'importe rien de `Post/`.
|
||||||
|
|
||||||
|
### Structure d'un domaine
|
||||||
|
|
||||||
|
Ajouter un nouveau domaine suit toujours le même schéma :
|
||||||
|
|
||||||
|
```
|
||||||
|
src/MonDomaine/
|
||||||
|
├── Exception/ ← Exceptions métier spécifiques
|
||||||
|
│ └── MonErreurException.php
|
||||||
|
├── MonEntite.php ← Modèle immuable
|
||||||
|
├── MonEntiteRepositoryInterface.php ← Contrat de persistance
|
||||||
|
├── MonEntiteRepository.php ← Implémentation PDO (implements l'interface)
|
||||||
|
├── MonEntiteService.php ← Logique métier (dépend de l'interface)
|
||||||
|
└── MonEntiteController.php ← Actions HTTP (dépend du service)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Créer les fichiers selon la structure ci-dessus
|
||||||
|
2. Ajouter le binding interface → classe dans `config/container.php` :
|
||||||
|
`MonEntiteRepositoryInterface::class => autowire(MonEntiteRepository::class)`
|
||||||
|
(les services et contrôleurs dont toutes les dépendances sont typées sur des interfaces
|
||||||
|
sont résolus automatiquement par l'autowiring PHP-DI — aucune factory supplémentaire)
|
||||||
|
3. Déclarer les routes dans `Routes.php`
|
||||||
|
4. Ajouter la migration dans `database/migrations/`
|
||||||
|
|
||||||
|
### Interfaces de dépôts
|
||||||
|
|
||||||
|
Chaque repository implémente son interface. Les services et contrôleurs dépendent uniquement de l'interface, jamais de la classe concrète :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Correct — le service dépend de l'abstraction
|
||||||
|
final class PostService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PostRepositoryInterface $postRepository,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ À éviter — couplage fort à l'implémentation
|
||||||
|
final class PostService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PostRepository $postRepository,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Interface | Implémentation | Domaine |
|
||||||
|
|------------------------------------|---------------------------|------------|
|
||||||
|
| `UserRepositoryInterface` | `UserRepository` | `User/` |
|
||||||
|
| `UserServiceInterface` | `UserService` | `User/` |
|
||||||
|
| `LoginAttemptRepositoryInterface` | `LoginAttemptRepository` | `Auth/` |
|
||||||
|
| `PasswordResetRepositoryInterface` | `PasswordResetRepository` | `Auth/` |
|
||||||
|
| `PasswordResetServiceInterface` | `PasswordResetService` | `Auth/` |
|
||||||
|
| `AuthServiceInterface` | `AuthService` | `Auth/` |
|
||||||
|
| `PostRepositoryInterface` | `PostRepository` | `Post/` |
|
||||||
|
| `PostServiceInterface` | `PostService` | `Post/` |
|
||||||
|
| `CategoryRepositoryInterface` | `CategoryRepository` | `Category/`|
|
||||||
|
| `CategoryServiceInterface` | `CategoryService` | `Category/`|
|
||||||
|
| `MediaRepositoryInterface` | `MediaRepository` | `Media/` |
|
||||||
|
| `MediaServiceInterface` | `MediaService` | `Media/` |
|
||||||
|
| `SessionManagerInterface` | `SessionManager` | `Shared/` |
|
||||||
|
| `MailServiceInterface` | `MailService` | `Shared/` |
|
||||||
|
| `FlashServiceInterface` | `FlashService` | `Shared/` |
|
||||||
|
|
||||||
|
### Exceptions métier
|
||||||
|
|
||||||
|
Les erreurs métier levées intentionnellement utilisent des exceptions dédiées plutôt que des exceptions génériques, ce qui permet aux appelants de les distinguer sans analyser le message :
|
||||||
|
|
||||||
|
| Exception | Namespace | Levée par |
|
||||||
|
|------------------------------|------------------------|--------------------------------------------------------|
|
||||||
|
| `DuplicateUsernameException` | `App\User\Exception` | `UserService::createUser()` |
|
||||||
|
| `DuplicateEmailException` | `App\User\Exception` | `UserService::createUser()` |
|
||||||
|
| `WeakPasswordException` | `App\User\Exception` | `UserService`, `AuthService`, `PasswordResetService` |
|
||||||
|
| `NotFoundException` | `App\Shared\Exception` | `PostService`, `AuthService` |
|
||||||
|
| `FileTooLargeException` | `App\Media\Exception` | `MediaService::store()` |
|
||||||
|
| `InvalidMimeTypeException` | `App\Media\Exception` | `MediaService::store()` |
|
||||||
|
| `StorageException` | `App\Media\Exception` | `MediaService::store()` |
|
||||||
|
|
||||||
|
## Base de données
|
||||||
|
|
||||||
|
```
|
||||||
|
users
|
||||||
|
├── id INTEGER PK AUTOINCREMENT
|
||||||
|
├── username TEXT UNIQUE NOT NULL
|
||||||
|
├── email TEXT UNIQUE NOT NULL
|
||||||
|
├── password_hash TEXT NOT NULL
|
||||||
|
├── role TEXT NOT NULL DEFAULT 'user' ← 'user' | 'editor' | 'admin'
|
||||||
|
└── created_at DATETIME
|
||||||
|
|
||||||
|
categories
|
||||||
|
├── id INTEGER PK AUTOINCREMENT
|
||||||
|
├── name TEXT UNIQUE NOT NULL
|
||||||
|
└── slug TEXT UNIQUE NOT NULL
|
||||||
|
|
||||||
|
posts
|
||||||
|
├── id INTEGER PK AUTOINCREMENT
|
||||||
|
├── title TEXT NOT NULL
|
||||||
|
├── content TEXT NOT NULL
|
||||||
|
├── slug TEXT UNIQUE NOT NULL
|
||||||
|
├── author_id INTEGER → users(id) ON DELETE SET NULL
|
||||||
|
├── category_id INTEGER → categories(id) ON DELETE SET NULL
|
||||||
|
├── created_at DATETIME
|
||||||
|
└── updated_at DATETIME
|
||||||
|
|
||||||
|
media
|
||||||
|
├── id INTEGER PK AUTOINCREMENT
|
||||||
|
├── filename TEXT NOT NULL
|
||||||
|
├── url TEXT NOT NULL
|
||||||
|
├── hash TEXT UNIQUE NOT NULL ← SHA-256, détection des doublons
|
||||||
|
├── user_id INTEGER → users(id) ON DELETE SET NULL
|
||||||
|
└── created_at DATETIME
|
||||||
|
|
||||||
|
password_resets
|
||||||
|
├── id INTEGER PK AUTOINCREMENT
|
||||||
|
├── user_id INTEGER → users(id) ON DELETE CASCADE
|
||||||
|
├── token_hash TEXT UNIQUE NOT NULL ← SHA-256, token brut jamais stocké
|
||||||
|
├── expires_at DATETIME NOT NULL ← 1 heure après création
|
||||||
|
├── used_at DATETIME ← NULL jusqu'à consommation
|
||||||
|
└── created_at DATETIME
|
||||||
|
|
||||||
|
posts_fts (table virtuelle FTS5, maintenue par triggers)
|
||||||
|
├── title
|
||||||
|
├── content ← HTML strippé via strip_tags
|
||||||
|
└── author_username
|
||||||
|
rowid = posts.id
|
||||||
|
|
||||||
|
login_attempts (protection brute-force — une ligne par IP)
|
||||||
|
├── ip TEXT PK ← adresse IP de l'appelant
|
||||||
|
├── attempts INTEGER NOT NULL ← compteur de tentatives échouées
|
||||||
|
├── locked_until TEXT DEFAULT NULL ← NULL tant que le seuil n'est pas atteint
|
||||||
|
└── updated_at TEXT NOT NULL ← mis à jour à chaque tentative
|
||||||
|
```
|
||||||
|
|
||||||
|
**Index explicites** (intégrés dans les migrations 003 et 004) :
|
||||||
|
|
||||||
|
| Index | Colonne | Justification |
|
||||||
|
|-------------------------|--------------------|----------------------------------------------------|
|
||||||
|
| `idx_posts_author_id` | `posts.author_id` | Filtre `findByUserId()` et recherche FTS par auteur |
|
||||||
|
| `idx_media_user_id` | `media.user_id` | Filtre `findByUserId()` dans la galerie média |
|
||||||
|
|
||||||
|
SQLite n'indexe pas automatiquement les colonnes de clé étrangère — seules `UNIQUE` et `PRIMARY KEY` le sont. Sans ces index, les requêtes filtrées par auteur/utilisateur effectuent un scan complet de la table.
|
||||||
|
|
||||||
|
**Configuration SQLite au démarrage** (dans `config/container.php`) :
|
||||||
|
|
||||||
|
```
|
||||||
|
PRAGMA journal_mode = WAL → lectures non bloquées par les écritures
|
||||||
|
PRAGMA busy_timeout = 3000 → attend 3 s avant d'échouer sur contention
|
||||||
|
PRAGMA synchronous = NORMAL → réduit les fsync en mode WAL (sûr)
|
||||||
|
PRAGMA foreign_keys = ON → active l'application réelle des contraintes FK
|
||||||
|
```
|
||||||
|
|
||||||
|
> Sans `PRAGMA foreign_keys = ON`, les clauses `ON DELETE SET NULL / CASCADE` sont
|
||||||
|
> enregistrées dans le schéma mais silencieusement ignorées par SQLite.
|
||||||
|
|
||||||
|
Les migrations s'exécutent automatiquement au démarrage (`Migrator::run()`) et ne sont jouées
|
||||||
|
qu'une fois (table `migrations` de suivi). `run()` appelle également `syncFtsIndex()` à chaque
|
||||||
|
démarrage : cette méthode insère dans `posts_fts` les articles dont le `rowid` est absent de
|
||||||
|
l'index, sans toucher aux entrées existantes (idempotent). Elle corrige le cas où des articles
|
||||||
|
auraient été insérés avant la création des triggers FTS5. Le provisionnement du compte
|
||||||
|
administrateur est délégué à `Seeder::seed()` — appelé après `Migrator::run()` à chaque
|
||||||
|
démarrage (idempotent : sans effet si le compte existe déjà).
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Méthode | URL | Accès | Description |
|
||||||
|
|---------|---------------------------------|---------------|------------------------------------------------|
|
||||||
|
| GET | `/` | Public | Accueil (`?categorie=`, `?q=`) |
|
||||||
|
| GET | `/article/{slug}` | Public | Détail d'un article |
|
||||||
|
| GET | `/rss.xml` | Public | Flux RSS (20 derniers articles) |
|
||||||
|
| GET | `/auth/login` | Public | Formulaire de connexion |
|
||||||
|
| POST | `/auth/login` | Public | Traitement de la connexion |
|
||||||
|
| POST | `/auth/logout` | Public | Déconnexion |
|
||||||
|
| GET | `/password/forgot` | Public | Formulaire mot de passe oublié |
|
||||||
|
| POST | `/password/forgot` | Public | Envoi du lien de réinitialisation (rate-limited par IP) |
|
||||||
|
| GET | `/password/reset` | Public | Formulaire réinitialisation (token en query) |
|
||||||
|
| POST | `/password/reset` | Public | Traitement de la réinitialisation |
|
||||||
|
| GET | `/account/password` | Auth | Formulaire changement de mot de passe |
|
||||||
|
| POST | `/account/password` | Auth | Traitement du changement de mot de passe |
|
||||||
|
| GET | `/admin/posts` | Auth | Liste des articles |
|
||||||
|
| GET | `/admin/posts/edit/{id}` | Auth | Formulaire d'édition |
|
||||||
|
| POST | `/admin/posts/create` | Auth | Création d'un article |
|
||||||
|
| POST | `/admin/posts/edit/{id}` | Auth | Mise à jour d'un article |
|
||||||
|
| POST | `/admin/posts/delete/{id}` | Auth | Suppression d'un article |
|
||||||
|
| GET | `/admin/categories` | Editor+ | Gestion des catégories |
|
||||||
|
| POST | `/admin/categories/create` | Editor+ | Création d'une catégorie |
|
||||||
|
| POST | `/admin/categories/delete/{id}` | Editor+ | Suppression d'une catégorie |
|
||||||
|
| GET | `/admin/media` | Auth | Galerie de médias |
|
||||||
|
| POST | `/admin/media/upload` | Auth | Upload d'image (AJAX Trumbowyg) |
|
||||||
|
| POST | `/admin/media/delete/{id}` | Auth | Suppression d'un média |
|
||||||
|
| GET | `/admin/users` | Admin | Liste des utilisateurs |
|
||||||
|
| GET | `/admin/users/create` | Admin | Formulaire de création d'utilisateur |
|
||||||
|
| POST | `/admin/users/create` | Admin | Création d'un utilisateur |
|
||||||
|
| POST | `/admin/users/role/{id}` | Admin | Modification du rôle d'un utilisateur |
|
||||||
|
| POST | `/admin/users/delete/{id}` | Admin | Suppression d'un utilisateur |
|
||||||
|
|
||||||
|
## Arborescence
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Auth/
|
||||||
|
│ ├── Middleware/
|
||||||
|
│ │ ├── AdminMiddleware.php
|
||||||
|
│ │ ├── AuthMiddleware.php
|
||||||
|
│ │ └── EditorMiddleware.php
|
||||||
|
│ ├── AccountController.php
|
||||||
|
│ ├── AuthController.php
|
||||||
|
│ ├── AuthService.php
|
||||||
|
│ ├── AuthServiceInterface.php
|
||||||
|
│ ├── LoginAttemptRepository.php
|
||||||
|
│ ├── LoginAttemptRepositoryInterface.php
|
||||||
|
│ ├── PasswordResetController.php
|
||||||
|
│ ├── PasswordResetRepository.php
|
||||||
|
│ ├── PasswordResetRepositoryInterface.php
|
||||||
|
│ ├── PasswordResetService.php
|
||||||
|
│ └── PasswordResetServiceInterface.php
|
||||||
|
├── Category/
|
||||||
|
│ ├── Category.php
|
||||||
|
│ ├── CategoryController.php
|
||||||
|
│ ├── CategoryRepository.php
|
||||||
|
│ ├── CategoryRepositoryInterface.php
|
||||||
|
│ ├── CategoryService.php
|
||||||
|
│ └── CategoryServiceInterface.php
|
||||||
|
├── Media/
|
||||||
|
│ ├── Exception/
|
||||||
|
│ │ ├── FileTooLargeException.php
|
||||||
|
│ │ ├── InvalidMimeTypeException.php
|
||||||
|
│ │ └── StorageException.php
|
||||||
|
│ ├── Media.php
|
||||||
|
│ ├── MediaController.php
|
||||||
|
│ ├── MediaRepository.php
|
||||||
|
│ ├── MediaRepositoryInterface.php
|
||||||
|
│ ├── MediaService.php
|
||||||
|
│ └── MediaServiceInterface.php
|
||||||
|
├── Post/
|
||||||
|
│ ├── Post.php
|
||||||
|
│ ├── PostController.php
|
||||||
|
│ ├── PostExtension.php
|
||||||
|
│ ├── PostRepository.php
|
||||||
|
│ ├── PostRepositoryInterface.php
|
||||||
|
│ ├── PostService.php
|
||||||
|
│ ├── PostServiceInterface.php
|
||||||
|
│ └── RssController.php
|
||||||
|
├── Shared/
|
||||||
|
│ ├── Database/Migrator.php
|
||||||
|
│ ├── Database/Seeder.php
|
||||||
|
│ ├── Exception/
|
||||||
|
│ │ └── NotFoundException.php
|
||||||
|
│ ├── Extension/
|
||||||
|
│ │ ├── AppExtension.php
|
||||||
|
│ │ ├── CsrfExtension.php
|
||||||
|
│ │ └── SessionExtension.php
|
||||||
|
│ ├── Html/
|
||||||
|
│ │ ├── HtmlPurifierFactory.php
|
||||||
|
│ │ ├── HtmlSanitizer.php
|
||||||
|
│ │ └── HtmlSanitizerInterface.php
|
||||||
|
│ ├── Http/
|
||||||
|
│ │ ├── FlashService.php
|
||||||
|
│ │ ├── FlashServiceInterface.php
|
||||||
|
│ │ ├── SessionManager.php
|
||||||
|
│ │ └── SessionManagerInterface.php
|
||||||
|
│ ├── Mail/
|
||||||
|
│ │ ├── MailService.php
|
||||||
|
│ │ └── MailServiceInterface.php
|
||||||
|
│ ├── Util/
|
||||||
|
│ │ ├── DateParser.php ← Conversion DateTime depuis la base (Post, User, Media)
|
||||||
|
│ │ └── SlugHelper.php ← Génération de slug (Post, Category)
|
||||||
|
│ ├── Bootstrap.php
|
||||||
|
│ ├── Config.php
|
||||||
|
│ └── Routes.php
|
||||||
|
|
||||||
|
├── User/
|
||||||
|
│ ├── Exception/
|
||||||
|
│ │ ├── DuplicateEmailException.php
|
||||||
|
│ │ ├── DuplicateUsernameException.php
|
||||||
|
│ │ └── WeakPasswordException.php
|
||||||
|
│ ├── User.php
|
||||||
|
│ ├── UserController.php
|
||||||
|
│ ├── UserRepository.php
|
||||||
|
│ ├── UserRepositoryInterface.php
|
||||||
|
│ ├── UserService.php
|
||||||
|
│ └── UserServiceInterface.php
|
||||||
|
|
||||||
|
config/
|
||||||
|
└── container.php ← Définitions PHP-DI (bindings + factories scalaires)
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── ControllerTestCase.php ← Classe de base abstraite (helpers PSR-7, assertions HTTP)
|
||||||
|
├── Auth/
|
||||||
|
│ ├── AccountControllerTest.php
|
||||||
|
│ ├── AuthControllerTest.php
|
||||||
|
│ ├── AuthServiceRateLimitTest.php
|
||||||
|
│ ├── AuthServiceTest.php
|
||||||
|
│ ├── LoginAttemptRepositoryTest.php
|
||||||
|
│ ├── PasswordResetControllerTest.php
|
||||||
|
│ ├── PasswordResetRepositoryTest.php
|
||||||
|
│ └── PasswordResetServiceTest.php
|
||||||
|
├── Category/
|
||||||
|
│ ├── CategoryControllerTest.php
|
||||||
|
│ ├── CategoryRepositoryTest.php
|
||||||
|
│ └── CategoryServiceTest.php
|
||||||
|
├── Media/
|
||||||
|
│ ├── MediaControllerTest.php
|
||||||
|
│ ├── MediaRepositoryTest.php
|
||||||
|
│ └── MediaServiceTest.php
|
||||||
|
├── Post/
|
||||||
|
│ ├── PostControllerTest.php
|
||||||
|
│ ├── PostRepositoryTest.php
|
||||||
|
│ ├── PostServiceTest.php
|
||||||
|
│ └── RssControllerTest.php
|
||||||
|
├── Shared/
|
||||||
|
│ ├── DateParserTest.php
|
||||||
|
│ ├── HtmlSanitizerTest.php
|
||||||
|
│ ├── SessionManagerTest.php
|
||||||
|
│ └── SlugHelperTest.php
|
||||||
|
└── User/
|
||||||
|
├── UserControllerTest.php
|
||||||
|
├── UserRepositoryTest.php
|
||||||
|
├── UserServiceTest.php
|
||||||
|
└── UserTest.php
|
||||||
|
|
||||||
|
views/
|
||||||
|
├── admin/
|
||||||
|
│ ├── categories/index.twig
|
||||||
|
│ ├── media/index.twig
|
||||||
|
│ ├── posts/{form,index}.twig
|
||||||
|
│ └── users/{form,index}.twig
|
||||||
|
├── emails/password-reset.twig
|
||||||
|
├── pages/
|
||||||
|
│ ├── account/password-change.twig
|
||||||
|
│ ├── auth/{login,password-forgot,password-reset}.twig
|
||||||
|
│ ├── post/detail.twig
|
||||||
|
│ ├── error.twig
|
||||||
|
│ └── home.twig
|
||||||
|
├── partials/
|
||||||
|
│ ├── _admin_nav.twig
|
||||||
|
│ ├── _header.twig
|
||||||
|
│ └── _footer.twig
|
||||||
|
└── layout.twig
|
||||||
|
|
||||||
|
public/
|
||||||
|
├── index.php # Point d'entrée HTTP
|
||||||
|
├── favicon.png
|
||||||
|
└── media/index.php # Renvoie 403
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS
|
||||||
|
|
||||||
|
L'architecture **7-1/BEM** sépare trois niveaux de responsabilité :
|
||||||
|
|
||||||
|
- **`abstracts/`** — design tokens centralisés. Modifier une variable propage le changement à l'ensemble du projet. Aucune valeur ne doit être codée en dur dans un composant.
|
||||||
|
- **`components/`** — composants agnostiques du domaine, réutilisables sans modification dans n'importe quel contexte.
|
||||||
|
- **`pages/`** — surcharges contextuelles. Un composant peut s'afficher différemment selon la page sans être modifié.
|
||||||
|
|
||||||
|
### Header
|
||||||
|
|
||||||
|
| Élément | Rôle |
|
||||||
|
|----------------------------|-------------------------------------------------------------|
|
||||||
|
| `.site-header` | Conteneur principal avec bordure basse |
|
||||||
|
| `.site-header__inner` | Flex row — logo à gauche, nav à droite |
|
||||||
|
| `.site-header__logo` | `<h1>` englobant le titre |
|
||||||
|
| `.site-header__logo-link` | Lien du titre — pas de soulignement, couleur héritée |
|
||||||
|
| `.site-header__nav` | Flex row des liens de navigation |
|
||||||
|
| `.site-header__user` | Nom de l'utilisateur connecté |
|
||||||
|
| `.site-header__action` | Élément d'action cliquable (lien ou bouton) |
|
||||||
|
|
||||||
|
### Boutons
|
||||||
|
|
||||||
|
| Classe | Rôle |
|
||||||
|
|-------------------|-----------------------------------------------------------|
|
||||||
|
| `.btn` | Base — padding, border-radius, display inline-block |
|
||||||
|
| `.btn--primary` | Fond bleu, texte blanc |
|
||||||
|
| `.btn--secondary` | Fond gris, texte blanc |
|
||||||
|
| `.btn--danger` | Fond rouge, texte blanc |
|
||||||
|
| `.btn--lg` | Padding agrandi — formulaires centrés |
|
||||||
|
| `.btn--full` | `width: 100%` |
|
||||||
|
| `.btn--sm` | Taille réduite — tableaux admin |
|
||||||
|
| `.btn-link` | Bloc autonome stylé comme un lien — déconnexion dans le header |
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
|
||||||
|
| Classe | Usage |
|
||||||
|
|--------------------|------------------------------------------|
|
||||||
|
| `.badge--admin` | Rôle administrateur (fond jaune) |
|
||||||
|
| `.badge--editor` | Rôle éditeur (fond bleu clair) |
|
||||||
|
| `.badge--user` | Rôle utilisateur (texte gris) |
|
||||||
|
| `.badge--category` | Catégorie — cliquable, filtre la liste |
|
||||||
|
|
||||||
|
### Composant carte
|
||||||
|
|
||||||
|
`.card` structure visuellement n'importe quelle entité listable sans référence au domaine métier.
|
||||||
|
|
||||||
|
| Élément | Rôle |
|
||||||
|
|-------------------------|------------------------------------------------------|
|
||||||
|
| `.card-list` | Conteneur de liste |
|
||||||
|
| `.card-list--contained` | Fond grisé encadrant les cartes |
|
||||||
|
| `.card` | Carte — fond blanc, border-radius, box-shadow |
|
||||||
|
| `.card__thumb-link` | Lien englobant la vignette |
|
||||||
|
| `.card__thumb` | Vignette image |
|
||||||
|
| `.card__initials` | Vignette fallback (initiales) |
|
||||||
|
| `.card__content` | Wrapper flex colonne (corps + actions) |
|
||||||
|
| `.card__body` | Zone textuelle — contrainte en hauteur (vignette) |
|
||||||
|
| `.card__title` | Titre |
|
||||||
|
| `.card__title-link` | Lien du titre — pas de soulignement, underline au survol |
|
||||||
|
| `.card__meta` | Métadonnées (date, auteur…) |
|
||||||
|
| `.card__excerpt` | Texte court |
|
||||||
|
| `.card__actions` | Zone d'actions — toujours visible hors du clip |
|
||||||
|
|
||||||
|
### Autres composants notables
|
||||||
|
|
||||||
|
- `.category-filter` / `__item` / `__item--active` — barre de navigation par catégorie
|
||||||
|
- `.category-create` — bloc de création sur `/admin/categories`
|
||||||
|
- `.admin-table` — tableau de gestion (`__self` pour l'utilisateur courant, `__muted` pour les actions indisponibles, `__role-select` pour le sélecteur de rôle). En mobile, les lignes s'empilent en blocs via `data-label` sur chaque `<td>`.
|
||||||
|
- `.admin-actions` — cellule d'actions en flex row (desktop) / flex column (mobile)
|
||||||
1833
docs/GUIDE.md
Normal file
1833
docs/GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
444
package-lock.json
generated
Normal file
444
package-lock.json
generated
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
{
|
||||||
|
"name": "slim-blog",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"jquery": "^3.7.0",
|
||||||
|
"trumbowyg": "^2.27.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"sass": "^1.80.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3",
|
||||||
|
"is-glob": "^4.0.3",
|
||||||
|
"node-addon-api": "^7.0.0",
|
||||||
|
"picomatch": "^4.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher-android-arm64": "2.5.6",
|
||||||
|
"@parcel/watcher-darwin-arm64": "2.5.6",
|
||||||
|
"@parcel/watcher-darwin-x64": "2.5.6",
|
||||||
|
"@parcel/watcher-freebsd-x64": "2.5.6",
|
||||||
|
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
||||||
|
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
||||||
|
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
||||||
|
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
||||||
|
"@parcel/watcher-win32-arm64": "2.5.6",
|
||||||
|
"@parcel/watcher-win32-ia32": "2.5.6",
|
||||||
|
"@parcel/watcher-win32-x64": "2.5.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-android-arm64": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-darwin-x64": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-arm64": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-ia32": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-x64": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/immutable": {
|
||||||
|
"version": "5.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
|
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/is-extglob": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-glob": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-extglob": "^2.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jquery": {
|
||||||
|
"version": "3.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
||||||
|
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
|
||||||
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdirp": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.18.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sass": {
|
||||||
|
"version": "1.98.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
|
||||||
|
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.0",
|
||||||
|
"immutable": "^5.1.5",
|
||||||
|
"source-map-js": ">=0.6.2 <2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"sass": "sass.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher": "^2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/trumbowyg": {
|
||||||
|
"version": "2.31.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/trumbowyg/-/trumbowyg-2.31.0.tgz",
|
||||||
|
"integrity": "sha512-I+DMiluTpLDx3yn6LR0TIVR7xIOjgtBQmpEE6Ofd+2yl5ruzY63q/yA/DfBuRVxdK7yDYSBe9FXpVjM1P2NdtA==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"jquery": ">=1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build:css": "sass assets/scss:public/assets/css --no-source-map --style=compressed",
|
||||||
|
"build:vendor": "mkdir -p public/assets/vendor && cp node_modules/jquery/dist/jquery.min.js public/assets/vendor/ && cp -r node_modules/trumbowyg/dist public/assets/vendor/trumbowyg",
|
||||||
|
"build": "npm run build:css && npm run build:vendor",
|
||||||
|
"watch": "sass --watch assets/scss:public/assets/css",
|
||||||
|
"clean": "rm -rf public/assets"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"sass": "^1.80.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jquery": "^3.7.0",
|
||||||
|
"trumbowyg": "^2.27.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
phpstan.neon
Normal file
6
phpstan.neon
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
parameters:
|
||||||
|
level: 8
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
excludePaths:
|
||||||
|
- src/Shared/Bootstrap.php
|
||||||
27
phpunit.xml
Normal file
27
phpunit.xml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||||
|
displayDetailsOnTestsThatTriggerErrors="true"
|
||||||
|
displayDetailsOnTestsThatTriggerNotices="true"
|
||||||
|
displayDetailsOnTestsThatTriggerWarnings="true">
|
||||||
|
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="slim-blog">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
<exclude>
|
||||||
|
<file>src/Shared/Bootstrap.php</file>
|
||||||
|
<file>src/Shared/Routes.php</file>
|
||||||
|
</exclude>
|
||||||
|
</source>
|
||||||
|
|
||||||
|
</phpunit>
|
||||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 801 B |
15
public/index.php
Normal file
15
public/index.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Shared\Bootstrap;
|
||||||
|
|
||||||
|
session_start([
|
||||||
|
'cookie_secure' => !empty($_SERVER['HTTPS']),
|
||||||
|
'cookie_httponly' => true,
|
||||||
|
'cookie_samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app = Bootstrap::create()->initialize();
|
||||||
|
$app->run();
|
||||||
114
src/Auth/AccountController.php
Normal file
114
src/Auth/AccountController.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôleur pour les actions liées au compte de l'utilisateur connecté.
|
||||||
|
*
|
||||||
|
* Accessible uniquement aux utilisateurs authentifiés (AuthMiddleware).
|
||||||
|
* Actuellement limité au changement de mot de passe, accessible via
|
||||||
|
* le lien « Mon compte » dans le header.
|
||||||
|
*/
|
||||||
|
final class AccountController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Twig $view Moteur de templates Twig
|
||||||
|
* @param AuthServiceInterface $authService Service d'authentification
|
||||||
|
* @param FlashServiceInterface $flash Service de messages flash
|
||||||
|
* @param SessionManagerInterface $sessionManager Gestionnaire de session
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly AuthServiceInterface $authService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
private readonly SessionManagerInterface $sessionManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche le formulaire de changement de mot de passe.
|
||||||
|
*
|
||||||
|
* Transmet les messages flash d'erreur et de succès issus
|
||||||
|
* d'une soumission précédente, ainsi que l'URL de retour pour
|
||||||
|
* le bouton Annuler (déduite du Referer, validée pour éviter
|
||||||
|
* les redirections ouvertes vers des domaines externes).
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La vue pages/account/password-change.twig
|
||||||
|
*/
|
||||||
|
public function showChangePassword(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
// Récupère l'URL de la page précédente pour le bouton Annuler.
|
||||||
|
// On valide que le Referer est bien une URL relative du site (commence par /)
|
||||||
|
// pour éviter toute redirection vers un domaine externe (open redirect).
|
||||||
|
$referer = $req->getHeaderLine('Referer');
|
||||||
|
$path = parse_url($referer, PHP_URL_PATH);
|
||||||
|
$path = is_string($path) ? $path : '';
|
||||||
|
$backUrl = (str_starts_with($path, '/') && $path !== '/account/password')
|
||||||
|
? $path
|
||||||
|
: '/admin/posts';
|
||||||
|
|
||||||
|
return $this->view->render($res, 'pages/account/password-change.twig', [
|
||||||
|
'error' => $this->flash->get('password_error'),
|
||||||
|
'success' => $this->flash->get('password_success'),
|
||||||
|
'back_url' => $backUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite la soumission du formulaire de changement de mot de passe.
|
||||||
|
*
|
||||||
|
* Vérifie que les deux nouveaux mots de passe sont identiques,
|
||||||
|
* puis délègue la vérification du mot de passe actuel et la mise
|
||||||
|
* à jour à AuthService.
|
||||||
|
*
|
||||||
|
* Note : getUserId() ne peut pas retourner null ici car la route
|
||||||
|
* est protégée par AuthMiddleware. La valeur de repli 0 ne sera
|
||||||
|
* jamais atteinte en pratique.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response Une redirection vers /account/password dans tous les cas
|
||||||
|
*/
|
||||||
|
public function changePassword(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$current = trim((string) ($data['current_password'] ?? ''));
|
||||||
|
$new = trim((string) ($data['new_password'] ?? ''));
|
||||||
|
$confirm = trim((string) ($data['new_password_confirm'] ?? ''));
|
||||||
|
|
||||||
|
// getUserId() ne peut pas être null : route protégée par AuthMiddleware
|
||||||
|
$userId = $this->sessionManager->getUserId() ?? 0;
|
||||||
|
|
||||||
|
if ($new !== $confirm) {
|
||||||
|
$this->flash->set('password_error', 'Les mots de passe ne correspondent pas');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/account/password')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->authService->changePassword($userId, $current, $new);
|
||||||
|
$this->flash->set('password_success', 'Mot de passe modifié avec succès');
|
||||||
|
} catch (WeakPasswordException) {
|
||||||
|
$this->flash->set('password_error', 'Le nouveau mot de passe doit contenir au moins 8 caractères');
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$this->flash->set('password_error', 'Le mot de passe actuel est incorrect');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->flash->set('password_error', 'Une erreur inattendue s\'est produite');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/account/password')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/Auth/AuthController.php
Normal file
75
src/Auth/AuthController.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Shared\Http\ClientIpResolver;
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
final class AuthController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly AuthServiceInterface $authService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
private readonly ClientIpResolver $clientIpResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showLogin(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
if ($this->authService->isLoggedIn()) {
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->render($res, 'pages/auth/login.twig', [
|
||||||
|
'error' => $this->flash->get('login_error'),
|
||||||
|
'success' => $this->flash->get('login_success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$ip = $this->clientIpResolver->resolve($req);
|
||||||
|
$remainingMinutes = $this->authService->checkRateLimit($ip);
|
||||||
|
|
||||||
|
if ($remainingMinutes > 0) {
|
||||||
|
$this->flash->set(
|
||||||
|
'login_error',
|
||||||
|
"Trop de tentatives. Réessayez dans {$remainingMinutes} minute"
|
||||||
|
. ($remainingMinutes > 1 ? 's' : '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/auth/login')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$username = trim((string) ($data['username'] ?? ''));
|
||||||
|
$password = trim((string) ($data['password'] ?? ''));
|
||||||
|
|
||||||
|
$user = $this->authService->authenticate($username, $password);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
$this->authService->recordFailure($ip);
|
||||||
|
$this->flash->set('login_error', 'Identifiants invalides');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/auth/login')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authService->resetRateLimit($ip);
|
||||||
|
$this->authService->login($user);
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$this->authService->logout();
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
193
src/Auth/AuthService.php
Normal file
193
src/Auth/AuthService.php
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Shared\Exception\NotFoundException;
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
use App\User\User;
|
||||||
|
use App\User\UserRepositoryInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'authentification.
|
||||||
|
*
|
||||||
|
* Centralise la logique métier liée à l'authentification :
|
||||||
|
* vérification des identifiants, ouverture et fermeture de session,
|
||||||
|
* changement de mot de passe et protection brute-force par IP.
|
||||||
|
*
|
||||||
|
* La création de comptes est déléguée à UserService (domaine User).
|
||||||
|
* La gestion de la session PHP est entièrement déléguée à SessionManagerInterface.
|
||||||
|
* Les noms d'utilisateurs sont normalisés en minuscules pour garantir
|
||||||
|
* l'insensibilité à la casse.
|
||||||
|
*/
|
||||||
|
final class AuthService implements AuthServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Nombre maximum de tentatives échouées avant verrouillage.
|
||||||
|
*/
|
||||||
|
private const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durée du verrouillage en minutes après dépassement du seuil.
|
||||||
|
*/
|
||||||
|
private const LOCK_MINUTES = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param UserRepositoryInterface $userRepository Dépôt de persistance des utilisateurs
|
||||||
|
* @param SessionManagerInterface $sessionManager Gestionnaire de session PHP
|
||||||
|
* @param LoginAttemptRepositoryInterface $loginAttemptRepository Dépôt des tentatives de connexion
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepositoryInterface $userRepository,
|
||||||
|
private readonly SessionManagerInterface $sessionManager,
|
||||||
|
private readonly LoginAttemptRepositoryInterface $loginAttemptRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si une adresse IP est actuellement verrouillée.
|
||||||
|
*
|
||||||
|
* Nettoie les entrées expirées avant la vérification. Si l'IP est
|
||||||
|
* verrouillée, retourne le nombre de minutes restantes (arrondi au supérieur,
|
||||||
|
* minimum 1) calculé depuis les timestamps bruts pour éviter les erreurs
|
||||||
|
* d'arithmétique sur DateInterval pour des durées supérieures à 59 minutes.
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
*
|
||||||
|
* @return int 0 si libre, nombre de minutes restantes si verrouillé
|
||||||
|
*/
|
||||||
|
public function checkRateLimit(string $ip): int
|
||||||
|
{
|
||||||
|
$this->loginAttemptRepository->deleteExpired();
|
||||||
|
|
||||||
|
$row = $this->loginAttemptRepository->findByIp($ip);
|
||||||
|
|
||||||
|
if ($row === null || $row['locked_until'] === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedUntil = new \DateTime($row['locked_until']);
|
||||||
|
$now = new \DateTime();
|
||||||
|
|
||||||
|
if ($lockedUntil <= $now) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// $diff->i et $diff->h sont des portions de l'intervalle (ex: 1h30 → h=1, i=30),
|
||||||
|
// pas des totaux — la somme serait incorrecte pour des durées > 59 min.
|
||||||
|
// On calcule directement depuis les timestamps bruts.
|
||||||
|
$secondsLeft = $lockedUntil->getTimestamp() - $now->getTimestamp();
|
||||||
|
|
||||||
|
return max(1, (int) ceil($secondsLeft / 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre un échec de connexion pour une IP.
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function recordFailure(string $ip): void
|
||||||
|
{
|
||||||
|
$this->loginAttemptRepository->recordFailure($ip, self::MAX_ATTEMPTS, self::LOCK_MINUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise le compteur de tentatives pour une IP (connexion réussie).
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
*/
|
||||||
|
public function resetRateLimit(string $ip): void
|
||||||
|
{
|
||||||
|
$this->loginAttemptRepository->resetForIp($ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentifie un utilisateur par nom d'utilisateur et mot de passe.
|
||||||
|
*
|
||||||
|
* Le nom d'utilisateur est normalisé en minuscules avant la recherche.
|
||||||
|
* Ne gère pas le rate limiting — responsabilité de l'appelant (AuthController).
|
||||||
|
*
|
||||||
|
* @param string $username Nom d'utilisateur (insensible à la casse)
|
||||||
|
* @param string $plainPassword Mot de passe en clair
|
||||||
|
*
|
||||||
|
* @return User|null L'utilisateur authentifié, ou null si les identifiants sont invalides
|
||||||
|
*/
|
||||||
|
public function authenticate(string $username, string $plainPassword): ?User
|
||||||
|
{
|
||||||
|
$user = $this->userRepository->findByUsername(mb_strtolower(trim($username)));
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password_verify(trim($plainPassword), $user->getPasswordHash())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifie le mot de passe de l'utilisateur connecté.
|
||||||
|
*
|
||||||
|
* Vérifie le mot de passe actuel avant d'appliquer le changement.
|
||||||
|
*
|
||||||
|
* @param int $userId Identifiant de l'utilisateur
|
||||||
|
* @param string $currentPassword Mot de passe actuel en clair (pour vérification)
|
||||||
|
* @param string $newPassword Nouveau mot de passe en clair (min. 8 caractères)
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si l'utilisateur est introuvable
|
||||||
|
* @throws \InvalidArgumentException Si le mot de passe actuel est incorrect
|
||||||
|
* @throws WeakPasswordException Si le nouveau mot de passe est trop court
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function changePassword(int $userId, string $currentPassword, string $newPassword): void
|
||||||
|
{
|
||||||
|
$user = $this->userRepository->findById($userId);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
throw new NotFoundException('Utilisateur', $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password_verify(trim($currentPassword), $user->getPasswordHash())) {
|
||||||
|
throw new \InvalidArgumentException('Mot de passe actuel incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen(trim($newPassword)) < 8) {
|
||||||
|
throw new WeakPasswordException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$newHash = password_hash(trim($newPassword), PASSWORD_BCRYPT, ['cost' => 12]);
|
||||||
|
$this->userRepository->updatePassword($userId, $newHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un utilisateur est actuellement connecté.
|
||||||
|
*
|
||||||
|
* @return bool True si une session utilisateur est active
|
||||||
|
*/
|
||||||
|
public function isLoggedIn(): bool
|
||||||
|
{
|
||||||
|
return $this->sessionManager->isAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ouvre une session pour l'utilisateur donné.
|
||||||
|
*
|
||||||
|
* @param User $user L'utilisateur à connecter
|
||||||
|
*/
|
||||||
|
public function login(User $user): void
|
||||||
|
{
|
||||||
|
$this->sessionManager->setUser($user->getId(), $user->getUsername(), $user->getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ferme la session de l'utilisateur connecté.
|
||||||
|
*/
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
$this->sessionManager->destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/Auth/AuthServiceInterface.php
Normal file
84
src/Auth/AuthServiceInterface.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Shared\Exception\NotFoundException;
|
||||||
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat du service d'authentification.
|
||||||
|
*
|
||||||
|
* Permet de mocker le service dans les tests unitaires sans dépendre
|
||||||
|
* de la classe concrète finale AuthService.
|
||||||
|
*/
|
||||||
|
interface AuthServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Vérifie si l'adresse IP est actuellement verrouillée par le rate limiter.
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
*
|
||||||
|
* @return int 0 si libre, nombre de minutes restantes si verrouillé
|
||||||
|
*/
|
||||||
|
public function checkRateLimit(string $ip): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre une tentative de connexion échouée pour une IP.
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
*/
|
||||||
|
public function recordFailure(string $ip): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise le compteur de tentatives pour une IP.
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function resetRateLimit(string $ip): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tente d'authentifier un utilisateur par ses identifiants.
|
||||||
|
*
|
||||||
|
* @param string $username Nom d'utilisateur (insensible à la casse)
|
||||||
|
* @param string $plainPassword Mot de passe en clair
|
||||||
|
*
|
||||||
|
* @return User|null L'utilisateur authentifié, ou null si les identifiants sont invalides
|
||||||
|
*/
|
||||||
|
public function authenticate(string $username, string $plainPassword): ?User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change le mot de passe d'un utilisateur après vérification de l'actuel.
|
||||||
|
*
|
||||||
|
* @param int $userId Identifiant de l'utilisateur
|
||||||
|
* @param string $currentPassword Mot de passe actuel en clair (pour vérification)
|
||||||
|
* @param string $newPassword Nouveau mot de passe en clair (min. 8 caractères)
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si l'utilisateur est introuvable
|
||||||
|
* @throws \InvalidArgumentException Si le mot de passe actuel est incorrect
|
||||||
|
* @throws WeakPasswordException Si le nouveau mot de passe est trop court
|
||||||
|
*/
|
||||||
|
public function changePassword(int $userId, string $currentPassword, string $newPassword): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si une session utilisateur est active.
|
||||||
|
*
|
||||||
|
* @return bool True si une session utilisateur est active
|
||||||
|
*/
|
||||||
|
public function isLoggedIn(): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ouvre une session pour l'utilisateur donné.
|
||||||
|
*
|
||||||
|
* @param User $user L'utilisateur à connecter
|
||||||
|
*/
|
||||||
|
public function login(User $user): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détruit la session utilisateur courante.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function logout(): void;
|
||||||
|
}
|
||||||
16
src/Auth/Exception/InvalidResetTokenException.php
Normal file
16
src/Auth/Exception/InvalidResetTokenException.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception levée lorsqu'un lien de réinitialisation est invalide,
|
||||||
|
* expiré, déjà consommé, ou lié à un utilisateur absent.
|
||||||
|
*/
|
||||||
|
final class InvalidResetTokenException extends \InvalidArgumentException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('Ce lien de réinitialisation est invalide ou a expiré.');
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/Auth/LoginAttemptRepository.php
Normal file
116
src/Auth/LoginAttemptRepository.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dépôt de persistance des tentatives de connexion.
|
||||||
|
*
|
||||||
|
* Gère la lecture et l'écriture dans la table `login_attempts` pour
|
||||||
|
* la protection contre le brute-force sur le formulaire de connexion.
|
||||||
|
*
|
||||||
|
* La clé primaire est l'adresse IP — une seule ligne par IP, mise à jour
|
||||||
|
* à chaque tentative (UPSERT). Les entrées dont `locked_until` est dépassé
|
||||||
|
* sont réinitialisées automatiquement lors de la prochaine vérification.
|
||||||
|
*/
|
||||||
|
final class LoginAttemptRepository implements LoginAttemptRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param PDO $db Instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly PDO $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la ligne de tentatives pour une IP donnée, ou null si absente.
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
*
|
||||||
|
* @return array{ip: string, attempts: int, locked_until: string|null, updated_at: string}|null
|
||||||
|
*/
|
||||||
|
public function findByIp(string $ip): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT * FROM login_attempts WHERE ip = :ip');
|
||||||
|
$stmt->execute([':ip' => $ip]);
|
||||||
|
|
||||||
|
/** @var array{ip: string, attempts: int, locked_until: string|null, updated_at: string}|false $row */
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre un échec de connexion pour une IP via un UPSERT atomique.
|
||||||
|
*
|
||||||
|
* Une seule opération SQL insère la ligne si elle n'existe pas, ou incrémente
|
||||||
|
* le compteur si elle existe déjà. L'atomicité élimine la race condition
|
||||||
|
* présente dans un pattern SELECT + INSERT/UPDATE séparé.
|
||||||
|
*
|
||||||
|
* Le paramètre locked_until est calculé côté PHP avant la requête afin de
|
||||||
|
* garder la logique lisible et testable ; il est passé deux fois (une pour
|
||||||
|
* le cas INSERT, une pour le cas UPDATE) car PDO interdit les paramètres nommés
|
||||||
|
* dupliqués dans une même requête préparée.
|
||||||
|
*
|
||||||
|
* Requiert SQLite >= 3.24 (juin 2018) pour la syntaxe ON CONFLICT DO UPDATE.
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
* @param int $maxAttempts Nombre d'échecs avant verrouillage
|
||||||
|
* @param int $lockMinutes Durée du verrouillage en minutes
|
||||||
|
*/
|
||||||
|
public function recordFailure(string $ip, int $maxAttempts, int $lockMinutes): void
|
||||||
|
{
|
||||||
|
$now = (new \DateTime())->format('Y-m-d H:i:s');
|
||||||
|
$lockUntil = (new \DateTime())->modify("+{$lockMinutes} minutes")->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'INSERT INTO login_attempts (ip, attempts, locked_until, updated_at)
|
||||||
|
VALUES (:ip, 1, CASE WHEN 1 >= :max1 THEN :lock1 ELSE NULL END, :now1)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET
|
||||||
|
attempts = login_attempts.attempts + 1,
|
||||||
|
locked_until = CASE WHEN login_attempts.attempts + 1 >= :max2
|
||||||
|
THEN :lock2
|
||||||
|
ELSE NULL END,
|
||||||
|
updated_at = :now2'
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':ip' => $ip,
|
||||||
|
':max1' => $maxAttempts,
|
||||||
|
':lock1' => $lockUntil,
|
||||||
|
':now1' => $now,
|
||||||
|
':max2' => $maxAttempts,
|
||||||
|
':lock2' => $lockUntil,
|
||||||
|
':now2' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise le compteur de tentatives pour une IP (connexion réussie).
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function resetForIp(string $ip): void
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('DELETE FROM login_attempts WHERE ip = :ip');
|
||||||
|
$stmt->execute([':ip' => $ip]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime les entrées dont le verrouillage est expiré.
|
||||||
|
*
|
||||||
|
* Appelé à chaque tentative de connexion pour éviter l'accumulation
|
||||||
|
* de lignes obsolètes sans tâche planifiée externe.
|
||||||
|
*/
|
||||||
|
public function deleteExpired(): void
|
||||||
|
{
|
||||||
|
$now = (new \DateTime())->format('Y-m-d H:i:s');
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'DELETE FROM login_attempts WHERE locked_until IS NOT NULL AND locked_until < :now'
|
||||||
|
);
|
||||||
|
$stmt->execute([':now' => $now]);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/Auth/LoginAttemptRepositoryInterface.php
Normal file
44
src/Auth/LoginAttemptRepositoryInterface.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat de persistance des tentatives de connexion.
|
||||||
|
*
|
||||||
|
* Découple AuthService de l'implémentation concrète PDO/SQLite,
|
||||||
|
* facilitant les mocks dans les tests unitaires.
|
||||||
|
*/
|
||||||
|
interface LoginAttemptRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retourne la ligne de tentatives pour une IP donnée, ou null si absente.
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
*
|
||||||
|
* @return array{ip: string, attempts: int, locked_until: string|null, updated_at: string}|null
|
||||||
|
*/
|
||||||
|
public function findByIp(string $ip): ?array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre un échec de connexion pour une IP (INSERT ou UPDATE).
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
* @param int $maxAttempts Nombre d'échecs avant verrouillage
|
||||||
|
* @param int $lockMinutes Durée du verrouillage en minutes
|
||||||
|
*/
|
||||||
|
public function recordFailure(string $ip, int $maxAttempts, int $lockMinutes): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise le compteur de tentatives pour une IP (connexion réussie).
|
||||||
|
*
|
||||||
|
* @param string $ip Adresse IP du client
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function resetForIp(string $ip): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime les entrées dont le verrouillage est expiré.
|
||||||
|
*/
|
||||||
|
public function deleteExpired(): void;
|
||||||
|
}
|
||||||
50
src/Auth/Middleware/AdminMiddleware.php
Normal file
50
src/Auth/Middleware/AdminMiddleware.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth\Middleware;
|
||||||
|
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||||
|
use Slim\Psr7\Response as SlimResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware de protection des routes réservées aux administrateurs.
|
||||||
|
*
|
||||||
|
* Intercepte les requêtes et redirige vers /admin/posts si l'utilisateur
|
||||||
|
* connecté n'a pas le rôle 'admin'.
|
||||||
|
*
|
||||||
|
* Ce middleware doit être utilisé en complément de AuthMiddleware,
|
||||||
|
* qui vérifie en amont que l'utilisateur est connecté.
|
||||||
|
* Ordre dans la chaîne Slim : ->add($adminMiddleware)->add($authMiddleware)
|
||||||
|
*/
|
||||||
|
final class AdminMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle admin)
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly SessionManagerInterface $sessionManager)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie le rôle admin avant de transmettre la requête au gestionnaire suivant.
|
||||||
|
*
|
||||||
|
* @param Request $request La requête HTTP entrante
|
||||||
|
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
|
||||||
|
*
|
||||||
|
* @return Response Une redirection 302 vers /admin/posts, ou la réponse normale
|
||||||
|
*/
|
||||||
|
public function process(Request $request, RequestHandler $handler): Response
|
||||||
|
{
|
||||||
|
if (!$this->sessionManager->isAdmin()) {
|
||||||
|
return (new SlimResponse())
|
||||||
|
->withHeader('Location', '/admin/posts')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Auth/Middleware/AuthMiddleware.php
Normal file
47
src/Auth/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth\Middleware;
|
||||||
|
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||||
|
use Slim\Psr7\Response as SlimResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware de protection des routes réservées aux utilisateurs connectés.
|
||||||
|
*
|
||||||
|
* Intercepte les requêtes entrantes et redirige vers /auth/login
|
||||||
|
* si aucune session utilisateur n'est active.
|
||||||
|
* Doit être appliqué avant AdminMiddleware dans la chaîne.
|
||||||
|
*/
|
||||||
|
final class AuthMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param SessionManagerInterface $sessionManager Gestionnaire de session (vérification de l'authentification)
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly SessionManagerInterface $sessionManager)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie l'authentification avant de transmettre la requête au gestionnaire suivant.
|
||||||
|
*
|
||||||
|
* @param Request $request La requête HTTP entrante
|
||||||
|
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
|
||||||
|
*
|
||||||
|
* @return Response Une redirection 302 vers /auth/login, ou la réponse normale
|
||||||
|
*/
|
||||||
|
public function process(Request $request, RequestHandler $handler): Response
|
||||||
|
{
|
||||||
|
if (!$this->sessionManager->isAuthenticated()) {
|
||||||
|
return (new SlimResponse())
|
||||||
|
->withHeader('Location', '/auth/login')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/Auth/Middleware/EditorMiddleware.php
Normal file
50
src/Auth/Middleware/EditorMiddleware.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth\Middleware;
|
||||||
|
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||||
|
use Slim\Psr7\Response as SlimResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware de protection des routes réservées aux éditeurs et administrateurs.
|
||||||
|
*
|
||||||
|
* Intercepte les requêtes et redirige vers /admin/posts si l'utilisateur
|
||||||
|
* connecté n'a ni le rôle 'editor' ni le rôle 'admin'.
|
||||||
|
*
|
||||||
|
* Ce middleware doit être utilisé en complément de AuthMiddleware,
|
||||||
|
* qui vérifie en amont que l'utilisateur est connecté.
|
||||||
|
* Ordre dans la chaîne Slim : ->add($editorMiddleware)->add($authMiddleware)
|
||||||
|
*/
|
||||||
|
final class EditorMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle)
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly SessionManagerInterface $sessionManager)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie le rôle (editor ou admin) avant de transmettre la requête au gestionnaire suivant.
|
||||||
|
*
|
||||||
|
* @param Request $request La requête HTTP entrante
|
||||||
|
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
|
||||||
|
*
|
||||||
|
* @return Response Une redirection 302 vers /admin/posts, ou la réponse normale
|
||||||
|
*/
|
||||||
|
public function process(Request $request, RequestHandler $handler): Response
|
||||||
|
{
|
||||||
|
if (!$this->sessionManager->isAdmin() && !$this->sessionManager->isEditor()) {
|
||||||
|
return (new SlimResponse())
|
||||||
|
->withHeader('Location', '/admin/posts')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/Auth/PasswordResetController.php
Normal file
218
src/Auth/PasswordResetController.php
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Auth\Exception\InvalidResetTokenException;
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôleur de réinitialisation de mot de passe.
|
||||||
|
*
|
||||||
|
* Gère le flux en deux étapes :
|
||||||
|
* 1. Demande de réinitialisation : saisie de l'email (GET/POST /password/forgot)
|
||||||
|
* 2. Réinitialisation effective : saisie du nouveau mot de passe (GET/POST /password/reset)
|
||||||
|
*
|
||||||
|
* Sécurité :
|
||||||
|
* - Le formulaire de demande affiche toujours un message de succès générique,
|
||||||
|
* même si l'email est inconnu, pour éviter l'énumération des comptes.
|
||||||
|
* - Le token est transmis uniquement via l'URL (GET) et un champ hidden (POST),
|
||||||
|
* jamais via la session.
|
||||||
|
* - Le endpoint POST /password/forgot est soumis au même rate limiting par IP
|
||||||
|
* que le login : 5 tentatives autorisées, verrouillage 15 min au-delà.
|
||||||
|
* Toute tentative est comptabilisée — il n'existe pas de "succès" identifiable
|
||||||
|
* sans révéler si l'adresse est enregistrée (anti-énumération).
|
||||||
|
*/
|
||||||
|
final class PasswordResetController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Twig $view Moteur de templates Twig
|
||||||
|
* @param PasswordResetServiceInterface $passwordResetService Service de réinitialisation
|
||||||
|
* @param AuthServiceInterface $authService Service d'authentification (rate limiting)
|
||||||
|
* @param FlashServiceInterface $flash Service de messages flash
|
||||||
|
* @param string $baseUrl URL de base de l'application (depuis APP_URL dans .env)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly PasswordResetServiceInterface $passwordResetService,
|
||||||
|
private readonly AuthServiceInterface $authService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
private readonly string $baseUrl,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche le formulaire de demande de réinitialisation.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La vue pages/auth/password-forgot.twig
|
||||||
|
*/
|
||||||
|
public function showForgot(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
return $this->view->render($res, 'pages/auth/password-forgot.twig', [
|
||||||
|
'error' => $this->flash->get('reset_error'),
|
||||||
|
'success' => $this->flash->get('reset_success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite la demande de réinitialisation.
|
||||||
|
*
|
||||||
|
* Vérifie d'abord le rate limit par IP. Toute tentative est enregistrée
|
||||||
|
* comme un échec — qu'un email existe ou non — afin de ne pas déséquilibrer
|
||||||
|
* le compteur en fonction du résultat, ce qui permettrait de déduire
|
||||||
|
* l'existence d'un compte (canal caché sur le rate-limit).
|
||||||
|
*
|
||||||
|
* Génère un token et envoie l'email si l'adresse existe.
|
||||||
|
* Affiche toujours un message de succès générique — ne révèle pas
|
||||||
|
* si l'adresse est enregistrée (protection contre l'énumération).
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response Redirection vers /password/forgot avec message flash
|
||||||
|
*/
|
||||||
|
public function forgot(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
// Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx).
|
||||||
|
// Même logique que AuthController::login() — voir son commentaire pour le détail.
|
||||||
|
$serverParams = $req->getServerParams();
|
||||||
|
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
|
||||||
|
$ip = $forwarded !== '' && $forwarded !== '0.0.0.0'
|
||||||
|
? trim(explode(',', $forwarded)[0])
|
||||||
|
: ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||||
|
|
||||||
|
// Vérification du rate limit avant tout traitement
|
||||||
|
$remainingMinutes = $this->authService->checkRateLimit($ip);
|
||||||
|
|
||||||
|
if ($remainingMinutes > 0) {
|
||||||
|
$this->flash->set(
|
||||||
|
'reset_error',
|
||||||
|
"Trop de demandes. Veuillez réessayer dans {$remainingMinutes} minute"
|
||||||
|
. ($remainingMinutes > 1 ? 's' : '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$email = trim((string) ($data['email'] ?? ''));
|
||||||
|
|
||||||
|
// La tentative est enregistrée systématiquement, résultat connu ou non.
|
||||||
|
// Réinitialiser le compteur uniquement en cas de succès révélerait si
|
||||||
|
// l'adresse existe (canal caché : compteur remis à zéro = email valide).
|
||||||
|
$this->authService->recordFailure($ip);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->passwordResetService->requestReset($email, $this->baseUrl);
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
// Erreur d'envoi d'email — on n'expose pas le détail à l'utilisateur
|
||||||
|
$this->flash->set('reset_error', 'Une erreur est survenue. Veuillez réessayer.');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message générique — ne révèle pas si l'email est connu
|
||||||
|
$this->flash->set(
|
||||||
|
'reset_success',
|
||||||
|
'Si cette adresse est associée à un compte, un email de réinitialisation a été envoyé'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche le formulaire de saisie du nouveau mot de passe.
|
||||||
|
*
|
||||||
|
* Valide le token en amont — redirige vers /password/forgot avec un message
|
||||||
|
* d'erreur si le token est absent, invalide ou expiré.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La vue pages/auth/password-reset.twig ou une redirection
|
||||||
|
*/
|
||||||
|
public function showReset(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$token = trim((string) ($req->getQueryParams()['token'] ?? ''));
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
$this->flash->set('reset_error', 'Lien de réinitialisation manquant');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->passwordResetService->validateToken($token);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
$this->flash->set('reset_error', 'Ce lien est invalide ou a expiré. Veuillez faire une nouvelle demande.');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->render($res, 'pages/auth/password-reset.twig', [
|
||||||
|
'token' => $token,
|
||||||
|
'error' => $this->flash->get('reset_error'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite la soumission du nouveau mot de passe.
|
||||||
|
*
|
||||||
|
* Vérifie que les deux mots de passe correspondent, puis délègue
|
||||||
|
* la validation du token et la mise à jour à PasswordResetServiceInterface.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response Redirection vers /auth/login en cas de succès,
|
||||||
|
* ou vers /password/reset?token=… en cas d'erreur
|
||||||
|
*/
|
||||||
|
public function reset(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$token = trim((string) ($data['token'] ?? ''));
|
||||||
|
$new = trim((string) ($data['new_password'] ?? ''));
|
||||||
|
$confirm = trim((string) ($data['new_password_confirm'] ?? ''));
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
$this->flash->set('reset_error', 'Lien de réinitialisation manquant');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($new !== $confirm) {
|
||||||
|
$this->flash->set('reset_error', 'Les mots de passe ne correspondent pas');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->passwordResetService->resetPassword($token, $new);
|
||||||
|
} catch (WeakPasswordException) {
|
||||||
|
$this->flash->set('reset_error', 'Le mot de passe doit contenir au moins 8 caractères');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
|
||||||
|
} catch (InvalidResetTokenException) {
|
||||||
|
$this->flash->set('reset_error', 'Ce lien de réinitialisation est invalide ou a expiré');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->flash->set('reset_error', 'Une erreur inattendue s\'est produite');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash->set('login_success', 'Mot de passe réinitialisé avec succès. Vous pouvez vous connecter');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/auth/login')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/Auth/PasswordResetRepository.php
Normal file
74
src/Auth/PasswordResetRepository.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class PasswordResetRepository implements PasswordResetRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(int $userId, string $tokenHash, string $expiresAt): void
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('
|
||||||
|
INSERT INTO password_resets (user_id, token_hash, expires_at, created_at)
|
||||||
|
VALUES (:user_id, :token_hash, :expires_at, :created_at)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':user_id' => $userId,
|
||||||
|
':token_hash' => $tokenHash,
|
||||||
|
':expires_at' => $expiresAt,
|
||||||
|
':created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findActiveByHash(string $tokenHash): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'SELECT * FROM password_resets WHERE token_hash = :token_hash AND used_at IS NULL'
|
||||||
|
);
|
||||||
|
$stmt->execute([':token_hash' => $tokenHash]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidateByUserId(int $userId): void
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'UPDATE password_resets SET used_at = :used_at WHERE user_id = :user_id AND used_at IS NULL'
|
||||||
|
);
|
||||||
|
$stmt->execute([':used_at' => date('Y-m-d H:i:s'), ':user_id' => $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically consume a token and return the affected row.
|
||||||
|
* Uses UPDATE ... RETURNING to avoid SELECT+UPDATE race conditions.
|
||||||
|
*/
|
||||||
|
public function consumeActiveToken(string $tokenHash, string $usedAt): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
'UPDATE password_resets
|
||||||
|
SET used_at = :used_at
|
||||||
|
WHERE token_hash = :token_hash
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at >= :now
|
||||||
|
RETURNING *'
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':used_at' => $usedAt,
|
||||||
|
':token_hash' => $tokenHash,
|
||||||
|
':now' => $usedAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Auth/PasswordResetRepositoryInterface.php
Normal file
21
src/Auth/PasswordResetRepositoryInterface.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
interface PasswordResetRepositoryInterface
|
||||||
|
{
|
||||||
|
public function create(int $userId, string $tokenHash, string $expiresAt): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consomme atomiquement un token non utilisé et non expiré.
|
||||||
|
*
|
||||||
|
* L'implémentation doit effectuer l'opération en une seule étape SQL
|
||||||
|
* afin d'éviter les courses entre lecture et écriture.
|
||||||
|
*
|
||||||
|
* @param string $tokenHash Hash SHA-256 du token de reset
|
||||||
|
* @param string $usedAt Horodatage de consommation au format SQL
|
||||||
|
* @return array<string, mixed>|null Les données du token consommé, ou null si le token est invalide, expiré ou déjà utilisé
|
||||||
|
*/
|
||||||
|
public function consumeActiveToken(string $tokenHash, string $usedAt): ?array;
|
||||||
|
}
|
||||||
105
src/Auth/PasswordResetService.php
Normal file
105
src/Auth/PasswordResetService.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Auth\Exception\InvalidResetTokenException;
|
||||||
|
use App\Shared\Mail\MailServiceInterface;
|
||||||
|
use App\User\Exception\WeakPasswordException;
|
||||||
|
use App\User\User;
|
||||||
|
use App\User\UserRepositoryInterface;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class PasswordResetService implements PasswordResetServiceInterface
|
||||||
|
{
|
||||||
|
private const TOKEN_TTL_MINUTES = 60;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PasswordResetRepositoryInterface $passwordResetRepository,
|
||||||
|
private readonly UserRepositoryInterface $userRepository,
|
||||||
|
private readonly MailServiceInterface $mailService,
|
||||||
|
private readonly PDO $db,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestReset(string $email, string $baseUrl): void
|
||||||
|
{
|
||||||
|
$user = $this->userRepository->findByEmail(mb_strtolower(trim($email)));
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->passwordResetRepository->invalidateByUserId($user->getId());
|
||||||
|
|
||||||
|
$tokenRaw = bin2hex(random_bytes(32));
|
||||||
|
$tokenHash = hash('sha256', $tokenRaw);
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_MINUTES * 60);
|
||||||
|
|
||||||
|
$this->passwordResetRepository->create($user->getId(), $tokenHash, $expiresAt);
|
||||||
|
|
||||||
|
$resetUrl = rtrim($baseUrl, '/') . '/password/reset?token=' . $tokenRaw;
|
||||||
|
|
||||||
|
$this->mailService->send(
|
||||||
|
to: $user->getEmail(),
|
||||||
|
subject: 'Réinitialisation de votre mot de passe',
|
||||||
|
template: 'emails/password-reset.twig',
|
||||||
|
context: [
|
||||||
|
'username' => $user->getUsername(),
|
||||||
|
'resetUrl' => $resetUrl,
|
||||||
|
'ttlMinutes' => self::TOKEN_TTL_MINUTES,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateToken(string $tokenRaw): ?User
|
||||||
|
{
|
||||||
|
$tokenHash = hash('sha256', $tokenRaw);
|
||||||
|
$row = $this->passwordResetRepository->findActiveByHash($tokenHash);
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtotime((string) $row['expires_at']) < time()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->userRepository->findById((int) $row['user_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(string $tokenRaw, string $newPassword): void
|
||||||
|
{
|
||||||
|
if (mb_strlen(trim($newPassword)) < 8) {
|
||||||
|
throw new WeakPasswordException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$usedAt = date('Y-m-d H:i:s');
|
||||||
|
$newHash = password_hash(trim($newPassword), PASSWORD_BCRYPT, ['cost' => 12]);
|
||||||
|
|
||||||
|
$this->db->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$row = $this->passwordResetRepository->consumeActiveToken(hash('sha256', $tokenRaw), $usedAt);
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
throw new InvalidResetTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userRepository->findById((int) $row['user_id']);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
throw new InvalidResetTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->userRepository->updatePassword($user->getId(), $newHash);
|
||||||
|
$this->db->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($this->db->inTransaction()) {
|
||||||
|
$this->db->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Auth/PasswordResetServiceInterface.php
Normal file
56
src/Auth/PasswordResetServiceInterface.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\Auth\Exception\InvalidResetTokenException;
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat du service de réinitialisation de mot de passe.
|
||||||
|
*
|
||||||
|
* Définit les trois opérations du flux de réinitialisation :
|
||||||
|
* 1. Demande (génération du token + envoi d'e-mail)
|
||||||
|
* 2. Validation du token reçu par e-mail
|
||||||
|
* 3. Réinitialisation effective du mot de passe
|
||||||
|
*/
|
||||||
|
interface PasswordResetServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Génère un token de réinitialisation et envoie le lien par e-mail.
|
||||||
|
*
|
||||||
|
* Retour silencieux si l'e-mail est inconnu — ne révèle pas l'existence du compte.
|
||||||
|
*
|
||||||
|
* @param string $email Adresse e-mail de l'utilisateur
|
||||||
|
* @param string $baseUrl URL de base de l'application (pour construire le lien)
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException Si l'envoi de l'e-mail échoue
|
||||||
|
*/
|
||||||
|
public function requestReset(string $email, string $baseUrl): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide un token brut reçu dans l'URL.
|
||||||
|
*
|
||||||
|
* Calcule le hash SHA-256 du token, vérifie son existence en base
|
||||||
|
* et s'assure qu'il n'est pas expiré ni déjà consommé.
|
||||||
|
*
|
||||||
|
* @param string $tokenRaw Token brut reçu en clair dans l'URL
|
||||||
|
*
|
||||||
|
* @return User|null L'utilisateur associé au token, ou null si invalide/expiré
|
||||||
|
*/
|
||||||
|
public function validateToken(string $tokenRaw): ?User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise le mot de passe d'un utilisateur.
|
||||||
|
*
|
||||||
|
* Valide le token, hache le nouveau mot de passe, met à jour la base
|
||||||
|
* et marque le token comme consommé.
|
||||||
|
*
|
||||||
|
* @param string $tokenRaw Token brut reçu dans l'URL
|
||||||
|
* @param string $newPassword Nouveau mot de passe en clair
|
||||||
|
*
|
||||||
|
* @throws InvalidResetTokenException Si le token est invalide ou expiré
|
||||||
|
* @throws \App\User\Exception\WeakPasswordException Si le mot de passe est trop court
|
||||||
|
*/
|
||||||
|
public function resetPassword(string $tokenRaw, string $newPassword): void;
|
||||||
|
}
|
||||||
91
src/Category/Category.php
Normal file
91
src/Category/Category.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modèle représentant une catégorie d'articles.
|
||||||
|
*
|
||||||
|
* Ce modèle est immuable après construction.
|
||||||
|
* La génération de slug est déléguée à SlugHelper::generate() dans CategoryService,
|
||||||
|
* avant la construction de l'objet.
|
||||||
|
*/
|
||||||
|
final class Category
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $id Identifiant en base (0 pour une nouvelle catégorie)
|
||||||
|
* @param string $name Nom de la catégorie (1–100 caractères)
|
||||||
|
* @param string $slug Slug URL de la catégorie
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si les données ne passent pas la validation
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly string $name,
|
||||||
|
private readonly string $slug,
|
||||||
|
) {
|
||||||
|
$this->validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une instance depuis un tableau associatif (ligne de base de données).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data Données issues de la base de données
|
||||||
|
*
|
||||||
|
* @return self L'instance hydratée
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
id: (int) ($data['id'] ?? 0),
|
||||||
|
name: (string) ($data['name'] ?? ''),
|
||||||
|
slug: (string) ($data['slug'] ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'identifiant de la catégorie.
|
||||||
|
*
|
||||||
|
* @return int L'identifiant en base (0 si non encore persisté)
|
||||||
|
*/
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le nom de la catégorie.
|
||||||
|
*
|
||||||
|
* @return string Le nom
|
||||||
|
*/
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le slug URL de la catégorie.
|
||||||
|
*
|
||||||
|
* @return string Le slug
|
||||||
|
*/
|
||||||
|
public function getSlug(): string
|
||||||
|
{
|
||||||
|
return $this->slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide les données de la catégorie.
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si le nom est vide ou dépasse 100 caractères
|
||||||
|
*/
|
||||||
|
private function validate(): void
|
||||||
|
{
|
||||||
|
if ($this->name === '') {
|
||||||
|
throw new \InvalidArgumentException('Le nom de la catégorie ne peut pas être vide');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($this->name) > 100) {
|
||||||
|
throw new \InvalidArgumentException('Le nom de la catégorie ne peut pas dépasser 100 caractères');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/Category/CategoryController.php
Normal file
114
src/Category/CategoryController.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Category;
|
||||||
|
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôleur pour la gestion des catégories.
|
||||||
|
*
|
||||||
|
* Accessible aux éditeurs et administrateurs (protégé par EditorMiddleware).
|
||||||
|
* Gère la liste des catégories, leur création et leur suppression.
|
||||||
|
* Toute la logique métier (génération de slug, validations, blocage de
|
||||||
|
* suppression) est déléguée à CategoryService.
|
||||||
|
*/
|
||||||
|
final class CategoryController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Twig $view Moteur de templates Twig
|
||||||
|
* @param CategoryServiceInterface $categoryService Service de gestion des catégories
|
||||||
|
* @param FlashServiceInterface $flash Service de messages flash
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly CategoryServiceInterface $categoryService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche la liste des catégories avec le formulaire de création.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La vue de gestion des catégories
|
||||||
|
*/
|
||||||
|
public function index(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
return $this->view->render($res, 'admin/categories/index.twig', [
|
||||||
|
'categories' => $this->categoryService->findAll(),
|
||||||
|
'error' => $this->flash->get('category_error'),
|
||||||
|
'success' => $this->flash->get('category_success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite la création d'une catégorie.
|
||||||
|
*
|
||||||
|
* Délègue entièrement à CategoryService::create() qui gère la génération
|
||||||
|
* du slug, la validation d'unicité et la validation du modèle.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response Une redirection vers /admin/categories
|
||||||
|
*/
|
||||||
|
public function create(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$name = (string) ($data['name'] ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->categoryService->create($name);
|
||||||
|
$trimmed = trim($name);
|
||||||
|
$this->flash->set('category_success', "La catégorie « {$trimmed} » a été créée avec succès");
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$this->flash->set('category_error', $e->getMessage());
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->flash->set('category_error', "Une erreur inattendue s'est produite");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une catégorie.
|
||||||
|
*
|
||||||
|
* Délègue à CategoryService::delete() qui refuse la suppression si des
|
||||||
|
* articles sont rattachés à la catégorie.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
* @param array<string, string> $args Paramètres de route (id)
|
||||||
|
*
|
||||||
|
* @return Response Une redirection vers /admin/categories
|
||||||
|
*/
|
||||||
|
public function delete(Request $req, Response $res, array $args): Response
|
||||||
|
{
|
||||||
|
$id = (int) ($args['id'] ?? 0);
|
||||||
|
$category = $this->categoryService->findById($id);
|
||||||
|
|
||||||
|
if ($category === null) {
|
||||||
|
$this->flash->set('category_error', 'Catégorie introuvable');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->categoryService->delete($category);
|
||||||
|
$this->flash->set('category_success', "La catégorie « {$category->getName()} » a été supprimée");
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$this->flash->set('category_error', $e->getMessage());
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->flash->set('category_error', "Une erreur inattendue s'est produite");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/Category/CategoryRepository.php
Normal file
134
src/Category/CategoryRepository.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Category;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dépôt pour la persistance des catégories.
|
||||||
|
*
|
||||||
|
* Responsabilité unique : exécuter les requêtes SQL liées à la table `categories`
|
||||||
|
* et retourner des instances de Category hydratées.
|
||||||
|
*/
|
||||||
|
final class CategoryRepository implements CategoryRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param PDO $db Instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly PDO $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne toutes les catégories triées alphabétiquement.
|
||||||
|
*
|
||||||
|
* @return Category[] La liste des catégories
|
||||||
|
*/
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->query('SELECT * FROM categories ORDER BY name ASC');
|
||||||
|
if ($stmt === false) {
|
||||||
|
throw new \RuntimeException('La requête SELECT sur categories a échoué.');
|
||||||
|
}
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => Category::fromArray($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une catégorie par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de la catégorie
|
||||||
|
*
|
||||||
|
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Category
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT * FROM categories WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ? Category::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une catégorie par son slug URL.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug de la catégorie
|
||||||
|
*
|
||||||
|
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||||
|
*/
|
||||||
|
public function findBySlug(string $slug): ?Category
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT * FROM categories WHERE slug = :slug');
|
||||||
|
$stmt->execute([':slug' => $slug]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ? Category::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste une nouvelle catégorie en base de données.
|
||||||
|
*
|
||||||
|
* @param Category $category La catégorie à créer
|
||||||
|
*
|
||||||
|
* @return int L'identifiant généré par la base de données
|
||||||
|
*/
|
||||||
|
public function create(Category $category): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('INSERT INTO categories (name, slug) VALUES (:name, :slug)');
|
||||||
|
$stmt->execute([':name' => $category->getName(), ':slug' => $category->getSlug()]);
|
||||||
|
|
||||||
|
return (int) $this->db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une catégorie de la base de données.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de la catégorie à supprimer
|
||||||
|
*
|
||||||
|
* @return int Nombre de lignes supprimées (0 si la catégorie n'existe plus)
|
||||||
|
*/
|
||||||
|
public function delete(int $id): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un nom est déjà utilisé par une catégorie existante.
|
||||||
|
*
|
||||||
|
* @param string $name Le nom à vérifier
|
||||||
|
*
|
||||||
|
* @return bool True si le nom est déjà pris
|
||||||
|
*/
|
||||||
|
public function nameExists(string $name): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT 1 FROM categories WHERE name = :name');
|
||||||
|
$stmt->execute([':name' => $name]);
|
||||||
|
|
||||||
|
return $stmt->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si au moins un article est rattaché à cette catégorie.
|
||||||
|
*
|
||||||
|
* Utilisé avant suppression pour bloquer la suppression d'une catégorie non vide.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de la catégorie
|
||||||
|
*
|
||||||
|
* @return bool True si au moins un article référence cette catégorie
|
||||||
|
*/
|
||||||
|
public function hasPost(int $id): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT 1 FROM posts WHERE category_id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
|
||||||
|
return $stmt->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/Category/CategoryRepositoryInterface.php
Normal file
74
src/Category/CategoryRepositoryInterface.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat de persistance des catégories.
|
||||||
|
*
|
||||||
|
* Découple les services et contrôleurs de l'implémentation concrète PDO/SQLite,
|
||||||
|
* facilitant les mocks dans les tests unitaires.
|
||||||
|
*/
|
||||||
|
interface CategoryRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retourne toutes les catégories triées alphabétiquement.
|
||||||
|
*
|
||||||
|
* @return Category[]
|
||||||
|
*/
|
||||||
|
public function findAll(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une catégorie par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de la catégorie
|
||||||
|
*
|
||||||
|
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une catégorie par son slug URL.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug de la catégorie
|
||||||
|
*
|
||||||
|
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||||
|
*/
|
||||||
|
public function findBySlug(string $slug): ?Category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste une nouvelle catégorie en base de données.
|
||||||
|
*
|
||||||
|
* @param Category $category La catégorie à créer
|
||||||
|
*
|
||||||
|
* @return int L'identifiant généré par la base de données
|
||||||
|
*/
|
||||||
|
public function create(Category $category): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une catégorie de la base de données.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de la catégorie à supprimer
|
||||||
|
*
|
||||||
|
* @return int Nombre de lignes supprimées
|
||||||
|
*/
|
||||||
|
public function delete(int $id): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un nom est déjà utilisé par une catégorie existante.
|
||||||
|
*
|
||||||
|
* @param string $name Le nom à vérifier
|
||||||
|
*
|
||||||
|
* @return bool True si le nom est déjà pris
|
||||||
|
*/
|
||||||
|
public function nameExists(string $name): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si au moins un article est rattaché à cette catégorie.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de la catégorie
|
||||||
|
*
|
||||||
|
* @return bool True si au moins un article référence cette catégorie
|
||||||
|
*/
|
||||||
|
public function hasPost(int $id): bool;
|
||||||
|
}
|
||||||
120
src/Category/CategoryService.php
Normal file
120
src/Category/CategoryService.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Category;
|
||||||
|
|
||||||
|
use App\Shared\Util\SlugHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de gestion des catégories.
|
||||||
|
*
|
||||||
|
* Centralise la logique métier liée aux catégories :
|
||||||
|
* - génération et validation du slug à la création
|
||||||
|
* - vérification d'unicité du nom
|
||||||
|
* - blocage de la suppression si des articles sont rattachés
|
||||||
|
*
|
||||||
|
* Les lectures (findAll, findById, findBySlug) sont exposées ici
|
||||||
|
* pour que CategoryController et PostController n'injectent pas
|
||||||
|
* directement le repository — cohérent avec le pattern des autres domaines.
|
||||||
|
*/
|
||||||
|
final class CategoryService implements CategoryServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param CategoryRepositoryInterface $categoryRepository Dépôt de persistance des catégories
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly CategoryRepositoryInterface $categoryRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne toutes les catégories triées alphabétiquement.
|
||||||
|
*
|
||||||
|
* @return Category[]
|
||||||
|
*/
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
return $this->categoryRepository->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une catégorie par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de la catégorie
|
||||||
|
*
|
||||||
|
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Category
|
||||||
|
{
|
||||||
|
return $this->categoryRepository->findById($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une catégorie par son slug URL.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug de la catégorie
|
||||||
|
*
|
||||||
|
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||||
|
*/
|
||||||
|
public function findBySlug(string $slug): ?Category
|
||||||
|
{
|
||||||
|
return $this->categoryRepository->findBySlug($slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une catégorie depuis un nom brut.
|
||||||
|
*
|
||||||
|
* Séquence :
|
||||||
|
* 1. Trim du nom
|
||||||
|
* 2. Génération du slug via SlugHelper
|
||||||
|
* 3. Rejet si le slug est vide (nom sans caractère ASCII exploitable)
|
||||||
|
* 4. Rejet si le nom est déjà utilisé
|
||||||
|
* 5. Construction du modèle (déclenche la validation longueur/vide)
|
||||||
|
* 6. Persistance
|
||||||
|
*
|
||||||
|
* @param string $name Nom brut de la catégorie (non encore trimmé)
|
||||||
|
*
|
||||||
|
* @return int L'identifiant de la catégorie créée
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si le slug est vide, le nom déjà pris,
|
||||||
|
* ou si la validation du modèle échoue
|
||||||
|
*/
|
||||||
|
public function create(string $name): int
|
||||||
|
{
|
||||||
|
$name = trim($name);
|
||||||
|
$slug = SlugHelper::generate($name);
|
||||||
|
|
||||||
|
if ($slug === '') {
|
||||||
|
throw new \InvalidArgumentException('Le nom fourni ne peut pas générer un slug URL valide');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->categoryRepository->nameExists($name)) {
|
||||||
|
throw new \InvalidArgumentException('Ce nom de catégorie est déjà utilisé');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le constructeur de Category valide le nom (vide, longueur max)
|
||||||
|
return $this->categoryRepository->create(new Category(0, $name, $slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une catégorie.
|
||||||
|
*
|
||||||
|
* Refuse la suppression si au moins un article est rattaché à la catégorie,
|
||||||
|
* afin d'éviter des articles sans catégorie de façon involontaire.
|
||||||
|
*
|
||||||
|
* @param Category $category La catégorie à supprimer
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si la catégorie contient des articles
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function delete(Category $category): void
|
||||||
|
{
|
||||||
|
if ($this->categoryRepository->hasPost($category->getId())) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
"La catégorie « {$category->getName()} » contient des articles et ne peut pas être supprimée"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->categoryRepository->delete($category->getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/Category/CategoryServiceInterface.php
Normal file
64
src/Category/CategoryServiceInterface.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat du service de gestion des catégories.
|
||||||
|
*
|
||||||
|
* Permet de mocker le service dans les tests unitaires sans dépendre
|
||||||
|
* de la classe concrète finale CategoryService.
|
||||||
|
*/
|
||||||
|
interface CategoryServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retourne toutes les catégories triées alphabétiquement.
|
||||||
|
*
|
||||||
|
* @return Category[]
|
||||||
|
*/
|
||||||
|
public function findAll(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une catégorie par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de la catégorie
|
||||||
|
*
|
||||||
|
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une catégorie par son slug URL.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug de la catégorie
|
||||||
|
*
|
||||||
|
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||||
|
*/
|
||||||
|
public function findBySlug(string $slug): ?Category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une catégorie depuis un nom brut.
|
||||||
|
*
|
||||||
|
* Génère le slug, valide l'unicité du nom et délègue la construction
|
||||||
|
* du modèle au constructeur de Category (qui valide taille et contenu).
|
||||||
|
*
|
||||||
|
* @param string $name Nom brut de la catégorie (non encore trimmé)
|
||||||
|
*
|
||||||
|
* @return int L'identifiant de la catégorie créée
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si le nom produit un slug vide ou est déjà utilisé
|
||||||
|
*/
|
||||||
|
public function create(string $name): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une catégorie.
|
||||||
|
*
|
||||||
|
* Refuse la suppression si des articles sont rattachés à la catégorie.
|
||||||
|
*
|
||||||
|
* @param Category $category La catégorie à supprimer
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si la catégorie contient des articles
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function delete(Category $category): void;
|
||||||
|
}
|
||||||
16
src/Media/Exception/FileTooLargeException.php
Normal file
16
src/Media/Exception/FileTooLargeException.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception levée lorsqu'un fichier uploadé dépasse la taille autorisée.
|
||||||
|
*/
|
||||||
|
final class FileTooLargeException extends \InvalidArgumentException
|
||||||
|
{
|
||||||
|
public function __construct(int $maxBytes)
|
||||||
|
{
|
||||||
|
$mb = round($maxBytes / 1024 / 1024);
|
||||||
|
parent::__construct("Fichier trop volumineux (maximum {$mb} Mo)");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Media/Exception/InvalidMimeTypeException.php
Normal file
15
src/Media/Exception/InvalidMimeTypeException.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception levée lorsqu'un fichier uploadé a un type MIME non autorisé.
|
||||||
|
*/
|
||||||
|
final class InvalidMimeTypeException extends \InvalidArgumentException
|
||||||
|
{
|
||||||
|
public function __construct(string $mime)
|
||||||
|
{
|
||||||
|
parent::__construct("Type de fichier non autorisé : {$mime} (JPEG, PNG, GIF ou WebP uniquement)");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Media/Exception/StorageException.php
Normal file
12
src/Media/Exception/StorageException.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception levée lorsqu'une opération sur le système de fichiers échoue
|
||||||
|
* (création de répertoire, copie ou déplacement d'un fichier converti).
|
||||||
|
*/
|
||||||
|
final class StorageException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
130
src/Media/Media.php
Normal file
130
src/Media/Media.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media;
|
||||||
|
|
||||||
|
use App\Shared\Util\DateParser;
|
||||||
|
use DateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modèle représentant un fichier média uploadé.
|
||||||
|
*
|
||||||
|
* Encapsule les métadonnées d'un fichier stocké dans public/media/.
|
||||||
|
* Le fichier physique est identifié par son nom de stockage opaque (filename),
|
||||||
|
* distinct du nom affiché à l'utilisateur.
|
||||||
|
*
|
||||||
|
* Le hash SHA-256 du contenu permet la détection des doublons à l'upload :
|
||||||
|
* si un fichier identique a déjà été uploadé, son URL est retournée
|
||||||
|
* directement sans créer un second fichier sur disque.
|
||||||
|
*/
|
||||||
|
final class Media
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DateTime Date d'upload — toujours non nulle après construction
|
||||||
|
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
|
||||||
|
*/
|
||||||
|
private readonly DateTime $createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $id Identifiant en base (0 pour un nouveau média)
|
||||||
|
* @param string $filename Nom de stockage opaque sur disque (ex: "a3f8c1d2_9f33.jpg")
|
||||||
|
* @param string $url URL publique d'accès au fichier (ex: "/media/a3f8c1d2_9f33.jpg")
|
||||||
|
* @param string $hash Hash SHA-256 du contenu binaire du fichier
|
||||||
|
* @param int|null $userId Identifiant de l'auteur (null si le compte a été supprimé)
|
||||||
|
* @param DateTime|null $createdAt Date d'upload (défaut : maintenant)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly string $filename,
|
||||||
|
private readonly string $url,
|
||||||
|
private readonly string $hash,
|
||||||
|
private readonly ?int $userId,
|
||||||
|
?DateTime $createdAt = null,
|
||||||
|
) {
|
||||||
|
$this->createdAt = $createdAt ?? new DateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une instance depuis un tableau associatif (ligne de base de données).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data Données issues de la base de données
|
||||||
|
*
|
||||||
|
* @return self L'instance hydratée
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
id: (int) ($data['id'] ?? 0),
|
||||||
|
filename: (string) ($data['filename'] ?? ''),
|
||||||
|
url: (string) ($data['url'] ?? ''),
|
||||||
|
hash: (string) ($data['hash'] ?? ''),
|
||||||
|
userId: isset($data['user_id']) ? (int) $data['user_id'] : null,
|
||||||
|
createdAt: DateParser::parse($data['created_at'] ?? null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'identifiant du média.
|
||||||
|
*
|
||||||
|
* @return int L'identifiant en base (0 si non encore persisté)
|
||||||
|
*/
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le nom de stockage du fichier sur disque.
|
||||||
|
*
|
||||||
|
* Ce nom est opaque et généré aléatoirement à l'upload.
|
||||||
|
* Il ne doit pas être affiché à l'utilisateur tel quel.
|
||||||
|
*
|
||||||
|
* @return string Le nom de fichier sur disque
|
||||||
|
*/
|
||||||
|
public function getFilename(): string
|
||||||
|
{
|
||||||
|
return $this->filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'URL publique d'accès au fichier.
|
||||||
|
*
|
||||||
|
* @return string L'URL publique (ex: "/media/a3f8c1d2_9f33.jpg")
|
||||||
|
*/
|
||||||
|
public function getUrl(): string
|
||||||
|
{
|
||||||
|
return $this->url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le hash SHA-256 du contenu binaire du fichier.
|
||||||
|
*
|
||||||
|
* Utilisé pour la détection des doublons à l'upload.
|
||||||
|
*
|
||||||
|
* @return string Le hash hexadécimal SHA-256
|
||||||
|
*/
|
||||||
|
public function getHash(): string
|
||||||
|
{
|
||||||
|
return $this->hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'identifiant de l'auteur du média.
|
||||||
|
*
|
||||||
|
* @return int|null L'identifiant de l'auteur, ou null si le compte a été supprimé
|
||||||
|
*/
|
||||||
|
public function getUserId(): ?int
|
||||||
|
{
|
||||||
|
return $this->userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la date d'upload du fichier.
|
||||||
|
*
|
||||||
|
* @return DateTime La date d'upload
|
||||||
|
*/
|
||||||
|
public function getCreatedAt(): DateTime
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/Media/MediaController.php
Normal file
180
src/Media/MediaController.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media;
|
||||||
|
|
||||||
|
use App\Media\Exception\FileTooLargeException;
|
||||||
|
use App\Media\Exception\InvalidMimeTypeException;
|
||||||
|
use App\Media\Exception\StorageException;
|
||||||
|
use App\Media\MediaServiceInterface;
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôleur du domaine Media.
|
||||||
|
*
|
||||||
|
* Gère deux responsabilités HTTP :
|
||||||
|
* 1. Upload d'images depuis l'éditeur Trumbowyg (réponse JSON)
|
||||||
|
* 2. Administration des médias uploadés (liste, suppression)
|
||||||
|
*
|
||||||
|
* Toute la logique métier (validation, conversion WebP, déduplication,
|
||||||
|
* stockage disque) est déléguée à MediaService via MediaServiceInterface.
|
||||||
|
*
|
||||||
|
* Droits d'accès :
|
||||||
|
* - Upload : tout utilisateur connecté
|
||||||
|
* - Liste : chaque utilisateur voit uniquement ses propres médias ;
|
||||||
|
* l'administrateur et l'éditeur voient tous les médias
|
||||||
|
* - Suppression : propriétaire du média, éditeur ou administrateur
|
||||||
|
*/
|
||||||
|
final class MediaController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Twig $view Moteur de templates Twig
|
||||||
|
* @param MediaServiceInterface $mediaService Service de gestion des médias
|
||||||
|
* @param FlashServiceInterface $flash Service de messages flash
|
||||||
|
* @param SessionManagerInterface $sessionManager Gestionnaire de session
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly MediaServiceInterface $mediaService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
private readonly SessionManagerInterface $sessionManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche la page de gestion des médias.
|
||||||
|
*
|
||||||
|
* Un éditeur ou un administrateur voit tous les médias.
|
||||||
|
* Un utilisateur avec le rôle 'user' voit uniquement ses propres médias.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La page HTML de gestion des médias
|
||||||
|
*/
|
||||||
|
public function index(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
|
||||||
|
$userId = $this->sessionManager->getUserId();
|
||||||
|
|
||||||
|
$media = $isAdmin
|
||||||
|
? $this->mediaService->findAll()
|
||||||
|
: $this->mediaService->findByUserId((int) $userId);
|
||||||
|
|
||||||
|
return $this->view->render($res, 'admin/media/index.twig', [
|
||||||
|
'media' => $media,
|
||||||
|
'error' => $this->flash->get('media_error'),
|
||||||
|
'success' => $this->flash->get('media_success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite l'upload d'une image envoyée par le plugin Trumbowyg Upload.
|
||||||
|
*
|
||||||
|
* Vérifie la présence et l'absence d'erreur PSR-7 avant de déléguer
|
||||||
|
* à MediaService. Les erreurs métier (taille, MIME, stockage) sont
|
||||||
|
* converties en réponses JSON avec le code HTTP approprié.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP multipart contenant le champ "image"
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response JSON {"success": true, "file": "/media/..."} ou {"error": "..."}
|
||||||
|
*/
|
||||||
|
public function upload(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$files = $req->getUploadedFiles();
|
||||||
|
$uploadedFile = $files['image'] ?? null;
|
||||||
|
|
||||||
|
if ($uploadedFile === null || $uploadedFile->getError() !== UPLOAD_ERR_OK) {
|
||||||
|
return $this->jsonError($res, "Aucun fichier reçu ou erreur d'upload", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$url = $this->mediaService->store($uploadedFile, $this->sessionManager->getUserId() ?? 0);
|
||||||
|
} catch (FileTooLargeException $e) {
|
||||||
|
return $this->jsonError($res, $e->getMessage(), 413);
|
||||||
|
} catch (InvalidMimeTypeException $e) {
|
||||||
|
return $this->jsonError($res, $e->getMessage(), 415);
|
||||||
|
} catch (StorageException $e) {
|
||||||
|
return $this->jsonError($res, $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->jsonSuccess($res, $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un média (fichier sur disque + entrée en base).
|
||||||
|
*
|
||||||
|
* Vérifie que l'utilisateur connecté est le propriétaire du média
|
||||||
|
* ou un administrateur / éditeur. Redirige avec un message flash dans les deux cas.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
* @param array<string, string> $args Paramètres de route (id)
|
||||||
|
*
|
||||||
|
* @return Response Redirection vers /admin/media
|
||||||
|
*/
|
||||||
|
public function delete(Request $req, Response $res, array $args): Response
|
||||||
|
{
|
||||||
|
$id = (int) ($args['id'] ?? 0);
|
||||||
|
$media = $this->mediaService->findById($id);
|
||||||
|
|
||||||
|
if ($media === null) {
|
||||||
|
$this->flash->set('media_error', 'Fichier introuvable');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/media')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $this->sessionManager->getUserId();
|
||||||
|
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
|
||||||
|
|
||||||
|
if (!$isAdmin && $media->getUserId() !== $userId) {
|
||||||
|
$this->flash->set('media_error', "Vous n'êtes pas autorisé à supprimer ce fichier");
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/media')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mediaService->delete($media);
|
||||||
|
$this->flash->set('media_success', 'Fichier supprimé');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/media')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne une réponse JSON de succès avec l'URL du fichier uploadé.
|
||||||
|
*
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
* @param string $fileUrl L'URL publique du fichier
|
||||||
|
*
|
||||||
|
* @return Response La réponse JSON {"success": true, "file": "..."}
|
||||||
|
*/
|
||||||
|
private function jsonSuccess(Response $res, string $fileUrl): Response
|
||||||
|
{
|
||||||
|
$res->getBody()->write(json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'file' => $fileUrl,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return $res->withHeader('Content-Type', 'application/json')->withStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne une réponse JSON d'erreur.
|
||||||
|
*
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
* @param string $message Le message d'erreur
|
||||||
|
* @param int $status Le code HTTP de l'erreur
|
||||||
|
*
|
||||||
|
* @return Response La réponse JSON {"error": "..."}
|
||||||
|
*/
|
||||||
|
private function jsonError(Response $res, string $message, int $status): Response
|
||||||
|
{
|
||||||
|
$res->getBody()->write(json_encode(['error' => $message], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return $res->withHeader('Content-Type', 'application/json')->withStatus($status);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/Media/MediaRepository.php
Normal file
139
src/Media/MediaRepository.php
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dépôt pour la persistance des médias uploadés.
|
||||||
|
*
|
||||||
|
* Responsabilité unique : exécuter les requêtes SQL liées à la table `media`
|
||||||
|
* et retourner des instances de Media hydratées.
|
||||||
|
*/
|
||||||
|
final class MediaRepository implements MediaRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Fragment SELECT commun à toutes les requêtes de lecture.
|
||||||
|
*/
|
||||||
|
private const SELECT = 'SELECT id, filename, url, hash, user_id, created_at FROM media';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PDO $db Instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly PDO $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les médias triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @return Media[] La liste complète des médias
|
||||||
|
*/
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->query(self::SELECT . ' ORDER BY id DESC');
|
||||||
|
if ($stmt === false) {
|
||||||
|
throw new \RuntimeException('La requête SELECT sur media a échoué.');
|
||||||
|
}
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => Media::fromArray($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les médias appartenant à un utilisateur donné,
|
||||||
|
* triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @param int $userId Identifiant de l'utilisateur
|
||||||
|
*
|
||||||
|
* @return Media[] La liste des médias de cet utilisateur
|
||||||
|
*/
|
||||||
|
public function findByUserId(int $userId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC');
|
||||||
|
$stmt->execute([':user_id' => $userId]);
|
||||||
|
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => Media::fromArray($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un média par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant du média
|
||||||
|
*
|
||||||
|
* @return Media|null Le média trouvé, ou null s'il n'existe pas
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Media
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(self::SELECT . ' WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ? Media::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un média par le hash SHA-256 de son contenu.
|
||||||
|
*
|
||||||
|
* Utilisé pour la détection des doublons à l'upload.
|
||||||
|
*
|
||||||
|
* @param string $hash Hash SHA-256 du contenu binaire du fichier
|
||||||
|
*
|
||||||
|
* @return Media|null Le média existant, ou null si aucun doublon
|
||||||
|
*/
|
||||||
|
public function findByHash(string $hash): ?Media
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash');
|
||||||
|
$stmt->execute([':hash' => $hash]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ? Media::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste un nouveau média en base de données.
|
||||||
|
*
|
||||||
|
* @param Media $media Le média à créer
|
||||||
|
*
|
||||||
|
* @return int L'identifiant généré par la base de données
|
||||||
|
*/
|
||||||
|
public function create(Media $media): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('
|
||||||
|
INSERT INTO media (filename, url, hash, user_id, created_at)
|
||||||
|
VALUES (:filename, :url, :hash, :user_id, :created_at)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':filename' => $media->getFilename(),
|
||||||
|
':url' => $media->getUrl(),
|
||||||
|
':hash' => $media->getHash(),
|
||||||
|
':user_id' => $media->getUserId(),
|
||||||
|
':created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un média de la base de données.
|
||||||
|
*
|
||||||
|
* La suppression du fichier physique sur disque est à la charge de l'appelant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant du média à supprimer
|
||||||
|
*
|
||||||
|
* @return int Nombre de lignes supprimées (0 si le média n'existe plus)
|
||||||
|
*/
|
||||||
|
public function delete(int $id): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('DELETE FROM media WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/Media/MediaRepositoryInterface.php
Normal file
65
src/Media/MediaRepositoryInterface.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat de persistance des médias uploadés.
|
||||||
|
*
|
||||||
|
* Découple les contrôleurs de l'implémentation concrète PDO/SQLite,
|
||||||
|
* facilitant les mocks dans les tests unitaires.
|
||||||
|
*/
|
||||||
|
interface MediaRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retourne tous les médias triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @return Media[]
|
||||||
|
*/
|
||||||
|
public function findAll(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les médias d'un utilisateur donné.
|
||||||
|
*
|
||||||
|
* @param int $userId Identifiant de l'utilisateur
|
||||||
|
*
|
||||||
|
* @return Media[]
|
||||||
|
*/
|
||||||
|
public function findByUserId(int $userId): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un média par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant du média
|
||||||
|
*
|
||||||
|
* @return Media|null Le média trouvé, ou null s'il n'existe pas
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Media;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un média par le hash SHA-256 de son contenu (déduplication).
|
||||||
|
*
|
||||||
|
* @param string $hash Hash SHA-256 du contenu binaire du fichier
|
||||||
|
*
|
||||||
|
* @return Media|null Le média existant, ou null si aucun doublon
|
||||||
|
*/
|
||||||
|
public function findByHash(string $hash): ?Media;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste un nouveau média en base de données.
|
||||||
|
*
|
||||||
|
* @param Media $media Le média à créer
|
||||||
|
*
|
||||||
|
* @return int L'identifiant généré par la base de données
|
||||||
|
*/
|
||||||
|
public function create(Media $media): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un média de la base de données.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant du média à supprimer
|
||||||
|
*
|
||||||
|
* @return int Nombre de lignes supprimées
|
||||||
|
*/
|
||||||
|
public function delete(int $id): int;
|
||||||
|
}
|
||||||
228
src/Media/MediaService.php
Normal file
228
src/Media/MediaService.php
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media;
|
||||||
|
|
||||||
|
use App\Media\Exception\FileTooLargeException;
|
||||||
|
use App\Media\Exception\InvalidMimeTypeException;
|
||||||
|
use App\Media\Exception\StorageException;
|
||||||
|
use PDOException;
|
||||||
|
use Psr\Http\Message\UploadedFileInterface;
|
||||||
|
|
||||||
|
final class MediaService implements MediaServiceInterface
|
||||||
|
{
|
||||||
|
private const ALLOWED_MIME_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const WEBP_CONVERTIBLE = ['image/jpeg', 'image/png'];
|
||||||
|
|
||||||
|
private const MIME_EXTENSIONS = [
|
||||||
|
'image/jpeg' => 'webp',
|
||||||
|
'image/png' => 'webp',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const MIME_EXTENSIONS_FALLBACK = [
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const MAX_PIXELS = 40000000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly MediaRepositoryInterface $mediaRepository,
|
||||||
|
private readonly string $uploadDir,
|
||||||
|
private readonly string $uploadUrl,
|
||||||
|
private readonly int $maxSize,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
return $this->mediaRepository->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByUserId(int $userId): array
|
||||||
|
{
|
||||||
|
return $this->mediaRepository->findByUserId($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?Media
|
||||||
|
{
|
||||||
|
return $this->mediaRepository->findById($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UploadedFileInterface $uploadedFile, int $userId): string
|
||||||
|
{
|
||||||
|
$size = $uploadedFile->getSize();
|
||||||
|
|
||||||
|
if (!is_int($size)) {
|
||||||
|
throw new StorageException('Impossible de déterminer la taille du fichier uploadé');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($size > $this->maxSize) {
|
||||||
|
throw new FileTooLargeException($this->maxSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpPathRaw = $uploadedFile->getStream()->getMetadata('uri');
|
||||||
|
|
||||||
|
if (!is_string($tmpPathRaw) || $tmpPathRaw === '') {
|
||||||
|
throw new StorageException('Impossible de localiser le fichier temporaire uploadé');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpPath = $tmpPathRaw;
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo->file($tmpPath);
|
||||||
|
|
||||||
|
if ($mime === false || !in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
|
||||||
|
throw new InvalidMimeTypeException($mime === false ? 'unknown' : $mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertReasonableDimensions($tmpPath);
|
||||||
|
|
||||||
|
$converted = false;
|
||||||
|
if (in_array($mime, self::WEBP_CONVERTIBLE, true)) {
|
||||||
|
$convertedPath = $this->convertToWebP($tmpPath);
|
||||||
|
if ($convertedPath !== null) {
|
||||||
|
$tmpPath = $convertedPath;
|
||||||
|
$converted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawHash = hash_file('sha256', $tmpPath);
|
||||||
|
|
||||||
|
if ($rawHash === false) {
|
||||||
|
if ($converted) {
|
||||||
|
@unlink($tmpPath);
|
||||||
|
}
|
||||||
|
throw new StorageException('Impossible de calculer le hash du fichier');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = $rawHash;
|
||||||
|
$existing = $this->mediaRepository->findByHash($hash);
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
if ($converted) {
|
||||||
|
@unlink($tmpPath);
|
||||||
|
}
|
||||||
|
return $existing->getUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($this->uploadDir) && !@mkdir($this->uploadDir, 0755, true)) {
|
||||||
|
throw new StorageException("Impossible de créer le répertoire d'upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = $converted
|
||||||
|
? self::MIME_EXTENSIONS[$mime]
|
||||||
|
: self::MIME_EXTENSIONS_FALLBACK[$mime];
|
||||||
|
$filename = uniqid('', true) . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
|
||||||
|
$destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
|
||||||
|
|
||||||
|
if ($converted) {
|
||||||
|
if (!copy($tmpPath, $destPath)) {
|
||||||
|
@unlink($tmpPath);
|
||||||
|
throw new StorageException('Impossible de déplacer le fichier converti');
|
||||||
|
}
|
||||||
|
@unlink($tmpPath);
|
||||||
|
} else {
|
||||||
|
$uploadedFile->moveTo($destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->uploadUrl . '/' . $filename;
|
||||||
|
$media = new Media(0, $filename, $url, $hash, $userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->mediaRepository->create($media);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$duplicate = $this->mediaRepository->findByHash($hash);
|
||||||
|
if ($duplicate !== null) {
|
||||||
|
@unlink($destPath);
|
||||||
|
return $duplicate->getUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($destPath);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Media $media): void
|
||||||
|
{
|
||||||
|
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $media->getFilename();
|
||||||
|
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
@unlink($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mediaRepository->delete($media->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertReasonableDimensions(string $path): void
|
||||||
|
{
|
||||||
|
$size = @getimagesize($path);
|
||||||
|
|
||||||
|
if ($size === false) {
|
||||||
|
throw new StorageException('Impossible de lire les dimensions de l\'image');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$width, $height] = $size;
|
||||||
|
|
||||||
|
if ($width <= 0 || $height <= 0) {
|
||||||
|
throw new StorageException('Dimensions d\'image invalides');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($width * $height) > self::MAX_PIXELS) {
|
||||||
|
throw new StorageException('Image trop volumineuse en dimensions pour être traitée');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function convertToWebP(string $sourcePath): ?string
|
||||||
|
{
|
||||||
|
if (!function_exists('imagewebp')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = file_get_contents($sourcePath);
|
||||||
|
|
||||||
|
if ($data === false || $data === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = imagecreatefromstring($data);
|
||||||
|
|
||||||
|
if ($image === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
imagealphablending($image, false);
|
||||||
|
imagesavealpha($image, true);
|
||||||
|
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_webp_');
|
||||||
|
|
||||||
|
if ($tmpFile === false) {
|
||||||
|
imagedestroy($image);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($tmpFile);
|
||||||
|
$tmpPath = $tmpFile . '.webp';
|
||||||
|
|
||||||
|
if (!imagewebp($image, $tmpPath, 85)) {
|
||||||
|
imagedestroy($image);
|
||||||
|
@unlink($tmpPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
imagedestroy($image);
|
||||||
|
|
||||||
|
return $tmpPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Media/MediaServiceInterface.php
Normal file
62
src/Media/MediaServiceInterface.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Media;
|
||||||
|
|
||||||
|
use Psr\Http\Message\UploadedFileInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat du service de gestion des médias.
|
||||||
|
*
|
||||||
|
* Permet de mocker le service dans les tests unitaires sans dépendre
|
||||||
|
* de la classe concrète finale MediaService.
|
||||||
|
*/
|
||||||
|
interface MediaServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retourne tous les médias triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @return Media[]
|
||||||
|
*/
|
||||||
|
public function findAll(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les médias appartenant à un utilisateur donné.
|
||||||
|
*
|
||||||
|
* @param int $userId Identifiant de l'utilisateur
|
||||||
|
*
|
||||||
|
* @return Media[]
|
||||||
|
*/
|
||||||
|
public function findByUserId(int $userId): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un média par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant du média
|
||||||
|
*
|
||||||
|
* @return Media|null Le média trouvé, ou null s'il n'existe pas
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Media;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide, convertit, déduplique et stocke un fichier uploadé.
|
||||||
|
*
|
||||||
|
* @param UploadedFileInterface $uploadedFile Le fichier PSR-7 reçu
|
||||||
|
* @param int $userId Identifiant de l'auteur
|
||||||
|
*
|
||||||
|
* @return string L'URL publique du fichier stocké
|
||||||
|
*
|
||||||
|
* @throws \App\Media\Exception\FileTooLargeException Si la taille dépasse le maximum autorisé
|
||||||
|
* @throws \App\Media\Exception\InvalidMimeTypeException Si le type MIME n'est pas autorisé
|
||||||
|
* @throws \App\Media\Exception\StorageException Si une opération disque échoue
|
||||||
|
*/
|
||||||
|
public function store(UploadedFileInterface $uploadedFile, int $userId): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un média : fichier physique sur disque et entrée en base.
|
||||||
|
*
|
||||||
|
* @param Media $media Le média à supprimer
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function delete(Media $media): void;
|
||||||
|
}
|
||||||
252
src/Post/Post.php
Normal file
252
src/Post/Post.php
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Post;
|
||||||
|
|
||||||
|
use App\Shared\Util\DateParser;
|
||||||
|
use App\Shared\Util\SlugHelper;
|
||||||
|
use DateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modèle représentant un article de blog.
|
||||||
|
*
|
||||||
|
* Encapsule les données et la validation d'un article.
|
||||||
|
* Ce modèle est immuable après construction.
|
||||||
|
* Le nom d'auteur est dénormalisé (chargé par JOIN dans PostRepository)
|
||||||
|
* pour éviter des requêtes supplémentaires à l'affichage.
|
||||||
|
* La logique de présentation (excerpt, formatage) est déléguée à PostExtension.
|
||||||
|
*
|
||||||
|
* Distinction slug :
|
||||||
|
* - getStoredSlug() : slug lu depuis la base de données (canonique, peut comporter
|
||||||
|
* un suffixe numérique pour lever les collisions, ex: "mon-article-2")
|
||||||
|
* - generateSlug() : slug calculé dynamiquement depuis le titre, utilisé uniquement
|
||||||
|
* par PostService lors de la création/modification pour produire le slug à stocker
|
||||||
|
*/
|
||||||
|
final class Post
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DateTime Date de création — toujours non nulle après construction
|
||||||
|
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
|
||||||
|
*/
|
||||||
|
private readonly DateTime $createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var DateTime Date de dernière modification — toujours non nulle après construction
|
||||||
|
*/
|
||||||
|
private readonly DateTime $updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $id Identifiant en base (0 pour un nouvel article)
|
||||||
|
* @param string $title Titre de l'article (1–255 caractères)
|
||||||
|
* @param string $content Contenu HTML de l'article (1–65 535 caractères)
|
||||||
|
* @param string $slug Slug URL canonique, tel que stocké en base
|
||||||
|
* @param int|null $authorId Identifiant de l'auteur (null si le compte a été supprimé)
|
||||||
|
* @param string|null $authorUsername Nom de l'auteur dénormalisé (null si le compte a été supprimé)
|
||||||
|
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||||
|
* @param string|null $categoryName Nom de la catégorie dénormalisé (null si sans catégorie)
|
||||||
|
* @param string|null $categorySlug Slug de la catégorie dénormalisé (null si sans catégorie)
|
||||||
|
* @param DateTime|null $createdAt Date de création (défaut : maintenant)
|
||||||
|
* @param DateTime|null $updatedAt Date de dernière modification (défaut : maintenant)
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si les données ne passent pas la validation
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly string $title,
|
||||||
|
private readonly string $content,
|
||||||
|
private readonly string $slug = '',
|
||||||
|
private readonly ?int $authorId = null,
|
||||||
|
private readonly ?string $authorUsername = null,
|
||||||
|
private readonly ?int $categoryId = null,
|
||||||
|
private readonly ?string $categoryName = null,
|
||||||
|
private readonly ?string $categorySlug = null,
|
||||||
|
?DateTime $createdAt = null,
|
||||||
|
?DateTime $updatedAt = null,
|
||||||
|
) {
|
||||||
|
$this->createdAt = $createdAt ?? new DateTime();
|
||||||
|
$this->updatedAt = $updatedAt ?? new DateTime();
|
||||||
|
$this->validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une instance depuis un tableau associatif (ligne de base de données).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data Données issues de la base de données (avec JOIN users)
|
||||||
|
*
|
||||||
|
* @return self L'instance hydratée
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
id: (int) ($data['id'] ?? 0),
|
||||||
|
title: (string) ($data['title'] ?? ''),
|
||||||
|
content: (string) ($data['content'] ?? ''),
|
||||||
|
slug: (string) ($data['slug'] ?? ''),
|
||||||
|
authorId: isset($data['author_id']) ? (int) $data['author_id'] : null,
|
||||||
|
authorUsername: isset($data['author_username']) ? (string) $data['author_username'] : null,
|
||||||
|
categoryId: isset($data['category_id']) ? (int) $data['category_id'] : null,
|
||||||
|
categoryName: isset($data['category_name']) ? (string) $data['category_name'] : null,
|
||||||
|
categorySlug: isset($data['category_slug']) ? (string) $data['category_slug'] : null,
|
||||||
|
createdAt: DateParser::parse($data['created_at'] ?? null),
|
||||||
|
updatedAt: DateParser::parse($data['updated_at'] ?? null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'identifiant de l'article.
|
||||||
|
*
|
||||||
|
* @return int L'identifiant en base (0 si non encore persisté)
|
||||||
|
*/
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le titre de l'article.
|
||||||
|
*
|
||||||
|
* @return string Le titre
|
||||||
|
*/
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le contenu HTML de l'article.
|
||||||
|
*
|
||||||
|
* @return string Le contenu HTML sanitisé (purifié par HTMLPurifier à l'écriture)
|
||||||
|
*/
|
||||||
|
public function getContent(): string
|
||||||
|
{
|
||||||
|
return $this->content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le slug canonique tel que stocké en base de données.
|
||||||
|
*
|
||||||
|
* Ce slug peut différer du résultat de generateSlug() si un suffixe numérique
|
||||||
|
* a été ajouté lors de la création pour lever une collision
|
||||||
|
* (ex: titre "Mon article" → slug en DB "mon-article-2").
|
||||||
|
* C'est cette valeur qu'il faut utiliser pour construire les URLs publiques.
|
||||||
|
*
|
||||||
|
* @return string Le slug canonique (vide si l'article n'a pas encore été persisté)
|
||||||
|
*/
|
||||||
|
public function getStoredSlug(): string
|
||||||
|
{
|
||||||
|
return $this->slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'identifiant de l'auteur.
|
||||||
|
*
|
||||||
|
* @return int|null L'identifiant de l'auteur, ou null si le compte a été supprimé
|
||||||
|
*/
|
||||||
|
public function getAuthorId(): ?int
|
||||||
|
{
|
||||||
|
return $this->authorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le nom d'utilisateur de l'auteur.
|
||||||
|
*
|
||||||
|
* @return string|null Le nom d'utilisateur, ou null si le compte a été supprimé
|
||||||
|
*/
|
||||||
|
public function getAuthorUsername(): ?string
|
||||||
|
{
|
||||||
|
return $this->authorUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'identifiant de la catégorie de l'article.
|
||||||
|
*
|
||||||
|
* @return int|null L'identifiant de la catégorie, ou null si l'article est sans catégorie
|
||||||
|
*/
|
||||||
|
public function getCategoryId(): ?int
|
||||||
|
{
|
||||||
|
return $this->categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le nom de la catégorie de l'article.
|
||||||
|
*
|
||||||
|
* @return string|null Le nom de la catégorie, ou null si l'article est sans catégorie
|
||||||
|
*/
|
||||||
|
public function getCategoryName(): ?string
|
||||||
|
{
|
||||||
|
return $this->categoryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le slug de la catégorie de l'article.
|
||||||
|
*
|
||||||
|
* @return string|null Le slug de la catégorie, ou null si l'article est sans catégorie
|
||||||
|
*/
|
||||||
|
public function getCategorySlug(): ?string
|
||||||
|
{
|
||||||
|
return $this->categorySlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la date de création de l'article.
|
||||||
|
*
|
||||||
|
* @return DateTime La date de création
|
||||||
|
*/
|
||||||
|
public function getCreatedAt(): DateTime
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la date de dernière modification de l'article.
|
||||||
|
*
|
||||||
|
* @return DateTime La date de dernière modification
|
||||||
|
*/
|
||||||
|
public function getUpdatedAt(): DateTime
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un slug URL-friendly calculé à partir du titre courant.
|
||||||
|
*
|
||||||
|
* Cette méthode est réservée à PostService pour produire le slug à stocker
|
||||||
|
* lors de la création ou de la modification d'un article.
|
||||||
|
* Pour construire une URL publique, utiliser getStoredSlug() qui retourne
|
||||||
|
* le slug canonique tel qu'il est enregistré en base de données.
|
||||||
|
*
|
||||||
|
* La génération est déléguée à SlugHelper::generate() — voir sa documentation
|
||||||
|
* pour le détail de l'algorithme (translittération ASCII, minuscules, tirets).
|
||||||
|
*
|
||||||
|
* @return string Le slug en minuscules avec tirets (ex: "ete-en-foret")
|
||||||
|
*/
|
||||||
|
public function generateSlug(): string
|
||||||
|
{
|
||||||
|
return SlugHelper::generate($this->title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide les données de l'article.
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si le titre est vide ou dépasse 255 caractères
|
||||||
|
* @throws \InvalidArgumentException Si le contenu est vide ou dépasse 65 535 caractères
|
||||||
|
*/
|
||||||
|
private function validate(): void
|
||||||
|
{
|
||||||
|
if ($this->title === '') {
|
||||||
|
throw new \InvalidArgumentException('Le titre ne peut pas être vide');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($this->title) > 255) {
|
||||||
|
throw new \InvalidArgumentException('Le titre ne peut pas dépasser 255 caractères');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->content === '') {
|
||||||
|
throw new \InvalidArgumentException('Le contenu ne peut pas être vide');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($this->content) > 65535) {
|
||||||
|
throw new \InvalidArgumentException('Le contenu ne peut pas dépasser 65 535 caractères');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
379
src/Post/PostController.php
Normal file
379
src/Post/PostController.php
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Post;
|
||||||
|
|
||||||
|
use App\Category\CategoryServiceInterface;
|
||||||
|
use App\Shared\Exception\NotFoundException;
|
||||||
|
use App\Shared\Http\FlashServiceInterface;
|
||||||
|
use App\Shared\Http\SessionManagerInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Exception\HttpNotFoundException;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôleur pour les articles.
|
||||||
|
*
|
||||||
|
* Gère les actions HTTP liées aux articles : affichage public et administration
|
||||||
|
* (liste, formulaire, création, modification, suppression).
|
||||||
|
* Délègue toute la logique métier à PostService et utilise FlashService
|
||||||
|
* pour transmettre les messages d'erreur entre redirections.
|
||||||
|
* L'identifiant de l'auteur est lu depuis SessionManager lors de la création.
|
||||||
|
* Les droits de modification et suppression sont vérifiés via canEditPost().
|
||||||
|
* CategoryService est injecté pour résoudre les slugs de catégorie
|
||||||
|
* en identifiants et fournir la liste des catégories aux vues.
|
||||||
|
*/
|
||||||
|
final class PostController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Twig $view Moteur de templates Twig
|
||||||
|
* @param PostServiceInterface $postService Service métier des articles
|
||||||
|
* @param CategoryServiceInterface $categoryService Service de gestion des catégories
|
||||||
|
* @param FlashServiceInterface $flash Service de messages flash
|
||||||
|
* @param SessionManagerInterface $sessionManager Gestionnaire de session
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Twig $view,
|
||||||
|
private readonly PostServiceInterface $postService,
|
||||||
|
private readonly CategoryServiceInterface $categoryService,
|
||||||
|
private readonly FlashServiceInterface $flash,
|
||||||
|
private readonly SessionManagerInterface $sessionManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche la page d'accueil avec la liste des articles.
|
||||||
|
*
|
||||||
|
* Accepte deux paramètres de requête cumulables :
|
||||||
|
* - `q` (string) : recherche plein texte FTS5 sur titre, contenu et auteur
|
||||||
|
* - `categorie` (string) : filtre par slug de catégorie
|
||||||
|
*
|
||||||
|
* Si `q` est fourni, les résultats sont triés par pertinence BM25.
|
||||||
|
* Sans `q`, les articles sont triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La vue de la page d'accueil
|
||||||
|
*/
|
||||||
|
public function index(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$params = $req->getQueryParams();
|
||||||
|
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||||
|
$categorySlug = (string) ($params['categorie'] ?? '');
|
||||||
|
$activeCategory = null;
|
||||||
|
$categoryId = null;
|
||||||
|
|
||||||
|
if ($categorySlug !== '') {
|
||||||
|
$activeCategory = $this->categoryService->findBySlug($categorySlug);
|
||||||
|
$categoryId = $activeCategory?->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = $searchQuery !== ''
|
||||||
|
? $this->postService->searchPosts($searchQuery, $categoryId)
|
||||||
|
: $this->postService->getAllPosts($categoryId);
|
||||||
|
|
||||||
|
return $this->view->render($res, 'pages/home.twig', [
|
||||||
|
'posts' => $posts,
|
||||||
|
'categories' => $this->categoryService->findAll(),
|
||||||
|
'activeCategory' => $activeCategory,
|
||||||
|
'searchQuery' => $searchQuery,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche le détail d'un article par son slug.
|
||||||
|
*
|
||||||
|
* Le contenu HTML est déjà sanitisé lors de la création/modification
|
||||||
|
* (via HtmlSanitizerInterface dans PostService) : aucun nettoyage supplémentaire
|
||||||
|
* n'est nécessaire à la lecture.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
* @param array<string, string> $args Les paramètres de route (slug)
|
||||||
|
*
|
||||||
|
* @return Response La vue de détail de l'article
|
||||||
|
*
|
||||||
|
* @throws HttpNotFoundException Si aucun article ne correspond au slug
|
||||||
|
*/
|
||||||
|
public function show(Request $req, Response $res, array $args): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$post = $this->postService->getPostBySlug((string) ($args['slug'] ?? ''));
|
||||||
|
} catch (NotFoundException) {
|
||||||
|
throw new HttpNotFoundException($req);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->render($res, 'pages/post/detail.twig', ['post' => $post]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche la liste des articles dans l'interface d'administration.
|
||||||
|
*
|
||||||
|
* Un administrateur ou un éditeur voit tous les articles.
|
||||||
|
* Un utilisateur normal voit uniquement ses propres articles.
|
||||||
|
*
|
||||||
|
* Accepte deux paramètres de requête cumulables :
|
||||||
|
* - `q` (string) : recherche plein texte FTS5 sur titre, contenu et auteur
|
||||||
|
* - `categorie` (string) : filtre par slug de catégorie
|
||||||
|
*
|
||||||
|
* Si `q` est fourni, les résultats sont triés par pertinence BM25.
|
||||||
|
* Sans `q`, les articles sont triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response La vue d'administration des posts
|
||||||
|
*/
|
||||||
|
public function admin(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
|
||||||
|
$userId = $this->sessionManager->getUserId();
|
||||||
|
|
||||||
|
$params = $req->getQueryParams();
|
||||||
|
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||||
|
$categorySlug = (string) ($params['categorie'] ?? '');
|
||||||
|
$activeCategory = null;
|
||||||
|
$categoryId = null;
|
||||||
|
|
||||||
|
if ($categorySlug !== '') {
|
||||||
|
$activeCategory = $this->categoryService->findBySlug($categorySlug);
|
||||||
|
$categoryId = $activeCategory?->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($searchQuery !== '') {
|
||||||
|
$authorId = $isAdmin ? null : (int) $userId;
|
||||||
|
$posts = $this->postService->searchPosts($searchQuery, $categoryId, $authorId);
|
||||||
|
} else {
|
||||||
|
$posts = $isAdmin
|
||||||
|
? $this->postService->getAllPosts($categoryId)
|
||||||
|
: $this->postService->getPostsByUserId((int) $userId, $categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->render($res, 'admin/posts/index.twig', [
|
||||||
|
'posts' => $posts,
|
||||||
|
'categories' => $this->categoryService->findAll(),
|
||||||
|
'activeCategory' => $activeCategory,
|
||||||
|
'searchQuery' => $searchQuery,
|
||||||
|
'error' => $this->flash->get('post_error'),
|
||||||
|
'success' => $this->flash->get('post_success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche le formulaire de création (id=0) ou d'édition d'un article.
|
||||||
|
*
|
||||||
|
* L'accès en édition est refusé si l'utilisateur n'est pas l'auteur
|
||||||
|
* de l'article et n'a pas le rôle admin.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
* @param array<string, string> $args Les paramètres de route (id)
|
||||||
|
*
|
||||||
|
* @return Response Le formulaire ou une redirection
|
||||||
|
*
|
||||||
|
* @throws HttpNotFoundException Si l'article demandé n'existe pas
|
||||||
|
*/
|
||||||
|
public function form(Request $req, Response $res, array $args): Response
|
||||||
|
{
|
||||||
|
$id = (int) ($args['id'] ?? 0);
|
||||||
|
$post = null;
|
||||||
|
|
||||||
|
if ($id > 0) {
|
||||||
|
try {
|
||||||
|
$post = $this->postService->getPostById($id);
|
||||||
|
} catch (NotFoundException) {
|
||||||
|
throw new HttpNotFoundException($req);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérification des droits avant affichage du formulaire
|
||||||
|
if (!$this->canEditPost($post)) {
|
||||||
|
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->render($res, 'admin/posts/form.twig', [
|
||||||
|
'post' => $post,
|
||||||
|
'categories' => $this->categoryService->findAll(),
|
||||||
|
'action' => $id > 0 ? "/admin/posts/edit/{$id}" : '/admin/posts/create',
|
||||||
|
'error' => $this->flash->get('post_error'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite la soumission du formulaire de création d'article.
|
||||||
|
*
|
||||||
|
* L'auteur est l'utilisateur connecté, lu depuis la session.
|
||||||
|
* Le slug est généré automatiquement depuis le titre par PostService —
|
||||||
|
* la valeur éventuellement saisie dans le formulaire est ignorée à la création
|
||||||
|
* (elle n'est prise en compte qu'à la modification via update()).
|
||||||
|
* En cas d'erreur de validation, redirige vers le formulaire avec un message flash.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response Une redirection vers /admin/posts ou /admin/posts/edit/0
|
||||||
|
*/
|
||||||
|
public function create(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
['title' => $title, 'content' => $content, 'category_id' => $categoryId] =
|
||||||
|
$this->extractPostData($req);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId);
|
||||||
|
$this->flash->set('post_success', 'L\'article a été créé avec succès');
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$this->flash->set('post_error', $e->getMessage());
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->flash->set('post_error', 'Une erreur inattendue s\'est produite');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite la soumission du formulaire de modification d'article.
|
||||||
|
*
|
||||||
|
* Vérifie les droits avant modification : seul l'auteur ou un admin peut modifier.
|
||||||
|
* Un second 404 est possible si l'article est supprimé entre la vérification
|
||||||
|
* des droits et l'UPDATE (race condition).
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
* @param array<string, string> $args Les paramètres de route (id)
|
||||||
|
*
|
||||||
|
* @return Response Une redirection vers /admin/posts ou vers le formulaire
|
||||||
|
*
|
||||||
|
* @throws HttpNotFoundException Si l'article n'existe pas ou a disparu (race condition)
|
||||||
|
*/
|
||||||
|
public function update(Request $req, Response $res, array $args): Response
|
||||||
|
{
|
||||||
|
$id = (int) $args['id'];
|
||||||
|
['title' => $title, 'content' => $content, 'slug' => $slug, 'category_id' => $categoryId] =
|
||||||
|
$this->extractPostData($req);
|
||||||
|
|
||||||
|
// Récupération de l'article pour vérification des droits avant modification
|
||||||
|
try {
|
||||||
|
$post = $this->postService->getPostById($id);
|
||||||
|
} catch (NotFoundException) {
|
||||||
|
throw new HttpNotFoundException($req);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->canEditPost($post)) {
|
||||||
|
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->postService->updatePost($id, $title, $content, $slug, $categoryId);
|
||||||
|
$this->flash->set('post_success', 'L\'article a été modifié avec succès');
|
||||||
|
} catch (NotFoundException) {
|
||||||
|
// L'article a disparu entre la vérification des droits et l'UPDATE (race condition)
|
||||||
|
throw new HttpNotFoundException($req);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$this->flash->set('post_error', $e->getMessage());
|
||||||
|
|
||||||
|
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->flash->set('post_error', 'Une erreur inattendue s\'est produite');
|
||||||
|
|
||||||
|
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un article.
|
||||||
|
*
|
||||||
|
* Vérifie les droits avant suppression : seul l'auteur ou un admin peut supprimer.
|
||||||
|
* Un second 404 est possible si l'article est supprimé entre la vérification
|
||||||
|
* des droits et le DELETE (race condition — cohérent avec update()).
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
* @param array<string, string> $args Les paramètres de route (id)
|
||||||
|
*
|
||||||
|
* @return Response Une redirection vers /admin/posts
|
||||||
|
*
|
||||||
|
* @throws HttpNotFoundException Si l'article n'existe pas ou a disparu (race condition)
|
||||||
|
*/
|
||||||
|
public function delete(Request $req, Response $res, array $args): Response
|
||||||
|
{
|
||||||
|
// Récupération de l'article pour vérification des droits avant suppression
|
||||||
|
try {
|
||||||
|
$post = $this->postService->getPostById((int) $args['id']);
|
||||||
|
} catch (NotFoundException) {
|
||||||
|
throw new HttpNotFoundException($req);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->canEditPost($post)) {
|
||||||
|
$this->flash->set('post_error', "Vous ne pouvez pas supprimer un article dont vous n'êtes pas l'auteur");
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->postService->deletePost($post->getId());
|
||||||
|
} catch (NotFoundException) {
|
||||||
|
// L'article a disparu entre la vérification des droits et le DELETE (race condition)
|
||||||
|
throw new HttpNotFoundException($req);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash->set('post_success', "L'article « {$post->getTitle()} » a été supprimé avec succès");
|
||||||
|
|
||||||
|
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur connecté est autorisé à modifier ou supprimer un article.
|
||||||
|
*
|
||||||
|
* L'accès est accordé si l'utilisateur est l'auteur de l'article
|
||||||
|
* ou s'il a le rôle administrateur.
|
||||||
|
*
|
||||||
|
* @param Post $post L'article concerné
|
||||||
|
*
|
||||||
|
* @return bool True si l'action est autorisée
|
||||||
|
*/
|
||||||
|
private function canEditPost(Post $post): bool
|
||||||
|
{
|
||||||
|
// Un administrateur ou un éditeur a tous les droits sur tous les articles
|
||||||
|
if ($this->sessionManager->isAdmin() || $this->sessionManager->isEditor()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un utilisateur standard ne peut agir que sur ses propres articles
|
||||||
|
return $post->getAuthorId() === $this->sessionManager->getUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait et normalise les données d'article depuis le corps de la requête.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
*
|
||||||
|
* @return array{title: string, content: string, slug: string, category_id: int|null} Les données nettoyées
|
||||||
|
*/
|
||||||
|
private function extractPostData(Request $req): array
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = (array) $req->getParsedBody();
|
||||||
|
$categoryId = ($data['category_id'] ?? '') !== ''
|
||||||
|
? (int) $data['category_id']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => trim((string) ($data['title'] ?? '')),
|
||||||
|
'content' => trim((string) ($data['content'] ?? '')),
|
||||||
|
'slug' => trim((string) ($data['slug'] ?? '')),
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/Post/PostExtension.php
Normal file
205
src/Post/PostExtension.php
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Post;
|
||||||
|
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension Twig pour la présentation des articles.
|
||||||
|
*
|
||||||
|
* Expose des fonctions utilitaires dans les templates Twig
|
||||||
|
* afin d'éviter d'appeler de la logique de présentation directement
|
||||||
|
* sur le modèle Post depuis les vues.
|
||||||
|
*
|
||||||
|
* Fonctions disponibles dans les templates :
|
||||||
|
*
|
||||||
|
* @example {{ post_excerpt(post) }} — extrait de 400 caractères par défaut
|
||||||
|
* @example {{ post_excerpt(post, 600) }} — extrait personnalisé de 600 caractères
|
||||||
|
* @example {{ post_url(post) }} — URL publique de l'article (/article/{slug})
|
||||||
|
* @example {{ post_thumbnail(post) }} — URL de la première image, ou null si aucune image
|
||||||
|
* @example {{ post_initials(post) }} — initiales du titre (ex: "AB" pour "Article de Blog")
|
||||||
|
*/
|
||||||
|
final class PostExtension extends AbstractExtension
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Déclare les fonctions Twig exposées aux templates.
|
||||||
|
*
|
||||||
|
* @return TwigFunction[] Les fonctions enregistrées dans l'environnement Twig
|
||||||
|
*/
|
||||||
|
public function getFunctions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFunction(
|
||||||
|
'post_excerpt',
|
||||||
|
fn (Post $post, int $length = 400) => self::excerpt($post, $length),
|
||||||
|
['is_safe' => ['html']]
|
||||||
|
),
|
||||||
|
new TwigFunction(
|
||||||
|
'post_url',
|
||||||
|
fn (Post $post) => '/article/'.$post->getStoredSlug()
|
||||||
|
),
|
||||||
|
new TwigFunction(
|
||||||
|
'post_thumbnail',
|
||||||
|
fn (Post $post) => self::thumbnail($post)
|
||||||
|
),
|
||||||
|
new TwigFunction(
|
||||||
|
'post_initials',
|
||||||
|
fn (Post $post) => self::initials($post)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un extrait HTML formaté du contenu de l'article.
|
||||||
|
*
|
||||||
|
* Conserve uniquement les balises sûres et porteuses de sens visuel
|
||||||
|
* (<ul>, <ol>, <li>, <strong>, <em>, <b>, <i>) afin que le formatage
|
||||||
|
* soit perceptible dans l'aperçu (listes à puces, gras, italique…).
|
||||||
|
* Toutes les autres balises sont supprimées par strip_tags().
|
||||||
|
*
|
||||||
|
* La hauteur de l'aperçu est contrainte côté CSS (max-height sur .card__body +
|
||||||
|
* dégradé de fondu sur .card__excerpt) — c'est CSS qui tronque visuellement,
|
||||||
|
* pas cette méthode. Le paramètre $length sert uniquement de garde-fou serveur :
|
||||||
|
* il évite d'envoyer l'intégralité d'un long article au navigateur. La valeur
|
||||||
|
* par défaut de 400 caractères est volontairement généreuse pour ne jamais
|
||||||
|
* couper un contenu que CSS aurait affiché en entier.
|
||||||
|
*
|
||||||
|
* La troncature opère sur le HTML filtré (pas sur le texte brut) afin de
|
||||||
|
* conserver le formatage de façon cohérente, quelle que soit la longueur
|
||||||
|
* du contenu. Le comptage de caractères ignore les balises.
|
||||||
|
*
|
||||||
|
* Le HTML retourné provient de HTMLPurifier (appliqué à l'écriture) —
|
||||||
|
* strip_tags() avec liste blanche élimine tout balisage résiduel non désiré.
|
||||||
|
* La fonction est déclarée is_safe => ['html'] : Twig ne l'échappe pas
|
||||||
|
* automatiquement, le |raw est inutile dans les templates.
|
||||||
|
*
|
||||||
|
* @param Post $post L'article dont générer l'extrait
|
||||||
|
* @param int $length Longueur maximale en caractères visibles (défaut : 400)
|
||||||
|
*
|
||||||
|
* @return string L'extrait en HTML partiel, tronqué si nécessaire
|
||||||
|
*/
|
||||||
|
private static function excerpt(Post $post, int $length): string
|
||||||
|
{
|
||||||
|
// Balises conservées : structurantes pour les listes, sémantiques pour le gras/italique.
|
||||||
|
// Toutes les autres (p, div, h1-h6, img, a, table…) sont supprimées pour
|
||||||
|
// garder un aperçu compact.
|
||||||
|
$allowed = '<ul><ol><li><strong><em><b><i>';
|
||||||
|
$html = strip_tags($post->getContent(), $allowed);
|
||||||
|
|
||||||
|
// Mesurer sur le texte brut : les balises ne comptent pas dans la limite visible
|
||||||
|
if (mb_strlen(strip_tags($html)) <= $length) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tronquer en avançant caractère par caractère dans le HTML, en ignorant
|
||||||
|
// les balises dans le comptage — le formatage est ainsi conservé dans la
|
||||||
|
// portion visible, de façon cohérente avec les articles courts.
|
||||||
|
$truncated = '';
|
||||||
|
$count = 0;
|
||||||
|
$inTag = false;
|
||||||
|
|
||||||
|
for ($i = 0, $len = mb_strlen($html); $i < $len && $count < $length; $i++) {
|
||||||
|
$char = mb_substr($html, $i, 1);
|
||||||
|
|
||||||
|
if ($char === '<') {
|
||||||
|
$inTag = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$truncated .= $char;
|
||||||
|
|
||||||
|
if ($inTag) {
|
||||||
|
if ($char === '>') {
|
||||||
|
$inTag = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer proprement les balises laissées ouvertes par la troncature
|
||||||
|
foreach (['li', 'ul', 'ol', 'em', 'strong', 'b', 'i'] as $tag) {
|
||||||
|
$opens = substr_count($truncated, "<{$tag}>") + substr_count($truncated, "<{$tag} ");
|
||||||
|
$closes = substr_count($truncated, "</{$tag}>");
|
||||||
|
for ($j = $closes; $j < $opens; $j++) {
|
||||||
|
$truncated .= "</{$tag}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $truncated . '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait l'URL de la première image présente dans le contenu de l'article.
|
||||||
|
*
|
||||||
|
* Utilise une regex sur l'attribut src de la première balise <img> trouvée.
|
||||||
|
* Le contenu étant sanitisé par HTMLPurifier, seuls les schémas http/https
|
||||||
|
* sont présents — aucun risque XSS via cet attribut.
|
||||||
|
* L'échappement de l'URL est délégué à Twig (auto-escape activé).
|
||||||
|
*
|
||||||
|
* @param Post $post L'article dont extraire la vignette
|
||||||
|
*
|
||||||
|
* @return string|null L'URL de la première image, ou null si aucune image
|
||||||
|
*/
|
||||||
|
private static function thumbnail(Post $post): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/', $post->getContent(), $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère les initiales du titre de l'article (1 à 2 caractères).
|
||||||
|
*
|
||||||
|
* Extrait la première lettre de chaque mot, conserve les deux premières,
|
||||||
|
* et retourne le résultat en majuscules. Les mots vides (articles, prépositions
|
||||||
|
* d'une lettre) sont ignorés pour favoriser les mots porteurs de sens.
|
||||||
|
*
|
||||||
|
* Exemples :
|
||||||
|
* "Article de Blog" → "AB"
|
||||||
|
* "Été en forêt" → "EF"
|
||||||
|
* "PHP" → "P"
|
||||||
|
* "" → "?"
|
||||||
|
*
|
||||||
|
* L'échappement HTML est délégué à Twig (auto-escape activé).
|
||||||
|
*
|
||||||
|
* @param Post $post L'article dont générer les initiales
|
||||||
|
*
|
||||||
|
* @return string Les initiales en majuscules (1–2 caractères), ou "?" si le titre est vide
|
||||||
|
*/
|
||||||
|
private static function initials(Post $post): string
|
||||||
|
{
|
||||||
|
// Filtrer les mots vides fréquents (articles, prépositions, coordinations)
|
||||||
|
// pour favoriser les mots porteurs de sens : "Article de Blog" → ["Article", "Blog"] → "AB"
|
||||||
|
$stopWords = ['a', 'au', 'aux', 'd', 'de', 'des', 'du', 'en', 'et', 'l', 'la', 'le', 'les', 'of', 'the', 'un', 'une'];
|
||||||
|
$words = array_filter(
|
||||||
|
preg_split('/\s+/', trim($post->getTitle())) ?: [],
|
||||||
|
static function (string $w) use ($stopWords): bool {
|
||||||
|
$normalized = mb_strtolower(trim($w, " \t\n\r\0\x0B'\"’`.-_"));
|
||||||
|
|
||||||
|
return $normalized !== ''
|
||||||
|
&& mb_strlen($normalized) > 1
|
||||||
|
&& !in_array($normalized, $stopWords, true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empty($words)) {
|
||||||
|
// Repli sur le premier caractère du titre brut si tous les mots font 1 lettre
|
||||||
|
$first = mb_substr(trim($post->getTitle()), 0, 1);
|
||||||
|
|
||||||
|
return $first !== '' ? mb_strtoupper($first) : '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
$words = array_values($words);
|
||||||
|
$initials = mb_strtoupper(mb_substr($words[0], 0, 1));
|
||||||
|
|
||||||
|
if (isset($words[1])) {
|
||||||
|
$initials .= mb_strtoupper(mb_substr($words[1], 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $initials;
|
||||||
|
}
|
||||||
|
}
|
||||||
334
src/Post/PostRepository.php
Normal file
334
src/Post/PostRepository.php
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Post;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dépôt pour la persistance des articles.
|
||||||
|
*
|
||||||
|
* Responsabilité unique : exécuter les requêtes SQL liées à la table `posts`
|
||||||
|
* et retourner des instances de Post hydratées.
|
||||||
|
* Chaque requête de lecture effectue un LEFT JOIN sur `users` pour charger
|
||||||
|
* le nom d'auteur, et un LEFT JOIN sur `categories` pour charger le nom et
|
||||||
|
* le slug de catégorie — sans requête supplémentaire.
|
||||||
|
*/
|
||||||
|
final class PostRepository implements PostRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Fragment SELECT commun à toutes les requêtes de lecture (avec JOINs).
|
||||||
|
*/
|
||||||
|
private const SELECT = '
|
||||||
|
SELECT posts.id, posts.title, posts.content, posts.slug,
|
||||||
|
posts.author_id, posts.category_id, posts.created_at, posts.updated_at,
|
||||||
|
users.username AS author_username,
|
||||||
|
categories.name AS category_name,
|
||||||
|
categories.slug AS category_slug
|
||||||
|
FROM posts
|
||||||
|
LEFT JOIN users ON users.id = posts.author_id
|
||||||
|
LEFT JOIN categories ON categories.id = posts.category_id
|
||||||
|
';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PDO $db Instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly PDO $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les articles triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
*
|
||||||
|
* @return Post[] La liste des articles
|
||||||
|
*/
|
||||||
|
public function findAll(?int $categoryId = null): array
|
||||||
|
{
|
||||||
|
if ($categoryId !== null) {
|
||||||
|
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
|
||||||
|
$stmt->execute([':category_id' => $categoryId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC');
|
||||||
|
if ($stmt === false) {
|
||||||
|
throw new \RuntimeException('La requête SELECT sur posts a échoué.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les N articles les plus récents, tous auteurs confondus.
|
||||||
|
*
|
||||||
|
* @param int $limit Nombre maximum d'articles à retourner
|
||||||
|
*
|
||||||
|
* @return Post[] Les articles les plus récents
|
||||||
|
*/
|
||||||
|
public function findRecent(int $limit): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.id DESC LIMIT :limit');
|
||||||
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les articles d'un utilisateur donné, triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @param int $userId Identifiant de l'auteur
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
*
|
||||||
|
* @return Post[] La liste des articles de cet utilisateur
|
||||||
|
*/
|
||||||
|
public function findByUserId(int $userId, ?int $categoryId = null): array
|
||||||
|
{
|
||||||
|
if ($categoryId !== null) {
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
self::SELECT . ' WHERE posts.author_id = :author_id AND posts.category_id = :category_id ORDER BY posts.id DESC'
|
||||||
|
);
|
||||||
|
$stmt->execute([':author_id' => $userId, ':category_id' => $categoryId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.author_id = :author_id ORDER BY posts.id DESC');
|
||||||
|
$stmt->execute([':author_id' => $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un article par son slug URL.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug URL de l'article
|
||||||
|
*
|
||||||
|
* @return Post|null L'article trouvé, ou null s'il n'existe pas
|
||||||
|
*/
|
||||||
|
public function findBySlug(string $slug): ?Post
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug');
|
||||||
|
$stmt->execute([':slug' => $slug]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ? Post::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un article par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article
|
||||||
|
*
|
||||||
|
* @return Post|null L'article trouvé, ou null s'il n'existe pas
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Post
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $row ? Post::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste un nouvel article en base de données.
|
||||||
|
*
|
||||||
|
* @param Post $post L'article à créer
|
||||||
|
* @param string $slug Le slug unique généré pour cet article
|
||||||
|
* @param int $authorId Identifiant de l'auteur
|
||||||
|
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||||
|
*
|
||||||
|
* @return int L'identifiant généré par la base de données
|
||||||
|
*/
|
||||||
|
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('
|
||||||
|
INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at)
|
||||||
|
VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':title' => $post->getTitle(),
|
||||||
|
':content' => $post->getContent(),
|
||||||
|
':slug' => $slug,
|
||||||
|
':author_id' => $authorId,
|
||||||
|
':category_id' => $categoryId,
|
||||||
|
':created_at' => date('Y-m-d H:i:s'),
|
||||||
|
':updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour un article existant en base de données.
|
||||||
|
*
|
||||||
|
* Retourne le nombre de lignes affectées. Une valeur de 0 indique que
|
||||||
|
* l'article n'existe plus au moment de l'écriture (suppression concurrente).
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article à modifier
|
||||||
|
* @param Post $post L'article avec les nouvelles données
|
||||||
|
* @param string $slug Le nouveau slug unique
|
||||||
|
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||||
|
*
|
||||||
|
* @return int Nombre de lignes affectées (0 si l'article n'existe plus)
|
||||||
|
*/
|
||||||
|
public function update(int $id, Post $post, string $slug, ?int $categoryId): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('
|
||||||
|
UPDATE posts
|
||||||
|
SET title = :title, content = :content, slug = :slug,
|
||||||
|
category_id = :category_id, updated_at = :updated_at
|
||||||
|
WHERE id = :id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':title' => $post->getTitle(),
|
||||||
|
':content' => $post->getContent(),
|
||||||
|
':slug' => $slug,
|
||||||
|
':category_id' => $categoryId,
|
||||||
|
':updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
':id' => $id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un article de la base de données.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article à supprimer
|
||||||
|
*
|
||||||
|
* @return int Nombre de lignes supprimées (0 si l'article n'existe plus)
|
||||||
|
*/
|
||||||
|
public function delete(int $id): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche des articles en plein texte via l'index FTS5.
|
||||||
|
*
|
||||||
|
* La requête est tokenisée mot par mot : chaque terme est traité comme un
|
||||||
|
* préfixe (ex: "slim" correspond à "Slim", "Slimframework"…). Les termes
|
||||||
|
* sont combinés en AND implicite — tous doivent être présents dans le document.
|
||||||
|
* Les caractères spéciaux FTS5 sont échappés par guillemets doubles.
|
||||||
|
*
|
||||||
|
* Les résultats sont triés par pertinence BM25 (meilleur en premier).
|
||||||
|
* Filtrages optionnels disponibles : par catégorie et/ou par auteur.
|
||||||
|
*
|
||||||
|
* @param string $query La saisie utilisateur brute
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
* @param int|null $authorId Filtre optionnel par identifiant d'auteur (rôle user)
|
||||||
|
*
|
||||||
|
* @return Post[] Les articles correspondant à la recherche, triés par pertinence
|
||||||
|
*/
|
||||||
|
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||||
|
{
|
||||||
|
$ftsQuery = $this->buildFtsQuery($query);
|
||||||
|
|
||||||
|
if ($ftsQuery === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = '
|
||||||
|
SELECT p.id, p.title, p.content, p.slug,
|
||||||
|
p.author_id, p.category_id, p.created_at, p.updated_at,
|
||||||
|
u.username AS author_username,
|
||||||
|
c.name AS category_name,
|
||||||
|
c.slug AS category_slug
|
||||||
|
FROM posts_fts f
|
||||||
|
JOIN posts p ON p.id = f.rowid
|
||||||
|
LEFT JOIN users u ON u.id = p.author_id
|
||||||
|
LEFT JOIN categories c ON c.id = p.category_id
|
||||||
|
WHERE posts_fts MATCH :query
|
||||||
|
';
|
||||||
|
|
||||||
|
$params = [':query' => $ftsQuery];
|
||||||
|
|
||||||
|
if ($categoryId !== null) {
|
||||||
|
$sql .= ' AND p.category_id = :category_id';
|
||||||
|
$params[':category_id'] = $categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($authorId !== null) {
|
||||||
|
$sql .= ' AND p.author_id = :author_id';
|
||||||
|
$params[':author_id'] = $authorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY rank';
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit une requête FTS5 sûre depuis la saisie utilisateur.
|
||||||
|
*
|
||||||
|
* Chaque mot est wrappé entre guillemets doubles (échappement interne
|
||||||
|
* des guillemets par doublement) et suivi d'un `*` pour la recherche
|
||||||
|
* par préfixe. Les mots sont joints par un espace (AND implicite FTS5).
|
||||||
|
*
|
||||||
|
* @param string $input La saisie brute de l'utilisateur
|
||||||
|
*
|
||||||
|
* @return string La requête FTS5 prête à l'emploi, ou '' si vide
|
||||||
|
*/
|
||||||
|
private function buildFtsQuery(string $input): string
|
||||||
|
{
|
||||||
|
$words = preg_split('/\s+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
|
||||||
|
if (empty($words)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$terms = array_map(
|
||||||
|
fn ($w) => '"' . str_replace('"', '""', $w) . '"*',
|
||||||
|
$words
|
||||||
|
);
|
||||||
|
|
||||||
|
return implode(' ', $terms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un slug est déjà utilisé par un autre article.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug à vérifier
|
||||||
|
* @param int|null $excludeId Identifiant à exclure de la vérification (pour les mises à jour)
|
||||||
|
*
|
||||||
|
* @return bool True si le slug est déjà pris par un autre article
|
||||||
|
*/
|
||||||
|
public function slugExists(string $slug, ?int $excludeId = null): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug');
|
||||||
|
$stmt->execute([':slug' => $slug]);
|
||||||
|
|
||||||
|
$existingId = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($existingId === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingId = (int) $existingId;
|
||||||
|
|
||||||
|
if ($excludeId !== null) {
|
||||||
|
return $existingId !== $excludeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/Post/PostRepositoryInterface.php
Normal file
113
src/Post/PostRepositoryInterface.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat de persistance des articles.
|
||||||
|
*
|
||||||
|
* Découple PostService de l'implémentation concrète PDO/SQLite,
|
||||||
|
* facilitant les mocks dans les tests unitaires.
|
||||||
|
*/
|
||||||
|
interface PostRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retourne tous les articles triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function findAll(?int $categoryId = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les N articles les plus récents (flux RSS).
|
||||||
|
*
|
||||||
|
* @param int $limit Nombre maximum d'articles à retourner
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function findRecent(int $limit): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les articles d'un utilisateur donné.
|
||||||
|
*
|
||||||
|
* @param int $userId Identifiant de l'auteur
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function findByUserId(int $userId, ?int $categoryId = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un article par son slug URL.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug URL de l'article
|
||||||
|
*
|
||||||
|
* @return Post|null L'article trouvé, ou null s'il n'existe pas
|
||||||
|
*/
|
||||||
|
public function findBySlug(string $slug): ?Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve un article par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article
|
||||||
|
*
|
||||||
|
* @return Post|null L'article trouvé, ou null s'il n'existe pas
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste un nouvel article en base de données.
|
||||||
|
*
|
||||||
|
* @param Post $post L'article à créer
|
||||||
|
* @param string $slug Le slug unique généré pour cet article
|
||||||
|
* @param int $authorId Identifiant de l'auteur
|
||||||
|
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||||
|
*
|
||||||
|
* @return int L'identifiant généré par la base de données
|
||||||
|
*/
|
||||||
|
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour un article existant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article à modifier
|
||||||
|
* @param Post $post L'article avec les nouvelles données
|
||||||
|
* @param string $slug Le nouveau slug unique
|
||||||
|
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||||
|
*
|
||||||
|
* @return int Nombre de lignes affectées
|
||||||
|
*/
|
||||||
|
public function update(int $id, Post $post, string $slug, ?int $categoryId): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un article de la base de données.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article à supprimer
|
||||||
|
*
|
||||||
|
* @return int Nombre de lignes supprimées
|
||||||
|
*/
|
||||||
|
public function delete(int $id): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche des articles en plein texte via FTS5.
|
||||||
|
*
|
||||||
|
* @param string $query La saisie utilisateur brute
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
* @param int|null $authorId Filtre optionnel par identifiant d'auteur
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un slug est déjà utilisé par un autre article.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug à vérifier
|
||||||
|
* @param int|null $excludeId Identifiant à exclure (mise à jour)
|
||||||
|
*
|
||||||
|
* @return bool True si le slug est déjà pris
|
||||||
|
*/
|
||||||
|
public function slugExists(string $slug, ?int $excludeId = null): bool;
|
||||||
|
}
|
||||||
252
src/Post/PostService.php
Normal file
252
src/Post/PostService.php
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Post;
|
||||||
|
|
||||||
|
use App\Shared\Exception\NotFoundException;
|
||||||
|
use App\Shared\Html\HtmlSanitizerInterface;
|
||||||
|
use App\Shared\Util\SlugHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service métier pour les articles.
|
||||||
|
*
|
||||||
|
* Centralise toute la logique qui ne relève ni du stockage (PostRepository)
|
||||||
|
* ni de la présentation (PostController / PostExtension) :
|
||||||
|
* - génération et unicité des slugs
|
||||||
|
* - sanitisation du contenu HTML à l'écriture
|
||||||
|
* - orchestration des opérations create / update / delete
|
||||||
|
*
|
||||||
|
* Flux de sanitisation :
|
||||||
|
* 1. L'utilisateur saisit du HTML via Trumbowyg
|
||||||
|
* 2. createPost() / updatePost() passent le contenu brut à HtmlSanitizerInterface
|
||||||
|
* 3. HtmlSanitizerInterface (implémentée par HtmlSanitizer) délègue à HTMLPurifier, configuré pour n'autoriser
|
||||||
|
* que les balises produites par Trumbowyg
|
||||||
|
* 4. Le contenu purifié est stocké en base — le filtre |raw dans Twig est sûr
|
||||||
|
*/
|
||||||
|
final class PostService implements PostServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param PostRepositoryInterface $postRepository Dépôt de persistance des articles
|
||||||
|
* @param HtmlSanitizerInterface $htmlSanitizer Service de sanitisation HTML
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly PostRepositoryInterface $postRepository,
|
||||||
|
private readonly HtmlSanitizerInterface $htmlSanitizer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les articles triés du plus récent au plus ancien.
|
||||||
|
*
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function getAllPosts(?int $categoryId = null): array
|
||||||
|
{
|
||||||
|
return $this->postRepository->findAll($categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les N articles les plus récents pour le flux RSS.
|
||||||
|
*
|
||||||
|
* @param int $limit Nombre maximum d'articles à retourner (défaut : 20)
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function getRecentPosts(int $limit = 20): array
|
||||||
|
{
|
||||||
|
return $this->postRepository->findRecent($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les articles d'un utilisateur donné.
|
||||||
|
*
|
||||||
|
* @param int $userId Identifiant de l'auteur
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function getPostsByUserId(int $userId, ?int $categoryId = null): array
|
||||||
|
{
|
||||||
|
return $this->postRepository->findByUserId($userId, $categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne un article par son slug URL.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug URL de l'article
|
||||||
|
*
|
||||||
|
* @return Post L'article avec contenu sûr
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si aucun article ne correspond au slug
|
||||||
|
*/
|
||||||
|
public function getPostBySlug(string $slug): Post
|
||||||
|
{
|
||||||
|
$post = $this->postRepository->findBySlug($slug);
|
||||||
|
|
||||||
|
if ($post === null) {
|
||||||
|
throw new NotFoundException('Article', $slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $post;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne un article par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article
|
||||||
|
*
|
||||||
|
* @return Post L'article avec son contenu
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si aucun article ne correspond à cet identifiant
|
||||||
|
*/
|
||||||
|
public function getPostById(int $id): Post
|
||||||
|
{
|
||||||
|
$post = $this->postRepository->findById($id);
|
||||||
|
|
||||||
|
if ($post === null) {
|
||||||
|
throw new NotFoundException('Article', $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $post;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un nouvel article et retourne son identifiant.
|
||||||
|
*
|
||||||
|
* Un slug unique est généré à partir du titre. Si le slug existe déjà,
|
||||||
|
* un suffixe numérique est ajouté (ex: "mon-article-2").
|
||||||
|
* Le contenu HTML est sanitisé avant stockage.
|
||||||
|
*
|
||||||
|
* @param string $title Titre de l'article
|
||||||
|
* @param string $content Contenu HTML brut (sera sanitisé)
|
||||||
|
* @param int $authorId Identifiant de l'auteur
|
||||||
|
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||||
|
*
|
||||||
|
* @return int L'identifiant de l'article créé
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
|
||||||
|
*/
|
||||||
|
public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int
|
||||||
|
{
|
||||||
|
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
|
||||||
|
$post = new Post(0, $title, $sanitizedContent);
|
||||||
|
$slug = $this->generateUniqueSlug($post->generateSlug());
|
||||||
|
|
||||||
|
return $this->postRepository->create($post, $slug, $authorId, $categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour un article existant.
|
||||||
|
*
|
||||||
|
* Le slug est préservé par défaut. Si $newSlugInput est fourni et différent
|
||||||
|
* du slug actuel, il est nettoyé puis rendu unique avant d'être appliqué.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article à modifier
|
||||||
|
* @param string $title Nouveau titre
|
||||||
|
* @param string $content Nouveau contenu HTML brut (sera sanitisé)
|
||||||
|
* @param string $newSlugInput Nouveau slug souhaité (vide = conserver l'actuel)
|
||||||
|
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si l'article n'existe plus
|
||||||
|
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function updatePost(
|
||||||
|
int $id,
|
||||||
|
string $title,
|
||||||
|
string $content,
|
||||||
|
string $newSlugInput = '',
|
||||||
|
?int $categoryId = null,
|
||||||
|
): void {
|
||||||
|
$current = $this->postRepository->findById($id);
|
||||||
|
|
||||||
|
if ($current === null) {
|
||||||
|
throw new NotFoundException('Article', $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
|
||||||
|
$post = new Post($id, $title, $sanitizedContent);
|
||||||
|
|
||||||
|
$slugToUse = $current->getStoredSlug();
|
||||||
|
$newSlugInput = trim($newSlugInput);
|
||||||
|
$cleanSlugInput = $this->normalizeSlugInput($newSlugInput);
|
||||||
|
|
||||||
|
if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) {
|
||||||
|
$slugToUse = $this->generateUniqueSlug($cleanSlugInput, $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$affected = $this->postRepository->update($id, $post, $slugToUse, $categoryId);
|
||||||
|
|
||||||
|
if ($affected === 0) {
|
||||||
|
throw new NotFoundException('Article', $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche des articles en plein texte via FTS5.
|
||||||
|
*
|
||||||
|
* @param string $query La saisie utilisateur brute
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
* @param int|null $authorId Filtre optionnel par identifiant d'auteur
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||||
|
{
|
||||||
|
return $this->postRepository->search($query, $categoryId, $authorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un article.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article à supprimer
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si l'article n'existe plus au moment de la suppression
|
||||||
|
*/
|
||||||
|
public function deletePost(int $id): void
|
||||||
|
{
|
||||||
|
$affected = $this->postRepository->delete($id);
|
||||||
|
|
||||||
|
if ($affected === 0) {
|
||||||
|
throw new NotFoundException('Article', $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nettoie une saisie utilisateur pour en faire un slug valide.
|
||||||
|
*
|
||||||
|
* Délègue à SlugHelper::generate() — voir sa documentation pour le détail
|
||||||
|
* de l'algorithme.
|
||||||
|
*
|
||||||
|
* @param string $input La valeur brute saisie par l'utilisateur
|
||||||
|
*
|
||||||
|
* @return string Le slug nettoyé, ou '' si invalide
|
||||||
|
*/
|
||||||
|
private function normalizeSlugInput(string $input): string
|
||||||
|
{
|
||||||
|
return SlugHelper::generate($input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un slug unique en ajoutant un suffixe numérique si nécessaire.
|
||||||
|
*
|
||||||
|
* @param string $baseSlug Le slug de base généré depuis le titre
|
||||||
|
* @param int|null $excludeId Identifiant à exclure lors de la vérification (mise à jour)
|
||||||
|
*
|
||||||
|
* @return string Le slug garanti unique
|
||||||
|
*/
|
||||||
|
private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string
|
||||||
|
{
|
||||||
|
$slug = $baseSlug;
|
||||||
|
$counter = 1;
|
||||||
|
|
||||||
|
while ($this->postRepository->slugExists($slug, $excludeId)) {
|
||||||
|
$slug = $baseSlug . '-' . $counter;
|
||||||
|
++$counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/Post/PostServiceInterface.php
Normal file
119
src/Post/PostServiceInterface.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Post;
|
||||||
|
|
||||||
|
use App\Shared\Exception\NotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat du service de gestion des articles.
|
||||||
|
*
|
||||||
|
* Permet de mocker le service dans les tests unitaires sans dépendre
|
||||||
|
* de la classe concrète finale PostService.
|
||||||
|
*/
|
||||||
|
interface PostServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retourne tous les articles publiés, avec un filtre optionnel par catégorie.
|
||||||
|
*
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function getAllPosts(?int $categoryId = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les articles les plus récents.
|
||||||
|
*
|
||||||
|
* @param int $limit Nombre maximum d'articles à retourner (défaut : 20)
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function getRecentPosts(int $limit = 20): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les articles d'un auteur donné.
|
||||||
|
*
|
||||||
|
* @param int $userId Identifiant de l'auteur
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function getPostsByUserId(int $userId, ?int $categoryId = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne un article par son slug URL.
|
||||||
|
*
|
||||||
|
* @param string $slug Le slug URL de l'article
|
||||||
|
*
|
||||||
|
* @return Post L'article avec contenu sûr
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si aucun article ne correspond au slug
|
||||||
|
*/
|
||||||
|
public function getPostBySlug(string $slug): Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne un article par son identifiant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article
|
||||||
|
*
|
||||||
|
* @return Post L'article avec son contenu
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si aucun article ne correspond à cet identifiant
|
||||||
|
*/
|
||||||
|
public function getPostById(int $id): Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un nouvel article.
|
||||||
|
*
|
||||||
|
* @param string $title Titre de l'article
|
||||||
|
* @param string $content Contenu HTML brut (sera sanitisé)
|
||||||
|
* @param int $authorId Identifiant de l'auteur
|
||||||
|
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||||
|
*
|
||||||
|
* @return int L'identifiant de l'article créé
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
|
||||||
|
*/
|
||||||
|
public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour un article existant.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article à modifier
|
||||||
|
* @param string $title Nouveau titre
|
||||||
|
* @param string $content Nouveau contenu HTML brut (sera sanitisé)
|
||||||
|
* @param string $newSlugInput Nouveau slug souhaité (vide = conserver l'actuel)
|
||||||
|
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si l'article n'existe plus
|
||||||
|
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
|
||||||
|
*/
|
||||||
|
public function updatePost(
|
||||||
|
int $id,
|
||||||
|
string $title,
|
||||||
|
string $content,
|
||||||
|
string $newSlugInput = '',
|
||||||
|
?int $categoryId = null,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche des articles par mots-clés dans le titre, le contenu et l'auteur.
|
||||||
|
*
|
||||||
|
* @param string $query La saisie utilisateur brute
|
||||||
|
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
|
||||||
|
* @param int|null $authorId Filtre optionnel par identifiant d'auteur
|
||||||
|
*
|
||||||
|
* @return Post[]
|
||||||
|
*/
|
||||||
|
public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un article.
|
||||||
|
*
|
||||||
|
* @param int $id Identifiant de l'article à supprimer
|
||||||
|
*
|
||||||
|
* @throws NotFoundException Si l'article n'existe plus au moment de la suppression
|
||||||
|
*/
|
||||||
|
public function deletePost(int $id): void;
|
||||||
|
}
|
||||||
94
src/Post/RssController.php
Normal file
94
src/Post/RssController.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Post;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôleur du flux RSS.
|
||||||
|
*
|
||||||
|
* Expose un flux RSS 2.0 des 20 articles les plus récents à l'URL /rss.xml.
|
||||||
|
* Le contenu HTML des articles est strippé pour le champ <description> afin
|
||||||
|
* de produire un résumé texte brut compatible avec tous les lecteurs RSS.
|
||||||
|
*
|
||||||
|
* Pas de vue Twig — le XML est généré directement via SimpleXMLElement
|
||||||
|
* pour rester indépendant du moteur de templates.
|
||||||
|
*/
|
||||||
|
final class RssController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Nombre maximum d'articles inclus dans le flux RSS.
|
||||||
|
*/
|
||||||
|
private const FEED_LIMIT = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PostServiceInterface $postService Service de récupération des articles
|
||||||
|
* @param string $appUrl URL de base de l'application (depuis APP_URL dans .env)
|
||||||
|
* @param string $appName Nom du blog affiché dans le flux
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly PostServiceInterface $postService,
|
||||||
|
private readonly string $appUrl,
|
||||||
|
private readonly string $appName,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère et retourne le flux RSS 2.0.
|
||||||
|
*
|
||||||
|
* @param Request $req La requête HTTP
|
||||||
|
* @param Response $res La réponse HTTP
|
||||||
|
*
|
||||||
|
* @return Response Le flux RSS en XML (application/rss+xml; charset=utf-8)
|
||||||
|
*/
|
||||||
|
public function feed(Request $req, Response $res): Response
|
||||||
|
{
|
||||||
|
$posts = $this->postService->getRecentPosts(self::FEED_LIMIT);
|
||||||
|
$baseUrl = $this->appUrl;
|
||||||
|
|
||||||
|
$xml = new \SimpleXMLElement(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"></rss>'
|
||||||
|
);
|
||||||
|
|
||||||
|
$channel = $xml->addChild('channel');
|
||||||
|
$channel->addChild('title', htmlspecialchars($this->appName));
|
||||||
|
$channel->addChild('link', $baseUrl . '/');
|
||||||
|
$channel->addChild('description', htmlspecialchars($this->appName . ' — flux RSS'));
|
||||||
|
$channel->addChild('language', 'fr-FR');
|
||||||
|
$channel->addChild('lastBuildDate', (new \DateTime())->format(\DateTime::RSS));
|
||||||
|
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
$item = $channel->addChild('item');
|
||||||
|
$item->addChild('title', htmlspecialchars($post->getTitle()));
|
||||||
|
|
||||||
|
$postUrl = $baseUrl . '/article/' . $post->getStoredSlug();
|
||||||
|
$item->addChild('link', $postUrl);
|
||||||
|
$item->addChild('guid', $postUrl);
|
||||||
|
|
||||||
|
// Extrait texte brut : strip_tags + truncature à 300 caractères
|
||||||
|
$excerpt = strip_tags($post->getContent());
|
||||||
|
$excerpt = mb_strlen($excerpt) > 300
|
||||||
|
? mb_substr($excerpt, 0, 300) . '…'
|
||||||
|
: $excerpt;
|
||||||
|
$item->addChild('description', htmlspecialchars($excerpt));
|
||||||
|
|
||||||
|
$item->addChild('pubDate', $post->getCreatedAt()->format(\DateTime::RSS));
|
||||||
|
|
||||||
|
if ($post->getAuthorUsername() !== null) {
|
||||||
|
$item->addChild('author', htmlspecialchars($post->getAuthorUsername()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($post->getCategoryName() !== null) {
|
||||||
|
$item->addChild('category', htmlspecialchars($post->getCategoryName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $xml->asXML();
|
||||||
|
|
||||||
|
$res->getBody()->write($body !== false ? $body : '');
|
||||||
|
|
||||||
|
return $res->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
213
src/Shared/Bootstrap.php
Normal file
213
src/Shared/Bootstrap.php
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared;
|
||||||
|
|
||||||
|
use App\Post\PostExtension;
|
||||||
|
use App\Shared\Database\Provisioner;
|
||||||
|
use App\Shared\Extension\AppExtension;
|
||||||
|
use App\Shared\Extension\CsrfExtension;
|
||||||
|
use App\Shared\Extension\SessionExtension;
|
||||||
|
use DI\ContainerBuilder;
|
||||||
|
use Dotenv\Dotenv;
|
||||||
|
use PDO;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Slim\App;
|
||||||
|
use Slim\Csrf\Guard;
|
||||||
|
use Slim\Exception\HttpException;
|
||||||
|
use Slim\Factory\AppFactory;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
use Slim\Views\TwigMiddleware;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class Bootstrap
|
||||||
|
{
|
||||||
|
private ?ContainerInterface $container = null;
|
||||||
|
private ?App $app = null;
|
||||||
|
|
||||||
|
public static function create(): self
|
||||||
|
{
|
||||||
|
return new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initialize(): App
|
||||||
|
{
|
||||||
|
$this->initializeInfrastructure();
|
||||||
|
$this->runAutoProvisioningIfEnabled();
|
||||||
|
|
||||||
|
return $this->createHttpApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initializeInfrastructure(): ContainerInterface
|
||||||
|
{
|
||||||
|
if ($this->container !== null) {
|
||||||
|
return $this->container;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->checkDirectories();
|
||||||
|
$this->checkExtensions();
|
||||||
|
$this->loadEnvironment();
|
||||||
|
$this->buildContainer();
|
||||||
|
|
||||||
|
return $this->container;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createHttpApp(): App
|
||||||
|
{
|
||||||
|
if ($this->app !== null) {
|
||||||
|
return $this->app;
|
||||||
|
}
|
||||||
|
|
||||||
|
$container = $this->initializeInfrastructure();
|
||||||
|
$this->app = AppFactory::createFromContainer($container);
|
||||||
|
|
||||||
|
$this->registerMiddlewares();
|
||||||
|
$this->registerRoutes();
|
||||||
|
$this->configureErrorHandling();
|
||||||
|
|
||||||
|
return $this->app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContainer(): ContainerInterface
|
||||||
|
{
|
||||||
|
return $this->initializeInfrastructure();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildContainer(): void
|
||||||
|
{
|
||||||
|
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
|
||||||
|
$builder = new ContainerBuilder();
|
||||||
|
$builder->addDefinitions(__DIR__ . '/../../config/container.php');
|
||||||
|
|
||||||
|
if (!$isDev) {
|
||||||
|
$builder->enableCompilation(__DIR__ . '/../../var/cache/di');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container = $builder->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkDirectories(): void
|
||||||
|
{
|
||||||
|
$dirs = [
|
||||||
|
__DIR__.'/../../var/cache/twig',
|
||||||
|
__DIR__.'/../../var/cache/htmlpurifier',
|
||||||
|
__DIR__.'/../../var/cache/di',
|
||||||
|
__DIR__.'/../../var/logs',
|
||||||
|
__DIR__.'/../../database',
|
||||||
|
__DIR__.'/../../public/media',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($dirs as $dir) {
|
||||||
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||||
|
throw new \RuntimeException("Impossible de créer le répertoire : {$dir}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkExtensions(): void
|
||||||
|
{
|
||||||
|
if (!function_exists('imagewebp')) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'L\'extension PHP GD avec le support WebP est requise. ' .
|
||||||
|
'Installez le paquet php-gd (ex: apt install php-gd) puis redémarrez PHP.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadEnvironment(): void
|
||||||
|
{
|
||||||
|
$dotenv = Dotenv::createImmutable(__DIR__.'/../..');
|
||||||
|
$dotenv->load();
|
||||||
|
$dotenv->required(['APP_URL', 'ADMIN_USERNAME', 'ADMIN_EMAIL', 'ADMIN_PASSWORD']);
|
||||||
|
|
||||||
|
date_default_timezone_set($_ENV['TIMEZONE'] ?? 'UTC');
|
||||||
|
|
||||||
|
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
|
||||||
|
|
||||||
|
if (!$isDev && ($_ENV['ADMIN_PASSWORD'] ?? '') === 'changeme123') {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'ADMIN_PASSWORD doit être changé avant de démarrer en production.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runAutoProvisioningIfEnabled(): void
|
||||||
|
{
|
||||||
|
$flag = strtolower(trim((string) ($_ENV['APP_AUTO_PROVISION'] ?? '')));
|
||||||
|
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
|
||||||
|
$enabled = $flag !== ''
|
||||||
|
? in_array($flag, ['1', 'true', 'yes', 'on'], true)
|
||||||
|
: $isDev;
|
||||||
|
|
||||||
|
if (!$enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Provisioner::run($this->container->get(PDO::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerMiddlewares(): void
|
||||||
|
{
|
||||||
|
$this->app->addBodyParsingMiddleware();
|
||||||
|
|
||||||
|
$twig = $this->container->get(Twig::class);
|
||||||
|
$twig->addExtension($this->container->get(AppExtension::class));
|
||||||
|
$twig->addExtension($this->container->get(SessionExtension::class));
|
||||||
|
$twig->addExtension($this->container->get(PostExtension::class));
|
||||||
|
|
||||||
|
$this->app->add(TwigMiddleware::create($this->app, $twig));
|
||||||
|
|
||||||
|
$guard = new Guard($this->app->getResponseFactory());
|
||||||
|
$guard->setPersistentTokenMode(true);
|
||||||
|
$twig->addExtension(new CsrfExtension($guard));
|
||||||
|
$this->app->add($guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerRoutes(): void
|
||||||
|
{
|
||||||
|
Routes::register($this->app);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configureErrorHandling(): void
|
||||||
|
{
|
||||||
|
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
|
||||||
|
$logger = $this->container->get(LoggerInterface::class);
|
||||||
|
$errorHandler = $this->app->addErrorMiddleware($isDev, true, true, $logger);
|
||||||
|
|
||||||
|
$errorHandler->setDefaultErrorHandler(
|
||||||
|
function (
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
Throwable $exception,
|
||||||
|
bool $displayErrorDetails,
|
||||||
|
bool $logErrors,
|
||||||
|
bool $logErrorDetails,
|
||||||
|
) use ($isDev): ResponseInterface {
|
||||||
|
if ($isDev) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusCode = 500;
|
||||||
|
if ($exception instanceof HttpException) {
|
||||||
|
$statusCode = $exception->getCode() ?: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->app->getResponseFactory()->createResponse($statusCode);
|
||||||
|
$twig = $this->container->get(Twig::class);
|
||||||
|
|
||||||
|
return $twig->render($response, 'pages/error.twig', [
|
||||||
|
'status' => $statusCode,
|
||||||
|
'message' => $statusCode === 404
|
||||||
|
? 'La page demandée est introuvable.'
|
||||||
|
: 'Une erreur inattendue s\'est produite.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/Shared/Config.php
Normal file
61
src/Shared/Config.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classe de configuration de l'application.
|
||||||
|
*
|
||||||
|
* Centralise la résolution des chemins et paramètres
|
||||||
|
* qui dépendent de l'environnement d'exécution.
|
||||||
|
*/
|
||||||
|
final class Config
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retourne le chemin du cache Twig, ou false si le cache est désactivé.
|
||||||
|
*
|
||||||
|
* Le cache Twig est désactivé en développement pour refléter
|
||||||
|
* immédiatement les modifications des templates.
|
||||||
|
* Le répertoire est créé en amont par Bootstrap::checkDirectories().
|
||||||
|
*
|
||||||
|
* @param bool $isDev True si l'application est en mode développement
|
||||||
|
*
|
||||||
|
* @return string|false Le chemin absolu du répertoire de cache, ou false
|
||||||
|
*/
|
||||||
|
public static function getTwigCache(bool $isDev): string|false
|
||||||
|
{
|
||||||
|
if ($isDev) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return __DIR__.'/../../var/cache/twig';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le chemin absolu vers le fichier de base de données SQLite.
|
||||||
|
*
|
||||||
|
* Crée le répertoire et le fichier s'ils n'existent pas encore.
|
||||||
|
* En pratique, Bootstrap::checkDirectories() crée le répertoire `database/`
|
||||||
|
* avant que cette méthode soit appelée ; les opérations @mkdir/@touch/@chmod
|
||||||
|
* ne seront actives que si getDatabasePath() est appelé hors du cycle Bootstrap
|
||||||
|
* (tests unitaires, scripts CLI, etc.).
|
||||||
|
*
|
||||||
|
* @return string Chemin absolu vers le fichier app.sqlite
|
||||||
|
*/
|
||||||
|
public static function getDatabasePath(): string
|
||||||
|
{
|
||||||
|
$path = dirname(__DIR__, 2).'/database/app.sqlite';
|
||||||
|
$dir = dirname($path);
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
@touch($path);
|
||||||
|
@chmod($path, 0664);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/Shared/Database/Migrator.php
Normal file
137
src/Shared/Database/Migrator.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Database;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestionnaire de migrations de base de données.
|
||||||
|
*
|
||||||
|
* Responsabilité unique : exécuter les migrations DDL et synchroniser
|
||||||
|
* l'index FTS5. Le provisionnement des données initiales est délégué
|
||||||
|
* à {@see Seeder}.
|
||||||
|
*
|
||||||
|
* Convention des fichiers de migration :
|
||||||
|
* - Placés dans database/migrations/
|
||||||
|
* - Nommés NNN_description.php (ex: 001_create_users.php)
|
||||||
|
* - Retournent un tableau ['up' => 'SQL...', 'down' => 'SQL...']
|
||||||
|
* - Triés et exécutés par ordre alphanumérique croissant
|
||||||
|
*
|
||||||
|
* run() est idempotent et sûr à appeler à chaque démarrage applicatif :
|
||||||
|
* les migrations déjà appliquées ne sont jamais rejouées.
|
||||||
|
*/
|
||||||
|
final class Migrator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Répertoire contenant les fichiers de migration.
|
||||||
|
*/
|
||||||
|
private const MIGRATIONS_DIR = __DIR__ . '/../../../database/migrations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exécute les migrations en attente puis synchronise l'index FTS5.
|
||||||
|
*
|
||||||
|
* Opération idempotente et sans effets de bord sur les données :
|
||||||
|
* sûre à appeler à chaque démarrage applicatif.
|
||||||
|
*
|
||||||
|
* Séquence :
|
||||||
|
* 1. Crée la table de suivi si absente
|
||||||
|
* 2. Joue les migrations en attente
|
||||||
|
* 3. Indexe dans posts_fts les articles absents de l'index (syncFtsIndex)
|
||||||
|
*
|
||||||
|
* @param PDO $db L'instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
public static function run(PDO $db): void
|
||||||
|
{
|
||||||
|
self::createMigrationTable($db);
|
||||||
|
self::runPendingMigrations($db);
|
||||||
|
self::syncFtsIndex($db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée la table de suivi des migrations si elle n'existe pas.
|
||||||
|
*
|
||||||
|
* Cette table doit exister avant de pouvoir lire les migrations appliquées.
|
||||||
|
*
|
||||||
|
* @param PDO $db L'instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
private static function createMigrationTable(PDO $db): void
|
||||||
|
{
|
||||||
|
$db->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
version TEXT NOT NULL UNIQUE,
|
||||||
|
run_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les fichiers de migration, filtre ceux déjà appliqués
|
||||||
|
* et exécute les migrations en attente dans l'ordre.
|
||||||
|
*
|
||||||
|
* @param PDO $db L'instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
private static function runPendingMigrations(PDO $db): void
|
||||||
|
{
|
||||||
|
// Versions déjà appliquées (indexées pour un accès O(1))
|
||||||
|
$stmt = $db->query('SELECT version FROM migrations');
|
||||||
|
$rows = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : [];
|
||||||
|
$applied = array_flip($rows);
|
||||||
|
|
||||||
|
// Fichiers de migration triés par nom (ordre alphanumérique = ordre numérique)
|
||||||
|
$files = glob(self::MIGRATIONS_DIR . '/*.php') ?: [];
|
||||||
|
sort($files);
|
||||||
|
|
||||||
|
$insert = $db->prepare('INSERT INTO migrations (version, run_at) VALUES (:version, :run_at)');
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$version = basename($file, '.php');
|
||||||
|
|
||||||
|
if (isset($applied[$version])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// require évalue le fichier à chaque appel dans la boucle.
|
||||||
|
// require_once aurait mis en cache le résultat du premier fichier
|
||||||
|
// et l'aurait réutilisé pour tous les suivants — à ne pas utiliser ici.
|
||||||
|
$migration = require $file;
|
||||||
|
|
||||||
|
$db->exec($migration['up']);
|
||||||
|
|
||||||
|
$insert->execute([
|
||||||
|
':version' => $version,
|
||||||
|
':run_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronise l'index FTS5 avec les articles présents en base.
|
||||||
|
*
|
||||||
|
* Insère dans posts_fts les articles dont le rowid est absent de l'index.
|
||||||
|
* Idempotent et sans effet si l'index est déjà à jour.
|
||||||
|
*
|
||||||
|
* Nécessaire car les triggers FTS5 ne couvrent que les INSERT/UPDATE/DELETE
|
||||||
|
* effectués APRÈS leur création — les articles existants au moment de la
|
||||||
|
* migration 006 ne sont pas indexés rétroactivement.
|
||||||
|
*
|
||||||
|
* strip_tags() est enregistrée comme fonction SQLite dans container.php via
|
||||||
|
* sqliteCreateFunction() avant l'appel à Migrator::run() — elle est donc
|
||||||
|
* disponible ici.
|
||||||
|
*
|
||||||
|
* @param PDO $db L'instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
private static function syncFtsIndex(PDO $db): void
|
||||||
|
{
|
||||||
|
$db->exec("
|
||||||
|
INSERT INTO posts_fts(rowid, title, content, author_username)
|
||||||
|
SELECT p.id,
|
||||||
|
p.title,
|
||||||
|
COALESCE(strip_tags(p.content), ''),
|
||||||
|
COALESCE((SELECT username FROM users WHERE id = p.author_id), '')
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.id NOT IN (SELECT rowid FROM posts_fts)
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Shared/Database/Provisioner.php
Normal file
37
src/Shared/Database/Provisioner.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Database;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestration du provisionnement de la base de données.
|
||||||
|
*
|
||||||
|
* Exécute les migrations puis le seeding éventuel sous verrou fichier
|
||||||
|
* afin d'éviter les exécutions concurrentes au démarrage ou en CLI.
|
||||||
|
*/
|
||||||
|
final class Provisioner
|
||||||
|
{
|
||||||
|
public static function run(PDO $db): void
|
||||||
|
{
|
||||||
|
$lockPath = __DIR__ . '/../../../database/.provision.lock';
|
||||||
|
$handle = fopen($lockPath, 'c+');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new \RuntimeException('Impossible d\'ouvrir le verrou de provisionnement');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!flock($handle, LOCK_EX)) {
|
||||||
|
throw new \RuntimeException('Impossible d\'obtenir le verrou de provisionnement');
|
||||||
|
}
|
||||||
|
|
||||||
|
Migrator::run($db);
|
||||||
|
Seeder::seed($db);
|
||||||
|
} finally {
|
||||||
|
flock($handle, LOCK_UN);
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/Shared/Database/Seeder.php
Normal file
71
src/Shared/Database/Seeder.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Database;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provisionnement des données initiales de l'application.
|
||||||
|
*
|
||||||
|
* Responsabilité unique : insérer les données nécessaires au premier démarrage
|
||||||
|
* (compte administrateur). N'exécute jamais de DDL — c'est le rôle du Migrator.
|
||||||
|
*
|
||||||
|
* Toutes les opérations sont idempotentes : le Seeder peut être appelé à chaque
|
||||||
|
* démarrage sans risque de doublon ni d'erreur si les données existent déjà.
|
||||||
|
*
|
||||||
|
* Variables d'environnement lues depuis .env :
|
||||||
|
* - ADMIN_USERNAME — nom d'utilisateur du compte admin (normalisé en minuscules)
|
||||||
|
* - ADMIN_EMAIL — adresse e-mail du compte admin (normalisée en minuscules)
|
||||||
|
* - ADMIN_PASSWORD — mot de passe en clair, haché en bcrypt avant insertion
|
||||||
|
*/
|
||||||
|
final class Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Exécute toutes les opérations de provisionnement.
|
||||||
|
*
|
||||||
|
* Appelé dans Bootstrap::initialize() après Migrator::run(), une fois
|
||||||
|
* que le schéma est garanti à jour.
|
||||||
|
*
|
||||||
|
* @param PDO $db L'instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
public static function seed(PDO $db): void
|
||||||
|
{
|
||||||
|
self::seedAdminUser($db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée le compte administrateur défini dans les variables d'environnement.
|
||||||
|
*
|
||||||
|
* Opération idempotente : le compte n'est créé que s'il n'existe pas encore.
|
||||||
|
* Sans effet si un utilisateur portant le même nom d'utilisateur est déjà présent.
|
||||||
|
*
|
||||||
|
* @param PDO $db L'instance de connexion à la base de données
|
||||||
|
*/
|
||||||
|
private static function seedAdminUser(PDO $db): void
|
||||||
|
{
|
||||||
|
$username = mb_strtolower(trim($_ENV['ADMIN_USERNAME'] ?? 'admin'));
|
||||||
|
$email = mb_strtolower(trim($_ENV['ADMIN_EMAIL'] ?? 'admin@example.com'));
|
||||||
|
$password = $_ENV['ADMIN_PASSWORD'] ?? 'changeme123';
|
||||||
|
|
||||||
|
$stmt = $db->prepare('SELECT id FROM users WHERE username = :username');
|
||||||
|
$stmt->execute([':username' => $username]);
|
||||||
|
|
||||||
|
if ($stmt->fetchColumn() !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO users (username, email, password_hash, role, created_at)
|
||||||
|
VALUES (:username, :email, :password_hash, :role, :created_at)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
':username' => $username,
|
||||||
|
':email' => $email,
|
||||||
|
':password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]),
|
||||||
|
':role' => 'admin',
|
||||||
|
':created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Shared/Exception/NotFoundException.php
Normal file
22
src/Shared/Exception/NotFoundException.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception levée lorsqu'une entité est introuvable en base de données.
|
||||||
|
*
|
||||||
|
* Exception générique réutilisable dans tous les domaines pour signaler
|
||||||
|
* qu'une ressource demandée n'existe pas ou n'existe plus.
|
||||||
|
*/
|
||||||
|
final class NotFoundException extends \RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $entity Type de l'entité (ex: 'Article', 'Utilisateur')
|
||||||
|
* @param int|string $identifier Identifiant de l'entité (id ou slug)
|
||||||
|
*/
|
||||||
|
public function __construct(string $entity, int|string $identifier)
|
||||||
|
{
|
||||||
|
parent::__construct("{$entity} introuvable : {$identifier}");
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Shared/Extension/AppExtension.php
Normal file
36
src/Shared/Extension/AppExtension.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Extension;
|
||||||
|
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\Extension\GlobalsInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension Twig pour les variables globales de l'application.
|
||||||
|
*
|
||||||
|
* Expose la variable globale `app_url` dans tous les templates Twig,
|
||||||
|
* utile pour construire des URLs absolues (balises OG, flux RSS, emails…).
|
||||||
|
*
|
||||||
|
* Usage dans un template :
|
||||||
|
* <meta property="og:url" content="{{ app_url }}{{ post_url(post) }}">
|
||||||
|
*/
|
||||||
|
final class AppExtension extends AbstractExtension implements GlobalsInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $appUrl URL de base de l'application, sans slash final (depuis APP_URL dans .env)
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly string $appUrl)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les variables globales injectées dans tous les templates.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getGlobals(): array
|
||||||
|
{
|
||||||
|
return ['app_url' => $this->appUrl];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Shared/Extension/CsrfExtension.php
Normal file
47
src/Shared/Extension/CsrfExtension.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Extension;
|
||||||
|
|
||||||
|
use Slim\Csrf\Guard;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\Extension\GlobalsInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension Twig pour l'accès aux tokens CSRF dans les templates.
|
||||||
|
*
|
||||||
|
* Expose une variable globale `csrf` dans tous les templates Twig,
|
||||||
|
* permettant d'injecter les champs cachés nécessaires dans les formulaires.
|
||||||
|
*
|
||||||
|
* Usage dans un template :
|
||||||
|
* <input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
|
||||||
|
* <input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
|
||||||
|
*/
|
||||||
|
final class CsrfExtension extends AbstractExtension implements GlobalsInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Guard $csrf Instance du middleware CSRF de Slim
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly Guard $csrf)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les variables globales injectées dans tous les templates.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getGlobals(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'csrf' => [
|
||||||
|
'keys' => [
|
||||||
|
'name' => $this->csrf->getTokenNameKey(),
|
||||||
|
'value' => $this->csrf->getTokenValueKey(),
|
||||||
|
],
|
||||||
|
'name' => $this->csrf->getTokenName(),
|
||||||
|
'value' => $this->csrf->getTokenValue(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Shared/Extension/SessionExtension.php
Normal file
42
src/Shared/Extension/SessionExtension.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Extension;
|
||||||
|
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\Extension\GlobalsInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension Twig pour l'accès aux données de session dans les templates.
|
||||||
|
*
|
||||||
|
* Expose la variable globale `session` dans tous les templates Twig,
|
||||||
|
* permettant de lire les données de session sans logique PHP dans les vues.
|
||||||
|
*
|
||||||
|
* Usage dans un template :
|
||||||
|
* {% if session.user_id is defined %}
|
||||||
|
* Connecté en tant que {{ session.username }}
|
||||||
|
* {% endif %}
|
||||||
|
*/
|
||||||
|
final class SessionExtension extends AbstractExtension implements GlobalsInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retourne les variables globales injectées dans tous les templates.
|
||||||
|
*
|
||||||
|
* Seules les données nécessaires aux templates sont exposées, et non
|
||||||
|
* la totalité de $_SESSION. Cela évite d'exposer les messages flash
|
||||||
|
* non encore consommés, les tokens CSRF internes et toute autre donnée
|
||||||
|
* de session ajoutée à l'avenir.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function getGlobals(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'session' => [
|
||||||
|
'user_id' => $_SESSION['user_id'] ?? null,
|
||||||
|
'username' => $_SESSION['username'] ?? null,
|
||||||
|
'role' => $_SESSION['role'] ?? null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Shared/Html/HtmlPurifierFactory.php
Normal file
66
src/Shared/Html/HtmlPurifierFactory.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Html;
|
||||||
|
|
||||||
|
use HTMLPurifier;
|
||||||
|
use HTMLPurifier_Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory de configuration pour HTMLPurifier.
|
||||||
|
*
|
||||||
|
* Centralise la création et la configuration d'une instance HTMLPurifier
|
||||||
|
* avec les balises, attributs et schémas URI autorisés pour le contenu
|
||||||
|
* des articles produit par l'éditeur Trumbowyg.
|
||||||
|
*
|
||||||
|
* Règles de sécurité appliquées :
|
||||||
|
* - Balises autorisées alignées sur les boutons Trumbowyg (img autorisé via plugin upload)
|
||||||
|
* - Schémas URI restreints à http, https et mailto (bloque javascript:, data:)
|
||||||
|
* - Conversion automatique des URL nues en liens (AutoFormat.Linkify)
|
||||||
|
*/
|
||||||
|
final class HtmlPurifierFactory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Crée et retourne une instance HTMLPurifier préconfigurée.
|
||||||
|
*
|
||||||
|
* Les balises autorisées sont alignées sur les boutons Trumbowyg exposés
|
||||||
|
* dans l'éditeur et le plugin trumbowyg.upload.
|
||||||
|
*
|
||||||
|
* Sécurité URI : seuls les schémas http, https et mailto sont autorisés
|
||||||
|
* dans les attributs href, ce qui bloque les liens javascript: et data:.
|
||||||
|
*
|
||||||
|
* @param string $cacheDir Chemin absolu vers le répertoire de cache HTMLPurifier
|
||||||
|
* (créé automatiquement s'il n'existe pas)
|
||||||
|
*
|
||||||
|
* @return HTMLPurifier L'instance configurée et prête à purifier
|
||||||
|
*/
|
||||||
|
public static function create(string $cacheDir): HTMLPurifier
|
||||||
|
{
|
||||||
|
if (!is_dir($cacheDir)) {
|
||||||
|
@mkdir($cacheDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = HTMLPurifier_Config::createDefault();
|
||||||
|
|
||||||
|
// Balises autorisées avec attribut style sur les éléments de texte
|
||||||
|
$config->set(
|
||||||
|
'HTML.Allowed',
|
||||||
|
'p[style],br,strong,em,u,del,h1[style],h2[style],h3[style],h4[style],h5[style],h6[style],ul,ol,li[style],blockquote[style],pre,a[href|title],img[src|alt|width|height]'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Autoriser uniquement la propriété CSS text-align (sécurité)
|
||||||
|
$config->set('CSS.AllowedProperties', ['text-align']);
|
||||||
|
|
||||||
|
// Restriction des schémas URI autorisés dans href
|
||||||
|
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]);
|
||||||
|
|
||||||
|
// Conversion automatique des URL nues en liens cliquables
|
||||||
|
$config->set('AutoFormat.Linkify', true);
|
||||||
|
|
||||||
|
// Configuration du cache de définitions HTMLPurifier
|
||||||
|
$config->set('Cache.DefinitionImpl', 'Serializer');
|
||||||
|
$config->set('Cache.SerializerPath', $cacheDir);
|
||||||
|
|
||||||
|
return new HTMLPurifier($config);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Shared/Html/HtmlSanitizer.php
Normal file
34
src/Shared/Html/HtmlSanitizer.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Html;
|
||||||
|
|
||||||
|
use HTMLPurifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de sanitisation du contenu HTML.
|
||||||
|
*
|
||||||
|
* Délègue le nettoyage du HTML à HTMLPurifier pour supprimer
|
||||||
|
* tout contenu potentiellement malveillant (XSS, balises non autorisées).
|
||||||
|
*/
|
||||||
|
final class HtmlSanitizer implements HtmlSanitizerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param HTMLPurifier $purifier Instance préconfigurée via HtmlPurifierFactory
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly HTMLPurifier $purifier)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nettoie le contenu HTML fourni et retourne une version sûre.
|
||||||
|
*
|
||||||
|
* @param string $html Le contenu HTML brut à sanitiser
|
||||||
|
*
|
||||||
|
* @return string Le contenu HTML nettoyé
|
||||||
|
*/
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
return $this->purifier->purify($html);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Shared/Html/HtmlSanitizerInterface.php
Normal file
19
src/Shared/Html/HtmlSanitizerInterface.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Html;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat pour la sanitisation du contenu HTML.
|
||||||
|
*/
|
||||||
|
interface HtmlSanitizerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Nettoie le contenu HTML fourni et retourne une version sûre.
|
||||||
|
*
|
||||||
|
* @param string $html Le contenu HTML brut à sanitiser
|
||||||
|
*
|
||||||
|
* @return string Le contenu HTML nettoyé
|
||||||
|
*/
|
||||||
|
public function sanitize(string $html): string;
|
||||||
|
}
|
||||||
58
src/Shared/Http/ClientIpResolver.php
Normal file
58
src/Shared/Http/ClientIpResolver.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Http;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout l'adresse IP cliente à partir de la requête HTTP.
|
||||||
|
*
|
||||||
|
* L'en-tête X-Forwarded-For n'est pris en compte que si REMOTE_ADDR
|
||||||
|
* correspond à un proxy explicitement approuvé. En l'absence d'IP
|
||||||
|
* exploitable, la valeur de repli '0.0.0.0' est renvoyée.
|
||||||
|
*/
|
||||||
|
final class ClientIpResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string[] $trustedProxies
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly array $trustedProxies = [])
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(ServerRequestInterface $request): string
|
||||||
|
{
|
||||||
|
$serverParams = $request->getServerParams();
|
||||||
|
$remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? ''));
|
||||||
|
|
||||||
|
if ($remoteAddr === '') {
|
||||||
|
return '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isTrustedProxy($remoteAddr)) {
|
||||||
|
return $remoteAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
|
||||||
|
|
||||||
|
if ($forwarded === '') {
|
||||||
|
return $remoteAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = trim(explode(',', $forwarded)[0]);
|
||||||
|
|
||||||
|
return filter_var($candidate, FILTER_VALIDATE_IP) ? $candidate : $remoteAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isTrustedProxy(string $remoteAddr): bool
|
||||||
|
{
|
||||||
|
foreach ($this->trustedProxies as $proxy) {
|
||||||
|
if ($proxy === '*' || $proxy === $remoteAddr) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/Shared/Http/FlashService.php
Normal file
45
src/Shared/Http/FlashService.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de messages flash.
|
||||||
|
*
|
||||||
|
* Gère les messages temporaires stockés en session pour être affichés
|
||||||
|
* après une redirection HTTP. Un message flash est lu une seule fois
|
||||||
|
* puis supprimé automatiquement.
|
||||||
|
*/
|
||||||
|
final class FlashService implements FlashServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Enregistre un message flash en session.
|
||||||
|
*
|
||||||
|
* @param string $key Clé d'identification du message (ex: 'login_error')
|
||||||
|
* @param string $message Texte du message à afficher
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function set(string $key, string $message): void
|
||||||
|
{
|
||||||
|
$_SESSION['flash'][$key] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un message flash et le supprime de la session.
|
||||||
|
*
|
||||||
|
* Le cast (string) protège contre une valeur non-string stockée
|
||||||
|
* directement dans $_SESSION['flash'] (ex: entier ou booléen) sans
|
||||||
|
* passer par set(), tout en garantissant le type de retour déclaré.
|
||||||
|
*
|
||||||
|
* @param string $key Clé d'identification du message
|
||||||
|
*
|
||||||
|
* @return string|null Le message, ou null s'il n'existe pas
|
||||||
|
*/
|
||||||
|
public function get(string $key): ?string
|
||||||
|
{
|
||||||
|
$message = $_SESSION['flash'][$key] ?? null;
|
||||||
|
unset($_SESSION['flash'][$key]);
|
||||||
|
|
||||||
|
return $message !== null ? (string) $message : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Shared/Http/FlashServiceInterface.php
Normal file
31
src/Shared/Http/FlashServiceInterface.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat du service de messages flash.
|
||||||
|
*
|
||||||
|
* Permet de mocker les messages flash dans les tests unitaires
|
||||||
|
* sans dépendre de la classe concrète finale FlashService.
|
||||||
|
*/
|
||||||
|
interface FlashServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Enregistre un message flash en session.
|
||||||
|
*
|
||||||
|
* @param string $key Clé d'identification du message (ex: 'login_error')
|
||||||
|
* @param string $message Texte du message à afficher
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function set(string $key, string $message): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un message flash et le supprime de la session.
|
||||||
|
*
|
||||||
|
* @param string $key Clé d'identification du message
|
||||||
|
*
|
||||||
|
* @return string|null Le message, ou null s'il n'existe pas
|
||||||
|
*/
|
||||||
|
public function get(string $key): ?string;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user