add redirect builder

This commit is contained in:
Simon Vieille 2022-02-14 17:51:00 +01:00
parent b1c995a316
commit 4554528017
17 changed files with 960 additions and 2 deletions

View file

@ -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:

View file

@ -0,0 +1,154 @@
<?php
namespace App\Core\Controller\Redirect;
use App\Core\Controller\Admin\Crud\CrudController;
use App\Core\Crud\CrudConfiguration;
use App\Core\Crud\Field;
use App\Core\Entity\EntityInterface;
use App\Core\Entity\Redirect as Entity;
use App\Core\Factory\RedirectFactory as Factory;
use App\Core\Form\Filter\RedirectFilterType as FilterType;
use App\Core\Form\RedirectType as Type;
use App\Core\Manager\EntityManager;
use App\Core\Repository\RedirectRepositoryQuery as RepositoryQuery;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\Annotation\Route;
class RedirectAdminController extends CrudController
{
/**
* @Route("/admin/redirect/{page}", name="admin_redirect_index", methods={"GET"}, requirements={"page":"\d+"})
*/
public function index(RepositoryQuery $query, Request $request, Session $session, int $page = 1): Response
{
return $this->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';
}
}

View file

@ -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)

211
core/Entity/Redirect.php Normal file
View file

@ -0,0 +1,211 @@
<?php
namespace App\Core\Entity;
use App\Repository\Entity\RedirectRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass=RedirectRepository::class)
*/
class Redirect implements EntityInterface
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
protected $id;
/**
* @ORM\Column(type="string", length=5)
*/
protected $scheme;
/**
* @ORM\Column(type="string", length=255)
*/
protected $domain;
/**
* @ORM\Column(type="string", length=6)
*/
protected $domainType;
/**
* @ORM\Column(type="string", length=255)
*/
protected $rule;
/**
* @ORM\Column(type="string", length=6)
*/
protected $ruleType;
/**
* @ORM\Column(type="string", length=255)
*/
protected $location;
/**
* @ORM\Column(type="integer")
*/
protected $redirectCode;
/**
* @ORM\Column(type="string", length=255)
*/
protected $label;
/**
* @ORM\Column(type="integer", nullable=true)
*/
protected $sortOrder;
/**
* @ORM\Column(type="boolean")
*/
private $isEnabled;
/**
* @ORM\Column(type="boolean")
*/
private $reuseQueryString;
public function getId(): ?int
{
return $this->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;
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Core\EventListener;
use App\Core\Repository\RedirectRepositoryQuery;
use App\Core\Router\RedirectMatcher;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* class RedirectListener.
*
* @author Simon Vieille <simon@deblan.fr>
*/
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()
));
}
}
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Core\Factory;
use App\Core\Factory\FactoryInterface;
use App\Core\Entity\Redirect as Entity;
class RedirectFactory implements FactoryInterface
{
public function create(): Entity
{
return new Entity();
}
}

View file

@ -0,0 +1,146 @@
<?php
namespace App\Core\Form\Filter;
use App\Core\Entity\Redirect;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class RedirectFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->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,
]);
}
}

182
core/Form/RedirectType.php Normal file
View file

@ -0,0 +1,182 @@
<?php
namespace App\Core\Form;
use App\Core\Entity\Redirect;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
class RedirectType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->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,
]);
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Core\Repository;
use App\Core\Entity\Redirect;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @method Redirect|null find($id, $lockMode = null, $lockVersion = null)
* @method Redirect|null findOneBy(array $criteria, array $orderBy = null)
* @method Redirect[] findAll()
* @method Redirect[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class RedirectRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Redirect::class);
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Core\Repository;
use App\Core\Repository\RepositoryQuery;
use Knp\Component\Pager\PaginatorInterface;
use App\Core\Repository\RedirectRepository as Repository;
class RedirectRepositoryQuery extends RepositoryQuery
{
public function __construct(Repository $repository, PaginatorInterface $paginator)
{
parent::__construct($repository, 'r', $paginator);
}
}

