├── .codecov.yml ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── composer.json ├── config ├── doctrine-mapping │ ├── NotFound.orm.xml │ └── Redirect.orm.xml ├── form.xml ├── not_found.xml ├── redirect.xml ├── remove_not_found_subscriber.xml └── validation.xml ├── phpunit.xml.dist ├── src ├── DependencyInjection │ ├── Configuration.php │ └── ZenstruckRedirectExtension.php ├── EventListener │ ├── CreateNotFoundListener.php │ ├── Doctrine │ │ └── RemoveNotFoundSubscriber.php │ ├── NotFoundListener.php │ └── RedirectOnNotFoundListener.php ├── Form │ └── Type │ │ └── RedirectType.php ├── Model │ ├── NotFound.php │ └── Redirect.php ├── Service │ ├── NotFoundManager.php │ └── RedirectManager.php └── ZenstruckRedirectBundle.php └── translations ├── ZenstruckRedirectBundle.de.yml ├── ZenstruckRedirectBundle.en.yml ├── validators.de.yml └── validators.en.yml /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 50% 11 | 12 | comment: false 13 | github_checks: 14 | annotations: false 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /phpunit.xml 3 | /vendor/ 4 | /var/ 5 | /.phpunit.result.cache 6 | /.php-cs-fixer.cache 7 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 32 | ]; 33 | ``` 34 | 35 | ## Configuration 36 | 37 | **NOTE:** A `NotFound` or `Redirect` or both must be configured. 38 | 39 | ### Redirect 40 | 41 | 1. Create your redirect class inheriting the MappedSuperClass this bundle provides: 42 | 43 | ```php 44 | namespace App\Entity; 45 | 46 | use Zenstruck\RedirectBundle\Model\Redirect as BaseRedirect; 47 | use Doctrine\ORM\Mapping as ORM; 48 | 49 | /** 50 | * @ORM\Entity 51 | * @ORM\Table(name="redirects") 52 | */ 53 | class Redirect extends BaseRedirect 54 | { 55 | /** 56 | * @ORM\Id 57 | * @ORM\Column(type="integer") 58 | * @ORM\GeneratedValue(strategy="AUTO") 59 | */ 60 | private $id; 61 | } 62 | ``` 63 | 64 | 2. Set this class in your `zenstruck_redirect.yml`: 65 | 66 | ```yaml 67 | zenstruck_redirect: 68 | redirect_class: App\Entity\Redirect 69 | ``` 70 | 71 | 3. Update your schema (or use a migration): 72 | ```bash 73 | bin/console doctrine:schema:update --force 74 | ``` 75 | 76 | ### NotFound 77 | 78 | 1. Create your not found class inheriting the MappedSuperClass this bundle provides: 79 | 80 | ```php 81 | namespace App\Entity; 82 | 83 | use Zenstruck\RedirectBundle\Model\NotFound as BaseNotFound; 84 | use Doctrine\ORM\Mapping as ORM; 85 | 86 | /** 87 | * @ORM\Entity 88 | * @ORM\Table(name="not_founds") 89 | */ 90 | class NotFound extends BaseNotFound 91 | { 92 | /** 93 | * @ORM\Id 94 | * @ORM\Column(type="integer") 95 | * @ORM\GeneratedValue(strategy="AUTO") 96 | */ 97 | private $id; 98 | } 99 | ``` 100 | 101 | 2. Set this class in your `zenstruck_redirect.yml`: 102 | 103 | ```yaml 104 | zenstruck_redirect: 105 | not_found_class: App\Entity\NotFound 106 | ``` 107 | 108 | 3. Update your schema (or use a migration): 109 | ```bash 110 | bin/console doctrine:schema:update --force 111 | ``` 112 | 113 | ## Form Type 114 | 115 | This bundle provides a form type (`zenstruck_redirect`) for creating/editing redirects. 116 | 117 | ```php 118 | $redirect = // ... 119 | $form = $this->createForm('zenstruck_redirect', $redirect); 120 | ``` 121 | 122 | You may want to disable the `source` field for already created redirects: 123 | 124 | ```php 125 | // new action 126 | $redirect = new Redirect(); 127 | $form = $this->createForm('zenstruck_redirect', $redirect); 128 | 129 | // edit action 130 | $redirect = // get from database 131 | $form = $this->createForm('zenstruck_redirect', $redirect, array('disable_source' => true)); 132 | ``` 133 | 134 | ## Full Default Configuration 135 | 136 | ```yaml 137 | zenstruck_redirect: 138 | redirect_class: ~ # Required if not_found_class is not set 139 | not_found_class: ~ # Required if redirect_class is not set 140 | model_manager_name: ~ 141 | 142 | # When enabled, when a redirect is updated or created, the NotFound entities with a matching path are removed. 143 | remove_not_founds: true 144 | ``` 145 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenstruck/redirect-bundle", 3 | "description": "Store redirects for your site and keeps statistics on redirects and 404 errors", 4 | "keywords": ["redirect"], 5 | "homepage": "https://github.com/kbond/ZenstruckRedirectBundle", 6 | "type": "symfony-bundle", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Kevin Bond", 11 | "email": "kevinbond@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=8.0", 16 | "doctrine/doctrine-bundle": "^2.6", 17 | "doctrine/orm": "^2.6|^3.0", 18 | "symfony/framework-bundle": "^5.4|^6.0|^7.0" 19 | }, 20 | "require-dev": { 21 | "matthiasnoback/symfony-dependency-injection-test": "^4.3 || ^5.0", 22 | "symfony/browser-kit": "^5.4|^6.0|^7.0", 23 | "symfony/form": "^5.4|^6.0|^7.0", 24 | "symfony/phpunit-bridge": "^6.0|^7.0", 25 | "symfony/translation": "^5.4|^6.0|^7.0", 26 | "symfony/validator": "^5.4|^6.0|^7.0", 27 | "zenstruck/foundry": "^1.38.3", 28 | "phpunit/phpunit": "9.6.13", 29 | "zenstruck/browser": "^1.6" 30 | }, 31 | "autoload": { 32 | "psr-4": { "Zenstruck\\RedirectBundle\\": "src/" } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { "Zenstruck\\RedirectBundle\\Tests\\": "tests/" } 36 | }, 37 | "minimum-stability": "dev", 38 | "prefer-stable": true 39 | } 40 | -------------------------------------------------------------------------------- /config/doctrine-mapping/NotFound.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /config/doctrine-mapping/Redirect.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /config/form.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | %zenstruck_redirect.redirect_class% 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /config/not_found.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Zenstruck\RedirectBundle\Service\NotFoundManager 9 | Zenstruck\RedirectBundle\EventListener\CreateNotFoundListener 10 | 11 | 12 | 13 | 14 | %zenstruck_redirect.not_found_class% 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /config/redirect.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Zenstruck\RedirectBundle\Service\RedirectManager 9 | Zenstruck\RedirectBundle\EventListener\RedirectOnNotFoundListener 10 | 11 | 12 | 13 | 14 | %zenstruck_redirect.redirect_class% 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /config/remove_not_found_subscriber.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Zenstruck\RedirectBundle\EventListener\Doctrine\RemoveNotFoundSubscriber 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /config/validation.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | tests 22 | 23 | 24 | 25 | 26 | 27 | src 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\DependencyInjection; 13 | 14 | use Symfony\Component\Config\Definition\Builder\TreeBuilder; 15 | use Symfony\Component\Config\Definition\ConfigurationInterface; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @internal 21 | */ 22 | final class Configuration implements ConfigurationInterface 23 | { 24 | public function getConfigTreeBuilder(): TreeBuilder 25 | { 26 | $treeBuilder = new TreeBuilder('zenstruck_redirect'); 27 | 28 | $rootNode = $treeBuilder->getRootNode(); 29 | 30 | $rootNode 31 | ->children() 32 | ->scalarNode('redirect_class') 33 | ->defaultNull() 34 | ->validate() 35 | ->ifTrue(fn($value) => !\is_subclass_of($value, 'Zenstruck\RedirectBundle\Model\Redirect')) 36 | ->thenInvalid('"redirect_class" must be an instance of "Zenstruck\RedirectBundle\Model\Redirect"') 37 | ->end() 38 | ->end() 39 | ->scalarNode('not_found_class') 40 | ->defaultNull() 41 | ->validate() 42 | ->ifTrue(fn($value) => !\is_subclass_of($value, 'Zenstruck\RedirectBundle\Model\NotFound')) 43 | ->thenInvalid('"not_found_class" must be an instance of "Zenstruck\RedirectBundle\Model\NotFound"') 44 | ->end() 45 | ->end() 46 | ->booleanNode('remove_not_founds') 47 | ->info('When enabled, when a redirect is updated or created, the NotFound entites with a matching path are removed.') 48 | ->defaultTrue() 49 | ->end() 50 | ->scalarNode('model_manager_name')->defaultNull()->end() 51 | ->end() 52 | ; 53 | 54 | return $treeBuilder; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/DependencyInjection/ZenstruckRedirectExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\DependencyInjection; 13 | 14 | use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; 15 | use Symfony\Component\Config\FileLocator; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\DependencyInjection\Loader; 18 | use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; 19 | 20 | /** 21 | * @author Kevin Bond 22 | * 23 | * @internal 24 | */ 25 | final class ZenstruckRedirectExtension extends ConfigurableExtension 26 | { 27 | protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void 28 | { 29 | if (null === $mergedConfig['redirect_class'] && null === $mergedConfig['not_found_class']) { 30 | throw new InvalidConfigurationException('A "redirect_class" or "not_found_class" must be set for "zenstruck_redirect".'); 31 | } 32 | 33 | $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../../config')); 34 | $modelManagerName = $mergedConfig['model_manager_name'] ?: 'default'; 35 | 36 | $container->setAlias('zenstruck_redirect.entity_manager', \sprintf('doctrine.orm.%s_entity_manager', $modelManagerName)); 37 | 38 | if (null !== $mergedConfig['redirect_class']) { 39 | $container->setParameter('zenstruck_redirect.redirect_class', $mergedConfig['redirect_class']); 40 | 41 | $loader->load('redirect.xml'); 42 | $loader->load('form.xml'); 43 | } 44 | 45 | if (null !== $mergedConfig['not_found_class']) { 46 | $container->setParameter('zenstruck_redirect.not_found_class', $mergedConfig['not_found_class']); 47 | 48 | $loader->load('not_found.xml'); 49 | } 50 | 51 | if ($mergedConfig['remove_not_founds'] && null !== $mergedConfig['not_found_class'] && null !== $mergedConfig['redirect_class']) { 52 | $loader->load('remove_not_found_subscriber.xml'); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/EventListener/CreateNotFoundListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\EventListener; 13 | 14 | use Symfony\Component\HttpKernel\Event\ExceptionEvent; 15 | use Zenstruck\RedirectBundle\Service\NotFoundManager; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @internal 21 | */ 22 | final class CreateNotFoundListener extends NotFoundListener 23 | { 24 | public function __construct(private NotFoundManager $notFoundManager) 25 | { 26 | } 27 | 28 | public function onKernelException(ExceptionEvent $event): void 29 | { 30 | if (!$this->isNotFoundException($event)) { 31 | return; 32 | } 33 | 34 | $this->notFoundManager->createFromRequest($event->getRequest()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/EventListener/Doctrine/RemoveNotFoundSubscriber.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\EventListener\Doctrine; 13 | 14 | use Doctrine\Persistence\Event\LifecycleEventArgs; 15 | use Symfony\Component\DependencyInjection\ContainerInterface; 16 | use Zenstruck\RedirectBundle\Model\Redirect; 17 | use Zenstruck\RedirectBundle\Service\NotFoundManager; 18 | 19 | /** 20 | * @author Kevin Bond 21 | * 22 | * @internal 23 | */ 24 | final class RemoveNotFoundSubscriber 25 | { 26 | public function __construct(private ContainerInterface $container) 27 | { 28 | } 29 | 30 | public function postUpdate(LifecycleEventArgs $args): void 31 | { 32 | $this->remoteNotFoundForRedirect($args); 33 | } 34 | 35 | public function postPersist(LifecycleEventArgs $args): void 36 | { 37 | $this->remoteNotFoundForRedirect($args); 38 | } 39 | 40 | private function remoteNotFoundForRedirect(LifecycleEventArgs $args): void 41 | { 42 | $entity = $args->getObject(); 43 | 44 | if (!$entity instanceof Redirect) { 45 | return; 46 | } 47 | 48 | $this->getNotFoundManager()->removeForRedirect($entity); 49 | } 50 | 51 | private function getNotFoundManager(): NotFoundManager 52 | { 53 | return $this->container->get('zenstruck_redirect.not_found_manager'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/EventListener/NotFoundListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\EventListener; 13 | 14 | use Symfony\Component\HttpKernel\Event\ExceptionEvent; 15 | use Symfony\Component\HttpKernel\Exception\HttpException; 16 | use Symfony\Component\HttpKernel\HttpKernelInterface; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @internal 22 | */ 23 | abstract class NotFoundListener 24 | { 25 | abstract public function onKernelException(ExceptionEvent $event); 26 | 27 | final protected function isNotFoundException(ExceptionEvent $event): bool 28 | { 29 | if (HttpKernelInterface::MAIN_REQUEST !== $event->getRequestType()) { 30 | return false; 31 | } 32 | 33 | $exception = $event->getThrowable(); 34 | 35 | return $exception instanceof HttpException && 404 === $exception->getStatusCode(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/EventListener/RedirectOnNotFoundListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\EventListener; 13 | 14 | use Symfony\Component\HttpFoundation\RedirectResponse; 15 | use Symfony\Component\HttpKernel\Event\ExceptionEvent; 16 | use Zenstruck\RedirectBundle\Service\RedirectManager; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @internal 22 | */ 23 | final class RedirectOnNotFoundListener extends NotFoundListener 24 | { 25 | public function __construct(private RedirectManager $redirectManager) 26 | { 27 | } 28 | 29 | public function onKernelException(ExceptionEvent $event): void 30 | { 31 | if (!$this->isNotFoundException($event)) { 32 | return; 33 | } 34 | 35 | if (null === $redirect = $this->redirectManager->findAndUpdate($event->getRequest()->getPathInfo())) { 36 | return; 37 | } 38 | 39 | $event->setResponse(new RedirectResponse( 40 | $redirect->getDestination(), 41 | $redirect->isPermanent() ? 301 : 302, 42 | )); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Form/Type/RedirectType.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\Form\Type; 13 | 14 | use Symfony\Component\Form\AbstractType; 15 | use Symfony\Component\Form\FormBuilderInterface; 16 | use Symfony\Component\Form\FormInterface; 17 | use Symfony\Component\OptionsResolver\OptionsResolver; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | final class RedirectType extends AbstractType 23 | { 24 | /** 25 | * @param string $class The Redirect class name 26 | */ 27 | public function __construct(private string $class) 28 | { 29 | } 30 | 31 | public function buildForm(FormBuilderInterface $builder, array $options): void 32 | { 33 | $builder 34 | ->add('source', null, [ 35 | 'label' => 'form.source', 36 | 'translation_domain' => 'ZenstruckRedirectBundle', 37 | 'disabled' => $options['disable_source'], 38 | ]) 39 | 40 | ->add('destination', null, [ 41 | 'label' => 'form.destination', 42 | 'translation_domain' => 'ZenstruckRedirectBundle', 43 | ]) 44 | ; 45 | } 46 | 47 | public function getBlockPrefix(): string 48 | { 49 | return 'zenstruck_redirect'; 50 | } 51 | 52 | public function configureOptions(OptionsResolver $resolver): void 53 | { 54 | $class = $this->class; 55 | 56 | $resolver->setDefaults([ 57 | 'data_class' => $this->class, 58 | 'disable_source' => false, 59 | 'empty_data' => fn(FormInterface $form) => new $class( 60 | $form->get('source')->getData(), 61 | $form->get('destination')->getData(), 62 | ), 63 | ]); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Model/NotFound.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\Model; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | abstract class NotFound 18 | { 19 | protected ?string $path; 20 | 21 | protected ?\DateTime $timestamp; 22 | 23 | protected ?string $referer; 24 | 25 | public function __construct(?string $path, protected ?string $fullUrl, ?string $referer = null, ?\DateTime $timestamp = null) 26 | { 27 | if (null === $timestamp) { 28 | $timestamp = new \DateTime('now'); 29 | } 30 | 31 | $path = \trim((string) $path) ?: null; 32 | 33 | if (null !== $path) { 34 | $parse_url = \parse_url($path, \PHP_URL_PATH); 35 | 36 | if (null != $parse_url) { 37 | $path = '/'.\ltrim($parse_url, '/'); 38 | } else { 39 | $path = '/'; 40 | } 41 | } 42 | 43 | $this->path = $path; 44 | $this->referer = $referer; 45 | $this->timestamp = $timestamp; 46 | } 47 | 48 | public function getPath(): ?string 49 | { 50 | return $this->path; 51 | } 52 | 53 | public function getFullUrl(): ?string 54 | { 55 | return $this->fullUrl; 56 | } 57 | 58 | public function getTimestamp(): \DateTime 59 | { 60 | return $this->timestamp; 61 | } 62 | 63 | public function getReferer(): ?string 64 | { 65 | return $this->referer; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Model/Redirect.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\Model; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | abstract class Redirect 18 | { 19 | protected ?string $source; 20 | 21 | protected ?string $destination; 22 | 23 | protected bool $permanent; 24 | 25 | protected int $count = 0; 26 | 27 | protected ?\DateTime $lastAccessed = null; 28 | 29 | public function __construct(?string $source, ?string $destination, bool $permanent = true) 30 | { 31 | $this->setSource($source); 32 | $this->setDestination($destination); 33 | $this->setPermanent($permanent); 34 | } 35 | 36 | public static function createFromNotFound(NotFound $notFound, string $destination, bool $permanent = true): static 37 | { 38 | return new static($notFound->getPath(), $destination, $permanent); 39 | } 40 | 41 | public function getSource(): ?string 42 | { 43 | return $this->source; 44 | } 45 | 46 | public function setSource(?string $source): void 47 | { 48 | $source = \trim((string) $source) ?: null; 49 | 50 | if (null !== $source) { 51 | $source = $this->createAbsoluteUri($source); 52 | } 53 | 54 | $this->source = $source; 55 | } 56 | 57 | public function getDestination(): ?string 58 | { 59 | return $this->destination; 60 | } 61 | 62 | public function setDestination(?string $destination): void 63 | { 64 | $destination = \trim((string) $destination) ?: null; 65 | 66 | if (null !== $destination && null === \parse_url($destination, \PHP_URL_SCHEME)) { 67 | $destination = $this->createAbsoluteUri($destination, true); 68 | } 69 | 70 | $this->destination = $destination; 71 | } 72 | 73 | public function isPermanent(): bool 74 | { 75 | return $this->permanent; 76 | } 77 | 78 | public function setPermanent(bool $permanent): void 79 | { 80 | $this->permanent = $permanent; 81 | } 82 | 83 | public function getCount(): int 84 | { 85 | return $this->count; 86 | } 87 | 88 | public function increaseCount(int $amount = 1): void 89 | { 90 | $this->count += $amount; 91 | } 92 | 93 | public function getLastAccessed(): ?\DateTime 94 | { 95 | return $this->lastAccessed; 96 | } 97 | 98 | public function updateLastAccessed(?\DateTime $time = null): void 99 | { 100 | if (null === $time) { 101 | $time = new \DateTime('now'); 102 | } 103 | 104 | $this->lastAccessed = $time; 105 | } 106 | 107 | protected function createAbsoluteUri(string $path, bool $allowQueryString = false): string 108 | { 109 | $parse_url = \parse_url($path, \PHP_URL_PATH); 110 | 111 | if (null != $parse_url) { 112 | $value = '/'.\ltrim($parse_url, '/'); 113 | } else { 114 | $value = '/'; 115 | } 116 | 117 | if ($allowQueryString && $query = \parse_url($path, \PHP_URL_QUERY)) { 118 | $value .= '?'.$query; 119 | } 120 | 121 | return $value; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Service/NotFoundManager.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\Service; 13 | 14 | use Doctrine\Persistence\ObjectManager; 15 | use Symfony\Component\HttpFoundation\Request; 16 | use Zenstruck\RedirectBundle\Model\NotFound; 17 | use Zenstruck\RedirectBundle\Model\Redirect; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | class NotFoundManager 23 | { 24 | /** 25 | * @param string $class The NotFound class name 26 | */ 27 | public function __construct(private string $class, private ObjectManager $om) 28 | { 29 | } 30 | 31 | public function createFromRequest(Request $request): NotFound 32 | { 33 | $notFound = new $this->class( 34 | $request->getPathInfo(), 35 | $request->getUri(), 36 | $request->server->get('HTTP_REFERER'), 37 | ); 38 | 39 | $this->om->persist($notFound); 40 | $this->om->flush(); 41 | 42 | return $notFound; 43 | } 44 | 45 | /** 46 | * Deletes NotFound entities for a Redirect's path. 47 | */ 48 | public function removeForRedirect(Redirect $redirect): void 49 | { 50 | $notFounds = $this->om->getRepository($this->class)->findBy(['path' => $redirect->getSource()]); 51 | 52 | foreach ($notFounds as $notFound) { 53 | $this->om->remove($notFound); 54 | } 55 | 56 | $this->om->flush(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Service/RedirectManager.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle\Service; 13 | 14 | use Doctrine\Persistence\ObjectManager; 15 | use Zenstruck\RedirectBundle\Model\Redirect; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | class RedirectManager 21 | { 22 | /** 23 | * @param string $class The Redirect class name 24 | */ 25 | public function __construct(private string $class, private ObjectManager $om) 26 | { 27 | } 28 | 29 | public function findAndUpdate(string $source): ?Redirect 30 | { 31 | /** @var Redirect|null $redirect */ 32 | $redirect = $this->om->getRepository($this->class)->findOneBy(['source' => $source]); 33 | 34 | if (null === $redirect) { 35 | return null; 36 | } 37 | 38 | $redirect->increaseCount(); 39 | $redirect->updateLastAccessed(); 40 | $this->om->flush(); 41 | 42 | return $redirect; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ZenstruckRedirectBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\RedirectBundle; 13 | 14 | use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DoctrineOrmMappingsPass; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\HttpKernel\Bundle\Bundle; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | final class ZenstruckRedirectBundle extends Bundle 22 | { 23 | public function build(ContainerBuilder $container): void 24 | { 25 | parent::build($container); 26 | 27 | $container->addCompilerPass( 28 | DoctrineOrmMappingsPass::createXmlMappingDriver( 29 | [__DIR__.'/../config/doctrine-mapping' => 'Zenstruck\RedirectBundle\Model'], 30 | [], 31 | false, 32 | [], 33 | true, 34 | ), 35 | ); 36 | } 37 | 38 | public function getPath(): string 39 | { 40 | return \dirname(__DIR__); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /translations/ZenstruckRedirectBundle.de.yml: -------------------------------------------------------------------------------- 1 | form: 2 | source: Quelle 3 | destination: Ziel 4 | -------------------------------------------------------------------------------- /translations/ZenstruckRedirectBundle.en.yml: -------------------------------------------------------------------------------- 1 | form: 2 | source: Source 3 | destination: Destination 4 | -------------------------------------------------------------------------------- /translations/validators.de.yml: -------------------------------------------------------------------------------- 1 | zenstruck_redirect: 2 | source: 3 | unique: Diese Quelle ist bereits in Verwendung. 4 | blank: Bitte eine Quelle angeben. 5 | destination: 6 | blank: Bitte ein Ziel angeben. 7 | -------------------------------------------------------------------------------- /translations/validators.en.yml: -------------------------------------------------------------------------------- 1 | zenstruck_redirect: 2 | source: 3 | unique: The source is already used. 4 | blank: Please enter a source. 5 | destination: 6 | blank: Please enter a destination. 7 | --------------------------------------------------------------------------------