first commit

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

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
/**
* Contrôleur pour les actions liées au compte de l'utilisateur connecté.
*
* Accessible uniquement aux utilisateurs authentifiés (AuthMiddleware).
* Actuellement limité au changement de mot de passe, accessible via
* le lien « Mon compte » dans le header.
*/
final class AccountController
{
/**
* @param Twig $view Moteur de templates Twig
* @param AuthServiceInterface $authService Service d'authentification
* @param FlashServiceInterface $flash Service de messages flash
* @param SessionManagerInterface $sessionManager Gestionnaire de session
*/
public function __construct(
private readonly Twig $view,
private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
) {
}
/**
* Affiche le formulaire de changement de mot de passe.
*
* Transmet les messages flash d'erreur et de succès issus
* d'une soumission précédente, ainsi que l'URL de retour pour
* le bouton Annuler (déduite du Referer, validée pour éviter
* les redirections ouvertes vers des domaines externes).
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue pages/account/password-change.twig
*/
public function showChangePassword(Request $req, Response $res): Response
{
// Récupère l'URL de la page précédente pour le bouton Annuler.
// On valide que le Referer est bien une URL relative du site (commence par /)
// pour éviter toute redirection vers un domaine externe (open redirect).
$referer = $req->getHeaderLine('Referer');
$path = parse_url($referer, PHP_URL_PATH);
$path = is_string($path) ? $path : '';
$backUrl = (str_starts_with($path, '/') && $path !== '/account/password')
? $path
: '/admin/posts';
return $this->view->render($res, 'pages/account/password-change.twig', [
'error' => $this->flash->get('password_error'),
'success' => $this->flash->get('password_success'),
'back_url' => $backUrl,
]);
}
/**
* Traite la soumission du formulaire de changement de mot de passe.
*
* Vérifie que les deux nouveaux mots de passe sont identiques,
* puis délègue la vérification du mot de passe actuel et la mise
* à jour à AuthService.
*
* Note : getUserId() ne peut pas retourner null ici car la route
* est protégée par AuthMiddleware. La valeur de repli 0 ne sera
* jamais atteinte en pratique.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Une redirection vers /account/password dans tous les cas
*/
public function changePassword(Request $req, Response $res): Response
{
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$current = trim((string) ($data['current_password'] ?? ''));
$new = trim((string) ($data['new_password'] ?? ''));
$confirm = trim((string) ($data['new_password_confirm'] ?? ''));
// getUserId() ne peut pas être null : route protégée par AuthMiddleware
$userId = $this->sessionManager->getUserId() ?? 0;
if ($new !== $confirm) {
$this->flash->set('password_error', 'Les mots de passe ne correspondent pas');
return $res->withHeader('Location', '/account/password')->withStatus(302);
}
try {
$this->authService->changePassword($userId, $current, $new);
$this->flash->set('password_success', 'Mot de passe modifié avec succès');
} catch (WeakPasswordException) {
$this->flash->set('password_error', 'Le nouveau mot de passe doit contenir au moins 8 caractères');
} catch (\InvalidArgumentException) {
$this->flash->set('password_error', 'Le mot de passe actuel est incorrect');
} catch (\Throwable) {
$this->flash->set('password_error', 'Une erreur inattendue s\'est produite');
}
return $res->withHeader('Location', '/account/password')->withStatus(302);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Shared\Http\ClientIpResolver;
use App\Shared\Http\FlashServiceInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
final class AuthController
{
public function __construct(
private readonly Twig $view,
private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash,
private readonly ClientIpResolver $clientIpResolver,
) {
}
public function showLogin(Request $req, Response $res): Response
{
if ($this->authService->isLoggedIn()) {
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
return $this->view->render($res, 'pages/auth/login.twig', [
'error' => $this->flash->get('login_error'),
'success' => $this->flash->get('login_success'),
]);
}
public function login(Request $req, Response $res): Response
{
$ip = $this->clientIpResolver->resolve($req);
$remainingMinutes = $this->authService->checkRateLimit($ip);
if ($remainingMinutes > 0) {
$this->flash->set(
'login_error',
"Trop de tentatives. Réessayez dans {$remainingMinutes} minute"
. ($remainingMinutes > 1 ? 's' : '')
);
return $res->withHeader('Location', '/auth/login')->withStatus(302);
}
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$username = trim((string) ($data['username'] ?? ''));
$password = trim((string) ($data['password'] ?? ''));
$user = $this->authService->authenticate($username, $password);
if ($user === null) {
$this->authService->recordFailure($ip);
$this->flash->set('login_error', 'Identifiants invalides');
return $res->withHeader('Location', '/auth/login')->withStatus(302);
}
$this->authService->resetRateLimit($ip);
$this->authService->login($user);
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
public function logout(Request $req, Response $res): Response
{
$this->authService->logout();
return $res->withHeader('Location', '/')->withStatus(302);
}
}

193
src/Auth/AuthService.php Normal file
View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
/**
* Service d'authentification.
*
* Centralise la logique métier liée à l'authentification :
* vérification des identifiants, ouverture et fermeture de session,
* changement de mot de passe et protection brute-force par IP.
*
* La création de comptes est déléguée à UserService (domaine User).
* La gestion de la session PHP est entièrement déléguée à SessionManagerInterface.
* Les noms d'utilisateurs sont normalisés en minuscules pour garantir
* l'insensibilité à la casse.
*/
final class AuthService implements AuthServiceInterface
{
/**
* Nombre maximum de tentatives échouées avant verrouillage.
*/
private const MAX_ATTEMPTS = 5;
/**
* Durée du verrouillage en minutes après dépassement du seuil.
*/
private const LOCK_MINUTES = 15;
/**
* @param UserRepositoryInterface $userRepository Dépôt de persistance des utilisateurs
* @param SessionManagerInterface $sessionManager Gestionnaire de session PHP
* @param LoginAttemptRepositoryInterface $loginAttemptRepository Dépôt des tentatives de connexion
*/
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly SessionManagerInterface $sessionManager,
private readonly LoginAttemptRepositoryInterface $loginAttemptRepository,
) {
}
/**
* Vérifie si une adresse IP est actuellement verrouillée.
*
* Nettoie les entrées expirées avant la vérification. Si l'IP est
* verrouillée, retourne le nombre de minutes restantes (arrondi au supérieur,
* minimum 1) calculé depuis les timestamps bruts pour éviter les erreurs
* d'arithmétique sur DateInterval pour des durées supérieures à 59 minutes.
*
* @param string $ip Adresse IP du client
*
* @return int 0 si libre, nombre de minutes restantes si verrouillé
*/
public function checkRateLimit(string $ip): int
{
$this->loginAttemptRepository->deleteExpired();
$row = $this->loginAttemptRepository->findByIp($ip);
if ($row === null || $row['locked_until'] === null) {
return 0;
}
$lockedUntil = new \DateTime($row['locked_until']);
$now = new \DateTime();
if ($lockedUntil <= $now) {
return 0;
}
// $diff->i et $diff->h sont des portions de l'intervalle (ex: 1h30 → h=1, i=30),
// pas des totaux — la somme serait incorrecte pour des durées > 59 min.
// On calcule directement depuis les timestamps bruts.
$secondsLeft = $lockedUntil->getTimestamp() - $now->getTimestamp();
return max(1, (int) ceil($secondsLeft / 60));
}
/**
* Enregistre un échec de connexion pour une IP.
*
* @param string $ip Adresse IP du client
* @return void
*/
public function recordFailure(string $ip): void
{
$this->loginAttemptRepository->recordFailure($ip, self::MAX_ATTEMPTS, self::LOCK_MINUTES);
}
/**
* Réinitialise le compteur de tentatives pour une IP (connexion réussie).
*
* @param string $ip Adresse IP du client
*/
public function resetRateLimit(string $ip): void
{
$this->loginAttemptRepository->resetForIp($ip);
}
/**
* Authentifie un utilisateur par nom d'utilisateur et mot de passe.
*
* Le nom d'utilisateur est normalisé en minuscules avant la recherche.
* Ne gère pas le rate limiting — responsabilité de l'appelant (AuthController).
*
* @param string $username Nom d'utilisateur (insensible à la casse)
* @param string $plainPassword Mot de passe en clair
*
* @return User|null L'utilisateur authentifié, ou null si les identifiants sont invalides
*/
public function authenticate(string $username, string $plainPassword): ?User
{
$user = $this->userRepository->findByUsername(mb_strtolower(trim($username)));
if ($user === null) {
return null;
}
if (!password_verify(trim($plainPassword), $user->getPasswordHash())) {
return null;
}
return $user;
}
/**
* Modifie le mot de passe de l'utilisateur connecté.
*
* Vérifie le mot de passe actuel avant d'appliquer le changement.
*
* @param int $userId Identifiant de l'utilisateur
* @param string $currentPassword Mot de passe actuel en clair (pour vérification)
* @param string $newPassword Nouveau mot de passe en clair (min. 8 caractères)
*
* @throws NotFoundException Si l'utilisateur est introuvable
* @throws \InvalidArgumentException Si le mot de passe actuel est incorrect
* @throws WeakPasswordException Si le nouveau mot de passe est trop court
* @return void
*/
public function changePassword(int $userId, string $currentPassword, string $newPassword): void
{
$user = $this->userRepository->findById($userId);
if ($user === null) {
throw new NotFoundException('Utilisateur', $userId);
}
if (!password_verify(trim($currentPassword), $user->getPasswordHash())) {
throw new \InvalidArgumentException('Mot de passe actuel incorrect');
}
if (mb_strlen(trim($newPassword)) < 8) {
throw new WeakPasswordException();
}
$newHash = password_hash(trim($newPassword), PASSWORD_BCRYPT, ['cost' => 12]);
$this->userRepository->updatePassword($userId, $newHash);
}
/**
* Vérifie si un utilisateur est actuellement connecté.
*
* @return bool True si une session utilisateur est active
*/
public function isLoggedIn(): bool
{
return $this->sessionManager->isAuthenticated();
}
/**
* Ouvre une session pour l'utilisateur donné.
*
* @param User $user L'utilisateur à connecter
*/
public function login(User $user): void
{
$this->sessionManager->setUser($user->getId(), $user->getUsername(), $user->getRole());
}
/**
* Ferme la session de l'utilisateur connecté.
*/
public function logout(): void
{
$this->sessionManager->destroy();
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Shared\Exception\NotFoundException;
use App\User\Exception\WeakPasswordException;
use App\User\User;
/**
* Contrat du service d'authentification.
*
* Permet de mocker le service dans les tests unitaires sans dépendre
* de la classe concrète finale AuthService.
*/
interface AuthServiceInterface
{
/**
* Vérifie si l'adresse IP est actuellement verrouillée par le rate limiter.
*
* @param string $ip Adresse IP du client
*
* @return int 0 si libre, nombre de minutes restantes si verrouillé
*/
public function checkRateLimit(string $ip): int;
/**
* Enregistre une tentative de connexion échouée pour une IP.
*
* @param string $ip Adresse IP du client
*/
public function recordFailure(string $ip): void;
/**
* Réinitialise le compteur de tentatives pour une IP.
*
* @param string $ip Adresse IP du client
* @return void
*/
public function resetRateLimit(string $ip): void;
/**
* Tente d'authentifier un utilisateur par ses identifiants.
*
* @param string $username Nom d'utilisateur (insensible à la casse)
* @param string $plainPassword Mot de passe en clair
*
* @return User|null L'utilisateur authentifié, ou null si les identifiants sont invalides
*/
public function authenticate(string $username, string $plainPassword): ?User;
/**
* Change le mot de passe d'un utilisateur après vérification de l'actuel.
*
* @param int $userId Identifiant de l'utilisateur
* @param string $currentPassword Mot de passe actuel en clair (pour vérification)
* @param string $newPassword Nouveau mot de passe en clair (min. 8 caractères)
*
* @throws NotFoundException Si l'utilisateur est introuvable
* @throws \InvalidArgumentException Si le mot de passe actuel est incorrect
* @throws WeakPasswordException Si le nouveau mot de passe est trop court
*/
public function changePassword(int $userId, string $currentPassword, string $newPassword): void;
/**
* Vérifie si une session utilisateur est active.
*
* @return bool True si une session utilisateur est active
*/
public function isLoggedIn(): bool;
/**
* Ouvre une session pour l'utilisateur donné.
*
* @param User $user L'utilisateur à connecter
*/
public function login(User $user): void;
/**
* Détruit la session utilisateur courante.
* @return void
*/
public function logout(): void;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Auth\Exception;
/**
* Exception levée lorsqu'un lien de réinitialisation est invalide,
* expiré, déjà consommé, ou lié à un utilisateur absent.
*/
final class InvalidResetTokenException extends \InvalidArgumentException
{
public function __construct()
{
parent::__construct('Ce lien de réinitialisation est invalide ou a expiré.');
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use PDO;
/**
* Dépôt de persistance des tentatives de connexion.
*
* Gère la lecture et l'écriture dans la table `login_attempts` pour
* la protection contre le brute-force sur le formulaire de connexion.
*
* La clé primaire est l'adresse IP — une seule ligne par IP, mise à jour
* à chaque tentative (UPSERT). Les entrées dont `locked_until` est dépassé
* sont réinitialisées automatiquement lors de la prochaine vérification.
*/
final class LoginAttemptRepository implements LoginAttemptRepositoryInterface
{
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db)
{
}
/**
* Retourne la ligne de tentatives pour une IP donnée, ou null si absente.
*
* @param string $ip Adresse IP du client
*
* @return array{ip: string, attempts: int, locked_until: string|null, updated_at: string}|null
*/
public function findByIp(string $ip): ?array
{
$stmt = $this->db->prepare('SELECT * FROM login_attempts WHERE ip = :ip');
$stmt->execute([':ip' => $ip]);
/** @var array{ip: string, attempts: int, locked_until: string|null, updated_at: string}|false $row */
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
/**
* Enregistre un échec de connexion pour une IP via un UPSERT atomique.
*
* Une seule opération SQL insère la ligne si elle n'existe pas, ou incrémente
* le compteur si elle existe déjà. L'atomicité élimine la race condition
* présente dans un pattern SELECT + INSERT/UPDATE séparé.
*
* Le paramètre locked_until est calculé côté PHP avant la requête afin de
* garder la logique lisible et testable ; il est passé deux fois (une pour
* le cas INSERT, une pour le cas UPDATE) car PDO interdit les paramètres nommés
* dupliqués dans une même requête préparée.
*
* Requiert SQLite >= 3.24 (juin 2018) pour la syntaxe ON CONFLICT DO UPDATE.
*
* @param string $ip Adresse IP du client
* @param int $maxAttempts Nombre d'échecs avant verrouillage
* @param int $lockMinutes Durée du verrouillage en minutes
*/
public function recordFailure(string $ip, int $maxAttempts, int $lockMinutes): void
{
$now = (new \DateTime())->format('Y-m-d H:i:s');
$lockUntil = (new \DateTime())->modify("+{$lockMinutes} minutes")->format('Y-m-d H:i:s');
$stmt = $this->db->prepare(
'INSERT INTO login_attempts (ip, attempts, locked_until, updated_at)
VALUES (:ip, 1, CASE WHEN 1 >= :max1 THEN :lock1 ELSE NULL END, :now1)
ON CONFLICT(ip) DO UPDATE SET
attempts = login_attempts.attempts + 1,
locked_until = CASE WHEN login_attempts.attempts + 1 >= :max2
THEN :lock2
ELSE NULL END,
updated_at = :now2'
);
$stmt->execute([
':ip' => $ip,
':max1' => $maxAttempts,
':lock1' => $lockUntil,
':now1' => $now,
':max2' => $maxAttempts,
':lock2' => $lockUntil,
':now2' => $now,
]);
}
/**
* Réinitialise le compteur de tentatives pour une IP (connexion réussie).
*
* @param string $ip Adresse IP du client
* @return void
*/
public function resetForIp(string $ip): void
{
$stmt = $this->db->prepare('DELETE FROM login_attempts WHERE ip = :ip');
$stmt->execute([':ip' => $ip]);
}
/**
* Supprime les entrées dont le verrouillage est expiré.
*
* Appelé à chaque tentative de connexion pour éviter l'accumulation
* de lignes obsolètes sans tâche planifiée externe.
*/
public function deleteExpired(): void
{
$now = (new \DateTime())->format('Y-m-d H:i:s');
$stmt = $this->db->prepare(
'DELETE FROM login_attempts WHERE locked_until IS NOT NULL AND locked_until < :now'
);
$stmt->execute([':now' => $now]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Auth;
/**
* Contrat de persistance des tentatives de connexion.
*
* Découple AuthService de l'implémentation concrète PDO/SQLite,
* facilitant les mocks dans les tests unitaires.
*/
interface LoginAttemptRepositoryInterface
{
/**
* Retourne la ligne de tentatives pour une IP donnée, ou null si absente.
*
* @param string $ip Adresse IP du client
*
* @return array{ip: string, attempts: int, locked_until: string|null, updated_at: string}|null
*/
public function findByIp(string $ip): ?array;
/**
* Enregistre un échec de connexion pour une IP (INSERT ou UPDATE).
*
* @param string $ip Adresse IP du client
* @param int $maxAttempts Nombre d'échecs avant verrouillage
* @param int $lockMinutes Durée du verrouillage en minutes
*/
public function recordFailure(string $ip, int $maxAttempts, int $lockMinutes): void;
/**
* Réinitialise le compteur de tentatives pour une IP (connexion réussie).
*
* @param string $ip Adresse IP du client
* @return void
*/
public function resetForIp(string $ip): void;
/**
* Supprime les entrées dont le verrouillage est expiré.
*/
public function deleteExpired(): void;
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Auth\Middleware;
use App\Shared\Http\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Response as SlimResponse;
/**
* Middleware de protection des routes réservées aux administrateurs.
*
* Intercepte les requêtes et redirige vers /admin/posts si l'utilisateur
* connecté n'a pas le rôle 'admin'.
*
* Ce middleware doit être utilisé en complément de AuthMiddleware,
* qui vérifie en amont que l'utilisateur est connecté.
* Ordre dans la chaîne Slim : ->add($adminMiddleware)->add($authMiddleware)
*/
final class AdminMiddleware implements MiddlewareInterface
{
/**
* @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle admin)
*/
public function __construct(private readonly SessionManagerInterface $sessionManager)
{
}
/**
* Vérifie le rôle admin avant de transmettre la requête au gestionnaire suivant.
*
* @param Request $request La requête HTTP entrante
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
*
* @return Response Une redirection 302 vers /admin/posts, ou la réponse normale
*/
public function process(Request $request, RequestHandler $handler): Response
{
if (!$this->sessionManager->isAdmin()) {
return (new SlimResponse())
->withHeader('Location', '/admin/posts')
->withStatus(302);
}
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Auth\Middleware;
use App\Shared\Http\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Response as SlimResponse;
/**
* Middleware de protection des routes réservées aux utilisateurs connectés.
*
* Intercepte les requêtes entrantes et redirige vers /auth/login
* si aucune session utilisateur n'est active.
* Doit être appliqué avant AdminMiddleware dans la chaîne.
*/
final class AuthMiddleware implements MiddlewareInterface
{
/**
* @param SessionManagerInterface $sessionManager Gestionnaire de session (vérification de l'authentification)
*/
public function __construct(private readonly SessionManagerInterface $sessionManager)
{
}
/**
* Vérifie l'authentification avant de transmettre la requête au gestionnaire suivant.
*
* @param Request $request La requête HTTP entrante
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
*
* @return Response Une redirection 302 vers /auth/login, ou la réponse normale
*/
public function process(Request $request, RequestHandler $handler): Response
{
if (!$this->sessionManager->isAuthenticated()) {
return (new SlimResponse())
->withHeader('Location', '/auth/login')
->withStatus(302);
}
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Auth\Middleware;
use App\Shared\Http\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Response as SlimResponse;
/**
* Middleware de protection des routes réservées aux éditeurs et administrateurs.
*
* Intercepte les requêtes et redirige vers /admin/posts si l'utilisateur
* connecté n'a ni le rôle 'editor' ni le rôle 'admin'.
*
* Ce middleware doit être utilisé en complément de AuthMiddleware,
* qui vérifie en amont que l'utilisateur est connecté.
* Ordre dans la chaîne Slim : ->add($editorMiddleware)->add($authMiddleware)
*/
final class EditorMiddleware implements MiddlewareInterface
{
/**
* @param SessionManagerInterface $sessionManager Gestionnaire de session (lecture du rôle)
*/
public function __construct(private readonly SessionManagerInterface $sessionManager)
{
}
/**
* Vérifie le rôle (editor ou admin) avant de transmettre la requête au gestionnaire suivant.
*
* @param Request $request La requête HTTP entrante
* @param RequestHandler $handler Le gestionnaire suivant dans la chaîne de middlewares
*
* @return Response Une redirection 302 vers /admin/posts, ou la réponse normale
*/
public function process(Request $request, RequestHandler $handler): Response
{
if (!$this->sessionManager->isAdmin() && !$this->sessionManager->isEditor()) {
return (new SlimResponse())
->withHeader('Location', '/admin/posts')
->withStatus(302);
}
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Shared\Http\FlashServiceInterface;
use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
/**
* Contrôleur de réinitialisation de mot de passe.
*
* Gère le flux en deux étapes :
* 1. Demande de réinitialisation : saisie de l'email (GET/POST /password/forgot)
* 2. Réinitialisation effective : saisie du nouveau mot de passe (GET/POST /password/reset)
*
* Sécurité :
* - Le formulaire de demande affiche toujours un message de succès générique,
* même si l'email est inconnu, pour éviter l'énumération des comptes.
* - Le token est transmis uniquement via l'URL (GET) et un champ hidden (POST),
* jamais via la session.
* - Le endpoint POST /password/forgot est soumis au même rate limiting par IP
* que le login : 5 tentatives autorisées, verrouillage 15 min au-delà.
* Toute tentative est comptabilisée — il n'existe pas de "succès" identifiable
* sans révéler si l'adresse est enregistrée (anti-énumération).
*/
final class PasswordResetController
{
/**
* @param Twig $view Moteur de templates Twig
* @param PasswordResetServiceInterface $passwordResetService Service de réinitialisation
* @param AuthServiceInterface $authService Service d'authentification (rate limiting)
* @param FlashServiceInterface $flash Service de messages flash
* @param string $baseUrl URL de base de l'application (depuis APP_URL dans .env)
*/
public function __construct(
private readonly Twig $view,
private readonly PasswordResetServiceInterface $passwordResetService,
private readonly AuthServiceInterface $authService,
private readonly FlashServiceInterface $flash,
private readonly string $baseUrl,
) {
}
/**
* Affiche le formulaire de demande de réinitialisation.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue pages/auth/password-forgot.twig
*/
public function showForgot(Request $req, Response $res): Response
{
return $this->view->render($res, 'pages/auth/password-forgot.twig', [
'error' => $this->flash->get('reset_error'),
'success' => $this->flash->get('reset_success'),
]);
}
/**
* Traite la demande de réinitialisation.
*
* Vérifie d'abord le rate limit par IP. Toute tentative est enregistrée
* comme un échec — qu'un email existe ou non — afin de ne pas déséquilibrer
* le compteur en fonction du résultat, ce qui permettrait de déduire
* l'existence d'un compte (canal caché sur le rate-limit).
*
* Génère un token et envoie l'email si l'adresse existe.
* Affiche toujours un message de succès générique — ne révèle pas
* si l'adresse est enregistrée (protection contre l'énumération).
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Redirection vers /password/forgot avec message flash
*/
public function forgot(Request $req, Response $res): Response
{
// Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx).
// Même logique que AuthController::login() — voir son commentaire pour le détail.
$serverParams = $req->getServerParams();
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
$ip = $forwarded !== '' && $forwarded !== '0.0.0.0'
? trim(explode(',', $forwarded)[0])
: ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0');
// Vérification du rate limit avant tout traitement
$remainingMinutes = $this->authService->checkRateLimit($ip);
if ($remainingMinutes > 0) {
$this->flash->set(
'reset_error',
"Trop de demandes. Veuillez réessayer dans {$remainingMinutes} minute"
. ($remainingMinutes > 1 ? 's' : '')
);
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$email = trim((string) ($data['email'] ?? ''));
// La tentative est enregistrée systématiquement, résultat connu ou non.
// Réinitialiser le compteur uniquement en cas de succès révélerait si
// l'adresse existe (canal caché : compteur remis à zéro = email valide).
$this->authService->recordFailure($ip);
try {
$this->passwordResetService->requestReset($email, $this->baseUrl);
} catch (\RuntimeException) {
// Erreur d'envoi d'email — on n'expose pas le détail à l'utilisateur
$this->flash->set('reset_error', 'Une erreur est survenue. Veuillez réessayer.');
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
// Message générique — ne révèle pas si l'email est connu
$this->flash->set(
'reset_success',
'Si cette adresse est associée à un compte, un email de réinitialisation a été envoyé'
);
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
/**
* Affiche le formulaire de saisie du nouveau mot de passe.
*
* Valide le token en amont — redirige vers /password/forgot avec un message
* d'erreur si le token est absent, invalide ou expiré.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue pages/auth/password-reset.twig ou une redirection
*/
public function showReset(Request $req, Response $res): Response
{
$token = trim((string) ($req->getQueryParams()['token'] ?? ''));
if ($token === '') {
$this->flash->set('reset_error', 'Lien de réinitialisation manquant');
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
$user = $this->passwordResetService->validateToken($token);
if ($user === null) {
$this->flash->set('reset_error', 'Ce lien est invalide ou a expiré. Veuillez faire une nouvelle demande.');
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
return $this->view->render($res, 'pages/auth/password-reset.twig', [
'token' => $token,
'error' => $this->flash->get('reset_error'),
]);
}
/**
* Traite la soumission du nouveau mot de passe.
*
* Vérifie que les deux mots de passe correspondent, puis délègue
* la validation du token et la mise à jour à PasswordResetServiceInterface.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Redirection vers /auth/login en cas de succès,
* ou vers /password/reset?token=… en cas d'erreur
*/
public function reset(Request $req, Response $res): Response
{
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$token = trim((string) ($data['token'] ?? ''));
$new = trim((string) ($data['new_password'] ?? ''));
$confirm = trim((string) ($data['new_password_confirm'] ?? ''));
if ($token === '') {
$this->flash->set('reset_error', 'Lien de réinitialisation manquant');
return $res->withHeader('Location', '/password/forgot')->withStatus(302);
}
if ($new !== $confirm) {
$this->flash->set('reset_error', 'Les mots de passe ne correspondent pas');
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
}
try {
$this->passwordResetService->resetPassword($token, $new);
} catch (WeakPasswordException) {
$this->flash->set('reset_error', 'Le mot de passe doit contenir au moins 8 caractères');
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
} catch (InvalidResetTokenException) {
$this->flash->set('reset_error', 'Ce lien de réinitialisation est invalide ou a expiré');
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
} catch (\Throwable) {
$this->flash->set('reset_error', 'Une erreur inattendue s\'est produite');
return $res->withHeader('Location', '/password/reset?token=' . urlencode($token))->withStatus(302);
}
$this->flash->set('login_success', 'Mot de passe réinitialisé avec succès. Vous pouvez vous connecter');
return $res->withHeader('Location', '/auth/login')->withStatus(302);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use PDO;
final class PasswordResetRepository implements PasswordResetRepositoryInterface
{
public function __construct(private readonly PDO $db)
{
}
public function create(int $userId, string $tokenHash, string $expiresAt): void
{
$stmt = $this->db->prepare('
INSERT INTO password_resets (user_id, token_hash, expires_at, created_at)
VALUES (:user_id, :token_hash, :expires_at, :created_at)
');
$stmt->execute([
':user_id' => $userId,
':token_hash' => $tokenHash,
':expires_at' => $expiresAt,
':created_at' => date('Y-m-d H:i:s'),
]);
}
public function findActiveByHash(string $tokenHash): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM password_resets WHERE token_hash = :token_hash AND used_at IS NULL'
);
$stmt->execute([':token_hash' => $tokenHash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function invalidateByUserId(int $userId): void
{
$stmt = $this->db->prepare(
'UPDATE password_resets SET used_at = :used_at WHERE user_id = :user_id AND used_at IS NULL'
);
$stmt->execute([':used_at' => date('Y-m-d H:i:s'), ':user_id' => $userId]);
}
/**
* Atomically consume a token and return the affected row.
* Uses UPDATE ... RETURNING to avoid SELECT+UPDATE race conditions.
*/
public function consumeActiveToken(string $tokenHash, string $usedAt): ?array
{
$stmt = $this->db->prepare(
'UPDATE password_resets
SET used_at = :used_at
WHERE token_hash = :token_hash
AND used_at IS NULL
AND expires_at >= :now
RETURNING *'
);
$stmt->execute([
':used_at' => $usedAt,
':token_hash' => $tokenHash,
':now' => $usedAt,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Auth;
interface PasswordResetRepositoryInterface
{
public function create(int $userId, string $tokenHash, string $expiresAt): void;
/**
* Consomme atomiquement un token non utilisé et non expiré.
*
* L'implémentation doit effectuer l'opération en une seule étape SQL
* afin d'éviter les courses entre lecture et écriture.
*
* @param string $tokenHash Hash SHA-256 du token de reset
* @param string $usedAt Horodatage de consommation au format SQL
* @return array<string, mixed>|null Les données du token consommé, ou null si le token est invalide, expiré ou déjà utilisé
*/
public function consumeActiveToken(string $tokenHash, string $usedAt): ?array;
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\Shared\Mail\MailServiceInterface;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use PDO;
final class PasswordResetService implements PasswordResetServiceInterface
{
private const TOKEN_TTL_MINUTES = 60;
public function __construct(
private readonly PasswordResetRepositoryInterface $passwordResetRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly MailServiceInterface $mailService,
private readonly PDO $db,
) {
}
public function requestReset(string $email, string $baseUrl): void
{
$user = $this->userRepository->findByEmail(mb_strtolower(trim($email)));
if ($user === null) {
return;
}
$this->passwordResetRepository->invalidateByUserId($user->getId());
$tokenRaw = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $tokenRaw);
$expiresAt = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_MINUTES * 60);
$this->passwordResetRepository->create($user->getId(), $tokenHash, $expiresAt);
$resetUrl = rtrim($baseUrl, '/') . '/password/reset?token=' . $tokenRaw;
$this->mailService->send(
to: $user->getEmail(),
subject: 'Réinitialisation de votre mot de passe',
template: 'emails/password-reset.twig',
context: [
'username' => $user->getUsername(),
'resetUrl' => $resetUrl,
'ttlMinutes' => self::TOKEN_TTL_MINUTES,
]
);
}
public function validateToken(string $tokenRaw): ?User
{
$tokenHash = hash('sha256', $tokenRaw);
$row = $this->passwordResetRepository->findActiveByHash($tokenHash);
if ($row === null) {
return null;
}
if (strtotime((string) $row['expires_at']) < time()) {
return null;
}
return $this->userRepository->findById((int) $row['user_id']);
}
public function resetPassword(string $tokenRaw, string $newPassword): void
{
if (mb_strlen(trim($newPassword)) < 8) {
throw new WeakPasswordException();
}
$usedAt = date('Y-m-d H:i:s');
$newHash = password_hash(trim($newPassword), PASSWORD_BCRYPT, ['cost' => 12]);
$this->db->beginTransaction();
try {
$row = $this->passwordResetRepository->consumeActiveToken(hash('sha256', $tokenRaw), $usedAt);
if ($row === null) {
throw new InvalidResetTokenException();
}
$user = $this->userRepository->findById((int) $row['user_id']);
if ($user === null) {
throw new InvalidResetTokenException();
}
$this->userRepository->updatePassword($user->getId(), $newHash);
$this->db->commit();
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Auth\Exception\InvalidResetTokenException;
use App\User\User;
/**
* Contrat du service de réinitialisation de mot de passe.
*
* Définit les trois opérations du flux de réinitialisation :
* 1. Demande (génération du token + envoi d'e-mail)
* 2. Validation du token reçu par e-mail
* 3. Réinitialisation effective du mot de passe
*/
interface PasswordResetServiceInterface
{
/**
* Génère un token de réinitialisation et envoie le lien par e-mail.
*
* Retour silencieux si l'e-mail est inconnu — ne révèle pas l'existence du compte.
*
* @param string $email Adresse e-mail de l'utilisateur
* @param string $baseUrl URL de base de l'application (pour construire le lien)
*
* @throws \RuntimeException Si l'envoi de l'e-mail échoue
*/
public function requestReset(string $email, string $baseUrl): void;
/**
* Valide un token brut reçu dans l'URL.
*
* Calcule le hash SHA-256 du token, vérifie son existence en base
* et s'assure qu'il n'est pas expiré ni déjà consommé.
*
* @param string $tokenRaw Token brut reçu en clair dans l'URL
*
* @return User|null L'utilisateur associé au token, ou null si invalide/expiré
*/
public function validateToken(string $tokenRaw): ?User;
/**
* Réinitialise le mot de passe d'un utilisateur.
*
* Valide le token, hache le nouveau mot de passe, met à jour la base
* et marque le token comme consommé.
*
* @param string $tokenRaw Token brut reçu dans l'URL
* @param string $newPassword Nouveau mot de passe en clair
*
* @throws InvalidResetTokenException Si le token est invalide ou expiré
* @throws \App\User\Exception\WeakPasswordException Si le mot de passe est trop court
*/
public function resetPassword(string $tokenRaw, string $newPassword): void;
}

91
src/Category/Category.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Category;
/**
* Modèle représentant une catégorie d'articles.
*
* Ce modèle est immuable après construction.
* La génération de slug est déléguée à SlugHelper::generate() dans CategoryService,
* avant la construction de l'objet.
*/
final class Category
{
/**
* @param int $id Identifiant en base (0 pour une nouvelle catégorie)
* @param string $name Nom de la catégorie (1100 caractères)
* @param string $slug Slug URL de la catégorie
*
* @throws \InvalidArgumentException Si les données ne passent pas la validation
*/
public function __construct(
private readonly int $id,
private readonly string $name,
private readonly string $slug,
) {
$this->validate();
}
/**
* Crée une instance depuis un tableau associatif (ligne de base de données).
*
* @param array<string, mixed> $data Données issues de la base de données
*
* @return self L'instance hydratée
*/
public static function fromArray(array $data): self
{
return new self(
id: (int) ($data['id'] ?? 0),
name: (string) ($data['name'] ?? ''),
slug: (string) ($data['slug'] ?? ''),
);
}
/**
* Retourne l'identifiant de la catégorie.
*
* @return int L'identifiant en base (0 si non encore persisté)
*/
public function getId(): int
{
return $this->id;
}
/**
* Retourne le nom de la catégorie.
*
* @return string Le nom
*/
public function getName(): string
{
return $this->name;
}
/**
* Retourne le slug URL de la catégorie.
*
* @return string Le slug
*/
public function getSlug(): string
{
return $this->slug;
}
/**
* Valide les données de la catégorie.
*
* @throws \InvalidArgumentException Si le nom est vide ou dépasse 100 caractères
*/
private function validate(): void
{
if ($this->name === '') {
throw new \InvalidArgumentException('Le nom de la catégorie ne peut pas être vide');
}
if (mb_strlen($this->name) > 100) {
throw new \InvalidArgumentException('Le nom de la catégorie ne peut pas dépasser 100 caractères');
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Category;
use App\Shared\Http\FlashServiceInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
/**
* Contrôleur pour la gestion des catégories.
*
* Accessible aux éditeurs et administrateurs (protégé par EditorMiddleware).
* Gère la liste des catégories, leur création et leur suppression.
* Toute la logique métier (génération de slug, validations, blocage de
* suppression) est déléguée à CategoryService.
*/
final class CategoryController
{
/**
* @param Twig $view Moteur de templates Twig
* @param CategoryServiceInterface $categoryService Service de gestion des catégories
* @param FlashServiceInterface $flash Service de messages flash
*/
public function __construct(
private readonly Twig $view,
private readonly CategoryServiceInterface $categoryService,
private readonly FlashServiceInterface $flash,
) {
}
/**
* Affiche la liste des catégories avec le formulaire de création.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue de gestion des catégories
*/
public function index(Request $req, Response $res): Response
{
return $this->view->render($res, 'admin/categories/index.twig', [
'categories' => $this->categoryService->findAll(),
'error' => $this->flash->get('category_error'),
'success' => $this->flash->get('category_success'),
]);
}
/**
* Traite la création d'une catégorie.
*
* Délègue entièrement à CategoryService::create() qui gère la génération
* du slug, la validation d'unicité et la validation du modèle.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Une redirection vers /admin/categories
*/
public function create(Request $req, Response $res): Response
{
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$name = (string) ($data['name'] ?? '');
try {
$this->categoryService->create($name);
$trimmed = trim($name);
$this->flash->set('category_success', "La catégorie « {$trimmed} » a été créée avec succès");
} catch (\InvalidArgumentException $e) {
$this->flash->set('category_error', $e->getMessage());
} catch (\Throwable) {
$this->flash->set('category_error', "Une erreur inattendue s'est produite");
}
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
}
/**
* Supprime une catégorie.
*
* Délègue à CategoryService::delete() qui refuse la suppression si des
* articles sont rattachés à la catégorie.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Paramètres de route (id)
*
* @return Response Une redirection vers /admin/categories
*/
public function delete(Request $req, Response $res, array $args): Response
{
$id = (int) ($args['id'] ?? 0);
$category = $this->categoryService->findById($id);
if ($category === null) {
$this->flash->set('category_error', 'Catégorie introuvable');
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
}
try {
$this->categoryService->delete($category);
$this->flash->set('category_success', "La catégorie « {$category->getName()} » a été supprimée");
} catch (\InvalidArgumentException $e) {
$this->flash->set('category_error', $e->getMessage());
} catch (\Throwable) {
$this->flash->set('category_error', "Une erreur inattendue s'est produite");
}
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Category;
use PDO;
/**
* Dépôt pour la persistance des catégories.
*
* Responsabilité unique : exécuter les requêtes SQL liées à la table `categories`
* et retourner des instances de Category hydratées.
*/
final class CategoryRepository implements CategoryRepositoryInterface
{
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db)
{
}
/**
* Retourne toutes les catégories triées alphabétiquement.
*
* @return Category[] La liste des catégories
*/
public function findAll(): array
{
$stmt = $this->db->query('SELECT * FROM categories ORDER BY name ASC');
if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur categories a échoué.');
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Category::fromArray($row), $rows);
}
/**
* Trouve une catégorie par son identifiant.
*
* @param int $id Identifiant de la catégorie
*
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
*/
public function findById(int $id): ?Category
{
$stmt = $this->db->prepare('SELECT * FROM categories WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Category::fromArray($row) : null;
}
/**
* Trouve une catégorie par son slug URL.
*
* @param string $slug Le slug de la catégorie
*
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
*/
public function findBySlug(string $slug): ?Category
{
$stmt = $this->db->prepare('SELECT * FROM categories WHERE slug = :slug');
$stmt->execute([':slug' => $slug]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Category::fromArray($row) : null;
}
/**
* Persiste une nouvelle catégorie en base de données.
*
* @param Category $category La catégorie à créer
*
* @return int L'identifiant généré par la base de données
*/
public function create(Category $category): int
{
$stmt = $this->db->prepare('INSERT INTO categories (name, slug) VALUES (:name, :slug)');
$stmt->execute([':name' => $category->getName(), ':slug' => $category->getSlug()]);
return (int) $this->db->lastInsertId();
}
/**
* Supprime une catégorie de la base de données.
*
* @param int $id Identifiant de la catégorie à supprimer
*
* @return int Nombre de lignes supprimées (0 si la catégorie n'existe plus)
*/
public function delete(int $id): int
{
$stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->rowCount();
}
/**
* Vérifie si un nom est déjà utilisé par une catégorie existante.
*
* @param string $name Le nom à vérifier
*
* @return bool True si le nom est déjà pris
*/
public function nameExists(string $name): bool
{
$stmt = $this->db->prepare('SELECT 1 FROM categories WHERE name = :name');
$stmt->execute([':name' => $name]);
return $stmt->fetchColumn() !== false;
}
/**
* Vérifie si au moins un article est rattaché à cette catégorie.
*
* Utilisé avant suppression pour bloquer la suppression d'une catégorie non vide.
*
* @param int $id Identifiant de la catégorie
*
* @return bool True si au moins un article référence cette catégorie
*/
public function hasPost(int $id): bool
{
$stmt = $this->db->prepare('SELECT 1 FROM posts WHERE category_id = :id');
$stmt->execute([':id' => $id]);
return $stmt->fetchColumn() !== false;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Category;
/**
* Contrat de persistance des catégories.
*
* Découple les services et contrôleurs de l'implémentation concrète PDO/SQLite,
* facilitant les mocks dans les tests unitaires.
*/
interface CategoryRepositoryInterface
{
/**
* Retourne toutes les catégories triées alphabétiquement.
*
* @return Category[]
*/
public function findAll(): array;
/**
* Trouve une catégorie par son identifiant.
*
* @param int $id Identifiant de la catégorie
*
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
*/
public function findById(int $id): ?Category;
/**
* Trouve une catégorie par son slug URL.
*
* @param string $slug Le slug de la catégorie
*
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
*/
public function findBySlug(string $slug): ?Category;
/**
* Persiste une nouvelle catégorie en base de données.
*
* @param Category $category La catégorie à créer
*
* @return int L'identifiant généré par la base de données
*/
public function create(Category $category): int;
/**
* Supprime une catégorie de la base de données.
*
* @param int $id Identifiant de la catégorie à supprimer
*
* @return int Nombre de lignes supprimées
*/
public function delete(int $id): int;
/**
* Vérifie si un nom est déjà utilisé par une catégorie existante.
*
* @param string $name Le nom à vérifier
*
* @return bool True si le nom est déjà pris
*/
public function nameExists(string $name): bool;
/**
* Vérifie si au moins un article est rattaché à cette catégorie.
*
* @param int $id Identifiant de la catégorie
*
* @return bool True si au moins un article référence cette catégorie
*/
public function hasPost(int $id): bool;
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Category;
use App\Shared\Util\SlugHelper;
/**
* Service de gestion des catégories.
*
* Centralise la logique métier liée aux catégories :
* - génération et validation du slug à la création
* - vérification d'unicité du nom
* - blocage de la suppression si des articles sont rattachés
*
* Les lectures (findAll, findById, findBySlug) sont exposées ici
* pour que CategoryController et PostController n'injectent pas
* directement le repository — cohérent avec le pattern des autres domaines.
*/
final class CategoryService implements CategoryServiceInterface
{
/**
* @param CategoryRepositoryInterface $categoryRepository Dépôt de persistance des catégories
*/
public function __construct(
private readonly CategoryRepositoryInterface $categoryRepository,
) {
}
/**
* Retourne toutes les catégories triées alphabétiquement.
*
* @return Category[]
*/
public function findAll(): array
{
return $this->categoryRepository->findAll();
}
/**
* Trouve une catégorie par son identifiant.
*
* @param int $id Identifiant de la catégorie
*
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
*/
public function findById(int $id): ?Category
{
return $this->categoryRepository->findById($id);
}
/**
* Trouve une catégorie par son slug URL.
*
* @param string $slug Le slug de la catégorie
*
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
*/
public function findBySlug(string $slug): ?Category
{
return $this->categoryRepository->findBySlug($slug);
}
/**
* Crée une catégorie depuis un nom brut.
*
* Séquence :
* 1. Trim du nom
* 2. Génération du slug via SlugHelper
* 3. Rejet si le slug est vide (nom sans caractère ASCII exploitable)
* 4. Rejet si le nom est déjà utilisé
* 5. Construction du modèle (déclenche la validation longueur/vide)
* 6. Persistance
*
* @param string $name Nom brut de la catégorie (non encore trimmé)
*
* @return int L'identifiant de la catégorie créée
*
* @throws \InvalidArgumentException Si le slug est vide, le nom déjà pris,
* ou si la validation du modèle échoue
*/
public function create(string $name): int
{
$name = trim($name);
$slug = SlugHelper::generate($name);
if ($slug === '') {
throw new \InvalidArgumentException('Le nom fourni ne peut pas générer un slug URL valide');
}
if ($this->categoryRepository->nameExists($name)) {
throw new \InvalidArgumentException('Ce nom de catégorie est déjà utilisé');
}
// Le constructeur de Category valide le nom (vide, longueur max)
return $this->categoryRepository->create(new Category(0, $name, $slug));
}
/**
* Supprime une catégorie.
*
* Refuse la suppression si au moins un article est rattaché à la catégorie,
* afin d'éviter des articles sans catégorie de façon involontaire.
*
* @param Category $category La catégorie à supprimer
*
* @throws \InvalidArgumentException Si la catégorie contient des articles
* @return void
*/
public function delete(Category $category): void
{
if ($this->categoryRepository->hasPost($category->getId())) {
throw new \InvalidArgumentException(
"La catégorie « {$category->getName()} » contient des articles et ne peut pas être supprimée"
);
}
$this->categoryRepository->delete($category->getId());
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Category;
/**
* Contrat du service de gestion des catégories.
*
* Permet de mocker le service dans les tests unitaires sans dépendre
* de la classe concrète finale CategoryService.
*/
interface CategoryServiceInterface
{
/**
* Retourne toutes les catégories triées alphabétiquement.
*
* @return Category[]
*/
public function findAll(): array;
/**
* Trouve une catégorie par son identifiant.
*
* @param int $id Identifiant de la catégorie
*
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
*/
public function findById(int $id): ?Category;
/**
* Trouve une catégorie par son slug URL.
*
* @param string $slug Le slug de la catégorie
*
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
*/
public function findBySlug(string $slug): ?Category;
/**
* Crée une catégorie depuis un nom brut.
*
* Génère le slug, valide l'unicité du nom et délègue la construction
* du modèle au constructeur de Category (qui valide taille et contenu).
*
* @param string $name Nom brut de la catégorie (non encore trimmé)
*
* @return int L'identifiant de la catégorie créée
*
* @throws \InvalidArgumentException Si le nom produit un slug vide ou est déjà utilisé
*/
public function create(string $name): int;
/**
* Supprime une catégorie.
*
* Refuse la suppression si des articles sont rattachés à la catégorie.
*
* @param Category $category La catégorie à supprimer
*
* @throws \InvalidArgumentException Si la catégorie contient des articles
* @return void
*/
public function delete(Category $category): void;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Media\Exception;
/**
* Exception levée lorsqu'un fichier uploadé dépasse la taille autorisée.
*/
final class FileTooLargeException extends \InvalidArgumentException
{
public function __construct(int $maxBytes)
{
$mb = round($maxBytes / 1024 / 1024);
parent::__construct("Fichier trop volumineux (maximum {$mb} Mo)");
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Media\Exception;
/**
* Exception levée lorsqu'un fichier uploadé a un type MIME non autorisé.
*/
final class InvalidMimeTypeException extends \InvalidArgumentException
{
public function __construct(string $mime)
{
parent::__construct("Type de fichier non autorisé : {$mime} (JPEG, PNG, GIF ou WebP uniquement)");
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Media\Exception;
/**
* Exception levée lorsqu'une opération sur le système de fichiers échoue
* (création de répertoire, copie ou déplacement d'un fichier converti).
*/
final class StorageException extends \RuntimeException
{
}

130
src/Media/Media.php Normal file
View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Media;
use App\Shared\Util\DateParser;
use DateTime;
/**
* Modèle représentant un fichier média uploadé.
*
* Encapsule les métadonnées d'un fichier stocké dans public/media/.
* Le fichier physique est identifié par son nom de stockage opaque (filename),
* distinct du nom affiché à l'utilisateur.
*
* Le hash SHA-256 du contenu permet la détection des doublons à l'upload :
* si un fichier identique a déjà été uploadé, son URL est retournée
* directement sans créer un second fichier sur disque.
*/
final class Media
{
/**
* @var DateTime Date d'upload — toujours non nulle après construction
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
*/
private readonly DateTime $createdAt;
/**
* @param int $id Identifiant en base (0 pour un nouveau média)
* @param string $filename Nom de stockage opaque sur disque (ex: "a3f8c1d2_9f33.jpg")
* @param string $url URL publique d'accès au fichier (ex: "/media/a3f8c1d2_9f33.jpg")
* @param string $hash Hash SHA-256 du contenu binaire du fichier
* @param int|null $userId Identifiant de l'auteur (null si le compte a été supprimé)
* @param DateTime|null $createdAt Date d'upload (défaut : maintenant)
*/
public function __construct(
private readonly int $id,
private readonly string $filename,
private readonly string $url,
private readonly string $hash,
private readonly ?int $userId,
?DateTime $createdAt = null,
) {
$this->createdAt = $createdAt ?? new DateTime();
}
/**
* Crée une instance depuis un tableau associatif (ligne de base de données).
*
* @param array<string, mixed> $data Données issues de la base de données
*
* @return self L'instance hydratée
*/
public static function fromArray(array $data): self
{
return new self(
id: (int) ($data['id'] ?? 0),
filename: (string) ($data['filename'] ?? ''),
url: (string) ($data['url'] ?? ''),
hash: (string) ($data['hash'] ?? ''),
userId: isset($data['user_id']) ? (int) $data['user_id'] : null,
createdAt: DateParser::parse($data['created_at'] ?? null),
);
}
/**
* Retourne l'identifiant du média.
*
* @return int L'identifiant en base (0 si non encore persisté)
*/
public function getId(): int
{
return $this->id;
}
/**
* Retourne le nom de stockage du fichier sur disque.
*
* Ce nom est opaque et généré aléatoirement à l'upload.
* Il ne doit pas être affiché à l'utilisateur tel quel.
*
* @return string Le nom de fichier sur disque
*/
public function getFilename(): string
{
return $this->filename;
}
/**
* Retourne l'URL publique d'accès au fichier.
*
* @return string L'URL publique (ex: "/media/a3f8c1d2_9f33.jpg")
*/
public function getUrl(): string
{
return $this->url;
}
/**
* Retourne le hash SHA-256 du contenu binaire du fichier.
*
* Utilisé pour la détection des doublons à l'upload.
*
* @return string Le hash hexadécimal SHA-256
*/
public function getHash(): string
{
return $this->hash;
}
/**
* Retourne l'identifiant de l'auteur du média.
*
* @return int|null L'identifiant de l'auteur, ou null si le compte a été supprimé
*/
public function getUserId(): ?int
{
return $this->userId;
}
/**
* Retourne la date d'upload du fichier.
*
* @return DateTime La date d'upload
*/
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Media;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Exception\StorageException;
use App\Media\MediaServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
/**
* Contrôleur du domaine Media.
*
* Gère deux responsabilités HTTP :
* 1. Upload d'images depuis l'éditeur Trumbowyg (réponse JSON)
* 2. Administration des médias uploadés (liste, suppression)
*
* Toute la logique métier (validation, conversion WebP, déduplication,
* stockage disque) est déléguée à MediaService via MediaServiceInterface.
*
* Droits d'accès :
* - Upload : tout utilisateur connecté
* - Liste : chaque utilisateur voit uniquement ses propres médias ;
* l'administrateur et l'éditeur voient tous les médias
* - Suppression : propriétaire du média, éditeur ou administrateur
*/
final class MediaController
{
/**
* @param Twig $view Moteur de templates Twig
* @param MediaServiceInterface $mediaService Service de gestion des médias
* @param FlashServiceInterface $flash Service de messages flash
* @param SessionManagerInterface $sessionManager Gestionnaire de session
*/
public function __construct(
private readonly Twig $view,
private readonly MediaServiceInterface $mediaService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
) {
}
/**
* Affiche la page de gestion des médias.
*
* Un éditeur ou un administrateur voit tous les médias.
* Un utilisateur avec le rôle 'user' voit uniquement ses propres médias.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La page HTML de gestion des médias
*/
public function index(Request $req, Response $res): Response
{
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
$userId = $this->sessionManager->getUserId();
$media = $isAdmin
? $this->mediaService->findAll()
: $this->mediaService->findByUserId((int) $userId);
return $this->view->render($res, 'admin/media/index.twig', [
'media' => $media,
'error' => $this->flash->get('media_error'),
'success' => $this->flash->get('media_success'),
]);
}
/**
* Traite l'upload d'une image envoyée par le plugin Trumbowyg Upload.
*
* Vérifie la présence et l'absence d'erreur PSR-7 avant de déléguer
* à MediaService. Les erreurs métier (taille, MIME, stockage) sont
* converties en réponses JSON avec le code HTTP approprié.
*
* @param Request $req La requête HTTP multipart contenant le champ "image"
* @param Response $res La réponse HTTP
*
* @return Response JSON {"success": true, "file": "/media/..."} ou {"error": "..."}
*/
public function upload(Request $req, Response $res): Response
{
$files = $req->getUploadedFiles();
$uploadedFile = $files['image'] ?? null;
if ($uploadedFile === null || $uploadedFile->getError() !== UPLOAD_ERR_OK) {
return $this->jsonError($res, "Aucun fichier reçu ou erreur d'upload", 400);
}
try {
$url = $this->mediaService->store($uploadedFile, $this->sessionManager->getUserId() ?? 0);
} catch (FileTooLargeException $e) {
return $this->jsonError($res, $e->getMessage(), 413);
} catch (InvalidMimeTypeException $e) {
return $this->jsonError($res, $e->getMessage(), 415);
} catch (StorageException $e) {
return $this->jsonError($res, $e->getMessage(), 500);
}
return $this->jsonSuccess($res, $url);
}
/**
* Supprime un média (fichier sur disque + entrée en base).
*
* Vérifie que l'utilisateur connecté est le propriétaire du média
* ou un administrateur / éditeur. Redirige avec un message flash dans les deux cas.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Paramètres de route (id)
*
* @return Response Redirection vers /admin/media
*/
public function delete(Request $req, Response $res, array $args): Response
{
$id = (int) ($args['id'] ?? 0);
$media = $this->mediaService->findById($id);
if ($media === null) {
$this->flash->set('media_error', 'Fichier introuvable');
return $res->withHeader('Location', '/admin/media')->withStatus(302);
}
$userId = $this->sessionManager->getUserId();
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
if (!$isAdmin && $media->getUserId() !== $userId) {
$this->flash->set('media_error', "Vous n'êtes pas autorisé à supprimer ce fichier");
return $res->withHeader('Location', '/admin/media')->withStatus(302);
}
$this->mediaService->delete($media);
$this->flash->set('media_success', 'Fichier supprimé');
return $res->withHeader('Location', '/admin/media')->withStatus(302);
}
/**
* Retourne une réponse JSON de succès avec l'URL du fichier uploadé.
*
* @param Response $res La réponse HTTP
* @param string $fileUrl L'URL publique du fichier
*
* @return Response La réponse JSON {"success": true, "file": "..."}
*/
private function jsonSuccess(Response $res, string $fileUrl): Response
{
$res->getBody()->write(json_encode([
'success' => true,
'file' => $fileUrl,
], JSON_THROW_ON_ERROR));
return $res->withHeader('Content-Type', 'application/json')->withStatus(200);
}
/**
* Retourne une réponse JSON d'erreur.
*
* @param Response $res La réponse HTTP
* @param string $message Le message d'erreur
* @param int $status Le code HTTP de l'erreur
*
* @return Response La réponse JSON {"error": "..."}
*/
private function jsonError(Response $res, string $message, int $status): Response
{
$res->getBody()->write(json_encode(['error' => $message], JSON_THROW_ON_ERROR));
return $res->withHeader('Content-Type', 'application/json')->withStatus($status);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Media;
use PDO;
/**
* Dépôt pour la persistance des médias uploadés.
*
* Responsabilité unique : exécuter les requêtes SQL liées à la table `media`
* et retourner des instances de Media hydratées.
*/
final class MediaRepository implements MediaRepositoryInterface
{
/**
* Fragment SELECT commun à toutes les requêtes de lecture.
*/
private const SELECT = 'SELECT id, filename, url, hash, user_id, created_at FROM media';
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db)
{
}
/**
* Retourne tous les médias triés du plus récent au plus ancien.
*
* @return Media[] La liste complète des médias
*/
public function findAll(): array
{
$stmt = $this->db->query(self::SELECT . ' ORDER BY id DESC');
if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur media a échoué.');
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Media::fromArray($row), $rows);
}
/**
* Retourne tous les médias appartenant à un utilisateur donné,
* triés du plus récent au plus ancien.
*
* @param int $userId Identifiant de l'utilisateur
*
* @return Media[] La liste des médias de cet utilisateur
*/
public function findByUserId(int $userId): array
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC');
$stmt->execute([':user_id' => $userId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Media::fromArray($row), $rows);
}
/**
* Trouve un média par son identifiant.
*
* @param int $id Identifiant du média
*
* @return Media|null Le média trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?Media
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Media::fromArray($row) : null;
}
/**
* Trouve un média par le hash SHA-256 de son contenu.
*
* Utilisé pour la détection des doublons à l'upload.
*
* @param string $hash Hash SHA-256 du contenu binaire du fichier
*
* @return Media|null Le média existant, ou null si aucun doublon
*/
public function findByHash(string $hash): ?Media
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash');
$stmt->execute([':hash' => $hash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Media::fromArray($row) : null;
}
/**
* Persiste un nouveau média en base de données.
*
* @param Media $media Le média à créer
*
* @return int L'identifiant généré par la base de données
*/
public function create(Media $media): int
{
$stmt = $this->db->prepare('
INSERT INTO media (filename, url, hash, user_id, created_at)
VALUES (:filename, :url, :hash, :user_id, :created_at)
');
$stmt->execute([
':filename' => $media->getFilename(),
':url' => $media->getUrl(),
':hash' => $media->getHash(),
':user_id' => $media->getUserId(),
':created_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->db->lastInsertId();
}
/**
* Supprime un média de la base de données.
*
* La suppression du fichier physique sur disque est à la charge de l'appelant.
*
* @param int $id Identifiant du média à supprimer
*
* @return int Nombre de lignes supprimées (0 si le média n'existe plus)
*/
public function delete(int $id): int
{
$stmt = $this->db->prepare('DELETE FROM media WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->rowCount();
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Media;
/**
* Contrat de persistance des médias uploadés.
*
* Découple les contrôleurs de l'implémentation concrète PDO/SQLite,
* facilitant les mocks dans les tests unitaires.
*/
interface MediaRepositoryInterface
{
/**
* Retourne tous les médias triés du plus récent au plus ancien.
*
* @return Media[]
*/
public function findAll(): array;
/**
* Retourne tous les médias d'un utilisateur donné.
*
* @param int $userId Identifiant de l'utilisateur
*
* @return Media[]
*/
public function findByUserId(int $userId): array;
/**
* Trouve un média par son identifiant.
*
* @param int $id Identifiant du média
*
* @return Media|null Le média trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?Media;
/**
* Trouve un média par le hash SHA-256 de son contenu (déduplication).
*
* @param string $hash Hash SHA-256 du contenu binaire du fichier
*
* @return Media|null Le média existant, ou null si aucun doublon
*/
public function findByHash(string $hash): ?Media;
/**
* Persiste un nouveau média en base de données.
*
* @param Media $media Le média à créer
*
* @return int L'identifiant généré par la base de données
*/
public function create(Media $media): int;
/**
* Supprime un média de la base de données.
*
* @param int $id Identifiant du média à supprimer
*
* @return int Nombre de lignes supprimées
*/
public function delete(int $id): int;
}

228
src/Media/MediaService.php Normal file
View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Media;
use App\Media\Exception\FileTooLargeException;
use App\Media\Exception\InvalidMimeTypeException;
use App\Media\Exception\StorageException;
use PDOException;
use Psr\Http\Message\UploadedFileInterface;
final class MediaService implements MediaServiceInterface
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
private const WEBP_CONVERTIBLE = ['image/jpeg', 'image/png'];
private const MIME_EXTENSIONS = [
'image/jpeg' => 'webp',
'image/png' => 'webp',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
private const MIME_EXTENSIONS_FALLBACK = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
private const MAX_PIXELS = 40000000;
public function __construct(
private readonly MediaRepositoryInterface $mediaRepository,
private readonly string $uploadDir,
private readonly string $uploadUrl,
private readonly int $maxSize,
) {
}
public function findAll(): array
{
return $this->mediaRepository->findAll();
}
public function findByUserId(int $userId): array
{
return $this->mediaRepository->findByUserId($userId);
}
public function findById(int $id): ?Media
{
return $this->mediaRepository->findById($id);
}
public function store(UploadedFileInterface $uploadedFile, int $userId): string
{
$size = $uploadedFile->getSize();
if (!is_int($size)) {
throw new StorageException('Impossible de déterminer la taille du fichier uploadé');
}
if ($size > $this->maxSize) {
throw new FileTooLargeException($this->maxSize);
}
$tmpPathRaw = $uploadedFile->getStream()->getMetadata('uri');
if (!is_string($tmpPathRaw) || $tmpPathRaw === '') {
throw new StorageException('Impossible de localiser le fichier temporaire uploadé');
}
$tmpPath = $tmpPathRaw;
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($tmpPath);
if ($mime === false || !in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new InvalidMimeTypeException($mime === false ? 'unknown' : $mime);
}
$this->assertReasonableDimensions($tmpPath);
$converted = false;
if (in_array($mime, self::WEBP_CONVERTIBLE, true)) {
$convertedPath = $this->convertToWebP($tmpPath);
if ($convertedPath !== null) {
$tmpPath = $convertedPath;
$converted = true;
}
}
$rawHash = hash_file('sha256', $tmpPath);
if ($rawHash === false) {
if ($converted) {
@unlink($tmpPath);
}
throw new StorageException('Impossible de calculer le hash du fichier');
}
$hash = $rawHash;
$existing = $this->mediaRepository->findByHash($hash);
if ($existing !== null) {
if ($converted) {
@unlink($tmpPath);
}
return $existing->getUrl();
}
if (!is_dir($this->uploadDir) && !@mkdir($this->uploadDir, 0755, true)) {
throw new StorageException("Impossible de créer le répertoire d'upload");
}
$extension = $converted
? self::MIME_EXTENSIONS[$mime]
: self::MIME_EXTENSIONS_FALLBACK[$mime];
$filename = uniqid('', true) . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
$destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
if ($converted) {
if (!copy($tmpPath, $destPath)) {
@unlink($tmpPath);
throw new StorageException('Impossible de déplacer le fichier converti');
}
@unlink($tmpPath);
} else {
$uploadedFile->moveTo($destPath);
}
$url = $this->uploadUrl . '/' . $filename;
$media = new Media(0, $filename, $url, $hash, $userId);
try {
$this->mediaRepository->create($media);
} catch (PDOException $e) {
$duplicate = $this->mediaRepository->findByHash($hash);
if ($duplicate !== null) {
@unlink($destPath);
return $duplicate->getUrl();
}
@unlink($destPath);
throw $e;
}
return $url;
}
public function delete(Media $media): void
{
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $media->getFilename();
if (file_exists($filePath)) {
@unlink($filePath);
}
$this->mediaRepository->delete($media->getId());
}
private function assertReasonableDimensions(string $path): void
{
$size = @getimagesize($path);
if ($size === false) {
throw new StorageException('Impossible de lire les dimensions de l\'image');
}
[$width, $height] = $size;
if ($width <= 0 || $height <= 0) {
throw new StorageException('Dimensions d\'image invalides');
}
if (($width * $height) > self::MAX_PIXELS) {
throw new StorageException('Image trop volumineuse en dimensions pour être traitée');
}
}
private function convertToWebP(string $sourcePath): ?string
{
if (!function_exists('imagewebp')) {
return null;
}
$data = file_get_contents($sourcePath);
if ($data === false || $data === '') {
return null;
}
$image = imagecreatefromstring($data);
if ($image === false) {
return null;
}
imagealphablending($image, false);
imagesavealpha($image, true);
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_webp_');
if ($tmpFile === false) {
imagedestroy($image);
return null;
}
@unlink($tmpFile);
$tmpPath = $tmpFile . '.webp';
if (!imagewebp($image, $tmpPath, 85)) {
imagedestroy($image);
@unlink($tmpPath);
return null;
}
imagedestroy($image);
return $tmpPath;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Media;
use Psr\Http\Message\UploadedFileInterface;
/**
* Contrat du service de gestion des médias.
*
* Permet de mocker le service dans les tests unitaires sans dépendre
* de la classe concrète finale MediaService.
*/
interface MediaServiceInterface
{
/**
* Retourne tous les médias triés du plus récent au plus ancien.
*
* @return Media[]
*/
public function findAll(): array;
/**
* Retourne tous les médias appartenant à un utilisateur donné.
*
* @param int $userId Identifiant de l'utilisateur
*
* @return Media[]
*/
public function findByUserId(int $userId): array;
/**
* Trouve un média par son identifiant.
*
* @param int $id Identifiant du média
*
* @return Media|null Le média trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?Media;
/**
* Valide, convertit, déduplique et stocke un fichier uploadé.
*
* @param UploadedFileInterface $uploadedFile Le fichier PSR-7 reçu
* @param int $userId Identifiant de l'auteur
*
* @return string L'URL publique du fichier stocké
*
* @throws \App\Media\Exception\FileTooLargeException Si la taille dépasse le maximum autorisé
* @throws \App\Media\Exception\InvalidMimeTypeException Si le type MIME n'est pas autorisé
* @throws \App\Media\Exception\StorageException Si une opération disque échoue
*/
public function store(UploadedFileInterface $uploadedFile, int $userId): string;
/**
* Supprime un média : fichier physique sur disque et entrée en base.
*
* @param Media $media Le média à supprimer
* @return void
*/
public function delete(Media $media): void;
}

252
src/Post/Post.php Normal file
View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Post;
use App\Shared\Util\DateParser;
use App\Shared\Util\SlugHelper;
use DateTime;
/**
* Modèle représentant un article de blog.
*
* Encapsule les données et la validation d'un article.
* Ce modèle est immuable après construction.
* Le nom d'auteur est dénormalisé (chargé par JOIN dans PostRepository)
* pour éviter des requêtes supplémentaires à l'affichage.
* La logique de présentation (excerpt, formatage) est déléguée à PostExtension.
*
* Distinction slug :
* - getStoredSlug() : slug lu depuis la base de données (canonique, peut comporter
* un suffixe numérique pour lever les collisions, ex: "mon-article-2")
* - generateSlug() : slug calculé dynamiquement depuis le titre, utilisé uniquement
* par PostService lors de la création/modification pour produire le slug à stocker
*/
final class Post
{
/**
* @var DateTime Date de création — toujours non nulle après construction
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
*/
private readonly DateTime $createdAt;
/**
* @var DateTime Date de dernière modification — toujours non nulle après construction
*/
private readonly DateTime $updatedAt;
/**
* @param int $id Identifiant en base (0 pour un nouvel article)
* @param string $title Titre de l'article (1255 caractères)
* @param string $content Contenu HTML de l'article (165 535 caractères)
* @param string $slug Slug URL canonique, tel que stocké en base
* @param int|null $authorId Identifiant de l'auteur (null si le compte a été supprimé)
* @param string|null $authorUsername Nom de l'auteur dénormalisé (null si le compte a été supprimé)
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
* @param string|null $categoryName Nom de la catégorie dénormalisé (null si sans catégorie)
* @param string|null $categorySlug Slug de la catégorie dénormalisé (null si sans catégorie)
* @param DateTime|null $createdAt Date de création (défaut : maintenant)
* @param DateTime|null $updatedAt Date de dernière modification (défaut : maintenant)
*
* @throws \InvalidArgumentException Si les données ne passent pas la validation
*/
public function __construct(
private readonly int $id,
private readonly string $title,
private readonly string $content,
private readonly string $slug = '',
private readonly ?int $authorId = null,
private readonly ?string $authorUsername = null,
private readonly ?int $categoryId = null,
private readonly ?string $categoryName = null,
private readonly ?string $categorySlug = null,
?DateTime $createdAt = null,
?DateTime $updatedAt = null,
) {
$this->createdAt = $createdAt ?? new DateTime();
$this->updatedAt = $updatedAt ?? new DateTime();
$this->validate();
}
/**
* Crée une instance depuis un tableau associatif (ligne de base de données).
*
* @param array<string, mixed> $data Données issues de la base de données (avec JOIN users)
*
* @return self L'instance hydratée
*/
public static function fromArray(array $data): self
{
return new self(
id: (int) ($data['id'] ?? 0),
title: (string) ($data['title'] ?? ''),
content: (string) ($data['content'] ?? ''),
slug: (string) ($data['slug'] ?? ''),
authorId: isset($data['author_id']) ? (int) $data['author_id'] : null,
authorUsername: isset($data['author_username']) ? (string) $data['author_username'] : null,
categoryId: isset($data['category_id']) ? (int) $data['category_id'] : null,
categoryName: isset($data['category_name']) ? (string) $data['category_name'] : null,
categorySlug: isset($data['category_slug']) ? (string) $data['category_slug'] : null,
createdAt: DateParser::parse($data['created_at'] ?? null),
updatedAt: DateParser::parse($data['updated_at'] ?? null),
);
}
/**
* Retourne l'identifiant de l'article.
*
* @return int L'identifiant en base (0 si non encore persisté)
*/
public function getId(): int
{
return $this->id;
}
/**
* Retourne le titre de l'article.
*
* @return string Le titre
*/
public function getTitle(): string
{
return $this->title;
}
/**
* Retourne le contenu HTML de l'article.
*
* @return string Le contenu HTML sanitisé (purifié par HTMLPurifier à l'écriture)
*/
public function getContent(): string
{
return $this->content;
}
/**
* Retourne le slug canonique tel que stocké en base de données.
*
* Ce slug peut différer du résultat de generateSlug() si un suffixe numérique
* a été ajouté lors de la création pour lever une collision
* (ex: titre "Mon article" → slug en DB "mon-article-2").
* C'est cette valeur qu'il faut utiliser pour construire les URLs publiques.
*
* @return string Le slug canonique (vide si l'article n'a pas encore été persisté)
*/
public function getStoredSlug(): string
{
return $this->slug;
}
/**
* Retourne l'identifiant de l'auteur.
*
* @return int|null L'identifiant de l'auteur, ou null si le compte a été supprimé
*/
public function getAuthorId(): ?int
{
return $this->authorId;
}
/**
* Retourne le nom d'utilisateur de l'auteur.
*
* @return string|null Le nom d'utilisateur, ou null si le compte a été supprimé
*/
public function getAuthorUsername(): ?string
{
return $this->authorUsername;
}
/**
* Retourne l'identifiant de la catégorie de l'article.
*
* @return int|null L'identifiant de la catégorie, ou null si l'article est sans catégorie
*/
public function getCategoryId(): ?int
{
return $this->categoryId;
}
/**
* Retourne le nom de la catégorie de l'article.
*
* @return string|null Le nom de la catégorie, ou null si l'article est sans catégorie
*/
public function getCategoryName(): ?string
{
return $this->categoryName;
}
/**
* Retourne le slug de la catégorie de l'article.
*
* @return string|null Le slug de la catégorie, ou null si l'article est sans catégorie
*/
public function getCategorySlug(): ?string
{
return $this->categorySlug;
}
/**
* Retourne la date de création de l'article.
*
* @return DateTime La date de création
*/
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
/**
* Retourne la date de dernière modification de l'article.
*
* @return DateTime La date de dernière modification
*/
public function getUpdatedAt(): DateTime
{
return $this->updatedAt;
}
/**
* Génère un slug URL-friendly calculé à partir du titre courant.
*
* Cette méthode est réservée à PostService pour produire le slug à stocker
* lors de la création ou de la modification d'un article.
* Pour construire une URL publique, utiliser getStoredSlug() qui retourne
* le slug canonique tel qu'il est enregistré en base de données.
*
* La génération est déléguée à SlugHelper::generate() — voir sa documentation
* pour le détail de l'algorithme (translittération ASCII, minuscules, tirets).
*
* @return string Le slug en minuscules avec tirets (ex: "ete-en-foret")
*/
public function generateSlug(): string
{
return SlugHelper::generate($this->title);
}
/**
* Valide les données de l'article.
*
* @throws \InvalidArgumentException Si le titre est vide ou dépasse 255 caractères
* @throws \InvalidArgumentException Si le contenu est vide ou dépasse 65 535 caractères
*/
private function validate(): void
{
if ($this->title === '') {
throw new \InvalidArgumentException('Le titre ne peut pas être vide');
}
if (mb_strlen($this->title) > 255) {
throw new \InvalidArgumentException('Le titre ne peut pas dépasser 255 caractères');
}
if ($this->content === '') {
throw new \InvalidArgumentException('Le contenu ne peut pas être vide');
}
if (mb_strlen($this->content) > 65535) {
throw new \InvalidArgumentException('Le contenu ne peut pas dépasser 65 535 caractères');
}
}
}

379
src/Post/PostController.php Normal file
View File

@@ -0,0 +1,379 @@
<?php
declare(strict_types=1);
namespace App\Post;
use App\Category\CategoryServiceInterface;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Exception\HttpNotFoundException;
use Slim\Views\Twig;
/**
* Contrôleur pour les articles.
*
* Gère les actions HTTP liées aux articles : affichage public et administration
* (liste, formulaire, création, modification, suppression).
* Délègue toute la logique métier à PostService et utilise FlashService
* pour transmettre les messages d'erreur entre redirections.
* L'identifiant de l'auteur est lu depuis SessionManager lors de la création.
* Les droits de modification et suppression sont vérifiés via canEditPost().
* CategoryService est injecté pour résoudre les slugs de catégorie
* en identifiants et fournir la liste des catégories aux vues.
*/
final class PostController
{
/**
* @param Twig $view Moteur de templates Twig
* @param PostServiceInterface $postService Service métier des articles
* @param CategoryServiceInterface $categoryService Service de gestion des catégories
* @param FlashServiceInterface $flash Service de messages flash
* @param SessionManagerInterface $sessionManager Gestionnaire de session
*/
public function __construct(
private readonly Twig $view,
private readonly PostServiceInterface $postService,
private readonly CategoryServiceInterface $categoryService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
) {
}
/**
* Affiche la page d'accueil avec la liste des articles.
*
* Accepte deux paramètres de requête cumulables :
* - `q` (string) : recherche plein texte FTS5 sur titre, contenu et auteur
* - `categorie` (string) : filtre par slug de catégorie
*
* Si `q` est fourni, les résultats sont triés par pertinence BM25.
* Sans `q`, les articles sont triés du plus récent au plus ancien.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue de la page d'accueil
*/
public function index(Request $req, Response $res): Response
{
$params = $req->getQueryParams();
$searchQuery = trim((string) ($params['q'] ?? ''));
$categorySlug = (string) ($params['categorie'] ?? '');
$activeCategory = null;
$categoryId = null;
if ($categorySlug !== '') {
$activeCategory = $this->categoryService->findBySlug($categorySlug);
$categoryId = $activeCategory?->getId();
}
$posts = $searchQuery !== ''
? $this->postService->searchPosts($searchQuery, $categoryId)
: $this->postService->getAllPosts($categoryId);
return $this->view->render($res, 'pages/home.twig', [
'posts' => $posts,
'categories' => $this->categoryService->findAll(),
'activeCategory' => $activeCategory,
'searchQuery' => $searchQuery,
]);
}
/**
* Affiche le détail d'un article par son slug.
*
* Le contenu HTML est déjà sanitisé lors de la création/modification
* (via HtmlSanitizerInterface dans PostService) : aucun nettoyage supplémentaire
* n'est nécessaire à la lecture.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (slug)
*
* @return Response La vue de détail de l'article
*
* @throws HttpNotFoundException Si aucun article ne correspond au slug
*/
public function show(Request $req, Response $res, array $args): Response
{
try {
$post = $this->postService->getPostBySlug((string) ($args['slug'] ?? ''));
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
}
return $this->view->render($res, 'pages/post/detail.twig', ['post' => $post]);
}
/**
* Affiche la liste des articles dans l'interface d'administration.
*
* Un administrateur ou un éditeur voit tous les articles.
* Un utilisateur normal voit uniquement ses propres articles.
*
* Accepte deux paramètres de requête cumulables :
* - `q` (string) : recherche plein texte FTS5 sur titre, contenu et auteur
* - `categorie` (string) : filtre par slug de catégorie
*
* Si `q` est fourni, les résultats sont triés par pertinence BM25.
* Sans `q`, les articles sont triés du plus récent au plus ancien.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue d'administration des posts
*/
public function admin(Request $req, Response $res): Response
{
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
$userId = $this->sessionManager->getUserId();
$params = $req->getQueryParams();
$searchQuery = trim((string) ($params['q'] ?? ''));
$categorySlug = (string) ($params['categorie'] ?? '');
$activeCategory = null;
$categoryId = null;
if ($categorySlug !== '') {
$activeCategory = $this->categoryService->findBySlug($categorySlug);
$categoryId = $activeCategory?->getId();
}
if ($searchQuery !== '') {
$authorId = $isAdmin ? null : (int) $userId;
$posts = $this->postService->searchPosts($searchQuery, $categoryId, $authorId);
} else {
$posts = $isAdmin
? $this->postService->getAllPosts($categoryId)
: $this->postService->getPostsByUserId((int) $userId, $categoryId);
}
return $this->view->render($res, 'admin/posts/index.twig', [
'posts' => $posts,
'categories' => $this->categoryService->findAll(),
'activeCategory' => $activeCategory,
'searchQuery' => $searchQuery,
'error' => $this->flash->get('post_error'),
'success' => $this->flash->get('post_success'),
]);
}
/**
* Affiche le formulaire de création (id=0) ou d'édition d'un article.
*
* L'accès en édition est refusé si l'utilisateur n'est pas l'auteur
* de l'article et n'a pas le rôle admin.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Le formulaire ou une redirection
*
* @throws HttpNotFoundException Si l'article demandé n'existe pas
*/
public function form(Request $req, Response $res, array $args): Response
{
$id = (int) ($args['id'] ?? 0);
$post = null;
if ($id > 0) {
try {
$post = $this->postService->getPostById($id);
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
}
// Vérification des droits avant affichage du formulaire
if (!$this->canEditPost($post)) {
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
}
return $this->view->render($res, 'admin/posts/form.twig', [
'post' => $post,
'categories' => $this->categoryService->findAll(),
'action' => $id > 0 ? "/admin/posts/edit/{$id}" : '/admin/posts/create',
'error' => $this->flash->get('post_error'),
]);
}
/**
* Traite la soumission du formulaire de création d'article.
*
* L'auteur est l'utilisateur connecté, lu depuis la session.
* Le slug est généré automatiquement depuis le titre par PostService —
* la valeur éventuellement saisie dans le formulaire est ignorée à la création
* (elle n'est prise en compte qu'à la modification via update()).
* En cas d'erreur de validation, redirige vers le formulaire avec un message flash.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Une redirection vers /admin/posts ou /admin/posts/edit/0
*/
public function create(Request $req, Response $res): Response
{
['title' => $title, 'content' => $content, 'category_id' => $categoryId] =
$this->extractPostData($req);
try {
$this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId);
$this->flash->set('post_success', 'L\'article a été créé avec succès');
} catch (\InvalidArgumentException $e) {
$this->flash->set('post_error', $e->getMessage());
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
} catch (\Throwable) {
$this->flash->set('post_error', 'Une erreur inattendue s\'est produite');
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
}
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
/**
* Traite la soumission du formulaire de modification d'article.
*
* Vérifie les droits avant modification : seul l'auteur ou un admin peut modifier.
* Un second 404 est possible si l'article est supprimé entre la vérification
* des droits et l'UPDATE (race condition).
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Une redirection vers /admin/posts ou vers le formulaire
*
* @throws HttpNotFoundException Si l'article n'existe pas ou a disparu (race condition)
*/
public function update(Request $req, Response $res, array $args): Response
{
$id = (int) $args['id'];
['title' => $title, 'content' => $content, 'slug' => $slug, 'category_id' => $categoryId] =
$this->extractPostData($req);
// Récupération de l'article pour vérification des droits avant modification
try {
$post = $this->postService->getPostById($id);
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
}
if (!$this->canEditPost($post)) {
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
try {
$this->postService->updatePost($id, $title, $content, $slug, $categoryId);
$this->flash->set('post_success', 'L\'article a été modifié avec succès');
} catch (NotFoundException) {
// L'article a disparu entre la vérification des droits et l'UPDATE (race condition)
throw new HttpNotFoundException($req);
} catch (\InvalidArgumentException $e) {
$this->flash->set('post_error', $e->getMessage());
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
} catch (\Throwable) {
$this->flash->set('post_error', 'Une erreur inattendue s\'est produite');
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
}
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
/**
* Supprime un article.
*
* Vérifie les droits avant suppression : seul l'auteur ou un admin peut supprimer.
* Un second 404 est possible si l'article est supprimé entre la vérification
* des droits et le DELETE (race condition — cohérent avec update()).
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Une redirection vers /admin/posts
*
* @throws HttpNotFoundException Si l'article n'existe pas ou a disparu (race condition)
*/
public function delete(Request $req, Response $res, array $args): Response
{
// Récupération de l'article pour vérification des droits avant suppression
try {
$post = $this->postService->getPostById((int) $args['id']);
} catch (NotFoundException) {
throw new HttpNotFoundException($req);
}
if (!$this->canEditPost($post)) {
$this->flash->set('post_error', "Vous ne pouvez pas supprimer un article dont vous n'êtes pas l'auteur");
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
try {
$this->postService->deletePost($post->getId());
} catch (NotFoundException) {
// L'article a disparu entre la vérification des droits et le DELETE (race condition)
throw new HttpNotFoundException($req);
}
$this->flash->set('post_success', "L'article « {$post->getTitle()} » a été supprimé avec succès");
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
/**
* Vérifie si l'utilisateur connecté est autorisé à modifier ou supprimer un article.
*
* L'accès est accordé si l'utilisateur est l'auteur de l'article
* ou s'il a le rôle administrateur.
*
* @param Post $post L'article concerné
*
* @return bool True si l'action est autorisée
*/
private function canEditPost(Post $post): bool
{
// Un administrateur ou un éditeur a tous les droits sur tous les articles
if ($this->sessionManager->isAdmin() || $this->sessionManager->isEditor()) {
return true;
}
// Un utilisateur standard ne peut agir que sur ses propres articles
return $post->getAuthorId() === $this->sessionManager->getUserId();
}
/**
* Extrait et normalise les données d'article depuis le corps de la requête.
*
* @param Request $req La requête HTTP
*
* @return array{title: string, content: string, slug: string, category_id: int|null} Les données nettoyées
*/
private function extractPostData(Request $req): array
{
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$categoryId = ($data['category_id'] ?? '') !== ''
? (int) $data['category_id']
: null;
return [
'title' => trim((string) ($data['title'] ?? '')),
'content' => trim((string) ($data['content'] ?? '')),
'slug' => trim((string) ($data['slug'] ?? '')),
'category_id' => $categoryId,
];
}
}

205
src/Post/PostExtension.php Normal file
View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Post;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* Extension Twig pour la présentation des articles.
*
* Expose des fonctions utilitaires dans les templates Twig
* afin d'éviter d'appeler de la logique de présentation directement
* sur le modèle Post depuis les vues.
*
* Fonctions disponibles dans les templates :
*
* @example {{ post_excerpt(post) }} — extrait de 400 caractères par défaut
* @example {{ post_excerpt(post, 600) }} — extrait personnalisé de 600 caractères
* @example {{ post_url(post) }} — URL publique de l'article (/article/{slug})
* @example {{ post_thumbnail(post) }} — URL de la première image, ou null si aucune image
* @example {{ post_initials(post) }} — initiales du titre (ex: "AB" pour "Article de Blog")
*/
final class PostExtension extends AbstractExtension
{
/**
* Déclare les fonctions Twig exposées aux templates.
*
* @return TwigFunction[] Les fonctions enregistrées dans l'environnement Twig
*/
public function getFunctions(): array
{
return [
new TwigFunction(
'post_excerpt',
fn (Post $post, int $length = 400) => self::excerpt($post, $length),
['is_safe' => ['html']]
),
new TwigFunction(
'post_url',
fn (Post $post) => '/article/'.$post->getStoredSlug()
),
new TwigFunction(
'post_thumbnail',
fn (Post $post) => self::thumbnail($post)
),
new TwigFunction(
'post_initials',
fn (Post $post) => self::initials($post)
),
];
}
/**
* Génère un extrait HTML formaté du contenu de l'article.
*
* Conserve uniquement les balises sûres et porteuses de sens visuel
* (<ul>, <ol>, <li>, <strong>, <em>, <b>, <i>) afin que le formatage
* soit perceptible dans l'aperçu (listes à puces, gras, italique…).
* Toutes les autres balises sont supprimées par strip_tags().
*
* La hauteur de l'aperçu est contrainte côté CSS (max-height sur .card__body +
* dégradé de fondu sur .card__excerpt) — c'est CSS qui tronque visuellement,
* pas cette méthode. Le paramètre $length sert uniquement de garde-fou serveur :
* il évite d'envoyer l'intégralité d'un long article au navigateur. La valeur
* par défaut de 400 caractères est volontairement généreuse pour ne jamais
* couper un contenu que CSS aurait affiché en entier.
*
* La troncature opère sur le HTML filtré (pas sur le texte brut) afin de
* conserver le formatage de façon cohérente, quelle que soit la longueur
* du contenu. Le comptage de caractères ignore les balises.
*
* Le HTML retourné provient de HTMLPurifier (appliqué à l'écriture) —
* strip_tags() avec liste blanche élimine tout balisage résiduel non désiré.
* La fonction est déclarée is_safe => ['html'] : Twig ne l'échappe pas
* automatiquement, le |raw est inutile dans les templates.
*
* @param Post $post L'article dont générer l'extrait
* @param int $length Longueur maximale en caractères visibles (défaut : 400)
*
* @return string L'extrait en HTML partiel, tronqué si nécessaire
*/
private static function excerpt(Post $post, int $length): string
{
// Balises conservées : structurantes pour les listes, sémantiques pour le gras/italique.
// Toutes les autres (p, div, h1-h6, img, a, table…) sont supprimées pour
// garder un aperçu compact.
$allowed = '<ul><ol><li><strong><em><b><i>';
$html = strip_tags($post->getContent(), $allowed);
// Mesurer sur le texte brut : les balises ne comptent pas dans la limite visible
if (mb_strlen(strip_tags($html)) <= $length) {
return $html;
}
// Tronquer en avançant caractère par caractère dans le HTML, en ignorant
// les balises dans le comptage — le formatage est ainsi conservé dans la
// portion visible, de façon cohérente avec les articles courts.
$truncated = '';
$count = 0;
$inTag = false;
for ($i = 0, $len = mb_strlen($html); $i < $len && $count < $length; $i++) {
$char = mb_substr($html, $i, 1);
if ($char === '<') {
$inTag = true;
}
$truncated .= $char;
if ($inTag) {
if ($char === '>') {
$inTag = false;
}
} else {
$count++;
}
}
// Fermer proprement les balises laissées ouvertes par la troncature
foreach (['li', 'ul', 'ol', 'em', 'strong', 'b', 'i'] as $tag) {
$opens = substr_count($truncated, "<{$tag}>") + substr_count($truncated, "<{$tag} ");
$closes = substr_count($truncated, "</{$tag}>");
for ($j = $closes; $j < $opens; $j++) {
$truncated .= "</{$tag}>";
}
}
return $truncated . '…';
}
/**
* Extrait l'URL de la première image présente dans le contenu de l'article.
*
* Utilise une regex sur l'attribut src de la première balise <img> trouvée.
* Le contenu étant sanitisé par HTMLPurifier, seuls les schémas http/https
* sont présents — aucun risque XSS via cet attribut.
* L'échappement de l'URL est délégué à Twig (auto-escape activé).
*
* @param Post $post L'article dont extraire la vignette
*
* @return string|null L'URL de la première image, ou null si aucune image
*/
private static function thumbnail(Post $post): ?string
{
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/', $post->getContent(), $matches)) {
return $matches[1];
}
return null;
}
/**
* Génère les initiales du titre de l'article (1 à 2 caractères).
*
* Extrait la première lettre de chaque mot, conserve les deux premières,
* et retourne le résultat en majuscules. Les mots vides (articles, prépositions
* d'une lettre) sont ignorés pour favoriser les mots porteurs de sens.
*
* Exemples :
* "Article de Blog" → "AB"
* "Été en forêt" → "EF"
* "PHP" → "P"
* "" → "?"
*
* L'échappement HTML est délégué à Twig (auto-escape activé).
*
* @param Post $post L'article dont générer les initiales
*
* @return string Les initiales en majuscules (12 caractères), ou "?" si le titre est vide
*/
private static function initials(Post $post): string
{
// Filtrer les mots vides fréquents (articles, prépositions, coordinations)
// pour favoriser les mots porteurs de sens : "Article de Blog" → ["Article", "Blog"] → "AB"
$stopWords = ['a', 'au', 'aux', 'd', 'de', 'des', 'du', 'en', 'et', 'l', 'la', 'le', 'les', 'of', 'the', 'un', 'une'];
$words = array_filter(
preg_split('/\s+/', trim($post->getTitle())) ?: [],
static function (string $w) use ($stopWords): bool {
$normalized = mb_strtolower(trim($w, " \t\n\r\0\x0B'\"`.-_"));
return $normalized !== ''
&& mb_strlen($normalized) > 1
&& !in_array($normalized, $stopWords, true);
}
);
if (empty($words)) {
// Repli sur le premier caractère du titre brut si tous les mots font 1 lettre
$first = mb_substr(trim($post->getTitle()), 0, 1);
return $first !== '' ? mb_strtoupper($first) : '?';
}
$words = array_values($words);
$initials = mb_strtoupper(mb_substr($words[0], 0, 1));
if (isset($words[1])) {
$initials .= mb_strtoupper(mb_substr($words[1], 0, 1));
}
return $initials;
}
}

334
src/Post/PostRepository.php Normal file
View File

@@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
namespace App\Post;
use PDO;
/**
* Dépôt pour la persistance des articles.
*
* Responsabilité unique : exécuter les requêtes SQL liées à la table `posts`
* et retourner des instances de Post hydratées.
* Chaque requête de lecture effectue un LEFT JOIN sur `users` pour charger
* le nom d'auteur, et un LEFT JOIN sur `categories` pour charger le nom et
* le slug de catégorie — sans requête supplémentaire.
*/
final class PostRepository implements PostRepositoryInterface
{
/**
* Fragment SELECT commun à toutes les requêtes de lecture (avec JOINs).
*/
private const SELECT = '
SELECT posts.id, posts.title, posts.content, posts.slug,
posts.author_id, posts.category_id, posts.created_at, posts.updated_at,
users.username AS author_username,
categories.name AS category_name,
categories.slug AS category_slug
FROM posts
LEFT JOIN users ON users.id = posts.author_id
LEFT JOIN categories ON categories.id = posts.category_id
';
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db)
{
}
/**
* Retourne tous les articles triés du plus récent au plus ancien.
*
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[] La liste des articles
*/
public function findAll(?int $categoryId = null): array
{
if ($categoryId !== null) {
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
$stmt->execute([':category_id' => $categoryId]);
} else {
$stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC');
if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur posts a échoué.');
}
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Post::fromArray($row), $rows);
}
/**
* Retourne les N articles les plus récents, tous auteurs confondus.
*
* @param int $limit Nombre maximum d'articles à retourner
*
* @return Post[] Les articles les plus récents
*/
public function findRecent(int $limit): array
{
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.id DESC LIMIT :limit');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Post::fromArray($row), $rows);
}
/**
* Retourne tous les articles d'un utilisateur donné, triés du plus récent au plus ancien.
*
* @param int $userId Identifiant de l'auteur
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[] La liste des articles de cet utilisateur
*/
public function findByUserId(int $userId, ?int $categoryId = null): array
{
if ($categoryId !== null) {
$stmt = $this->db->prepare(
self::SELECT . ' WHERE posts.author_id = :author_id AND posts.category_id = :category_id ORDER BY posts.id DESC'
);
$stmt->execute([':author_id' => $userId, ':category_id' => $categoryId]);
} else {
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.author_id = :author_id ORDER BY posts.id DESC');
$stmt->execute([':author_id' => $userId]);
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Post::fromArray($row), $rows);
}
/**
* Trouve un article par son slug URL.
*
* @param string $slug Le slug URL de l'article
*
* @return Post|null L'article trouvé, ou null s'il n'existe pas
*/
public function findBySlug(string $slug): ?Post
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug');
$stmt->execute([':slug' => $slug]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Post::fromArray($row) : null;
}
/**
* Trouve un article par son identifiant.
*
* @param int $id Identifiant de l'article
*
* @return Post|null L'article trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?Post
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Post::fromArray($row) : null;
}
/**
* Persiste un nouvel article en base de données.
*
* @param Post $post L'article à créer
* @param string $slug Le slug unique généré pour cet article
* @param int $authorId Identifiant de l'auteur
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int L'identifiant généré par la base de données
*/
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int
{
$stmt = $this->db->prepare('
INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at)
VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)
');
$stmt->execute([
':title' => $post->getTitle(),
':content' => $post->getContent(),
':slug' => $slug,
':author_id' => $authorId,
':category_id' => $categoryId,
':created_at' => date('Y-m-d H:i:s'),
':updated_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->db->lastInsertId();
}
/**
* Met à jour un article existant en base de données.
*
* Retourne le nombre de lignes affectées. Une valeur de 0 indique que
* l'article n'existe plus au moment de l'écriture (suppression concurrente).
*
* @param int $id Identifiant de l'article à modifier
* @param Post $post L'article avec les nouvelles données
* @param string $slug Le nouveau slug unique
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int Nombre de lignes affectées (0 si l'article n'existe plus)
*/
public function update(int $id, Post $post, string $slug, ?int $categoryId): int
{
$stmt = $this->db->prepare('
UPDATE posts
SET title = :title, content = :content, slug = :slug,
category_id = :category_id, updated_at = :updated_at
WHERE id = :id
');
$stmt->execute([
':title' => $post->getTitle(),
':content' => $post->getContent(),
':slug' => $slug,
':category_id' => $categoryId,
':updated_at' => date('Y-m-d H:i:s'),
':id' => $id,
]);
return $stmt->rowCount();
}
/**
* Supprime un article de la base de données.
*
* @param int $id Identifiant de l'article à supprimer
*
* @return int Nombre de lignes supprimées (0 si l'article n'existe plus)
*/
public function delete(int $id): int
{
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->rowCount();
}
/**
* Recherche des articles en plein texte via l'index FTS5.
*
* La requête est tokenisée mot par mot : chaque terme est traité comme un
* préfixe (ex: "slim" correspond à "Slim", "Slimframework"…). Les termes
* sont combinés en AND implicite — tous doivent être présents dans le document.
* Les caractères spéciaux FTS5 sont échappés par guillemets doubles.
*
* Les résultats sont triés par pertinence BM25 (meilleur en premier).
* Filtrages optionnels disponibles : par catégorie et/ou par auteur.
*
* @param string $query La saisie utilisateur brute
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
* @param int|null $authorId Filtre optionnel par identifiant d'auteur (rôle user)
*
* @return Post[] Les articles correspondant à la recherche, triés par pertinence
*/
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
{
$ftsQuery = $this->buildFtsQuery($query);
if ($ftsQuery === '') {
return [];
}
$sql = '
SELECT p.id, p.title, p.content, p.slug,
p.author_id, p.category_id, p.created_at, p.updated_at,
u.username AS author_username,
c.name AS category_name,
c.slug AS category_slug
FROM posts_fts f
JOIN posts p ON p.id = f.rowid
LEFT JOIN users u ON u.id = p.author_id
LEFT JOIN categories c ON c.id = p.category_id
WHERE posts_fts MATCH :query
';
$params = [':query' => $ftsQuery];
if ($categoryId !== null) {
$sql .= ' AND p.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
if ($authorId !== null) {
$sql .= ' AND p.author_id = :author_id';
$params[':author_id'] = $authorId;
}
$sql .= ' ORDER BY rank';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Post::fromArray($row), $rows);
}
/**
* Construit une requête FTS5 sûre depuis la saisie utilisateur.
*
* Chaque mot est wrappé entre guillemets doubles (échappement interne
* des guillemets par doublement) et suivi d'un `*` pour la recherche
* par préfixe. Les mots sont joints par un espace (AND implicite FTS5).
*
* @param string $input La saisie brute de l'utilisateur
*
* @return string La requête FTS5 prête à l'emploi, ou '' si vide
*/
private function buildFtsQuery(string $input): string
{
$words = preg_split('/\s+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [];
if (empty($words)) {
return '';
}
$terms = array_map(
fn ($w) => '"' . str_replace('"', '""', $w) . '"*',
$words
);
return implode(' ', $terms);
}
/**
* Vérifie si un slug est déjà utilisé par un autre article.
*
* @param string $slug Le slug à vérifier
* @param int|null $excludeId Identifiant à exclure de la vérification (pour les mises à jour)
*
* @return bool True si le slug est déjà pris par un autre article
*/
public function slugExists(string $slug, ?int $excludeId = null): bool
{
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug');
$stmt->execute([':slug' => $slug]);
$existingId = $stmt->fetchColumn();
if ($existingId === false) {
return false;
}
$existingId = (int) $existingId;
if ($excludeId !== null) {
return $existingId !== $excludeId;
}
return true;
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Post;
/**
* Contrat de persistance des articles.
*
* Découple PostService de l'implémentation concrète PDO/SQLite,
* facilitant les mocks dans les tests unitaires.
*/
interface PostRepositoryInterface
{
/**
* Retourne tous les articles triés du plus récent au plus ancien.
*
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
public function findAll(?int $categoryId = null): array;
/**
* Retourne les N articles les plus récents (flux RSS).
*
* @param int $limit Nombre maximum d'articles à retourner
*
* @return Post[]
*/
public function findRecent(int $limit): array;
/**
* Retourne tous les articles d'un utilisateur donné.
*
* @param int $userId Identifiant de l'auteur
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
public function findByUserId(int $userId, ?int $categoryId = null): array;
/**
* Trouve un article par son slug URL.
*
* @param string $slug Le slug URL de l'article
*
* @return Post|null L'article trouvé, ou null s'il n'existe pas
*/
public function findBySlug(string $slug): ?Post;
/**
* Trouve un article par son identifiant.
*
* @param int $id Identifiant de l'article
*
* @return Post|null L'article trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?Post;
/**
* Persiste un nouvel article en base de données.
*
* @param Post $post L'article à créer
* @param string $slug Le slug unique généré pour cet article
* @param int $authorId Identifiant de l'auteur
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int L'identifiant généré par la base de données
*/
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int;
/**
* Met à jour un article existant.
*
* @param int $id Identifiant de l'article à modifier
* @param Post $post L'article avec les nouvelles données
* @param string $slug Le nouveau slug unique
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int Nombre de lignes affectées
*/
public function update(int $id, Post $post, string $slug, ?int $categoryId): int;
/**
* Supprime un article de la base de données.
*
* @param int $id Identifiant de l'article à supprimer
*
* @return int Nombre de lignes supprimées
*/
public function delete(int $id): int;
/**
* Recherche des articles en plein texte via FTS5.
*
* @param string $query La saisie utilisateur brute
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
* @param int|null $authorId Filtre optionnel par identifiant d'auteur
*
* @return Post[]
*/
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array;
/**
* Vérifie si un slug est déjà utilisé par un autre article.
*
* @param string $slug Le slug à vérifier
* @param int|null $excludeId Identifiant à exclure (mise à jour)
*
* @return bool True si le slug est déjà pris
*/
public function slugExists(string $slug, ?int $excludeId = null): bool;
}

252
src/Post/PostService.php Normal file
View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Post;
use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface;
use App\Shared\Util\SlugHelper;
/**
* Service métier pour les articles.
*
* Centralise toute la logique qui ne relève ni du stockage (PostRepository)
* ni de la présentation (PostController / PostExtension) :
* - génération et unicité des slugs
* - sanitisation du contenu HTML à l'écriture
* - orchestration des opérations create / update / delete
*
* Flux de sanitisation :
* 1. L'utilisateur saisit du HTML via Trumbowyg
* 2. createPost() / updatePost() passent le contenu brut à HtmlSanitizerInterface
* 3. HtmlSanitizerInterface (implémentée par HtmlSanitizer) délègue à HTMLPurifier, configuré pour n'autoriser
* que les balises produites par Trumbowyg
* 4. Le contenu purifié est stocké en base — le filtre |raw dans Twig est sûr
*/
final class PostService implements PostServiceInterface
{
/**
* @param PostRepositoryInterface $postRepository Dépôt de persistance des articles
* @param HtmlSanitizerInterface $htmlSanitizer Service de sanitisation HTML
*/
public function __construct(
private readonly PostRepositoryInterface $postRepository,
private readonly HtmlSanitizerInterface $htmlSanitizer,
) {
}
/**
* Retourne tous les articles triés du plus récent au plus ancien.
*
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
public function getAllPosts(?int $categoryId = null): array
{
return $this->postRepository->findAll($categoryId);
}
/**
* Retourne les N articles les plus récents pour le flux RSS.
*
* @param int $limit Nombre maximum d'articles à retourner (défaut : 20)
*
* @return Post[]
*/
public function getRecentPosts(int $limit = 20): array
{
return $this->postRepository->findRecent($limit);
}
/**
* Retourne tous les articles d'un utilisateur donné.
*
* @param int $userId Identifiant de l'auteur
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
public function getPostsByUserId(int $userId, ?int $categoryId = null): array
{
return $this->postRepository->findByUserId($userId, $categoryId);
}
/**
* Retourne un article par son slug URL.
*
* @param string $slug Le slug URL de l'article
*
* @return Post L'article avec contenu sûr
*
* @throws NotFoundException Si aucun article ne correspond au slug
*/
public function getPostBySlug(string $slug): Post
{
$post = $this->postRepository->findBySlug($slug);
if ($post === null) {
throw new NotFoundException('Article', $slug);
}
return $post;
}
/**
* Retourne un article par son identifiant.
*
* @param int $id Identifiant de l'article
*
* @return Post L'article avec son contenu
*
* @throws NotFoundException Si aucun article ne correspond à cet identifiant
*/
public function getPostById(int $id): Post
{
$post = $this->postRepository->findById($id);
if ($post === null) {
throw new NotFoundException('Article', $id);
}
return $post;
}
/**
* Crée un nouvel article et retourne son identifiant.
*
* Un slug unique est généré à partir du titre. Si le slug existe déjà,
* un suffixe numérique est ajouté (ex: "mon-article-2").
* Le contenu HTML est sanitisé avant stockage.
*
* @param string $title Titre de l'article
* @param string $content Contenu HTML brut (sera sanitisé)
* @param int $authorId Identifiant de l'auteur
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int L'identifiant de l'article créé
*
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
*/
public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int
{
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
$post = new Post(0, $title, $sanitizedContent);
$slug = $this->generateUniqueSlug($post->generateSlug());
return $this->postRepository->create($post, $slug, $authorId, $categoryId);
}
/**
* Met à jour un article existant.
*
* Le slug est préservé par défaut. Si $newSlugInput est fourni et différent
* du slug actuel, il est nettoyé puis rendu unique avant d'être appliqué.
*
* @param int $id Identifiant de l'article à modifier
* @param string $title Nouveau titre
* @param string $content Nouveau contenu HTML brut (sera sanitisé)
* @param string $newSlugInput Nouveau slug souhaité (vide = conserver l'actuel)
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @throws NotFoundException Si l'article n'existe plus
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
* @return void
*/
public function updatePost(
int $id,
string $title,
string $content,
string $newSlugInput = '',
?int $categoryId = null,
): void {
$current = $this->postRepository->findById($id);
if ($current === null) {
throw new NotFoundException('Article', $id);
}
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
$post = new Post($id, $title, $sanitizedContent);
$slugToUse = $current->getStoredSlug();
$newSlugInput = trim($newSlugInput);
$cleanSlugInput = $this->normalizeSlugInput($newSlugInput);
if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) {
$slugToUse = $this->generateUniqueSlug($cleanSlugInput, $id);
}
$affected = $this->postRepository->update($id, $post, $slugToUse, $categoryId);
if ($affected === 0) {
throw new NotFoundException('Article', $id);
}
}
/**
* Recherche des articles en plein texte via FTS5.
*
* @param string $query La saisie utilisateur brute
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
* @param int|null $authorId Filtre optionnel par identifiant d'auteur
*
* @return Post[]
*/
public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array
{
return $this->postRepository->search($query, $categoryId, $authorId);
}
/**
* Supprime un article.
*
* @param int $id Identifiant de l'article à supprimer
*
* @throws NotFoundException Si l'article n'existe plus au moment de la suppression
*/
public function deletePost(int $id): void
{
$affected = $this->postRepository->delete($id);
if ($affected === 0) {
throw new NotFoundException('Article', $id);
}
}
/**
* Nettoie une saisie utilisateur pour en faire un slug valide.
*
* Délègue à SlugHelper::generate() — voir sa documentation pour le détail
* de l'algorithme.
*
* @param string $input La valeur brute saisie par l'utilisateur
*
* @return string Le slug nettoyé, ou '' si invalide
*/
private function normalizeSlugInput(string $input): string
{
return SlugHelper::generate($input);
}
/**
* Génère un slug unique en ajoutant un suffixe numérique si nécessaire.
*
* @param string $baseSlug Le slug de base généré depuis le titre
* @param int|null $excludeId Identifiant à exclure lors de la vérification (mise à jour)
*
* @return string Le slug garanti unique
*/
private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string
{
$slug = $baseSlug;
$counter = 1;
while ($this->postRepository->slugExists($slug, $excludeId)) {
$slug = $baseSlug . '-' . $counter;
++$counter;
}
return $slug;
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Post;
use App\Shared\Exception\NotFoundException;
/**
* Contrat du service de gestion des articles.
*
* Permet de mocker le service dans les tests unitaires sans dépendre
* de la classe concrète finale PostService.
*/
interface PostServiceInterface
{
/**
* Retourne tous les articles publiés, avec un filtre optionnel par catégorie.
*
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
public function getAllPosts(?int $categoryId = null): array;
/**
* Retourne les articles les plus récents.
*
* @param int $limit Nombre maximum d'articles à retourner (défaut : 20)
*
* @return Post[]
*/
public function getRecentPosts(int $limit = 20): array;
/**
* Retourne les articles d'un auteur donné.
*
* @param int $userId Identifiant de l'auteur
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
public function getPostsByUserId(int $userId, ?int $categoryId = null): array;
/**
* Retourne un article par son slug URL.
*
* @param string $slug Le slug URL de l'article
*
* @return Post L'article avec contenu sûr
*
* @throws NotFoundException Si aucun article ne correspond au slug
*/
public function getPostBySlug(string $slug): Post;
/**
* Retourne un article par son identifiant.
*
* @param int $id Identifiant de l'article
*
* @return Post L'article avec son contenu
*
* @throws NotFoundException Si aucun article ne correspond à cet identifiant
*/
public function getPostById(int $id): Post;
/**
* Crée un nouvel article.
*
* @param string $title Titre de l'article
* @param string $content Contenu HTML brut (sera sanitisé)
* @param int $authorId Identifiant de l'auteur
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int L'identifiant de l'article créé
*
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
*/
public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int;
/**
* Met à jour un article existant.
*
* @param int $id Identifiant de l'article à modifier
* @param string $title Nouveau titre
* @param string $content Nouveau contenu HTML brut (sera sanitisé)
* @param string $newSlugInput Nouveau slug souhaité (vide = conserver l'actuel)
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @throws NotFoundException Si l'article n'existe plus
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
*/
public function updatePost(
int $id,
string $title,
string $content,
string $newSlugInput = '',
?int $categoryId = null,
): void;
/**
* Recherche des articles par mots-clés dans le titre, le contenu et l'auteur.
*
* @param string $query La saisie utilisateur brute
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
* @param int|null $authorId Filtre optionnel par identifiant d'auteur
*
* @return Post[]
*/
public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array;
/**
* Supprime un article.
*
* @param int $id Identifiant de l'article à supprimer
*
* @throws NotFoundException Si l'article n'existe plus au moment de la suppression
*/
public function deletePost(int $id): void;
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Post;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Contrôleur du flux RSS.
*
* Expose un flux RSS 2.0 des 20 articles les plus récents à l'URL /rss.xml.
* Le contenu HTML des articles est strippé pour le champ <description> afin
* de produire un résumé texte brut compatible avec tous les lecteurs RSS.
*
* Pas de vue Twig — le XML est généré directement via SimpleXMLElement
* pour rester indépendant du moteur de templates.
*/
final class RssController
{
/**
* Nombre maximum d'articles inclus dans le flux RSS.
*/
private const FEED_LIMIT = 20;
/**
* @param PostServiceInterface $postService Service de récupération des articles
* @param string $appUrl URL de base de l'application (depuis APP_URL dans .env)
* @param string $appName Nom du blog affiché dans le flux
*/
public function __construct(
private readonly PostServiceInterface $postService,
private readonly string $appUrl,
private readonly string $appName,
) {
}
/**
* Génère et retourne le flux RSS 2.0.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Le flux RSS en XML (application/rss+xml; charset=utf-8)
*/
public function feed(Request $req, Response $res): Response
{
$posts = $this->postService->getRecentPosts(self::FEED_LIMIT);
$baseUrl = $this->appUrl;
$xml = new \SimpleXMLElement(
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"></rss>'
);
$channel = $xml->addChild('channel');
$channel->addChild('title', htmlspecialchars($this->appName));
$channel->addChild('link', $baseUrl . '/');
$channel->addChild('description', htmlspecialchars($this->appName . ' — flux RSS'));
$channel->addChild('language', 'fr-FR');
$channel->addChild('lastBuildDate', (new \DateTime())->format(\DateTime::RSS));
foreach ($posts as $post) {
$item = $channel->addChild('item');
$item->addChild('title', htmlspecialchars($post->getTitle()));
$postUrl = $baseUrl . '/article/' . $post->getStoredSlug();
$item->addChild('link', $postUrl);
$item->addChild('guid', $postUrl);
// Extrait texte brut : strip_tags + truncature à 300 caractères
$excerpt = strip_tags($post->getContent());
$excerpt = mb_strlen($excerpt) > 300
? mb_substr($excerpt, 0, 300) . '…'
: $excerpt;
$item->addChild('description', htmlspecialchars($excerpt));
$item->addChild('pubDate', $post->getCreatedAt()->format(\DateTime::RSS));
if ($post->getAuthorUsername() !== null) {
$item->addChild('author', htmlspecialchars($post->getAuthorUsername()));
}
if ($post->getCategoryName() !== null) {
$item->addChild('category', htmlspecialchars($post->getCategoryName()));
}
}
$body = $xml->asXML();
$res->getBody()->write($body !== false ? $body : '');
return $res->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
}
}

213
src/Shared/Bootstrap.php Normal file
View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Shared;
use App\Post\PostExtension;
use App\Shared\Database\Provisioner;
use App\Shared\Extension\AppExtension;
use App\Shared\Extension\CsrfExtension;
use App\Shared\Extension\SessionExtension;
use DI\ContainerBuilder;
use Dotenv\Dotenv;
use PDO;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Slim\App;
use Slim\Csrf\Guard;
use Slim\Exception\HttpException;
use Slim\Factory\AppFactory;
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;
use Throwable;
final class Bootstrap
{
private ?ContainerInterface $container = null;
private ?App $app = null;
public static function create(): self
{
return new self();
}
private function __construct()
{
}
public function initialize(): App
{
$this->initializeInfrastructure();
$this->runAutoProvisioningIfEnabled();
return $this->createHttpApp();
}
public function initializeInfrastructure(): ContainerInterface
{
if ($this->container !== null) {
return $this->container;
}
$this->checkDirectories();
$this->checkExtensions();
$this->loadEnvironment();
$this->buildContainer();
return $this->container;
}
public function createHttpApp(): App
{
if ($this->app !== null) {
return $this->app;
}
$container = $this->initializeInfrastructure();
$this->app = AppFactory::createFromContainer($container);
$this->registerMiddlewares();
$this->registerRoutes();
$this->configureErrorHandling();
return $this->app;
}
public function getContainer(): ContainerInterface
{
return $this->initializeInfrastructure();
}
private function buildContainer(): void
{
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
$builder = new ContainerBuilder();
$builder->addDefinitions(__DIR__ . '/../../config/container.php');
if (!$isDev) {
$builder->enableCompilation(__DIR__ . '/../../var/cache/di');
}
$this->container = $builder->build();
}
private function checkDirectories(): void
{
$dirs = [
__DIR__.'/../../var/cache/twig',
__DIR__.'/../../var/cache/htmlpurifier',
__DIR__.'/../../var/cache/di',
__DIR__.'/../../var/logs',
__DIR__.'/../../database',
__DIR__.'/../../public/media',
];
foreach ($dirs as $dir) {
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
throw new \RuntimeException("Impossible de créer le répertoire : {$dir}");
}
}
}
private function checkExtensions(): void
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException(
'L\'extension PHP GD avec le support WebP est requise. ' .
'Installez le paquet php-gd (ex: apt install php-gd) puis redémarrez PHP.'
);
}
}
private function loadEnvironment(): void
{
$dotenv = Dotenv::createImmutable(__DIR__.'/../..');
$dotenv->load();
$dotenv->required(['APP_URL', 'ADMIN_USERNAME', 'ADMIN_EMAIL', 'ADMIN_PASSWORD']);
date_default_timezone_set($_ENV['TIMEZONE'] ?? 'UTC');
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
if (!$isDev && ($_ENV['ADMIN_PASSWORD'] ?? '') === 'changeme123') {
throw new \RuntimeException(
'ADMIN_PASSWORD doit être changé avant de démarrer en production.'
);
}
}
private function runAutoProvisioningIfEnabled(): void
{
$flag = strtolower(trim((string) ($_ENV['APP_AUTO_PROVISION'] ?? '')));
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
$enabled = $flag !== ''
? in_array($flag, ['1', 'true', 'yes', 'on'], true)
: $isDev;
if (!$enabled) {
return;
}
Provisioner::run($this->container->get(PDO::class));
}
private function registerMiddlewares(): void
{
$this->app->addBodyParsingMiddleware();
$twig = $this->container->get(Twig::class);
$twig->addExtension($this->container->get(AppExtension::class));
$twig->addExtension($this->container->get(SessionExtension::class));
$twig->addExtension($this->container->get(PostExtension::class));
$this->app->add(TwigMiddleware::create($this->app, $twig));
$guard = new Guard($this->app->getResponseFactory());
$guard->setPersistentTokenMode(true);
$twig->addExtension(new CsrfExtension($guard));
$this->app->add($guard);
}
private function registerRoutes(): void
{
Routes::register($this->app);
}
private function configureErrorHandling(): void
{
$isDev = strtolower($_ENV['APP_ENV'] ?? 'production') === 'development';
$logger = $this->container->get(LoggerInterface::class);
$errorHandler = $this->app->addErrorMiddleware($isDev, true, true, $logger);
$errorHandler->setDefaultErrorHandler(
function (
ServerRequestInterface $request,
Throwable $exception,
bool $displayErrorDetails,
bool $logErrors,
bool $logErrorDetails,
) use ($isDev): ResponseInterface {
if ($isDev) {
throw $exception;
}
$statusCode = 500;
if ($exception instanceof HttpException) {
$statusCode = $exception->getCode() ?: 500;
}
$response = $this->app->getResponseFactory()->createResponse($statusCode);
$twig = $this->container->get(Twig::class);
return $twig->render($response, 'pages/error.twig', [
'status' => $statusCode,
'message' => $statusCode === 404
? 'La page demandée est introuvable.'
: 'Une erreur inattendue s\'est produite.',
]);
}
);
}
}

61
src/Shared/Config.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Shared;
/**
* Classe de configuration de l'application.
*
* Centralise la résolution des chemins et paramètres
* qui dépendent de l'environnement d'exécution.
*/
final class Config
{
/**
* Retourne le chemin du cache Twig, ou false si le cache est désactivé.
*
* Le cache Twig est désactivé en développement pour refléter
* immédiatement les modifications des templates.
* Le répertoire est créé en amont par Bootstrap::checkDirectories().
*
* @param bool $isDev True si l'application est en mode développement
*
* @return string|false Le chemin absolu du répertoire de cache, ou false
*/
public static function getTwigCache(bool $isDev): string|false
{
if ($isDev) {
return false;
}
return __DIR__.'/../../var/cache/twig';
}
/**
* Retourne le chemin absolu vers le fichier de base de données SQLite.
*
* Crée le répertoire et le fichier s'ils n'existent pas encore.
* En pratique, Bootstrap::checkDirectories() crée le répertoire `database/`
* avant que cette méthode soit appelée ; les opérations @mkdir/@touch/@chmod
* ne seront actives que si getDatabasePath() est appelé hors du cycle Bootstrap
* (tests unitaires, scripts CLI, etc.).
*
* @return string Chemin absolu vers le fichier app.sqlite
*/
public static function getDatabasePath(): string
{
$path = dirname(__DIR__, 2).'/database/app.sqlite';
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
if (!file_exists($path)) {
@touch($path);
@chmod($path, 0664);
}
return $path;
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Shared\Database;
use PDO;
/**
* Gestionnaire de migrations de base de données.
*
* Responsabilité unique : exécuter les migrations DDL et synchroniser
* l'index FTS5. Le provisionnement des données initiales est délégué
* à {@see Seeder}.
*
* Convention des fichiers de migration :
* - Placés dans database/migrations/
* - Nommés NNN_description.php (ex: 001_create_users.php)
* - Retournent un tableau ['up' => 'SQL...', 'down' => 'SQL...']
* - Triés et exécutés par ordre alphanumérique croissant
*
* run() est idempotent et sûr à appeler à chaque démarrage applicatif :
* les migrations déjà appliquées ne sont jamais rejouées.
*/
final class Migrator
{
/**
* Répertoire contenant les fichiers de migration.
*/
private const MIGRATIONS_DIR = __DIR__ . '/../../../database/migrations';
/**
* Exécute les migrations en attente puis synchronise l'index FTS5.
*
* Opération idempotente et sans effets de bord sur les données :
* sûre à appeler à chaque démarrage applicatif.
*
* Séquence :
* 1. Crée la table de suivi si absente
* 2. Joue les migrations en attente
* 3. Indexe dans posts_fts les articles absents de l'index (syncFtsIndex)
*
* @param PDO $db L'instance de connexion à la base de données
*/
public static function run(PDO $db): void
{
self::createMigrationTable($db);
self::runPendingMigrations($db);
self::syncFtsIndex($db);
}
/**
* Crée la table de suivi des migrations si elle n'existe pas.
*
* Cette table doit exister avant de pouvoir lire les migrations appliquées.
*
* @param PDO $db L'instance de connexion à la base de données
*/
private static function createMigrationTable(PDO $db): void
{
$db->exec('
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE,
run_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
');
}
/**
* Charge les fichiers de migration, filtre ceux déjà appliqués
* et exécute les migrations en attente dans l'ordre.
*
* @param PDO $db L'instance de connexion à la base de données
*/
private static function runPendingMigrations(PDO $db): void
{
// Versions déjà appliquées (indexées pour un accès O(1))
$stmt = $db->query('SELECT version FROM migrations');
$rows = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : [];
$applied = array_flip($rows);
// Fichiers de migration triés par nom (ordre alphanumérique = ordre numérique)
$files = glob(self::MIGRATIONS_DIR . '/*.php') ?: [];
sort($files);
$insert = $db->prepare('INSERT INTO migrations (version, run_at) VALUES (:version, :run_at)');
foreach ($files as $file) {
$version = basename($file, '.php');
if (isset($applied[$version])) {
continue;
}
// require évalue le fichier à chaque appel dans la boucle.
// require_once aurait mis en cache le résultat du premier fichier
// et l'aurait réutilisé pour tous les suivants — à ne pas utiliser ici.
$migration = require $file;
$db->exec($migration['up']);
$insert->execute([
':version' => $version,
':run_at' => date('Y-m-d H:i:s'),
]);
}
}
/**
* Synchronise l'index FTS5 avec les articles présents en base.
*
* Insère dans posts_fts les articles dont le rowid est absent de l'index.
* Idempotent et sans effet si l'index est déjà à jour.
*
* Nécessaire car les triggers FTS5 ne couvrent que les INSERT/UPDATE/DELETE
* effectués APRÈS leur création — les articles existants au moment de la
* migration 006 ne sont pas indexés rétroactivement.
*
* strip_tags() est enregistrée comme fonction SQLite dans container.php via
* sqliteCreateFunction() avant l'appel à Migrator::run() — elle est donc
* disponible ici.
*
* @param PDO $db L'instance de connexion à la base de données
*/
private static function syncFtsIndex(PDO $db): void
{
$db->exec("
INSERT INTO posts_fts(rowid, title, content, author_username)
SELECT p.id,
p.title,
COALESCE(strip_tags(p.content), ''),
COALESCE((SELECT username FROM users WHERE id = p.author_id), '')
FROM posts p
WHERE p.id NOT IN (SELECT rowid FROM posts_fts)
");
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Shared\Database;
use PDO;
/**
* Orchestration du provisionnement de la base de données.
*
* Exécute les migrations puis le seeding éventuel sous verrou fichier
* afin d'éviter les exécutions concurrentes au démarrage ou en CLI.
*/
final class Provisioner
{
public static function run(PDO $db): void
{
$lockPath = __DIR__ . '/../../../database/.provision.lock';
$handle = fopen($lockPath, 'c+');
if ($handle === false) {
throw new \RuntimeException('Impossible d\'ouvrir le verrou de provisionnement');
}
try {
if (!flock($handle, LOCK_EX)) {
throw new \RuntimeException('Impossible d\'obtenir le verrou de provisionnement');
}
Migrator::run($db);
Seeder::seed($db);
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Shared\Database;
use PDO;
/**
* Provisionnement des données initiales de l'application.
*
* Responsabilité unique : insérer les données nécessaires au premier démarrage
* (compte administrateur). N'exécute jamais de DDL — c'est le rôle du Migrator.
*
* Toutes les opérations sont idempotentes : le Seeder peut être appelé à chaque
* démarrage sans risque de doublon ni d'erreur si les données existent déjà.
*
* Variables d'environnement lues depuis .env :
* - ADMIN_USERNAME — nom d'utilisateur du compte admin (normalisé en minuscules)
* - ADMIN_EMAIL — adresse e-mail du compte admin (normalisée en minuscules)
* - ADMIN_PASSWORD — mot de passe en clair, haché en bcrypt avant insertion
*/
final class Seeder
{
/**
* Exécute toutes les opérations de provisionnement.
*
* Appelé dans Bootstrap::initialize() après Migrator::run(), une fois
* que le schéma est garanti à jour.
*
* @param PDO $db L'instance de connexion à la base de données
*/
public static function seed(PDO $db): void
{
self::seedAdminUser($db);
}
/**
* Crée le compte administrateur défini dans les variables d'environnement.
*
* Opération idempotente : le compte n'est créé que s'il n'existe pas encore.
* Sans effet si un utilisateur portant le même nom d'utilisateur est déjà présent.
*
* @param PDO $db L'instance de connexion à la base de données
*/
private static function seedAdminUser(PDO $db): void
{
$username = mb_strtolower(trim($_ENV['ADMIN_USERNAME'] ?? 'admin'));
$email = mb_strtolower(trim($_ENV['ADMIN_EMAIL'] ?? 'admin@example.com'));
$password = $_ENV['ADMIN_PASSWORD'] ?? 'changeme123';
$stmt = $db->prepare('SELECT id FROM users WHERE username = :username');
$stmt->execute([':username' => $username]);
if ($stmt->fetchColumn() !== false) {
return;
}
$stmt = $db->prepare('
INSERT INTO users (username, email, password_hash, role, created_at)
VALUES (:username, :email, :password_hash, :role, :created_at)
');
$stmt->execute([
':username' => $username,
':email' => $email,
':password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]),
':role' => 'admin',
':created_at' => date('Y-m-d H:i:s'),
]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Shared\Exception;
/**
* Exception levée lorsqu'une entité est introuvable en base de données.
*
* Exception générique réutilisable dans tous les domaines pour signaler
* qu'une ressource demandée n'existe pas ou n'existe plus.
*/
final class NotFoundException extends \RuntimeException
{
/**
* @param string $entity Type de l'entité (ex: 'Article', 'Utilisateur')
* @param int|string $identifier Identifiant de l'entité (id ou slug)
*/
public function __construct(string $entity, int|string $identifier)
{
parent::__construct("{$entity} introuvable : {$identifier}");
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Shared\Extension;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;
/**
* Extension Twig pour les variables globales de l'application.
*
* Expose la variable globale `app_url` dans tous les templates Twig,
* utile pour construire des URLs absolues (balises OG, flux RSS, emails…).
*
* Usage dans un template :
* <meta property="og:url" content="{{ app_url }}{{ post_url(post) }}">
*/
final class AppExtension extends AbstractExtension implements GlobalsInterface
{
/**
* @param string $appUrl URL de base de l'application, sans slash final (depuis APP_URL dans .env)
*/
public function __construct(private readonly string $appUrl)
{
}
/**
* Retourne les variables globales injectées dans tous les templates.
*
* @return array<string, mixed>
*/
public function getGlobals(): array
{
return ['app_url' => $this->appUrl];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Shared\Extension;
use Slim\Csrf\Guard;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;
/**
* Extension Twig pour l'accès aux tokens CSRF dans les templates.
*
* Expose une variable globale `csrf` dans tous les templates Twig,
* permettant d'injecter les champs cachés nécessaires dans les formulaires.
*
* Usage dans un template :
* <input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
* <input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
*/
final class CsrfExtension extends AbstractExtension implements GlobalsInterface
{
/**
* @param Guard $csrf Instance du middleware CSRF de Slim
*/
public function __construct(private readonly Guard $csrf)
{
}
/**
* Retourne les variables globales injectées dans tous les templates.
*
* @return array<string, mixed>
*/
public function getGlobals(): array
{
return [
'csrf' => [
'keys' => [
'name' => $this->csrf->getTokenNameKey(),
'value' => $this->csrf->getTokenValueKey(),
],
'name' => $this->csrf->getTokenName(),
'value' => $this->csrf->getTokenValue(),
],
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Shared\Extension;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;
/**
* Extension Twig pour l'accès aux données de session dans les templates.
*
* Expose la variable globale `session` dans tous les templates Twig,
* permettant de lire les données de session sans logique PHP dans les vues.
*
* Usage dans un template :
* {% if session.user_id is defined %}
* Connecté en tant que {{ session.username }}
* {% endif %}
*/
final class SessionExtension extends AbstractExtension implements GlobalsInterface
{
/**
* Retourne les variables globales injectées dans tous les templates.
*
* Seules les données nécessaires aux templates sont exposées, et non
* la totalité de $_SESSION. Cela évite d'exposer les messages flash
* non encore consommés, les tokens CSRF internes et toute autre donnée
* de session ajoutée à l'avenir.
*
* @return array<string, array<string, mixed>>
*/
public function getGlobals(): array
{
return [
'session' => [
'user_id' => $_SESSION['user_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'role' => $_SESSION['role'] ?? null,
],
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Shared\Html;
use HTMLPurifier;
use HTMLPurifier_Config;
/**
* Factory de configuration pour HTMLPurifier.
*
* Centralise la création et la configuration d'une instance HTMLPurifier
* avec les balises, attributs et schémas URI autorisés pour le contenu
* des articles produit par l'éditeur Trumbowyg.
*
* Règles de sécurité appliquées :
* - Balises autorisées alignées sur les boutons Trumbowyg (img autorisé via plugin upload)
* - Schémas URI restreints à http, https et mailto (bloque javascript:, data:)
* - Conversion automatique des URL nues en liens (AutoFormat.Linkify)
*/
final class HtmlPurifierFactory
{
/**
* Crée et retourne une instance HTMLPurifier préconfigurée.
*
* Les balises autorisées sont alignées sur les boutons Trumbowyg exposés
* dans l'éditeur et le plugin trumbowyg.upload.
*
* Sécurité URI : seuls les schémas http, https et mailto sont autorisés
* dans les attributs href, ce qui bloque les liens javascript: et data:.
*
* @param string $cacheDir Chemin absolu vers le répertoire de cache HTMLPurifier
* (créé automatiquement s'il n'existe pas)
*
* @return HTMLPurifier L'instance configurée et prête à purifier
*/
public static function create(string $cacheDir): HTMLPurifier
{
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0755, true);
}
$config = HTMLPurifier_Config::createDefault();
// Balises autorisées avec attribut style sur les éléments de texte
$config->set(
'HTML.Allowed',
'p[style],br,strong,em,u,del,h1[style],h2[style],h3[style],h4[style],h5[style],h6[style],ul,ol,li[style],blockquote[style],pre,a[href|title],img[src|alt|width|height]'
);
// Autoriser uniquement la propriété CSS text-align (sécurité)
$config->set('CSS.AllowedProperties', ['text-align']);
// Restriction des schémas URI autorisés dans href
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true, 'mailto' => true]);
// Conversion automatique des URL nues en liens cliquables
$config->set('AutoFormat.Linkify', true);
// Configuration du cache de définitions HTMLPurifier
$config->set('Cache.DefinitionImpl', 'Serializer');
$config->set('Cache.SerializerPath', $cacheDir);
return new HTMLPurifier($config);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Shared\Html;
use HTMLPurifier;
/**
* Service de sanitisation du contenu HTML.
*
* Délègue le nettoyage du HTML à HTMLPurifier pour supprimer
* tout contenu potentiellement malveillant (XSS, balises non autorisées).
*/
final class HtmlSanitizer implements HtmlSanitizerInterface
{
/**
* @param HTMLPurifier $purifier Instance préconfigurée via HtmlPurifierFactory
*/
public function __construct(private readonly HTMLPurifier $purifier)
{
}
/**
* Nettoie le contenu HTML fourni et retourne une version sûre.
*
* @param string $html Le contenu HTML brut à sanitiser
*
* @return string Le contenu HTML nettoyé
*/
public function sanitize(string $html): string
{
return $this->purifier->purify($html);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Shared\Html;
/**
* Contrat pour la sanitisation du contenu HTML.
*/
interface HtmlSanitizerInterface
{
/**
* Nettoie le contenu HTML fourni et retourne une version sûre.
*
* @param string $html Le contenu HTML brut à sanitiser
*
* @return string Le contenu HTML nettoyé
*/
public function sanitize(string $html): string;
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Shared\Http;
use Psr\Http\Message\ServerRequestInterface;
/**
* Résout l'adresse IP cliente à partir de la requête HTTP.
*
* L'en-tête X-Forwarded-For n'est pris en compte que si REMOTE_ADDR
* correspond à un proxy explicitement approuvé. En l'absence d'IP
* exploitable, la valeur de repli '0.0.0.0' est renvoyée.
*/
final class ClientIpResolver
{
/**
* @param string[] $trustedProxies
*/
public function __construct(private readonly array $trustedProxies = [])
{
}
public function resolve(ServerRequestInterface $request): string
{
$serverParams = $request->getServerParams();
$remoteAddr = trim((string) ($serverParams['REMOTE_ADDR'] ?? ''));
if ($remoteAddr === '') {
return '0.0.0.0';
}
if (!$this->isTrustedProxy($remoteAddr)) {
return $remoteAddr;
}
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
if ($forwarded === '') {
return $remoteAddr;
}
$candidate = trim(explode(',', $forwarded)[0]);
return filter_var($candidate, FILTER_VALIDATE_IP) ? $candidate : $remoteAddr;
}
private function isTrustedProxy(string $remoteAddr): bool
{
foreach ($this->trustedProxies as $proxy) {
if ($proxy === '*' || $proxy === $remoteAddr) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Shared\Http;
/**
* Service de messages flash.
*
* Gère les messages temporaires stockés en session pour être affichés
* après une redirection HTTP. Un message flash est lu une seule fois
* puis supprimé automatiquement.
*/
final class FlashService implements FlashServiceInterface
{
/**
* Enregistre un message flash en session.
*
* @param string $key Clé d'identification du message (ex: 'login_error')
* @param string $message Texte du message à afficher
* @return void
*/
public function set(string $key, string $message): void
{
$_SESSION['flash'][$key] = $message;
}
/**
* Récupère un message flash et le supprime de la session.
*
* Le cast (string) protège contre une valeur non-string stockée
* directement dans $_SESSION['flash'] (ex: entier ou booléen) sans
* passer par set(), tout en garantissant le type de retour déclaré.
*
* @param string $key Clé d'identification du message
*
* @return string|null Le message, ou null s'il n'existe pas
*/
public function get(string $key): ?string
{
$message = $_SESSION['flash'][$key] ?? null;
unset($_SESSION['flash'][$key]);
return $message !== null ? (string) $message : null;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Shared\Http;
/**
* Contrat du service de messages flash.
*
* Permet de mocker les messages flash dans les tests unitaires
* sans dépendre de la classe concrète finale FlashService.
*/
interface FlashServiceInterface
{
/**
* Enregistre un message flash en session.
*
* @param string $key Clé d'identification du message (ex: 'login_error')
* @param string $message Texte du message à afficher
* @return void
*/
public function set(string $key, string $message): void;
/**
* Récupère un message flash et le supprime de la session.
*
* @param string $key Clé d'identification du message
*
* @return string|null Le message, ou null s'il n'existe pas
*/
public function get(string $key): ?string;
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Shared\Http;
/**
* Gestionnaire de session.
*
* Centralise toutes les manipulations de $_SESSION pour éviter
* les accès directs au superglobal depuis les services métier.
* Utilisé par AuthService pour la gestion de l'authentification.
*/
final class SessionManager implements SessionManagerInterface
{
/**
* Stocke l'identifiant, le nom et le rôle de l'utilisateur connecté en session.
*
* Régénère l'identifiant de session avant d'écrire les données utilisateur
* pour prévenir la fixation de session : un attaquant qui connaîtrait
* l'ID de session anonyme ne peut pas hériter de la session authentifiée.
*
* @param int $userId Identifiant de l'utilisateur
* @param string $username Nom d'utilisateur
* @param string $role Rôle de l'utilisateur : 'user', 'editor' ou 'admin'
* @return void
*/
public function setUser(int $userId, string $username, string $role = 'user'): void
{
// Régénération de l'ID de session pour prévenir la fixation de session.
// Le guard évite une notice PHP en contexte CLI (tests unitaires).
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
$_SESSION['user_id'] = $userId;
$_SESSION['username'] = $username;
$_SESSION['role'] = $role;
}
/**
* Retourne l'identifiant de l'utilisateur connecté.
*
* @return int|null L'identifiant, ou null si aucune session active
*/
public function getUserId(): ?int
{
return isset($_SESSION['user_id']) && $_SESSION['user_id'] !== ''
? (int) $_SESSION['user_id']
: null;
}
/**
* Vérifie si une session utilisateur est active.
*
* @return bool True si un utilisateur est connecté
*/
public function isAuthenticated(): bool
{
return $this->getUserId() !== null;
}
/**
* Vérifie si l'utilisateur connecté est administrateur.
*
* @return bool True si l'utilisateur a le rôle 'admin'
*/
public function isAdmin(): bool
{
return ($_SESSION['role'] ?? '') === 'admin';
}
/**
* Vérifie si l'utilisateur connecté est éditeur.
*
* @return bool True si l'utilisateur a le rôle 'editor'
*/
public function isEditor(): bool
{
return ($_SESSION['role'] ?? '') === 'editor';
}
/**
* Détruit la session courante.
*
* Vide les données, expire le cookie de session (avec les mêmes attributs
* que lors de sa création) et détruit la session PHP.
* L'attribut SameSite=Lax limite l'envoi du cookie aux navigations
* de premier niveau, réduisant l'exposition aux attaques CSRF.
*/
public function destroy(): void
{
$_SESSION = [];
if (session_id() !== '') {
$sessionName = session_name();
if ($sessionName !== false) {
setcookie($sessionName, '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
}
}
if (session_status() === PHP_SESSION_ACTIVE) {
session_destroy();
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Shared\Http;
/**
* Contrat du gestionnaire de session.
*
* Permet de mocker la gestion de session dans les tests unitaires
* sans dépendre de la classe concrète finale SessionManager.
*/
interface SessionManagerInterface
{
/**
* Stocke l'identifiant, le nom et le rôle de l'utilisateur connecté en session.
*
* @param int $userId Identifiant de l'utilisateur
* @param string $username Nom d'utilisateur
* @param string $role Rôle de l'utilisateur : 'user', 'editor' ou 'admin'
* @return void
*/
public function setUser(int $userId, string $username, string $role = 'user'): void;
/**
* Retourne l'identifiant de l'utilisateur connecté.
*
* @return int|null L'identifiant, ou null si aucune session active
*/
public function getUserId(): ?int;
/**
* Vérifie si une session utilisateur est active.
*
* @return bool True si un utilisateur est connecté
*/
public function isAuthenticated(): bool;
/**
* Vérifie si l'utilisateur connecté est administrateur.
*
* @return bool True si l'utilisateur a le rôle 'admin'
*/
public function isAdmin(): bool;
/**
* Vérifie si l'utilisateur connecté est éditeur.
*
* @return bool True si l'utilisateur a le rôle 'editor'
*/
public function isEditor(): bool;
/**
* Détruit la session courante.
*/
public function destroy(): void;
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Shared\Mail;
use PHPMailer\PHPMailer\Exception as MailerException;
use PHPMailer\PHPMailer\PHPMailer;
use Slim\Views\Twig;
/**
* Service d'envoi d'emails.
*
* Wrapper autour de PHPMailer configuré depuis les variables d'environnement.
* Le rendu des templates est délégué à Twig — les templates d'emails
* sont des vues autonomes dans views/emails/ (pas d'héritage de layout.twig).
*/
final class MailService implements MailServiceInterface
{
/**
* @param Twig $twig Instance Twig pour le rendu des templates d'emails
* @param string $host Serveur SMTP
* @param int $port Port SMTP (465 pour SSL, 587 pour TLS)
* @param string $username Identifiant SMTP
* @param string $password Mot de passe SMTP
* @param string $encryption Chiffrement : 'ssl' ou 'tls'
* @param string $from Adresse expéditeur
* @param string $fromName Nom expéditeur
*/
public function __construct(
private readonly Twig $twig,
private readonly string $host,
private readonly int $port,
private readonly string $username,
private readonly string $password,
private readonly string $encryption,
private readonly string $from,
private readonly string $fromName,
) {
}
/**
* Envoie un email HTML à partir d'un template Twig.
*
* Le corps texte brut est généré automatiquement depuis le HTML
* via strip_tags() pour les clients mail qui n'affichent pas le HTML.
*
* @param string $to Adresse email du destinataire
* @param string $subject Sujet de l'email
* @param string $template Chemin du template Twig (ex: 'emails/password-reset.twig')
* @param array<string, mixed> $context Variables transmises au template
*
* @throws \RuntimeException Si l'envoi échoue
* @return void
*/
public function send(string $to, string $subject, string $template, array $context = []): void
{
$html = $this->twig->getEnvironment()->render($template, $context);
$mail = $this->createMailer();
$mail->addAddress($to);
$mail->Subject = $subject;
$mail->Body = $html;
$mail->AltBody = strip_tags($html);
try {
$mail->send();
} catch (MailerException $e) {
throw new \RuntimeException("Échec de l'envoi de l'email : {$e->getMessage()}", 0, $e);
}
}
/**
* Crée et configure une instance PHPMailer prête à l'envoi.
*
* @return PHPMailer L'instance configurée avec les paramètres SMTP injectés
*/
private function createMailer(): PHPMailer
{
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $this->host;
$mail->SMTPAuth = true;
$mail->Username = $this->username;
$mail->Password = $this->password;
$mail->SMTPSecure = $this->encryption === 'ssl'
? PHPMailer::ENCRYPTION_SMTPS
: PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = $this->port;
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->setFrom($this->from, $this->fromName);
return $mail;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Shared\Mail;
/**
* Contrat du service d'envoi d'emails.
*
* Permet de mocker l'envoi d'emails dans les tests unitaires
* sans dépendre de la classe concrète finale MailService.
*/
interface MailServiceInterface
{
/**
* Envoie un email HTML à partir d'un template Twig.
*
* @param string $to Adresse email du destinataire
* @param string $subject Sujet de l'email
* @param string $template Chemin du template Twig (ex: 'emails/password-reset.twig')
* @param array<string, mixed> $context Variables transmises au template
*
* @throws \RuntimeException Si l'envoi échoue
* @return void
*/
public function send(string $to, string $subject, string $template, array $context = []): void;
}

125
src/Shared/Routes.php Normal file
View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Shared;
use App\Auth\AccountController;
use App\Auth\Middleware\AdminMiddleware;
use App\Auth\AuthController;
use App\Auth\Middleware\AuthMiddleware;
use App\Auth\Middleware\EditorMiddleware;
use App\Auth\PasswordResetController;
use App\Category\CategoryController;
use App\Media\MediaController;
use App\Post\PostController;
use App\Post\RssController;
use App\User\UserController;
use Slim\App;
/**
* Déclaration de toutes les routes de l'application.
*
* Organisées en sept groupes :
* - Routes publiques (accueil, détail article)
* - Routes d'authentification (connexion, déconnexion)
* - Routes de réinitialisation de mot de passe (publiques)
* - Routes de compte utilisateur (changement de mot de passe)
* - Routes d'administration articles et médias (tous les utilisateurs connectés)
* - Routes d'administration catégories (éditeurs et administrateurs)
* - Routes d'administration utilisateurs (administrateurs uniquement)
*
* Les handlers utilisent la notation [ClassName::class, 'method'].
* Slim les résout paresseusement depuis le conteneur PHP-DI au moment où
* la route est matchée — aucun contrôleur n'est instancié au démarrage.
*/
final class Routes
{
/**
* Enregistre toutes les routes dans l'application Slim.
*
* @param \Slim\App<\Psr\Container\ContainerInterface> $app L'instance Slim (conteneur PHP-DI enregistré via AppFactory::createFromContainer)
* @return void
*/
public static function register(App $app): void
{
// ------------------------------------------------------------
// Routes publiques
// ------------------------------------------------------------
$app->get('/', [PostController::class, 'index']);
$app->get('/article/{slug}', [PostController::class, 'show']);
$app->get('/rss.xml', [RssController::class, 'feed']);
// ------------------------------------------------------------
// Routes d'authentification
// ------------------------------------------------------------
$app->get('/auth/login', [AuthController::class, 'showLogin']);
$app->post('/auth/login', [AuthController::class, 'login']);
$app->post('/auth/logout', [AuthController::class, 'logout']);
// ------------------------------------------------------------
// Routes de réinitialisation de mot de passe (publiques)
// ------------------------------------------------------------
$app->get('/password/forgot', [PasswordResetController::class, 'showForgot']);
$app->post('/password/forgot', [PasswordResetController::class, 'forgot']);
$app->get('/password/reset', [PasswordResetController::class, 'showReset']);
$app->post('/password/reset', [PasswordResetController::class, 'reset']);
// ------------------------------------------------------------
// Routes de compte (utilisateur connecté)
// ------------------------------------------------------------
$app->group('/account', function ($group) {
$group->get('/password', [AccountController::class, 'showChangePassword']);
$group->post('/password', [AccountController::class, 'changePassword']);
})->add(AuthMiddleware::class);
// ------------------------------------------------------------
// Routes d'administration posts et médias (tout utilisateur connecté)
// ------------------------------------------------------------
$app->group('/admin', function ($group) {
$group->get('', fn ($req, $res) => $res->withHeader('Location', '/admin/posts')->withStatus(302));
// Posts
$group->get('/posts', [PostController::class, 'admin']);
$group->get('/posts/edit/{id}', [PostController::class, 'form']);
$group->post('/posts/create', [PostController::class, 'create']);
$group->post('/posts/edit/{id}', [PostController::class, 'update']);
$group->post('/posts/delete/{id}', [PostController::class, 'delete']);
// Médias — upload Trumbowyg (réponse JSON)
// Attend un champ "image" en multipart/form-data
// Retourne {"success": true, "file": "/media/..."} ou {"error": "..."}
$group->post('/media/upload', [MediaController::class, 'upload']);
// Médias — gestion (liste + suppression)
$group->get('/media', [MediaController::class, 'index']);
$group->post('/media/delete/{id}', [MediaController::class, 'delete']);
})->add(AuthMiddleware::class);
// ------------------------------------------------------------
// Routes d'administration catégories (éditeurs et admins)
// ------------------------------------------------------------
$app->group('/admin/categories', function ($group) {
$group->get('', [CategoryController::class, 'index']);
$group->post('/create', [CategoryController::class, 'create']);
$group->post('/delete/{id}', [CategoryController::class, 'delete']);
})->add(EditorMiddleware::class)->add(AuthMiddleware::class);
// ------------------------------------------------------------
// Routes d'administration utilisateurs (admin uniquement)
// ------------------------------------------------------------
$app->group('/admin/users', function ($group) {
$group->get('', [UserController::class, 'index']);
$group->get('/create', [UserController::class, 'showCreate']);
$group->post('/create', [UserController::class, 'create']);
$group->post('/role/{id}', [UserController::class, 'updateRole']);
$group->post('/delete/{id}', [UserController::class, 'delete']);
})->add(AdminMiddleware::class)->add(AuthMiddleware::class);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Shared\Util;
use DateTime;
/**
* Utilitaire de conversion de dates issues de la base de données.
*
* Centralise la logique partagée par les modèles Post, User et Media
* pour convertir une valeur brute en DateTime de façon silencieuse :
* une valeur absente ou malformée retourne null plutôt que de propager
* une exception non gérée qui ferait crasher la page entière.
*/
final class DateParser
{
/**
* Convertit une valeur de date issue de la base de données en DateTime.
*
* @param mixed $value Valeur brute issue de la base de données
*
* @return DateTime|null L'instance DateTime, ou null si la valeur est absente ou invalide
*/
public static function parse(mixed $value): ?DateTime
{
if ($value === null || $value === '') {
return null;
}
try {
return new DateTime((string) $value);
} catch (\Exception) {
return null;
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Shared\Util;
/**
* Utilitaire de génération de slug URL-friendly.
*
* Centralise la logique de translittération partagée par les domaines
* Post et Category pour éviter la duplication de code.
*
* Algorithme :
* 1. Translittération ASCII//TRANSLIT//IGNORE — convertit les caractères
* accentués en leur équivalent ASCII avant le nettoyage :
* "Été en forêt" → "ete-en-foret"
* "Ça & Là !" → "ca-la"
* Le flag IGNORE supprime silencieusement les caractères sans équivalent
* ASCII (CJK, etc.) plutôt que de laisser iconv retourner false.
* 2. Passage en minuscules
* 3. Remplacement de toute séquence de caractères non alphanumériques par un tiret
* 4. Suppression des tirets en début et fin
*/
final class SlugHelper
{
/**
* Génère un slug URL-friendly depuis une chaîne quelconque.
*
* @param string $input La chaîne source (titre, nom, etc.)
*
* @return string Le slug en minuscules avec tirets (ex: "ete-en-foret"),
* ou '' si la chaîne ne contient aucun caractère exploitable
*/
public static function generate(string $input): string
{
$slug = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $input);
// iconv peut retourner false sur certaines plateformes — repli sur l'entrée brute
if ($slug === false) {
$slug = $input;
}
$slug = mb_strtolower($slug);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
return trim($slug, '-');
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\User\Exception;
/**
* Exception levée lorsqu'une adresse e-mail est déjà utilisée.
*
* Permet aux appelants de distinguer cette erreur métier précise
* d'une InvalidArgumentException générique sans analyser le message.
*/
final class DuplicateEmailException extends \InvalidArgumentException
{
public function __construct(string $email)
{
parent::__construct("Cette adresse e-mail est déjà utilisée : {$email}");
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\User\Exception;
/**
* Exception levée lorsqu'un nom d'utilisateur est déjà pris.
*
* Permet aux appelants de distinguer cette erreur métier précise
* d'une InvalidArgumentException générique sans analyser le message.
*/
final class DuplicateUsernameException extends \InvalidArgumentException
{
public function __construct(string $username)
{
parent::__construct("Ce nom d'utilisateur est déjà pris : {$username}");
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\User\Exception;
use App\User\User;
/**
* Exception levée lorsqu'un rôle utilisateur non autorisé est demandé.
*/
final class InvalidRoleException extends \InvalidArgumentException
{
public function __construct(string $role)
{
$validRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN];
parent::__construct(
"Le rôle '{$role}' est invalide. Valeurs autorisées : " . implode(', ', $validRoles)
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\User\Exception;
/**
* Exception levée lorsqu'un mot de passe ne respecte pas les règles de complexité.
*
* Permet aux appelants de distinguer cette erreur métier précise
* d'une InvalidArgumentException générique sans analyser le message.
*/
final class WeakPasswordException extends \InvalidArgumentException
{
/**
* @param int $minLength Longueur minimale requise
*/
public function __construct(int $minLength = 8)
{
parent::__construct("Le mot de passe doit contenir au moins {$minLength} caractères");
}
}

191
src/User/User.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\User;
use App\Shared\Util\DateParser;
use DateTime;
/**
* Modèle représentant un utilisateur de l'application.
*
* Encapsule les données et la validation d'un compte utilisateur.
* Ce modèle est immuable : toutes les propriétés sont en lecture seule
* après construction.
*/
final class User
{
/**
* @var DateTime Date de création — toujours non nulle après construction
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
*/
private readonly DateTime $createdAt;
/**
* Rôles valides pour un utilisateur.
*/
public const ROLE_USER = 'user';
public const ROLE_EDITOR = 'editor';
public const ROLE_ADMIN = 'admin';
/**
* @param int $id Identifiant en base (0 pour un nouvel utilisateur)
* @param string $username Nom d'utilisateur (normalisé en minuscules)
* @param string $email Adresse e-mail (normalisée en minuscules)
* @param string $passwordHash Hash bcrypt du mot de passe
* @param string $role Rôle de l'utilisateur : 'user', 'editor' ou 'admin'
* @param DateTime|null $createdAt Date de création (défaut : maintenant)
*
* @throws \InvalidArgumentException Si les données ne passent pas la validation
*/
public function __construct(
private readonly int $id,
private readonly string $username,
private readonly string $email,
private readonly string $passwordHash,
private readonly string $role = self::ROLE_USER,
?DateTime $createdAt = null,
) {
$this->createdAt = $createdAt ?? new DateTime();
$this->validate();
}
/**
* Crée une instance depuis un tableau associatif (ligne de base de données).
*
* @param array<string, mixed> $data Données issues de la base de données
*
* @return self L'instance hydratée
*/
public static function fromArray(array $data): self
{
return new self(
id: (int) ($data['id'] ?? 0),
username: (string) ($data['username'] ?? ''),
email: (string) ($data['email'] ?? ''),
passwordHash: (string) ($data['password_hash'] ?? ''),
role: (string) ($data['role'] ?? self::ROLE_USER),
createdAt: DateParser::parse($data['created_at'] ?? null),
);
}
/**
* Retourne l'identifiant de l'utilisateur.
*
* @return int L'identifiant en base (0 si non encore persisté)
*/
public function getId(): int
{
return $this->id;
}
/**
* Retourne le nom d'utilisateur.
*
* @return string Le nom d'utilisateur normalisé en minuscules
*/
public function getUsername(): string
{
return $this->username;
}
/**
* Retourne l'adresse e-mail.
*
* @return string L'adresse e-mail normalisée en minuscules
*/
public function getEmail(): string
{
return $this->email;
}
/**
* Retourne le hash bcrypt du mot de passe.
*
* @return string Le hash bcrypt
*/
public function getPasswordHash(): string
{
return $this->passwordHash;
}
/**
* Retourne le rôle de l'utilisateur.
*
* @return string 'user', 'editor' ou 'admin'
*/
public function getRole(): string
{
return $this->role;
}
/**
* Indique si l'utilisateur a le rôle administrateur.
*
* @return bool True si l'utilisateur est administrateur
*/
public function isAdmin(): bool
{
return $this->role === self::ROLE_ADMIN;
}
/**
* Indique si l'utilisateur a le rôle éditeur.
*
* @return bool True si l'utilisateur est éditeur
*/
public function isEditor(): bool
{
return $this->role === self::ROLE_EDITOR;
}
/**
* Retourne la date de création du compte.
*
* @return DateTime La date de création
*/
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
/**
* Valide les données de l'utilisateur.
*
* @throws \InvalidArgumentException Si le nom d'utilisateur fait moins de 3 ou plus de 50 caractères
* @throws \InvalidArgumentException Si l'adresse e-mail est invalide ou vide
* @throws \InvalidArgumentException Si le hash du mot de passe est vide
* @throws \InvalidArgumentException Si le rôle n'est pas une valeur autorisée
*/
private function validate(): void
{
if (mb_strlen($this->username) < 3) {
throw new \InvalidArgumentException(
"Le nom d'utilisateur doit contenir au moins 3 caractères"
);
}
if (mb_strlen($this->username) > 50) {
throw new \InvalidArgumentException(
"Le nom d'utilisateur ne peut pas dépasser 50 caractères"
);
}
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("L'email n'est pas valide");
}
if ($this->passwordHash === '') {
throw new \InvalidArgumentException(
'Le hash du mot de passe ne peut pas être vide'
);
}
$validRoles = [self::ROLE_USER, self::ROLE_EDITOR, self::ROLE_ADMIN];
if (!in_array($this->role, $validRoles, true)) {
throw new \InvalidArgumentException(
"Le rôle '{$this->role}' est invalide. Valeurs autorisées : " . implode(', ', $validRoles)
);
}
}
}

234
src/User/UserController.php Normal file
View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace App\User;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
/**
* Contrôleur pour la gestion des utilisateurs en administration.
*
* Accessible uniquement aux administrateurs (AdminMiddleware).
* Gère la liste, la création, la modification de rôle et la suppression des comptes.
* Toute la logique de persistance est déléguée à UserService.
*
* Règles de protection communes :
* - Le compte administrateur (role = 'admin') ne peut pas être supprimé ni rétrogradé
* - Un administrateur ne peut pas supprimer son propre compte ni changer son propre rôle
*/
final class UserController
{
/**
* @param Twig $view Moteur de templates Twig
* @param UserServiceInterface $userService Service de gestion des utilisateurs
* @param FlashServiceInterface $flash Service de messages flash
* @param SessionManagerInterface $sessionManager Gestionnaire de session
*/
public function __construct(
private readonly Twig $view,
private readonly UserServiceInterface $userService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
) {
}
/**
* Affiche la liste de tous les utilisateurs.
*
* Passe l'identifiant de l'utilisateur courant à la vue
* pour conditionner l'affichage du bouton de suppression.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue admin/users/index.twig
*/
public function index(Request $req, Response $res): Response
{
return $this->view->render($res, 'admin/users/index.twig', [
'users' => $this->userService->findAll(),
'currentUserId' => $this->sessionManager->getUserId(),
'error' => $this->flash->get('user_error'),
'success' => $this->flash->get('user_success'),
]);
}
/**
* Affiche le formulaire de création d'un utilisateur.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue admin/users/form.twig
*/
public function showCreate(Request $req, Response $res): Response
{
return $this->view->render($res, 'admin/users/form.twig', [
'error' => $this->flash->get('user_error'),
]);
}
/**
* Traite la soumission du formulaire de création d'utilisateur.
*
* Vérifie que les mots de passe correspondent avant de déléguer
* la création à UserService. En cas d'erreur, redirige vers le
* formulaire avec un message flash.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Redirection vers /admin/users en cas de succès,
* ou vers /admin/users/create en cas d'erreur
*/
public function create(Request $req, Response $res): Response
{
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$username = trim((string) ($data['username'] ?? ''));
$email = trim((string) ($data['email'] ?? ''));
$password = trim((string) ($data['password'] ?? ''));
$confirm = trim((string) ($data['password_confirm'] ?? ''));
// Restreindre les rôles assignables depuis le formulaire.
// Le rôle 'admin' est exclu : il ne peut être attribué que directement
// en base de données, pour éviter qu'un admin ne crée d'autres admins
// en manipulant la requête HTTP.
$allowedRoles = [User::ROLE_USER, User::ROLE_EDITOR];
$rawRole = trim((string) ($data['role'] ?? ''));
$role = in_array($rawRole, $allowedRoles, true)
? $rawRole
: User::ROLE_USER;
if ($password !== $confirm) {
$this->flash->set('user_error', 'Les mots de passe ne correspondent pas');
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
}
try {
$this->userService->createUser($username, $email, $password, $role);
$this->flash->set('user_success', "L'utilisateur « {$username} » a été créé avec succès");
return $res->withHeader('Location', '/admin/users')->withStatus(302);
} catch (DuplicateUsernameException) {
$this->flash->set('user_error', "Ce nom d'utilisateur est déjà pris");
} catch (DuplicateEmailException) {
$this->flash->set('user_error', 'Cette adresse e-mail est déjà utilisée');
} catch (WeakPasswordException) {
$this->flash->set('user_error', 'Le mot de passe doit contenir au moins 8 caractères');
} catch (InvalidRoleException $e) {
$this->flash->set('user_error', $e->getMessage());
} catch (\Throwable) {
$this->flash->set('user_error', "Une erreur inattendue s'est produite");
}
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
}
/**
* Met à jour le rôle d'un utilisateur.
*
* La modification est refusée dans trois cas :
* - l'utilisateur cible est introuvable
* - l'administrateur connecté tente de modifier son propre rôle
* - l'utilisateur cible est déjà administrateur
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Redirection vers /admin/users dans tous les cas
*/
public function updateRole(Request $req, Response $res, array $args): Response
{
$id = (int) $args['id'];
$user = $this->userService->findById($id);
if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($user->isAdmin()) {
$this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$allowedRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN];
/** @var array<string, mixed> $body */
$body = (array) $req->getParsedBody();
$rawRole = trim((string) ($body['role'] ?? ''));
$role = in_array($rawRole, $allowedRoles, true) ? $rawRole : null;
if ($role === null) {
$this->flash->set('user_error', 'Rôle invalide');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$this->userService->updateRole($id, $role);
$this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour");
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
/**
* Supprime un utilisateur.
*
* La suppression est refusée dans trois cas :
* - l'utilisateur cible est introuvable
* - l'utilisateur cible est administrateur (role = 'admin')
* - l'administrateur connecté tente de supprimer son propre compte
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Redirection vers /admin/users dans tous les cas
*/
public function delete(Request $req, Response $res, array $args): Response
{
$id = (int) $args['id'];
$user = $this->userService->findById($id);
if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($user->isAdmin()) {
$this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$this->userService->delete($id);
$this->flash->set('user_success', "L'utilisateur « {$user->getUsername()} » a été supprimé avec succès");
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
}

150
src/User/UserRepository.php Normal file
View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\User;
use PDO;
/**
* Dépôt pour la persistance des utilisateurs.
*
* Responsabilité unique : exécuter les requêtes SQL liées à la table `users`
* et retourner des instances de User hydratées.
*/
final class UserRepository implements UserRepositoryInterface
{
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db)
{
}
/**
* Retourne tous les utilisateurs triés par date de création.
*
* @return User[] La liste des utilisateurs
*/
public function findAll(): array
{
$stmt = $this->db->query('SELECT * FROM users ORDER BY created_at ASC');
if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur users a échoué.');
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => User::fromArray($row), $rows);
}
/**
* Trouve un utilisateur par son identifiant.
*
* @param int $id Identifiant de l'utilisateur
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
/**
* Trouve un utilisateur par son nom d'utilisateur (insensible à la casse).
*
* @param string $username Nom d'utilisateur normalisé en minuscules
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findByUsername(string $username): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute([':username' => $username]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
/**
* Trouve un utilisateur par son adresse e-mail (insensible à la casse).
*
* @param string $email Adresse e-mail normalisée en minuscules
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findByEmail(string $email): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
/**
* Persiste un nouvel utilisateur en base de données.
*
* @param User $user L'utilisateur à créer
*
* @return int L'identifiant généré par la base de données
*/
public function create(User $user): int
{
$stmt = $this->db->prepare('
INSERT INTO users (username, email, password_hash, role, created_at)
VALUES (:username, :email, :password_hash, :role, :created_at)
');
$stmt->execute([
':username' => $user->getUsername(),
':email' => $user->getEmail(),
':password_hash' => $user->getPasswordHash(),
':role' => $user->getRole(),
':created_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->db->lastInsertId();
}
/**
* Met à jour le hash du mot de passe d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $newHash Nouveau hash bcrypt
*/
public function updatePassword(int $id, string $newHash): void
{
$stmt = $this->db->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id');
$stmt->execute([':password_hash' => $newHash, ':id' => $id]);
}
/**
* Met à jour le rôle d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $role Nouveau rôle : 'user', 'editor' ou 'admin'
*/
public function updateRole(int $id, string $role): void
{
$stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id');
$stmt->execute([':role' => $role, ':id' => $id]);
}
/**
* Supprime un utilisateur de la base de données.
*
* @param int $id Identifiant de l'utilisateur à supprimer
* @return void
*/
public function delete(int $id): void
{
$stmt = $this->db->prepare('DELETE FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\User;
/**
* Contrat de persistance des utilisateurs.
*
* Découple les services métier de l'implémentation concrète (PDO/SQLite),
* facilitant les mocks dans les tests et un éventuel changement de stockage.
*/
interface UserRepositoryInterface
{
/**
* Retourne tous les utilisateurs triés par date de création.
*
* @return User[]
*/
public function findAll(): array;
/**
* Trouve un utilisateur par son identifiant.
*
* @param int $id Identifiant de l'utilisateur
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?User;
/**
* Trouve un utilisateur par son nom d'utilisateur.
*
* @param string $username Nom d'utilisateur normalisé en minuscules
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findByUsername(string $username): ?User;
/**
* Trouve un utilisateur par son adresse e-mail.
*
* @param string $email Adresse e-mail normalisée en minuscules
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findByEmail(string $email): ?User;
/**
* Persiste un nouvel utilisateur en base de données.
*
* @param User $user L'utilisateur à créer
*
* @return int L'identifiant généré par la base de données
*/
public function create(User $user): int;
/**
* Met à jour le hash du mot de passe d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $newHash Nouveau hash bcrypt
*/
public function updatePassword(int $id, string $newHash): void;
/**
* Met à jour le rôle d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $role Nouveau rôle : 'user', 'editor' ou 'admin'
*/
public function updateRole(int $id, string $role): void;
/**
* Supprime un utilisateur de la base de données.
*
* @param int $id Identifiant de l'utilisateur à supprimer
* @return void
*/
public function delete(int $id): void;
}

89
src/User/UserService.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\User;
use App\Shared\Exception\NotFoundException;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException;
final class UserService implements UserServiceInterface
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
) {
}
public function findAll(): array
{
return $this->userRepository->findAll();
}
public function findById(int $id): ?User
{
return $this->userRepository->findById($id);
}
public function delete(int $id): void
{
$this->requireExistingUser($id);
$this->userRepository->delete($id);
}
public function updateRole(int $id, string $role): void
{
$this->assertValidRole($role);
$this->requireExistingUser($id);
$this->userRepository->updateRole($id, $role);
}
public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User
{
$username = mb_strtolower(trim($username));
$email = mb_strtolower(trim($email));
$plainPassword = trim($plainPassword);
$this->assertValidRole($role);
if ($this->userRepository->findByUsername($username)) {
throw new DuplicateUsernameException($username);
}
if ($this->userRepository->findByEmail($email)) {
throw new DuplicateEmailException($email);
}
if (mb_strlen($plainPassword) < 8) {
throw new WeakPasswordException();
}
$passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]);
$user = new User(0, $username, $email, $passwordHash, $role);
$this->userRepository->create($user);
return $user;
}
private function assertValidRole(string $role): void
{
$validRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN];
if (!in_array($role, $validRoles, true)) {
throw new InvalidRoleException($role);
}
}
private function requireExistingUser(int $id): User
{
$user = $this->userRepository->findById($id);
if ($user === null) {
throw new NotFoundException('Utilisateur', $id);
}
return $user;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\User;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\WeakPasswordException;
/**
* Contrat du service de gestion des utilisateurs.
*
* Permet de mocker le service dans les tests unitaires sans dépendre
* de la classe concrète finale UserService.
*/
interface UserServiceInterface
{
/**
* Retourne tous les utilisateurs triés par date de création (ordre croissant).
*
* @return User[]
*/
public function findAll(): array;
/**
* Trouve un utilisateur par son identifiant.
*
* @param int $id Identifiant de l'utilisateur
*
* @return User|null L'utilisateur trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?User;
/**
* Supprime un utilisateur de la base de données.
*
* @param int $id Identifiant de l'utilisateur à supprimer
*/
public function delete(int $id): void;
/**
* Crée un nouveau compte utilisateur.
*
* @param string $username Nom d'utilisateur souhaité (min. 3 caractères)
* @param string $email Adresse e-mail valide
* @param string $plainPassword Mot de passe en clair (min. 8 caractères)
* @param string $role Rôle attribué : 'user', 'editor' ou 'admin' (défaut : 'user')
*
* @return User L'utilisateur créé (sans mot de passe en clair)
*
* @throws DuplicateUsernameException Si le nom d'utilisateur est déjà pris
* @throws DuplicateEmailException Si l'adresse e-mail est déjà utilisée
* @throws WeakPasswordException Si le mot de passe est trop court
* @throws InvalidRoleException Si le rôle est invalide
* @throws \InvalidArgumentException Si le nom ou l'email ne passent pas la validation
*/
public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User;
/**
* Met à jour le rôle d'un utilisateur.
*
* @param int $id Identifiant de l'utilisateur
* @param string $role Nouveau rôle : 'user', 'editor' ou 'admin'
*
* @throws InvalidRoleException Si le rôle est invalide
*/
public function updateRole(int $id, string $role): void;
}