From fac7a85653a8771203dab56cef8385bc0794addd Mon Sep 17 00:00:00 2001 From: Ben Walch Date: Mon, 9 Oct 2023 15:41:33 +0200 Subject: [PATCH] configurable redirector adapter (#147) --- UPGRADE.md | 4 +- config/pimcore/config.yaml | 20 ++++++- config/services/helper.yaml | 2 - docs/51_RedirectorAdapter.md | 57 ++++++++++++++++++- src/Adapter/Redirector/AbstractRedirector.php | 29 +++++++++- src/Adapter/Redirector/CookieRedirector.php | 39 ++++++++++--- src/Adapter/Redirector/GeoRedirector.php | 30 +++++++--- .../Redirector/RedirectorInterface.php | 6 ++ .../Compiler/RedirectorAdapterPass.php | 12 +++- src/DependencyInjection/Configuration.php | 30 +--------- src/DependencyInjection/I18nExtension.php | 2 +- src/EventListener/DetectorListener.php | 23 ++++---- src/Helper/CookieHelper.php | 22 +++---- 13 files changed, 195 insertions(+), 81 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 9e7dd33..482d3f8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -7,7 +7,9 @@ ### New Features - [BC BREAK] Config node `mode` has been removed and will be handled internally which simplifies i18n usability -- Configurable cookie expire flag +- [BC BREAK] Config node `cookie` has been removed. Please use `i18n.registry.redirector.cookie.config.cookie` instead. + See [Redirector Adapter](docs/51_RedirectorAdapter.md) for further reference +- Fully configurable redirector adapters *** diff --git a/config/pimcore/config.yaml b/config/pimcore/config.yaml index ebd063c..14ec4cc 100644 --- a/config/pimcore/config.yaml +++ b/config/pimcore/config.yaml @@ -1,3 +1,21 @@ doctrine_migrations: migrations_paths: - 'I18nBundle\Migrations': '@I18nBundle/src/Migrations' \ No newline at end of file + 'I18nBundle\Migrations': '@I18nBundle/src/Migrations' + +i18n: + registry: + redirector: + cookie: + config: + cookie: + path: / + secure: false + http_only: true + same_site: lax + expire: '+1 year' + geo: + config: + rules: + - { ignore_country: false, strict_country: true, strict_language: false } + - { ignore_country: false, strict_country: false, strict_language: false } + - { ignore_country: true, strict_country: false, strict_language: true } diff --git a/config/services/helper.yaml b/config/services/helper.yaml index a6e2df6..e552e73 100644 --- a/config/services/helper.yaml +++ b/config/services/helper.yaml @@ -5,8 +5,6 @@ services: autoconfigure: true public: false - I18nBundle\Helper\CookieHelper: ~ - I18nBundle\Helper\AdminLocaleHelper: ~ I18nBundle\Helper\AdminMessageRendererHelper: ~ diff --git a/docs/51_RedirectorAdapter.md b/docs/51_RedirectorAdapter.md index 3ca178d..ce55f56 100644 --- a/docs/51_RedirectorAdapter.md +++ b/docs/51_RedirectorAdapter.md @@ -7,12 +7,33 @@ redirector gets applied. ### Cookie Redirector > Priority: `300` -If enabled, visitor gets redirected to the last selected locale +If enabled, visitor gets redirected to the last selected locale + +Configuration: +``` +config: + cookie: + path: + domain: + secure: + http_only: + same_site: + expire: +``` ### GEO Redirector > Priority: `200` If enabled, visitor gets redirected based on IP and browser language +Configuration +``` +config: + rules: + - { ignore_country: false, strict_country: true, strict_language: false } + - { ignore_country: false, strict_country: false, strict_language: false } + ... +``` + ### Fallback Redirector > Priority: `100` If enabled, visitor gets redirected based on the `default_locale` setting defined in i18n settings (available in each zone) @@ -52,6 +73,22 @@ App\Services\I18nBundle\RedirectorAdapter\Website: - { name: i18n.adapter.redirector, alias: website, priority: 110 } ``` +### 2. Enable +you can also provide config for your redirector + +```yaml +# config/config.yaml +i18n: + registry: + rediretor: + website: + enabled: true + config: + my_config_node: 'value' + # etc ... + +``` + ### 3. Create a class Create a class, extend it from `AbstractRedirector`. @@ -64,6 +101,7 @@ namespace App\Services\I18nBundle\RedirectorAdapter; use I18nBundle\Adapter\Redirector\AbstractRedirector; use I18nBundle\Adapter\Redirector\RedirectorBag; use I18nBundle\Model\ZoneSiteInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; class Website extends AbstractRedirector { @@ -73,6 +111,9 @@ class Website extends AbstractRedirector if ($this->lastRedirectorWasSuccessful($redirectorBag) === true) { return; } + + // get custom config + $myConfigValue = $this->config['my_config_node']; // get the last decision bag $lastDecisionBag = $redirectorBag->getLastRedirectorDecision(); @@ -144,5 +185,19 @@ class Website extends AbstractRedirector $this->setDecision(['valid' => false]); } } + + protected function getConfigResolver(): ?OptionsResolver + { + $optionsResolver = new OptionsResolver(); + + // preare your options resolver + $optionsResolver->setRequired('my_config_node'); + $optionsResolver->setAllowedTypes('my_config_node', 'string'); + // etc .. + + return $optionsResolver; + + } + } ``` diff --git a/src/Adapter/Redirector/AbstractRedirector.php b/src/Adapter/Redirector/AbstractRedirector.php index da170db..291c9ad 100644 --- a/src/Adapter/Redirector/AbstractRedirector.php +++ b/src/Adapter/Redirector/AbstractRedirector.php @@ -7,6 +7,7 @@ abstract class AbstractRedirector implements RedirectorInterface { protected bool $enabled = true; + protected array $config = []; protected ?string $name; protected array $decision = []; @@ -20,6 +21,23 @@ public function setEnabled(bool $enabled): void $this->enabled = $enabled; } + public function getConfig(): array + { + return $this->config; + } + + public function setConfig(array $config): void + { + $configResolver = $this->getConfigResolver(); + if (null === $configResolver) { + if (!empty($config)) { + throw new \Exception(sprintf('redirector adapter "%s" has a config, but no config resolver was provided.', $this->getName())); + } + } else { + $this->config = $configResolver->resolve($config); + } + } + public function getName(): string { return $this->name; @@ -32,12 +50,12 @@ public function setName($name): void public function setDecision(array $decision): void { - $this->decision = $this->getResolver()->resolve($decision); + $this->decision = $this->getDecisionResolver()->resolve($decision); } public function getDecision(): array { - return $this->getResolver()->resolve($this->decision); + return $this->getDecisionResolver()->resolve($this->decision); } public function lastRedirectorWasSuccessful(RedirectorBag $redirectorBag): bool @@ -52,7 +70,12 @@ public function lastRedirectorWasSuccessful(RedirectorBag $redirectorBag): bool return false; } - private function getResolver(): OptionsResolver + protected function getConfigResolver(): ?OptionsResolver + { + return null; + } + + private function getDecisionResolver(): OptionsResolver { $resolver = new OptionsResolver(); $resolver->setDefaults([ diff --git a/src/Adapter/Redirector/CookieRedirector.php b/src/Adapter/Redirector/CookieRedirector.php index e8a8c5b..f1a3d51 100644 --- a/src/Adapter/Redirector/CookieRedirector.php +++ b/src/Adapter/Redirector/CookieRedirector.php @@ -3,18 +3,16 @@ namespace I18nBundle\Adapter\Redirector; use I18nBundle\Helper\CookieHelper; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\OptionsResolver\OptionsResolver; class CookieRedirector extends AbstractRedirector { - protected CookieHelper $cookieHelper; - - public function __construct(CookieHelper $cookieHelper) - { - $this->cookieHelper = $cookieHelper; - } public function makeDecision(RedirectorBag $redirectorBag): void { + $cookieHelper = new CookieHelper($this->config['cookie']); + if ($this->lastRedirectorWasSuccessful($redirectorBag) === true) { return; } @@ -26,7 +24,7 @@ public function makeDecision(RedirectorBag $redirectorBag): void $language = null; $request = $redirectorBag->getRequest(); - $redirectCookie = $this->cookieHelper->get($request); + $redirectCookie = $cookieHelper->get($request); //if no cookie available the validation fails. if (is_array($redirectCookie) && !empty($redirectCookie['url'])) { @@ -45,4 +43,31 @@ public function makeDecision(RedirectorBag $redirectorBag): void 'url' => $url ]); } + + protected function getConfigResolver(): OptionsResolver + { + $resolver = new OptionsResolver(); + $resolver->setRequired('cookie'); + $resolver->setDefault('cookie', function(OptionsResolver $cookieResolver) { + $cookieResolver + ->setRequired(['path', 'domain', 'secure', 'http_only', 'same_site']) + ->setDefaults([ + 'path' => '/', + 'domain' => null, + 'secure' => false, + 'http_only' => true, + 'same_site' => Cookie::SAMESITE_LAX, + 'expire' => '+1 year' + ]) + ->setAllowedTypes('path', 'string') + ->setAllowedTypes('domain', ['string', 'null']) + ->setAllowedTypes('secure', 'bool') + ->setAllowedTypes('http_only', 'bool') + ->setAllowedTypes('same_site', 'string') + ->setAllowedTypes('expire', ['integer', 'string']) + ->setAllowedValues('same_site', [Cookie::SAMESITE_LAX, Cookie::SAMESITE_STRICT, Cookie::SAMESITE_NONE]); + }); + + return $resolver; + } } diff --git a/src/Adapter/Redirector/GeoRedirector.php b/src/Adapter/Redirector/GeoRedirector.php index 70eff6d..9f64235 100644 --- a/src/Adapter/Redirector/GeoRedirector.php +++ b/src/Adapter/Redirector/GeoRedirector.php @@ -4,6 +4,7 @@ use I18nBundle\Helper\UserHelper; use I18nBundle\Model\ZoneSiteInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; class GeoRedirector extends AbstractRedirector { @@ -47,18 +48,14 @@ public function makeDecision(RedirectorBag $redirectorBag): void ]; $prioritisedListQuery = []; - $prioritisedList = [ - ['ignoreCountry' => false, 'countryStrictMode' => true, 'languageStrictMode' => false], - ['ignoreCountry' => false, 'countryStrictMode' => false, 'languageStrictMode' => false], - ['ignoreCountry' => true, 'countryStrictMode' => false, 'languageStrictMode' => true] - ]; + $prioritisedList = $this->config['rules']; foreach ($prioritisedList as $index => $list) { foreach ($userLanguagesIso as $priority => $userLocale) { - $country = $list['ignoreCountry'] ? null : $userCountryIso; - $countryStrictMode = $list['countryStrictMode']; - $languageStrictMode = $list['languageStrictMode']; + $country = $list['ignore_country'] ? null : $userCountryIso; + $countryStrictMode = $list['strict_country']; + $languageStrictMode = $list['strict_language']; if (null !== $zoneSite = $this->findZoneSite($zoneSites, $userLocale, $country, $countryStrictMode, $languageStrictMode)) { $prioritisedListQuery[] = [ @@ -144,4 +141,21 @@ protected function findZoneSite( return $indexId !== false ? $zoneSites[$indexId] : null; } + + protected function getConfigResolver(): OptionsResolver + { + $resolver = new OptionsResolver(); + $resolver + ->setRequired('rules') + ->setDefault('rules', function(OptionsResolver $rulesResolver) { + $rulesResolver + ->setPrototype(true) + ->setRequired(['ignore_country', 'strict_country', 'strict_language']) + ->setAllowedTypes('ignore_country', 'bool') + ->setAllowedTypes('strict_country', 'bool') + ->setAllowedTypes('strict_language', 'bool'); + }); + + return $resolver; + } } diff --git a/src/Adapter/Redirector/RedirectorInterface.php b/src/Adapter/Redirector/RedirectorInterface.php index e7872cb..ef1a8f2 100644 --- a/src/Adapter/Redirector/RedirectorInterface.php +++ b/src/Adapter/Redirector/RedirectorInterface.php @@ -2,6 +2,8 @@ namespace I18nBundle\Adapter\Redirector; +use Symfony\Component\OptionsResolver\OptionsResolver; + interface RedirectorInterface { public function isEnabled(): bool; @@ -16,6 +18,10 @@ public function setDecision(array $decision): void; public function getDecision(): array; + public function setConfig(array $config): void; + + public function getConfig(): array; + public function lastRedirectorWasSuccessful(RedirectorBag $redirectorBag): bool; public function makeDecision(RedirectorBag $redirectorBag): void; diff --git a/src/DependencyInjection/Compiler/RedirectorAdapterPass.php b/src/DependencyInjection/Compiler/RedirectorAdapterPass.php index fe2c894..1ccb6cf 100644 --- a/src/DependencyInjection/Compiler/RedirectorAdapterPass.php +++ b/src/DependencyInjection/Compiler/RedirectorAdapterPass.php @@ -13,7 +13,9 @@ public function process(ContainerBuilder $container): void { $services = []; $definition = $container->getDefinition(RedirectorRegistry::class); - $registryAvailability = $container->getParameter('i18n.registry_availability'); + $registry = $container->getParameter('i18n.registry'); + + $redirectorRegistry = $registry['redirector']; foreach ($container->findTaggedServiceIds('i18n.adapter.redirector', true) as $serviceId => $attributes) { $priority = $attributes[0]['priority'] ?? 0; @@ -33,11 +35,17 @@ public function process(ContainerBuilder $container): void foreach ($services as $service) { $serviceAlias = $service['alias']; - $available = isset($registryAvailability['redirector'][$serviceAlias]) ? $registryAvailability['redirector'][$serviceAlias]['enabled'] : true; + $available = isset($redirectorRegistry[$serviceAlias]) ? $redirectorRegistry[$serviceAlias]['enabled'] : true; + + if (!$available) { + continue; + } + $definition->addMethodCall('register', [$service['reference'], $serviceAlias]); $service['definition']->addMethodCall('setName', [$serviceAlias]); $service['definition']->addMethodCall('setEnabled', [$available]); + $service['definition']->addMethodCall('setConfig', [$redirectorRegistry[$serviceAlias]['config'] ?? []]); } } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 1640910..f2b9e73 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,7 +4,6 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; -use Symfony\Component\HttpFoundation\Cookie; class Configuration implements ConfigurationInterface { @@ -29,6 +28,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->arrayPrototype() ->children() ->booleanNode('enabled')->defaultTrue()->end() + ->variableNode('config')->end() ->end() ->end() ->end() @@ -135,34 +135,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->treatNullLike(['enabled' => false]) ->end() ->end() - ->arrayNode('cookie') - ->info('Cookie settings') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('path') - ->defaultValue('/') - ->end() - ->scalarNode('domain') - ->defaultNull() - ->end() - ->booleanNode('secure') - ->defaultFalse() - ->end() - ->booleanNode('httpOnly') - ->defaultTrue() - ->end() - ->scalarNode('expire') - ->defaultValue('+1 year') - ->end() - ->scalarNode('sameSite') - ->defaultValue(Cookie::SAMESITE_LAX) - ->validate() - ->ifNotInArray([Cookie::SAMESITE_NONE, Cookie::SAMESITE_LAX, Cookie::SAMESITE_STRICT]) - ->thenInvalid('Invalid sameSite setting for cookie: %s') - ->end() - ->end() - ->end() - ->end() ->end(); return $treeBuilder; diff --git a/src/DependencyInjection/I18nExtension.php b/src/DependencyInjection/I18nExtension.php index 5d473ba..6bd858f 100644 --- a/src/DependencyInjection/I18nExtension.php +++ b/src/DependencyInjection/I18nExtension.php @@ -54,7 +54,7 @@ public function load(array $configs, ContainerBuilder $container): void $configManagerDefinition = $container->getDefinition(BundleConfiguration::class); $configManagerDefinition->addMethodCall('setConfig', [$config]); - $container->setParameter('i18n.registry_availability', $config['registry']); + $container->setParameter('i18n.registry', $config['registry']); // set geo db path (including legacy path) /** @phpstan-ignore-next-line */ diff --git a/src/EventListener/DetectorListener.php b/src/EventListener/DetectorListener.php index 0e19ce1..36e6faf 100644 --- a/src/EventListener/DetectorListener.php +++ b/src/EventListener/DetectorListener.php @@ -7,8 +7,8 @@ use I18nBundle\Helper\RequestValidatorHelper; use I18nBundle\Http\I18nContextResolverInterface; use I18nBundle\Model\ZoneSiteInterface; +use I18nBundle\Registry\RedirectorRegistry; use I18nBundle\Resolver\PimcoreDocumentResolverInterface; -use I18nBundle\Configuration\Configuration; use I18nBundle\Resolver\RedirectResolver; use I18nBundle\Tool\System; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -20,15 +20,15 @@ class DetectorListener implements EventSubscriberInterface { + public function __construct( - protected Configuration $configuration, - protected CookieHelper $cookieHelper, protected PimcoreDocumentResolverInterface $pimcoreDocumentResolver, protected I18nContextResolverInterface $i18nContextResolver, + protected RedirectorRegistry $redirectorRegistry, protected RedirectResolver $redirectResolver, protected RequestValidatorHelper $requestValidatorHelper - ) { - } + ) + {} public static function getSubscribedEvents(): array { @@ -101,16 +101,15 @@ public function onKernelResponse(ResponseEvent $event): void return; } - $registryConfig = $this->configuration->getConfig('registry'); - $available = isset($registryConfig['redirector']['cookie']) - ? $registryConfig['redirector']['cookie']['enabled'] - : true; + $cookieRedirector = $this->redirectorRegistry->get('cookie'); //check if we're allowed to bake a cookie at the first place! - if ($available === false) { + if (false === $cookieRedirector->isEnabled()) { return; } + $cookieHelper = new CookieHelper($cookieRedirector->getConfig()['cookie']); + $i18nContext = $this->i18nContextResolver->getContext($event->getRequest()); if (!$i18nContext instanceof I18nContextInterface) { @@ -122,7 +121,7 @@ public function onKernelResponse(ResponseEvent $event): void $zoneSites = $zone->getSites(true); $validUri = $this->redirectResolver->resolveRedirectUrl(strtok($event->getRequest()->getUri(), '?')); - $cookie = $this->cookieHelper->get($event->getRequest()); + $cookie = $cookieHelper->get($event->getRequest()); //same domain, do nothing. if ($cookie !== null && $validUri === $cookie['url']) { @@ -138,7 +137,7 @@ public function onKernelResponse(ResponseEvent $event): void return; } - $this->cookieHelper->set($event->getResponse(), [ + $cookieHelper->set($event->getResponse(), [ 'url' => $validUri, 'locale' => $i18nContext->getLocaleDefinition()->getLocale(), 'language' => $i18nContext->getLocaleDefinition()->getLanguageIso(), diff --git a/src/Helper/CookieHelper.php b/src/Helper/CookieHelper.php index 3b99a7f..5d63b24 100644 --- a/src/Helper/CookieHelper.php +++ b/src/Helper/CookieHelper.php @@ -10,12 +10,8 @@ class CookieHelper { - private Configuration $configuration; - - public function __construct(Configuration $configuration) - { - $this->configuration = $configuration; - } + public function __construct(protected array $config) + {} public function get(Request $request, string $key = Definitions::REDIRECT_COOKIE_NAME): ?array { @@ -41,14 +37,12 @@ public function get(Request $request, string $key = Definitions::REDIRECT_COOKIE public function set(Response $response, array $params): Cookie { - $cookieConfig = $this->configuration->getConfig('cookie'); - - $path = $cookieConfig['path']; - $domain = $cookieConfig['domain']; - $secure = $cookieConfig['secure']; - $httpOnly = $cookieConfig['httpOnly']; - $sameSite = $cookieConfig['sameSite']; - $expire = is_string($cookieConfig['expire']) ? strtotime($cookieConfig['expire']) : $cookieConfig['expire']; + $path = $this->config['path']; + $domain = $this->config['domain']; + $secure = $this->config['secure']; + $httpOnly = $this->config['http_only']; + $sameSite = $this->config['same_site']; + $expire = is_string($this->config['expire']) ? strtotime($this->config['expire']) : $this->config['expire']; $cookieData = base64_encode(json_encode($params)); $cookie = new Cookie(Definitions::REDIRECT_COOKIE_NAME, $cookieData, $expire, $path, $domain, $secure, $httpOnly, false, $sameSite);