diff --git a/LICENSE.md b/LICENSE.md index 8e18fa6..d4d8a00 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright © 2024 Ambroise Maupate +Copyright © 2023 Ambroise Maupate Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/composer.json b/composer.json index c1a273b..358b231 100644 --- a/composer.json +++ b/composer.json @@ -15,27 +15,27 @@ } ], "type": "symfony-bundle", - "minimum-stability": "dev", "prefer-stable": true, "require": { "php": ">=8.1", - "roadiz/core-bundle": "2.3.*", - "roadiz/openid": "2.3.*", - "symfony/framework-bundle": "6.4.*" + "pimple/pimple": "^3.3.1", + "roadiz/core-bundle": "2.2.*", + "roadiz/openid": "2.2.*", + "symfony/framework-bundle": "5.4.*" }, "require-dev": { "php-coveralls/php-coveralls": "^2.4", "phpstan/phpstan": "^1.5.3", "phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-symfony": "^1.1.8", - "roadiz/doc-generator": "2.3.*", - "roadiz/documents": "2.3.*", - "roadiz/dts-generator": "2.3.*", - "roadiz/entity-generator": "2.3.*", - "roadiz/jwt": "2.3.*", - "roadiz/markdown": "2.3.*", - "roadiz/models": "2.3.*", - "roadiz/random": "2.3.*", + "roadiz/doc-generator": "2.2.*", + "roadiz/documents": "2.2.*", + "roadiz/dts-generator": "2.2.*", + "roadiz/entity-generator": "2.2.*", + "roadiz/jwt": "2.2.*", + "roadiz/markdown": "2.2.*", + "roadiz/models": "2.2.*", + "roadiz/random": "2.2.*", "squizlabs/php_codesniffer": "^3.5" }, "config": { @@ -60,8 +60,8 @@ }, "extra": { "branch-alias": { - "dev-main": "2.3.x-dev", - "dev-develop": "2.4.x-dev" + "dev-main": "2.2.x-dev", + "dev-develop": "2.3.x-dev" } } } diff --git a/config/services.yaml b/config/services.yaml index 9e0b714..07c94b5 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -19,9 +19,28 @@ services: - '../src/Tests/' - '../src/Event/' + # + # Automatic themes registration + # + Themes\: + resource: '%kernel.project_dir%/themes/' + autowire: true + autoconfigure: true + exclude: + - '%kernel.project_dir%/themes/DependencyInjection/' + - '%kernel.project_dir%/themes/app/' + - '%kernel.project_dir%/themes/public/' + - '%kernel.project_dir%/themes/Resources/' + - '%kernel.project_dir%/themes/Services/' + - '%kernel.project_dir%/themes/static/' + - '%kernel.project_dir%/themes/Entity/' + - '%kernel.project_dir%/themes/Kernel.php' + - '%kernel.project_dir%/themes/Tests/' + # Explicit declaration RZ\Roadiz\CompatBundle\Controller\AppController: ~ RZ\Roadiz\CompatBundle\Controller\Controller: ~ + RZ\Roadiz\CompatBundle\Controller\FrontendController: ~ securityTokenStorage: alias: security.token_storage @@ -71,6 +90,8 @@ services: roadiz_compat.twig_loader: class: Twig\Loader\FilesystemLoader tags: ['twig.loader'] + RZ\Roadiz\CompatBundle\Routing\ThemeRoutesLoader: + tags: [ routing.loader ] # # Made routers theme aware diff --git a/phpstan.neon b/phpstan.neon index ccffa25..34ed3a4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,8 +7,6 @@ parameters: - */bower_components/* - */static/* ignoreErrors: - - identifier: missingType.iterableValue - - identifier: missingType.generics - '#Call to an undefined method RZ\\Roadiz\\CoreBundle\\Repository#' - '#Call to an undefined method RZ\\Roadiz\\UserBundle\\Repository#' - '#Call to an undefined method Doctrine\\Persistence\\ObjectRepository#' @@ -32,6 +30,8 @@ parameters: - '#does not accept Doctrine\\Common\\Collections\\ReadableCollection]+>#' reportUnmatchedIgnoredErrors: false + checkGenericClassInNonGenericObjectType: false + checkMissingIterableValueType: false includes: - vendor/phpstan/phpstan-doctrine/extension.neon - vendor/phpstan/phpstan-doctrine/rules.neon diff --git a/src/Aliases.php b/src/Aliases.php index 3625bc2..c99e4f4 100644 --- a/src/Aliases.php +++ b/src/Aliases.php @@ -14,6 +14,7 @@ public static function getAliases(): array return [ \RZ\Roadiz\CompatBundle\Controller\AppController::class => \RZ\Roadiz\CMS\Controllers\AppController::class, \RZ\Roadiz\CompatBundle\Controller\Controller::class => \RZ\Roadiz\CMS\Controllers\Controller::class, + \RZ\Roadiz\CompatBundle\Controller\FrontendController::class => \RZ\Roadiz\CMS\Controllers\FrontendController::class, \RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface::class => \RZ\Roadiz\Utils\Theme\ThemeResolverInterface::class, \RZ\Roadiz\CoreBundle\Bag\NodeTypes::class => \RZ\Roadiz\Core\Bags\NodeTypes::class, \RZ\Roadiz\CoreBundle\Bag\Roles::class => \RZ\Roadiz\Core\Bags\Roles::class, @@ -238,6 +239,8 @@ public static function getAliases(): array \RZ\Roadiz\CoreBundle\ListManager\Paginator::class => \RZ\Roadiz\Core\ListManagers\Paginator::class, \RZ\Roadiz\CoreBundle\ListManager\QueryBuilderListManager::class => \RZ\Roadiz\Core\ListManagers\QueryBuilderListManager::class, \RZ\Roadiz\CoreBundle\ListManager\TagListManager::class => \RZ\Roadiz\Core\ListManagers\TagListManager::class, + \RZ\Roadiz\CoreBundle\Mailer\ContactFormManager::class => \RZ\Roadiz\Utils\ContactFormManager::class, + \RZ\Roadiz\CoreBundle\Mailer\EmailManager::class => \RZ\Roadiz\Utils\EmailManager::class, \RZ\Roadiz\CoreBundle\Node\NodeDuplicator::class => \RZ\Roadiz\Utils\Node\NodeDuplicator::class, \RZ\Roadiz\CoreBundle\Node\NodeFactory::class => \RZ\Roadiz\Utils\Node\NodeFactory::class, \RZ\Roadiz\CoreBundle\Node\NodeMover::class => \RZ\Roadiz\Utils\Node\NodeMover::class, diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index ab8979f..a34efc8 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -4,13 +4,23 @@ namespace RZ\Roadiz\CompatBundle\Controller; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\NotFoundExceptionInterface; +use InvalidArgumentException; use ReflectionClass; use ReflectionException; use RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface; +use RZ\Roadiz\Core\AbstractEntities\PersistableInterface; +use RZ\Roadiz\Core\AbstractEntities\TranslationInterface; +use RZ\Roadiz\CoreBundle\Entity\Node; +use RZ\Roadiz\CoreBundle\Entity\NodesSources; use RZ\Roadiz\CoreBundle\Entity\Theme; +use RZ\Roadiz\CoreBundle\Entity\User; +use RZ\Roadiz\CoreBundle\EntityHandler\NodeHandler; use RZ\Roadiz\CoreBundle\Exception\ThemeClassNotValidException; +use RZ\Roadiz\CoreBundle\Form\Error\FormErrorSerializer; +use RZ\Roadiz\CoreBundle\Security\Authorization\Chroot\NodeChrootResolver; +use RZ\Roadiz\Documents\Packages; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; @@ -18,6 +28,9 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Routing\Loader\YamlFileLoader; +use Symfony\Component\Routing\RouteCollection; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\String\UnicodeString; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; @@ -80,6 +93,10 @@ abstract class AppController extends Controller * Assignation for twig template engine. */ protected array $assignation = []; + /** + * @var Node|null + */ + private ?Node $homeNode = null; /** * @return string @@ -129,6 +146,34 @@ public static function isBackendTheme(): bool return static::$backendTheme; } + /** + * @return RouteCollection + * @throws ReflectionException + */ + public static function getRoutes(): RouteCollection + { + $locator = static::getFileLocator(); + $loader = new YamlFileLoader($locator); + return $loader->load('routes.yml'); + } + + /** + * Return a file locator with theme + * Resource folder. + * + * @return FileLocator + * @throws ReflectionException + */ + public static function getFileLocator(): FileLocator + { + $resourcesFolder = static::getResourcesFolder(); + return new FileLocator([ + $resourcesFolder, + $resourcesFolder . '/routing', + $resourcesFolder . '/config', + ]); + } + /** * Return theme Resource folder according to * main theme class inheriting AppController. @@ -138,7 +183,6 @@ public static function isBackendTheme(): bool * * @return string * @throws ReflectionException - * @throws ThemeClassNotValidException */ public static function getResourcesFolder(): string { @@ -189,6 +233,24 @@ public static function getThemeMainClassName(): string return static::getThemeDir() . 'App'; } + /** + * These routes are used to extend Roadiz back-office. + * + * @return RouteCollection|null + * @throws ReflectionException + */ + public static function getBackendRoutes(): ?RouteCollection + { + $locator = static::getFileLocator(); + + try { + $loader = new YamlFileLoader($locator); + return $loader->load('backend-routes.yml'); + } catch (InvalidArgumentException $e) { + return null; + } + } + /** * @return string * @throws ReflectionException @@ -255,13 +317,11 @@ public function getAssignation(): array * - securityAuthorizationChecker * * @return $this - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface */ - public function prepareBaseAssignation(): static + public function prepareBaseAssignation() { /** @var KernelInterface $kernel */ - $kernel = $this->container->get('kernel'); + $kernel = $this->get('kernel'); $this->assignation = [ 'head' => [ 'ajax' => $this->getRequest()->isXmlHttpRequest(), @@ -284,7 +344,7 @@ public function prepareBaseAssignation(): static * @throws RuntimeError * @throws SyntaxError */ - public function throw404(string $message = ''): Response + public function throw404($message = '') { $this->assignation['nodeName'] = 'error-404'; $this->assignation['nodeTypeName'] = 'error404'; @@ -303,14 +363,12 @@ public function throw404(string $message = ''): Response * Return the current Theme * * @return Theme|null - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface */ public function getTheme(): ?Theme { $this->getStopwatch()->start('getTheme'); /** @var ThemeResolverInterface $themeResolver */ - $themeResolver = $this->container->get(ThemeResolverInterface::class); + $themeResolver = $this->get(ThemeResolverInterface::class); if (null === $this->theme) { $className = new UnicodeString(static::getCalledClass()); while (!$className->endsWith('App')) { @@ -397,14 +455,72 @@ public function publishErrorMessage(Request $request, string $msg, ?object $sour $this->publishMessage($request, $msg, 'error', $source); } + /** + * Validate a request against a given ROLE_* + * and check chroot + * and throws an AccessDeniedException exception. + * + * @param mixed $attributes + * @param mixed $nodeId + * @param bool|false $includeChroot + * @return void + * + * @throws AccessDeniedException + * @deprecated Use denyAccessUnlessGranted with NodeVoter attribute and a Node subject. + */ + public function validateNodeAccessForRole(mixed $attributes, mixed $nodeId = null, bool $includeChroot = false): void + { + /** @var Node|null $node */ + $node = null; + /** @var User $user */ + $user = $this->getUser(); + /** @var NodeChrootResolver $chrootResolver */ + $chrootResolver = $this->get(NodeChrootResolver::class); + $chroot = $chrootResolver->getChroot($user); + + if ($this->isGranted($attributes) && $chroot === null) { + /* + * Already grant access if user is not chroot-ed. + */ + return; + } + + if ($nodeId instanceof Node) { + $node = $nodeId; + } elseif (\is_scalar($nodeId)) { + /** @var Node|null $node */ + $node = $this->em()->find(Node::class, (int) $nodeId); + } + + if (null === $node) { + throw $this->createAccessDeniedException("You don't have access to this page"); + } + + $this->em()->refresh($node); + + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->getHandlerFactory()->getHandler($node); + $parents = $nodeHandler->getParents(); + + if ($includeChroot) { + $parents[] = $node; + } + + if (!$this->isGranted($attributes)) { + throw $this->createAccessDeniedException("You don't have access to this page"); + } + + if (null !== $user && $chroot !== null && !in_array($chroot, $parents, true)) { + throw $this->createAccessDeniedException("You don't have access to this page"); + } + } + /** * Generate a simple view to inform visitors that website is * currently unavailable. * * @param Request $request * @return Response - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface */ public function maintenanceAction(Request $request): Response { @@ -445,9 +561,9 @@ public function makeResponseCachable( bool $allowClientCache = false ): Response { /** @var Kernel $kernel */ - $kernel = $this->container->get('kernel'); + $kernel = $this->get('kernel'); /** @var RequestStack $requestStack */ - $requestStack = $this->container->get(RequestStack::class); + $requestStack = $this->get(RequestStack::class); $settings = $this->getSettingsBag(); if ( @@ -481,4 +597,58 @@ public function makeResponseCachable( return $response; } + + /** + * Returns a fully qualified view path for Twig rendering. + * + * @param string $view + * @param string $namespace + * @return string + */ + protected function getNamespacedView(string $view, string $namespace = ''): string + { + if ($namespace !== "" && $namespace !== "/") { + $view = '@' . $namespace . '/' . $view; + } elseif (static::getThemeDir() !== "" && $namespace !== "/") { + // when no namespace is used + // use current theme directory + $view = '@' . static::getThemeDir() . '/' . $view; + } + + return $view; + } + + /** + * @param TranslationInterface|null $translation + * @return null|Node + */ + protected function getHome(?TranslationInterface $translation = null): ?Node + { + $this->getStopwatch()->start('getHome'); + if (null === $this->homeNode) { + $nodeRepository = $this->em()->getRepository(Node::class); + if ($translation !== null) { + $this->homeNode = $nodeRepository->findHomeWithTranslation($translation); + } else { + $this->homeNode = $nodeRepository->findHomeWithDefaultTranslation(); + } + } + $this->getStopwatch()->stop('getHome'); + + return $this->homeNode; + } + + /** + * Return all Form errors as an array. + * + * @param FormInterface $form + * @return array + * @deprecated Use FormErrorSerializer::getErrorsAsArray instead + */ + protected function getErrorsAsArray(FormInterface $form): array + { + /** @var FormErrorSerializer $formErrorSerializer */ + $formErrorSerializer = $this->get(FormErrorSerializer::class); + return $formErrorSerializer->getErrorsAsArray($form); + } } diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index 598b792..0a21661 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -6,7 +6,6 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; -use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; use Psr\Log\LoggerInterface; use RZ\Roadiz\Core\AbstractEntities\PersistableInterface; @@ -24,6 +23,8 @@ use RZ\Roadiz\CoreBundle\Form\Error\FormErrorSerializer; use RZ\Roadiz\CoreBundle\ListManager\EntityListManager; use RZ\Roadiz\CoreBundle\ListManager\EntityListManagerInterface; +use RZ\Roadiz\CoreBundle\Mailer\ContactFormManager; +use RZ\Roadiz\CoreBundle\Mailer\EmailManager; use RZ\Roadiz\CoreBundle\Node\NodeFactory; use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; use RZ\Roadiz\CoreBundle\Repository\TranslationRepository; @@ -35,7 +36,6 @@ use RZ\Roadiz\Documents\UrlGenerators\DocumentUrlGeneratorInterface; use RZ\Roadiz\OpenId\OAuth2LinkGenerator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bundle\SecurityBundle\Security; use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormBuilderInterface; @@ -50,6 +50,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; @@ -67,7 +68,6 @@ public static function getSubscribedServices(): array 'csrfTokenManager' => CsrfTokenManagerInterface::class, 'defaultTranslation' => 'defaultTranslation', 'dispatcher' => 'event_dispatcher', - 'doctrine' => 'doctrine', 'em' => EntityManagerInterface::class, 'event_dispatcher' => 'event_dispatcher', EventDispatcherInterface::class => EventDispatcherInterface::class, @@ -85,7 +85,9 @@ public static function getSubscribedServices(): array 'translator' => TranslatorInterface::class, 'urlGenerator' => UrlGeneratorInterface::class, UrlGeneratorInterface::class => UrlGeneratorInterface::class, + ContactFormManager::class => ContactFormManager::class, DocumentUrlGeneratorInterface::class => DocumentUrlGeneratorInterface::class, + EmailManager::class => EmailManager::class, Environment::class => Environment::class, FormErrorSerializer::class => FormErrorSerializer::class, LoggerInterface::class => LoggerInterface::class, @@ -110,12 +112,11 @@ public static function getSubscribedServices(): array /** * @return Request - * @deprecated */ protected function getRequest(): Request { /** @var RequestStack $requestStack */ - $requestStack = $this->container->get(RequestStack::class); + $requestStack = $this->get(RequestStack::class); $request = $requestStack->getCurrentRequest(); if (null === $request) { throw new BadRequestHttpException('Request is not available in this context'); @@ -123,16 +124,25 @@ protected function getRequest(): Request return $request; } + /** + * @return Security + */ + protected function getAuthorizationChecker(): Security + { + /** @var Security $security */ # php-stan hint + $security = $this->get(Security::class); + return $security; + } + /** * Alias for `$this->container['securityTokenStorage']`. * * @return TokenStorageInterface - * @deprecated */ protected function getTokenStorage(): TokenStorageInterface { /** @var TokenStorageInterface $tokenStorage */ # php-stan hint - $tokenStorage = $this->container->get(TokenStorageInterface::class); + $tokenStorage = $this->get(TokenStorageInterface::class); return $tokenStorage; } @@ -140,110 +150,76 @@ protected function getTokenStorage(): TokenStorageInterface * Alias for `$this->container['em']`. * * @return ObjectManager - * @deprecated */ protected function em(): ObjectManager { - return $this->container->get('em'); + return $this->getDoctrine()->getManager(); } /** * @return TranslatorInterface - * @deprecated */ protected function getTranslator(): TranslatorInterface { /** @var TranslatorInterface $translator */ # php-stan hint - $translator = $this->container->get(TranslatorInterface::class); + $translator = $this->get(TranslatorInterface::class); return $translator; } /** * @return Environment - * @deprecated */ protected function getTwig(): Environment { /** @var Environment $twig */ # php-stan hint - $twig = $this->container->get(Environment::class); + $twig = $this->get(Environment::class); return $twig; } - /** - * @return Stopwatch - * @deprecated - */ protected function getStopwatch(): Stopwatch { /** @var Stopwatch $stopwatch */ - $stopwatch = $this->container->get(Stopwatch::class); + $stopwatch = $this->get(Stopwatch::class); return $stopwatch; } - /** - * @deprecated - */ protected function getPreviewResolver(): PreviewResolverInterface { /** @var PreviewResolverInterface $previewResolver */ - $previewResolver = $this->container->get(PreviewResolverInterface::class); + $previewResolver = $this->get(PreviewResolverInterface::class); return $previewResolver; } - /** - * @return ManagerRegistry - * @throws \Psr\Container\ContainerExceptionInterface - * @throws \Psr\Container\NotFoundExceptionInterface - * @deprecated - */ - protected function getDoctrine(): ManagerRegistry - { - return $this->container->get('doctrine'); - } - /** * @param object $event * @param string|null $eventName * @return object The passed $event MUST be returned - * @deprecated */ protected function dispatchEvent(object $event, string $eventName = null): object { /** @var EventDispatcherInterface $eventDispatcher */ # php-stan hint - $eventDispatcher = $this->container->get(EventDispatcherInterface::class); + $eventDispatcher = $this->get(EventDispatcherInterface::class); return $eventDispatcher->dispatch($event, $eventName); } - /** - * @return Settings - * @deprecated - */ protected function getSettingsBag(): Settings { /** @var Settings $settingsBag */ # php-stan hint - $settingsBag = $this->container->get(Settings::class); + $settingsBag = $this->get(Settings::class); return $settingsBag; } - /** - * @return HandlerFactoryInterface - * @deprecated - */ protected function getHandlerFactory(): HandlerFactoryInterface { /** @var HandlerFactoryInterface $handlerFactory */ # php-stan hint - $handlerFactory = $this->container->get(HandlerFactoryInterface::class); + $handlerFactory = $this->get(HandlerFactoryInterface::class); return $handlerFactory; } - /** - * @return LoggerInterface - * @deprecated - */ protected function getLogger(): LoggerInterface { /** @var LoggerInterface $logger */ # php-stan hint - $logger = $this->container->get(LoggerInterface::class); + $logger = $this->get(LoggerInterface::class); return $logger; } @@ -259,7 +235,7 @@ protected function generateUrl($route, array $parameters = [], int $referenceTyp { if ($route instanceof NodesSources) { /** @var UrlGeneratorInterface $urlGenerator */ - $urlGenerator = $this->container->get(UrlGeneratorInterface::class); + $urlGenerator = $this->get(UrlGeneratorInterface::class); return $urlGenerator->generate( RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, array_merge($parameters, [RouteObjectInterface::ROUTE_OBJECT => $route]), @@ -299,6 +275,28 @@ public function removeTrailingSlashAction(Request $request): RedirectResponse return $this->redirect($url, Response::HTTP_MOVED_PERMANENTLY); } + /** + * Make translation variable with the good localization. + * + * @param Request $request + * @param string|null $_locale + * + * @return TranslationInterface + * @throws NoTranslationAvailableException + */ + protected function bindLocaleFromRoute(Request $request, $_locale = null): TranslationInterface + { + /* + * If you use a static route for Home page + * we need to grab manually language. + * + * Get language from static route + */ + $translation = $this->findTranslationForLocale($_locale); + $request->setLocale($translation->getPreferredLocale()); + return $translation; + } + /** * @param string|null $_locale * @@ -410,7 +408,7 @@ protected function denyResourceExceptForFormats(Request $request, array $accepta protected function createNamedFormBuilder(string $name = 'form', $data = null, array $options = []) { /** @var FormFactoryInterface $formFactory */ - $formFactory = $this->container->get(FormFactoryInterface::class); + $formFactory = $this->get(FormFactoryInterface::class); return $formFactory->createNamedBuilder($name, FormType::class, $data, $options); } @@ -434,6 +432,33 @@ public function createEntityListManager(string $entity, array $criteria = [], ar ); } + /** + * Create and return a ContactFormManager to build and send contact + * form by email. + * + * @return ContactFormManager + * @deprecated Use constructor service injection + */ + public function createContactFormManager(): ContactFormManager + { + /** @var ContactFormManager $contactFormManager */ # php-stan hinting + $contactFormManager = $this->get(ContactFormManager::class); + return $contactFormManager; + } + + /** + * Create and return a EmailManager to build and send emails. + * + * @return EmailManager + * @deprecated Use constructor service injection + */ + public function createEmailManager(): EmailManager + { + /** @var EmailManager $emailManager */ # php-stan hinting + $emailManager = $this->get(EmailManager::class); + return $emailManager; + } + /** * Get a user from the tokenStorage. * @@ -445,6 +470,10 @@ public function createEntityListManager(string $entity, array $criteria = [], ar */ protected function getUser(): ?UserInterface { + if (!$this->has('securityTokenStorage')) { + throw new \LogicException('No TokenStorage has been registered in your application.'); + } + /** @var TokenInterface|null $token */ $token = $this->getTokenStorage()->getToken(); return $token?->getUser(); diff --git a/src/Controller/FrontendController.php b/src/Controller/FrontendController.php new file mode 100644 index 0000000..2f4ac37 --- /dev/null +++ b/src/Controller/FrontendController.php @@ -0,0 +1,434 @@ + + */ + protected static array $specificNodesControllers = [ + 'home', + ]; + + protected ?Node $node = null; + protected ?NodesSources $nodeSource = null; + protected ?TranslationInterface $translation = null; + /** + * @var \Pimple\Container|null + * @deprecated Use a service locator object + */ + protected ?\Pimple\Container $themeContainer = null; + + public static function getSubscribedServices(): array + { + return array_merge(parent::getSubscribedServices(), [ + ThemeResolverInterface::class => ThemeResolverInterface::class + ]); + } + + /** + * @return Node|null + */ + protected function getNode(): ?Node + { + return $this->node; + } + + /** + * @return NodesSources|null + */ + protected function getNodeSource(): ?NodesSources + { + return $this->nodeSource; + } + + /** + * @return TranslationInterface|null + */ + protected function getTranslation(): ?TranslationInterface + { + return $this->translation; + } + + /** + * Default action for any node URL. + * + * @param Request $request + * @param Node|null $node + * @param TranslationInterface|null $translation + * + * @return Response + */ + public function indexAction( + Request $request, + Node $node = null, + TranslationInterface $translation = null + ) { + $this->getStopwatch()->start('handleNodeController'); + $this->node = $node; + $this->translation = $translation; + + // Main node based routing method + return $this->handle($request, $this->node, $this->translation); + } + + /** + * Handle node based routing, returns a Response object + * for a node-based request. + * + * @param Request $request + * @param Node|null $node + * @param TranslationInterface|null $translation + * @return Response + * @throws \ReflectionException + */ + protected function handle( + Request $request, + Node $node = null, + TranslationInterface $translation = null + ) { + $this->getStopwatch()->start('handleNodeController'); + + if ($node !== null) { + $nodeRouteHelper = new NodeRouteHelper( + $node, + $this->getTheme(), + $this->getPreviewResolver(), + $this->getLogger(), + DefaultNodeSourceController::class + ); + $controllerPath = $nodeRouteHelper->getController(); + $method = $nodeRouteHelper->getMethod(); + + if (true !== $nodeRouteHelper->isViewable()) { + $msg = "No front-end controller found for '" . + $node->getNodeName() . + "' node. You need to create a " . $controllerPath . "."; + throw $this->createNotFoundException($msg); + } + + return $this->forward($controllerPath . '::' . $method, [ + 'node' => $node, + 'translation' => $translation + ]); + } + + throw $this->createNotFoundException("No node was found to handle"); + } + + /** + * Default action for default URL (homepage). + * + * @param Request $request + * @param string|null $_locale + * + * @return Response + */ + public function homeAction(Request $request, $_locale = null) + { + /* + * If you use a static route for Home page + * we need to grab manually language. + * + * Get language from static route + */ + $translation = $this->bindLocaleFromRoute($request, $_locale); + + /* + * Grab home flagged node + */ + $node = $this->getHome($translation); + $this->prepareThemeAssignation($node, $translation); + + return $this->render('home.html.twig', $this->assignation); + } + + /** + * Store basic information for your theme from a Node object. + * + * @param Node|null $node + * @param TranslationInterface|null $translation + * + * @return void + */ + protected function prepareThemeAssignation(Node $node = null, TranslationInterface $translation = null) + { + if (null === $this->themeContainer) { + $this->getStopwatch()->start('prepareThemeAssignation'); + $this->storeNodeAndTranslation($node, $translation); + $home = $this->getHome($translation); + if (null !== $home && null !== $translation) { + $this->assignation['home'] = $home; + $this->assignation['homeSource'] = $home->getNodeSourcesByTranslation($translation)->first(); + } + /* + * Use a DI container to delay API requests + */ + $this->themeContainer = new \Pimple\Container(); + + $this->getStopwatch()->start('extendAssignation'); + $this->extendAssignation(); + $this->getStopwatch()->stop('extendAssignation'); + $this->getStopwatch()->stop('prepareThemeAssignation'); + } + } + + /** + * Store current node and translation into controller. + * + * It makes following fields available into template assignation: + * + * * node + * * nodeSource + * * translation + * * pageMeta + * * title + * * description + * * keywords + * + * @param Node|null $node + * @param TranslationInterface|null $translation + * @return void + */ + public function storeNodeAndTranslation(Node $node = null, TranslationInterface $translation = null) + { + $this->node = $node; + $this->translation = $translation; + $this->assignation['translation'] = $this->translation; + $this->getRequest()->attributes->set('translation', $this->translation); + + if (null !== $this->node && null !== $translation) { + $this->getRequest()->attributes->set('node', $this->node); + $this->nodeSource = $this->node->getNodeSourcesByTranslation($translation)->first() ?: null; + $this->assignation['node'] = $this->node; + $this->assignation['nodeSource'] = $this->nodeSource; + } + + $this->assignation['pageMeta'] = $this->getNodeSEO(); + } + + /** + * Get SEO information for current node. + * + * This method must return a 3-fields array with: + * + * * `title` + * * `description` + * * `keywords` + * + * @param NodesSources|null $fallbackNodeSource + * + * @return array + */ + protected function getNodeSEO(NodesSources $fallbackNodeSource = null) + { + if (null !== $this->nodeSource) { + /** @var NodesSourcesHandler $nodesSourcesHandler */ + $nodesSourcesHandler = $this->getHandlerFactory()->getHandler($this->nodeSource); + return $nodesSourcesHandler->getSEO(); + } + + if (null !== $fallbackNodeSource) { + /** @var NodesSourcesHandler $nodesSourcesHandler */ + $nodesSourcesHandler = $this->getHandlerFactory()->getHandler($fallbackNodeSource); + return $nodesSourcesHandler->getSEO(); + } + + return [ + 'title' => '', + 'description' => '', + 'keywords' => '', + ]; + } + + /** + * Extends theme assignation with custom data. + * + * Override this method in your theme to add your own service + * and data. + * + * @return void + */ + protected function extendAssignation() + { + } + + /** + * Add a default translation locale for static routes and + * node SEO data. + * + * * [parent assignations…] + * * **_default_locale** + * * meta + * * siteName + * * siteCopyright + * * siteDescription + * + * @return $this + */ + public function prepareBaseAssignation(): static + { + parent::prepareBaseAssignation(); + + /** @var TranslationInterface $translation */ + $translation = $this->get('defaultTranslation'); + $this->assignation['_default_locale'] = $translation->getLocale(); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function maintenanceAction(Request $request): Response + { + $translation = $this->bindLocaleFromRoute($request, $request->getLocale()); + $this->prepareThemeAssignation(null, $translation); + + return new Response( + $this->renderView('maintenance.html.twig', $this->assignation), + Response::HTTP_SERVICE_UNAVAILABLE, + ['content-type' => 'text/html'] + ); + } + + /** + * Store basic information for your theme from a NodesSources object. + * + * @param NodesSources|null $nodeSource + * @param TranslationInterface|null $translation + * + * @return void + */ + protected function prepareNodeSourceAssignation( + NodesSources $nodeSource = null, + TranslationInterface $translation = null + ): void { + if (null === $this->themeContainer) { + $this->storeNodeSourceAndTranslation($nodeSource, $translation); + /** @deprecated Should not fetch home at each request */ + $this->assignation['home'] = $this->getHome($translation); + /* + * Use a DI container to delay API requests + */ + $this->themeContainer = new \Pimple\Container(); + $this->extendAssignation(); + } + } + + /** + * Store current nodeSource and translation into controller. + * + * It makes following fields available into template assignation: + * + * * node + * * nodeSource + * * translation + * * pageMeta + * * title + * * description + * * keywords + * + * @param NodesSources|null $nodeSource + * @param TranslationInterface|null $translation + * @return void + */ + public function storeNodeSourceAndTranslation( + NodesSources $nodeSource = null, + TranslationInterface $translation = null + ): void { + $this->nodeSource = $nodeSource; + + if (null !== $this->nodeSource) { + $this->node = $this->nodeSource->getNode(); + $this->translation = $this->nodeSource->getTranslation(); + + $this->getRequest()->attributes->set('translation', $this->translation); + $this->getRequest()->attributes->set('node', $this->node); + + $this->assignation['translation'] = $this->translation; + $this->assignation['node'] = $this->node; + $this->assignation['nodeSource'] = $this->nodeSource; + } else { + $this->translation = $translation; + $this->assignation['translation'] = $this->translation; + $this->getRequest()->attributes->set('translation', $this->translation); + } + + $this->assignation['pageMeta'] = $this->getNodeSEO(); + } + + /** + * Deny access (404) node-source access if its publication date is in the future. + * + * @throws \Exception + * @return void + */ + protected function denyAccessUnlessPublished(): void + { + if (null !== $this->nodeSource) { + if ( + $this->nodeSource->getPublishedAt() > new \DateTime() && + !$this->getPreviewResolver()->isPreview() + ) { + throw $this->createNotFoundException(); + } + } + } + + /** + * @inheritDoc + */ + public function createEntityListManager(string $entity, array $criteria = [], array $ordering = []): EntityListManagerInterface + { + return parent::createEntityListManager($entity, $criteria, $ordering) + ->setAllowRequestSearching(false) + ->setAllowRequestSorting(false); + } +} diff --git a/src/DependencyInjection/Compiler/ThemesTranslatorPathsCompilerPass.php b/src/DependencyInjection/Compiler/ThemesTranslatorPathsCompilerPass.php index 0e4997b..7ad0261 100644 --- a/src/DependencyInjection/Compiler/ThemesTranslatorPathsCompilerPass.php +++ b/src/DependencyInjection/Compiler/ThemesTranslatorPathsCompilerPass.php @@ -9,7 +9,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Finder\Finder; -final class ThemesTranslatorPathsCompilerPass implements CompilerPassInterface +class ThemesTranslatorPathsCompilerPass implements CompilerPassInterface { /** * @inheritDoc @@ -21,9 +21,6 @@ public function process(ContainerBuilder $container): void } } - /** - * @throws \ReflectionException - */ private function registerThemeTranslatorResources(ContainerBuilder $container): void { /** @var string $projectDir */ diff --git a/src/EventSubscriber/ControllerEventSubscriber.php b/src/EventSubscriber/ControllerEventSubscriber.php index ea44f91..1f2b2bd 100644 --- a/src/EventSubscriber/ControllerEventSubscriber.php +++ b/src/EventSubscriber/ControllerEventSubscriber.php @@ -4,8 +4,6 @@ namespace RZ\Roadiz\CompatBundle\EventSubscriber; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\NotFoundExceptionInterface; use RZ\Roadiz\CompatBundle\Controller\AppController; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ControllerEvent; @@ -23,10 +21,6 @@ public static function getSubscribedEvents(): array ]; } - /** - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ public function onKernelController(ControllerEvent $event): void { $controller = $event->getController(); diff --git a/src/EventSubscriber/ExceptionSubscriber.php b/src/EventSubscriber/ExceptionSubscriber.php index d0b00ca..d5ce4fe 100644 --- a/src/EventSubscriber/ExceptionSubscriber.php +++ b/src/EventSubscriber/ExceptionSubscriber.php @@ -7,6 +7,7 @@ use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; use RZ\Roadiz\CompatBundle\Controller\AppController; use RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface; use RZ\Roadiz\CoreBundle\Entity\Theme; @@ -31,11 +32,21 @@ */ final class ExceptionSubscriber implements EventSubscriberInterface { + protected LoggerInterface $logger; + private ThemeResolverInterface $themeResolver; + private ContainerInterface $serviceLocator; + protected bool $debug; + public function __construct( - private readonly ThemeResolverInterface $themeResolver, - private readonly ContainerInterface $serviceLocator, - private readonly bool $debug + ThemeResolverInterface $themeResolver, + ContainerInterface $serviceLocator, + LoggerInterface $logger, + bool $debug ) { + $this->debug = $debug; + $this->themeResolver = $themeResolver; + $this->serviceLocator = $serviceLocator; + $this->logger = $logger; } /** diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php index 24bda0c..dcc5a72 100644 --- a/src/EventSubscriber/MaintenanceModeSubscriber.php +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -15,16 +15,25 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; -use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Security\Core\Security; final class MaintenanceModeSubscriber implements EventSubscriberInterface { + private Settings $settings; + private Security $security; + private ThemeResolverInterface $themeResolver; + private ContainerInterface $serviceLocator; + public function __construct( - private readonly Settings $settings, - private readonly Security $security, - private readonly ThemeResolverInterface $themeResolver, - private readonly ContainerInterface $serviceLocator + Settings $settings, + Security $security, + ThemeResolverInterface $themeResolver, + ContainerInterface $serviceLocator ) { + $this->settings = $settings; + $this->security = $security; + $this->themeResolver = $themeResolver; + $this->serviceLocator = $serviceLocator; } /** @@ -124,6 +133,15 @@ private function getControllerForTheme(Theme $theme, Request $request): Abstract $request->attributes->set('theme', $controller->getTheme()); } + /* + * Set request locale if _locale param + * is present in Route. + */ + $routeParams = $request->get('_route_params'); + if (!empty($routeParams["_locale"])) { + $request->setLocale($routeParams["_locale"]); + } + return $controller; } } diff --git a/src/Routing/ThemeAwareNodeRouter.php b/src/Routing/ThemeAwareNodeRouter.php index 0edea96..906feb9 100644 --- a/src/Routing/ThemeAwareNodeRouter.php +++ b/src/Routing/ThemeAwareNodeRouter.php @@ -4,7 +4,6 @@ namespace RZ\Roadiz\CompatBundle\Routing; -use Psr\Cache\InvalidArgumentException; use RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface; use RZ\Roadiz\CoreBundle\Routing\NodeRouter; use Symfony\Cmf\Component\Routing\VersatileGeneratorInterface; @@ -16,10 +15,17 @@ final class ThemeAwareNodeRouter implements RouterInterface, RequestMatcherInterface, VersatileGeneratorInterface { - public function __construct( - private readonly ThemeResolverInterface $themeResolver, - private readonly NodeRouter $innerRouter - ) { + private ThemeResolverInterface $themeResolver; + private NodeRouter $innerRouter; + + /** + * @param ThemeResolverInterface $themeResolver + * @param NodeRouter $innerRouter + */ + public function __construct(ThemeResolverInterface $themeResolver, NodeRouter $innerRouter) + { + $this->themeResolver = $themeResolver; + $this->innerRouter = $innerRouter; } public function setContext(RequestContext $context): void @@ -42,10 +48,6 @@ public function getRouteCollection(): RouteCollection return $this->innerRouter->getRouteCollection(); } - /** - * @inheritDoc - * @throws InvalidArgumentException - */ public function generate(string $name, array $parameters = [], int $referenceType = self::ABSOLUTE_PATH): string { $this->innerRouter->setTheme($this->themeResolver->findTheme($this->getContext()->getHost())); @@ -57,10 +59,12 @@ public function match(string $pathinfo): array return $this->innerRouter->match($pathinfo); } - /** - * @inheritDoc - */ - public function getRouteDebugMessage(mixed $name, array $parameters = []): string + public function supports($name): bool + { + return $this->innerRouter->supports($name); + } + + public function getRouteDebugMessage($name, array $parameters = []): string { return $this->innerRouter->getRouteDebugMessage($name, $parameters); } diff --git a/src/Routing/ThemeAwareNodeUrlMatcher.php b/src/Routing/ThemeAwareNodeUrlMatcher.php index 0c2b1fb..78c306e 100644 --- a/src/Routing/ThemeAwareNodeUrlMatcher.php +++ b/src/Routing/ThemeAwareNodeUrlMatcher.php @@ -15,10 +15,15 @@ final class ThemeAwareNodeUrlMatcher implements UrlMatcherInterface, RequestMatcherInterface, NodeUrlMatcherInterface { + private ThemeResolverInterface $themeResolver; + private NodeUrlMatcher $innerMatcher; + public function __construct( - private readonly ThemeResolverInterface $themeResolver, - private readonly NodeUrlMatcher $innerMatcher + ThemeResolverInterface $themeResolver, + NodeUrlMatcher $innerMatcher ) { + $this->themeResolver = $themeResolver; + $this->innerMatcher = $innerMatcher; } /** diff --git a/src/Routing/ThemeRoutesLoader.php b/src/Routing/ThemeRoutesLoader.php new file mode 100644 index 0000000..e9c2bdd --- /dev/null +++ b/src/Routing/ThemeRoutesLoader.php @@ -0,0 +1,75 @@ +themeResolver = $themeResolver; + } + + /** + * @param mixed $resource + * @param string|null $type + * @return RouteCollection + */ + public function load($resource, string $type = null): RouteCollection + { + if (true === $this->isLoaded) { + throw new \RuntimeException('Do not add the "extra" loader twice'); + } + + $routeCollection = new RouteCollection(); + $frontendThemes = $this->themeResolver->getFrontendThemes(); + foreach ($frontendThemes as $theme) { + /** @var class-string $feClass */ + $feClass = $theme->getClassName(); + /** @var RouteCollection $feCollection */ + $feCollection = call_user_func([$feClass, 'getRoutes']); + /** @var RouteCollection $feBackendCollection */ + $feBackendCollection = call_user_func([$feClass, 'getBackendRoutes']); + + if ($feCollection !== null) { + // set host pattern if defined + if ($theme->getHostname() != '*' && $theme->getHostname() != '') { + $feCollection->setHost($theme->getHostname()); + } + /* + * Add a global prefix on theme static routes + */ + if ($theme->getRoutePrefix() != '') { + $feCollection->addPrefix($theme->getRoutePrefix()); + } + $routeCollection->addCollection($feCollection); + } + + if ($feBackendCollection !== null) { + /* + * Do not prefix or hostname admin routes. + */ + $routeCollection->addCollection($feBackendCollection); + } + } + + $this->isLoaded = true; + + return $routeCollection; + } + + public function supports($resource, string $type = null): bool + { + return 'themes' === $type; + } +} diff --git a/src/Theme/ThemeInfo.php b/src/Theme/ThemeInfo.php index cbff7db..a86e5bb 100644 --- a/src/Theme/ThemeInfo.php +++ b/src/Theme/ThemeInfo.php @@ -22,17 +22,18 @@ final class ThemeInfo */ private ?string $classname = null; private Filesystem $filesystem; + private string $projectDir; private ?string $themePath = null; private static array $protectedThemeNames = ['Rozier']; /** - * @param string $name Short theme name or FQN classname + * @param class-string|string $name Short theme name or FQN classname * @param string $projectDir - * @throws ThemeClassNotValidException */ - public function __construct(string $name, private readonly string $projectDir) + public function __construct(string $name, string $projectDir) { $this->filesystem = new Filesystem(); + $this->projectDir = $projectDir; if (class_exists($name)) { /* @@ -40,10 +41,11 @@ public function __construct(string $name, private readonly string $projectDir) */ $this->classname = $this->validateClassname($name); $this->name = $this->extractNameFromClassname($this->classname); + $this->themeName = $this->getThemeNameFromName(); } else { $this->name = $this->validateName($name); + $this->themeName = $this->getThemeNameFromName(); } - $this->themeName = $this->getThemeNameFromName(); } public function isProtected(): bool @@ -59,10 +61,16 @@ public function isProtected(): bool */ protected function guessClassnameFromThemeName(string $themeName): string { - $className = match ($themeName) { - 'RozierApp', 'RozierTheme', 'Rozier' => '\\Themes\\Rozier\\RozierApp', - default => '\\Themes\\' . $themeName . '\\' . $themeName . 'App', - }; + switch ($themeName) { + case 'RozierApp': + case 'RozierTheme': + case 'Rozier': + $className = '\\Themes\\Rozier\\RozierApp'; + break; + default: + $className = '\\Themes\\' . $themeName . '\\' . $themeName . 'App'; + break; + } if (class_exists($className)) { return $className; @@ -78,7 +86,6 @@ protected function guessClassnameFromThemeName(string $themeName): string * @param class-string $classname * * @return string - * @throws ThemeClassNotValidException */ protected function extractNameFromClassname(string $classname): string { @@ -90,7 +97,6 @@ protected function extractNameFromClassname(string $classname): string /** * @param class-string $classname * @return class-string - * @throws ThemeClassNotValidException */ protected function validateClassname(string $classname): string { @@ -106,6 +112,7 @@ protected function validateClassname(string $classname): string /** * @param string $name + * * @return string */ protected function validateName(string $name): string @@ -123,7 +130,6 @@ protected function validateName(string $name): string /** * @return bool - * @throws ThemeClassNotValidException */ public function exists(): bool { @@ -156,7 +162,6 @@ protected function getProtectedThemePath(): string * Attention: theme could be located in vendor folder (/vendor/roadiz/roadiz) * * @return string Theme absolute path. - * @throws ThemeClassNotValidException */ public function getThemePath(): string { @@ -179,7 +184,6 @@ public function getThemePath(): string * @param class-string|null $className * * @return null|ReflectionClass - * @throws ThemeClassNotValidException */ public function getThemeReflectionClass(string $className = null): ?ReflectionClass { @@ -228,7 +232,6 @@ public function getThemeName(): string /** * @return class-string Theme class FQN - * @throws ThemeClassNotValidException */ public function getClassname(): string { @@ -240,12 +243,16 @@ public function getClassname(): string /** * @return bool - * @throws ThemeClassNotValidException */ public function isValid(): bool { try { $className = $this->getClassname(); + } catch (\InvalidArgumentException $exception) { + return false; + } + + try { $reflection = new ReflectionClass($className); if ($reflection->isSubclassOf(AbstractController::class)) { return true;