diff --git a/config/container.php b/config/container.php index 8540f15..4b05e68 100644 --- a/config/container.php +++ b/config/container.php @@ -21,18 +21,22 @@ use App\Auth\PasswordResetRepository; use App\Auth\PasswordResetRepositoryInterface; use App\Auth\PasswordResetService; use App\Auth\PasswordResetServiceInterface; +use App\Category\Application\CategoryApplicationService; use App\Category\CategoryRepository; use App\Category\CategoryRepositoryInterface; use App\Category\CategoryService; use App\Category\CategoryServiceInterface; +use App\Category\Infrastructure\PdoCategoryRepository; use App\Media\MediaRepository; use App\Media\MediaRepositoryInterface; use App\Media\MediaService; use App\Media\MediaServiceInterface; +use App\Post\Application\PostApplicationService; use App\Post\PostRepository; use App\Post\PostRepositoryInterface; use App\Post\PostService; use App\Post\PostServiceInterface; +use App\Post\Infrastructure\PdoPostRepository; use App\Post\RssController; use App\Shared\Config; use App\Shared\Extension\AppExtension; @@ -65,12 +69,12 @@ return [ // ── Bindings interface → implémentation ────────────────────────────────── AuthServiceInterface::class => autowire(AuthService::class), - PostServiceInterface::class => autowire(PostService::class), + PostServiceInterface::class => autowire(PostApplicationService::class), UserServiceInterface::class => autowire(UserService::class), - CategoryServiceInterface::class => autowire(CategoryService::class), - CategoryRepositoryInterface::class => autowire(CategoryRepository::class), + CategoryServiceInterface::class => autowire(CategoryApplicationService::class), + CategoryRepositoryInterface::class => autowire(PdoCategoryRepository::class), MediaRepositoryInterface::class => autowire(MediaRepository::class), - PostRepositoryInterface::class => autowire(PostRepository::class), + PostRepositoryInterface::class => autowire(PdoPostRepository::class), UserRepositoryInterface::class => autowire(UserRepository::class), LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class), PasswordResetRepositoryInterface::class => autowire(PasswordResetRepository::class), diff --git a/database/.provision.lock b/database/.provision.lock new file mode 100644 index 0000000..e69de29 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b43a585..941568c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,13 @@ # Architecture +> **Refactor DDD légère — lot 1** +> +> `Post/` et `Category/` introduisent maintenant une organisation verticale +> `Application / Infrastructure / Http / Domain` pour alléger la lecture et préparer +> un découpage plus fin par cas d'usage. Les classes historiques à la racine du domaine +> sont conservées comme **ponts de compatibilité** afin de préserver les routes, le conteneur +> DI et la suite de tests pendant la transition. + ## Domaines PHP Chaque domaine dans `src/` est autonome : modèle, interface de dépôt, implémentation du dépôt, diff --git a/src/Category/Application/CategoryApplicationService.php b/src/Category/Application/CategoryApplicationService.php new file mode 100644 index 0000000..a25ffb3 --- /dev/null +++ b/src/Category/Application/CategoryApplicationService.php @@ -0,0 +1,75 @@ +categoryRepository->findAll(); + } + + /** @return PaginatedResult */ + 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); + } + + public function findBySlug(string $slug): ?Category + { + return $this->categoryRepository->findBySlug($slug); + } + + public function create(string $name): int + { + $name = trim($name); + $slug = $this->slugGenerator->generate($name); + + if ($slug === '') { + throw new \InvalidArgumentException('Le nom fourni ne peut pas générer un slug URL valide'); + } + + if ($this->categoryRepository->nameExists($name)) { + throw new \InvalidArgumentException('Ce nom de catégorie est déjà utilisé'); + } + + return $this->categoryRepository->create(new Category(0, $name, $slug)); + } + + public function delete(Category $category): void + { + if ($this->categoryRepository->hasPost($category->getId())) { + throw new \InvalidArgumentException("La catégorie « {$category->getName()} » contient des articles et ne peut pas être supprimée"); + } + + $this->categoryRepository->delete($category->getId()); + } +} diff --git a/src/Category/CategoryController.php b/src/Category/CategoryController.php index d2f386e..31fd399 100644 --- a/src/Category/CategoryController.php +++ b/src/Category/CategoryController.php @@ -3,76 +3,10 @@ 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; - -final class CategoryController +/** + * Pont de compatibilité : le contrôleur HTTP principal vit désormais dans + * App\Category\Http\CategoryController. + */ +final class CategoryController extends Http\CategoryController { - private const PER_PAGE = 20; - - public function __construct( - private readonly Twig $view, - private readonly CategoryServiceInterface $categoryService, - private readonly FlashServiceInterface $flash, - ) { - } - - 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' => $paginated->getItems(), - 'pagination' => PaginationPresenter::fromRequest($req, $paginated), - 'error' => $this->flash->get('category_error'), - 'success' => $this->flash->get('category_success'), - ]); - } - - public function create(Request $req, Response $res): Response - { - $data = (array) $req->getParsedBody(); - $name = (string) ($data['name'] ?? ''); - - try { - $this->categoryService->create($name); - $trimmed = trim($name); - $this->flash->set('category_success', "La catégorie « {$trimmed} » a été créée avec succès"); - } catch (\InvalidArgumentException $e) { - $this->flash->set('category_error', $e->getMessage()); - } catch (\Throwable) { - $this->flash->set('category_error', "Une erreur inattendue s'est produite"); - } - - return $res->withHeader('Location', '/admin/categories')->withStatus(302); - } - - /** - * @param array $args - */ - public function delete(Request $req, Response $res, array $args): Response - { - $id = (int) ($args['id'] ?? 0); - $category = $this->categoryService->findById($id); - - if ($category === null) { - $this->flash->set('category_error', 'Catégorie introuvable'); - return $res->withHeader('Location', '/admin/categories')->withStatus(302); - } - - try { - $this->categoryService->delete($category); - $this->flash->set('category_success', "La catégorie « {$category->getName()} » a été supprimée"); - } catch (\InvalidArgumentException $e) { - $this->flash->set('category_error', $e->getMessage()); - } catch (\Throwable) { - $this->flash->set('category_error', "Une erreur inattendue s'est produite"); - } - - return $res->withHeader('Location', '/admin/categories')->withStatus(302); - } } diff --git a/src/Category/CategoryRepository.php b/src/Category/CategoryRepository.php index b626421..4e96208 100644 --- a/src/Category/CategoryRepository.php +++ b/src/Category/CategoryRepository.php @@ -3,91 +3,12 @@ declare(strict_types=1); namespace App\Category; -use PDO; +use App\Category\Infrastructure\PdoCategoryRepository; -final class CategoryRepository implements CategoryRepositoryInterface +/** + * Pont de compatibilité : l'implémentation PDO principale vit désormais dans + * App\Category\Infrastructure\PdoCategoryRepository. + */ +final class CategoryRepository extends PdoCategoryRepository implements CategoryRepositoryInterface { - public function __construct(private readonly PDO $db) - { - } - - 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é.'); - } - - 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); - } - - 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; - } - - 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; - } - - public function create(Category $category): int - { - $stmt = $this->db->prepare('INSERT INTO categories (name, slug) VALUES (:name, :slug)'); - $stmt->execute([':name' => $category->getName(), ':slug' => $category->getSlug()]); - - return (int) $this->db->lastInsertId(); - } - - public function delete(int $id): int - { - $stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id'); - $stmt->execute([':id' => $id]); - - return $stmt->rowCount(); - } - - public function nameExists(string $name): bool - { - $stmt = $this->db->prepare('SELECT 1 FROM categories WHERE name = :name'); - $stmt->execute([':name' => $name]); - - return $stmt->fetchColumn() !== false; - } - - public function hasPost(int $id): bool - { - $stmt = $this->db->prepare('SELECT 1 FROM posts WHERE category_id = :id'); - $stmt->execute([':id' => $id]); - - return $stmt->fetchColumn() !== false; - } } diff --git a/src/Category/CategoryService.php b/src/Category/CategoryService.php index 3bfd4ba..9fa19d7 100644 --- a/src/Category/CategoryService.php +++ b/src/Category/CategoryService.php @@ -3,72 +3,12 @@ declare(strict_types=1); namespace App\Category; -use App\Shared\Pagination\PaginatedResult; -use App\Shared\Util\SlugHelper; +use App\Category\Application\CategoryApplicationService; -final class CategoryService implements CategoryServiceInterface +/** + * Pont de compatibilité : l'implémentation métier principale vit désormais dans + * App\Category\Application\CategoryApplicationService. + */ +final class CategoryService extends CategoryApplicationService implements CategoryServiceInterface { - public function __construct( - private readonly CategoryRepositoryInterface $categoryRepository, - ) { - } - - public function findAll(): array - { - return $this->categoryRepository->findAll(); - } - - /** - * @return PaginatedResult - */ - 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); - } - - public function findBySlug(string $slug): ?Category - { - return $this->categoryRepository->findBySlug($slug); - } - - public function create(string $name): int - { - $name = trim($name); - $slug = SlugHelper::generate($name); - - if ($slug === '') { - throw new \InvalidArgumentException('Le nom fourni ne peut pas générer un slug URL valide'); - } - - if ($this->categoryRepository->nameExists($name)) { - throw new \InvalidArgumentException('Ce nom de catégorie est déjà utilisé'); - } - - return $this->categoryRepository->create(new Category(0, $name, $slug)); - } - - public function delete(Category $category): void - { - if ($this->categoryRepository->hasPost($category->getId())) { - throw new \InvalidArgumentException( - "La catégorie « {$category->getName()} » contient des articles et ne peut pas être supprimée" - ); - } - - $this->categoryRepository->delete($category->getId()); - } } diff --git a/src/Category/Domain/CategorySlugGenerator.php b/src/Category/Domain/CategorySlugGenerator.php new file mode 100644 index 0000000..b042071 --- /dev/null +++ b/src/Category/Domain/CategorySlugGenerator.php @@ -0,0 +1,14 @@ +getQueryParams()); + $paginated = $this->categoryService->findPaginated($page, self::PER_PAGE); + + return $this->view->render($res, 'admin/categories/index.twig', [ + 'categories' => $paginated->getItems(), + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'error' => $this->flash->get('category_error'), + 'success' => $this->flash->get('category_success'), + ]); + } + + public function create(Request $req, Response $res): Response + { + $data = (array) $req->getParsedBody(); + $name = (string) ($data['name'] ?? ''); + + try { + $this->categoryService->create($name); + $trimmed = trim($name); + $this->flash->set('category_success', "La catégorie « {$trimmed} » a été créée avec succès"); + } catch (\InvalidArgumentException $e) { + $this->flash->set('category_error', $e->getMessage()); + } catch (\Throwable) { + $this->flash->set('category_error', "Une erreur inattendue s'est produite"); + } + + return $res->withHeader('Location', '/admin/categories')->withStatus(302); + } + + /** @param array $args */ + public function delete(Request $req, Response $res, array $args): Response + { + $id = (int) ($args['id'] ?? 0); + $category = $this->categoryService->findById($id); + + if ($category === null) { + $this->flash->set('category_error', 'Catégorie introuvable'); + return $res->withHeader('Location', '/admin/categories')->withStatus(302); + } + + try { + $this->categoryService->delete($category); + $this->flash->set('category_success', "La catégorie « {$category->getName()} » a été supprimée"); + } catch (\InvalidArgumentException $e) { + $this->flash->set('category_error', $e->getMessage()); + } catch (\Throwable) { + $this->flash->set('category_error', "Une erreur inattendue s'est produite"); + } + + return $res->withHeader('Location', '/admin/categories')->withStatus(302); + } +} diff --git a/src/Category/Infrastructure/PdoCategoryRepository.php b/src/Category/Infrastructure/PdoCategoryRepository.php new file mode 100644 index 0000000..1829ab3 --- /dev/null +++ b/src/Category/Infrastructure/PdoCategoryRepository.php @@ -0,0 +1,106 @@ +db->query('SELECT * FROM categories ORDER BY name ASC'); + if ($stmt === false) { + throw new \RuntimeException('La requête SELECT sur categories a échoué.'); + } + + return array_map( + static fn (array $row): Category => Category::fromArray($row), + $stmt->fetchAll(PDO::FETCH_ASSOC) + ); + } + + /** @return Category[] */ + 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( + static fn (array $row): Category => 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('Le comptage des catégories a échoué.'); + } + + return (int) $stmt->fetchColumn(); + } + + 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; + } + + 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; + } + + public function create(Category $category): int + { + $stmt = $this->db->prepare('INSERT INTO categories (name, slug) VALUES (:name, :slug)'); + $stmt->execute([ + ':name' => $category->getName(), + ':slug' => $category->getSlug(), + ]); + + return (int) $this->db->lastInsertId(); + } + + public function delete(int $id): int + { + $stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id'); + $stmt->execute([':id' => $id]); + + return $stmt->rowCount(); + } + + public function nameExists(string $name): bool + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM categories WHERE name = :name'); + $stmt->execute([':name' => $name]); + + return (int) $stmt->fetchColumn() > 0; + } + + public function hasPost(int $id): bool + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :id'); + $stmt->execute([':id' => $id]); + + return (int) $stmt->fetchColumn() > 0; + } +} diff --git a/src/Post/Application/PostApplicationService.php b/src/Post/Application/PostApplicationService.php new file mode 100644 index 0000000..9a00448 --- /dev/null +++ b/src/Post/Application/PostApplicationService.php @@ -0,0 +1,174 @@ +postRepository->findAll($categoryId); + } + + /** @return PaginatedResult */ + 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, + ); + } + + /** @return Post[] */ + public function getRecentPosts(int $limit = 20): array + { + return $this->postRepository->findRecent($limit); + } + + /** @return Post[] */ + public function getPostsByUserId(int $userId, ?int $categoryId = null): array + { + return $this->postRepository->findByUserId($userId, $categoryId); + } + + /** @return PaginatedResult */ + 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); + + if ($post === null) { + throw new NotFoundException('Article', $slug); + } + + return $post; + } + + public function getPostById(int $id): Post + { + $post = $this->postRepository->findById($id); + + if ($post === null) { + throw new NotFoundException('Article', $id); + } + + return $post; + } + + public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int + { + $sanitizedContent = $this->htmlSanitizer->sanitize($content); + $post = new Post(0, $title, $sanitizedContent); + $slug = $this->generateUniqueSlug($post->generateSlug()); + + return $this->postRepository->create($post, $slug, $authorId, $categoryId); + } + + public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void + { + $current = $this->postRepository->findById($id); + + if ($current === null) { + throw new NotFoundException('Article', $id); + } + + $sanitizedContent = $this->htmlSanitizer->sanitize($content); + $post = new Post($id, $title, $sanitizedContent); + $slugToUse = $current->getStoredSlug(); + $cleanSlugInput = $this->slugGenerator->normalize(trim($newSlugInput)); + + if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) { + $slugToUse = $this->generateUniqueSlug($cleanSlugInput, $id); + } + + $affected = $this->postRepository->update($id, $post, $slugToUse, $categoryId); + + if ($affected === 0) { + throw new NotFoundException('Article', $id); + } + } + + /** @return Post[] */ + public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array + { + return $this->postRepository->search($query, $categoryId, $authorId); + } + + /** @return PaginatedResult */ + 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); + } + + /** @return Post[] */ + 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); + + if ($affected === 0) { + throw new NotFoundException('Article', $id); + } + } + + private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string + { + return $this->slugGenerator->unique( + $baseSlug, + fn (string $slug): bool => $this->postRepository->slugExists($slug, $excludeId), + ); + } +} diff --git a/src/Post/Domain/PostSlugGenerator.php b/src/Post/Domain/PostSlugGenerator.php new file mode 100644 index 0000000..8f9bb70 --- /dev/null +++ b/src/Post/Domain/PostSlugGenerator.php @@ -0,0 +1,27 @@ +getQueryParams(); + $page = PaginationPresenter::resolvePage($params); + $searchQuery = trim((string) ($params['q'] ?? '')); + $categorySlug = (string) ($params['categorie'] ?? ''); + $activeCategory = null; + $categoryId = null; + + if ($categorySlug !== '') { + $activeCategory = $this->categoryService->findBySlug($categorySlug); + $categoryId = $activeCategory?->getId(); + } + + $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' => $paginated->getItems(), + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'totalPosts' => $paginated->getTotal(), + 'categories' => $this->categoryService->findAll(), + 'activeCategory' => $activeCategory, + 'searchQuery' => $searchQuery, + ]); + } + + /** @param array $args */ + public function show(Request $req, Response $res, array $args): Response + { + try { + $post = $this->postService->getPostBySlug((string) ($args['slug'] ?? '')); + } catch (NotFoundException) { + throw new HttpNotFoundException($req); + } + + return $this->view->render($res, 'pages/post/detail.twig', ['post' => $post]); + } + + 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; + $categoryId = null; + + if ($categorySlug !== '') { + $activeCategory = $this->categoryService->findBySlug($categorySlug); + $categoryId = $activeCategory?->getId(); + } + + if ($searchQuery !== '') { + $authorId = $isAdmin ? null : (int) $userId; + $paginated = $this->postService->searchPostsPaginated( + $searchQuery, + $page, + self::ADMIN_PER_PAGE, + $categoryId, + $authorId, + ); + } else { + $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' => $paginated->getItems(), + 'pagination' => PaginationPresenter::fromRequest($req, $paginated), + 'totalPosts' => $paginated->getTotal(), + 'categories' => $this->categoryService->findAll(), + 'activeCategory' => $activeCategory, + 'searchQuery' => $searchQuery, + 'error' => $this->flash->get('post_error'), + 'success' => $this->flash->get('post_success'), + ]); + } + + /** @param array $args */ + public function form(Request $req, Response $res, array $args): Response + { + $id = (int) ($args['id'] ?? 0); + $post = null; + + if ($id > 0) { + try { + $post = $this->postService->getPostById($id); + } catch (NotFoundException) { + throw new HttpNotFoundException($req); + } + + if (!$this->canEditPost($post)) { + $this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur"); + + return $res->withHeader('Location', '/admin/posts')->withStatus(302); + } + } + + return $this->view->render($res, 'admin/posts/form.twig', [ + 'post' => $post, + 'categories' => $this->categoryService->findAll(), + 'action' => $id > 0 ? "/admin/posts/edit/{$id}" : '/admin/posts/create', + 'error' => $this->flash->get('post_error'), + ]); + } + + public function create(Request $req, Response $res): Response + { + ['title' => $title, 'content' => $content, 'category_id' => $categoryId] = $this->extractPostData($req); + + try { + $this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId); + $this->flash->set('post_success', 'L\'article a été créé avec succès'); + } catch (\InvalidArgumentException $e) { + $this->flash->set('post_error', $e->getMessage()); + + return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302); + } catch (\Throwable) { + $this->flash->set('post_error', 'Une erreur inattendue s\'est produite'); + + return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302); + } + + return $res->withHeader('Location', '/admin/posts')->withStatus(302); + } + + /** @param array $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); + + try { + $post = $this->postService->getPostById($id); + } catch (NotFoundException) { + throw new HttpNotFoundException($req); + } + + if (!$this->canEditPost($post)) { + $this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur"); + + return $res->withHeader('Location', '/admin/posts')->withStatus(302); + } + + try { + $this->postService->updatePost($id, $title, $content, $slug, $categoryId); + $this->flash->set('post_success', 'L\'article a été modifié avec succès'); + } catch (NotFoundException) { + throw new HttpNotFoundException($req); + } catch (\InvalidArgumentException $e) { + $this->flash->set('post_error', $e->getMessage()); + + return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302); + } catch (\Throwable) { + $this->flash->set('post_error', 'Une erreur inattendue s\'est produite'); + + return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302); + } + + return $res->withHeader('Location', '/admin/posts')->withStatus(302); + } + + /** @param array $args */ + public function delete(Request $req, Response $res, array $args): Response + { + try { + $post = $this->postService->getPostById((int) $args['id']); + } catch (NotFoundException) { + throw new HttpNotFoundException($req); + } + + if (!$this->canEditPost($post)) { + $this->flash->set('post_error', "Vous ne pouvez pas supprimer un article dont vous n'êtes pas l'auteur"); + + return $res->withHeader('Location', '/admin/posts')->withStatus(302); + } + + try { + $this->postService->deletePost($post->getId()); + } catch (NotFoundException) { + throw new HttpNotFoundException($req); + } + + $this->flash->set('post_success', "L'article « {$post->getTitle()} » a été supprimé avec succès"); + + return $res->withHeader('Location', '/admin/posts')->withStatus(302); + } + + private function canEditPost(Post $post): bool + { + if ($this->sessionManager->isAdmin() || $this->sessionManager->isEditor()) { + return true; + } + + return $post->getAuthorId() === $this->sessionManager->getUserId(); + } + + /** @return array{title: string, content: string, slug: string, category_id: ?int} */ + private function extractPostData(Request $req): array + { + $data = (array) $req->getParsedBody(); + + return [ + 'title' => trim((string) ($data['title'] ?? '')), + 'content' => (string) ($data['content'] ?? ''), + 'slug' => trim((string) ($data['slug'] ?? '')), + 'category_id' => isset($data['category_id']) && $data['category_id'] !== '' ? (int) $data['category_id'] : null, + ]; + } +} diff --git a/src/Post/Http/RssController.php b/src/Post/Http/RssController.php new file mode 100644 index 0000000..ca9cb23 --- /dev/null +++ b/src/Post/Http/RssController.php @@ -0,0 +1,57 @@ +postService->getRecentPosts(self::FEED_LIMIT); + $baseUrl = $this->appUrl; + + $xml = new \SimpleXMLElement(''); + $channel = $xml->addChild('channel'); + $channel->addChild('title', htmlspecialchars($this->appName)); + $channel->addChild('link', $baseUrl . '/'); + $channel->addChild('description', htmlspecialchars($this->appName . ' — flux RSS')); + $channel->addChild('language', 'fr-FR'); + $channel->addChild('lastBuildDate', (new \DateTime())->format(\DateTime::RSS)); + + foreach ($posts as $post) { + $item = $channel->addChild('item'); + $item->addChild('title', htmlspecialchars($post->getTitle())); + $postUrl = $baseUrl . '/article/' . $post->getStoredSlug(); + $item->addChild('link', $postUrl); + $item->addChild('guid', $postUrl); + $excerpt = strip_tags($post->getContent()); + $excerpt = mb_strlen($excerpt) > 300 ? mb_substr($excerpt, 0, 300) . '…' : $excerpt; + $item->addChild('description', htmlspecialchars($excerpt)); + $item->addChild('pubDate', $post->getCreatedAt()->format(\DateTime::RSS)); + if ($post->getAuthorUsername() !== null) { + $item->addChild('author', htmlspecialchars($post->getAuthorUsername())); + } + if ($post->getCategoryName() !== null) { + $item->addChild('category', htmlspecialchars($post->getCategoryName())); + } + } + + $body = $xml->asXML(); + $res->getBody()->write($body !== false ? $body : ''); + + return $res->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); + } +} diff --git a/src/Post/Infrastructure/PdoPostRepository.php b/src/Post/Infrastructure/PdoPostRepository.php new file mode 100644 index 0000000..90458d5 --- /dev/null +++ b/src/Post/Infrastructure/PdoPostRepository.php @@ -0,0 +1,332 @@ +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)); + } + + $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC'); + $stmt->execute([':category_id' => $categoryId]); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return Post[] */ + 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('Le comptage des posts a échoué.'); + } + + return (int) $stmt->fetchColumn(); + } + + $stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id'); + $stmt->execute([':category_id' => $categoryId]); + + return (int) $stmt->fetchColumn(); + } + + /** @return Post[] */ + public function findRecent(int $limit): array + { + $stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.created_at DESC LIMIT :limit'); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return Post[] */ + 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) { + $sql .= ' AND posts.category_id = :category_id'; + $params[':category_id'] = $categoryId; + } + + $sql .= ' ORDER BY posts.id DESC'; + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return Post[] */ + 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(); + } + + 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; + } + + 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; + } + + public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int + { + $stmt = $this->db->prepare( + 'INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at) + VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)' + ); + + $stmt->execute([ + ':title' => $post->getTitle(), + ':content' => $post->getContent(), + ':slug' => $slug, + ':author_id' => $authorId, + ':category_id' => $categoryId, + ':created_at' => date('Y-m-d H:i:s'), + ':updated_at' => date('Y-m-d H:i:s'), + ]); + + return (int) $this->db->lastInsertId(); + } + + public function update(int $id, Post $post, string $slug, ?int $categoryId): int + { + $stmt = $this->db->prepare( + 'UPDATE posts + SET title = :title, content = :content, slug = :slug, + category_id = :category_id, updated_at = :updated_at + WHERE id = :id' + ); + + $stmt->execute([ + ':title' => $post->getTitle(), + ':content' => $post->getContent(), + ':slug' => $slug, + ':category_id' => $categoryId, + ':updated_at' => date('Y-m-d H:i:s'), + ':id' => $id, + ]); + + return $stmt->rowCount(); + } + + public function delete(int $id): int + { + $stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id'); + $stmt->execute([':id' => $id]); + + return $stmt->rowCount(); + } + + /** @return Post[] */ + public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array + { + $params = [':q' => $query]; + $sql = $this->buildSearchSql($query, $params, $categoryId, $authorId); + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return Post[] */ + public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array + { + $params = [':q' => $query]; + $sql = $this->buildSearchSql($query, $params, $categoryId, $authorId); + $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 countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int + { + $params = [':q' => $query]; + $sql = 'SELECT COUNT(*) FROM posts WHERE id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)'; + + if ($categoryId !== null) { + $sql .= ' AND category_id = :category_id'; + $params[':category_id'] = $categoryId; + } + + if ($authorId !== null) { + $sql .= ' AND author_id = :author_id'; + $params[':author_id'] = $authorId; + } + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + + return (int) $stmt->fetchColumn(); + } + + public function slugExists(string $slug, ?int $excludeId = null): bool + { + $stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug LIMIT 1'); + $stmt->execute([':slug' => $slug]); + + $existingId = $stmt->fetchColumn(); + + if ($existingId === false) { + return false; + } + + if ($excludeId !== null && (int) $existingId === $excludeId) { + return false; + } + + return true; + } + + public function countByEmbeddedMediaUrl(string $url): int + { + $stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE content LIKE :pattern'); + $stmt->execute([':pattern' => '%' . $url . '%']); + + return (int) $stmt->fetchColumn(); + } + + /** @return Post[] */ + public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array + { + $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.content LIKE :pattern ORDER BY posts.id DESC LIMIT :limit'); + $stmt->bindValue(':pattern', '%' . $url . '%', PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @param array $params */ + private function buildSearchSql(string $query, array &$params, ?int $categoryId = null, ?int $authorId = null): string + { + $sql = self::SELECT . ' WHERE posts.id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)'; + + if ($categoryId !== null) { + $sql .= ' AND posts.category_id = :category_id'; + $params[':category_id'] = $categoryId; + } + + if ($authorId !== null) { + $sql .= ' AND posts.author_id = :author_id'; + $params[':author_id'] = $authorId; + } + + return $sql; + } + + /** @param array $params */ + private function bindParams(PDOStatement $stmt, array $params): void + { + foreach ($params as $name => $value) { + $stmt->bindValue($name, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + } + + /** @param array> $rows + * @return Post[] */ + private function hydratePosts(array $rows): array + { + return array_map(static fn (array $row): Post => Post::fromArray($row), $rows); + } +} diff --git a/src/Post/Infrastructure/TwigPostExtension.php b/src/Post/Infrastructure/TwigPostExtension.php new file mode 100644 index 0000000..9bc00e9 --- /dev/null +++ b/src/Post/Infrastructure/TwigPostExtension.php @@ -0,0 +1,98 @@ + self::excerpt($post, $length), ['is_safe' => ['html']]), + new TwigFunction('post_url', fn (Post $post) => '/article/' . $post->getStoredSlug()), + new TwigFunction('post_thumbnail', fn (Post $post) => self::thumbnail($post)), + new TwigFunction('post_initials', fn (Post $post) => self::initials($post)), + ]; + } + + private static function excerpt(Post $post, int $length): string + { + $allowed = '
    1. '; + $html = strip_tags($post->getContent(), $allowed); + + if (mb_strlen(strip_tags($html)) <= $length) { + return $html; + } + + $truncated = ''; + $count = 0; + $inTag = false; + + for ($i = 0, $len = mb_strlen($html); $i < $len && $count < $length; $i++) { + $char = mb_substr($html, $i, 1); + if ($char === '<') { + $inTag = true; + } + $truncated .= $char; + if ($inTag) { + if ($char === '>') { + $inTag = false; + } + } else { + $count++; + } + } + + foreach (['li', 'ul', 'ol', 'em', 'strong', 'b', 'i'] as $tag) { + $opens = substr_count($truncated, "<{$tag}>") + substr_count($truncated, "<{$tag} "); + $closes = substr_count($truncated, ""); + for ($j = $closes; $j < $opens; $j++) { + $truncated .= ""; + } + } + + return $truncated . '…'; + } + + private static function thumbnail(Post $post): ?string + { + if (preg_match('/]+src=["\']([^"\']+)["\']/', $post->getContent(), $matches)) { + return $matches[1]; + } + + return null; + } + + private static function initials(Post $post): string + { + $stopWords = ['a', 'au', 'aux', 'd', 'de', 'des', 'du', 'en', 'et', 'l', 'la', 'le', 'les', 'of', 'the', 'un', 'une']; + $words = array_filter( + preg_split('/\s+/', trim($post->getTitle())) ?: [], + static function (string $w) use ($stopWords): bool { + $normalized = mb_strtolower(trim($w, " \t\n\r\0\x0B'\"’`.-_")); + + return $normalized !== '' && mb_strlen($normalized) > 1 && !in_array($normalized, $stopWords, true); + } + ); + + if (empty($words)) { + $first = mb_substr(trim($post->getTitle()), 0, 1); + + return $first !== '' ? mb_strtoupper($first) : '?'; + } + + $words = array_values($words); + $initials = mb_strtoupper(mb_substr($words[0], 0, 1)); + + if (isset($words[1])) { + $initials .= mb_strtoupper(mb_substr($words[1], 0, 1)); + } + + return $initials; + } +} diff --git a/src/Post/PostController.php b/src/Post/PostController.php index 0ecafb6..272229c 100644 --- a/src/Post/PostController.php +++ b/src/Post/PostController.php @@ -3,253 +3,10 @@ declare(strict_types=1); namespace App\Post; -use App\Category\CategoryServiceInterface; -use App\Shared\Exception\NotFoundException; -use App\Shared\Http\FlashServiceInterface; -use App\Shared\Http\SessionManagerInterface; -use 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; - -final class PostController +/** + * Pont de compatibilité : le contrôleur HTTP principal vit désormais dans + * App\Post\Http\PostController. + */ +final class PostController extends Http\PostController { - private const PUBLIC_PER_PAGE = 6; - private const ADMIN_PER_PAGE = 12; - - public function __construct( - private readonly Twig $view, - private readonly PostServiceInterface $postService, - private readonly CategoryServiceInterface $categoryService, - private readonly FlashServiceInterface $flash, - private readonly SessionManagerInterface $sessionManager, - ) { - } - - 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; - $categoryId = null; - - if ($categorySlug !== '') { - $activeCategory = $this->categoryService->findBySlug($categorySlug); - $categoryId = $activeCategory?->getId(); - } - - $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' => $paginated->getItems(), - 'pagination' => PaginationPresenter::fromRequest($req, $paginated), - 'totalPosts' => $paginated->getTotal(), - 'categories' => $this->categoryService->findAll(), - 'activeCategory' => $activeCategory, - 'searchQuery' => $searchQuery, - ]); - } - - /** - * @param array $args - */ - public function show(Request $req, Response $res, array $args): Response - { - try { - $post = $this->postService->getPostBySlug((string) ($args['slug'] ?? '')); - } catch (NotFoundException) { - throw new HttpNotFoundException($req); - } - - return $this->view->render($res, 'pages/post/detail.twig', ['post' => $post]); - } - - 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; - $categoryId = null; - - if ($categorySlug !== '') { - $activeCategory = $this->categoryService->findBySlug($categorySlug); - $categoryId = $activeCategory?->getId(); - } - - if ($searchQuery !== '') { - $authorId = $isAdmin ? null : (int) $userId; - $paginated = $this->postService->searchPostsPaginated( - $searchQuery, - $page, - self::ADMIN_PER_PAGE, - $categoryId, - $authorId, - ); - } else { - $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' => $paginated->getItems(), - 'pagination' => PaginationPresenter::fromRequest($req, $paginated), - 'totalPosts' => $paginated->getTotal(), - 'categories' => $this->categoryService->findAll(), - 'activeCategory' => $activeCategory, - 'searchQuery' => $searchQuery, - 'error' => $this->flash->get('post_error'), - 'success' => $this->flash->get('post_success'), - ]); - } - - /** - * @param array $args - */ - public function form(Request $req, Response $res, array $args): Response - { - $id = (int) ($args['id'] ?? 0); - $post = null; - - if ($id > 0) { - try { - $post = $this->postService->getPostById($id); - } catch (NotFoundException) { - throw new HttpNotFoundException($req); - } - - if (!$this->canEditPost($post)) { - $this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur"); - - return $res->withHeader('Location', '/admin/posts')->withStatus(302); - } - } - - return $this->view->render($res, 'admin/posts/form.twig', [ - 'post' => $post, - 'categories' => $this->categoryService->findAll(), - 'action' => $id > 0 ? "/admin/posts/edit/{$id}" : '/admin/posts/create', - 'error' => $this->flash->get('post_error'), - ]); - } - - public function create(Request $req, Response $res): Response - { - ['title' => $title, 'content' => $content, 'category_id' => $categoryId] = $this->extractPostData($req); - - try { - $this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId); - $this->flash->set('post_success', 'L\'article a été créé avec succès'); - } catch (\InvalidArgumentException $e) { - $this->flash->set('post_error', $e->getMessage()); - - return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302); - } catch (\Throwable) { - $this->flash->set('post_error', 'Une erreur inattendue s\'est produite'); - - return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302); - } - - return $res->withHeader('Location', '/admin/posts')->withStatus(302); - } - - /** - * @param array $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); - - try { - $post = $this->postService->getPostById($id); - } catch (NotFoundException) { - throw new HttpNotFoundException($req); - } - - if (!$this->canEditPost($post)) { - $this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur"); - - return $res->withHeader('Location', '/admin/posts')->withStatus(302); - } - - try { - $this->postService->updatePost($id, $title, $content, $slug, $categoryId); - $this->flash->set('post_success', 'L\'article a été modifié avec succès'); - } catch (NotFoundException) { - throw new HttpNotFoundException($req); - } catch (\InvalidArgumentException $e) { - $this->flash->set('post_error', $e->getMessage()); - - return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302); - } catch (\Throwable) { - $this->flash->set('post_error', 'Une erreur inattendue s\'est produite'); - - return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302); - } - - return $res->withHeader('Location', '/admin/posts')->withStatus(302); - } - - /** - * @param array $args - */ - public function delete(Request $req, Response $res, array $args): Response - { - try { - $post = $this->postService->getPostById((int) $args['id']); - } catch (NotFoundException) { - throw new HttpNotFoundException($req); - } - - if (!$this->canEditPost($post)) { - $this->flash->set('post_error', "Vous ne pouvez pas supprimer un article dont vous n'êtes pas l'auteur"); - - return $res->withHeader('Location', '/admin/posts')->withStatus(302); - } - - try { - $this->postService->deletePost($post->getId()); - } catch (NotFoundException) { - throw new HttpNotFoundException($req); - } - - $this->flash->set('post_success', "L'article « {$post->getTitle()} » a été supprimé avec succès"); - - return $res->withHeader('Location', '/admin/posts')->withStatus(302); - } - - private function canEditPost(Post $post): bool - { - if ($this->sessionManager->isAdmin() || $this->sessionManager->isEditor()) { - return true; - } - - return $post->getAuthorId() === $this->sessionManager->getUserId(); - } - - /** - * @return array{title: string, content: string, slug: string, category_id: int|null} - */ - private function extractPostData(Request $req): array - { - $data = (array) $req->getParsedBody(); - $categoryId = ($data['category_id'] ?? '') !== '' ? (int) $data['category_id'] : null; - - return [ - 'title' => trim((string) ($data['title'] ?? '')), - 'content' => trim((string) ($data['content'] ?? '')), - 'slug' => trim((string) ($data['slug'] ?? '')), - 'category_id' => $categoryId, - ]; - } } diff --git a/src/Post/PostExtension.php b/src/Post/PostExtension.php index bd22894..766a286 100644 --- a/src/Post/PostExtension.php +++ b/src/Post/PostExtension.php @@ -3,203 +3,10 @@ declare(strict_types=1); namespace App\Post; -use Twig\Extension\AbstractExtension; -use Twig\TwigFunction; - /** - * Extension Twig pour la présentation des articles. - * - * Expose des fonctions utilitaires dans les templates Twig - * afin d'éviter d'appeler de la logique de présentation directement - * sur le modèle Post depuis les vues. - * - * Fonctions disponibles dans les templates : - * - * @example {{ post_excerpt(post) }} — extrait de 400 caractères par défaut - * @example {{ post_excerpt(post, 600) }} — extrait personnalisé de 600 caractères - * @example {{ post_url(post) }} — URL publique de l'article (/article/{slug}) - * @example {{ post_thumbnail(post) }} — URL de la première image, ou null si aucune image - * @example {{ post_initials(post) }} — initiales du titre (ex: "AB" pour "Article de Blog") + * Pont de compatibilité : l'extension Twig principale vit désormais dans + * App\Post\Infrastructure\TwigPostExtension. */ -final class PostExtension extends AbstractExtension +final class PostExtension extends Infrastructure\TwigPostExtension { - /** - * Déclare les fonctions Twig exposées aux templates. - * - * @return TwigFunction[] Les fonctions enregistrées dans l'environnement Twig - */ - public function getFunctions(): array - { - return [ - new TwigFunction( - 'post_excerpt', - fn (Post $post, int $length = 400) => self::excerpt($post, $length), - ['is_safe' => ['html']] - ), - new TwigFunction( - 'post_url', - fn (Post $post) => '/article/'.$post->getStoredSlug() - ), - new TwigFunction( - 'post_thumbnail', - fn (Post $post) => self::thumbnail($post) - ), - new TwigFunction( - 'post_initials', - fn (Post $post) => self::initials($post) - ), - ]; - } - - /** - * Génère un extrait HTML formaté du contenu de l'article. - * - * Conserve uniquement les balises sûres et porteuses de sens visuel - * (
        ,
          ,
        1. , , , , ) afin que le formatage - * soit perceptible dans l'aperçu (listes à puces, gras, italique…). - * Toutes les autres balises sont supprimées par strip_tags(). - * - * La hauteur de l'aperçu est contrainte côté CSS (max-height sur .card__body + - * dégradé de fondu sur .card__excerpt) — c'est CSS qui tronque visuellement, - * pas cette méthode. Le paramètre $length sert uniquement de garde-fou serveur : - * il évite d'envoyer l'intégralité d'un long article au navigateur. La valeur - * par défaut de 400 caractères est volontairement généreuse pour ne jamais - * couper un contenu que CSS aurait affiché en entier. - * - * La troncature opère sur le HTML filtré (pas sur le texte brut) afin de - * conserver le formatage de façon cohérente, quelle que soit la longueur - * du contenu. Le comptage de caractères ignore les balises. - * - * Le HTML retourné provient de HTMLPurifier (appliqué à l'écriture) — - * strip_tags() avec liste blanche élimine tout balisage résiduel non désiré. - * La fonction est déclarée is_safe => ['html'] : Twig ne l'échappe pas - * automatiquement, le |raw est inutile dans les templates. - * - * @param Post $post L'article dont générer l'extrait - * @param int $length Longueur maximale en caractères visibles (défaut : 400) - * - * @return string L'extrait en HTML partiel, tronqué si nécessaire - */ - private static function excerpt(Post $post, int $length): string - { - // Balises conservées : structurantes pour les listes, sémantiques pour le gras/italique. - // Toutes les autres (p, div, h1-h6, img, a, table…) sont supprimées pour - // garder un aperçu compact. - $allowed = '
            1. '; - $html = strip_tags($post->getContent(), $allowed); - - // Mesurer sur le texte brut : les balises ne comptent pas dans la limite visible - if (mb_strlen(strip_tags($html)) <= $length) { - return $html; - } - - // Tronquer en avançant caractère par caractère dans le HTML, en ignorant - // les balises dans le comptage — le formatage est ainsi conservé dans la - // portion visible, de façon cohérente avec les articles courts. - $truncated = ''; - $count = 0; - $inTag = false; - - for ($i = 0, $len = mb_strlen($html); $i < $len && $count < $length; $i++) { - $char = mb_substr($html, $i, 1); - - if ($char === '<') { - $inTag = true; - } - - $truncated .= $char; - - if ($inTag) { - if ($char === '>') { - $inTag = false; - } - } else { - $count++; - } - } - - // Fermer proprement les balises laissées ouvertes par la troncature - foreach (['li', 'ul', 'ol', 'em', 'strong', 'b', 'i'] as $tag) { - $opens = substr_count($truncated, "<{$tag}>") + substr_count($truncated, "<{$tag} "); - $closes = substr_count($truncated, ""); - for ($j = $closes; $j < $opens; $j++) { - $truncated .= ""; - } - } - - return $truncated . '…'; - } - - /** - * Extrait l'URL de la première image présente dans le contenu de l'article. - * - * Utilise une regex sur l'attribut src de la première balise trouvée. - * Le contenu étant sanitisé par HTMLPurifier, seuls les schémas http/https - * sont présents — aucun risque XSS via cet attribut. - * L'échappement de l'URL est délégué à Twig (auto-escape activé). - * - * @param Post $post L'article dont extraire la vignette - * - * @return string|null L'URL de la première image, ou null si aucune image - */ - private static function thumbnail(Post $post): ?string - { - if (preg_match('/]+src=["\']([^"\']+)["\']/', $post->getContent(), $matches)) { - return $matches[1]; - } - - return null; - } - - /** - * Génère les initiales du titre de l'article (1 à 2 caractères). - * - * Extrait la première lettre de chaque mot, conserve les deux premières, - * et retourne le résultat en majuscules. Les mots vides (articles, prépositions - * d'une lettre) sont ignorés pour favoriser les mots porteurs de sens. - * - * Exemples : - * "Article de Blog" → "AB" - * "Été en forêt" → "EF" - * "PHP" → "P" - * "" → "?" - * - * L'échappement HTML est délégué à Twig (auto-escape activé). - * - * @param Post $post L'article dont générer les initiales - * - * @return string Les initiales en majuscules (1–2 caractères), ou "?" si le titre est vide - */ - private static function initials(Post $post): string - { - // Filtrer les mots vides fréquents (articles, prépositions, coordinations) - // pour favoriser les mots porteurs de sens : "Article de Blog" → ["Article", "Blog"] → "AB" - $stopWords = ['a', 'au', 'aux', 'd', 'de', 'des', 'du', 'en', 'et', 'l', 'la', 'le', 'les', 'of', 'the', 'un', 'une']; - $words = array_filter( - preg_split('/\s+/', trim($post->getTitle())) ?: [], - static function (string $w) use ($stopWords): bool { - $normalized = mb_strtolower(trim($w, " \t\n\r\0\x0B'\"’`.-_")); - - return $normalized !== '' - && mb_strlen($normalized) > 1 - && !in_array($normalized, $stopWords, true); - } - ); - - if (empty($words)) { - // Repli sur le premier caractère du titre brut si tous les mots font 1 lettre - $first = mb_substr(trim($post->getTitle()), 0, 1); - - return $first !== '' ? mb_strtoupper($first) : '?'; - } - - $words = array_values($words); - $initials = mb_strtoupper(mb_substr($words[0], 0, 1)); - - if (isset($words[1])) { - $initials .= mb_strtoupper(mb_substr($words[1], 0, 1)); - } - - return $initials; - } } diff --git a/src/Post/PostRepository.php b/src/Post/PostRepository.php index ae10570..c59bd7d 100644 --- a/src/Post/PostRepository.php +++ b/src/Post/PostRepository.php @@ -3,378 +3,12 @@ declare(strict_types=1); namespace App\Post; -use PDO; +use App\Post\Infrastructure\PdoPostRepository; -final class PostRepository implements PostRepositoryInterface +/** + * Pont de compatibilité : l'implémentation PDO principale vit désormais dans + * App\Post\Infrastructure\PdoPostRepository. + */ +final class PostRepository extends PdoPostRepository implements PostRepositoryInterface { - private const SELECT = ' - SELECT posts.id, posts.title, posts.content, posts.slug, - posts.author_id, posts.category_id, posts.created_at, posts.updated_at, - users.username AS author_username, - categories.name AS category_name, - categories.slug AS category_slug - FROM posts - LEFT JOIN users ON users.id = posts.author_id - LEFT JOIN categories ON categories.id = posts.category_id - '; - - public function __construct(private readonly PDO $db) - { - } - - public function findAll(?int $categoryId = null): array - { - 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)); - } - - $stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC'); - $stmt->execute([':category_id' => $categoryId]); - - 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(); - } - - 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(); - - return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC)); - } - - 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) { - $sql .= ' AND posts.category_id = :category_id'; - $params[':category_id'] = $categoryId; - } - - $sql .= ' ORDER BY posts.id DESC'; - $stmt = $this->db->prepare($sql); - $stmt->execute($params); - - 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(); - } - - 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; - } - - 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; - } - - public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int - { - $stmt = $this->db->prepare( - 'INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at) - VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)' - ); - - $stmt->execute([ - ':title' => $post->getTitle(), - ':content' => $post->getContent(), - ':slug' => $slug, - ':author_id' => $authorId, - ':category_id' => $categoryId, - ':created_at' => date('Y-m-d H:i:s'), - ':updated_at' => date('Y-m-d H:i:s'), - ]); - - return (int) $this->db->lastInsertId(); - } - - public function update(int $id, Post $post, string $slug, ?int $categoryId): int - { - $stmt = $this->db->prepare( - 'UPDATE posts - SET title = :title, content = :content, slug = :slug, - category_id = :category_id, updated_at = :updated_at - WHERE id = :id' - ); - - $stmt->execute([ - ':title' => $post->getTitle(), - ':content' => $post->getContent(), - ':slug' => $slug, - ':category_id' => $categoryId, - ':updated_at' => date('Y-m-d H:i:s'), - ':id' => $id, - ]); - - return $stmt->rowCount(); - } - - public function delete(int $id): int - { - $stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id'); - $stmt->execute([':id' => $id]); - - return $stmt->rowCount(); - } - - public function search(string $query, ?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'; - - $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 COUNT(*) - FROM posts_fts f - JOIN posts p ON p.id = f.rowid - 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; - } - - $stmt = $this->db->prepare($sql); - $this->bindParams($stmt, $params); - $stmt->execute(); - - return (int) $stmt->fetchColumn(); - } - - 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)); - } - - /** - * @return array{0:string,1:array} - */ - 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) ?: []; - - if (empty($words)) { - return ''; - } - - $terms = array_map( - fn ($w) => '"' . str_replace('"', '""', $w) . '"*', - $words - ); - - return implode(' ', $terms); - } - - /** - * @param array $params - */ - private function bindParams(\PDOStatement $stmt, array $params): void - { - foreach ($params as $key => $value) { - $stmt->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR); - } - } - - /** - * @param array> $rows - * @return Post[] - */ - private function hydratePosts(array $rows): array - { - return array_map(fn ($row) => Post::fromArray($row), $rows); - } } diff --git a/src/Post/PostService.php b/src/Post/PostService.php index f8d4b5c..7f2cd5c 100644 --- a/src/Post/PostService.php +++ b/src/Post/PostService.php @@ -3,180 +3,12 @@ declare(strict_types=1); namespace App\Post; -use App\Shared\Exception\NotFoundException; -use App\Shared\Html\HtmlSanitizerInterface; -use App\Shared\Pagination\PaginatedResult; -use App\Shared\Util\SlugHelper; +use App\Post\Application\PostApplicationService; -final class PostService implements PostServiceInterface +/** + * Pont de compatibilité : l'implémentation métier principale vit désormais dans + * App\Post\Application\PostApplicationService. + */ +final class PostService extends PostApplicationService implements PostServiceInterface { - public function __construct( - private readonly PostRepositoryInterface $postRepository, - private readonly HtmlSanitizerInterface $htmlSanitizer, - ) { - } - - public function getAllPosts(?int $categoryId = null): array - { - return $this->postRepository->findAll($categoryId); - } - - /** - * @return PaginatedResult - */ - 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); - } - - public function getPostsByUserId(int $userId, ?int $categoryId = null): array - { - return $this->postRepository->findByUserId($userId, $categoryId); - } - - /** - * @return PaginatedResult - */ - 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); - - if ($post === null) { - throw new NotFoundException('Article', $slug); - } - - return $post; - } - - public function getPostById(int $id): Post - { - $post = $this->postRepository->findById($id); - - if ($post === null) { - throw new NotFoundException('Article', $id); - } - - return $post; - } - - public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int - { - $sanitizedContent = $this->htmlSanitizer->sanitize($content); - $post = new Post(0, $title, $sanitizedContent); - $slug = $this->generateUniqueSlug($post->generateSlug()); - - return $this->postRepository->create($post, $slug, $authorId, $categoryId); - } - - public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void - { - $current = $this->postRepository->findById($id); - - if ($current === null) { - throw new NotFoundException('Article', $id); - } - - $sanitizedContent = $this->htmlSanitizer->sanitize($content); - $post = new Post($id, $title, $sanitizedContent); - $slugToUse = $current->getStoredSlug(); - $newSlugInput = trim($newSlugInput); - $cleanSlugInput = $this->normalizeSlugInput($newSlugInput); - - if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) { - $slugToUse = $this->generateUniqueSlug($cleanSlugInput, $id); - } - - $affected = $this->postRepository->update($id, $post, $slugToUse, $categoryId); - - if ($affected === 0) { - throw new NotFoundException('Article', $id); - } - } - - public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array - { - return $this->postRepository->search($query, $categoryId, $authorId); - } - - /** - * @return PaginatedResult - */ - 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); - - if ($affected === 0) { - throw new NotFoundException('Article', $id); - } - } - - private function normalizeSlugInput(string $input): string - { - return SlugHelper::generate($input); - } - - private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string - { - $slug = $baseSlug; - $counter = 1; - - while ($this->postRepository->slugExists($slug, $excludeId)) { - $slug = $baseSlug . '-' . $counter; - ++$counter; - } - - return $slug; - } } diff --git a/src/Post/RssController.php b/src/Post/RssController.php index a62d998..e2effcc 100644 --- a/src/Post/RssController.php +++ b/src/Post/RssController.php @@ -3,92 +3,10 @@ declare(strict_types=1); namespace App\Post; -use Psr\Http\Message\ResponseInterface as Response; -use Psr\Http\Message\ServerRequestInterface as Request; - /** - * Contrôleur du flux RSS. - * - * Expose un flux RSS 2.0 des 20 articles les plus récents à l'URL /rss.xml. - * Le contenu HTML des articles est strippé pour le champ afin - * de produire un résumé texte brut compatible avec tous les lecteurs RSS. - * - * Pas de vue Twig — le XML est généré directement via SimpleXMLElement - * pour rester indépendant du moteur de templates. + * Pont de compatibilité : le contrôleur RSS principal vit désormais dans + * App\Post\Http\RssController. */ -final class RssController +final class RssController extends Http\RssController { - /** - * Nombre maximum d'articles inclus dans le flux RSS. - */ - private const FEED_LIMIT = 20; - - /** - * @param PostServiceInterface $postService Service de récupération des articles - * @param string $appUrl URL de base de l'application (depuis APP_URL dans .env) - * @param string $appName Nom du blog affiché dans le flux - */ - public function __construct( - private readonly PostServiceInterface $postService, - private readonly string $appUrl, - private readonly string $appName, - ) { - } - - /** - * Génère et retourne le flux RSS 2.0. - * - * @param Request $req La requête HTTP - * @param Response $res La réponse HTTP - * - * @return Response Le flux RSS en XML (application/rss+xml; charset=utf-8) - */ - public function feed(Request $req, Response $res): Response - { - $posts = $this->postService->getRecentPosts(self::FEED_LIMIT); - $baseUrl = $this->appUrl; - - $xml = new \SimpleXMLElement( - '' - ); - - $channel = $xml->addChild('channel'); - $channel->addChild('title', htmlspecialchars($this->appName)); - $channel->addChild('link', $baseUrl . '/'); - $channel->addChild('description', htmlspecialchars($this->appName . ' — flux RSS')); - $channel->addChild('language', 'fr-FR'); - $channel->addChild('lastBuildDate', (new \DateTime())->format(\DateTime::RSS)); - - foreach ($posts as $post) { - $item = $channel->addChild('item'); - $item->addChild('title', htmlspecialchars($post->getTitle())); - - $postUrl = $baseUrl . '/article/' . $post->getStoredSlug(); - $item->addChild('link', $postUrl); - $item->addChild('guid', $postUrl); - - // Extrait texte brut : strip_tags + truncature à 300 caractères - $excerpt = strip_tags($post->getContent()); - $excerpt = mb_strlen($excerpt) > 300 - ? mb_substr($excerpt, 0, 300) . '…' - : $excerpt; - $item->addChild('description', htmlspecialchars($excerpt)); - - $item->addChild('pubDate', $post->getCreatedAt()->format(\DateTime::RSS)); - - if ($post->getAuthorUsername() !== null) { - $item->addChild('author', htmlspecialchars($post->getAuthorUsername())); - } - - if ($post->getCategoryName() !== null) { - $item->addChild('category', htmlspecialchars($post->getCategoryName())); - } - } - - $body = $xml->asXML(); - - $res->getBody()->write($body !== false ? $body : ''); - - return $res->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); - } }