From 4554528017675b8bdb993e3c8171de444a57b1c8 Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Mon, 14 Feb 2022 17:51:00 +0100 Subject: [PATCH] add redirect builder --- config/services.yaml | 5 + .../Redirect/RedirectAdminController.php | 154 +++++++++++++ .../Site/NavigationAdminController.php | 1 + core/Entity/Redirect.php | 211 ++++++++++++++++++ core/EventListener/RedirectListener.php | 58 +++++ core/Factory/RedirectFactory.php | 14 ++ core/Form/Filter/RedirectFilterType.php | 146 ++++++++++++ core/Form/RedirectType.php | 182 +++++++++++++++ core/Repository/RedirectRepository.php | 21 ++ core/Repository/RedirectRepositoryQuery.php | 15 ++ core/Resources/translations/messages.fr.yaml | 12 + .../views/admin/crud/field/button.html.twig | 2 +- .../views/admin/crud/field/text.html.twig | 2 +- .../redirect/redirect_admin/_form.html.twig | 45 ++++ .../redirect_admin/field/rule.html.twig | 23 ++ .../site/navigation_admin/index.html.twig | 7 + core/Router/RedirectMatcher.php | 64 ++++++ 17 files changed, 960 insertions(+), 2 deletions(-) create mode 100644 core/Controller/Redirect/RedirectAdminController.php create mode 100644 core/Entity/Redirect.php create mode 100644 core/EventListener/RedirectListener.php create mode 100644 core/Factory/RedirectFactory.php create mode 100644 core/Form/Filter/RedirectFilterType.php create mode 100644 core/Form/RedirectType.php create mode 100644 core/Repository/RedirectRepository.php create mode 100644 core/Repository/RedirectRepositoryQuery.php create mode 100644 core/Resources/views/redirect/redirect_admin/_form.html.twig create mode 100644 core/Resources/views/redirect/redirect_admin/field/rule.html.twig create mode 100644 core/Router/RedirectMatcher.php diff --git a/config/services.yaml b/config/services.yaml index 2e65ad0..88fe2f6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -18,6 +18,11 @@ services: exclude: - '../core/DependencyInjection/' - '../core/Entity/' + + App\Core\EventListener\RedirectListener: + tags: + - { name: kernel.event_listener, event: kernel.exception } + App\: resource: '../src/' exclude: diff --git a/core/Controller/Redirect/RedirectAdminController.php b/core/Controller/Redirect/RedirectAdminController.php new file mode 100644 index 0000000..da62811 --- /dev/null +++ b/core/Controller/Redirect/RedirectAdminController.php @@ -0,0 +1,154 @@ +doIndex($page, $query, $request, $session); + } + + /** + * @Route("/admin/redirect/new", name="admin_redirect_new", methods={"GET", "POST"}) + */ + public function new(Factory $factory, EntityManager $entityManager, Request $request): Response + { + return $this->doNew($factory->create(), $entityManager, $request); + } + + /** + * @Route("/admin/redirect/show/{entity}", name="admin_redirect_show", methods={"GET"}) + */ + public function show(Entity $entity): Response + { + return $this->doShow($entity); + } + + /** + * @Route("/admin/redirect/filter", name="admin_redirect_filter", methods={"GET"}) + */ + public function filter(Session $session): Response + { + return $this->doFilter($session); + } + + /** + * @Route("/admin/redirect/edit/{entity}", name="admin_redirect_edit", methods={"GET", "POST"}) + */ + public function edit(Entity $entity, EntityManager $entityManager, Request $request): Response + { + return $this->doEdit($entity, $entityManager, $request); + } + + /** + * @Route("/admin/redirect/sort/{page}", name="admin_redirect_sort", methods={"POST"}, requirements={"page":"\d+"}) + */ + public function sort(RepositoryQuery $query, EntityManager $entityManager, Request $request, Session $session, int $page = 1): Response + { + return $this->doSort($page, $query, $entityManager, $request, $session); + } + + /** + * @Route("/admin/redirect/batch/{page}", name="admin_redirect_batch", methods={"POST"}, requirements={"page":"\d+"}) + */ + public function batch(RepositoryQuery $query, EntityManager $entityManager, Request $request, Session $session, int $page = 1): Response + { + return $this->doBatch($page, $query, $entityManager, $request, $session); + } + + /** + * @Route("/admin/redirect/delete/{entity}", name="admin_redirect_delete", methods={"DELETE"}) + */ + public function delete(Entity $entity, EntityManager $entityManager, Request $request): Response + { + return $this->doDelete($entity, $entityManager, $request); + } + + protected function getConfiguration(): CrudConfiguration + { + return CrudConfiguration::create() + ->setPageTitle('index', 'Redirects') + ->setPageTitle('edit', '{label}') + ->setPageTitle('new', 'New redirect') + + ->setPageRoute('index', 'admin_redirect_index') + ->setPageRoute('new', 'admin_redirect_new') + ->setPageRoute('edit', 'admin_redirect_edit') + ->setPageRoute('sort', 'admin_redirect_sort') + ->setPageRoute('batch', 'admin_redirect_batch') + ->setPageRoute('delete', 'admin_redirect_delete') + ->setPageRoute('filter', 'admin_redirect_filter') + + ->setForm('edit', Type::class, []) + ->setForm('new', Type::class) + ->setForm('filter', FilterType::class) + + ->setView('form', '@Core/redirect/redirect_admin/_form.html.twig') + ->setMaxPerPage('index', 100) + ->setIsSortableCollection('index', true) + + ->setAction('index', 'show', false) + ->setAction('edit', 'show', false) + + ->setField('index', 'Label', Field\TextField::class, [ + 'property' => 'label', + 'attr' => ['class' => 'col-4'], + ]) + ->setField('index', 'Rule', Field\TextField::class, [ + 'view' => '@Core/redirect/redirect_admin/field/rule.html.twig', + 'attr' => ['class' => 'col-4'], + ]) + ->setField('index', 'Enabled', Field\ButtonField::class, [ + 'property_builder' => function(EntityInterface $entity) { + return $entity->getIsEnabled() ? 'Yes' : 'No'; + }, + 'attr' => ['class' => 'col-2'], + 'button_attr_builder' => function(EntityInterface $entity) { + return ['class' => 'btn btn-sm btn-'.($entity->getIsEnabled() ? 'success' : 'primary')]; + }, + ]) + ->setField('index', 'Type', Field\ButtonField::class, [ + 'property' => 'redirectCode', + 'attr' => ['class' => 'col-2'], + 'button_attr' => ['class' => 'btn btn-sm btn-light border-secondary font-weight-bold'], + ]) + ->setBatchAction('index', 'enable', 'Enable', function (EntityInterface $entity, EntityManager $manager) { + $entity->setIsEnabled(true); + + $manager->update($entity); + }) + ->setBatchAction('index', 'disable', 'Disable', function (EntityInterface $entity, EntityManager $manager) { + $entity->setIsEnabled(false); + + $manager->update($entity); + }) + ->setBatchAction('index', 'delete', 'Delete', function (EntityInterface $entity, EntityManager $manager) { + $manager->delete($entity); + }) + ; + } + + protected function getSection(): string + { + return 'site_navigation'; + } +} diff --git a/core/Controller/Site/NavigationAdminController.php b/core/Controller/Site/NavigationAdminController.php index ca7af07..b1676d8 100644 --- a/core/Controller/Site/NavigationAdminController.php +++ b/core/Controller/Site/NavigationAdminController.php @@ -107,6 +107,7 @@ class NavigationAdminController extends CrudController ->setPageRoute('sort', 'admin_site_navigation_sort') ->setPageRoute('delete', 'admin_site_navigation_delete') ->setPageRoute('filter', 'admin_site_navigation_filter') + ->setPageRoute('redirects', 'admin_redirect_index') ->setForm('edit', Type::class, []) ->setForm('new', Type::class) diff --git a/core/Entity/Redirect.php b/core/Entity/Redirect.php new file mode 100644 index 0000000..b4f78e2 --- /dev/null +++ b/core/Entity/Redirect.php @@ -0,0 +1,211 @@ +id; + } + + public function getScheme(): ?string + { + return $this->scheme; + } + + public function setScheme(string $scheme): self + { + $this->scheme = $scheme; + + return $this; + } + + public function getDomain(): ?string + { + return $this->domain; + } + + public function setDomain(string $domain): self + { + $this->domain = $domain; + + return $this; + } + + public function getDomainType(): ?string + { + return $this->domainType; + } + + public function setDomainType(string $domainType): self + { + $this->domainType = $domainType; + + return $this; + } + + public function getRule(): ?string + { + return $this->rule; + } + + public function setRule(string $rule): self + { + $this->rule = $rule; + + return $this; + } + + public function getRuleType(): ?string + { + return $this->ruleType; + } + + public function setRuleType(string $ruleType): self + { + $this->ruleType = $ruleType; + + return $this; + } + + public function getLocation(): ?string + { + return $this->location; + } + + public function setLocation(string $location): self + { + $this->location = $location; + + return $this; + } + + public function getRedirectCode(): ?int + { + return $this->redirectCode; + } + + public function setRedirectCode(int $redirectCode): self + { + $this->redirectCode = $redirectCode; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getSortOrder(): ?int + { + return $this->sortOrder; + } + + public function setSortOrder(?int $sortOrder): self + { + $this->sortOrder = $sortOrder; + + return $this; + } + + public function getIsEnabled(): ?bool + { + return $this->isEnabled; + } + + public function setIsEnabled(bool $isEnabled): self + { + $this->isEnabled = $isEnabled; + + return $this; + } + + public function getReuseQueryString(): ?bool + { + return $this->reuseQueryString; + } + + public function setReuseQueryString(bool $reuseQueryString): self + { + $this->reuseQueryString = $reuseQueryString; + + return $this; + } +} diff --git a/core/EventListener/RedirectListener.php b/core/EventListener/RedirectListener.php new file mode 100644 index 0000000..5694ec3 --- /dev/null +++ b/core/EventListener/RedirectListener.php @@ -0,0 +1,58 @@ + + */ +class RedirectListener +{ + protected RedirectMatcher $matcher; + protected RedirectRepositoryQuery $repository; + + public function __construct(RedirectMatcher $matcher, RedirectRepositoryQuery $repository) + { + $this->matcher = $matcher; + $this->repository = $repository; + } + + public function onKernelException(ExceptionEvent $event) + { + $request = $event->getRequest(); + + if (!$event->getThrowable() instanceof NotFoundHttpException) { + return; + } + + $redirects = $this->repository + ->orderBy('.sortOrder') + ->where('.isEnabled=1') + ->find() + ; + + $uri = $event->getRequest()->getUri(); + + foreach ($redirects as $redirect) { + if ($this->matcher->match($redirect, $uri)) { + if ($redirect->getReuseQueryString() && count($event->getRequest()->query)) { + $query = sprintf('?%s', http_build_query($event->getRequest()->query->all())); + } else { + $query = ''; + } + + $event->setResponse(new RedirectResponse( + $redirect->getLocation().$query, + $redirect->getRedirectCode() + )); + } + } + } +} diff --git a/core/Factory/RedirectFactory.php b/core/Factory/RedirectFactory.php new file mode 100644 index 0000000..d7a138a --- /dev/null +++ b/core/Factory/RedirectFactory.php @@ -0,0 +1,14 @@ +add( + 'label', + TextType::class, + [ + 'label' => 'Label', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'scheme', + ChoiceType::class, + [ + 'label' => 'Scheme', + 'required' => false, + 'choices' => [ + 'http(s)://' => 'all', + 'http://' => 'http', + 'https://' => 'https', + ], + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'domain', + TextType::class, + [ + 'label' => 'Domain', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'domainType', + ChoiceType::class, + [ + 'label' => 'Type', + 'required' => false, + 'choices' => [ + 'Domain' => 'domain', + 'Regular expression' => 'regexp', + ], + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'rule', + TextType::class, + [ + 'label' => '', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'ruleType', + ChoiceType::class, + [ + 'label' => 'Type', + 'required' => false, + 'choices' => [ + 'Path' => 'path', + 'Regular expression' => 'regexp', + ], + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'location', + TextType::class, + [ + 'label' => 'Location', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'redirectCode', + ChoiceType::class, + [ + 'label' => 'Code', + 'required' => false, + 'choices' => [ + '301 - Moved Permanently' => 301, + '307 - Temporary Redirect' => 307, + ], + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + 'csrf_protection' => false, + ]); + } +} diff --git a/core/Form/RedirectType.php b/core/Form/RedirectType.php new file mode 100644 index 0000000..3acce35 --- /dev/null +++ b/core/Form/RedirectType.php @@ -0,0 +1,182 @@ +add( + 'label', + TextType::class, + [ + 'label' => 'Label', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'scheme', + ChoiceType::class, + [ + 'label' => 'Scheme', + 'required' => true, + 'choices' => [ + 'http(s)://' => 'all', + 'http://' => 'http', + 'https://' => 'https', + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'domain', + TextType::class, + [ + 'label' => 'Domain', + 'required' => true, + 'attr' => [ + ], + 'help' => 'Regular expression: do not add the delimiter', + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'domainType', + ChoiceType::class, + [ + 'label' => 'Type', + 'required' => true, + 'choices' => [ + 'Domain' => 'domain', + 'Regular expression' => 'regexp', + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'rule', + TextType::class, + [ + 'label' => 'Rule', + 'required' => true, + 'attr' => [ + ], + 'help' => 'Regular expression: do not add the delimiter', + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'ruleType', + ChoiceType::class, + [ + 'label' => 'Type', + 'required' => true, + 'choices' => [ + 'Path' => 'path', + 'Regular expression' => 'regexp', + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'location', + TextType::class, + [ + 'label' => 'Location', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'redirectCode', + ChoiceType::class, + [ + 'label' => 'Code', + 'required' => true, + 'choices' => [ + '301 - Moved Permanently' => 301, + '307 - Temporary Redirect' => 307, + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'isEnabled', + CheckboxType::class, + [ + 'label' => 'Enabled', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'reuseQueryString', + CheckboxType::class, + [ + 'label' => 'Reuse the query string', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Redirect::class, + ]); + } +} diff --git a/core/Repository/RedirectRepository.php b/core/Repository/RedirectRepository.php new file mode 100644 index 0000000..c6d6070 --- /dev/null +++ b/core/Repository/RedirectRepository.php @@ -0,0 +1,21 @@ +{% if options.raw %}{{ value|raw }}{% else %}{{ value }}{% endif %} +<{{ options.button_tag }} {% for k, v in options.button_attr %}{{ k }}="{{ v }}"{% endfor %}>{% if options.raw %}{{ value|raw }}{% else %}{{ value|trans }}{% endif %} diff --git a/core/Resources/views/admin/crud/field/text.html.twig b/core/Resources/views/admin/crud/field/text.html.twig index a274c49..e9cf8ac 100644 --- a/core/Resources/views/admin/crud/field/text.html.twig +++ b/core/Resources/views/admin/crud/field/text.html.twig @@ -1,2 +1,2 @@ -{% if options.raw %}{{ value|raw }}{% else %}{{ value }}{% endif %} +{% if options.raw %}{{ value|raw }}{% else %}{{ value|trans }}{% endif %} diff --git a/core/Resources/views/redirect/redirect_admin/_form.html.twig b/core/Resources/views/redirect/redirect_admin/_form.html.twig new file mode 100644 index 0000000..83daace --- /dev/null +++ b/core/Resources/views/redirect/redirect_admin/_form.html.twig @@ -0,0 +1,45 @@ +
+
+
+
+ {{ form_row(form.label) }} +
+
+
+ {{ form_row(form.isEnabled) }} +
+
+
+ {{ form_row(form.scheme) }} +
+
+ +
+
+ {{ form_row(form.domainType) }} +
+
+ {{ form_row(form.domain) }} +
+
+ +
+
+ {{ form_row(form.ruleType) }} +
+
+ {{ form_row(form.rule) }} +
+
+ +
+
+ {{ form_row(form.redirectCode) }} +
+
+ {{ form_row(form.location) }} + {{ form_row(form.reuseQueryString) }} +
+
+
+
diff --git a/core/Resources/views/redirect/redirect_admin/field/rule.html.twig b/core/Resources/views/redirect/redirect_admin/field/rule.html.twig new file mode 100644 index 0000000..34bd810 --- /dev/null +++ b/core/Resources/views/redirect/redirect_admin/field/rule.html.twig @@ -0,0 +1,23 @@ +
+ + {%- if entity.scheme == 'all' -%} + http(s) + {%- else -%} + {{ entity.scheme }} + {%- endif -%}:// + + + {{ entity.domain }} + + + {{ entity.rule }} + +
+ +→ + +
+ + {{ entity.location }} + +
diff --git a/core/Resources/views/site/navigation_admin/index.html.twig b/core/Resources/views/site/navigation_admin/index.html.twig index 29fb1be..decc41a 100644 --- a/core/Resources/views/site/navigation_admin/index.html.twig +++ b/core/Resources/views/site/navigation_admin/index.html.twig @@ -1,5 +1,12 @@ {% extends '@Core/admin/crud/index.html.twig' %} +{% block header_actions_before %} + + + {{ configuration.actionTitle(context, 'redirects', 'Redirects')|trans }} + +{% endblock %} + {% block list_item_actions_before %} diff --git a/core/Router/RedirectMatcher.php b/core/Router/RedirectMatcher.php new file mode 100644 index 0000000..5002294 --- /dev/null +++ b/core/Router/RedirectMatcher.php @@ -0,0 +1,64 @@ + + */ +class RedirectMatcher +{ + public function match(Redirect $redirect, $url): bool + { + $data = $this->parse($url); + + if (!$this->matchSchema($redirect->getScheme(), $data['scheme'])) { + return false; + } + + if (!$this->matchDomain($redirect->getDomain(), $redirect->getDomainType(), $data['host'])) { + return false; + } + + if (!$this->matchRule($redirect->getRule(), $redirect->getRuleType(), $data['path'])) { + return false; + } + + return true; + } + + protected function matchSchema(string $redirectScheme, $scheme): bool + { + if ('all' === $redirectScheme) { + return true; + } + + return $redirectScheme === $scheme; + } + + protected function matchDomain(string $domain, string $type, string $host): bool + { + if ('domain' === $type) { + return $domain === $host; + } + + return preg_match('`'.$domain.'`', $host) > 0; + } + + protected function matchRule(string $rule, string $type, string $path): bool + { + if ('path' === $type) { + return $rule === $path; + } + + return preg_match('`'.$rule.'`', $path) > 0; + } + + protected function parse($url): array + { + return parse_url($url); + } +}