452 lines
23 KiB
Markdown
452 lines
23 KiB
Markdown
# 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.
|
|
|
|
## Domaines PHP
|
|
|
|
Chaque domaine dans `src/` est autonome : modèle, interface de dépôt, implémentation du dépôt,
|
|
contrôleur. Les dépendances inter-domaines sont explicites, minimales et toujours unidirectionnelles
|
|
(voir § Dépendances inter-domaines ci-dessous).
|
|
|
|
| Domaine | Réutilisable | Notes |
|
|
|-------------|:---:|------------------------------------------------------------------------------|
|
|
| `User/` | ✅ | Modèle utilisateur, persistance, création de comptes |
|
|
| `Auth/` | ✅ | Sessions, authentification, reset mot de passe — dépend de `User/` en lecture |
|
|
| `Category/` | ✅ | Générique — deux lignes à adapter si la table cible change |
|
|
| `Media/` | ✅ | Upload, déduplication, gestion des fichiers |
|
|
| `Shared/` | ✅ | Infrastructure complète — rien de métier |
|
|
| `Post/` | ➡ | Spécifique au blog — dépend de `Category/` en présentation. Remplacer par `Product/` pour une boutique |
|
|
|
|
### Dépendances inter-domaines
|
|
|
|
Le projet compte deux dépendances explicites entre domaines métier :
|
|
|
|
**`Auth/ → User/`** — `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 :
|
|
|
|
```
|
|
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` | `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/` |
|
|
|
|
### 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` | `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()` |
|
|
|
|
## 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)
|