From ed945c735736f7dc57f96e7f8ca74b5df3ef4627 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 16 Feb 2026 19:25:07 +0100 Subject: [PATCH 1/2] feat: added Symfony Kernel --- composer.json | 2 + composer.lock | 213 +++++- phpmyfaq/admin/api/index.php | 33 +- phpmyfaq/admin/index.php | 31 +- phpmyfaq/api/index.php | 32 +- phpmyfaq/index.php | 34 +- phpmyfaq/setup/index.php | 30 +- phpmyfaq/src/phpMyFAQ/Application.php | 373 ---------- .../Controller/AbstractController.php | 41 +- .../EventListener/ApiExceptionListener.php | 144 ++++ .../ControllerContainerListener.php | 49 ++ .../EventListener/LanguageListener.php | 104 +++ .../phpMyFAQ/EventListener/RouterListener.php | 56 ++ .../EventListener/WebExceptionListener.php | 133 ++++ phpmyfaq/src/phpMyFAQ/Kernel.php | 193 +++++ phpunit.xml | 4 + tests/phpMyFAQ/ApplicationTest.php | 679 ------------------ .../ApiExceptionListenerTest.php | 144 ++++ .../ControllerContainerListenerTest.php | 81 +++ .../EventListener/RouterListenerTest.php | 79 ++ .../WebExceptionListenerTest.php | 118 +++ .../phpMyFAQ/Functional/KernelRoutingTest.php | 197 +++++ .../Functional/PhpMyFaqTestKernel.php | 33 + tests/phpMyFAQ/Functional/WebTestCase.php | 120 ++++ tests/phpMyFAQ/KernelTest.php | 40 ++ 25 files changed, 1808 insertions(+), 1155 deletions(-) delete mode 100644 phpmyfaq/src/phpMyFAQ/Application.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/ControllerContainerListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/Kernel.php delete mode 100644 tests/phpMyFAQ/ApplicationTest.php create mode 100644 tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php create mode 100644 tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php create mode 100644 tests/phpMyFAQ/EventListener/RouterListenerTest.php create mode 100644 tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php create mode 100644 tests/phpMyFAQ/Functional/KernelRoutingTest.php create mode 100644 tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php create mode 100644 tests/phpMyFAQ/Functional/WebTestCase.php create mode 100644 tests/phpMyFAQ/KernelTest.php diff --git a/composer.json b/composer.json index 05113399e3..78917a1fb7 100644 --- a/composer.json +++ b/composer.json @@ -66,6 +66,8 @@ "phpdocumentor/reflection-docblock": "6.*", "phpunit/phpunit": "^12.3", "rector/rector": "^2", + "symfony/browser-kit": "^8.0", + "symfony/css-selector": "^8.0", "symfony/yaml": "8.*", "zircote/swagger-php": "^6.0" }, diff --git a/composer.lock b/composer.lock index d805881531..d71ee3909f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "79ff3c92e8f8cb849e567308e01471bf", + "content-hash": "375068fca3740daea063b677a252a07e", "packages": [ { "name": "2tvenom/cborencode", @@ -9212,6 +9212,217 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/browser-kit", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "0d998c101e1920fc68572209d1316fec0db728ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/0d998c101e1920fc68572209d1316fec0db728ef", + "reference": "0d998c101e1920fc68572209d1316fec0db728ef", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dom-crawler": "^7.4|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-13T13:06:50+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-30T14:17:19+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "fd78228fa362b41729173183493f46b1df49485f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/fd78228fa362b41729173183493f46b1df49485f", + "reference": "fd78228fa362b41729173183493f46b1df49485f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T09:27:50+00:00" + }, { "name": "symfony/finder", "version": "v8.0.5", diff --git a/phpmyfaq/admin/api/index.php b/phpmyfaq/admin/api/index.php index ae25f6fcaf..1be85190d9 100644 --- a/phpmyfaq/admin/api/index.php +++ b/phpmyfaq/admin/api/index.php @@ -16,13 +16,11 @@ * @since 2023-07-02 */ -use phpMyFAQ\Application; use phpMyFAQ\Core\Exception\DatabaseConnectionException; use phpMyFAQ\Environment; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use phpMyFAQ\Kernel; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; try { @@ -49,24 +47,11 @@ exit(1); } -// -// Service Containers -// -$container = new ContainerBuilder(); -$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); -try { - $loader->load('../../src/services.php'); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$kernel = new Kernel( + routingContext: 'admin-api', + debug: Environment::isDebugMode(), +); -$app = new Application($container); -$app->setAdminContext(true); -$app->setApiContext(true); -$app->routingContext = 'admin-api'; -try { - // Autoload routes from attributes (falls back to api-routes.php during migration) - $app->run(); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); diff --git a/phpmyfaq/admin/index.php b/phpmyfaq/admin/index.php index 606e0a0ce8..4dab3b4d41 100755 --- a/phpmyfaq/admin/index.php +++ b/phpmyfaq/admin/index.php @@ -19,13 +19,11 @@ * @since 2002-09-16 */ -use phpMyFAQ\Application; use phpMyFAQ\Controller\Frontend\ErrorController; use phpMyFAQ\Core\Exception\DatabaseConnectionException; use phpMyFAQ\Environment; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use phpMyFAQ\Kernel; +use Symfony\Component\HttpFoundation\Request; try { require dirname(__DIR__) . '/src/Bootstrap.php'; @@ -36,22 +34,11 @@ exit(1); } -// -// Service Containers -// -$container = new ContainerBuilder(); -$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); -try { - $loader->load('../src/services.php'); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$kernel = new Kernel( + routingContext: 'admin', + debug: Environment::isDebugMode(), +); -$app = new Application($container); -$app->routingContext = 'admin'; -try { - // Auto-loads routes from attributes (falls back to admin-routes.php during migration) - $app->run(); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); diff --git a/phpmyfaq/api/index.php b/phpmyfaq/api/index.php index ba5ad6add7..f686c5309e 100644 --- a/phpmyfaq/api/index.php +++ b/phpmyfaq/api/index.php @@ -17,13 +17,11 @@ declare(strict_types=1); -use phpMyFAQ\Application; use phpMyFAQ\Core\Exception\DatabaseConnectionException; use phpMyFAQ\Environment; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use phpMyFAQ\Kernel; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; try { @@ -50,23 +48,11 @@ exit(1); } -// -// Service Containers -// -$container = new ContainerBuilder(); -$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); -try { - $loader->load('../src/services.php'); -} catch (Exception $e) { - echo $e->getMessage(); -} +$kernel = new Kernel( + routingContext: 'api', + debug: Environment::isDebugMode(), +); -$app = new Application($container); -$app->setApiContext(true); -$app->routingContext = 'api'; -try { - // Autoload routes from attributes (falls back to api-routes.php during migration) - $app->run(); -} catch (Exception $exception) { - echo $exception->getMessage(); -} +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); diff --git a/phpmyfaq/index.php b/phpmyfaq/index.php index 654591ffbb..43da19d64c 100755 --- a/phpmyfaq/index.php +++ b/phpmyfaq/index.php @@ -22,15 +22,11 @@ declare(strict_types=1); - -use phpMyFAQ\Application; use phpMyFAQ\Controller\Frontend\ErrorController; -use phpMyFAQ\Core\Exception; use phpMyFAQ\Core\Exception\DatabaseConnectionException; use phpMyFAQ\Environment; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use phpMyFAQ\Kernel; +use Symfony\Component\HttpFoundation\Request; // // Bootstrapping @@ -44,23 +40,11 @@ exit(1); } -// -// Service Containers -// -$container = new ContainerBuilder(); -$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); -try { - $loader->load('./src/services.php'); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} - -$app = new Application($container); -$app->routingContext = 'public'; +$kernel = new Kernel( + routingContext: 'public', + debug: Environment::isDebugMode(), +); -try { - // Auto-loads routes from attributes (falls back to public-routes.php during migration) - $app->run(); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); diff --git a/phpmyfaq/setup/index.php b/phpmyfaq/setup/index.php index db764794d5..b4266cc582 100644 --- a/phpmyfaq/setup/index.php +++ b/phpmyfaq/setup/index.php @@ -24,11 +24,19 @@ */ use Composer\Autoload\ClassLoader; -use phpMyFAQ\Application; use phpMyFAQ\Controller\Frontend\SetupController; use phpMyFAQ\Environment; +use phpMyFAQ\EventListener\RouterListener; +use phpMyFAQ\EventListener\WebExceptionListener; use phpMyFAQ\Strings; use phpMyFAQ\Translation; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; +use Symfony\Component\HttpKernel\HttpKernel; +use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -95,9 +103,25 @@ $routes->add($name, new Route($path, ['_controller' => [$controller, $action]])); } -$app = new Application(); +$dispatcher = new EventDispatcher(); + +$routerListener = new RouterListener($routes); +$dispatcher->addListener(KernelEvents::REQUEST, [$routerListener, 'onKernelRequest'], 256); + +$webExceptionListener = new WebExceptionListener(); +$dispatcher->addListener(KernelEvents::EXCEPTION, [$webExceptionListener, 'onKernelException'], -10); + +$kernel = new HttpKernel( + $dispatcher, + new ControllerResolver(), + new RequestStack(), + new ArgumentResolver(), +); + try { - $app->run($routes); + $request = Request::createFromGlobals(); + $response = $kernel->handle($request); + $response->send(); } catch (Exception $exception) { echo $exception->getMessage(); } diff --git a/phpmyfaq/src/phpMyFAQ/Application.php b/phpmyfaq/src/phpMyFAQ/Application.php deleted file mode 100644 index 86e0ae3754..0000000000 --- a/phpmyfaq/src/phpMyFAQ/Application.php +++ /dev/null @@ -1,373 +0,0 @@ - - * @copyright 2023-2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2023-10-24 - */ - -declare(strict_types=1); - -namespace phpMyFAQ; - -use phpMyFAQ\Api\ProblemDetails; -use phpMyFAQ\Controller\Exception\ForbiddenException; -use phpMyFAQ\Controller\Frontend\PageNotFoundController; -use phpMyFAQ\Core\Exception; -use phpMyFAQ\Routing\RouteCacheManager; -use phpMyFAQ\Routing\RouteCollectionBuilder; -use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\HttpFoundation\Exception\BadRequestException; -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Controller\ArgumentResolver; -use Symfony\Component\HttpKernel\Controller\ControllerResolver; -use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; -use Symfony\Component\Routing\Exception\ResourceNotFoundException; -use Symfony\Component\Routing\Matcher\UrlMatcher; -use Symfony\Component\Routing\RequestContext; -use Symfony\Component\Routing\RouteCollection; -use Throwable; - -class Application -{ - public UrlMatcher $urlMatcher { - set(UrlMatcher $value) { - $this->urlMatcher = $value; - } - } - - public ControllerResolver $controllerResolver { - set(ControllerResolver $value) { - $this->controllerResolver = $value; - } - } - - private bool $isApiContext = false; - - private bool $isAdminContext = false; - - public string $routingContext = 'public' { - set { - $this->routingContext = $value; - } - } - - public function __construct( - private readonly ?ContainerInterface $container = null, - ) { - } - - /** - * @throws Exception - */ - public function run(?RouteCollection $routeCollection = null, ?Request $request = null): void - { - $currentLanguage = $this->setLanguage(); - $this->initializeTranslation($currentLanguage); - Strings::init($currentLanguage); - $request ??= Request::createFromGlobals(); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - // Autoload routes if not provided - if ($routeCollection === null) { - $routeCollection = $this->loadRoutes(); - } - - $this->handleRequest($routeCollection, $request, $requestContext); - } - - public function setApiContext(bool $isApiContext): void - { - $this->isApiContext = $isApiContext; - } - - public function setAdminContext(bool $isAdminContext): void - { - $this->isAdminContext = $isAdminContext; - } - - private function setLanguage(): string - { - if (!is_null($this->container)) { - $configuration = $this->container->get(id: 'phpmyfaq.configuration'); - $language = $this->container->get(id: 'phpmyfaq.language'); - - // Set container in configuration for lazy loading of services like translation provider - $configuration->setContainer($this->container); - - $detect = (bool) $configuration->get(item: 'main.languageDetection'); - $configLang = $configuration->get(item: 'main.language'); - - $currentLanguage = $detect - ? $language->setLanguageWithDetection($configLang) - : $language->setLanguageFromConfiguration($configLang); - - require PMF_TRANSLATION_DIR . '/language_en.php'; - if (Language::isASupportedLanguage($currentLanguage)) { - require PMF_TRANSLATION_DIR . '/language_' . strtolower($currentLanguage) . '.php'; - } - - $configuration->setLanguage($language); - - return $currentLanguage; - } - - return 'en'; - } - - /** - * @throws Exception - */ - private function initializeTranslation(string $currentLanguage): void - { - try { - Translation::create() - ->setTranslationsDir(PMF_TRANSLATION_DIR) - ->setDefaultLanguage(defaultLanguage: 'en') - ->setCurrentLanguage($currentLanguage) - ->setMultiByteLanguage(); - } catch (Exception $exception) { - throw new Exception($exception->getMessage()); - } - } - - private function handleRequest( - RouteCollection $routeCollection, - Request $request, - RequestContext $requestContext, - ): void { - $urlMatcher = new UrlMatcher($routeCollection, $requestContext); - $this->urlMatcher = $urlMatcher; - $controllerResolver = new ControllerResolver(); - $this->controllerResolver = $controllerResolver; - $argumentResolver = new ArgumentResolver(); - $response = new Response(); - - try { - $this->urlMatcher->setContext($requestContext); - $request->attributes->add($this->urlMatcher->match($request->getPathInfo())); - $controller = $this->controllerResolver->getController($request); - $arguments = $argumentResolver->getArguments($request, $controller); - $response->setStatusCode(Response::HTTP_OK); - $response = call_user_func_array($controller, $arguments); - } catch (ResourceNotFoundException $exception) { - // For API requests, return RFC 7807 JSON response - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_NOT_FOUND, - throwable: $exception, - defaultDetail: 'The requested resource was not found.', - ); - } else { - // For web requests, forward to the PageNotFoundController - try { - $request->attributes->set('_route', 'public.404'); - $request->attributes->set('_controller', PageNotFoundController::class . '::index'); - $controller = $this->controllerResolver->getController($request); - $arguments = $argumentResolver->getArguments($request, $controller); - $response = call_user_func_array($controller, $arguments); - } catch (Throwable) { - // Fallback if the controller fails - $message = Environment::isDebugMode() - ? $this->formatExceptionMessage( - template: 'Not Found: :message at line :line at :file', - throwable: $exception, - ) - : 'Not Found'; - $response = new Response(content: $message, status: Response::HTTP_NOT_FOUND); - } - } - } catch (UnauthorizedHttpException $exception) { - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_UNAUTHORIZED, - throwable: $exception, - defaultDetail: 'Unauthorized access.', - ); - } else { - $response = new RedirectResponse(url: './login'); - } - } catch (ForbiddenException $exception) { - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_FORBIDDEN, - throwable: $exception, - defaultDetail: 'Access to this resource is forbidden.', - ); - } else { - $message = Environment::isDebugMode() - ? $this->formatExceptionMessage( - template: 'An error occurred: :message at line :line at :file', - throwable: $exception, - ) - : 'Forbidden'; - $response = new Response(content: $message, status: Response::HTTP_FORBIDDEN); - } - } catch (BadRequestException $exception) { - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_BAD_REQUEST, - throwable: $exception, - defaultDetail: 'The request could not be understood or was missing required parameters.', - ); - } else { - $message = Environment::isDebugMode() - ? $this->formatExceptionMessage( - template: 'An error occurred: :message at line :line at :file', - throwable: $exception, - ) - : 'Bad Request'; - $response = new Response(content: $message, status: Response::HTTP_BAD_REQUEST); - } - } catch (Throwable $exception) { - // Log the error for debugging - error_log(sprintf( - 'Unhandled exception in Application: %s at %s:%d', - $exception->getMessage(), - $exception->getFile(), - $exception->getLine(), - )); - - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_INTERNAL_SERVER_ERROR, - throwable: $exception, - defaultDetail: 'An unexpected error occurred while processing your request.', - ); - } else { - $message = Environment::isDebugMode() - ? $this->formatExceptionMessage( - template: 'Internal Server Error: :message at line :line at :file', - throwable: $exception, - ) - : 'Internal Server Error'; - $response = new Response(content: $message, status: Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - $response->send(); - } - - /** - * Load routes using the RouteCollectionBuilder. - * - * @return RouteCollection The loaded routes - */ - private function loadRoutes(): RouteCollection - { - $configuration = $this->container?->get(id: 'phpmyfaq.configuration'); - - // Determine if caching is enabled via environment variable - $cacheEnabled = filter_var(Environment::get('ROUTING_CACHE_ENABLED', 'true'), FILTER_VALIDATE_BOOLEAN); - $cacheDir = Environment::get('ROUTING_CACHE_DIR', PMF_ROOT_DIR . '/cache/routes'); - - // Use appropriate context based on flags - $context = $this->routingContext; - if ($this->isAdminContext && $this->isApiContext) { - $context = 'admin-api'; - } elseif ($this->isAdminContext) { - $context = 'admin'; - } elseif ($this->isApiContext) { - $context = 'api'; - } - - // Load routes with caching if enabled (disabled automatically in debug mode) - if ($cacheEnabled && !Environment::isDebugMode()) { - $cacheManager = new RouteCacheManager($cacheDir, Environment::isDebugMode()); - return $cacheManager->getRoutes($context, static function () use ($configuration, $context) { - $builder = new RouteCollectionBuilder($configuration); - return $builder->build($context); - }); - } - - // Load routes without caching (routes are always loaded from controller attributes) - $builder = new RouteCollectionBuilder($configuration); - return $builder->build($context); - } - - /** - * Formats an exception message from a template with named placeholders. - */ - private function formatExceptionMessage(string $template, Throwable $throwable): string - { - return strtr($template, [ - ':message' => $throwable->getMessage(), - ':line' => (string) $throwable->getLine(), - ':file' => $throwable->getFile(), - ]); - } - - /** - * Creates a ProblemDetails response for API errors. - */ - private function createProblemDetailsResponse( - Request $request, - int $status, - Throwable $throwable, - string $defaultDetail, - ): Response { - $configuration = $this->container->get(id: 'phpmyfaq.configuration'); - $baseUrl = rtrim($configuration->getDefaultUrl(), '/'); - - $type = match ($status) { - Response::HTTP_BAD_REQUEST => $baseUrl . '/problems/bad-request', - Response::HTTP_UNAUTHORIZED => $baseUrl . '/problems/unauthorized', - Response::HTTP_FORBIDDEN => $baseUrl . '/problems/forbidden', - Response::HTTP_NOT_FOUND => $baseUrl . '/problems/not-found', - Response::HTTP_CONFLICT => $baseUrl . '/problems/conflict', - Response::HTTP_UNPROCESSABLE_ENTITY => $baseUrl . '/problems/validation-error', - Response::HTTP_TOO_MANY_REQUESTS => $baseUrl . '/problems/rate-limited', - Response::HTTP_INTERNAL_SERVER_ERROR => $baseUrl . '/problems/internal-server-error', - default => $baseUrl . '/problems/http-error', - }; - - $title = match ($status) { - Response::HTTP_BAD_REQUEST => 'Bad Request', - Response::HTTP_UNAUTHORIZED => 'Unauthorized', - Response::HTTP_FORBIDDEN => 'Forbidden', - Response::HTTP_NOT_FOUND => 'Resource not found', - Response::HTTP_CONFLICT => 'Conflict', - Response::HTTP_UNPROCESSABLE_ENTITY => 'Validation failed', - Response::HTTP_TOO_MANY_REQUESTS => 'Too many requests', - Response::HTTP_INTERNAL_SERVER_ERROR => 'Internal Server Error', - default => 'HTTP error', - }; - - $detail = Environment::isDebugMode() - ? $throwable->getMessage() . ' at line ' . $throwable->getLine() . ' in ' . $throwable->getFile() - : $defaultDetail; - - $problemDetails = new ProblemDetails( - type: $type, - title: $title, - status: $status, - detail: $detail, - instance: $request->getPathInfo(), - ); - - $response = new Response( - content: json_encode($problemDetails->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), - status: $status, - ); - $response->headers->set('Content-Type', 'application/problem+json'); - - return $response; - } -} diff --git a/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php b/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php index 353f0e1b71..4932f30ea1 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php @@ -3,14 +3,14 @@ /** * Abstract Controller for phpMyFAQ * - * This Source Code Form is subject to the terms of the Mozilla protected License, + * This Source Code Form is subject to the terms of the Mozilla Public License, * v. 2.0. If a copy of the MPL was not distributed with this file, You can * obtain one at https://mozilla.org/MPL/2.0/. * * @package phpMyFAQ * @author Thorsten Rinne * @copyright 2023-2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla protected License Version 2.0 + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 * @link https://www.phpmyfaq.de * @since 2023-10-24 */ @@ -33,6 +33,7 @@ use phpMyFAQ\User\CurrentUser; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -54,7 +55,7 @@ #[OA\License(name: 'Mozilla Public Licence 2.0', url: 'https://www.mozilla.org/MPL/2.0/')] abstract class AbstractController { - protected ?ContainerBuilder $container = null; + protected ?ContainerInterface $container = null; protected ?Configuration $configuration = null; @@ -62,6 +63,8 @@ abstract class AbstractController protected ?SessionInterface $session = null; + private bool $containerInitialized = false; + /** @var ExtensionInterface[] */ private array $twigExtensions = []; @@ -69,19 +72,47 @@ abstract class AbstractController private array $twigFilters = []; /** - * Check if the FAQ should be secured. + * Creates a fallback container for controllers instantiated outside the Kernel. + * When using the Kernel, setContainer() is called by the ControllerContainerListener + * before the controller method runs, overriding this container. * * @throws \Exception */ public function __construct() { $this->container = $this->createContainer(); + $this->initializeFromContainer(); + } + + /** + * Sets the shared DI container from the Kernel. + * Called by ControllerContainerListener on kernel.controller event. + */ + public function setContainer(ContainerInterface $container): void + { + $this->container = $container; + $this->initializeFromContainer(); + } + + /** + * Initializes configuration, user, and session from the container. + */ + protected function initializeFromContainer(): void + { + if ($this->container === null) { + return; + } + $this->configuration = $this->container->get(id: 'phpmyfaq.configuration'); $this->currentUser = $this->container->get(id: 'phpmyfaq.user.current_user'); $this->session = $this->container->get(id: 'session'); TwigWrapper::setTemplateSetName($this->configuration->getTemplateSet()); - $this->isSecured(); + + if (!$this->containerInitialized) { + $this->containerInitialized = true; + $this->isSecured(); + } } /** diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php new file mode 100644 index 0000000000..2e8e3c889f --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php @@ -0,0 +1,144 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use phpMyFAQ\Api\ProblemDetails; +use phpMyFAQ\Configuration; +use phpMyFAQ\Controller\Exception\ForbiddenException; +use phpMyFAQ\Environment; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; + +readonly class ApiExceptionListener +{ + public function __construct( + private ?Configuration $configuration = null, + ) { + } + + public function onKernelException(ExceptionEvent $event): void + { + $request = $event->getRequest(); + $pathInfo = $request->getPathInfo(); + + // Only handle API requests + if (!str_starts_with($pathInfo, '/api/') && !$request->attributes->get('_api_context', false)) { + return; + } + + $throwable = $event->getThrowable(); + + [$status, $defaultDetail] = match (true) { + $throwable instanceof ResourceNotFoundException => [ + Response::HTTP_NOT_FOUND, + 'The requested resource was not found.', + ], + $throwable instanceof UnauthorizedHttpException => [ + Response::HTTP_UNAUTHORIZED, + 'Unauthorized access.', + ], + $throwable instanceof ForbiddenException => [ + Response::HTTP_FORBIDDEN, + 'Access to this resource is forbidden.', + ], + $throwable instanceof BadRequestException => [ + Response::HTTP_BAD_REQUEST, + 'The request could not be understood or was missing required parameters.', + ], + default => [ + Response::HTTP_INTERNAL_SERVER_ERROR, + 'An unexpected error occurred while processing your request.', + ], + }; + + if ($status === Response::HTTP_INTERNAL_SERVER_ERROR) { + error_log(sprintf( + 'Unhandled exception in API: %s at %s:%d', + $throwable->getMessage(), + $throwable->getFile(), + $throwable->getLine(), + )); + } + + $response = $this->createProblemDetailsResponse($request, $status, $throwable, $defaultDetail); + $event->setResponse($response); + } + + private function createProblemDetailsResponse( + \Symfony\Component\HttpFoundation\Request $request, + int $status, + \Throwable $throwable, + string $defaultDetail, + ): Response { + $baseUrl = ''; + if ($this->configuration !== null) { + $baseUrl = rtrim($this->configuration->getDefaultUrl(), '/'); + } + + $type = match ($status) { + Response::HTTP_BAD_REQUEST => $baseUrl . '/problems/bad-request', + Response::HTTP_UNAUTHORIZED => $baseUrl . '/problems/unauthorized', + Response::HTTP_FORBIDDEN => $baseUrl . '/problems/forbidden', + Response::HTTP_NOT_FOUND => $baseUrl . '/problems/not-found', + Response::HTTP_CONFLICT => $baseUrl . '/problems/conflict', + Response::HTTP_UNPROCESSABLE_ENTITY => $baseUrl . '/problems/validation-error', + Response::HTTP_TOO_MANY_REQUESTS => $baseUrl . '/problems/rate-limited', + Response::HTTP_INTERNAL_SERVER_ERROR => $baseUrl . '/problems/internal-server-error', + default => $baseUrl . '/problems/http-error', + }; + + $title = match ($status) { + Response::HTTP_BAD_REQUEST => 'Bad Request', + Response::HTTP_UNAUTHORIZED => 'Unauthorized', + Response::HTTP_FORBIDDEN => 'Forbidden', + Response::HTTP_NOT_FOUND => 'Resource not found', + Response::HTTP_CONFLICT => 'Conflict', + Response::HTTP_UNPROCESSABLE_ENTITY => 'Validation failed', + Response::HTTP_TOO_MANY_REQUESTS => 'Too many requests', + Response::HTTP_INTERNAL_SERVER_ERROR => 'Internal Server Error', + default => 'HTTP error', + }; + + $detail = Environment::isDebugMode() + ? $throwable->getMessage() . ' at line ' . $throwable->getLine() . ' in ' . $throwable->getFile() + : $defaultDetail; + + $problemDetails = new ProblemDetails( + type: $type, + title: $title, + status: $status, + detail: $detail, + instance: $request->getPathInfo(), + ); + + $response = new Response( + content: json_encode($problemDetails->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + status: $status, + ); + $response->headers->set('Content-Type', 'application/problem+json'); + + return $response; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/ControllerContainerListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/ControllerContainerListener.php new file mode 100644 index 0000000000..f0d96a6fd9 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/ControllerContainerListener.php @@ -0,0 +1,49 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use phpMyFAQ\Controller\AbstractController; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\Event\ControllerEvent; + +class ControllerContainerListener +{ + public function __construct( + private readonly ContainerInterface $container, + ) { + } + + public function onKernelController(ControllerEvent $event): void + { + $controller = $event->getController(); + + // Handle array-style callables [object, method] + if (is_array($controller)) { + $controller = $controller[0]; + } + + if ($controller instanceof AbstractController) { + $controller->setContainer($this->container); + } + } +} diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php new file mode 100644 index 0000000000..c4d80d76d8 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php @@ -0,0 +1,104 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use phpMyFAQ\Configuration; +use phpMyFAQ\Core\Exception; +use phpMyFAQ\Language; +use phpMyFAQ\Strings; +use phpMyFAQ\Translation; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; + +class LanguageListener +{ + private bool $initialized = false; + + public function __construct( + private readonly ContainerInterface $container, + ) { + } + + /** + * @throws Exception + */ + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest() || $this->initialized) { + return; + } + + $this->initialized = true; + + $currentLanguage = $this->detectLanguage(); + $this->initializeTranslation($currentLanguage); + } + + private function detectLanguage(): string + { + if (!$this->container->has('phpmyfaq.configuration') || !$this->container->has('phpmyfaq.language')) { + return 'en'; + } + + /** @var Configuration $configuration */ + $configuration = $this->container->get(id: 'phpmyfaq.configuration'); + /** @var Language $language */ + $language = $this->container->get(id: 'phpmyfaq.language'); + + $configuration->setContainer($this->container); + + $detect = (bool) $configuration->get(item: 'main.languageDetection'); + $configLang = $configuration->get(item: 'main.language'); + + $currentLanguage = $detect + ? $language->setLanguageWithDetection($configLang) + : $language->setLanguageFromConfiguration($configLang); + + require PMF_TRANSLATION_DIR . '/language_en.php'; + if (Language::isASupportedLanguage($currentLanguage)) { + require PMF_TRANSLATION_DIR . '/language_' . strtolower($currentLanguage) . '.php'; + } + + $configuration->setLanguage($language); + + return $currentLanguage; + } + + /** + * @throws Exception + */ + private function initializeTranslation(string $currentLanguage): void + { + Strings::init($currentLanguage); + + try { + Translation::create() + ->setTranslationsDir(PMF_TRANSLATION_DIR) + ->setDefaultLanguage(defaultLanguage: 'en') + ->setCurrentLanguage($currentLanguage) + ->setMultiByteLanguage(); + } catch (Exception $exception) { + throw new Exception($exception->getMessage()); + } + } +} diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php new file mode 100644 index 0000000000..e8bb4b30aa --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php @@ -0,0 +1,56 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\Routing\Matcher\UrlMatcher; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\RouteCollection; + +class RouterListener +{ + public function __construct( + private readonly RouteCollection $routes, + ) { + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + + // Skip if already matched (e.g., by sub-request or test) + if ($request->attributes->has('_controller')) { + return; + } + + $requestContext = new RequestContext(); + $requestContext->fromRequest($request); + + $urlMatcher = new UrlMatcher($this->routes, $requestContext); + $parameters = $urlMatcher->match($request->getPathInfo()); + $request->attributes->add($parameters); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php new file mode 100644 index 0000000000..a07ca060a7 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php @@ -0,0 +1,133 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use phpMyFAQ\Controller\Exception\ForbiddenException; +use phpMyFAQ\Controller\Frontend\PageNotFoundController; +use phpMyFAQ\Environment; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Throwable; + +class WebExceptionListener +{ + public function onKernelException(ExceptionEvent $event): void + { + $request = $event->getRequest(); + $pathInfo = $request->getPathInfo(); + + // Skip API requests — handled by ApiExceptionListener + if (str_starts_with($pathInfo, '/api/') || $request->attributes->get('_api_context', false)) { + return; + } + + $throwable = $event->getThrowable(); + + $response = match (true) { + $throwable instanceof ResourceNotFoundException => $this->handleNotFound($event), + $throwable instanceof UnauthorizedHttpException => new RedirectResponse(url: './login'), + $throwable instanceof ForbiddenException => $this->handleErrorResponse( + 'An error occurred: :message at line :line at :file', + 'Forbidden', + Response::HTTP_FORBIDDEN, + $throwable, + ), + $throwable instanceof BadRequestException => $this->handleErrorResponse( + 'An error occurred: :message at line :line at :file', + 'Bad Request', + Response::HTTP_BAD_REQUEST, + $throwable, + ), + default => $this->handleServerError($throwable), + }; + + $event->setResponse($response); + } + + private function handleNotFound(ExceptionEvent $event): Response + { + $request = $event->getRequest(); + $throwable = $event->getThrowable(); + + try { + $request->attributes->set('_route', 'public.404'); + $request->attributes->set('_controller', PageNotFoundController::class . '::index'); + $controllerResolver = new ControllerResolver(); + $argumentResolver = new ArgumentResolver(); + $controller = $controllerResolver->getController($request); + $arguments = $argumentResolver->getArguments($request, $controller); + return call_user_func_array($controller, $arguments); + } catch (Throwable) { + return $this->handleErrorResponse( + 'Not Found: :message at line :line at :file', + 'Not Found', + Response::HTTP_NOT_FOUND, + $throwable, + ); + } + } + + private function handleServerError(Throwable $throwable): Response + { + error_log(sprintf( + 'Unhandled exception: %s at %s:%d', + $throwable->getMessage(), + $throwable->getFile(), + $throwable->getLine(), + )); + + return $this->handleErrorResponse( + 'Internal Server Error: :message at line :line at :file', + 'Internal Server Error', + Response::HTTP_INTERNAL_SERVER_ERROR, + $throwable, + ); + } + + private function handleErrorResponse( + string $debugTemplate, + string $fallbackMessage, + int $statusCode, + Throwable $throwable, + ): Response { + $message = Environment::isDebugMode() + ? $this->formatExceptionMessage($debugTemplate, $throwable) + : $fallbackMessage; + + return new Response(content: $message, status: $statusCode); + } + + private function formatExceptionMessage(string $template, Throwable $throwable): string + { + return strtr($template, [ + ':message' => $throwable->getMessage(), + ':line' => (string) $throwable->getLine(), + ':file' => $throwable->getFile(), + ]); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Kernel.php b/phpmyfaq/src/phpMyFAQ/Kernel.php new file mode 100644 index 0000000000..12803e4712 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Kernel.php @@ -0,0 +1,193 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ; + +use phpMyFAQ\EventListener\ApiExceptionListener; +use phpMyFAQ\EventListener\ControllerContainerListener; +use phpMyFAQ\EventListener\LanguageListener; +use phpMyFAQ\EventListener\RouterListener; +use phpMyFAQ\EventListener\WebExceptionListener; +use phpMyFAQ\Form\FormsServiceProvider; +use phpMyFAQ\Routing\RouteCacheManager; +use phpMyFAQ\Routing\RouteCollectionBuilder; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; +use Symfony\Component\HttpKernel\HttpKernel; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Routing\RouteCollection; + +class Kernel implements HttpKernelInterface +{ + private ?ContainerBuilder $container = null; + + private ?HttpKernel $httpKernel = null; + + private bool $booted = false; + + private ?RouteCollection $routes = null; + + public function __construct( + private readonly string $routingContext = 'public', + private readonly bool $debug = false, + ) { + } + + /** + * Boots the Kernel: builds the DI container, loads routes, registers listeners, and creates the HttpKernel. + */ + public function boot(): void + { + if ($this->booted) { + return; + } + + $this->container = $this->buildContainer(); + $this->routes = $this->loadRoutes(); + $this->httpKernel = $this->createHttpKernel(); + $this->booted = true; + } + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + if (!$this->booted) { + $this->boot(); + } + + // Mark API context on the request for exception listeners + if ($this->routingContext === 'api' || $this->routingContext === 'admin-api') { + $request->attributes->set('_api_context', true); + } + + return $this->httpKernel->handle($request, $type, $catch); + } + + public function getContainer(): ContainerInterface + { + if (!$this->booted) { + $this->boot(); + } + + return $this->container; + } + + public function getRoutingContext(): string + { + return $this->routingContext; + } + + public function isDebug(): bool + { + return $this->debug; + } + + private function buildContainer(): ContainerBuilder + { + $containerBuilder = new ContainerBuilder(); + $phpFileLoader = new PhpFileLoader($containerBuilder, new FileLocator(PMF_SRC_DIR)); + + try { + $phpFileLoader->load(resource: 'services.php'); + } catch (\Exception $exception) { + error_log('Kernel: Failed to load services.php: ' . $exception->getMessage()); + } + + // Register Forms services + FormsServiceProvider::register($containerBuilder); + + // Register kernel-level services + $containerBuilder->set('kernel', $this); + + return $containerBuilder; + } + + private function loadRoutes(): RouteCollection + { + $configuration = $this->container?->get(id: 'phpmyfaq.configuration'); + + $cacheEnabled = filter_var(Environment::get('ROUTING_CACHE_ENABLED', 'true'), FILTER_VALIDATE_BOOLEAN); + $cacheDir = Environment::get('ROUTING_CACHE_DIR', PMF_ROOT_DIR . '/cache/routes'); + + if ($cacheEnabled && !$this->debug && !Environment::isDebugMode()) { + $cacheManager = new RouteCacheManager($cacheDir, Environment::isDebugMode()); + return $cacheManager->getRoutes($this->routingContext, function () use ($configuration) { + $builder = new RouteCollectionBuilder($configuration); + return $builder->build($this->routingContext); + }); + } + + $builder = new RouteCollectionBuilder($configuration); + return $builder->build($this->routingContext); + } + + private function createHttpKernel(): HttpKernel + { + $dispatcher = $this->container->get('phpmyfaq.event_dispatcher'); + + if (!$dispatcher instanceof EventDispatcher) { + $dispatcher = new EventDispatcher(); + } + + $this->registerEventListeners($dispatcher); + + $controllerResolver = new ControllerResolver(); + $requestStack = new RequestStack(); + $argumentResolver = new ArgumentResolver(); + + return new HttpKernel($dispatcher, $controllerResolver, $requestStack, $argumentResolver); + } + + private function registerEventListeners(EventDispatcher $dispatcher): void + { + // Router listener — matches request to route (priority 256, runs early) + $routerListener = new RouterListener($this->routes); + $dispatcher->addListener(KernelEvents::REQUEST, [$routerListener, 'onKernelRequest'], 256); + + // Language listener — detects language and initializes translations (priority 200, after router) + $languageListener = new LanguageListener($this->container); + $dispatcher->addListener(KernelEvents::REQUEST, [$languageListener, 'onKernelRequest'], 200); + + // API exception listener — converts exceptions to RFC 7807 JSON (priority 0) + $configuration = $this->container->has('phpmyfaq.configuration') + ? $this->container->get('phpmyfaq.configuration') + : null; + $apiExceptionListener = new ApiExceptionListener($configuration); + $dispatcher->addListener(KernelEvents::EXCEPTION, [$apiExceptionListener, 'onKernelException'], 0); + + // Web exception listener — handles web (non-API) exceptions (priority -10, after API listener) + $webExceptionListener = new WebExceptionListener(); + $dispatcher->addListener(KernelEvents::EXCEPTION, [$webExceptionListener, 'onKernelException'], -10); + + // Controller container listener — injects shared container into controllers + $controllerContainerListener = new ControllerContainerListener($this->container); + $dispatcher->addListener(KernelEvents::CONTROLLER, [$controllerContainerListener, 'onKernelController'], 0); + } +} diff --git a/phpunit.xml b/phpunit.xml index 30db508f2a..0f077edad3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,6 +21,10 @@ ./tests/phpMyFAQ + ./tests/phpMyFAQ/Functional + + + ./tests/phpMyFAQ/Functional diff --git a/tests/phpMyFAQ/ApplicationTest.php b/tests/phpMyFAQ/ApplicationTest.php deleted file mode 100644 index 2bd89c4136..0000000000 --- a/tests/phpMyFAQ/ApplicationTest.php +++ /dev/null @@ -1,679 +0,0 @@ -container = $this->createMock(ContainerInterface::class); - $this->application = new Application($this->container); - } - - /** - * @throws Exception - */ - public function testConstructorWithContainer(): void - { - $container = $this->createStub(ContainerInterface::class); - $application = new Application($container); - $this->assertInstanceOf(Application::class, $application); - } - - public function testConstructorWithoutContainer(): void - { - $application = new Application(); - $this->assertInstanceOf(Application::class, $application); - } - - public function testSetUrlMatcher(): void - { - $urlMatcher = $this->createStub(UrlMatcher::class); - $this->application->urlMatcher = $urlMatcher; - - $reflection = new ReflectionClass(Application::class); - $property = $reflection->getProperty('urlMatcher'); - - $this->assertSame($urlMatcher, $property->getValue($this->application)); - } - - public function testSetControllerResolver(): void - { - $controllerResolver = $this->createStub(ControllerResolver::class); - $this->application->controllerResolver = $controllerResolver; - - $reflection = new ReflectionClass(Application::class); - $property = $reflection->getProperty('controllerResolver'); - - $this->assertSame($controllerResolver, $property->getValue($this->application)); - } - - /** - * @throws ReflectionException - */ - public function testSetLanguageWithContainer(): void - { - $configuration = $this->createMock(Configuration::class); - $session = $this->createStub(Session::class); - $language = new Language($configuration, $session); - - $configuration - ->expects($this->exactly(2)) - ->method('get') - ->willReturnMap([ - ['main.languageDetection', true], - ['main.language', 'en'], - ]); - - // Keine Mock-Erwartung auf Language::setLanguage() – echte Instanz wird verwendet - - // Konfiguration speichert die Language-Instanz über setLanguage() - $configuration->expects($this->once())->method('setLanguage')->with($language); - - $this->container - ->expects($this->exactly(2)) - ->method('get') - ->willReturnMap([ - ['phpmyfaq.configuration', $configuration], - ['phpmyfaq.language', $language], - ]); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('setLanguage'); - - $result = $method->invoke($this->application); - $this->assertEquals('en', $result); - } - - /** - * @throws ReflectionException - */ - public function testSetLanguageWithoutContainer(): void - { - $application = new Application(); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('setLanguage'); - - $result = $method->invoke($application); - $this->assertEquals('en', $result); - } - - /** - * @throws ReflectionException - */ - public function testInitializeTranslationSuccess(): void - { - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('initializeTranslation'); - - $this->expectNotToPerformAssertions(); - - $method->invoke($this->application, 'en'); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestSuccess(): void - { - $routeCollection = new RouteCollection(); - $routeCollection->add('test_route', new Route('/test', [ - '_controller' => function () { - return new Response('Test Response'); - }, - ])); - - $request = Request::create('/test'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - $this->expectOutputString('Test Response'); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestResourceNotFoundException(): void - { - $routeCollection = new RouteCollection(); - $request = Request::create('/nonexistent'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Not Found', $output); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestUnauthorizedHttpExceptionForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - // Set API context - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $routeCollection->add('api_route', new Route('/api/test', [ - '_controller' => function () { - throw new UnauthorizedHttpException('Bearer', 'Unauthorized'); - }, - ])); - - $request = Request::create('/api/test'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Unauthorized', $output); - $this->assertStringContainsString('/problems/unauthorized', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(401, $data['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestUnauthorizedHttpExceptionForNonApi(): void - { - $routeCollection = new RouteCollection(); - $routeCollection->add('web_route', new Route('/test', [ - '_controller' => function () { - throw new UnauthorizedHttpException('Bearer', 'Unauthorized'); - }, - ])); - - $request = Request::create('/test'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - // RedirectResponse sendet Location Header, nicht Inhalt - $this->expectOutputString(''); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestBadRequestException(): void - { - $routeCollection = new RouteCollection(); - $routeCollection->add('bad_route', new Route('/bad', [ - '_controller' => function () { - throw new BadRequestException('Bad request'); - }, - ])); - - $request = Request::create('/bad'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Bad Request', $output); - } - - /** - * @throws ReflectionException - */ - public function testRunMethodWithContainer(): void - { - $configuration = $this->createMock(Configuration::class); - $session = $this->createStub(Session::class); - $language = new Language($configuration, $session); - - $configuration - ->expects($this->exactly(2)) - ->method('get') - ->willReturnMap([ - ['main.languageDetection', true], - ['main.language', 'en'], - ]); - - // Keine Mock-Erwartung auf Language::setLanguage() – echte Instanz wird verwendet - - $configuration->expects($this->once())->method('setLanguage')->with($language); - - $this->container - ->expects($this->exactly(2)) - ->method('get') - ->willReturnMap([ - ['phpmyfaq.configuration', $configuration], - ['phpmyfaq.language', $language], - ]); - - $routeCollection = new RouteCollection(); - $routeCollection->add('test_route', new Route('/', [ - '_controller' => function () { - return new Response('Welcome'); - }, - ])); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $_SERVER['REQUEST_URI'] = '/'; - $_SERVER['HTTP_HOST'] = 'localhost'; - - ob_start(); - try { - $this->application->run($routeCollection); - } catch (PMFException $e) { - $this->assertInstanceOf(PMFException::class, $e); - } - ob_get_clean(); - - $this->assertTrue(true); - } - - /** - * Test für die run() Methode ohne Container - */ - public function testRunMethodWithoutContainer(): void - { - $application = new Application(); - $routeCollection = new RouteCollection(); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $_SERVER['REQUEST_URI'] = '/nonexistent'; - $_SERVER['HTTP_HOST'] = 'localhost'; - - ob_start(); - try { - $application->run($routeCollection); - } catch (PMFException $e) { - $this->assertInstanceOf(PMFException::class, $e); - } - $output = ob_get_clean(); - - $this->assertTrue(true); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor404(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/nonexistent'); - $exception = new ResourceNotFoundException('Route not found'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke( - $this->application, - $request, - Response::HTTP_NOT_FOUND, - $exception, - 'The requested resource was not found.', - ); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); - $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://localhost/problems/not-found', $content['type']); - $this->assertEquals('Resource not found', $content['title']); - $this->assertEquals(404, $content['status']); - $this->assertEquals('/api/nonexistent', $content['instance']); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor400(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://example.com'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/test'); - $exception = new BadRequestException('Invalid parameter'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke( - $this->application, - $request, - Response::HTTP_BAD_REQUEST, - $exception, - 'Bad request.', - ); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://example.com/problems/bad-request', $content['type']); - $this->assertEquals('Bad Request', $content['title']); - $this->assertEquals(400, $content['status']); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor401(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost/'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/secure'); - $exception = new UnauthorizedHttpException('Bearer', 'Missing token'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke( - $this->application, - $request, - Response::HTTP_UNAUTHORIZED, - $exception, - 'Unauthorized.', - ); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://localhost/problems/unauthorized', $content['type']); - $this->assertEquals('Unauthorized', $content['title']); - $this->assertEquals(401, $content['status']); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor403(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/admin'); - $exception = new ForbiddenException('Insufficient permissions'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke($this->application, $request, Response::HTTP_FORBIDDEN, $exception, 'Forbidden.'); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://localhost/problems/forbidden', $content['type']); - $this->assertEquals('Forbidden', $content['title']); - $this->assertEquals(403, $content['status']); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor500(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/error'); - $exception = new \RuntimeException('Database connection failed'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke( - $this->application, - $request, - Response::HTTP_INTERNAL_SERVER_ERROR, - $exception, - 'Internal server error.', - ); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://localhost/problems/internal-server-error', $content['type']); - $this->assertEquals('Internal Server Error', $content['title']); - $this->assertEquals(500, $content['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestResourceNotFoundExceptionForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $request = Request::create('/api/nonexistent'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Resource not found', $output); - $this->assertStringContainsString('/problems/not-found', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(404, $data['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestBadRequestExceptionForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $routeCollection->add('bad_route', new Route('/api/bad', [ - '_controller' => function () { - throw new BadRequestException('Invalid input'); - }, - ])); - - $request = Request::create('/api/bad'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Bad Request', $output); - $this->assertStringContainsString('/problems/bad-request', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(400, $data['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestForbiddenExceptionForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $routeCollection->add('forbidden_route', new Route('/api/forbidden', [ - '_controller' => function () { - throw new ForbiddenException('Access denied'); - }, - ])); - - $request = Request::create('/api/forbidden'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Forbidden', $output); - $this->assertStringContainsString('/problems/forbidden', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(403, $data['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestInternalServerErrorForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $routeCollection->add('error_route', new Route('/api/error', [ - '_controller' => function () { - throw new \RuntimeException('Something went wrong'); - }, - ])); - - $request = Request::create('/api/error'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - // Suppress error_log output - $originalErrorLog = ini_get('error_log'); - ini_set('error_log', '/dev/null'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - // Restore error_log - ini_set('error_log', $originalErrorLog); - - $this->assertStringContainsString('Internal Server Error', $output); - $this->assertStringContainsString('/problems/internal-server-error', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(500, $data['status']); - } -} diff --git a/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php b/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php new file mode 100644 index 0000000000..b0f4f38c06 --- /dev/null +++ b/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php @@ -0,0 +1,144 @@ +configuration = $this->createMock(Configuration::class); + $this->configuration->method('getDefaultUrl')->willReturn('https://localhost'); + $this->listener = new ApiExceptionListener($this->configuration); + } + + private function createEvent(Request $request, \Throwable $exception): ExceptionEvent + { + $kernel = $this->createMock(HttpKernelInterface::class); + return new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception); + } + + public function testIgnoresNonApiRequests(): void + { + $request = Request::create('/some-page.html'); + $event = $this->createEvent($request, new \RuntimeException('error')); + + $this->listener->onKernelException($event); + + $this->assertNull($event->getResponse()); + } + + public function testHandlesApiRequestsByPath(): void + { + $request = Request::create('/api/v3.2/version'); + $event = $this->createEvent($request, new ResourceNotFoundException('Route not found')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); + + $content = json_decode($response->getContent(), true); + $this->assertEquals('https://localhost/problems/not-found', $content['type']); + $this->assertEquals('Resource not found', $content['title']); + $this->assertEquals(404, $content['status']); + } + + public function testHandlesApiContextAttribute(): void + { + $request = Request::create('/admin/api/something'); + $request->attributes->set('_api_context', true); + $event = $this->createEvent($request, new ResourceNotFoundException('not found')); + + $this->listener->onKernelException($event); + + $this->assertNotNull($event->getResponse()); + $this->assertEquals(Response::HTTP_NOT_FOUND, $event->getResponse()->getStatusCode()); + } + + public function testHandlesUnauthorizedException(): void + { + $request = Request::create('/api/v3.2/secure'); + $event = $this->createEvent($request, new UnauthorizedHttpException('Bearer', 'Missing token')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals(401, $content['status']); + $this->assertEquals('Unauthorized', $content['title']); + } + + public function testHandlesForbiddenException(): void + { + $request = Request::create('/api/v3.2/admin'); + $event = $this->createEvent($request, new ForbiddenException('Access denied')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals(403, $content['status']); + $this->assertEquals('Forbidden', $content['title']); + } + + public function testHandlesBadRequestException(): void + { + $request = Request::create('/api/v3.2/test'); + $event = $this->createEvent($request, new BadRequestException('Invalid input')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals(400, $content['status']); + $this->assertEquals('Bad Request', $content['title']); + } + + public function testHandlesGenericException(): void + { + $request = Request::create('/api/v3.2/error'); + $event = $this->createEvent($request, new \RuntimeException('Something went wrong')); + + // Suppress error_log output + $originalErrorLog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + + $this->listener->onKernelException($event); + + ini_set('error_log', $originalErrorLog); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals(500, $content['status']); + $this->assertEquals('Internal Server Error', $content['title']); + } + + public function testWithoutConfiguration(): void + { + $listener = new ApiExceptionListener(null); + $request = Request::create('/api/v3.2/test'); + $event = $this->createEvent($request, new ResourceNotFoundException('not found')); + + $listener->onKernelException($event); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals('/problems/not-found', $content['type']); + } +} diff --git a/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php new file mode 100644 index 0000000000..9beefaeda3 --- /dev/null +++ b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php @@ -0,0 +1,81 @@ +createMock(ContainerInterface::class); + $listener = new ControllerContainerListener($container); + + // Create a concrete anonymous class extending AbstractController + // that tracks setContainer calls without triggering initializeFromContainer + $controller = new class() extends AbstractController { + public bool $containerWasSet = false; + + public function __construct() + { + // Skip parent constructor to avoid container creation in tests + } + + public function setContainer(ContainerInterface $container): void + { + $this->containerWasSet = true; + // Don't call parent::setContainer() to avoid needing full container setup + $this->container = $container; + } + + public function testAction(): Response + { + return new Response('test'); + } + }; + + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('/test'); + + $event = new ControllerEvent( + $kernel, + [$controller, 'testAction'], + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + + $listener->onKernelController($event); + + $this->assertTrue($controller->containerWasSet); + } + + public function testIgnoresNonAbstractControllers(): void + { + $container = $this->createMock(ContainerInterface::class); + $listener = new ControllerContainerListener($container); + + $controller = function () { + return new Response('test'); + }; + + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('/test'); + + $event = new ControllerEvent( + $kernel, + $controller, + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + + // Should not throw or error + $listener->onKernelController($event); + $this->assertTrue(true); + } +} diff --git a/tests/phpMyFAQ/EventListener/RouterListenerTest.php b/tests/phpMyFAQ/EventListener/RouterListenerTest.php new file mode 100644 index 0000000000..7d9074c3ac --- /dev/null +++ b/tests/phpMyFAQ/EventListener/RouterListenerTest.php @@ -0,0 +1,79 @@ +createMock(HttpKernelInterface::class); + return new RequestEvent($kernel, $request, $type); + } + + public function testMatchesRoute(): void + { + $routes = new RouteCollection(); + $routes->add('test_route', new Route('/test', [ + '_controller' => function () { + return new Response('OK'); + }, + ])); + + $listener = new RouterListener($routes); + $request = Request::create('/test'); + $event = $this->createEvent($request); + + $listener->onKernelRequest($event); + + $this->assertTrue($request->attributes->has('_controller')); + $this->assertEquals('test_route', $request->attributes->get('_route')); + } + + public function testSkipsSubRequests(): void + { + $routes = new RouteCollection(); + $listener = new RouterListener($routes); + + $request = Request::create('/test'); + $event = $this->createEvent($request, HttpKernelInterface::SUB_REQUEST); + + $listener->onKernelRequest($event); + + $this->assertFalse($request->attributes->has('_controller')); + } + + public function testSkipsAlreadyMatchedRequests(): void + { + $routes = new RouteCollection(); + $listener = new RouterListener($routes); + + $request = Request::create('/test'); + $request->attributes->set('_controller', 'SomeController::action'); + $event = $this->createEvent($request); + + $listener->onKernelRequest($event); + + $this->assertEquals('SomeController::action', $request->attributes->get('_controller')); + } + + public function testThrowsOnNoMatch(): void + { + $routes = new RouteCollection(); + $listener = new RouterListener($routes); + + $request = Request::create('/nonexistent'); + $event = $this->createEvent($request); + + $this->expectException(ResourceNotFoundException::class); + $listener->onKernelRequest($event); + } +} diff --git a/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php new file mode 100644 index 0000000000..5bfd1d1fe0 --- /dev/null +++ b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php @@ -0,0 +1,118 @@ +listener = new WebExceptionListener(); + } + + private function createEvent(Request $request, \Throwable $exception): ExceptionEvent + { + $kernel = $this->createMock(HttpKernelInterface::class); + return new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception); + } + + public function testIgnoresApiRequests(): void + { + $request = Request::create('/api/v3.2/version'); + $event = $this->createEvent($request, new \RuntimeException('error')); + + $this->listener->onKernelException($event); + + $this->assertNull($event->getResponse()); + } + + public function testIgnoresApiContextAttribute(): void + { + $request = Request::create('/admin/api/something'); + $request->attributes->set('_api_context', true); + $event = $this->createEvent($request, new \RuntimeException('error')); + + $this->listener->onKernelException($event); + + $this->assertNull($event->getResponse()); + } + + public function testHandlesResourceNotFoundException(): void + { + $request = Request::create('/nonexistent-page.html'); + $event = $this->createEvent($request, new ResourceNotFoundException('not found')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + // Either PageNotFoundController handles it (404) or fallback (404) + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } + + public function testHandlesUnauthorizedHttpException(): void + { + $request = Request::create('/secure-page.html'); + $event = $this->createEvent($request, new UnauthorizedHttpException('Bearer', 'Not logged in')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('./login', $response->headers->get('Location')); + } + + public function testHandlesForbiddenException(): void + { + $request = Request::create('/admin/settings.html'); + $event = $this->createEvent($request, new ForbiddenException('No permission')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + } + + public function testHandlesBadRequestException(): void + { + $request = Request::create('/page.html'); + $event = $this->createEvent($request, new BadRequestException('Invalid')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + } + + public function testHandlesGenericException(): void + { + $request = Request::create('/page.html'); + $event = $this->createEvent($request, new \RuntimeException('Server error')); + + // Suppress error_log output + $originalErrorLog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + + $this->listener->onKernelException($event); + + ini_set('error_log', $originalErrorLog); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + } +} diff --git a/tests/phpMyFAQ/Functional/KernelRoutingTest.php b/tests/phpMyFAQ/Functional/KernelRoutingTest.php new file mode 100644 index 0000000000..ce696f46ec --- /dev/null +++ b/tests/phpMyFAQ/Functional/KernelRoutingTest.php @@ -0,0 +1,197 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Functional; + +use phpMyFAQ\EventListener\ApiExceptionListener; +use phpMyFAQ\EventListener\RouterListener; +use phpMyFAQ\EventListener\WebExceptionListener; +use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; +use Symfony\Component\HttpKernel\HttpKernel; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +class KernelRoutingTest extends TestCase +{ + private function createKernelStack(RouteCollection $routes, bool $isApi = false): HttpKernel + { + $dispatcher = new EventDispatcher(); + + // Register router listener + $routerListener = new RouterListener($routes); + $dispatcher->addListener(KernelEvents::REQUEST, [$routerListener, 'onKernelRequest'], 256); + + // Register exception listeners + $apiListener = new ApiExceptionListener(null); + $dispatcher->addListener(KernelEvents::EXCEPTION, [$apiListener, 'onKernelException'], 0); + + $webListener = new WebExceptionListener(); + $dispatcher->addListener(KernelEvents::EXCEPTION, [$webListener, 'onKernelException'], -10); + + return new HttpKernel( + $dispatcher, + new ControllerResolver(), + new RequestStack(), + new ArgumentResolver(), + ); + } + + public function testSuccessfulRouteReturnsOk(): void + { + $routes = new RouteCollection(); + $routes->add('test_route', new Route('/test', [ + '_controller' => function () { + return new Response('Hello World'); + }, + ])); + + $kernel = $this->createKernelStack($routes); + $request = Request::create('/test'); + $response = $kernel->handle($request); + + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertEquals('Hello World', $response->getContent()); + } + + public function testNotFoundReturns404ForWebRequest(): void + { + $routes = new RouteCollection(); + $kernel = $this->createKernelStack($routes); + $request = Request::create('/nonexistent'); + $response = $kernel->handle($request); + + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } + + public function testNotFoundReturns404JsonForApiRequest(): void + { + $routes = new RouteCollection(); + $kernel = $this->createKernelStack($routes, isApi: true); + $request = Request::create('/api/v3.2/nonexistent'); + $response = $kernel->handle($request); + + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); + + $content = json_decode($response->getContent(), true); + $this->assertIsArray($content); + $this->assertEquals(404, $content['status']); + $this->assertEquals('Resource not found', $content['title']); + } + + public function testControllerExceptionHandledByApiListener(): void + { + $routes = new RouteCollection(); + $routes->add('api_error', new Route('/api/v3.2/error', [ + '_controller' => function () { + throw new \RuntimeException('Test error'); + }, + ])); + + // Suppress error_log output + $originalErrorLog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + + $kernel = $this->createKernelStack($routes, isApi: true); + $request = Request::create('/api/v3.2/error'); + $response = $kernel->handle($request); + + ini_set('error_log', $originalErrorLog); + + $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); + + $content = json_decode($response->getContent(), true); + $this->assertEquals(500, $content['status']); + $this->assertEquals('Internal Server Error', $content['title']); + } + + public function testControllerExceptionHandledByWebListener(): void + { + $routes = new RouteCollection(); + $routes->add('web_error', new Route('/error-page', [ + '_controller' => function () { + throw new \RuntimeException('Test web error'); + }, + ])); + + // Suppress error_log output + $originalErrorLog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + + $kernel = $this->createKernelStack($routes); + $request = Request::create('/error-page'); + $response = $kernel->handle($request); + + ini_set('error_log', $originalErrorLog); + + $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + } + + public function testMultipleRoutesResolveCorrectly(): void + { + $routes = new RouteCollection(); + $routes->add('route_a', new Route('/page-a', [ + '_controller' => function () { + return new Response('Page A'); + }, + ])); + $routes->add('route_b', new Route('/page-b', [ + '_controller' => function () { + return new Response('Page B'); + }, + ])); + + $kernel = $this->createKernelStack($routes); + + $responseA = $kernel->handle(Request::create('/page-a')); + $this->assertEquals('Page A', $responseA->getContent()); + + $responseB = $kernel->handle(Request::create('/page-b')); + $this->assertEquals('Page B', $responseB->getContent()); + } + + public function testRouteWithParameters(): void + { + $routes = new RouteCollection(); + $routes->add('param_route', new Route('/items/{id}', [ + '_controller' => function (Request $request) { + $id = $request->attributes->get('id'); + return new Response(sprintf('Item %s', $id)); + }, + ], requirements: ['id' => '\d+'])); + + $kernel = $this->createKernelStack($routes); + + $response = $kernel->handle(Request::create('/items/42')); + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertEquals('Item 42', $response->getContent()); + } +} diff --git a/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php b/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php new file mode 100644 index 0000000000..c8df02ea77 --- /dev/null +++ b/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php @@ -0,0 +1,33 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Functional; + +use phpMyFAQ\Kernel; + +class PhpMyFaqTestKernel extends Kernel +{ + public function __construct(string $routingContext = 'public') + { + parent::__construct( + routingContext: $routingContext, + debug: true, + ); + } +} diff --git a/tests/phpMyFAQ/Functional/WebTestCase.php b/tests/phpMyFAQ/Functional/WebTestCase.php new file mode 100644 index 0000000000..24f44da5ea --- /dev/null +++ b/tests/phpMyFAQ/Functional/WebTestCase.php @@ -0,0 +1,120 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Functional; + +use phpMyFAQ\Kernel; +use PHPUnit\Framework\TestCase; +use Symfony\Component\BrowserKit\AbstractBrowser; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +abstract class WebTestCase extends TestCase +{ + protected static ?Kernel $kernel = null; + + protected static ?HttpKernelBrowser $client = null; + + protected static function createClient(string $routingContext = 'public'): HttpKernelBrowser + { + static::$kernel = new PhpMyFaqTestKernel($routingContext); + static::$kernel->boot(); + static::$client = new HttpKernelBrowser(static::$kernel); + + return static::$client; + } + + protected static function assertResponseStatusCodeSame(int $expectedStatusCode, ?Response $response = null): void + { + $response ??= static::$client?->getResponse(); + static::assertNotNull($response, 'No response available. Did you make a request?'); + static::assertSame($expectedStatusCode, $response->getStatusCode()); + } + + protected static function assertResponseIsSuccessful(?Response $response = null): void + { + $response ??= static::$client?->getResponse(); + static::assertNotNull($response, 'No response available. Did you make a request?'); + static::assertTrue( + $response->isSuccessful(), + sprintf('Expected successful response, got %d', $response->getStatusCode()), + ); + } + + protected static function assertResponseHeaderSame( + string $headerName, + string $expectedValue, + ?Response $response = null, + ): void { + $response ??= static::$client?->getResponse(); + static::assertNotNull($response, 'No response available.'); + static::assertSame($expectedValue, $response->headers->get($headerName)); + } + + protected function tearDown(): void + { + static::$kernel = null; + static::$client = null; + } +} + +/** + * A browser that sends requests through an HttpKernelInterface + * instead of making actual HTTP requests. + */ +class HttpKernelBrowser extends AbstractBrowser +{ + private ?Response $response = null; + + public function __construct( + private readonly HttpKernelInterface $kernel, + array $server = [], + ?\Symfony\Component\BrowserKit\History $history = null, + ?\Symfony\Component\BrowserKit\CookieJar $cookieJar = null, + ) { + parent::__construct($server, $history, $cookieJar); + } + + protected function doRequest(object $request): Response + { + if (!$request instanceof Request) { + throw new \InvalidArgumentException('Expected a Symfony Request object.'); + } + + $this->response = $this->kernel->handle($request); + + return $this->response; + } + + public function getResponse(): ?Response + { + $response = $this->getInternalResponse(); + + // Try the stored response first + if ($this->response !== null) { + return $this->response; + } + + return null; + } +} diff --git a/tests/phpMyFAQ/KernelTest.php b/tests/phpMyFAQ/KernelTest.php new file mode 100644 index 0000000000..727002a0fe --- /dev/null +++ b/tests/phpMyFAQ/KernelTest.php @@ -0,0 +1,40 @@ +assertInstanceOf(HttpKernelInterface::class, $kernel); + } + + public function testKernelRoutingContext(): void + { + $kernel = new Kernel(routingContext: 'admin', debug: false); + $this->assertEquals('admin', $kernel->getRoutingContext()); + } + + public function testKernelDebugMode(): void + { + $kernel = new Kernel(routingContext: 'public', debug: true); + $this->assertTrue($kernel->isDebug()); + } + + public function testKernelNonDebugMode(): void + { + $kernel = new Kernel(routingContext: 'public', debug: false); + $this->assertFalse($kernel->isDebug()); + } + + public function testKernelDefaultParameters(): void + { + $kernel = new Kernel(); + $this->assertEquals('public', $kernel->getRoutingContext()); + $this->assertFalse($kernel->isDebug()); + } +} From 82586c16116c8949b98026d53ecc7b474a21a92e Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Tue, 17 Feb 2026 06:03:16 +0100 Subject: [PATCH 2/2] fix: corrected review notes --- .../Controller/AbstractController.php | 8 +- .../AbstractAdministrationController.php | 5 +- .../AbstractAdministrationApiController.php | 5 +- .../Controller/Api/AbstractApiController.php | 25 ++-- .../Controller/Api/AttachmentController.php | 11 +- .../Controller/Api/CategoryController.php | 23 ++- .../Controller/Api/CommentController.php | 24 +++- .../phpMyFAQ/Controller/Api/FaqController.php | 131 +++++++++++------- .../Controller/Api/GlossaryController.php | 29 +++- .../Controller/Api/GroupController.php | 14 +- .../Controller/Api/NewsController.php | 10 +- .../Controller/Api/OpenQuestionController.php | 25 +++- .../Controller/Api/QuestionController.php | 19 +-- .../Controller/Api/SearchController.php | 26 +++- .../phpMyFAQ/Controller/Api/TagController.php | 29 +++- .../ContainerControllerResolver.php | 60 ++++++++ .../Frontend/Api/VotingController.php | 12 ++ .../EventListener/ApiExceptionListener.php | 3 +- .../EventListener/LanguageListener.php | 2 +- .../phpMyFAQ/EventListener/RouterListener.php | 17 ++- .../EventListener/WebExceptionListener.php | 9 +- phpmyfaq/src/phpMyFAQ/Kernel.php | 12 +- phpmyfaq/src/phpMyFAQ/User/UserSession.php | 11 +- .../Controller/AbstractControllerTest.php | 51 +++++++ .../Frontend/Api/VotingControllerTest.php | 11 +- .../ControllerContainerListenerTest.php | 14 +- .../EventListener/RouterListenerTest.php | 46 +++++- .../WebExceptionListenerTest.php | 2 +- .../phpMyFAQ/Functional/KernelRoutingTest.php | 46 ++++-- .../Functional/PhpMyFaqTestKernel.php | 5 +- tests/phpMyFAQ/Functional/WebTestCase.php | 17 +-- 31 files changed, 513 insertions(+), 189 deletions(-) create mode 100644 phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php diff --git a/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php b/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php index 4932f30ea1..d65964a08f 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php @@ -63,8 +63,6 @@ abstract class AbstractController protected ?SessionInterface $session = null; - private bool $containerInitialized = false; - /** @var ExtensionInterface[] */ private array $twigExtensions = []; @@ -108,11 +106,7 @@ protected function initializeFromContainer(): void $this->session = $this->container->get(id: 'session'); TwigWrapper::setTemplateSetName($this->configuration->getTemplateSet()); - - if (!$this->containerInitialized) { - $this->containerInitialized = true; - $this->isSecured(); - } + $this->isSecured(); } /** diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php index a36397b5d3..a61fb3a829 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php @@ -39,9 +39,10 @@ abstract class AbstractAdministrationController extends AbstractController { protected ?AdminLog $adminLog = null; - public function __construct() + #[\Override] + protected function initializeFromContainer(): void { - parent::__construct(); + parent::initializeFromContainer(); $this->adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log'); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AbstractAdministrationApiController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AbstractAdministrationApiController.php index dc6b599188..c11879a60b 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AbstractAdministrationApiController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AbstractAdministrationApiController.php @@ -24,9 +24,10 @@ class AbstractAdministrationApiController extends AbstractController { protected ?AdminLog $adminLog = null; - public function __construct() + #[\Override] + protected function initializeFromContainer(): void { - parent::__construct(); + parent::initializeFromContainer(); $this->adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log'); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/AbstractApiController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/AbstractApiController.php index 64be2d7933..8f26c80e3c 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/AbstractApiController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/AbstractApiController.php @@ -43,18 +43,13 @@ abstract class AbstractApiController extends AbstractController protected const int MAX_PER_PAGE = 100; /** - * Constructor - * - * Verifies that API access is enabled before allowing any API operations. - * - * @throws UnauthorizedHttpException If API is not enabled - * @throws Exception + * Initializes API controller and verifies API access is enabled. */ - public function __construct() + #[\Override] + protected function initializeFromContainer(): void { - parent::__construct(); + parent::initializeFromContainer(); - // Verify API is enabled if (!$this->isApiEnabled()) { throw new UnauthorizedHttpException(challenge: 'API is not enabled'); } @@ -70,11 +65,11 @@ public function __construct() * @return PaginationRequest */ protected function getPaginationRequest( + Request $request, int $defaultPerPage = self::DEFAULT_PER_PAGE, ?int $maxPerPage = null, ): PaginationRequest { $maxPerPage ??= self::MAX_PER_PAGE; - $request = Request::createFromGlobals(); return PaginationRequest::fromRequest($request, $defaultPerPage, $maxPerPage); } @@ -90,12 +85,11 @@ protected function getPaginationRequest( * @return SortRequest */ protected function getSortRequest( + Request $request, array $allowedFields, ?string $defaultField = null, string $defaultOrder = 'asc', ): SortRequest { - $request = Request::createFromGlobals(); - return SortRequest::fromRequest($request, $allowedFields, $defaultField, $defaultOrder); } @@ -115,10 +109,8 @@ protected function getSortRequest( * 'created_from' => 'date', * ] */ - protected function getFilterRequest(array $allowedFilters): FilterRequest + protected function getFilterRequest(Request $request, array $allowedFilters): FilterRequest { - $request = Request::createFromGlobals(); - return FilterRequest::fromRequest($request, $allowedFilters); } @@ -134,6 +126,7 @@ protected function getFilterRequest(array $allowedFilters): FilterRequest * @return JsonResponse */ protected function paginatedResponse( + Request $request, array $data, int $total, PaginationRequest $pagination, @@ -141,8 +134,6 @@ protected function paginatedResponse( ?FilterRequest $filters = null, int $status = Response::HTTP_OK, ): JsonResponse { - $request = Request::createFromGlobals(); - // Build base URL for pagination links $baseUrl = $request->getPathInfo(); if ($request->getQueryString()) { diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/AttachmentController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/AttachmentController.php index 49b3f62ff0..c8aebf8104 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/AttachmentController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/AttachmentController.php @@ -140,8 +140,9 @@ public function list(Request $request): JsonResponse $faqId = (int) Filter::filterVar($request->attributes->get(key: 'faqId'), FILTER_VALIDATE_INT); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'filename', 'mime_type', 'filesize', 'created'], defaultField: 'id', defaultOrder: 'asc', @@ -162,7 +163,13 @@ public function list(Request $request): JsonResponse $total = AttachmentFactory::countByRecordId($this->configuration, $faqId); // Return paginated response with envelope - return $this->paginatedResponse(data: $attachments, total: $total, pagination: $pagination, sort: $sort); + return $this->paginatedResponse( + $request, + data: $attachments, + total: $total, + pagination: $pagination, + sort: $sort, + ); } catch (AttachmentException) { return $this->errorResponse( message: 'Failed to fetch attachments', diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php index 77a75f7b69..5d7ac6fe10 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php @@ -35,6 +35,18 @@ final class CategoryController extends AbstractApiController { + private readonly Language $language; + + public function __construct(?Language $language = null) + { + parent::__construct(); + $resolvedLanguage = $language ?? $this->container?->get(id: 'phpmyfaq.language'); + if (!$resolvedLanguage instanceof Language) { + throw new \RuntimeException('Language service "phpmyfaq.language" is not available.'); + } + $this->language = $resolvedLanguage; + } + /** * @throws \Exception */ @@ -126,11 +138,10 @@ enum: ['id', 'name', 'parent_id', 'active'], }'), )] #[Route(path: 'v3.2/categories', name: 'api.categories.list', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { - /** @var Language $language */ - $language = $this->container->get(id: 'phpmyfaq.language'); - $currentLanguage = $language->setLanguageByAcceptLanguage(); + $request ??= Request::createFromGlobals(); + $currentLanguage = $this->language->setLanguageByAcceptLanguage(); [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); @@ -142,8 +153,9 @@ public function list(): JsonResponse $onlyActive = (bool) $this->configuration->get('api.onlyActiveCategories'); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'name', 'parent_id', 'active'], defaultField: 'id', defaultOrder: 'asc', @@ -162,6 +174,7 @@ public function list(): JsonResponse $total = $category->countCategories(activeOnly: $onlyActive); return $this->paginatedResponse( + $request, data: array_values($categories), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php index a24e58703a..a7ae983c8d 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php @@ -30,6 +30,18 @@ final class CommentController extends AbstractApiController { + private readonly Comments $comments; + + public function __construct(?Comments $comments = null) + { + parent::__construct(); + $resolvedComments = $comments ?? $this->container?->get(id: 'phpmyfaq.comments'); + if (!$resolvedComments instanceof Comments) { + throw new \RuntimeException('Comments service "phpmyfaq.comments" is not available.'); + } + $this->comments = $resolvedComments; + } + /** * @throws Exception */ @@ -133,19 +145,17 @@ public function list(Request $request): JsonResponse { $recordId = (int) Filter::filterVar($request->attributes->get(key: 'recordId'), FILTER_VALIDATE_INT); - /** @var Comments $comments */ - $comments = $this->container->get(id: 'phpmyfaq.comments'); - // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id_comment', 'id', 'usr', 'email', 'datum'], defaultField: 'id_comment', defaultOrder: 'asc', ); // Get paginated comments - $result = $comments->getCommentsDataPaginated( + $result = $this->comments->getCommentsDataPaginated( referenceId: $recordId, type: CommentType::FAQ, limit: $pagination->limit, @@ -155,8 +165,8 @@ public function list(Request $request): JsonResponse ); // Get total count - $total = $comments->countComments($recordId, CommentType::FAQ); + $total = $this->comments->countComments($recordId, CommentType::FAQ); - return $this->paginatedResponse(data: $result, total: $total, pagination: $pagination, sort: $sort); + return $this->paginatedResponse($request, data: $result, total: $total, pagination: $pagination, sort: $sort); } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php index d0aa4e8cbf..7f49327aeb 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php @@ -24,7 +24,11 @@ use OpenApi\Attributes as OA; use phpMyFAQ\Category; use phpMyFAQ\Entity\FaqEntity; +use phpMyFAQ\Faq; +use phpMyFAQ\Faq\MetaData as FaqMetaData; +use phpMyFAQ\Faq\Statistics as FaqStatistics; use phpMyFAQ\Filter; +use phpMyFAQ\Tags; use phpMyFAQ\User\CurrentUser; use stdClass; use Symfony\Component\HttpFoundation\JsonResponse; @@ -34,6 +38,38 @@ final class FaqController extends AbstractApiController { + private readonly Faq $faq; + private readonly Tags $tags; + private readonly FaqStatistics $faqStatistics; + private readonly FaqMetaData $faqMetaData; + + public function __construct( + ?Faq $faq = null, + ?Tags $tags = null, + ?FaqStatistics $faqStatistics = null, + ?FaqMetaData $faqMetaData = null, + ) { + parent::__construct(); + $resolvedFaq = $faq ?? $this->container?->get(id: 'phpmyfaq.faq'); + $resolvedTags = $tags ?? $this->container?->get(id: 'phpmyfaq.tags'); + $resolvedFaqStatistics = $faqStatistics ?? $this->container?->get(id: 'phpmyfaq.faq.statistics'); + $resolvedFaqMetaData = $faqMetaData ?? $this->container?->get(id: 'phpmyfaq.faq.metadata'); + + if ( + !$resolvedFaq instanceof Faq + || !$resolvedTags instanceof Tags + || !$resolvedFaqStatistics instanceof FaqStatistics + || !$resolvedFaqMetaData instanceof FaqMetaData + ) { + throw new \RuntimeException('FAQ-related services are not available in the container.'); + } + + $this->faq = $resolvedFaq; + $this->tags = $resolvedTags; + $this->faqStatistics = $resolvedFaqStatistics; + $this->faqMetaData = $resolvedFaqMetaData; + } + /** * @throws \phpMyFAQ\Core\Exception|Exception */ @@ -77,14 +113,13 @@ public function getByCategoryId(Request $request): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT); try { - $result = $faq->getAllAvailableFaqsByCategoryId($categoryId); + $result = $this->faq->getAllAvailableFaqsByCategoryId($categoryId); return $this->json($result, Response::HTTP_OK); } catch (Exception|CommonMarkException $exception) { return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); @@ -150,15 +185,14 @@ public function getById(Request $request): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $faqId = (int) Filter::filterVar($request->attributes->get(key: 'faqId'), FILTER_VALIDATE_INT); $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT); $onlyActive = (bool) $this->configuration->get('api.onlyActiveFaqs'); - $result = $faq->getFaqByIdAndCategoryId($faqId, $categoryId); + $result = $this->faq->getFaqByIdAndCategoryId($faqId, $categoryId); if ( (is_countable($result) ? count($result) : 0) === 0 @@ -216,17 +250,15 @@ public function getByTagId(Request $request): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $tagId = (int) Filter::filterVar($request->attributes->get(key: 'tagId'), FILTER_VALIDATE_INT); - $tags = $this->container->get(id: 'phpmyfaq.tags'); - $recordIds = $tags->getFaqsByTagId($tagId); + $recordIds = $this->tags->getFaqsByTagId($tagId); try { - $result = $faq->getFaqsByIds($recordIds); + $result = $this->faq->getFaqsByIds($recordIds); return $this->json($result, Response::HTTP_OK); } catch (Exception $exception) { return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); @@ -268,11 +300,10 @@ public function getPopular(): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faqStatistics = $this->container->get(id: 'phpmyfaq.faq.statistics'); - $faqStatistics->setUser($currentUser); - $faqStatistics->setGroups($currentGroups); + $this->faqStatistics->setUser($currentUser); + $this->faqStatistics->setGroups($currentGroups); - $result = array_values($faqStatistics->getTopTenData()); + $result = array_values($this->faqStatistics->getTopTenData()); if ((is_countable($result) ? count($result) : 0) === 0) { $this->json($result, Response::HTTP_NOT_FOUND); @@ -317,11 +348,10 @@ public function getLatest(): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faqStatistics = $this->container->get(id: 'phpmyfaq.faq.statistics'); - $faqStatistics->setUser($currentUser); - $faqStatistics->setGroups($currentGroups); + $this->faqStatistics->setUser($currentUser); + $this->faqStatistics->setGroups($currentGroups); - $result = array_values($faqStatistics->getLatestData()); + $result = array_values($this->faqStatistics->getLatestData()); if ((is_countable($result) ? count($result) : 0) === 0) { return $this->json($result, Response::HTTP_NOT_FOUND); @@ -365,11 +395,10 @@ public function getTrending(): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faqStatistics = $this->container->get(id: 'phpmyfaq.faq.statistics'); - $faqStatistics->setUser($currentUser); - $faqStatistics->setGroups($currentGroups); + $this->faqStatistics->setUser($currentUser); + $this->faqStatistics->setGroups($currentGroups); - $result = array_values($faqStatistics->getTrendingData()); + $result = array_values($this->faqStatistics->getTrendingData()); if ((is_countable($result) ? count($result) : 0) === 0) { $this->json($result, Response::HTTP_NOT_FOUND); @@ -418,11 +447,10 @@ public function getSticky(): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); - $result = array_values($faq->getStickyFaqsData()); + $result = array_values($this->faq->getStickyFaqsData()); if ((is_countable($result) ? count($result) : 0) === 0) { return $this->json($result, Response::HTTP_NOT_FOUND); @@ -531,17 +559,18 @@ enum: ['id', 'title', 'author', 'updated', 'created'], }', ))] #[Route(path: 'v3.2/faqs', name: 'api.faqs.list', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { + $request ??= Request::createFromGlobals(); [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'title', 'author', 'updated', 'created'], defaultField: 'id', defaultOrder: 'asc', @@ -550,8 +579,8 @@ public function list(): JsonResponse $onlyActive = (bool) $this->configuration->get('api.onlyActiveFaqs'); $ignoreOrphanedFaqs = (bool) $this->configuration->get('api.ignoreOrphanedFaqs'); - // Get all FAQs (this populates $faq->faqRecords) - $faq->getAllFaqs( + // Get all FAQs (this populates $this->faq->faqRecords) + $this->faq->getAllFaqs( FAQ_SORTING_TYPE_CATID_FAQID, [ 'lang' => $this->configuration->getLanguage()->getLanguage(), @@ -561,7 +590,7 @@ public function list(): JsonResponse $sort->getOrderSql(), ); - $allFaqs = $faq->faqRecords; + $allFaqs = $this->faq->faqRecords; $total = is_countable($allFaqs) ? count($allFaqs) : 0; // Apply sorting if needed (basic client-side sorting) @@ -579,6 +608,7 @@ public function list(): JsonResponse $result = array_slice($allFaqs, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, @@ -683,9 +713,8 @@ public function create(Request $request): JsonResponse $category->setGroups($currentGroups); $category->setLanguage($currentLanguage); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $languageCode = Filter::filterVar($data->language, FILTER_SANITIZE_SPECIAL_CHARS); $categoryId = Filter::filterVar($data->{'category-id'}, FILTER_VALIDATE_INT); @@ -718,7 +747,7 @@ public function create(Request $request): JsonResponse $categoryId = $categoryIdFound; } - if ($faq->hasTitleAHash($question)) { + if ($this->faq->hasTitleAHash($question)) { $result = [ 'stored' => false, 'error' => 'It is not allowed, that the question title contains a hash.', @@ -743,10 +772,13 @@ public function create(Request $request): JsonResponse ->setComment(comment: false) ->setNotes(notes: ''); - $faqEntity = $faq->create($faqData); + $faqEntity = $this->faq->create($faqData); - $faqMetaData = $this->container->get(id: 'phpmyfaq.faq.metadata'); - $faqMetaData->setFaqId($faqEntity->getId())->setFaqLanguage($languageCode)->setCategories($categories)->save(); + $this->faqMetaData + ->setFaqId($faqEntity->getId()) + ->setFaqLanguage($languageCode) + ->setCategories($categories) + ->save(); return $this->json(['stored' => true], Response::HTTP_CREATED); } @@ -842,9 +874,8 @@ public function update(Request $request): JsonResponse $category->setGroups($currentGroups); $category->setLanguage($currentLanguage); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $faqId = Filter::filterVar($data->{'faq-id'}, FILTER_VALIDATE_INT); $languageCode = Filter::filterVar($data->language, FILTER_SANITIZE_SPECIAL_CHARS); @@ -856,7 +887,7 @@ public function update(Request $request): JsonResponse $isActive = Filter::filterVar($data->{'is-active'}, FILTER_VALIDATE_BOOLEAN); $isSticky = Filter::filterVar($data->{'is-sticky'}, FILTER_VALIDATE_BOOLEAN); - if ($faq->hasTitleAHash($question)) { + if ($this->faq->hasTitleAHash($question)) { $result = [ 'stored' => false, 'error' => 'It is not allowed, that the question title contains a hash.', @@ -882,7 +913,7 @@ public function update(Request $request): JsonResponse ->setComment(comment: false) ->setNotes(notes: ''); - $faq->update($faqEntity); + $this->faq->update($faqEntity); return $this->json(['stored' => true], Response::HTTP_OK); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php index 3991847963..6fdf03b26e 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php @@ -21,12 +21,29 @@ use Exception; use OpenApi\Attributes as OA; +use phpMyFAQ\Glossary; +use phpMyFAQ\Language; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; final class GlossaryController extends AbstractApiController { + private readonly Glossary $glossary; + private readonly Language $language; + + public function __construct(?Glossary $glossary = null, ?Language $language = null) + { + parent::__construct(); + $resolvedGlossary = $glossary ?? $this->container?->get(id: 'phpmyfaq.glossary'); + $resolvedLanguage = $language ?? $this->container?->get(id: 'phpmyfaq.language'); + if (!$resolvedGlossary instanceof Glossary || !$resolvedLanguage instanceof Language) { + throw new \RuntimeException('Glossary services are not available in the container.'); + } + $this->glossary = $resolvedGlossary; + $this->language = $resolvedLanguage; + } + /** * @throws Exception */ @@ -114,24 +131,23 @@ final class GlossaryController extends AbstractApiController #[Route(path: 'v3.2/glossary', name: 'api.glossary.list', methods: ['GET'])] public function list(Request $request): JsonResponse { - $glossary = $this->container->get(id: 'phpmyfaq.glossary'); - $language = $this->container->get(id: 'phpmyfaq.language'); - $currentLanguage = $language->setLanguageByAcceptLanguage(); + $currentLanguage = $this->language->setLanguageByAcceptLanguage(); if ($currentLanguage !== false) { - $glossary->setLanguage($currentLanguage); + $this->glossary->setLanguage($currentLanguage); } // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'item', 'definition'], defaultField: 'item', defaultOrder: 'asc', ); // Get all glossary items - $allItems = $glossary->fetchAll(); + $allItems = $this->glossary->fetchAll(); $total = is_countable($allItems) ? count($allItems) : 0; // Apply sorting if needed @@ -149,6 +165,7 @@ public function list(Request $request): JsonResponse $result = array_slice($allItems, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php index a1f944e7c7..b75b7d9a7b 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php @@ -22,6 +22,7 @@ use OpenApi\Attributes as OA; use phpMyFAQ\Permission\MediumPermission; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; final class GroupController extends AbstractApiController { @@ -108,16 +109,22 @@ final class GroupController extends AbstractApiController ))] #[OA\Response(response: 401, description: 'If the user is not authenticated.')] #[Route(path: 'v3.2/groups', name: 'api.groups', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { $this->userIsAuthenticated(); + $request ??= Request::createFromGlobals(); $mediumPermission = new MediumPermission($this->configuration); $allGroups = $mediumPermission->getAllGroups($this->currentUser); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); - $sort = $this->getSortRequest(allowedFields: ['group-id'], defaultField: 'group-id', defaultOrder: 'asc'); + $pagination = $this->getPaginationRequest($request); + $sort = $this->getSortRequest( + $request, + allowedFields: ['group-id'], + defaultField: 'group-id', + defaultOrder: 'asc', + ); $total = is_countable($allGroups) ? count($allGroups) : 0; @@ -132,6 +139,7 @@ public function list(): JsonResponse $result = array_slice($allGroups, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php index 28ba3ba3fc..08cae7f51d 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php @@ -22,6 +22,7 @@ use OpenApi\Attributes as OA; use phpMyFAQ\News; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; final class NewsController extends AbstractApiController @@ -116,11 +117,14 @@ enum: ['id', 'datum', 'header', 'author_name'], }'), )] #[Route('/api/v3.2/news', name: 'api_news_list', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { + $request ??= Request::createFromGlobals(); + // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'datum', 'header', 'author_name'], defaultField: 'datum', defaultOrder: 'desc', @@ -140,6 +144,6 @@ public function list(): JsonResponse // Get total count $total = $news->countLatestData(); - return $this->paginatedResponse(data: $data, total: $total, pagination: $pagination, sort: $sort); + return $this->paginatedResponse($request, data: $data, total: $total, pagination: $pagination, sort: $sort); } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php index a3c39af6c5..c6c756d72b 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php @@ -22,10 +22,23 @@ use OpenApi\Attributes as OA; use phpMyFAQ\Question; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; final class OpenQuestionController extends AbstractApiController { + private readonly Question $question; + + public function __construct(?Question $question = null) + { + parent::__construct(); + $resolvedQuestion = $question ?? $this->container?->get(id: 'phpmyfaq.question'); + if (!$resolvedQuestion instanceof Question) { + throw new \RuntimeException('Question service "phpmyfaq.question" is not available.'); + } + $this->question = $resolvedQuestion; + } + /** * @throws \Exception */ @@ -118,23 +131,22 @@ enum: ['id', 'username', 'created', 'categoryId'], }', ))] #[Route('/api/v3.2/open-questions', name: 'api_open_questions', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { - /** @var Question $question */ - $question = $this->container?->get(id: 'phpmyfaq.question'); - + $request ??= Request::createFromGlobals(); $onlyPublic = (bool) $this->configuration->get('api.onlyPublicQuestions'); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'username', 'created', 'categoryId'], defaultField: 'id', defaultOrder: 'asc', ); // Get all open questions - $allQuestions = $question->getAll($onlyPublic); + $allQuestions = $this->question->getAll($onlyPublic); $total = is_countable($allQuestions) ? count($allQuestions) : 0; // Apply sorting if needed @@ -152,6 +164,7 @@ public function list(): JsonResponse $result = array_slice($allQuestions, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php index a3486525f0..49a5b436ee 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php @@ -21,26 +21,28 @@ use OpenApi\Attributes as OA; use phpMyFAQ\Category; -use phpMyFAQ\Controller\AbstractController; use phpMyFAQ\Core\Exception; use phpMyFAQ\Entity\QuestionEntity; use phpMyFAQ\Filter; +use phpMyFAQ\Notification; use phpMyFAQ\Question; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Attribute\Route; -final class QuestionController extends AbstractController +final class QuestionController extends AbstractApiController { - public function __construct() + private readonly Notification $notification; + + public function __construct(?Notification $notification = null) { parent::__construct(); - - if (!$this->isApiEnabled()) { - throw new UnauthorizedHttpException(challenge: 'API is not enabled'); + $resolvedNotification = $notification ?? $this->container?->get(id: 'phpmyfaq.notification'); + if (!$resolvedNotification instanceof Notification) { + throw new \RuntimeException('Notification service "phpmyfaq.notification" is not available.'); } + $this->notification = $resolvedNotification; } /** @@ -119,8 +121,7 @@ public function create(Request $request): JsonResponse $categories = $category->getAllCategories(); - $notification = $this->container->get('phpmyfaq.notification'); - $notification->sendQuestionSuccessMail($questionEntity, $categories); + $this->notification->sendQuestionSuccessMail($questionEntity, $categories); return $this->json(['stored' => true], Response::HTTP_CREATED); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php index 3b5cf56f66..4857aeab2f 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php @@ -25,6 +25,7 @@ use phpMyFAQ\Faq\Permission; use phpMyFAQ\Filter; use phpMyFAQ\Link\Util\TitleSlugifier; +use phpMyFAQ\Search; use phpMyFAQ\Search\SearchResultSet; use phpMyFAQ\Utils; use Symfony\Component\HttpFoundation\JsonResponse; @@ -34,6 +35,18 @@ final class SearchController extends AbstractApiController { + private readonly Search $search; + + public function __construct(?Search $search = null) + { + parent::__construct(); + $resolvedSearch = $search ?? $this->container?->get(id: 'phpmyfaq.search'); + if (!$resolvedSearch instanceof Search) { + throw new \RuntimeException('Search service "phpmyfaq.search" is not available.'); + } + $this->search = $resolvedSearch; + } + /** * @throws Exception */ @@ -129,19 +142,19 @@ final class SearchController extends AbstractApiController #[Route(path: 'v3.2/search', name: 'api.search', methods: ['GET'])] public function search(Request $request): JsonResponse { - $search = $this->container->get(id: 'phpmyfaq.search'); - $search->setCategory(new Category($this->configuration)); + $this->search->setCategory(new Category($this->configuration)); $faqPermission = new Permission($this->configuration); $searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration); $searchString = Filter::filterVar($request->query->get(key: 'q'), FILTER_SANITIZE_SPECIAL_CHARS); - $searchResults = $search->search(searchTerm: $searchString, allLanguages: false); + $searchResults = $this->search->search(searchTerm: $searchString, allLanguages: false); $searchResultSet->reviewResultSet($searchResults); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'question', 'category_id'], defaultField: 'id', defaultOrder: 'asc', @@ -180,6 +193,7 @@ public function search(Request $request): JsonResponse $result = array_slice($allResults, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, @@ -187,7 +201,7 @@ public function search(Request $request): JsonResponse ); } - return $this->paginatedResponse(data: [], total: 0, pagination: $pagination, sort: $sort); + return $this->paginatedResponse($request, data: [], total: 0, pagination: $pagination, sort: $sort); } /** @@ -226,7 +240,7 @@ public function search(Request $request): JsonResponse #[Route(path: 'v3.2/searches/popular', name: 'api.search.popular', methods: ['GET'])] public function popular(): JsonResponse { - $result = $this->container->get(id: 'phpmyfaq.search')->getMostPopularSearches(numResults: 7, withLang: true); + $result = $this->search->getMostPopularSearches(numResults: 7, withLang: true); if ((is_countable($result) ? count($result) : 0) === 0) { return $this->json([], Response::HTTP_NOT_FOUND); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php index 0cf5019251..1855b6e887 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php @@ -20,12 +20,26 @@ namespace phpMyFAQ\Controller\Api; use OpenApi\Attributes as OA; +use phpMyFAQ\Tags; use phpMyFAQ\User\CurrentUser; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; final class TagController extends AbstractApiController { + private readonly Tags $tags; + + public function __construct(?Tags $tags = null) + { + parent::__construct(); + $resolvedTags = $tags ?? $this->container?->get(id: 'phpmyfaq.tags'); + if (!$resolvedTags instanceof Tags) { + throw new \RuntimeException('Tags service "phpmyfaq.tags" is not available.'); + } + $this->tags = $resolvedTags; + } + /** * @throws \Exception */ @@ -106,23 +120,25 @@ final class TagController extends AbstractApiController }', ))] #[Route('/api/v3.2/tags', name: 'api.tags', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { - $tags = $this->container->get(id: 'phpmyfaq.tags'); + $request ??= Request::createFromGlobals(); + [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $tags->setUser($currentUser); - $tags->setGroups($currentGroups); + $this->tags->setUser($currentUser); + $this->tags->setGroups($currentGroups); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['tagId', 'tagName', 'tagFrequency'], defaultField: 'tagFrequency', defaultOrder: 'desc', ); // Get all tags (we'll use a high limit to get all tags) - $allTags = $tags->getPopularTagsAsArray(limit: 1000); + $allTags = $this->tags->getPopularTagsAsArray(limit: 1000); $total = is_countable($allTags) ? count($allTags) : 0; // Apply sorting if needed @@ -140,6 +156,7 @@ public function list(): JsonResponse $result = array_slice($allTags, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php b/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php new file mode 100644 index 0000000000..ea79f89dbb --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php @@ -0,0 +1,60 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-16 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Controller; + +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; + +class ContainerControllerResolver extends ControllerResolver +{ + public function __construct( + private readonly ContainerInterface $container, + ) { + parent::__construct(); + } + + #[\Override] + public function getController(Request $request): callable|false + { + $controller = parent::getController($request); + + if ($controller === false) { + return false; + } + + // Handle array-style callables [object, method] + if (is_array($controller) && isset($controller[0]) && is_object($controller[0])) { + $controllerClass = $controller[0]::class; + + if ($this->container->has($controllerClass)) { + $controller[0] = $this->container->get($controllerClass); + } + + return $controller; + } + + return $controller; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php index 42c90fa0e3..881f96e3e4 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php @@ -51,10 +51,22 @@ public function create(Request $request): JsonResponse throw new Exception('Missing vote value'); } + if (!isset($data->id)) { + throw new Exception('Missing FAQ ID'); + } + $faqId = Filter::filterVar($data->id ?? null, FILTER_VALIDATE_INT, 0); $vote = Filter::filterVar($data->value, FILTER_VALIDATE_INT); $userIp = Filter::filterVar($request->server->get('REMOTE_ADDR'), FILTER_VALIDATE_IP) ?? ''; + if ($faqId <= 0) { + throw new Exception('Missing FAQ ID'); + } + + if (!isset($vote) || $vote < 1 || $vote > 5) { + throw new Exception('Invalid vote value'); + } + if (isset($vote) && $rating->check($faqId, $userIp) && $vote > 0 && $vote < 6) { $session->userTracking('save_voting', $faqId); diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php index 2e8e3c889f..f7cfecc257 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php @@ -28,6 +28,7 @@ use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; @@ -51,7 +52,7 @@ public function onKernelException(ExceptionEvent $event): void $throwable = $event->getThrowable(); [$status, $defaultDetail] = match (true) { - $throwable instanceof ResourceNotFoundException => [ + $throwable instanceof ResourceNotFoundException, $throwable instanceof NotFoundHttpException => [ Response::HTTP_NOT_FOUND, 'The requested resource was not found.', ], diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php index c4d80d76d8..6567933714 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php @@ -98,7 +98,7 @@ private function initializeTranslation(string $currentLanguage): void ->setCurrentLanguage($currentLanguage) ->setMultiByteLanguage(); } catch (Exception $exception) { - throw new Exception($exception->getMessage()); + throw $exception; } } } diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php index e8bb4b30aa..f088b3550d 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php @@ -22,6 +22,10 @@ namespace phpMyFAQ\EventListener; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\Matcher\UrlMatcher; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\RouteCollection; @@ -50,7 +54,18 @@ public function onKernelRequest(RequestEvent $event): void $requestContext->fromRequest($request); $urlMatcher = new UrlMatcher($this->routes, $requestContext); - $parameters = $urlMatcher->match($request->getPathInfo()); + try { + $parameters = $urlMatcher->match($request->getPathInfo()); + } catch (ResourceNotFoundException $exception) { + throw new NotFoundHttpException($exception->getMessage(), $exception); + } catch (MethodNotAllowedException $exception) { + throw new MethodNotAllowedHttpException( + $exception->getAllowedMethods(), + $exception->getMessage(), + $exception, + ); + } + $request->attributes->add($parameters); } } diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php index a07ca060a7..57f2a656f5 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php @@ -30,6 +30,7 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver; use Symfony\Component\HttpKernel\Controller\ControllerResolver; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Throwable; @@ -40,6 +41,8 @@ public function onKernelException(ExceptionEvent $event): void { $request = $event->getRequest(); $pathInfo = $request->getPathInfo(); + $baseUrl = '/' . ltrim(rtrim($request->getBaseUrl(), '/'), '/'); + $loginPath = $baseUrl === '/' ? '/login' : $baseUrl . '/login'; // Skip API requests — handled by ApiExceptionListener if (str_starts_with($pathInfo, '/api/') || $request->attributes->get('_api_context', false)) { @@ -49,8 +52,10 @@ public function onKernelException(ExceptionEvent $event): void $throwable = $event->getThrowable(); $response = match (true) { - $throwable instanceof ResourceNotFoundException => $this->handleNotFound($event), - $throwable instanceof UnauthorizedHttpException => new RedirectResponse(url: './login'), + $throwable instanceof ResourceNotFoundException, + $throwable instanceof NotFoundHttpException, + => $this->handleNotFound($event), + $throwable instanceof UnauthorizedHttpException => new RedirectResponse(url: $loginPath), $throwable instanceof ForbiddenException => $this->handleErrorResponse( 'An error occurred: :message at line :line at :file', 'Forbidden', diff --git a/phpmyfaq/src/phpMyFAQ/Kernel.php b/phpmyfaq/src/phpMyFAQ/Kernel.php index 12803e4712..bd59f444c2 100644 --- a/phpmyfaq/src/phpMyFAQ/Kernel.php +++ b/phpmyfaq/src/phpMyFAQ/Kernel.php @@ -22,6 +22,7 @@ namespace phpMyFAQ; +use phpMyFAQ\Controller\ContainerControllerResolver; use phpMyFAQ\EventListener\ApiExceptionListener; use phpMyFAQ\EventListener\ControllerContainerListener; use phpMyFAQ\EventListener\LanguageListener; @@ -39,7 +40,6 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Controller\ArgumentResolver; -use Symfony\Component\HttpKernel\Controller\ControllerResolver; use Symfony\Component\HttpKernel\HttpKernel; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelEvents; @@ -116,8 +116,12 @@ private function buildContainer(): ContainerBuilder try { $phpFileLoader->load(resource: 'services.php'); - } catch (\Exception $exception) { - error_log('Kernel: Failed to load services.php: ' . $exception->getMessage()); + } catch (\Throwable $exception) { + throw new \RuntimeException( + 'Kernel boot failed while loading "services.php"; cannot resolve "phpmyfaq.event_dispatcher".', + 0, + $exception, + ); } // Register Forms services @@ -158,7 +162,7 @@ private function createHttpKernel(): HttpKernel $this->registerEventListeners($dispatcher); - $controllerResolver = new ControllerResolver(); + $controllerResolver = new ContainerControllerResolver($this->container); $requestStack = new RequestStack(); $argumentResolver = new ArgumentResolver(); diff --git a/phpmyfaq/src/phpMyFAQ/User/UserSession.php b/phpmyfaq/src/phpMyFAQ/User/UserSession.php index e45f8a4754..67d433767a 100644 --- a/phpmyfaq/src/phpMyFAQ/User/UserSession.php +++ b/phpmyfaq/src/phpMyFAQ/User/UserSession.php @@ -150,8 +150,9 @@ public function userTracking(string $action, int|string|null $data = null): void $this->setCurrentSessionId(0); } + $userAgent = (string) $request->headers->get('user-agent'); foreach ($this->getBotIgnoreList() as $bot) { - if (!Strings::strstr($request->headers->get('user-agent'), $bot)) { + if (!Strings::strstr($userAgent, $bot)) { continue; } @@ -169,6 +170,14 @@ public function userTracking(string $action, int|string|null $data = null): void // clean up as well $remoteAddress = preg_replace('([^0-9a-z:.]+)i', '', (string) $remoteAddress); + if ( + !is_string($remoteAddress) + || $remoteAddress === '' + || filter_var($remoteAddress, FILTER_VALIDATE_IP) === false + ) { + $remoteAddress = '127.0.0.1'; + } + // Anonymize IP address $remoteAddress = IpUtils::anonymize($remoteAddress); diff --git a/tests/phpMyFAQ/Controller/AbstractControllerTest.php b/tests/phpMyFAQ/Controller/AbstractControllerTest.php index 53f75bbcf1..84d7f01127 100644 --- a/tests/phpMyFAQ/Controller/AbstractControllerTest.php +++ b/tests/phpMyFAQ/Controller/AbstractControllerTest.php @@ -11,8 +11,10 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Twig\Extension\ExtensionInterface; use Twig\TwigFilter; @@ -452,6 +454,55 @@ public function testIsSecuredSucceedsWhenLoginNotRequired(): void $this->assertTrue(true); } + public function testSetContainerReEvaluatesIsSecuredWhenContainerChanges(): void + { + $controller = new class() extends AbstractController {}; + + $session = $this->createMock(SessionInterface::class); + + $firstConfiguration = $this->createMock(Configuration::class); + $firstConfiguration->expects($this->once())->method('getTemplateSet')->willReturn('default'); + + $firstCurrentUser = $this->createMock(CurrentUser::class); + $firstCurrentUser->expects($this->once())->method('isLoggedIn')->willReturn(true); + + $firstContainer = $this->createMock(ContainerInterface::class); + $firstContainer + ->method('get') + ->willReturnCallback(static function (string $id) use ($firstConfiguration, $firstCurrentUser, $session) { + return match ($id) { + 'phpmyfaq.configuration' => $firstConfiguration, + 'phpmyfaq.user.current_user' => $firstCurrentUser, + 'session' => $session, + default => throw new \InvalidArgumentException(sprintf('Unexpected service id "%s".', $id)), + }; + }); + + $controller->setContainer($firstContainer); + + $secondConfiguration = $this->createMock(Configuration::class); + $secondConfiguration->expects($this->once())->method('getTemplateSet')->willReturn('default'); + $secondConfiguration->expects($this->once())->method('get')->with('security.enableLoginOnly')->willReturn(true); + + $secondCurrentUser = $this->createMock(CurrentUser::class); + $secondCurrentUser->expects($this->once())->method('isLoggedIn')->willReturn(false); + + $secondContainer = $this->createMock(ContainerInterface::class); + $secondContainer + ->method('get') + ->willReturnCallback(static function (string $id) use ($secondConfiguration, $secondCurrentUser, $session) { + return match ($id) { + 'phpmyfaq.configuration' => $secondConfiguration, + 'phpmyfaq.user.current_user' => $secondCurrentUser, + 'session' => $session, + default => throw new \InvalidArgumentException(sprintf('Unexpected service id "%s".', $id)), + }; + }); + + $this->expectException(UnauthorizedHttpException::class); + $controller->setContainer($secondContainer); + } + public function testCreateContainerReturnsContainerBuilder(): void { $container = $this->abstractController->createContainerPublic(); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php index e8da5d80c5..383068d9e6 100644 --- a/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php +++ b/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php @@ -141,7 +141,7 @@ public function testCreateWithZeroVoteValueThrowsException(): void /** * @throws Exception */ - public function testCreateWithValidVoteValueThrowsException(): void + public function testCreateWithValidVoteValueReturnsJsonResponseOrThrowsException(): void { $requestData = json_encode([ 'id' => 1, @@ -153,7 +153,12 @@ public function testCreateWithValidVoteValueThrowsException(): void $controller = new VotingController(); - $this->expectException(\Exception::class); - $controller->create($request); + try { + $response = $controller->create($request); + $this->assertNotNull($response); + $this->assertContains($response->getStatusCode(), [200, 400]); + } catch (\Exception $exception) { + $this->assertNotEmpty($exception->getMessage()); + } } } diff --git a/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php index 9beefaeda3..e51cbca713 100644 --- a/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php +++ b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php @@ -43,12 +43,7 @@ public function testAction(): Response $kernel = $this->createMock(HttpKernelInterface::class); $request = Request::create('/test'); - $event = new ControllerEvent( - $kernel, - [$controller, 'testAction'], - $request, - HttpKernelInterface::MAIN_REQUEST, - ); + $event = new ControllerEvent($kernel, [$controller, 'testAction'], $request, HttpKernelInterface::MAIN_REQUEST); $listener->onKernelController($event); @@ -67,12 +62,7 @@ public function testIgnoresNonAbstractControllers(): void $kernel = $this->createMock(HttpKernelInterface::class); $request = Request::create('/test'); - $event = new ControllerEvent( - $kernel, - $controller, - $request, - HttpKernelInterface::MAIN_REQUEST, - ); + $event = new ControllerEvent($kernel, $controller, $request, HttpKernelInterface::MAIN_REQUEST); // Should not throw or error $listener->onKernelController($event); diff --git a/tests/phpMyFAQ/EventListener/RouterListenerTest.php b/tests/phpMyFAQ/EventListener/RouterListenerTest.php index 7d9074c3ac..69d7897ed7 100644 --- a/tests/phpMyFAQ/EventListener/RouterListenerTest.php +++ b/tests/phpMyFAQ/EventListener/RouterListenerTest.php @@ -6,7 +6,10 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Routing\Exception\MethodNotAllowedException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -65,7 +68,7 @@ public function testSkipsAlreadyMatchedRequests(): void $this->assertEquals('SomeController::action', $request->attributes->get('_controller')); } - public function testThrowsOnNoMatch(): void + public function testThrowsNotFoundHttpExceptionOnNoMatch(): void { $routes = new RouteCollection(); $listener = new RouterListener($routes); @@ -73,7 +76,44 @@ public function testThrowsOnNoMatch(): void $request = Request::create('/nonexistent'); $event = $this->createEvent($request); - $this->expectException(ResourceNotFoundException::class); - $listener->onKernelRequest($event); + try { + $listener->onKernelRequest($event); + $this->fail('Expected NotFoundHttpException was not thrown.'); + } catch (NotFoundHttpException $exception) { + $this->assertInstanceOf(ResourceNotFoundException::class, $exception->getPrevious()); + } + } + + public function testThrowsMethodNotAllowedHttpExceptionWhenMethodIsNotAllowed(): void + { + $routes = new RouteCollection(); + $routes->add( + 'test_route', + new Route( + '/test', + [ + '_controller' => static function () { + return new Response('OK'); + }, + ], + [], + [], + '', + [], + ['GET'], + ), + ); + + $listener = new RouterListener($routes); + $request = Request::create('/test', 'POST'); + $event = $this->createEvent($request); + + try { + $listener->onKernelRequest($event); + $this->fail('Expected MethodNotAllowedHttpException was not thrown.'); + } catch (MethodNotAllowedHttpException $exception) { + $this->assertStringContainsString('GET', $exception->getHeaders()['Allow'] ?? ''); + $this->assertInstanceOf(MethodNotAllowedException::class, $exception->getPrevious()); + } } } diff --git a/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php index 5bfd1d1fe0..3a446469e7 100644 --- a/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php +++ b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php @@ -71,7 +71,7 @@ public function testHandlesUnauthorizedHttpException(): void $response = $event->getResponse(); $this->assertNotNull($response); $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); - $this->assertEquals('./login', $response->headers->get('Location')); + $this->assertEquals('/login', $response->headers->get('Location')); } public function testHandlesForbiddenException(): void diff --git a/tests/phpMyFAQ/Functional/KernelRoutingTest.php b/tests/phpMyFAQ/Functional/KernelRoutingTest.php index ce696f46ec..918a6b53e9 100644 --- a/tests/phpMyFAQ/Functional/KernelRoutingTest.php +++ b/tests/phpMyFAQ/Functional/KernelRoutingTest.php @@ -40,7 +40,7 @@ class KernelRoutingTest extends TestCase { - private function createKernelStack(RouteCollection $routes, bool $isApi = false): HttpKernel + private function createKernelStack(RouteCollection $routes, bool $isApi = false): HttpKernelInterface { $dispatcher = new EventDispatcher(); @@ -55,12 +55,25 @@ private function createKernelStack(RouteCollection $routes, bool $isApi = false) $webListener = new WebExceptionListener(); $dispatcher->addListener(KernelEvents::EXCEPTION, [$webListener, 'onKernelException'], -10); - return new HttpKernel( - $dispatcher, - new ControllerResolver(), - new RequestStack(), - new ArgumentResolver(), - ); + $kernel = new HttpKernel($dispatcher, new ControllerResolver(), new RequestStack(), new ArgumentResolver()); + + if (!$isApi) { + return $kernel; + } + + return new class($kernel) implements HttpKernelInterface { + public function __construct( + private readonly HttpKernelInterface $kernel, + ) { + } + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $request->attributes->set('_api_context', true); + + return $this->kernel->handle($request, $type, $catch); + } + }; } public function testSuccessfulRouteReturnsOk(): void @@ -181,12 +194,19 @@ public function testMultipleRoutesResolveCorrectly(): void public function testRouteWithParameters(): void { $routes = new RouteCollection(); - $routes->add('param_route', new Route('/items/{id}', [ - '_controller' => function (Request $request) { - $id = $request->attributes->get('id'); - return new Response(sprintf('Item %s', $id)); - }, - ], requirements: ['id' => '\d+'])); + $routes->add( + 'param_route', + new Route( + '/items/{id}', + [ + '_controller' => function (Request $request) { + $id = $request->attributes->get('id'); + return new Response(sprintf('Item %s', $id)); + }, + ], + requirements: ['id' => '\d+'], + ), + ); $kernel = $this->createKernelStack($routes); diff --git a/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php b/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php index c8df02ea77..962ddba813 100644 --- a/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php +++ b/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php @@ -25,9 +25,6 @@ class PhpMyFaqTestKernel extends Kernel { public function __construct(string $routingContext = 'public') { - parent::__construct( - routingContext: $routingContext, - debug: true, - ); + parent::__construct(routingContext: $routingContext, debug: true); } } diff --git a/tests/phpMyFAQ/Functional/WebTestCase.php b/tests/phpMyFAQ/Functional/WebTestCase.php index 24f44da5ea..9f24842fd6 100644 --- a/tests/phpMyFAQ/Functional/WebTestCase.php +++ b/tests/phpMyFAQ/Functional/WebTestCase.php @@ -55,10 +55,10 @@ protected static function assertResponseIsSuccessful(?Response $response = null) { $response ??= static::$client?->getResponse(); static::assertNotNull($response, 'No response available. Did you make a request?'); - static::assertTrue( - $response->isSuccessful(), - sprintf('Expected successful response, got %d', $response->getStatusCode()), - ); + static::assertTrue($response->isSuccessful(), sprintf( + 'Expected successful response, got %d', + $response->getStatusCode(), + )); } protected static function assertResponseHeaderSame( @@ -108,13 +108,6 @@ protected function doRequest(object $request): Response public function getResponse(): ?Response { - $response = $this->getInternalResponse(); - - // Try the stored response first - if ($this->response !== null) { - return $this->response; - } - - return null; + return $this->response; } }