Refatoring : Working state

This commit is contained in:
julien
2026-03-16 15:37:57 +01:00
parent aa00bec846
commit a5ca0df375
16 changed files with 251 additions and 568 deletions

View File

@@ -3,18 +3,18 @@
![PHP](https://img.shields.io/badge/PHP-8.4.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)
![Tests](https://img.shields.io/badge/tests-442%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
et `Shared` portent une architecture DDD légère, lisible et réutilisable 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
- **Médias** — upload WebP avec déduplication SHA-256 par utilisateur
- **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`)
@@ -50,6 +50,8 @@ php bin/provision.php
php -S localhost:8080 -t public
```
Le projet est encore en développement : l'historique des migrations a été simplifié en une baseline courte. Si vous aviez une ancienne base locale, supprimez `database/app.sqlite` puis reprovisionnez.
Pour surveiller les modifications SCSS et recompiler automatiquement en développement :
```bash
@@ -177,11 +179,11 @@ Le contenu du blog (articles publiés) est soumis à [CC BY-SA 4.0](https://crea
## Provisioning
Le provisionnement (migrations + seed admin) s'execute explicitement via `php bin/provision.php`.
Le provisionnement (migrations + seed admin) s'exécute explicitement via `php bin/provision.php`.
- Developpement local : executer `php bin/provision.php` apres `cp .env.example .env`
- Docker / production : executer `docker compose exec app php bin/provision.php` apres le demarrage du conteneur
- Développement local : exécuter `php bin/provision.php` apres `cp .env.example .env`
- Docker / production : exécuter `docker compose exec app php bin/provision.php` apres le demarrage du conteneur
Le runtime HTTP ne provisionne plus automatiquement la base. Si le schema n'est pas present, l'application echoue avec un message explicite demandant d'executer la commande de provisionnement.
Le runtime HTTP ne provisionne plus automatiquement la base. Si le schéma n'est pas présent, l'application echoue avec un message explicite demandant d'exécuter la commande de provisionnement.
Pour repartir d'un schema frais en developpement apres un nettoyage de l'historique des migrations, supprimez d'abord la base SQLite locale puis relancez le provisionnement : `rm -f database/app.sqlite` (ou votre fichier SQLite configure), puis `php bin/provision.php`.
Pour repartir d'un schéma frais en développement apres un nettoyage de l'historique des migrations, supprimez d'abord la base SQLite locale puis relancez le provisionnement : `rm -f database/app.sqlite` (ou votre fichier SQLite configure), puis `php bin/provision.php`.

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* Migration 001 — Création du schéma principal de l'application.
*
* Cette baseline remplace l'historique détaillé des migrations tant que
* le projet est encore en développement et qu'aucune donnée n'est à conserver.
*/
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
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL
);
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);
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
url TEXT NOT NULL,
hash TEXT NOT NULL,
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);
CREATE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id);
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
);
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;
DROP TABLE IF EXISTS password_resets;
DROP INDEX IF EXISTS idx_media_hash_user_id;
DROP INDEX IF EXISTS idx_media_user_id;
DROP TABLE IF EXISTS media;
DROP INDEX IF EXISTS idx_posts_author_id;
DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS users;
",
];

View File

@@ -1,19 +0,0 @@
<?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

@@ -1,23 +0,0 @@
<?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

@@ -1,25 +1,9 @@
<?php
declare(strict_types=1);
/**
* 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.
* Migration 002 Création de l'index de recherche FTS5 et de ses triggers.
*/
return [
'up' => "
@@ -57,12 +41,27 @@ return [
AFTER DELETE ON posts BEGIN
DELETE FROM posts_fts WHERE rowid = OLD.id;
END;
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;
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;
DROP TABLE IF EXISTS posts_fts;
",
];

View File

@@ -1,31 +0,0 @@
<?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

@@ -1,22 +0,0 @@
<?php
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,
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);
CREATE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id);
",
'down' => "
DROP INDEX IF EXISTS idx_media_hash_user_id;
DROP INDEX IF EXISTS idx_media_user_id;
DROP TABLE IF EXISTS media;
",
];

View File

@@ -1,26 +0,0 @@
<?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

@@ -1,27 +0,0 @@
<?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

@@ -1,25 +0,0 @@
<?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;
",
];

View File

@@ -1,128 +1,86 @@
# Architecture
> **Architecture cible après cleanup final**
>
> `Post/`, `Category/`, `User/`, `Media/` et `Auth/` suivent maintenant une organisation verticale
> `Application / Infrastructure / Http / Domain`. Les anciennes classes de transition
> ont été retirées : routes, conteneur DI et tests pointent directement vers les
> implémentations finales, ce qui réduit la dette de compatibilité tout en conservant
> les mêmes fonctionnalités.
Slim Blog suit désormais une organisation verticale légère par domaine, avec quatre zones récurrentes :
## Domaines PHP
- `Domain` : règles métier simples, politiques, objets de valeur utilitaires
- `Application` : orchestration des cas d'usage
- `Infrastructure` : PDO, filesystem local, services techniques concrets
- `Http` : contrôleurs et adaptation requête/réponse
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).
Cette structure garde les mêmes fonctionnalités qu'au départ, mais réduit la dette de transition : routes, conteneur DI et tests pointent désormais directement vers les implémentations finales.
| 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 |
## Vue d'ensemble
### Dépendances inter-domaines
| Domaine | Rôle principal | Dépendances métier |
|-------------|----------------|--------------------|
| `Auth/` | Connexion, sessions, réinitialisation de mot de passe | `User/` |
| `Category/` | Catégories éditoriales | — |
| `Media/` | Upload, stockage local, usage dans les articles | `Post/` pour le comptage d'usage |
| `Post/` | Articles, recherche, RSS | `Category/` en présentation |
| `User/` | Comptes, rôles, création/suppression | — |
| `Shared/` | Infrastructure transverse | — |
Le projet compte deux dépendances explicites entre domaines métier :
## Structure cible d'un domaine
**`Auth/ → User/`** — `AuthApplicationService` et `PasswordResetApplicationService` 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 : `PostApplicationService` et `PdoPostRepository` 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 :
```
```text
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)
├── Application/
│ └── MonDomaineApplicationService.php
├── Domain/
│ └── RegleOuPolitique.php
├── Http/
│ └── MonDomaineController.php
── Infrastructure/
│ └── PdoMonDomaineRepository.php
├── MonEntite.php
├── MonDomaineRepositoryInterface.php
└── MonDomaineServiceInterface.php
```
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/`
Principes appliqués dans le projet :
### Interfaces de dépôts
1. Les contrôleurs HTTP dépendent d'interfaces de service (`*ServiceInterface`).
2. Les services applicatifs dépendent d'interfaces de repository et, si nécessaire, de ports techniques.
3. Les implémentations concrètes vivent en `Infrastructure/` et sont câblées dans `config/container.php`.
4. `Shared/` reste réservé au transverse : bootstrap, pagination, session, mail, sanitation HTML, utilitaires communs.
Chaque repository implémente son interface. Les services et contrôleurs dépendent uniquement de l'interface, jamais de la classe concrète :
## Dépendances inter-domaines
```php
// ✅ Correct — le service dépend de l'abstraction
final class PostService
{
public function __construct(
private readonly PostRepositoryInterface $postRepository,
) {}
}
Les dépendances entre domaines métier restent limitées et unidirectionnelles :
// ❌ À éviter — couplage fort à l'implémentation
final class PostService
{
public function __construct(
private readonly PostRepository $postRepository,
) {}
}
```
- **`Auth/ → User/`** : `AuthApplicationService` et `PasswordResetApplicationService` lisent les comptes via `UserRepositoryInterface`.
- **`Post/ → Category/`** : `Post\Http\PostController` injecte `CategoryServiceInterface` pour alimenter les formulaires et filtres.
- **`Media/ → Post/`** : `MediaApplicationService` utilise `PostRepositoryInterface` pour vérifier l'usage d'un média avant suppression.
| Interface | Implémentation | Domaine |
|------------------------------------|---------------------------|------------|
| `UserRepositoryInterface` | `PdoUserRepository` | `User/` |
| `UserServiceInterface` | `UserApplicationService` | `User/` |
| `LoginAttemptRepositoryInterface` | `PdoLoginAttemptRepository` | `Auth/` |
| `PasswordResetRepositoryInterface` | `PdoPasswordResetRepository` | `Auth/` |
| `PasswordResetServiceInterface` | `PasswordResetApplicationService` | `Auth/` |
| `AuthServiceInterface` | `AuthApplicationService` | `Auth/` |
| `PostRepositoryInterface` | `PdoPostRepository` | `Post/` |
| `PostServiceInterface` | `PostApplicationService` | `Post/` |
| `CategoryRepositoryInterface` | `PdoCategoryRepository` | `Category/`|
| `CategoryServiceInterface` | `CategoryApplicationService` | `Category/`|
| `MediaRepositoryInterface` | `PdoMediaRepository` | `Media/` |
| `MediaServiceInterface` | `MediaApplicationService` | `Media/` |
| `MediaStorageInterface` | `LocalMediaStorage` | `Media/` |
| `SessionManagerInterface` | `SessionManager` | `Shared/` |
| `MailServiceInterface` | `MailService` | `Shared/` |
| `FlashServiceInterface` | `FlashService` | `Shared/` |
Aucun domaine ne dépend d'une implémentation concrète d'un autre domaine.
### Exceptions métier
## Bindings DI principaux
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 :
| Interface | Implémentation |
|-----------|----------------|
| `AuthServiceInterface` | `Auth\Application\AuthApplicationService` |
| `PasswordResetServiceInterface` | `Auth\Application\PasswordResetApplicationService` |
| `LoginAttemptRepositoryInterface` | `Auth\Infrastructure\PdoLoginAttemptRepository` |
| `PasswordResetRepositoryInterface` | `Auth\Infrastructure\PdoPasswordResetRepository` |
| `CategoryServiceInterface` | `Category\Application\CategoryApplicationService` |
| `CategoryRepositoryInterface` | `Category\Infrastructure\PdoCategoryRepository` |
| `MediaServiceInterface` | `Media\Application\MediaApplicationService` |
| `MediaRepositoryInterface` | `Media\Infrastructure\PdoMediaRepository` |
| `MediaStorageInterface` | `Media\Infrastructure\LocalMediaStorage` |
| `PostServiceInterface` | `Post\Application\PostApplicationService` |
| `PostRepositoryInterface` | `Post\Infrastructure\PdoPostRepository` |
| `UserServiceInterface` | `User\Application\UserApplicationService` |
| `UserRepositoryInterface` | `User\Infrastructure\PdoUserRepository` |
| Exception | Namespace | Levée par |
|------------------------------|------------------------|--------------------------------------------------------|
| `DuplicateUsernameException` | `App\User\Exception` | `UserApplicationService::createUser()` |
| `DuplicateEmailException` | `App\User\Exception` | `UserApplicationService::createUser()` |
| `WeakPasswordException` | `App\User\Exception` | `UserApplicationService`, `AuthApplicationService`, `PasswordResetApplicationService` |
| `NotFoundException` | `App\Shared\Exception` | `PostApplicationService`, `AuthApplicationService` |
| `FileTooLargeException` | `App\Media\Exception` | `MediaApplicationService::store()` |
| `InvalidMimeTypeException` | `App\Media\Exception` | `MediaApplicationService::store()` |
| `StorageException` | `App\Media\Exception` | `MediaApplicationService::store()` |
## Schéma de base de données
## Base de données
```
```text
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'
├── role TEXT NOT NULL DEFAULT 'user'
└── created_at DATETIME
categories
@@ -144,307 +102,122 @@ media
├── id INTEGER PK AUTOINCREMENT
├── filename TEXT NOT NULL
├── url TEXT NOT NULL
├── hash TEXT UNIQUE NOT NULL ← SHA-256, détection des doublons
├── hash TEXT NOT NULL
├── 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
├── token_hash TEXT UNIQUE NOT NULL
├── expires_at DATETIME NOT NULL
├── used_at DATETIME DEFAULT NULL
└── 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
login_attempts
├── ip TEXT PRIMARY KEY
├── attempts INTEGER NOT NULL DEFAULT 0
── locked_until TEXT DEFAULT NULL
└── updated_at TEXT NOT NULL
```
**Index explicites** (intégrés dans les migrations 003 et 004) :
### Index utiles
| 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 |
| Index | Colonne(s) | Usage |
|-------|------------|-------|
| `idx_posts_author_id` | `posts(author_id)` | listes admin / recherche auteur |
| `idx_media_user_id` | `media(user_id)` | galerie média par utilisateur |
| `idx_media_hash_user_id` | `media(hash, user_id)` | déduplication par utilisateur |
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.
## Flux principaux
**Configuration SQLite au démarrage** (dans `config/container.php`) :
### Création / mise à jour d'un article
```
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
```
1. `Post\Http\PostController` lit la requête HTTP.
2. `PostApplicationService` valide, sanitise et génère un slug unique.
3. `PdoPostRepository` persiste en base.
4. Twig rend la réponse ou le contrôleur redirige avec un flash.
> 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.
### Upload d'un média
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à).
1. `Media\Http\MediaController` reçoit le fichier et l'utilisateur courant.
2. `MediaApplicationService` valide le type et la taille, prépare l'upload.
3. `LocalMediaStorage` convertit l'image en WebP et écrit le fichier.
4. `PdoMediaRepository` persiste l'enregistrement.
5. La déduplication se fait par couple `(hash, user_id)`.
## Routes
### Réinitialisation de mot de passe
| 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 |
1. `PasswordResetController` applique le rate limit par IP.
2. `PasswordResetApplicationService` invalide les anciens tokens actifs, crée un nouveau token hashé et envoie l'e-mail.
3. La consommation du token est atomique via `PdoPasswordResetRepository`.
## Arborescence
## Arborescence actuelle
```
```text
src/
├── Auth/
│ ├── Application/
│ ├── Domain/
│ ├── Http/
│ ├── Infrastructure/
│ ├── 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/
│ ├── Application/
│ ├── Domain/
│ ├── Http/
│ ├── Infrastructure/
│ ├── Category.php
│ ├── CategoryController.php
│ ├── CategoryRepository.php
│ ├── CategoryRepositoryInterface.php
│ ├── CategoryService.php
│ └── CategoryServiceInterface.php
├── Media/
│ ├── Application/
│ ├── Domain/
│ ├── Exception/
│ ├── FileTooLargeException.php
│ ├── InvalidMimeTypeException.php
│ │ └── StorageException.php
│ ├── Http/
│ ├── Infrastructure/
│ ├── Media.php
│ ├── MediaController.php
│ ├── MediaRepository.php
│ ├── MediaRepositoryInterface.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
│ ├── Application/
│ ├── Domain/
│ ├── Http/
│ ├── Infrastructure/
│ ├── Post.php
│ ├── PostRepositoryInterface.php
── PostServiceInterface.php
├── Shared/
│ ├── Database/
│ ├── Exception/
│ ├── Extension/
│ ├── Html/
│ ├── Http/
│ │ ├── FlashService.php
│ │ ├── FlashServiceInterface.php
│ │ ├── SessionManager.php
│ │ └── SessionManagerInterface.php
│ ├── Mail/
│ ├── MailService.php
│ │ └── MailServiceInterface.php
│ ├── Pagination/
│ ├── 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
├── Application/
├── Domain/
├── Exception/
── Http/
├── Infrastructure/
├── User.php
├── UserRepositoryInterface.php
── UserServiceInterface.php
```
## CSS
## Règles de maintenance
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)
- Ajouter une nouvelle fonctionnalité dans le domaine concerné avant de créer un nouveau dossier partagé.
- Préférer une nouvelle classe d'application ciblée à une extension d'un contrôleur trop gros.
- Garder les interfaces aux frontières utiles : repository, session, mail, storage.
- Éviter les wrappers de compatibilité temporaires une fois la migration terminée.
- Valider chaque lot avec PHPUnit et PHPStan avant de passer au suivant.

View File

@@ -87,7 +87,7 @@ Le projet a un double objectif : fonctionner comme un vrai blog, et servir de ba
- Articles avec éditeur WYSIWYG, slugs stables et recherche full-text
- Catégories pour filtrer les articles
- Médias : téléversement d'images converties en WebP avec déduplication
- Médias : téléversement d'images converties en WebP avec déduplication par utilisateur
- Comptes utilisateurs avec trois rôles : user, editor, admin
- Réinitialisation de mot de passe par e-mail
- Flux RSS des 20 derniers articles
@@ -101,6 +101,9 @@ C'est précisément son intérêt. Le code ne se cache pas derrière des couches
Ce guide accompagne cette progression. Le chapitre 2 pose les bases du langage, les chapitres suivants expliquent les choix d'architecture, et le chapitre 8 montre comment faire évoluer le projet concrètement. L'objectif n'est pas d'impressionner, c'est d'être compréhensible.
> **Note de mise à jour architecture** — le code actuel utilise des classes concrètes nommées `*ApplicationService`, des contrôleurs sous `Http/` et des implémentations PDO sous `Infrastructure/`. Quelques exemples pédagogiques plus loin gardent volontairement les noms courts `PostService`, `UserService`, etc. pour alléger les explications ; ils correspondent respectivement à `PostApplicationService`, `UserApplicationService`, `MediaApplicationService`, `CategoryApplicationService`, `AuthApplicationService` et `PasswordResetApplicationService`.
**À qui s'adresse ce guide ?** Le lecteur visé sait déjà programmer — en Python, JavaScript, ou dans un autre langage — et est à l'aise avec les concepts fondamentaux : variables, boucles, fonctions, objets. En revanche, il n'a jamais ou peu écrit de PHP. Le chapitre 2 ne réexplique pas ces concepts : il montre comment PHP les traite, en insistant sur les endroits où le langage fait des choix inhabituels ou impose des conventions qui peuvent surprendre.
### 1.4 Interface de l'application
@@ -571,7 +574,7 @@ Contient les migrations SQL qui construisent le schéma de la base de données.
```
database/ ← migrations SQL, une par table
└── migrations/ ← exécutées dans l'ordre alphanumérique
├── 001_create_users.php
├── 001_create_schema.php
├── 002_create_categories.php
├── 003_create_posts.php
├── 004_create_media.php
@@ -583,7 +586,7 @@ database/ ← migrations SQL, une par table
Chaque fichier retourne un tableau associatif avec les requêtes SQL. Voici la migration qui crée la table `users` :
```php
// database/migrations/001_create_users.php
// database/migrations/001_create_schema.php
return [
'up' => "
CREATE TABLE IF NOT EXISTS users (

View File

@@ -7,8 +7,8 @@ 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.
* La génération de slug est déléguée à CategorySlugGenerator dans
* CategoryApplicationService, avant la construction de l'objet.
*/
final class Category
{

View File

@@ -14,13 +14,13 @@ use DateTime;
* 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.
* La logique de présentation (excerpt, formatage) est déléguée à TwigPostExtension.
*
* 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
* par PostApplicationService lors de la création/modification pour produire le slug à stocker
*/
final class Post
{
@@ -210,7 +210,7 @@ final class Post
/**
* 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
* Cette méthode est réservée à PostApplicationService 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.

View File

@@ -14,7 +14,7 @@ use PDO;
*
* Convention des fichiers de migration :
* - Placés dans database/migrations/
* - Nommés NNN_description.php (ex: 001_create_users.php)
* - Nommés NNN_description.php (ex: 001_create_schema.php)
* - Retournent un tableau ['up' => 'SQL...', 'down' => 'SQL...']
* - Triés et exécutés par ordre alphanumérique croissant
*

View File

@@ -104,7 +104,7 @@ final class MigratorTest extends TestCase
Migrator::run($this->db);
$count = $this->countMigrations();
// Le projet a au moins une migration (001_create_users)
// Le projet a au moins une migration de baseline (001_create_schema)
$this->assertGreaterThan(0, $count, 'Au moins une migration doit être enregistrée');
}