first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

61
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,177 @@
# Slim Blog
![PHP](https://img.shields.io/badge/PHP-8.1%2B-777BB4?logo=php&logoColor=white)
![Slim](https://img.shields.io/badge/Slim-4-74a045)
![PHPStan](https://img.shields.io/badge/PHPStan-niveau%208-blue)
![Tests](https://img.shields.io/badge/tests-355%20passing-brightgreen)
![Licence](https://img.shields.io/badge/licence-MIT-green)
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.

View 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;
}
}

View 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;

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}
}
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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";

View 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;
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

195
config/container.php Normal file
View 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', '/'));
}),
];

View 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',
];

View 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',
];

View 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;
",
];

View 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;
",
];

View 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',
];

View 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;
",
];

View 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;
",
];

View 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
View 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
View 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
View 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
View 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
View 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
View 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 §&nbsp;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

File diff suppressed because it is too large Load Diff

444
package-lock.json generated Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
parameters:
level: 8
paths:
- src
excludePaths:
- src/Shared/Bootstrap.php

27
phpunit.xml Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

15
public/index.php Normal file
View 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();

View 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);
}
}

View 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
View 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();
}
}

View 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;
}

View 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é.');
}
}

View 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]);
}
}

View 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;
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}

View 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;
}
}
}

View 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
View 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 (1100 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');
}
}
}

View 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);
}
}

View 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;
}
}

View 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;
}

View 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());
}
}

View 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;
}

View 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)");
}
}

View 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)");
}
}

View 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
View 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;
}
}

View 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);
}
}

View 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();
}
}

View 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
View 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;
}
}

View 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
View 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 (1255 caractères)
* @param string $content Contenu HTML de l'article (165 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
View 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
View 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 (12 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
View 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;
}
}

View 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
View 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;
}
}

View 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;
}

View 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
View 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
View 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;
}
}

View 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)
");
}
}

View 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);
}
}
}

View 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'),
]);
}
}

View 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}");
}
}

View 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];
}
}

View 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(),
],
];
}
}

View 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,
],
];
}
}

View 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);
}
}

View 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);
}
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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