commit 8f7e61bda067b3cb16cd30ebffa94ce03a1bf9a4
Author: julien
Date: Mon Mar 16 01:47:07 2026 +0100
first commit
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5aa552c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,61 @@
+# ============================================
+# Environnement & Configuration
+# ============================================
+.env
+
+# ============================================
+# Dépendances Composer
+# ============================================
+# Reconstruites dans l'image par composer install
+vendor/
+
+# ============================================
+# Dépendances NPM
+# ============================================
+# Reconstruites dans l'image par npm install
+node_modules/
+
+# ============================================
+# Assets générés
+# ============================================
+# Reconstruits dans l'image par npm run build
+public/assets/
+
+# ============================================
+# Données persistantes (bind mounts)
+# ============================================
+# Montés depuis l'hôte au démarrage — ne pas inclure dans l'image
+data/
+public/media/
+database/*.sqlite
+
+# ============================================
+# Cache & Logs
+# ============================================
+var/
+coverage/
+.php-cs-fixer.cache
+.phpstan/
+.phpunit.result.cache
+
+# ============================================
+# Tests & Documentation
+# ============================================
+tests/
+docs/
+
+# ============================================
+# Versioning
+# ============================================
+.git/
+
+# ============================================
+# IDE & OS
+# ============================================
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+Thumbs.db
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e58c8eb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,45 @@
+# =============================================================================
+# Général
+# =============================================================================
+
+# Environnement de l'application (development, test ou production)
+APP_ENV=development
+
+# Nom du blog (utilisé dans le flux RSS et les emails)
+APP_NAME="Slim Blog"
+
+# URL de base de l'application (utilisée pour les liens dans les emails et le flux RSS).
+# Développement : APP_URL=http://localhost:8080
+# Production : APP_URL=https://blog.exemple.com
+APP_URL=http://localhost:8080
+
+# Fuseau horaire
+TIMEZONE=Europe/Paris
+
+# =============================================================================
+# Administration
+# =============================================================================
+
+# Compte administrateur (créé automatiquement au premier démarrage)
+ADMIN_USERNAME=admin
+ADMIN_EMAIL=admin@example.com
+ADMIN_PASSWORD=changeme123
+
+# =============================================================================
+# Email
+# =============================================================================
+
+MAIL_HOST=smtp.example.com
+MAIL_PORT=587
+MAIL_USERNAME=noreply@example.com
+MAIL_PASSWORD=your_smtp_password
+MAIL_ENCRYPTION=tls
+MAIL_FROM=noreply@example.com
+MAIL_FROM_NAME="Slim Blog"
+
+# =============================================================================
+# Uploads
+# =============================================================================
+
+# Taille maximale en octets (doit être < upload_max_filesize dans docker/php/php.ini en production)
+UPLOAD_MAX_SIZE=5242880
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f7ea1d3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,56 @@
+# ============================================
+# Environnement & Configuration
+# ============================================
+.env
+
+# ============================================
+# Dépendances Composer
+# ============================================
+vendor/
+
+# ============================================
+# Dépendances NPM
+# ============================================
+node_modules/
+
+# ============================================
+# Assets générés
+# ============================================
+public/assets/
+
+# ============================================
+# Base de données
+# ============================================
+database/*.sqlite
+database/*.sqlite-shm
+database/*.sqlite-wal
+
+# ============================================
+# Cache & Logs
+# ============================================
+coverage/
+var/
+.php-cs-fixer.cache
+.phpstan/
+.phpunit.result.cache
+
+# ============================================
+# Media
+# ============================================
+public/media/
+
+# ============================================
+# Docker volumes
+# ============================================
+data/
+
+# ============================================
+# IDE & OS
+# ============================================
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+Thumbs.db
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..25a1b6e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,116 @@
+# Contribuer au projet
+
+## Prérequis
+
+Les mêmes que pour le développement (voir [README](README.md)), plus :
+
+- PHP 8.1+ avec l'extension `dom` (requise par HTMLPurifier et PHPUnit)
+
+## Lancer les tests
+
+```bash
+composer install
+vendor/bin/phpunit
+```
+
+Avec rapport de couverture (nécessite Xdebug ou PCOV) :
+
+```bash
+vendor/bin/phpunit --coverage-text
+```
+
+## Analyse statique (PHPStan)
+
+```bash
+vendor/bin/phpstan analyse
+```
+
+Cette commande utilise `phpstan.neon` qui définit le niveau 8 et les chemins analysés. PDO est nativement connu de PHPStan — aucun stub supplémentaire n'est nécessaire.
+
+PHPStan est configuré au niveau 8, le plus strict. L'exécuter avant toute Pull Request garantit la cohérence des types et détecte les erreurs avant l'exécution.
+
+## Suite de tests
+
+Les tests sont dans `tests/`, organisés en miroir de `src/`.
+
+### `tests/Auth/`
+
+| Fichier | Classe testée | Ce qui est vérifié |
+|--------------------------------|---------------------------|---------------------|
+| `AuthServiceTest` | `AuthService` | `createUser()` (normalisation, unicité via exceptions métier, longueur mdp), `authenticate()`, `changePassword()`, `login/logout/isLoggedIn()` |
+| `AuthServiceRateLimitTest` | `AuthService` | `checkRateLimit()` (IP libre, verrouillée, expirée, minimum 1 minute, `deleteExpired()`), `recordFailure()` (constantes MAX_ATTEMPTS/LOCK_MINUTES), `resetRateLimit()` |
+| `LoginAttemptRepositoryTest` | `LoginAttemptRepository` | `findByIp()`, `recordFailure()` (INSERT vs UPDATE, compteur, seuil exact, fenêtre temporelle), `resetForIp()`, `deleteExpired()` |
+| `PasswordResetServiceTest` | `PasswordResetService` | `requestReset()` (email inconnu silencieux, invalidation, création, envoi, URL), `validateToken()` (inexistant, expiré, valide), `resetPassword()` (token invalide, mdp trop court via `WeakPasswordException`, mise à jour + consommation) |
+| `PasswordResetRepositoryTest` | `PasswordResetRepository` | `create()`, `findActiveByHash()` (filtre `used_at = null`), `invalidateByUserId()` et `markAsUsed()` (jamais de `delete`) |
+
+### `tests/User/`
+
+| Fichier | Classe testée | Ce qui est vérifié |
+|-----------------------|------------------|---------------------|
+| `UserTest` | `User` | Construction, validation (username, email, hash, rôle), limites min/max, `fromArray()` |
+| `UserRepositoryTest` | `UserRepository` | `findAll/ById/ByUsername/ByEmail()`, `create()`, `updatePassword()`, `delete()` |
+
+### `tests/Shared/`
+
+| Fichier | Classe testée | Ce qui est vérifié |
+|-----------------------|------------------|---------------------|
+| `SessionManagerTest` | `SessionManager` / `SessionManagerInterface` | `isAuthenticated()`, `getUserId()`, rôles, écriture `$_SESSION`, `destroy()` |
+| `HtmlSanitizerTest` | `HtmlSanitizer` | Balises autorisées conservées, XSS supprimé (`monde ' . str_repeat('x', 30) . '
';
+ $post = new Post(1, 'Titre', $html, 'titre');
+
+ $excerpt = $this->call('post_excerpt', $post, 20);
+
+ self::assertStringContainsString('Bonjour', $excerpt);
+ self::assertStringContainsString('', $excerpt);
+ self::assertStringNotContainsString('';
+ $result = $this->sanitizer->sanitize($html);
+
+ $this->assertStringNotContainsString('">XSS';
+ $result = $this->sanitizer->sanitize($html);
+
+ $this->assertStringNotContainsString('data:', $result);
+ }
+
+ /**
+ * La balise