diff --git a/README.md b/README.md index 974dfe0..2990293 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,18 @@    - +  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`. diff --git a/database/migrations/001_create_schema.php b/database/migrations/001_create_schema.php new file mode 100644 index 0000000..fef5503 --- /dev/null +++ b/database/migrations/001_create_schema.php @@ -0,0 +1,79 @@ + " + 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; + ", +]; diff --git a/database/migrations/001_create_users.php b/database/migrations/001_create_users.php deleted file mode 100644 index 5f5ff17..0000000 --- a/database/migrations/001_create_users.php +++ /dev/null @@ -1,19 +0,0 @@ - " - 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', -]; diff --git a/database/migrations/002_create_categories.php b/database/migrations/002_create_categories.php deleted file mode 100644 index 48820ec..0000000 --- a/database/migrations/002_create_categories.php +++ /dev/null @@ -1,23 +0,0 @@ - " - 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', -]; diff --git a/database/migrations/006_create_posts_fts.php b/database/migrations/002_create_posts_search.php similarity index 57% rename from database/migrations/006_create_posts_fts.php rename to database/migrations/002_create_posts_search.php index a42bf3f..bb77bbe 100644 --- a/database/migrations/006_create_posts_fts.php +++ b/database/migrations/002_create_posts_search.php @@ -1,25 +1,9 @@ " @@ -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; ", ]; diff --git a/database/migrations/003_create_posts.php b/database/migrations/003_create_posts.php deleted file mode 100644 index 279c38c..0000000 --- a/database/migrations/003_create_posts.php +++ /dev/null @@ -1,31 +0,0 @@ - " - 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; - ", -]; diff --git a/database/migrations/004_create_media.php b/database/migrations/004_create_media.php deleted file mode 100644 index 4dc0a11..0000000 --- a/database/migrations/004_create_media.php +++ /dev/null @@ -1,22 +0,0 @@ - " - 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; - ", -]; diff --git a/database/migrations/005_create_password_resets.php b/database/migrations/005_create_password_resets.php deleted file mode 100644 index ed07f78..0000000 --- a/database/migrations/005_create_password_resets.php +++ /dev/null @@ -1,26 +0,0 @@ - " - 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', -]; diff --git a/database/migrations/007_create_login_attempts.php b/database/migrations/007_create_login_attempts.php deleted file mode 100644 index 58963ae..0000000 --- a/database/migrations/007_create_login_attempts.php +++ /dev/null @@ -1,27 +0,0 @@ - " - 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; - ", -]; diff --git a/database/migrations/008_sync_posts_fts_when_users_change.php b/database/migrations/008_sync_posts_fts_when_users_change.php deleted file mode 100644 index 8f4a8a5..0000000 --- a/database/migrations/008_sync_posts_fts_when_users_change.php +++ /dev/null @@ -1,25 +0,0 @@ - " - 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; - ", -]; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 005313d..277edcc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 § 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` | `