diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml index 189e3a5..3b17d56 100644 --- a/.github/workflows/run-test.yml +++ b/.github/workflows/run-test.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: ['8.1', '8.2', '8.3'] + php-version: ['8.0', '8.1'] steps: - uses: shivammathur/setup-php@v2 with: diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..660b9c6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,16 @@ +language: php +sudo: required +php: + - 7.4 + - 8.0 + - 8.1 + - nightly +install: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev --no-interaction +script: + - vendor/bin/phpcs --report=full --report-file=./report.txt -p ./src + - vendor/bin/phpstan analyse -c phpstan.neon +jobs: + allow_failures: + - php: nightly 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..44b3db3 100644 --- a/composer.json +++ b/composer.json @@ -15,27 +15,26 @@ } ], "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.*" + "php": ">=8.0", + "pimple/pimple": "^3.3.1", + "roadiz/core-bundle": "2.1.*", + "roadiz/openid": "2.1.*", + "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.1.*", + "roadiz/documents": "2.1.*", + "roadiz/dts-generator": "2.1.*", + "roadiz/entity-generator": "2.1.*", + "roadiz/jwt": "2.1.*", + "roadiz/markdown": "2.1.*", + "roadiz/models": "2.1.*", + "roadiz/random": "2.1.*", "squizlabs/php_codesniffer": "^3.5" }, "config": { @@ -60,8 +59,8 @@ }, "extra": { "branch-alias": { - "dev-main": "2.3.x-dev", - "dev-develop": "2.4.x-dev" + "dev-main": "2.1.x-dev", + "dev-develop": "2.2.x-dev" } } } diff --git a/config/services.yaml b/config/services.yaml index 9e0b714..eb194f0 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 @@ -38,6 +57,10 @@ services: rolesBag: alias: RZ\Roadiz\CoreBundle\Bag\Roles public: true + assetPackages: + alias: RZ\Roadiz\Documents\Packages + deprecated: ~ + public: true Symfony\Contracts\Translation\TranslatorInterface: alias: 'translator.default' public: true @@ -71,6 +94,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 @@ -95,4 +120,5 @@ services: RZ\Roadiz\CompatBundle\EventSubscriber\ExceptionSubscriber: arguments: - '@RZ\Roadiz\CompatBundle\Theme\ThemeResolverInterface' + - '@RZ\Roadiz\CoreBundle\Exception\ExceptionViewer' - '@service_container' diff --git a/phpstan.neon b/phpstan.neon index ccffa25..f4795d7 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#' @@ -28,10 +26,10 @@ parameters: - '#Doctrine\\ORM\\Mapping\\GeneratedValue constructor expects#' - '#type mapping mismatch: property can contain Doctrine\\Common\\Collections\\Collection]+> but database expects Doctrine\\Common\\Collections\\Collection&iterable<[^\>]+>#' - '#should return Doctrine\\Common\\Collections\\Collection]+Interface> but returns Doctrine\\Common\\Collections\\Collection]+>#' - - '#but returns Doctrine\\Common\\Collections\\ReadableCollection]+>#' - - '#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..a825448 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, @@ -64,7 +65,8 @@ public static function getAliases(): array \RZ\Roadiz\CoreBundle\Entity\Folder::class => \RZ\Roadiz\Core\Entities\Folder::class, \RZ\Roadiz\CoreBundle\Entity\FolderTranslation::class => \RZ\Roadiz\Core\Entities\FolderTranslation::class, \RZ\Roadiz\CoreBundle\Entity\Group::class => \RZ\Roadiz\Core\Entities\Group::class, - \RZ\Roadiz\CoreBundle\Logger\Entity\Log::class => \RZ\Roadiz\Core\Entities\Log::class, + \RZ\Roadiz\CoreBundle\Entity\Log::class => \RZ\Roadiz\Core\Entities\Log::class, + \RZ\Roadiz\CoreBundle\Entity\LoginAttempt::class => \RZ\Roadiz\Core\Entities\LoginAttempt::class, \RZ\Roadiz\CoreBundle\Entity\Node::class => \RZ\Roadiz\Core\Entities\Node::class, \RZ\Roadiz\CoreBundle\Entity\NodeType::class => \RZ\Roadiz\Core\Entities\NodeType::class, \RZ\Roadiz\CoreBundle\Entity\NodeTypeField::class => \RZ\Roadiz\Core\Entities\NodeTypeField::class, @@ -167,6 +169,8 @@ public static function getAliases(): array \RZ\Roadiz\CoreBundle\Form\Constraint\RecaptchaValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\RecaptchaValidator::class, \RZ\Roadiz\CoreBundle\Form\Constraint\SimpleLatinString::class => \RZ\Roadiz\CMS\Forms\Constraints\SimpleLatinString::class, \RZ\Roadiz\CoreBundle\Form\Constraint\SimpleLatinStringValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\SimpleLatinStringValidator::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueEntity::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueEntity::class, + \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueEntityValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueEntityValidator::class, \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueFilename::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueFilename::class, \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueFilenameValidator::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueFilenameValidator::class, \RZ\Roadiz\CoreBundle\Form\Constraint\UniqueNodeName::class => \RZ\Roadiz\CMS\Forms\Constraints\UniqueNodeName::class, @@ -238,6 +242,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/Console/ThemeGenerateCommand.php b/src/Console/ThemeGenerateCommand.php new file mode 100644 index 0000000..07af21f --- /dev/null +++ b/src/Console/ThemeGenerateCommand.php @@ -0,0 +1,106 @@ +projectDir = $projectDir; + $this->themeGenerator = $themeGenerator; + } + + protected function configure(): void + { + $this->setName('themes:generate') + ->setDescription('Generate a new theme based on BaseTheme boilerplate. Requires "find", "sed" and "git" commands.') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'Theme name (without the "Theme" suffix)' + ) + ->addOption( + 'develop', + 'd', + InputOption::VALUE_NONE, + 'Use BaseTheme develop branch instead of master.' + ) + ->addOption( + 'branch', + 'b', + InputOption::VALUE_REQUIRED, + 'Choose BaseTheme branch.' + ) + ->addOption('symlink', null, InputOption::VALUE_NONE, 'Symlinks the theme assets instead of copying it') + ->addOption('relative', null, InputOption::VALUE_NONE, 'Make relative symlinks') + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $branch = 'master'; + if ($input->getOption('develop')) { + $branch = 'develop'; + } + if ($input->getOption('branch')) { + $branch = $input->getOption('branch'); + } + if ($input->getOption('relative')) { + $expectedMethod = ThemeGenerator::METHOD_RELATIVE_SYMLINK; + } elseif ($input->getOption('symlink')) { + $expectedMethod = ThemeGenerator::METHOD_ABSOLUTE_SYMLINK; + } else { + $expectedMethod = ThemeGenerator::METHOD_COPY; + } + + $name = str_replace('/', '\\', $input->getArgument('name')); + $themeInfo = new ThemeInfo($name, $this->projectDir); + + if ( + $io->confirm( + 'Are you sure you want to generate a new theme called: "' . $themeInfo->getThemeName() . '"' . + ' using ' . $branch . ' branch and installing its assets with ' . $expectedMethod . ' method?', + false + ) + ) { + if (!$themeInfo->exists()) { + $this->themeGenerator->downloadTheme($themeInfo, $branch); + $io->success('BaseTheme cloned into ' . $themeInfo->getThemePath()); + } + + $this->themeGenerator->renameTheme($themeInfo); + $this->themeGenerator->installThemeAssets($themeInfo, $expectedMethod); + + $io->note([ + 'Register your theme into your config/packages/roadiz_core.yaml configuration file', + '---', + 'themes:', + ' - classname: ' . $themeInfo->getClassname(), + ]); + $io->success($themeInfo->getThemeName() . ' has been regenerated and is ready to be installed, have fun!'); + } + + return 0; + } +} diff --git a/src/Console/ThemeInstallCommand.php b/src/Console/ThemeInstallCommand.php index fd47244..906b842 100644 --- a/src/Console/ThemeInstallCommand.php +++ b/src/Console/ThemeInstallCommand.php @@ -28,8 +28,6 @@ /** * Command line utils for managing themes from terminal. - * - * @deprecated Use RZ\Roadiz\CoreBundle\Console\AppInstallCommand instead. */ class ThemeInstallCommand extends Command { @@ -147,7 +145,7 @@ protected function importThemeData(?ThemeInfo $themeInfo, string $themeConfigPat { $data = $this->getThemeConfig($themeConfigPath); - if (isset($data["importFiles"])) { + if (false !== $data && isset($data["importFiles"])) { if (isset($data["importFiles"]['groups'])) { foreach ($data["importFiles"]['groups'] as $filename) { $this->importFile($themeInfo, $filename, $this->groupsImporter); @@ -178,6 +176,14 @@ protected function importThemeData(?ThemeInfo $themeInfo, string $themeConfigPat $this->importFile($themeInfo, $filename, $this->attributeImporter); } } + if ($this->io->isVeryVerbose()) { + $this->io->note( + 'You should do a `bin/console generate:nsentities`' . + ' to regenerate your node-types source classes, ' . + 'and a `bin/console doctrine:schema:update --dump-sql --force` ' . + 'to apply your changes into database.' + ); + } } else { $this->io->warning('Config file "' . $themeConfigPath . '" has no data to import.'); } @@ -229,10 +235,6 @@ protected function getThemeConfig(string $themeConfigPath): array if (false === $fileContent = file_get_contents($themeConfigPath)) { throw new \RuntimeException($themeConfigPath . ' file is not readable'); } - $data = Yaml::parse($fileContent); - if (!\is_array($data)) { - throw new \RuntimeException($themeConfigPath . ' file is not a valid YAML file'); - } - return $data; + return Yaml::parse($fileContent); } } diff --git a/src/Console/ThemeMigrateCommand.php b/src/Console/ThemeMigrateCommand.php index f539f22..4a01afb 100644 --- a/src/Console/ThemeMigrateCommand.php +++ b/src/Console/ThemeMigrateCommand.php @@ -4,7 +4,6 @@ namespace RZ\Roadiz\CompatBundle\Console; -use RZ\Roadiz\CoreBundle\Doctrine\SchemaUpdater; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -14,28 +13,26 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Process\Process; -/** - * @deprecated Use RZ\Roadiz\CoreBundle\Console\AppMigrateCommand instead. - */ class ThemeMigrateCommand extends Command { protected string $projectDir; - private SchemaUpdater $schemaUpdater; - public function __construct(SchemaUpdater $schemaUpdater, string $projectDir) + /** + * @param string $projectDir + */ + public function __construct(string $projectDir) { parent::__construct(); $this->projectDir = $projectDir; - $this->schemaUpdater = $schemaUpdater; } protected function configure(): void { $this->setName('themes:migrate') - ->setDescription('Update your app node-types, settings, roles against theme import files') + ->setDescription('Update your site against theme import files, regenerate NSEntities, update database schema and clear caches.') ->addArgument( 'classname', - InputArgument::OPTIONAL, + InputArgument::REQUIRED, 'Main theme classname (Use / instead of \\ and do not forget starting slash) or path to config.yml' ) ->addOption( @@ -43,18 +40,6 @@ protected function configure(): void 'd', InputOption::VALUE_NONE, 'Do nothing, only print information.' - ) - ->addOption( - 'doctrine-migrations', - null, - InputOption::VALUE_NONE, - 'Generate and execute pending Doctrine migrations.' - ) - ->addOption( - 'ns-entities', - null, - InputOption::VALUE_NONE, - 'Regenerate NS entities classes (NS classes should be versioned).' ); } @@ -62,10 +47,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - if (null === $className = $input->getArgument('classname')) { - $className = 'src/Resources/config.yml'; - } - $question = new ConfirmationQuestion( 'Are you sure to migrate against this theme? This can lead in data loss.', !$input->isInteractive() @@ -78,59 +59,59 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($input->getOption('dry-run')) { $this->runCommand( 'themes:install', - sprintf('--data "%s" --dry-run', $className), + sprintf('--data "%s" --dry-run', $input->getArgument('classname')), null, $input->isInteractive(), $output->isQuiet(), ); } else { + $this->runCommand( + 'doctrine:migrations:migrate', + '--allow-no-migration', + null, + false, + $output->isQuiet() + ) === 0 ? $io->success('doctrine:migrations:migrate') : $io->error('doctrine:migrations:migrate'); + $this->runCommand( 'themes:install', - sprintf('--data "%s"', $className), + sprintf('--data "%s"', $input->getArgument('classname')), null, $input->isInteractive(), $output->isQuiet() ) === 0 ? $io->success('themes:install') : $io->error('themes:install'); - if ($input->getOption('ns-entities')) { - $this->runCommand( - 'generate:nsentities', - '', - null, - $input->isInteractive(), - $output->isQuiet() - ) === 0 ? $io->success('generate:nsentities') : $io->error('generate:nsentities'); - - if ($input->getOption('doctrine-migrations')) { - $this->schemaUpdater->updateNodeTypesSchema(); - $this->schemaUpdater->updateSchema(); - $io->success('doctrine-migrations'); - } - - $this->runCommand( - 'doctrine:cache:clear-metadata', - '', - null, - false, - true - ) === 0 ? $io->success('doctrine:cache:clear-metadata') : $io->error('doctrine:cache:clear-metadata'); - - $this->runCommand( - 'cache:clear', - '', - null, - false, - true - ) === 0 ? $io->success('cache:clear') : $io->error('cache:clear'); - - $this->runCommand( - 'cache:pool:clear', - 'cache.global_clearer', - null, - false, - true - ) === 0 ? $io->success('cache:pool:clear') : $io->error('cache:pool:clear'); - } + $this->runCommand( + 'generate:nsentities', + '', + null, + $input->isInteractive(), + $output->isQuiet() + ) === 0 ? $io->success('generate:nsentities') : $io->error('generate:nsentities'); + + $this->runCommand( + 'doctrine:cache:clear-metadata', + '', + null, + $input->isInteractive(), + $output->isQuiet() + ) === 0 ? $io->success('doctrine:cache:clear-metadata') : $io->error('doctrine:cache:clear-metadata'); + + $this->runCommand( + 'doctrine:schema:update', + '--dump-sql --force', + null, + $input->isInteractive(), + $output->isQuiet() + ) === 0 ? $io->success('doctrine:schema:update') : $io->error('doctrine:schema:update'); + + $this->runCommand( + 'cache:clear', + '', + null, + $input->isInteractive(), + $output->isQuiet() + ) === 0 ? $io->success('cache:clear') : $io->error('cache:clear'); } return 0; } diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index ab8979f..2b055a0 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -4,13 +4,22 @@ 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\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 +27,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 +92,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 +145,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 +182,6 @@ public static function isBackendTheme(): bool * * @return string * @throws ReflectionException - * @throws ThemeClassNotValidException */ public static function getResourcesFolder(): string { @@ -149,7 +192,7 @@ public static function getResourcesFolder(): string * Return theme root folder. * * @return string - * @throws ReflectionException|ThemeClassNotValidException + * @throws ReflectionException */ public static function getThemeFolder(): string { @@ -189,6 +232,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 @@ -200,7 +261,7 @@ public static function getTranslationsFolder(): string /** * @return string - * @throws ReflectionException|ThemeClassNotValidException + * @throws ReflectionException */ public static function getPublicFolder(): string { @@ -255,18 +316,18 @@ 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(), 'devMode' => $kernel->isDebug(), 'maintenanceMode' => (bool) $this->getSettingsBag()->get('maintenance_mode'), + 'universalAnalyticsId' => $this->getSettingsBag()->get('universal_analytics_id'), + 'googleTagManagerId' => $this->getSettingsBag()->get('google_tag_manager_id'), 'baseUrl' => $this->getRequest()->getSchemeAndHttpHost() . $this->getRequest()->getBasePath(), ] ]; @@ -284,7 +345,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 +364,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')) { @@ -333,9 +392,9 @@ public function getTheme(): ?Theme * * @param Request $request * @param string $msg - * @param object|null $source + * @param NodesSources|null $source */ - public function publishConfirmMessage(Request $request, string $msg, ?object $source = null): void + public function publishConfirmMessage(Request $request, string $msg, ?NodesSources $source = null): void { $this->publishMessage($request, $msg, 'confirm', $source); } @@ -347,13 +406,13 @@ public function publishConfirmMessage(Request $request, string $msg, ?object $so * @param Request $request * @param string $msg * @param string $level - * @param object|null $source + * @param NodesSources|null $source */ protected function publishMessage( Request $request, string $msg, string $level = "confirm", - ?object $source = null + ?NodesSources $source = null ): void { $session = $this->getSession(); if ($session instanceof Session) { @@ -362,12 +421,10 @@ protected function publishMessage( switch ($level) { case 'error': - case 'danger': - case 'fail': - $this->getLogger()->error($msg, ['entity' => $source]); + $this->getLogger()->error($msg, ['source' => $source]); break; default: - $this->getLogger()->info($msg, ['entity' => $source]); + $this->getLogger()->info($msg, ['source' => $source]); break; } } @@ -380,7 +437,7 @@ protected function publishMessage( public function getSession(): ?SessionInterface { $request = $this->getRequest(); - return $request->hasPreviousSession() ? $request->getSession() : null; + return null !== $request && $request->hasPreviousSession() ? $request->getSession() : null; } /** @@ -389,22 +446,79 @@ public function getSession(): ?SessionInterface * * @param Request $request * @param string $msg - * @param object|null $source + * @param NodesSources|null $source * @return void */ - public function publishErrorMessage(Request $request, string $msg, ?object $source = null): void + public function publishErrorMessage(Request $request, string $msg, NodesSources $source = null): void { $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 + */ + 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 new AccessDeniedException("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 new AccessDeniedException("You don't have access to this page"); + } + + if (null !== $user && $chroot !== null && !in_array($chroot, $parents, true)) { + throw new AccessDeniedException("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 +559,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 +595,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..c333049 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -5,11 +5,8 @@ namespace RZ\Roadiz\CompatBundle\Controller; 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; use RZ\Roadiz\Core\AbstractEntities\TranslationInterface; use RZ\Roadiz\Core\Handlers\HandlerFactoryInterface; use RZ\Roadiz\CoreBundle\Bag\NodeTypes; @@ -19,11 +16,12 @@ use RZ\Roadiz\CoreBundle\Entity\Translation; use RZ\Roadiz\CoreBundle\EntityApi\NodeApi; use RZ\Roadiz\CoreBundle\EntityApi\NodeSourceApi; -use RZ\Roadiz\CoreBundle\Exception\ForceResponseException; use RZ\Roadiz\CoreBundle\Exception\NoTranslationAvailableException; 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; @@ -31,11 +29,11 @@ use RZ\Roadiz\CoreBundle\SearchEngine\NodeSourceSearchHandlerInterface; use RZ\Roadiz\CoreBundle\Security\Authorization\Chroot\NodeChrootResolver; use RZ\Roadiz\Documents\MediaFinders\RandomImageFinder; +use RZ\Roadiz\Documents\Packages; use RZ\Roadiz\Documents\Renderer\RendererInterface; 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 +48,8 @@ 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\Exception\AccessDeniedException; +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; @@ -64,13 +64,12 @@ abstract class Controller extends AbstractController public static function getSubscribedServices(): array { return array_merge(parent::getSubscribedServices(), [ + 'assetPackages' => Packages::class, 'csrfTokenManager' => CsrfTokenManagerInterface::class, 'defaultTranslation' => 'defaultTranslation', 'dispatcher' => 'event_dispatcher', - 'doctrine' => 'doctrine', 'em' => EntityManagerInterface::class, 'event_dispatcher' => 'event_dispatcher', - EventDispatcherInterface::class => EventDispatcherInterface::class, 'kernel' => KernelInterface::class, 'logger' => LoggerInterface::class, 'nodeApi' => NodeApi::class, @@ -84,8 +83,9 @@ public static function getSubscribedServices(): array 'stopwatch' => Stopwatch::class, '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, @@ -103,19 +103,17 @@ public static function getSubscribedServices(): array Stopwatch::class => Stopwatch::class, TokenStorageInterface::class => TokenStorageInterface::class, TranslatorInterface::class => TranslatorInterface::class, - FormFactoryInterface::class => FormFactoryInterface::class, \RZ\Roadiz\Core\Handlers\HandlerFactoryInterface::class => HandlerFactoryInterface::class, ]); } /** * @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 +121,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 +147,86 @@ 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 + protected function dispatchEvent($event) { /** @var EventDispatcherInterface $eventDispatcher */ # php-stan hint - $eventDispatcher = $this->container->get(EventDispatcherInterface::class); - return $eventDispatcher->dispatch($event, $eventName); + $eventDispatcher = $this->get('event_dispatcher'); + return $eventDispatcher->dispatch($event); } - /** - * @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 + * @return Packages * @deprecated */ + protected function getPackages(): Packages + { + /** @var Packages $packages */ # php-stan hint + $packages = $this->get('assetPackages'); + return $packages; + } + 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 +242,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('urlGenerator'); return $urlGenerator->generate( RouteObjectInterface::OBJECT_BASED_ROUTE_NAME, array_merge($parameters, [RouteObjectInterface::ROUTE_OBJECT => $route]), @@ -270,18 +253,33 @@ protected function generateUrl($route, array $parameters = [], int $referenceTyp } /** - * @return class-string + * @return string */ - public static function getCalledClass(): string + public static function getCalledClass() { $className = get_called_class(); - if (!str_starts_with($className, "\\")) { + if (\mb_strpos($className, "\\") !== 0) { $className = "\\" . $className; } - // @phpstan-ignore-next-line return $className; } + /** + * Validate a request against a given ROLE_* and throws + * an AccessDeniedException exception. + * + * @param string $role + * @deprecated Use denyAccessUnlessGranted() method instead + * @throws AccessDeniedException + * @return void + */ + public function validateAccessForRole($role) + { + if (!$this->isGranted($role)) { + throw new AccessDeniedException("You don't have access to this page:" . $role); + } + } + /** * Custom route for redirecting routes with a trailing slash. * @@ -289,7 +287,7 @@ public static function getCalledClass(): string * * @return RedirectResponse */ - public function removeTrailingSlashAction(Request $request): RedirectResponse + public function removeTrailingSlashAction(Request $request) { $pathInfo = $request->getPathInfo(); $requestUri = $request->getRequestUri(); @@ -299,11 +297,32 @@ 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 $_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 * * @return TranslationInterface - * @throws NonUniqueResultException */ protected function findTranslationForLocale(string $_locale = null): TranslationInterface { @@ -348,7 +367,7 @@ public function render(string $view, array $parameters = [], Response $response try { return parent::render($view, $parameters, $response); } catch (RuntimeError $e) { - if ($e->getPrevious() instanceof ForceResponseException) { + if ($e->getPrevious() instanceof \RZ\Roadiz\CoreBundle\Exception\ForceResponseException) { return $e->getPrevious()->getResponse(); } else { throw $e; @@ -375,7 +394,7 @@ protected function getNamespacedView(string $view, string $namespace = ''): stri * @param int $httpStatus * @return JsonResponse */ - public function renderJson(array $data = [], int $httpStatus = Response::HTTP_OK): JsonResponse + public function renderJson(array $data = [], int $httpStatus = JsonResponse::HTTP_OK) { return $this->json($data, $httpStatus); } @@ -405,25 +424,24 @@ protected function denyResourceExceptForFormats(Request $request, array $accepta * @param array $options Options for the form * * @return FormBuilderInterface - * @deprecated Use constructor service injection */ protected function createNamedFormBuilder(string $name = 'form', $data = null, array $options = []) { /** @var FormFactoryInterface $formFactory */ - $formFactory = $this->container->get(FormFactoryInterface::class); + $formFactory = $this->get('form.factory'); return $formFactory->createNamedBuilder($name, FormType::class, $data, $options); } /** * Creates and returns an EntityListManager instance. * - * @param class-string $entity Entity class path + * @param mixed $entity Entity class path * @param array $criteria * @param array $ordering * * @return EntityListManagerInterface */ - public function createEntityListManager(string $entity, array $criteria = [], array $ordering = []): EntityListManagerInterface + public function createEntityListManager($entity, array $criteria = [], array $ordering = []) { return new EntityListManager( $this->getRequest(), @@ -434,19 +452,54 @@ public function createEntityListManager(string $entity, array $criteria = [], ar ); } + /** + * Create and return a ContactFormManager to build and send contact + * form by email. + * + * @return ContactFormManager + */ + 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 + */ + public function createEmailManager(): EmailManager + { + /** @var EmailManager $emailManager */ # php-stan hinting + $emailManager = $this->get(EmailManager::class); + return $emailManager; + } + /** * Get a user from the tokenStorage. * - * @return UserInterface|null + * @return UserInterface|object|null * * @throws \LogicException If tokenStorage is not available * * @see TokenInterface::getUser() */ - protected function getUser(): ?UserInterface + protected function getUser() { + 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(); + if (null === $token) { + return null; + } + + $user = $token->getUser(); + + return \is_object($user) ? $user : null; } } diff --git a/src/Controller/FrontendController.php b/src/Controller/FrontendController.php new file mode 100644 index 0000000..b106f5b --- /dev/null +++ b/src/Controller/FrontendController.php @@ -0,0 +1,433 @@ + + */ + 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($entity, array $criteria = [], array $ordering = []) + { + 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..a16056d 100644 --- a/src/EventSubscriber/ExceptionSubscriber.php +++ b/src/EventSubscriber/ExceptionSubscriber.php @@ -4,38 +4,47 @@ namespace RZ\Roadiz\CompatBundle\EventSubscriber; -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; +use RZ\Roadiz\CoreBundle\Exception\ExceptionViewer; use RZ\Roadiz\CoreBundle\Exception\MaintenanceModeException; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ExceptionEvent; -use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Routing\Exception\ResourceNotFoundException; -use Symfony\Component\Security\Core\Exception\AccessDeniedException; -use Throwable; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; -use Twig\Error\SyntaxError; /** * @package RZ\Roadiz\CoreBundle\Event */ final class ExceptionSubscriber implements EventSubscriberInterface { + protected LoggerInterface $logger; + protected bool $debug; + protected ExceptionViewer $viewer; + private ThemeResolverInterface $themeResolver; + private ContainerInterface $serviceLocator; + public function __construct( - private readonly ThemeResolverInterface $themeResolver, - private readonly ContainerInterface $serviceLocator, - private readonly bool $debug + ThemeResolverInterface $themeResolver, + ExceptionViewer $viewer, + ContainerInterface $serviceLocator, + LoggerInterface $logger, + bool $debug ) { + $this->debug = $debug; + $this->viewer = $viewer; + $this->themeResolver = $themeResolver; + $this->serviceLocator = $serviceLocator; + $this->logger = $logger; } /** @@ -51,70 +60,10 @@ public static function getSubscribedEvents(): array ]; } - /** - * @param Request $request - * @return bool - */ - private function isFormatJson(Request $request): bool - { - if ( - $request->attributes->has('_format') && - ( - $request->attributes->get('_format') == 'json' || - $request->attributes->get('_format') == 'ld+json' - ) - ) { - return true; - } - - $contentType = $request->headers->get('Content-Type'); - if ( - \is_string($contentType) && - ( - \str_starts_with($contentType, 'application/json') || - \str_starts_with($contentType, 'application/ld+json') - ) - ) { - return true; - } - - if ( - in_array('application/json', $request->getAcceptableContentTypes()) || - in_array('application/ld+json', $request->getAcceptableContentTypes()) - ) { - return true; - } - - return false; - } - - /** - * @param Throwable $exception - * @return int - */ - private function getHttpStatusCode(Throwable $exception): int - { - if ($exception instanceof AccessDeniedException || $exception instanceof AccessDeniedHttpException) { - return Response::HTTP_FORBIDDEN; - } elseif ($exception instanceof HttpExceptionInterface) { - return $exception->getStatusCode(); - } elseif ($exception instanceof ResourceNotFoundException) { - return Response::HTTP_NOT_FOUND; - } elseif ($exception instanceof MaintenanceModeException) { - return Response::HTTP_SERVICE_UNAVAILABLE; - } - - return Response::HTTP_INTERNAL_SERVER_ERROR; - } - /** * @param ExceptionEvent $event - * @throws LoaderError - * @throws RuntimeError - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - * @throws Throwable - * @throws SyntaxError + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface */ public function onKernelException(ExceptionEvent $event): void { @@ -132,7 +81,7 @@ public function onKernelException(ExceptionEvent $event): void $exception = $exception->getPrevious(); } - if (!$this->isFormatJson($event->getRequest())) { + if (!$this->viewer->isFormatJson($event->getRequest())) { if ($exception instanceof MaintenanceModeException) { /* * Themed exception pages… @@ -146,7 +95,7 @@ public function onKernelException(ExceptionEvent $event): void /** @var Response $response */ $response = $ctrl->maintenanceAction($event->getRequest()); // Set http code according to status - $response->setStatusCode($this->getHttpStatusCode($exception)); + $response->setStatusCode($this->viewer->getHttpStatusCode($exception)); $event->setResponse($response); return; } catch (LoaderError $error) { @@ -160,6 +109,41 @@ public function onKernelException(ExceptionEvent $event): void } } + /** + * Create an emergency response to be sent instead of error logs. + * + * @param \Exception|\TypeError $e + * @param Request $request + * + * @return Response + */ + protected function getEmergencyResponse($e, Request $request): Response + { + /* + * Log error before displaying a fallback page. + */ + $class = get_class($e); + /* + * Do not flood logs with not-found errors + */ + if (!($e instanceof NotFoundHttpException) && !($e instanceof ResourceNotFoundException)) { + if ($e instanceof HttpExceptionInterface) { + // If HTTP exception do not log to critical + $this->logger->notice($e->getMessage(), [ + 'trace' => $e->getTraceAsString(), + 'exception' => $class, + ]); + } else { + $this->logger->emergency($e->getMessage(), [ + 'trace' => $e->getTraceAsString(), + 'exception' => $class, + ]); + } + } + + return $this->viewer->getResponse($e, $request, $this->debug); + } + /** * @param ExceptionEvent $event * @return null|Theme @@ -195,18 +179,18 @@ protected function isNotFoundExceptionWithTheme(ExceptionEvent $event): ?Theme /** * @param Theme $theme - * @param Throwable $exception + * @param \Throwable $exception * @param ExceptionEvent $event * * @return Response * @throws LoaderError * @throws RuntimeError - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - * @throws Throwable - * @throws SyntaxError + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \Throwable + * @throws \Twig\Error\SyntaxError */ - protected function createThemeNotFoundResponse(Theme $theme, Throwable $exception, ExceptionEvent $event): Response + protected function createThemeNotFoundResponse(Theme $theme, \Throwable $exception, ExceptionEvent $event): Response { $ctrlClass = $theme->getClassName(); $controller = new $ctrlClass(); diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php index 24bda0c..9ab3974 100644 --- a/src/EventSubscriber/MaintenanceModeSubscriber.php +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -15,22 +15,31 @@ 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; } /** * @return array */ - private function getAuthorizedRoutes(): array + private function getAuthorizedRoutes() { return [ 'loginPage', @@ -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;