View file

@ -193,3 +193,15 @@
"Choose": "Choisir"
"Associated": "Associé(e)"
"Directory": "Répertoire"
"Redirects": "Redirections"
"New redirect": "Nouvelle redirection"
"Scheme": "Schéma"
"Rule": "Règle"
"Path": "Chemin"
"Location": "Emplacement"
"301 - Moved Permanently": "301 - Redirection permanante"
"307 - Temporary Redirect": "307 - Redirection temporaire"
"Enabled": "Activé(e)"
"Enable": "Activer"
"Disable": "Désactiver"
"Reuse the query string": "Réutiliser la chaîne de requête"

View file

@ -1 +1 @@
<{{ options.button_tag }} {% for k, v in options.button_attr %}{{ k }}="{{ v }}"{% endfor %}>{% if options.raw %}{{ value|raw }}{% else %}{{ value }}{% endif %}</{{ options.button_tag }}>
<{{ options.button_tag }} {% for k, v in options.button_attr %}{{ k }}="{{ v }}"{% endfor %}>{% if options.raw %}{{ value|raw }}{% else %}{{ value|trans }}{% endif %}</{{ options.button_tag }}>

View file

@ -1,2 +1,2 @@
{% if options.raw %}{{ value|raw }}{% else %}{{ value }}{% endif %}
{% if options.raw %}{{ value|raw }}{% else %}{{ value|trans }}{% endif %}

View file

@ -0,0 +1,45 @@
<div class="row">
<div class="col-12 p-2">
<div class="row">
<div class="col-md-8">
{{ form_row(form.label) }}
</div>
</div>
<div class="col-md-12">
{{ form_row(form.isEnabled) }}
</div>
<div class="row">
<div class="col-md-3">
{{ form_row(form.scheme) }}
</div>
</div>
<div class="row">
<div class="col-md-3">
{{ form_row(form.domainType) }}
</div>
<div class="col-md-5 pl-md-3">
{{ form_row(form.domain) }}
</div>
</div>
<div class="row">
<div class="col-md-3">
{{ form_row(form.ruleType) }}
</div>
<div class="col-md-5 pl-md-3">
{{ form_row(form.rule) }}
</div>
</div>
<div class="row">
<div class="col-md-3">
{{ form_row(form.redirectCode) }}
</div>
<div class="col-md-5 pl-md-3">
{{ form_row(form.location) }}
{{ form_row(form.reuseQueryString) }}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,23 @@
<div class="btn-group">
<span class="btn btn-sm btn-dark border-secondary disabled">
{%- if entity.scheme == 'all' -%}
http(s)
{%- else -%}
{{ entity.scheme }}
{%- endif -%}://
</span>
<span class="btn btn-sm btn-info border-secondary disabled">
{{ entity.domain }}
</span>
<span class="btn btn-sm btn-success border-secondary disabled">
{{ entity.rule }}
</span>
</div>
<div class="btn-group">
<span class="btn btn-sm btn-warning border-secondary disabled">
{{ entity.location }}
</span>
</div>

View file

@ -1,5 +1,12 @@
{% extends '@Core/admin/crud/index.html.twig' %}
{% block header_actions_before %}
<a href="{{ path(configuration.pageRoute('redirects'), configuration.pageRouteParams('redirects')) }}" class="btn btn-light">
<span class="fa fa-map-signs pr-1"></span>
{{ configuration.actionTitle(context, 'redirects', 'Redirects')|trans }}
</a>
{% endblock %}
{% block list_item_actions_before %}
<a href="{{ path('admin_site_tree_navigation', {navigation: item.id}) }}" class="btn btn-sm btn-success mr-1">
<span class="fa fa-sitemap"></span>

View file

@ -0,0 +1,64 @@
<?php
namespace App\Core\Router;
use App\Core\Entity\Redirect;
/**
* class RedirectMatcher.
*
* @author Simon Vieille <simon@deblan.fr>
*/
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);
}
}