Working state
This commit is contained in:
@@ -49,3 +49,5 @@ MAIL_FROM_NAME="Slim Blog"
|
||||
|
||||
# Taille maximale en octets (doit être < upload_max_filesize dans docker/php/php.ini en production)
|
||||
UPLOAD_MAX_SIZE=5242880
|
||||
|
||||
SESSION_NAME=slim_blog_session
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,7 +24,6 @@ public/assets/
|
||||
database/*.sqlite
|
||||
database/*.sqlite-shm
|
||||
database/*.sqlite-wal
|
||||
database/.provision.lock
|
||||
|
||||
# ============================================
|
||||
# Cache & Logs
|
||||
|
||||
@@ -183,3 +183,5 @@ Le provisionnement (migrations + seed admin) s'execute explicitement via `php bi
|
||||
- Docker / production : executer `docker compose exec app php bin/provision.php` apres le demarrage du conteneur
|
||||
|
||||
Le runtime HTTP ne provisionne plus automatiquement la base. Si le schema n'est pas present, l'application echoue avec un message explicite demandant d'executer la commande de provisionnement.
|
||||
|
||||
Pour repartir d'un schema frais en developpement apres un nettoyage de l'historique des migrations, supprimez d'abord la base SQLite locale puis relancez le provisionnement : `rm -f database/app.sqlite` (ou votre fichier SQLite configure), puis `php bin/provision.php`.
|
||||
|
||||
@@ -144,9 +144,10 @@ return [
|
||||
}),
|
||||
|
||||
MediaServiceInterface::class => factory(
|
||||
function (MediaRepositoryInterface $mediaRepository): MediaServiceInterface {
|
||||
function (MediaRepositoryInterface $mediaRepository, PostRepositoryInterface $postRepository): MediaServiceInterface {
|
||||
return new MediaService(
|
||||
mediaRepository: $mediaRepository,
|
||||
postRepository: $postRepository,
|
||||
uploadDir: dirname(__DIR__) . '/public/media',
|
||||
uploadUrl: '/media',
|
||||
maxSize: (int) ($_ENV['UPLOAD_MAX_SIZE'] ?? 5 * 1024 * 1024),
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Migration 004 — Création de la table des médias uploadés.
|
||||
*
|
||||
* La colonne hash (SHA-256) est unique et permet la détection des doublons à l'upload.
|
||||
* user_id est nullable : SET NULL si le compte auteur est supprimé.
|
||||
*
|
||||
* Index explicite sur user_id : SQLite n'indexe pas automatiquement les FK.
|
||||
* Utilisé par findByUserId() dans la galerie média.
|
||||
*/
|
||||
return [
|
||||
'up' => "
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
hash TEXT NOT NULL,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id);
|
||||
",
|
||||
|
||||
'down' => "
|
||||
DROP INDEX IF EXISTS idx_media_hash_user_id;
|
||||
DROP INDEX IF EXISTS idx_media_user_id;
|
||||
DROP TABLE IF EXISTS media;
|
||||
",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||
displayDetailsOnPhpunitDeprecations="true"
|
||||
displayDetailsOnPhpunitNotices="true"
|
||||
displayDetailsOnTestsThatTriggerErrors="true"
|
||||
displayDetailsOnTestsThatTriggerNotices="true"
|
||||
displayDetailsOnTestsThatTriggerWarnings="true">
|
||||
|
||||
@@ -10,11 +10,20 @@ $bootstrap = Bootstrap::create();
|
||||
$bootstrap->initializeInfrastructure();
|
||||
|
||||
$trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER);
|
||||
$sessionName = trim((string) ($_ENV['SESSION_NAME'] ?? 'slim_blog_session')) ?: 'slim_blog_session';
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
session_name($sessionName);
|
||||
|
||||
session_start([
|
||||
'cookie_secure' => RequestContext::isHttps($_SERVER, $trustedProxies),
|
||||
'cookie_httponly' => true,
|
||||
'cookie_samesite' => 'Lax',
|
||||
'cookie_lifetime' => 0,
|
||||
'use_strict_mode' => 1,
|
||||
]);
|
||||
|
||||
$app = $bootstrap->createHttpApp();
|
||||
|
||||
@@ -4,25 +4,15 @@ declare(strict_types=1);
|
||||
namespace App\Category;
|
||||
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Pagination\PaginationPresenter;
|
||||
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
|
||||
*/
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly CategoryServiceInterface $categoryService,
|
||||
@@ -30,37 +20,21 @@ final class CategoryController
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$page = PaginationPresenter::resolvePage($req->getQueryParams());
|
||||
$paginated = $this->categoryService->findPaginated($page, self::PER_PAGE);
|
||||
|
||||
return $this->view->render($res, 'admin/categories/index.twig', [
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'categories' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'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'] ?? '');
|
||||
|
||||
@@ -78,16 +52,7 @@ final class CategoryController
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function delete(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
@@ -96,7 +61,6 @@ final class CategoryController
|
||||
|
||||
if ($category === null) {
|
||||
$this->flash->set('category_error', 'Catégorie introuvable');
|
||||
|
||||
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,78 +5,60 @@ 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);
|
||||
return array_map(fn ($row) => Category::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findPage(int $limit, int $offset): array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM categories ORDER BY name ASC LIMIT :limit OFFSET :offset');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(fn ($row) => Category::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countAll(): int
|
||||
{
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM categories');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête COUNT sur categories a échoué.');
|
||||
}
|
||||
|
||||
return (int) ($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)');
|
||||
@@ -85,13 +67,6 @@ final class CategoryRepository implements CategoryRepositoryInterface
|
||||
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');
|
||||
@@ -100,13 +75,6 @@ final class CategoryRepository implements CategoryRepositoryInterface
|
||||
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');
|
||||
@@ -115,15 +83,6 @@ final class CategoryRepository implements CategoryRepositoryInterface
|
||||
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');
|
||||
|
||||
@@ -3,72 +3,25 @@ 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[]
|
||||
*/
|
||||
/** @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
|
||||
*/
|
||||
/** @return Category[] */
|
||||
public function findPage(int $limit, int $offset): array;
|
||||
|
||||
public function countAll(): int;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,82 +3,48 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
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
|
||||
* @return PaginatedResult<Category>
|
||||
*/
|
||||
public function findPaginated(int $page, int $perPage): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->categoryRepository->countAll();
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->categoryRepository->findPage($perPage, $offset),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -92,21 +58,9 @@ final class CategoryService implements CategoryServiceInterface
|
||||
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())) {
|
||||
|
||||
@@ -3,62 +3,23 @@ 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.
|
||||
*/
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
|
||||
interface CategoryServiceInterface
|
||||
{
|
||||
/**
|
||||
* Retourne toutes les catégories triées alphabétiquement.
|
||||
*
|
||||
* @return Category[]
|
||||
*/
|
||||
/** @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
|
||||
* @return PaginatedResult<Category>
|
||||
*/
|
||||
public function findPaginated(int $page, int $perPage): PaginatedResult;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,37 +6,17 @@ 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 App\Shared\Pagination\PaginationPresenter;
|
||||
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
|
||||
*/
|
||||
private const PER_PAGE = 12;
|
||||
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly MediaServiceInterface $mediaService,
|
||||
@@ -45,45 +25,30 @@ final class MediaController
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
$page = PaginationPresenter::resolvePage($req->getQueryParams());
|
||||
|
||||
$media = $isAdmin
|
||||
? $this->mediaService->findAll()
|
||||
: $this->mediaService->findByUserId((int) $userId);
|
||||
$paginated = $isAdmin
|
||||
? $this->mediaService->findPaginated($page, self::PER_PAGE)
|
||||
: $this->mediaService->findByUserIdPaginated((int) $userId, $page, self::PER_PAGE);
|
||||
|
||||
$usageByMediaId = [];
|
||||
foreach ($paginated->getItems() as $item) {
|
||||
$usageByMediaId[$item->getId()] = $this->mediaService->getUsageSummary($item, 5);
|
||||
}
|
||||
|
||||
return $this->view->render($res, 'admin/media/index.twig', [
|
||||
'media' => $media,
|
||||
'media' => $paginated->getItems(),
|
||||
'mediaUsage' => $usageByMediaId,
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'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();
|
||||
@@ -107,16 +72,7 @@ final class MediaController
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function delete(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
@@ -125,7 +81,6 @@ final class MediaController
|
||||
|
||||
if ($media === null) {
|
||||
$this->flash->set('media_error', 'Fichier introuvable');
|
||||
|
||||
return $res->withHeader('Location', '/admin/media')->withStatus(302);
|
||||
}
|
||||
|
||||
@@ -134,6 +89,25 @@ final class MediaController
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $usage */
|
||||
$usage = $this->mediaService->getUsageSummary($media, 3);
|
||||
$usageCount = isset($usage['count']) && is_int($usage['count']) ? $usage['count'] : 0;
|
||||
/** @var array<int, \App\Post\Post> $usagePosts */
|
||||
$usagePosts = isset($usage['posts']) && is_array($usage['posts']) ? $usage['posts'] : [];
|
||||
|
||||
if ($usageCount > 0) {
|
||||
$titles = array_map(
|
||||
static fn ($post) => '« ' . $post->getTitle() . ' »',
|
||||
$usagePosts
|
||||
);
|
||||
$details = $titles === [] ? '' : ' Utilisé dans : ' . implode(', ', $titles) . '.';
|
||||
$this->flash->set(
|
||||
'media_error',
|
||||
'Ce média est encore référencé dans ' . $usageCount . ' article(s) et ne peut pas être supprimé.' . $details
|
||||
);
|
||||
|
||||
return $res->withHeader('Location', '/admin/media')->withStatus(302);
|
||||
}
|
||||
@@ -144,14 +118,6 @@ final class MediaController
|
||||
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([
|
||||
@@ -162,15 +128,6 @@ final class MediaController
|
||||
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));
|
||||
|
||||
@@ -5,109 +5,104 @@ 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);
|
||||
return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findPage(int $limit, int $offset): array
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY id DESC LIMIT :limit OFFSET :offset');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countAll(): int
|
||||
{
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM media');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête COUNT sur media a échoué.');
|
||||
}
|
||||
|
||||
return (int) ($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findByUserPage(int $userId, int $limit, int $offset): array
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE user_id = :user_id ORDER BY id DESC LIMIT :limit OFFSET :offset');
|
||||
$stmt->bindValue(':user_id', $userId, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(fn ($row) => Media::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countByUserId(int $userId): int
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM media WHERE user_id = :user_id');
|
||||
$stmt->execute([':user_id' => $userId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = $this->db->prepare(self::SELECT . ' WHERE hash = :hash ORDER BY id DESC LIMIT 1');
|
||||
$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 findByHashForUser(string $hash, int $userId): ?Media
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE hash = :hash AND user_id = :user_id ORDER BY id DESC LIMIT 1');
|
||||
$stmt->execute([':hash' => $hash, ':user_id' => $userId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Media::fromArray($row) : null;
|
||||
}
|
||||
|
||||
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 = $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(),
|
||||
@@ -120,15 +115,6 @@ final class MediaRepository implements MediaRepositoryInterface
|
||||
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');
|
||||
|
||||
@@ -3,63 +3,31 @@ 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[]
|
||||
*/
|
||||
/** @return Media[] */
|
||||
public function findAll(): array;
|
||||
|
||||
/**
|
||||
* Retourne tous les médias d'un utilisateur donné.
|
||||
*
|
||||
* @param int $userId Identifiant de l'utilisateur
|
||||
*
|
||||
* @return Media[]
|
||||
*/
|
||||
/** @return Media[] */
|
||||
public function findPage(int $limit, int $offset): array;
|
||||
|
||||
public function countAll(): int;
|
||||
|
||||
/** @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
|
||||
*/
|
||||
/** @return Media[] */
|
||||
public function findByUserPage(int $userId, int $limit, int $offset): array;
|
||||
|
||||
public function countByUserId(int $userId): int;
|
||||
|
||||
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 findByHashForUser(string $hash, int $userId): ?Media;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,38 +6,32 @@ namespace App\Media;
|
||||
use App\Media\Exception\FileTooLargeException;
|
||||
use App\Media\Exception\InvalidMimeTypeException;
|
||||
use App\Media\Exception\StorageException;
|
||||
use App\Post\PostRepositoryInterface;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
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 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 PostRepositoryInterface $postRepository,
|
||||
private readonly string $uploadDir,
|
||||
private readonly string $uploadUrl,
|
||||
private readonly int $maxSize,
|
||||
@@ -49,11 +43,45 @@ final class MediaService implements MediaServiceInterface
|
||||
return $this->mediaRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<Media>
|
||||
*/
|
||||
public function findPaginated(int $page, int $perPage): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->mediaRepository->countAll();
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->mediaRepository->findPage($perPage, $offset),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function findByUserId(int $userId): array
|
||||
{
|
||||
return $this->mediaRepository->findByUserId($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<Media>
|
||||
*/
|
||||
public function findByUserIdPaginated(int $userId, int $page, int $perPage): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->mediaRepository->countByUserId($userId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->mediaRepository->findByUserPage($userId, $perPage, $offset),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Media
|
||||
{
|
||||
return $this->mediaRepository->findById($id);
|
||||
@@ -106,7 +134,7 @@ final class MediaService implements MediaServiceInterface
|
||||
}
|
||||
|
||||
$hash = $rawHash;
|
||||
$existing = $this->mediaRepository->findByHash($hash);
|
||||
$existing = $this->mediaRepository->findByHashForUser($hash, $userId);
|
||||
|
||||
if ($existing !== null) {
|
||||
if ($converted) {
|
||||
@@ -119,9 +147,7 @@ final class MediaService implements MediaServiceInterface
|
||||
throw new StorageException("Impossible de créer le répertoire d'upload");
|
||||
}
|
||||
|
||||
$extension = $converted
|
||||
? self::MIME_EXTENSIONS[$mime]
|
||||
: self::MIME_EXTENSIONS_FALLBACK[$mime];
|
||||
$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;
|
||||
|
||||
@@ -141,19 +167,27 @@ final class MediaService implements MediaServiceInterface
|
||||
try {
|
||||
$this->mediaRepository->create($media);
|
||||
} catch (PDOException $e) {
|
||||
$duplicate = $this->mediaRepository->findByHash($hash);
|
||||
if ($duplicate !== null) {
|
||||
@unlink($destPath);
|
||||
|
||||
$duplicate = $this->mediaRepository->findByHashForUser($hash, $userId);
|
||||
if ($duplicate !== null) {
|
||||
return $duplicate->getUrl();
|
||||
}
|
||||
|
||||
@unlink($destPath);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
public function getUsageSummary(Media $media, int $sampleLimit = 5): array
|
||||
{
|
||||
return [
|
||||
'count' => $this->postRepository->countByEmbeddedMediaUrl($media->getUrl()),
|
||||
'posts' => $this->postRepository->findByEmbeddedMediaUrl($media->getUrl(), $sampleLimit),
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(Media $media): void
|
||||
{
|
||||
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $media->getFilename();
|
||||
|
||||
@@ -3,60 +3,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Media;
|
||||
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
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[]
|
||||
*/
|
||||
/** @return Media[] */
|
||||
public function findAll(): array;
|
||||
|
||||
/**
|
||||
* Retourne tous les médias appartenant à un utilisateur donné.
|
||||
*
|
||||
* @param int $userId Identifiant de l'utilisateur
|
||||
*
|
||||
* @return Media[]
|
||||
* @return PaginatedResult<Media>
|
||||
*/
|
||||
public function findPaginated(int $page, int $perPage): PaginatedResult;
|
||||
|
||||
/** @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
|
||||
* @return PaginatedResult<Media>
|
||||
*/
|
||||
public function findByUserIdPaginated(int $userId, int $page, int $perPage): PaginatedResult;
|
||||
|
||||
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
|
||||
*/
|
||||
/** @return array{count:int, posts:array<int, \App\Post\Post>} */
|
||||
public function getUsageSummary(Media $media, int $sampleLimit = 5): array;
|
||||
|
||||
public function delete(Media $media): void;
|
||||
}
|
||||
|
||||
@@ -7,32 +7,17 @@ use App\Category\CategoryServiceInterface;
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\Shared\Pagination\PaginationPresenter;
|
||||
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
|
||||
*/
|
||||
private const PUBLIC_PER_PAGE = 6;
|
||||
private const ADMIN_PER_PAGE = 12;
|
||||
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly PostServiceInterface $postService,
|
||||
@@ -42,24 +27,10 @@ final class PostController
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
$page = PaginationPresenter::resolvePage($params);
|
||||
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||
$categorySlug = (string) ($params['categorie'] ?? '');
|
||||
$activeCategory = null;
|
||||
@@ -70,12 +41,14 @@ final class PostController
|
||||
$categoryId = $activeCategory?->getId();
|
||||
}
|
||||
|
||||
$posts = $searchQuery !== ''
|
||||
? $this->postService->searchPosts($searchQuery, $categoryId)
|
||||
: $this->postService->getAllPosts($categoryId);
|
||||
$paginated = $searchQuery !== ''
|
||||
? $this->postService->searchPostsPaginated($searchQuery, $page, self::PUBLIC_PER_PAGE, $categoryId)
|
||||
: $this->postService->getAllPostsPaginated($page, self::PUBLIC_PER_PAGE, $categoryId);
|
||||
|
||||
return $this->view->render($res, 'pages/home.twig', [
|
||||
'posts' => $posts,
|
||||
'posts' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'totalPosts' => $paginated->getTotal(),
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'activeCategory' => $activeCategory,
|
||||
'searchQuery' => $searchQuery,
|
||||
@@ -83,19 +56,7 @@ final class PostController
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function show(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
@@ -108,30 +69,12 @@ final class PostController
|
||||
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();
|
||||
$page = PaginationPresenter::resolvePage($params);
|
||||
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||
$categorySlug = (string) ($params['categorie'] ?? '');
|
||||
$activeCategory = null;
|
||||
@@ -144,15 +87,23 @@ final class PostController
|
||||
|
||||
if ($searchQuery !== '') {
|
||||
$authorId = $isAdmin ? null : (int) $userId;
|
||||
$posts = $this->postService->searchPosts($searchQuery, $categoryId, $authorId);
|
||||
$paginated = $this->postService->searchPostsPaginated(
|
||||
$searchQuery,
|
||||
$page,
|
||||
self::ADMIN_PER_PAGE,
|
||||
$categoryId,
|
||||
$authorId,
|
||||
);
|
||||
} else {
|
||||
$posts = $isAdmin
|
||||
? $this->postService->getAllPosts($categoryId)
|
||||
: $this->postService->getPostsByUserId((int) $userId, $categoryId);
|
||||
$paginated = $isAdmin
|
||||
? $this->postService->getAllPostsPaginated($page, self::ADMIN_PER_PAGE, $categoryId)
|
||||
: $this->postService->getPostsByUserIdPaginated((int) $userId, $page, self::ADMIN_PER_PAGE, $categoryId);
|
||||
}
|
||||
|
||||
return $this->view->render($res, 'admin/posts/index.twig', [
|
||||
'posts' => $posts,
|
||||
'posts' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'totalPosts' => $paginated->getTotal(),
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'activeCategory' => $activeCategory,
|
||||
'searchQuery' => $searchQuery,
|
||||
@@ -162,18 +113,7 @@ final class PostController
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function form(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
@@ -187,7 +127,6 @@ final class PostController
|
||||
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");
|
||||
|
||||
@@ -203,24 +142,9 @@ final class PostController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
['title' => $title, 'content' => $content, 'category_id' => $categoryId] = $this->extractPostData($req);
|
||||
|
||||
try {
|
||||
$this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId);
|
||||
@@ -239,27 +163,13 @@ final class PostController
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
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);
|
||||
['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) {
|
||||
@@ -276,7 +186,6 @@ final class PostController
|
||||
$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());
|
||||
@@ -292,23 +201,10 @@ final class PostController
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
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) {
|
||||
@@ -324,7 +220,6 @@ final class PostController
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -333,41 +228,22 @@ final class PostController
|
||||
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
|
||||
* @return array{title: string, content: string, slug: string, category_id: int|null}
|
||||
*/
|
||||
private function extractPostData(Request $req): array
|
||||
{
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = (array) $req->getParsedBody();
|
||||
$categoryId = ($data['category_id'] ?? '') !== ''
|
||||
? (int) $data['category_id']
|
||||
: null;
|
||||
$categoryId = ($data['category_id'] ?? '') !== '' ? (int) $data['category_id'] : null;
|
||||
|
||||
return [
|
||||
'title' => trim((string) ($data['title'] ?? '')),
|
||||
|
||||
@@ -5,20 +5,8 @@ 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,
|
||||
@@ -30,130 +18,150 @@ final class PostRepository implements PostRepositoryInterface
|
||||
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 {
|
||||
if ($categoryId === null) {
|
||||
$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é.');
|
||||
}
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findPage(int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT;
|
||||
$params = [];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' WHERE posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countAll(?int $categoryId = null): int
|
||||
{
|
||||
if ($categoryId === null) {
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM posts');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête COUNT sur posts a échoué.');
|
||||
}
|
||||
|
||||
return (int) ($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
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]);
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$sql .= ' ORDER BY posts.id DESC';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countByUserId(int $userId, ?int $categoryId = null): int
|
||||
{
|
||||
$sql = 'SELECT COUNT(*) FROM posts WHERE author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = $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(),
|
||||
@@ -168,27 +176,14 @@ final class PostRepository implements PostRepositoryInterface
|
||||
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
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE posts
|
||||
SET title = :title, content = :content, slug = :slug,
|
||||
category_id = :category_id, updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
');
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':title' => $post->getTitle(),
|
||||
@@ -202,13 +197,6 @@ final class PostRepository implements PostRepositoryInterface
|
||||
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');
|
||||
@@ -217,23 +205,6 @@ final class PostRepository implements PostRepositoryInterface
|
||||
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);
|
||||
@@ -242,19 +213,50 @@ final class PostRepository implements PostRepositoryInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
[$sql, $params] = $this->buildSearchSql($ftsQuery, $categoryId, $authorId);
|
||||
$sql .= ' ORDER BY rank';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$ftsQuery = $this->buildFtsQuery($query);
|
||||
|
||||
if ($ftsQuery === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$sql, $params] = $this->buildSearchSql($ftsQuery, $categoryId, $authorId);
|
||||
$sql .= ' ORDER BY rank LIMIT :limit OFFSET :offset';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int
|
||||
{
|
||||
$ftsQuery = $this->buildFtsQuery($query);
|
||||
|
||||
if ($ftsQuery === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$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
|
||||
SELECT COUNT(*)
|
||||
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) {
|
||||
@@ -267,27 +269,80 @@ final class PostRepository implements PostRepositoryInterface
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY rank';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
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;
|
||||
|
||||
return $excludeId !== null ? $existingId !== $excludeId : true;
|
||||
}
|
||||
|
||||
public function countByEmbeddedMediaUrl(string $url): int
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE instr(content, :url) > 0');
|
||||
$stmt->execute([':url' => $url]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
self::SELECT . ' WHERE instr(posts.content, :url) > 0 ORDER BY posts.updated_at DESC LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':url', $url, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return array{0:string,1:array<string,mixed>}
|
||||
*/
|
||||
private function buildSearchSql(string $ftsQuery, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
return [$sql, $params];
|
||||
}
|
||||
|
||||
private function buildFtsQuery(string $input): string
|
||||
{
|
||||
$words = preg_split('/\s+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
@@ -305,30 +360,21 @@ final class PostRepository implements PostRepositoryInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool
|
||||
private function bindParams(\PDOStatement $stmt, array $params): void
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
|
||||
$existingId = $stmt->fetchColumn();
|
||||
|
||||
if ($existingId === false) {
|
||||
return false;
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||
}
|
||||
}
|
||||
|
||||
$existingId = (int) $existingId;
|
||||
|
||||
if ($excludeId !== null) {
|
||||
return $existingId !== $excludeId;
|
||||
}
|
||||
|
||||
return true;
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return Post[]
|
||||
*/
|
||||
private function hydratePosts(array $rows): array
|
||||
{
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,111 +3,49 @@ 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[]
|
||||
*/
|
||||
/** @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[]
|
||||
*/
|
||||
/** @return Post[] */
|
||||
public function findPage(int $limit, int $offset, ?int $categoryId = null): array;
|
||||
|
||||
public function countAll(?int $categoryId = null): int;
|
||||
|
||||
/** @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[]
|
||||
*/
|
||||
/** @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
|
||||
*/
|
||||
/** @return Post[] */
|
||||
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array;
|
||||
|
||||
public function countByUserId(int $userId, ?int $categoryId = null): int;
|
||||
|
||||
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[]
|
||||
*/
|
||||
/** @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
|
||||
*/
|
||||
/** @return Post[] */
|
||||
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array;
|
||||
|
||||
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int;
|
||||
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool;
|
||||
|
||||
public function countByEmbeddedMediaUrl(string $url): int;
|
||||
|
||||
/** @return Post[] */
|
||||
public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array;
|
||||
}
|
||||
|
||||
@@ -5,82 +5,66 @@ namespace App\Post;
|
||||
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Html\HtmlSanitizerInterface;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
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[]
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function getAllPostsPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countAll($categoryId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->findPage($perPage, $offset, $categoryId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function getPostsByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countByUserId($userId, $categoryId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->findByUserPage($userId, $perPage, $offset, $categoryId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function getPostBySlug(string $slug): Post
|
||||
{
|
||||
$post = $this->postRepository->findBySlug($slug);
|
||||
@@ -92,15 +76,6 @@ final class PostService implements PostServiceInterface
|
||||
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);
|
||||
@@ -112,22 +87,6 @@ final class PostService implements PostServiceInterface
|
||||
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);
|
||||
@@ -137,29 +96,8 @@ final class PostService implements PostServiceInterface
|
||||
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 {
|
||||
public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void
|
||||
{
|
||||
$current = $this->postRepository->findById($id);
|
||||
|
||||
if ($current === null) {
|
||||
@@ -168,7 +106,6 @@ final class PostService implements PostServiceInterface
|
||||
|
||||
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
|
||||
$post = new Post($id, $title, $sanitizedContent);
|
||||
|
||||
$slugToUse = $current->getStoredSlug();
|
||||
$newSlugInput = trim($newSlugInput);
|
||||
$cleanSlugInput = $this->normalizeSlugInput($newSlugInput);
|
||||
@@ -184,27 +121,38 @@ final class PostService implements PostServiceInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function searchPostsPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countSearch($query, $categoryId, $authorId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->searchPage($query, $perPage, $offset, $categoryId, $authorId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function countMediaUsages(string $url): int
|
||||
{
|
||||
return $this->postRepository->countByEmbeddedMediaUrl($url);
|
||||
}
|
||||
|
||||
public function findMediaUsages(string $url, int $limit = 5): array
|
||||
{
|
||||
return $this->postRepository->findByEmbeddedMediaUrl($url, $limit);
|
||||
}
|
||||
|
||||
public function deletePost(int $id): void
|
||||
{
|
||||
$affected = $this->postRepository->delete($id);
|
||||
@@ -214,29 +162,11 @@ final class PostService implements PostServiceInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -4,116 +4,49 @@ declare(strict_types=1);
|
||||
namespace App\Post;
|
||||
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
|
||||
/**
|
||||
* 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[]
|
||||
*/
|
||||
/** @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[]
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function getAllPostsPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
|
||||
|
||||
/** @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[]
|
||||
*/
|
||||
/** @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
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function getPostsByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
|
||||
|
||||
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;
|
||||
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[]
|
||||
*/
|
||||
/** @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
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function searchPostsPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult;
|
||||
|
||||
public function countMediaUsages(string $url): int;
|
||||
|
||||
/** @return Post[] */
|
||||
public function findMediaUsages(string $url, int $limit = 5): array;
|
||||
|
||||
public function deletePost(int $id): void;
|
||||
}
|
||||
|
||||
@@ -3,31 +3,10 @@ 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);
|
||||
}
|
||||
@@ -35,13 +14,10 @@ final class SessionManager implements SessionManagerInterface
|
||||
$_SESSION['user_id'] = $userId;
|
||||
$_SESSION['username'] = $username;
|
||||
$_SESSION['role'] = $role;
|
||||
$_SESSION['session_established_at'] = time();
|
||||
$_SESSION['session_last_regenerated_at'] = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'] !== ''
|
||||
@@ -49,44 +25,21 @@ final class SessionManager implements SessionManagerInterface
|
||||
: 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 = [];
|
||||
|
||||
65
src/Shared/Pagination/PaginatedResult.php
Normal file
65
src/Shared/Pagination/PaginatedResult.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Pagination;
|
||||
|
||||
/**
|
||||
* Résultat paginé générique.
|
||||
*
|
||||
* @template T
|
||||
*/
|
||||
class PaginatedResult
|
||||
{
|
||||
/** @var array<int, T> */
|
||||
private readonly array $items;
|
||||
|
||||
/**
|
||||
* @param array<int, T> $items
|
||||
*/
|
||||
public function __construct(
|
||||
array $items,
|
||||
private readonly int $total,
|
||||
private readonly int $currentPage,
|
||||
private readonly int $perPage,
|
||||
) {
|
||||
$this->items = $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, T>
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function getTotal(): int
|
||||
{
|
||||
return $this->total;
|
||||
}
|
||||
|
||||
public function getCurrentPage(): int
|
||||
{
|
||||
return $this->currentPage;
|
||||
}
|
||||
|
||||
public function getPerPage(): int
|
||||
{
|
||||
return $this->perPage;
|
||||
}
|
||||
|
||||
public function getTotalPages(): int
|
||||
{
|
||||
return max(1, (int) ceil($this->total / max(1, $this->perPage)));
|
||||
}
|
||||
|
||||
public function hasPreviousPage(): bool
|
||||
{
|
||||
return $this->currentPage > 1;
|
||||
}
|
||||
|
||||
public function hasNextPage(): bool
|
||||
{
|
||||
return $this->currentPage < $this->getTotalPages();
|
||||
}
|
||||
}
|
||||
74
src/Shared/Pagination/PaginationPresenter.php
Normal file
74
src/Shared/Pagination/PaginationPresenter.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Pagination;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class PaginationPresenter
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $queryParams
|
||||
*/
|
||||
public static function resolvePage(array $queryParams): int
|
||||
{
|
||||
$rawPage = $queryParams['page'] ?? 1;
|
||||
$page = filter_var($rawPage, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
|
||||
return $page === false ? 1 : (int) $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PaginatedResult<mixed> $result
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function fromRequest(ServerRequestInterface $request, PaginatedResult $result): array
|
||||
{
|
||||
$totalPages = $result->getTotalPages();
|
||||
$currentPage = min($result->getCurrentPage(), $totalPages);
|
||||
$baseQuery = $request->getQueryParams();
|
||||
unset($baseQuery['page']);
|
||||
|
||||
$pages = [];
|
||||
$start = max(1, $currentPage - 2);
|
||||
$end = min($totalPages, $currentPage + 2);
|
||||
|
||||
for ($page = $start; $page <= $end; ++$page) {
|
||||
$pages[] = [
|
||||
'number' => $page,
|
||||
'url' => self::buildPageUrl($request->getUri()->getPath(), $baseQuery, $page),
|
||||
'current' => $page === $currentPage,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'currentPage' => $currentPage,
|
||||
'perPage' => $result->getPerPage(),
|
||||
'total' => $result->getTotal(),
|
||||
'totalPages' => $totalPages,
|
||||
'hasPrevious' => $currentPage > 1,
|
||||
'hasNext' => $currentPage < $totalPages,
|
||||
'previousUrl' => $currentPage > 1
|
||||
? self::buildPageUrl($request->getUri()->getPath(), $baseQuery, $currentPage - 1)
|
||||
: null,
|
||||
'nextUrl' => $currentPage < $totalPages
|
||||
? self::buildPageUrl($request->getUri()->getPath(), $baseQuery, $currentPage + 1)
|
||||
: null,
|
||||
'pages' => $pages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function buildPageUrl(string $path, array $query, int $page): string
|
||||
{
|
||||
if ($page > 1) {
|
||||
$query['page'] = $page;
|
||||
}
|
||||
|
||||
$queryString = http_build_query($query);
|
||||
|
||||
return $queryString === '' ? $path : $path . '?' . $queryString;
|
||||
}
|
||||
}
|
||||
17
src/User/Exception/RoleAssignmentNotAllowedException.php
Normal file
17
src/User/Exception/RoleAssignmentNotAllowedException.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\User\Exception;
|
||||
|
||||
use App\User\User;
|
||||
|
||||
final class RoleAssignmentNotAllowedException extends \InvalidArgumentException
|
||||
{
|
||||
public function __construct(string $role)
|
||||
{
|
||||
parent::__construct(
|
||||
"Le rôle '{$role}' ne peut pas être attribué depuis l'interface. Rôles autorisés : "
|
||||
. implode(', ', User::assignableRoles())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,38 +6,14 @@ 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,
|
||||
@@ -51,11 +27,7 @@ final class User
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
@@ -70,105 +42,69 @@ final class User
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'identifiant de l'utilisateur.
|
||||
*
|
||||
* @return int L'identifiant en base (0 si non encore persisté)
|
||||
* @return string[]
|
||||
*/
|
||||
public static function allRoles(): array
|
||||
{
|
||||
return [self::ROLE_USER, self::ROLE_EDITOR, self::ROLE_ADMIN];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function assignableRoles(): array
|
||||
{
|
||||
return [self::ROLE_USER, self::ROLE_EDITOR];
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
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"
|
||||
);
|
||||
throw new \InvalidArgumentException("Le nom d'utilisateur ne peut pas dépasser 50 caractères");
|
||||
}
|
||||
|
||||
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
|
||||
@@ -176,15 +112,12 @@ final class User
|
||||
}
|
||||
|
||||
if ($this->passwordHash === '') {
|
||||
throw new \InvalidArgumentException(
|
||||
'Le hash du mot de passe ne peut pas être vide'
|
||||
);
|
||||
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)) {
|
||||
if (!in_array($this->role, self::allRoles(), true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Le rôle '{$this->role}' est invalide. Valeurs autorisées : " . implode(', ', $validRoles)
|
||||
"Le rôle '{$this->role}' est invalide. Valeurs autorisées : " . implode(', ', self::allRoles())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,33 +5,20 @@ namespace App\User;
|
||||
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\Shared\Pagination\PaginationPresenter;
|
||||
use App\User\Exception\DuplicateEmailException;
|
||||
use App\User\Exception\DuplicateUsernameException;
|
||||
use App\User\Exception\InvalidRoleException;
|
||||
use App\User\Exception\RoleAssignmentNotAllowedException;
|
||||
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
|
||||
*/
|
||||
private const PER_PAGE = 15;
|
||||
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly UserServiceInterface $userService,
|
||||
@@ -40,84 +27,47 @@ final class UserController
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$page = PaginationPresenter::resolvePage($req->getQueryParams());
|
||||
$paginated = $this->userService->findPaginated($page, self::PER_PAGE);
|
||||
|
||||
return $this->view->render($res, 'admin/users/index.twig', [
|
||||
'users' => $this->userService->findAll(),
|
||||
'users' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'currentUserId' => $this->sessionManager->getUserId(),
|
||||
'assignableRoles' => User::assignableRoles(),
|
||||
'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', [
|
||||
'assignableRoles' => User::assignableRoles(),
|
||||
'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;
|
||||
$role = in_array($rawRole, User::assignableRoles(), 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");
|
||||
@@ -125,7 +75,7 @@ final class UserController
|
||||
$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) {
|
||||
} catch (InvalidRoleException|RoleAssignmentNotAllowedException $e) {
|
||||
$this->flash->set('user_error', $e->getMessage());
|
||||
} catch (\Throwable) {
|
||||
$this->flash->set('user_error', "Une erreur inattendue s'est produite");
|
||||
@@ -135,18 +85,7 @@ final class UserController
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function updateRole(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
@@ -155,53 +94,40 @@ final class UserController
|
||||
|
||||
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é');
|
||||
|
||||
$this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié depuis l\'interface');
|
||||
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;
|
||||
$role = in_array($rawRole, User::assignableRoles(), true) ? $rawRole : null;
|
||||
|
||||
if ($role === null) {
|
||||
$this->flash->set('user_error', 'Rôle invalide');
|
||||
|
||||
return $res->withHeader('Location', '/admin/users')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->userService->updateRole($id, $role);
|
||||
$this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour");
|
||||
} catch (InvalidRoleException|RoleAssignmentNotAllowedException $e) {
|
||||
$this->flash->set('user_error', $e->getMessage());
|
||||
}
|
||||
|
||||
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
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function delete(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
@@ -210,19 +136,16 @@ final class UserController
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,101 +5,75 @@ 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);
|
||||
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findPage(int $limit, int $offset): array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM users ORDER BY created_at ASC LIMIT :limit OFFSET :offset');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countAll(): int
|
||||
{
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM users');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête COUNT sur users a échoué.');
|
||||
}
|
||||
|
||||
return (int) ($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = $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(),
|
||||
@@ -112,36 +86,18 @@ final class UserRepository implements UserRepositoryInterface
|
||||
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');
|
||||
|
||||
@@ -3,78 +3,27 @@ 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[]
|
||||
*/
|
||||
/** @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
|
||||
*/
|
||||
/** @return User[] */
|
||||
public function findPage(int $limit, int $offset): array;
|
||||
|
||||
public function countAll(): int;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
namespace App\User;
|
||||
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
use App\User\Exception\DuplicateEmailException;
|
||||
use App\User\Exception\DuplicateUsernameException;
|
||||
use App\User\Exception\InvalidRoleException;
|
||||
use App\User\Exception\RoleAssignmentNotAllowedException;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
|
||||
final class UserService implements UserServiceInterface
|
||||
@@ -21,6 +23,23 @@ final class UserService implements UserServiceInterface
|
||||
return $this->userRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<User>
|
||||
*/
|
||||
public function findPaginated(int $page, int $perPage): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->userRepository->countAll();
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->userRepository->findPage($perPage, $offset),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?User
|
||||
{
|
||||
return $this->userRepository->findById($id);
|
||||
@@ -34,7 +53,7 @@ final class UserService implements UserServiceInterface
|
||||
|
||||
public function updateRole(int $id, string $role): void
|
||||
{
|
||||
$this->assertValidRole($role);
|
||||
$this->assertRoleCanBeAssigned($role);
|
||||
$this->requireExistingUser($id);
|
||||
$this->userRepository->updateRole($id, $role);
|
||||
}
|
||||
@@ -45,7 +64,7 @@ final class UserService implements UserServiceInterface
|
||||
$email = mb_strtolower(trim($email));
|
||||
$plainPassword = trim($plainPassword);
|
||||
|
||||
$this->assertValidRole($role);
|
||||
$this->assertRoleCanBeAssigned($role);
|
||||
|
||||
if ($this->userRepository->findByUsername($username)) {
|
||||
throw new DuplicateUsernameException($username);
|
||||
@@ -67,13 +86,15 @@ final class UserService implements UserServiceInterface
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function assertValidRole(string $role): void
|
||||
private function assertRoleCanBeAssigned(string $role): void
|
||||
{
|
||||
$validRoles = [User::ROLE_USER, User::ROLE_EDITOR, User::ROLE_ADMIN];
|
||||
|
||||
if (!in_array($role, $validRoles, true)) {
|
||||
if (!in_array($role, User::allRoles(), true)) {
|
||||
throw new InvalidRoleException($role);
|
||||
}
|
||||
|
||||
if (!in_array($role, User::assignableRoles(), true)) {
|
||||
throw new RoleAssignmentNotAllowedException($role);
|
||||
}
|
||||
}
|
||||
|
||||
private function requireExistingUser(int $id): User
|
||||
|
||||
@@ -3,67 +3,36 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\User;
|
||||
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
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[]
|
||||
*/
|
||||
/** @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
|
||||
* @return PaginatedResult<User>
|
||||
*/
|
||||
public function findPaginated(int $page, int $perPage): PaginatedResult;
|
||||
|
||||
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
|
||||
* @throws DuplicateUsernameException
|
||||
* @throws DuplicateEmailException
|
||||
* @throws WeakPasswordException
|
||||
* @throws InvalidRoleException
|
||||
*/
|
||||
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
|
||||
* @throws InvalidRoleException
|
||||
*/
|
||||
public function updateRole(int $id, string $role): void;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestCase;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour AccountController.
|
||||
@@ -19,7 +19,7 @@ use Tests\ControllerTestCase;
|
||||
* incorrect, erreur inattendue et succès.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class AccountControllerTest extends ControllerTestCase
|
||||
final class AccountControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
@@ -9,7 +9,7 @@ use App\Shared\Http\ClientIpResolver;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\User\User;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestCase;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour AuthController.
|
||||
@@ -19,7 +19,7 @@ use Tests\ControllerTestCase;
|
||||
* aucune base de données, aucun serveur HTTP n'est requis.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class AuthControllerTest extends ControllerTestCase
|
||||
final class AuthControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
@@ -12,7 +12,7 @@ use App\Shared\Http\FlashServiceInterface;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
use App\User\User;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestCase;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PasswordResetController.
|
||||
@@ -29,7 +29,7 @@ use Tests\ControllerTestCase;
|
||||
* - reset() couvre 5 chemins de sortie (token vide, mismatch, trop court, invalide, succès)
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class PasswordResetControllerTest extends ControllerTestCase
|
||||
final class PasswordResetControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
@@ -7,8 +7,9 @@ use App\Category\Category;
|
||||
use App\Category\CategoryController;
|
||||
use App\Category\CategoryServiceInterface;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestCase;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour CategoryController.
|
||||
@@ -18,7 +19,7 @@ use Tests\ControllerTestCase;
|
||||
* suppression avec catégorie introuvable, succès et erreur métier.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class CategoryControllerTest extends ControllerTestCase
|
||||
final class CategoryControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
@@ -51,7 +52,7 @@ final class CategoryControllerTest extends ControllerTestCase
|
||||
*/
|
||||
public function testIndexRendersWithCategories(): void
|
||||
{
|
||||
$this->categoryService->method('findAll')->willReturn([]);
|
||||
$this->categoryService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20));
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
|
||||
@@ -19,7 +19,7 @@ use Slim\Psr7\Response as SlimResponse;
|
||||
* sans passer par le routeur Slim — les middlewares sont testés séparément.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
abstract class ControllerTestCase extends TestCase
|
||||
abstract class ControllerTestBase extends TestCase
|
||||
{
|
||||
// ── Factories ────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,9 +11,10 @@ use App\Media\MediaController;
|
||||
use App\Media\MediaServiceInterface;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
use Tests\ControllerTestCase;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour MediaController.
|
||||
@@ -24,7 +25,7 @@ use Tests\ControllerTestCase;
|
||||
* - delete : introuvable, non-propriétaire, succès propriétaire, succès admin
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaControllerTest extends ControllerTestCase
|
||||
final class MediaControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
@@ -65,8 +66,8 @@ final class MediaControllerTest extends ControllerTestCase
|
||||
$this->sessionManager->method('isAdmin')->willReturn(true);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
|
||||
$this->mediaService->expects($this->once())->method('findAll')->willReturn([]);
|
||||
$this->mediaService->expects($this->never())->method('findByUserId');
|
||||
$this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->mediaService->expects($this->never())->method('findByUserIdPaginated');
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
@@ -86,8 +87,8 @@ final class MediaControllerTest extends ControllerTestCase
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(true);
|
||||
|
||||
$this->mediaService->expects($this->once())->method('findAll')->willReturn([]);
|
||||
$this->mediaService->expects($this->never())->method('findByUserId');
|
||||
$this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->mediaService->expects($this->never())->method('findByUserIdPaginated');
|
||||
|
||||
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
|
||||
|
||||
@@ -103,8 +104,8 @@ final class MediaControllerTest extends ControllerTestCase
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->sessionManager->method('getUserId')->willReturn(42);
|
||||
|
||||
$this->mediaService->expects($this->once())->method('findByUserId')->with(42)->willReturn([]);
|
||||
$this->mediaService->expects($this->never())->method('findAll');
|
||||
$this->mediaService->expects($this->once())->method('findByUserIdPaginated')->with(42, 1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->mediaService->expects($this->never())->method('findPaginated');
|
||||
|
||||
$this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Tests\Media;
|
||||
use App\Media\Media;
|
||||
use App\Media\MediaRepositoryInterface;
|
||||
use App\Media\MediaService;
|
||||
use App\Post\PostRepositoryInterface;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -19,6 +20,8 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
|
||||
/** @var MediaRepositoryInterface&MockObject */
|
||||
private MediaRepositoryInterface $repository;
|
||||
|
||||
private PostRepositoryInterface $postRepository;
|
||||
|
||||
private string $uploadDir;
|
||||
|
||||
private MediaService $service;
|
||||
@@ -26,10 +29,11 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(MediaRepositoryInterface::class);
|
||||
$this->postRepository = $this->createMock(PostRepositoryInterface::class);
|
||||
$this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true);
|
||||
@mkdir($this->uploadDir, 0755, true);
|
||||
|
||||
$this->service = new MediaService($this->repository, $this->uploadDir, '/media', 5 * 1024 * 1024);
|
||||
$this->service = new MediaService($this->repository, $this->postRepository, $this->uploadDir, '/media', 5 * 1024 * 1024);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
@@ -49,8 +53,8 @@ final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
|
||||
$duplicate = new Media(77, 'existing.gif', '/media/existing.gif', $hash, 1);
|
||||
|
||||
$this->repository->expects($this->exactly(2))
|
||||
->method('findByHash')
|
||||
->with($hash)
|
||||
->method('findByHashForUser')
|
||||
->with($hash, 1)
|
||||
->willReturnOnConsecutiveCalls(null, $duplicate);
|
||||
|
||||
$this->repository->expects($this->once())
|
||||
|
||||
@@ -3,26 +3,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use App\Media\MediaService;
|
||||
use App\Media\MediaRepositoryInterface;
|
||||
use App\Media\Exception\FileTooLargeException;
|
||||
use App\Media\Exception\StorageException;
|
||||
use App\Media\MediaRepositoryInterface;
|
||||
use App\Media\MediaService;
|
||||
use App\Post\PostRepositoryInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class MediaServiceEdgeCasesTest extends TestCase
|
||||
{
|
||||
public function testRejectsWhenSizeUnknown(): void
|
||||
{
|
||||
$repo = $this->createMock(MediaRepositoryInterface::class);
|
||||
$postRepo = $this->createMock(PostRepositoryInterface::class);
|
||||
|
||||
$file = $this->createMock(UploadedFileInterface::class);
|
||||
$file->method('getSize')->willReturn(null);
|
||||
|
||||
$service = new MediaService($repo, '/tmp', '/media', 1000);
|
||||
$service = new MediaService($repo, $postRepo, '/tmp', '/media', 1000);
|
||||
|
||||
$this->expectException(StorageException::class);
|
||||
$service->store($file, 1);
|
||||
@@ -31,6 +32,7 @@ final class MediaServiceEdgeCasesTest extends TestCase
|
||||
public function testRejectsWhenFileTooLarge(): void
|
||||
{
|
||||
$repo = $this->createMock(MediaRepositoryInterface::class);
|
||||
$postRepo = $this->createMock(PostRepositoryInterface::class);
|
||||
|
||||
$stream = $this->createMock(StreamInterface::class);
|
||||
$stream->method('getMetadata')->willReturn('/tmp/file');
|
||||
@@ -39,7 +41,7 @@ final class MediaServiceEdgeCasesTest extends TestCase
|
||||
$file->method('getSize')->willReturn(999999);
|
||||
$file->method('getStream')->willReturn($stream);
|
||||
|
||||
$service = new MediaService($repo, '/tmp', '/media', 100);
|
||||
$service = new MediaService($repo, $postRepo, '/tmp', '/media', 100);
|
||||
|
||||
$this->expectException(FileTooLargeException::class);
|
||||
$service->store($file, 1);
|
||||
|
||||
@@ -6,17 +6,18 @@ namespace Tests\Media;
|
||||
use App\Media\Exception\InvalidMimeTypeException;
|
||||
use App\Media\MediaRepositoryInterface;
|
||||
use App\Media\MediaService;
|
||||
use App\Post\PostRepositoryInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class MediaServiceInvalidMimeTest extends TestCase
|
||||
{
|
||||
public function testRejectsNonImageContentEvenWithImageLikeFilename(): void
|
||||
{
|
||||
$repo = $this->createMock(MediaRepositoryInterface::class);
|
||||
$postRepo = $this->createMock(PostRepositoryInterface::class);
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'upload_');
|
||||
self::assertNotFalse($tmpFile);
|
||||
@@ -30,7 +31,7 @@ final class MediaServiceInvalidMimeTest extends TestCase
|
||||
$file->method('getStream')->willReturn($stream);
|
||||
$file->method('getClientFilename')->willReturn('photo.png');
|
||||
|
||||
$service = new MediaService($repo, sys_get_temp_dir(), '/media', 500000);
|
||||
$service = new MediaService($repo, $postRepo, sys_get_temp_dir(), '/media', 500000);
|
||||
|
||||
try {
|
||||
$this->expectException(InvalidMimeTypeException::class);
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Tests\Media;
|
||||
use App\Media\Exception\StorageException;
|
||||
use App\Media\MediaRepositoryInterface;
|
||||
use App\Media\MediaService;
|
||||
use App\Post\PostRepositoryInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
@@ -25,7 +26,9 @@ final class MediaServiceInvalidTempPathTest extends TestCase
|
||||
$file->method('getSize')->willReturn(128);
|
||||
$file->method('getStream')->willReturn($stream);
|
||||
|
||||
$service = new MediaService($repository, sys_get_temp_dir(), '/media', 500000);
|
||||
$postRepo = $this->createMock(PostRepositoryInterface::class);
|
||||
|
||||
$service = new MediaService($repository, $postRepo, sys_get_temp_dir(), '/media', 500000);
|
||||
|
||||
$this->expectException(StorageException::class);
|
||||
$this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé');
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Media\Exception\InvalidMimeTypeException;
|
||||
use App\Media\Media;
|
||||
use App\Media\MediaRepositoryInterface;
|
||||
use App\Media\MediaService;
|
||||
use App\Post\PostRepositoryInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
@@ -33,6 +34,8 @@ final class MediaServiceTest extends TestCase
|
||||
/** @var MediaRepositoryInterface&MockObject */
|
||||
private MediaRepositoryInterface $repository;
|
||||
|
||||
private PostRepositoryInterface $postRepository;
|
||||
|
||||
private string $uploadDir;
|
||||
|
||||
private MediaService $service;
|
||||
@@ -40,11 +43,13 @@ final class MediaServiceTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(MediaRepositoryInterface::class);
|
||||
$this->postRepository = $this->createMock(PostRepositoryInterface::class);
|
||||
$this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid();
|
||||
@mkdir($this->uploadDir, 0755, true);
|
||||
|
||||
$this->service = new MediaService(
|
||||
mediaRepository: $this->repository,
|
||||
postRepository: $this->postRepository,
|
||||
uploadDir: $this->uploadDir,
|
||||
uploadUrl: '/media',
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
@@ -104,10 +109,13 @@ final class MediaServiceTest extends TestCase
|
||||
public function testStoreReturnsDuplicateUrl(): void
|
||||
{
|
||||
$tmpFile = $this->createMinimalJpeg();
|
||||
$hash = hash_file('sha256', $tmpFile);
|
||||
|
||||
$existing = new Media(7, 'existing.jpg', '/media/existing.jpg', $hash, 1);
|
||||
$this->repository->method('findByHash')->willReturn($existing);
|
||||
$existing = new Media(7, 'existing.jpg', '/media/existing.jpg', 'existing-hash', 1);
|
||||
$this->repository
|
||||
->expects($this->once())
|
||||
->method('findByHashForUser')
|
||||
->with($this->callback(static fn (mixed $value): bool => is_string($value) && $value !== ''), 1)
|
||||
->willReturn($existing);
|
||||
$this->repository->expects($this->never())->method('create');
|
||||
|
||||
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
|
||||
@@ -128,7 +136,7 @@ final class MediaServiceTest extends TestCase
|
||||
{
|
||||
$tmpFile = $this->createMinimalJpeg();
|
||||
|
||||
$this->repository->method('findByHash')->willReturn(null);
|
||||
$this->repository->method('findByHashForUser')->willReturn(null);
|
||||
$this->repository->expects($this->once())->method('create');
|
||||
|
||||
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
|
||||
|
||||
@@ -37,10 +37,16 @@ final class PostConcurrentUpdateIntegrationTest extends TestCase
|
||||
$realRepo = new PostRepository($this->db);
|
||||
$repo = new class($realRepo) implements PostRepositoryInterface {
|
||||
private bool $deleted = false;
|
||||
|
||||
public function __construct(private readonly PostRepository $inner) {}
|
||||
|
||||
public function findAll(?int $categoryId = null): array { return $this->inner->findAll($categoryId); }
|
||||
public function findPage(int $limit, int $offset, ?int $categoryId = null): array { return $this->inner->findPage($limit, $offset, $categoryId); }
|
||||
public function countAll(?int $categoryId = null): int { return $this->inner->countAll($categoryId); }
|
||||
public function findRecent(int $limit): array { return $this->inner->findRecent($limit); }
|
||||
public function findByUserId(int $userId, ?int $categoryId = null): array { return $this->inner->findByUserId($userId, $categoryId); }
|
||||
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array { return $this->inner->findByUserPage($userId, $limit, $offset, $categoryId); }
|
||||
public function countByUserId(int $userId, ?int $categoryId = null): int { return $this->inner->countByUserId($userId, $categoryId); }
|
||||
public function findBySlug(string $slug): ?Post { return $this->inner->findBySlug($slug); }
|
||||
public function findById(int $id): ?Post { return $this->inner->findById($id); }
|
||||
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int { return $this->inner->create($post, $slug, $authorId, $categoryId); }
|
||||
@@ -53,7 +59,11 @@ final class PostConcurrentUpdateIntegrationTest extends TestCase
|
||||
}
|
||||
public function delete(int $id): int { return $this->inner->delete($id); }
|
||||
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array { return $this->inner->search($query, $categoryId, $authorId); }
|
||||
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array { return $this->inner->searchPage($query, $limit, $offset, $categoryId, $authorId); }
|
||||
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int { return $this->inner->countSearch($query, $categoryId, $authorId); }
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool { return $this->inner->slugExists($slug, $excludeId); }
|
||||
public function countByEmbeddedMediaUrl(string $url): int { return $this->inner->countByEmbeddedMediaUrl($url); }
|
||||
public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array { return $this->inner->findByEmbeddedMediaUrl($url, $limit); }
|
||||
};
|
||||
|
||||
$sanitizer = new class implements HtmlSanitizerInterface {
|
||||
|
||||
@@ -11,9 +11,10 @@ use App\Post\PostServiceInterface;
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Tests\ControllerTestCase;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PostController.
|
||||
@@ -28,7 +29,7 @@ use Tests\ControllerTestCase;
|
||||
* - delete() : 404, droits insuffisants, succès
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class PostControllerTest extends ControllerTestCase
|
||||
final class PostControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
@@ -73,8 +74,8 @@ final class PostControllerTest extends ControllerTestCase
|
||||
*/
|
||||
public function testIndexCallsGetAllPostsWithNoFilter(): void
|
||||
{
|
||||
$this->postService->expects($this->once())->method('getAllPosts')->with(null)->willReturn([]);
|
||||
$this->postService->expects($this->never())->method('searchPosts');
|
||||
$this->postService->expects($this->once())->method('getAllPostsPaginated')->with(1, 6, null)->willReturn(new PaginatedResult([], 0, 1, 6));
|
||||
$this->postService->expects($this->never())->method('searchPostsPaginated');
|
||||
|
||||
$res = $this->controller->index($this->makeGet('/'), $this->makeResponse());
|
||||
|
||||
@@ -87,10 +88,10 @@ final class PostControllerTest extends ControllerTestCase
|
||||
public function testIndexCallsSearchPostsWhenQueryParamPresent(): void
|
||||
{
|
||||
$this->postService->expects($this->once())
|
||||
->method('searchPosts')
|
||||
->with('php', null)
|
||||
->willReturn([]);
|
||||
$this->postService->expects($this->never())->method('getAllPosts');
|
||||
->method('searchPostsPaginated')
|
||||
->with('php', 1, 6, null)
|
||||
->willReturn(new PaginatedResult([], 0, 1, 6));
|
||||
$this->postService->expects($this->never())->method('getAllPostsPaginated');
|
||||
|
||||
$this->controller->index($this->makeGet('/', ['q' => 'php']), $this->makeResponse());
|
||||
}
|
||||
@@ -104,9 +105,9 @@ final class PostControllerTest extends ControllerTestCase
|
||||
$this->categoryService->expects($this->once())->method('findBySlug')->with('php')->willReturn($category);
|
||||
|
||||
$this->postService->expects($this->once())
|
||||
->method('getAllPosts')
|
||||
->with(3)
|
||||
->willReturn([]);
|
||||
->method('getAllPostsPaginated')
|
||||
->with(1, 6, 3)
|
||||
->willReturn(new PaginatedResult([], 0, 1, 6));
|
||||
|
||||
$this->controller->index(
|
||||
$this->makeGet('/', ['categorie' => 'php']),
|
||||
@@ -165,8 +166,8 @@ final class PostControllerTest extends ControllerTestCase
|
||||
$this->sessionManager->method('isAdmin')->willReturn(true);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
|
||||
$this->postService->expects($this->once())->method('getAllPosts')->willReturn([]);
|
||||
$this->postService->expects($this->never())->method('getPostsByUserId');
|
||||
$this->postService->expects($this->once())->method('getAllPostsPaginated')->with(1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->postService->expects($this->never())->method('getPostsByUserIdPaginated');
|
||||
|
||||
$res = $this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse());
|
||||
|
||||
@@ -182,8 +183,8 @@ final class PostControllerTest extends ControllerTestCase
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->sessionManager->method('getUserId')->willReturn(5);
|
||||
|
||||
$this->postService->expects($this->once())->method('getPostsByUserId')->with(5, null)->willReturn([]);
|
||||
$this->postService->expects($this->never())->method('getAllPosts');
|
||||
$this->postService->expects($this->once())->method('getPostsByUserIdPaginated')->with(5, 1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->postService->expects($this->never())->method('getAllPostsPaginated');
|
||||
|
||||
$this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse());
|
||||
}
|
||||
@@ -197,9 +198,9 @@ final class PostControllerTest extends ControllerTestCase
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
|
||||
$this->postService->expects($this->once())
|
||||
->method('searchPosts')
|
||||
->with('php', null, null)
|
||||
->willReturn([]);
|
||||
->method('searchPostsPaginated')
|
||||
->with('php', 1, 12, null, null)
|
||||
->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
|
||||
$this->controller->admin(
|
||||
$this->makeGet('/admin/posts', ['q' => 'php']),
|
||||
@@ -492,7 +493,7 @@ final class PostControllerTest extends ControllerTestCase
|
||||
* Crée une entité Post de test avec les paramètres minimaux.
|
||||
*
|
||||
* Nommé buildPostEntity (et non makePost) pour ne pas masquer
|
||||
* ControllerTestCase::makePost() qui forge une requête HTTP.
|
||||
* ControllerTestBase::makePost() qui forge une requête HTTP.
|
||||
*/
|
||||
private function buildPostEntity(
|
||||
int $id,
|
||||
|
||||
@@ -143,9 +143,7 @@ final class PostRepositoryTest extends TestCase
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $p): bool =>
|
||||
isset($p[':category_id']) && $p[':category_id'] === 3
|
||||
));
|
||||
->with([':category_id' => 3]);
|
||||
|
||||
$this->repository->findAll(3);
|
||||
}
|
||||
@@ -217,9 +215,7 @@ final class PostRepositoryTest extends TestCase
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $p): bool =>
|
||||
isset($p[':author_id']) && $p[':author_id'] === 7
|
||||
));
|
||||
->with([':author_id' => 7]);
|
||||
|
||||
$this->repository->findByUserId(7);
|
||||
}
|
||||
@@ -234,11 +230,7 @@ final class PostRepositoryTest extends TestCase
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $p): bool =>
|
||||
isset($p[':author_id'], $p[':category_id'])
|
||||
&& $p[':author_id'] === 7
|
||||
&& $p[':category_id'] === 3
|
||||
));
|
||||
->with([':author_id' => 7, ':category_id' => 3]);
|
||||
|
||||
$this->repository->findByUserId(7, 3);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Post\Post;
|
||||
use App\Post\PostServiceInterface;
|
||||
use App\Post\RssController;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestCase;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour RssController.
|
||||
@@ -20,7 +20,7 @@ use Tests\ControllerTestCase;
|
||||
* - Appel à getRecentPosts() avec la constante FEED_LIMIT (20)
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class RssControllerTest extends ControllerTestCase
|
||||
final class RssControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var PostServiceInterface&MockObject */
|
||||
private PostServiceInterface $postService;
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\User;
|
||||
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
use App\User\Exception\DuplicateEmailException;
|
||||
use App\User\Exception\DuplicateUsernameException;
|
||||
use App\User\Exception\WeakPasswordException;
|
||||
@@ -12,7 +13,7 @@ use App\User\User;
|
||||
use App\User\UserController;
|
||||
use App\User\UserServiceInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestCase;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour UserController.
|
||||
@@ -25,7 +26,7 @@ use Tests\ControllerTestCase;
|
||||
* - delete() : introuvable, cible admin, soi-même, succès
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class UserControllerTest extends ControllerTestCase
|
||||
final class UserControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
@@ -63,7 +64,7 @@ final class UserControllerTest extends ControllerTestCase
|
||||
*/
|
||||
public function testIndexRendersWithUserList(): void
|
||||
{
|
||||
$this->userService->method('findAll')->willReturn([]);
|
||||
$this->userService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20));
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$this->view->expects($this->once())
|
||||
|
||||
@@ -235,7 +235,7 @@ final class UserServiceTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* updateRole() accepte les trois rôles valides sans lever d'exception.
|
||||
* updateRole() accepte uniquement les rôles attribuables depuis l'interface.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('validRolesProvider')]
|
||||
public function testUpdateRoleAcceptsAllValidRoles(string $role): void
|
||||
@@ -254,7 +254,6 @@ final class UserServiceTest extends TestCase
|
||||
return [
|
||||
'user' => [User::ROLE_USER],
|
||||
'editor' => [User::ROLE_EDITOR],
|
||||
'admin' => [User::ROLE_ADMIN],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,7 @@
|
||||
<input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
|
||||
<input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
|
||||
<button type="submit" class="btn btn--sm btn--danger"
|
||||
onclick="return confirm('Supprimer la catégorie « {{ category.name }} » ?
|
||||
|
||||
Cette action est impossible si des articles lui sont rattachés.')">
|
||||
onclick="return confirm('Supprimer la catégorie « {{ category.name }} » ?\n\nCette action est impossible si des articles lui sont rattachés.')">
|
||||
Supprimer
|
||||
</button>
|
||||
</form>
|
||||
@@ -66,6 +64,8 @@ Cette action est impossible si des articles lui sont rattachés.')">
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
|
||||
{% else %}
|
||||
<p><em>Aucune catégorie créée.</em></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
<tr>
|
||||
<th>Aperçu</th>
|
||||
<th>URL</th>
|
||||
<th>Usage</th>
|
||||
<th>Uploadé le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in media %}
|
||||
{% set usage = mediaUsage[item.id] ?? {'count': 0, 'posts': []} %}
|
||||
<tr>
|
||||
<td data-label="Aperçu">
|
||||
<div class="upload">
|
||||
@@ -47,6 +49,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Usage">
|
||||
{% if usage.count > 0 %}
|
||||
<strong>{{ usage.count }} article{{ usage.count > 1 ? 's' : '' }}</strong>
|
||||
<ul>
|
||||
{% for post in usage.posts %}
|
||||
<li><a href="/admin/posts/edit/{{ post.id }}">{{ post.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="admin-table__muted">Aucun</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Uploadé le">{{ item.createdAt|date("d/m/Y H:i") }}</td>
|
||||
<td data-label="Actions">
|
||||
<div class="u-inline-actions">
|
||||
@@ -54,7 +68,8 @@
|
||||
<input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
|
||||
<input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
|
||||
<button type="submit" class="btn btn--sm btn--danger"
|
||||
onclick="return confirm('Supprimer ce fichier ?\n\nAttention : s\'il est utilisé dans un article, l\'image n\'apparaîtra plus.')">
|
||||
{% if usage.count > 0 %}disabled title="Supprimez ou remplacez d'abord l'image dans les articles référencés"{% endif %}
|
||||
onclick="return confirm('Supprimer ce fichier ?')">
|
||||
Supprimer
|
||||
</button>
|
||||
</form>
|
||||
@@ -64,6 +79,8 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
|
||||
{% else %}
|
||||
<p><em>Aucun fichier uploadé.</em></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
{% if searchQuery %}
|
||||
<p class="search-bar__info">
|
||||
{% if posts is not empty %}
|
||||
{{ posts|length }} résultat{{ posts|length > 1 ? 's' : '' }} pour « {{ searchQuery }} »
|
||||
{% if totalPosts > 0 %}
|
||||
{{ totalPosts }} résultat{{ totalPosts > 1 ? 's' : '' }} pour « {{ searchQuery }} »
|
||||
{% else %}
|
||||
Aucun résultat pour « {{ searchQuery }} »
|
||||
{% endif %}
|
||||
@@ -74,8 +74,7 @@
|
||||
<td data-label="Titre"><strong>{{ post.title }}</strong></td>
|
||||
<td data-label="Catégorie">
|
||||
{% if post.categoryName %}
|
||||
<a href="/admin/posts?categorie={{ post.categorySlug }}"
|
||||
class="badge badge--category">{{ post.categoryName }}</a>
|
||||
<a href="/admin/posts?categorie={{ post.categorySlug }}" class="badge badge--category">{{ post.categoryName }}</a>
|
||||
{% else %}
|
||||
<span class="admin-table__muted">—</span>
|
||||
{% endif %}
|
||||
@@ -101,6 +100,8 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
|
||||
{% else %}
|
||||
<p><em>{% if searchQuery %}Aucun résultat pour « {{ searchQuery }} ».{% else %}Aucun article à gérer.{% endif %}</em></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -54,10 +54,12 @@
|
||||
<label for="role" class="form-container__label">
|
||||
<span>Rôle</span>
|
||||
<select id="role" name="role" class="form-container__select">
|
||||
<option value="user">Utilisateur</option>
|
||||
<option value="editor">Éditeur</option>
|
||||
{% for role in assignableRoles %}
|
||||
<option value="{{ role }}">{% if role == 'editor' %}Éditeur{% else %}Utilisateur{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<small class="form-container__hint">Le rôle administrateur reste réservé au provisionnement initial.</small>
|
||||
</p>
|
||||
|
||||
<div class="form-container__actions">
|
||||
|
||||
@@ -58,9 +58,11 @@
|
||||
<input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
|
||||
<div class="u-inline-actions">
|
||||
<select name="role" class="admin-table__role-select">
|
||||
<option value="user" {% if user.role == 'user' %}selected{% endif %}>Utilisateur</option>
|
||||
<option value="editor" {% if user.role == 'editor' %}selected{% endif %}>Éditeur</option>
|
||||
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>Admin</option>
|
||||
{% for role in assignableRoles %}
|
||||
<option value="{{ role }}" {% if user.role == role %}selected{% endif %}>
|
||||
{% if role == 'editor' %}Éditeur{% else %}Utilisateur{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn--sm btn--secondary">Modifier</button>
|
||||
</div>
|
||||
@@ -89,6 +91,8 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
|
||||
{% else %}
|
||||
<p><em>Aucun utilisateur.</em></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
|
||||
{% if searchQuery %}
|
||||
<p class="search-bar__info">
|
||||
{% if posts is not empty %}
|
||||
{{ posts|length }} résultat{{ posts|length > 1 ? 's' : '' }} pour « {{ searchQuery }} »
|
||||
{% if totalPosts > 0 %}
|
||||
{{ totalPosts }} résultat{{ totalPosts > 1 ? 's' : '' }} pour « {{ searchQuery }} »
|
||||
{% else %}
|
||||
Aucun résultat pour « {{ searchQuery }} »
|
||||
{% endif %}
|
||||
@@ -53,7 +53,6 @@
|
||||
{% for post in posts %}
|
||||
{% set thumb = post_thumbnail(post) %}
|
||||
<article class="card">
|
||||
|
||||
<a href="{{ post_url(post) }}" class="card__thumb-link" tabindex="-1" aria-hidden="true">
|
||||
{% if thumb %}
|
||||
<img class="card__thumb" src="{{ thumb }}" alt="">
|
||||
@@ -85,10 +84,11 @@
|
||||
<a href="{{ post_url(post) }}" class="card__actions-link">Lire la suite →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
{% else %}
|
||||
<p>Aucun article publié{% if searchQuery %} pour « {{ searchQuery }} »{% elseif activeCategory %} dans cette catégorie{% endif %}.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include 'partials/_pagination.twig' with { pagination: pagination } %}
|
||||
{% endblock %}
|
||||
|
||||
29
views/partials/_pagination.twig
Normal file
29
views/partials/_pagination.twig
Normal file
@@ -0,0 +1,29 @@
|
||||
{% if pagination.totalPages > 1 %}
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
{% if pagination.hasPrevious %}
|
||||
<a href="{{ pagination.previousUrl }}" class="btn btn--sm btn--secondary">← Précédent</a>
|
||||
{% else %}
|
||||
<span class="admin-table__muted">← Précédent</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="pagination__summary">
|
||||
Page {{ pagination.currentPage }} sur {{ pagination.totalPages }}
|
||||
</span>
|
||||
|
||||
<span class="pagination__pages">
|
||||
{% for page in pagination.pages %}
|
||||
{% if page.current %}
|
||||
<strong>{{ page.number }}</strong>
|
||||
{% else %}
|
||||
<a href="{{ page.url }}">{{ page.number }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</span>
|
||||
|
||||
{% if pagination.hasNext %}
|
||||
<a href="{{ pagination.nextUrl }}" class="btn btn--sm btn--secondary">Suivant →</a>
|
||||
{% else %}
|
||||
<span class="admin-table__muted">Suivant →</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user