first commit
This commit is contained in:
114
src/Auth/AccountController.php
Normal file
114
src/Auth/AccountController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
75
src/Auth/AuthController.php
Normal file
75
src/Auth/AuthController.php
Normal 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
193
src/Auth/AuthService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
84
src/Auth/AuthServiceInterface.php
Normal file
84
src/Auth/AuthServiceInterface.php
Normal 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;
|
||||
}
|
||||
16
src/Auth/Exception/InvalidResetTokenException.php
Normal file
16
src/Auth/Exception/InvalidResetTokenException.php
Normal 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é.');
|
||||
}
|
||||
}
|
||||
116
src/Auth/LoginAttemptRepository.php
Normal file
116
src/Auth/LoginAttemptRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
44
src/Auth/LoginAttemptRepositoryInterface.php
Normal file
44
src/Auth/LoginAttemptRepositoryInterface.php
Normal 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;
|
||||
}
|
||||
50
src/Auth/Middleware/AdminMiddleware.php
Normal file
50
src/Auth/Middleware/AdminMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
47
src/Auth/Middleware/AuthMiddleware.php
Normal file
47
src/Auth/Middleware/AuthMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
src/Auth/Middleware/EditorMiddleware.php
Normal file
50
src/Auth/Middleware/EditorMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
218
src/Auth/PasswordResetController.php
Normal file
218
src/Auth/PasswordResetController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
74
src/Auth/PasswordResetRepository.php
Normal file
74
src/Auth/PasswordResetRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
21
src/Auth/PasswordResetRepositoryInterface.php
Normal file
21
src/Auth/PasswordResetRepositoryInterface.php
Normal 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;
|
||||
}
|
||||
105
src/Auth/PasswordResetService.php
Normal file
105
src/Auth/PasswordResetService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/Auth/PasswordResetServiceInterface.php
Normal file
56
src/Auth/PasswordResetServiceInterface.php
Normal 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
91
src/Category/Category.php
Normal 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 (1–100 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/Category/CategoryController.php
Normal file
114
src/Category/CategoryController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
134
src/Category/CategoryRepository.php
Normal file
134
src/Category/CategoryRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
74
src/Category/CategoryRepositoryInterface.php
Normal file
74
src/Category/CategoryRepositoryInterface.php
Normal 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;
|
||||
}
|
||||
120
src/Category/CategoryService.php
Normal file
120
src/Category/CategoryService.php
Normal 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());
|
||||
}
|
||||
}
|
||||
64
src/Category/CategoryServiceInterface.php
Normal file
64
src/Category/CategoryServiceInterface.php
Normal 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;
|
||||
}
|
||||
16
src/Media/Exception/FileTooLargeException.php
Normal file
16
src/Media/Exception/FileTooLargeException.php
Normal 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)");
|
||||
}
|
||||
}
|
||||
15
src/Media/Exception/InvalidMimeTypeException.php
Normal file
15
src/Media/Exception/InvalidMimeTypeException.php
Normal 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)");
|
||||
}
|
||||
}
|
||||
12
src/Media/Exception/StorageException.php
Normal file
12
src/Media/Exception/StorageException.php
Normal 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
130
src/Media/Media.php
Normal 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;
|
||||
}
|
||||
}
|
||||
180
src/Media/MediaController.php
Normal file
180
src/Media/MediaController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
139
src/Media/MediaRepository.php
Normal file
139
src/Media/MediaRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
65
src/Media/MediaRepositoryInterface.php
Normal file
65
src/Media/MediaRepositoryInterface.php
Normal 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
228
src/Media/MediaService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/Media/MediaServiceInterface.php
Normal file
62
src/Media/MediaServiceInterface.php
Normal 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
252
src/Post/Post.php
Normal 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 (1–255 caractères)
|
||||
* @param string $content Contenu HTML de l'article (1–65 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
379
src/Post/PostController.php
Normal 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
205
src/Post/PostExtension.php
Normal 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 (1–2 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
334
src/Post/PostRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
113
src/Post/PostRepositoryInterface.php
Normal file
113
src/Post/PostRepositoryInterface.php
Normal 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
252
src/Post/PostService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
119
src/Post/PostServiceInterface.php
Normal file
119
src/Post/PostServiceInterface.php
Normal 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;
|
||||
}
|
||||
94
src/Post/RssController.php
Normal file
94
src/Post/RssController.php
Normal 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
213
src/Shared/Bootstrap.php
Normal 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
61
src/Shared/Config.php
Normal 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;
|
||||
}
|
||||
}
|
||||
137
src/Shared/Database/Migrator.php
Normal file
137
src/Shared/Database/Migrator.php
Normal 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)
|
||||
");
|
||||
}
|
||||
}
|
||||
37
src/Shared/Database/Provisioner.php
Normal file
37
src/Shared/Database/Provisioner.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/Shared/Database/Seeder.php
Normal file
71
src/Shared/Database/Seeder.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
22
src/Shared/Exception/NotFoundException.php
Normal file
22
src/Shared/Exception/NotFoundException.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
36
src/Shared/Extension/AppExtension.php
Normal file
36
src/Shared/Extension/AppExtension.php
Normal 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];
|
||||
}
|
||||
}
|
||||
47
src/Shared/Extension/CsrfExtension.php
Normal file
47
src/Shared/Extension/CsrfExtension.php
Normal 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(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
42
src/Shared/Extension/SessionExtension.php
Normal file
42
src/Shared/Extension/SessionExtension.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
66
src/Shared/Html/HtmlPurifierFactory.php
Normal file
66
src/Shared/Html/HtmlPurifierFactory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
src/Shared/Html/HtmlSanitizer.php
Normal file
34
src/Shared/Html/HtmlSanitizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
src/Shared/Html/HtmlSanitizerInterface.php
Normal file
19
src/Shared/Html/HtmlSanitizerInterface.php
Normal 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;
|
||||
}
|
||||
58
src/Shared/Http/ClientIpResolver.php
Normal file
58
src/Shared/Http/ClientIpResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
45
src/Shared/Http/FlashService.php
Normal file
45
src/Shared/Http/FlashService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/Shared/Http/FlashServiceInterface.php
Normal file
31
src/Shared/Http/FlashServiceInterface.php
Normal 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;
|
||||
}
|
||||
112
src/Shared/Http/SessionManager.php
Normal file
112
src/Shared/Http/SessionManager.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/Shared/Http/SessionManagerInterface.php
Normal file
56
src/Shared/Http/SessionManagerInterface.php
Normal 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;
|
||||
}
|
||||
95
src/Shared/Mail/MailService.php
Normal file
95
src/Shared/Mail/MailService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/Shared/Mail/MailServiceInterface.php
Normal file
26
src/Shared/Mail/MailServiceInterface.php
Normal 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
125
src/Shared/Routes.php
Normal 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);
|
||||
}
|
||||
}
|
||||
37
src/Shared/Util/DateParser.php
Normal file
37
src/Shared/Util/DateParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/Shared/Util/SlugHelper.php
Normal file
47
src/Shared/Util/SlugHelper.php
Normal 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, '-');
|
||||
}
|
||||
}
|
||||
18
src/User/Exception/DuplicateEmailException.php
Normal file
18
src/User/Exception/DuplicateEmailException.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
18
src/User/Exception/DuplicateUsernameException.php
Normal file
18
src/User/Exception/DuplicateUsernameException.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
21
src/User/Exception/InvalidRoleException.php
Normal file
21
src/User/Exception/InvalidRoleException.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
21
src/User/Exception/WeakPasswordException.php
Normal file
21
src/User/Exception/WeakPasswordException.php
Normal 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
191
src/User/User.php
Normal 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
234
src/User/UserController.php
Normal 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
150
src/User/UserRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
80
src/User/UserRepositoryInterface.php
Normal file
80
src/User/UserRepositoryInterface.php
Normal 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
89
src/User/UserService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/User/UserServiceInterface.php
Normal file
69
src/User/UserServiceInterface.php
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user