diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md index d6d929cb50ed6..502a9f2ef0e77 100644 --- a/src/Symfony/Bridge/Twig/CHANGELOG.md +++ b/src/Symfony/Bridge/Twig/CHANGELOG.md @@ -1,6 +1,10 @@ CHANGELOG ========= +7.4 +--- + * Add `rate_limit()` Twig function + 7.3 --- diff --git a/src/Symfony/Bridge/Twig/Extension/RateLimiterExtension.php b/src/Symfony/Bridge/Twig/Extension/RateLimiterExtension.php new file mode 100644 index 0000000000000..b2cde180fa362 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/RateLimiterExtension.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Santiago San Martin + */ +final class RateLimiterExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('rate_limit', [RateLimiterRuntime::class, 'rateLimit']), + ]; + } +} diff --git a/src/Symfony/Bridge/Twig/Extension/RateLimiterRuntime.php b/src/Symfony/Bridge/Twig/Extension/RateLimiterRuntime.php new file mode 100644 index 0000000000000..b98c35af9a062 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/RateLimiterRuntime.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Psr\Container\ContainerInterface; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; + +/** + * @author Santiago San Martin + */ +final class RateLimiterRuntime +{ + public function __construct( + private readonly ContainerInterface $rateLimiterLocator, + private readonly RequestStack $requestStack, + private ?ExpressionLanguage $expressionLanguage = null, + ) { + } + + public function rateLimit(string $rateLimiterName, string|Expression|null $key = null, int $tokens = 1): bool + { + if (!class_exists(RateLimiterFactory::class)) { + throw new \LogicException('The "rate_limit()" function requires symfony/rate-limiter. Try running "composer require symfony/rate-limiter".'); + } + + $rateLimiterFactory = $this->rateLimiterLocator->get('limiter.'.$rateLimiterName); + if (!$rateLimiterFactory instanceof RateLimiterFactory && !$rateLimiterFactory instanceof RateLimiterFactoryInterface) { + $className = interface_exists(RateLimiterFactoryInterface::class) ? RateLimiterFactoryInterface::class : RateLimiterFactory::class; + throw new \LogicException(\sprintf('The service "%s" is not an instance of "%s".', $rateLimiterName, $className)); + } + + $limiter = $rateLimiterFactory->create($this->generateKey($key)); + + return $limiter->consume($tokens)->isAccepted(); + } + + private function generateKey(string|Expression|null $key): ?string + { + if (!$key instanceof Expression) { + return $key; + } + + $this->expressionLanguage ??= new ExpressionLanguage(); + + return (string) $this->expressionLanguage->evaluate($key, [ + 'request' => $this->requestStack->getMainRequest(), + ]); + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/templates/rate_limiter/custom_key.twig b/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/templates/rate_limiter/custom_key.twig new file mode 100644 index 0000000000000..f7ad0cd859fea --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/templates/rate_limiter/custom_key.twig @@ -0,0 +1,6 @@ +{% set limit = rate_limit('anonymous_api', 'custom_key', 50) %} +{% if limit %} + custom_key: allowed +{% else %} + custom_key: denied +{% endif %} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/templates/rate_limiter/exceeded_limit.twig b/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/templates/rate_limiter/exceeded_limit.twig new file mode 100644 index 0000000000000..5d4bd8b5ea880 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/templates/rate_limiter/exceeded_limit.twig @@ -0,0 +1,6 @@ +{% set limit = rate_limit('anonymous_api', 'exceed_key') %} +{% if limit %} + exceeded_limit: allowed +{% else %} + exceeded_limit: denied +{% endif %} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/templates/rate_limiter/expression_key.twig b/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/templates/rate_limiter/expression_key.twig new file mode 100644 index 0000000000000..aed2aabbec35c --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/Fixtures/templates/rate_limiter/expression_key.twig @@ -0,0 +1,6 @@ +{% set limit = rate_limit('anonymous_api', expression('request.getClientIp()')) %} +{% if limit %} + expression_key: allowed +{% else %} + expression_key: denied +{% endif %} diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/RateLimiterExtensionTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/RateLimiterExtensionTest.php new file mode 100644 index 0000000000000..e256f9fdfb60d --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/RateLimiterExtensionTest.php @@ -0,0 +1,174 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresMethod; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Bridge\Twig\Extension\ExpressionExtension; +use Symfony\Bridge\Twig\Extension\RateLimiterExtension; +use Symfony\Bridge\Twig\Extension\RateLimiterRuntime; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimit; +use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; +use Twig\Environment; +use Twig\Loader\ArrayLoader; +use Twig\RuntimeLoader\RuntimeLoaderInterface; + +#[RequiresMethod(RateLimiterFactory::class, '__construct')] +class RateLimiterExtensionTest extends TestCase +{ + #[DataProvider('provideRateLimitTemplatesUsingExpression')] + public function testRateLimiterWithExpression( + string $templateFile, + bool $isAccepted, + int|string $expectedConsume, + Expression $expression, + string $expectedOutput, + ): void { + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '127.0.0.1']); + $requestStack = new RequestStack(); + $requestStack->push($request); + $expectedKey = (new ExpressionLanguage())->evaluate($expression, ['request' => $request]); + + $this->doTestRateLimiter( + templateFile: $templateFile, + isAccepted: $isAccepted, + expectedConsume: $expectedConsume, + expectedLimiterKey: $expectedKey, + expectedOutput: $expectedOutput, + requestStack: $requestStack + ); + } + + public static function provideRateLimitTemplatesUsingExpression(): array + { + $expression = new Expression('request.getClientIp()'); + + return [ + ['expression_key.twig', true, 1, $expression, 'expression_key: allowed'], + ['expression_key.twig', false, 1, $expression, 'expression_key: denied'], + ]; + } + + #[DataProvider('provideRateLimitTemplatesWithoutExpression')] + public function testRateLimiterWithoutExpression( + string $templateFile, + bool $isAccepted, + int|string $expectedConsume, + string $expectedKey, + string $expectedOutput, + ): void { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('/')); + + $this->doTestRateLimiter( + templateFile: $templateFile, + isAccepted: $isAccepted, + expectedConsume: $expectedConsume, + expectedLimiterKey: $expectedKey, + expectedOutput: $expectedOutput, + requestStack: $requestStack + ); + } + + public static function provideRateLimitTemplatesWithoutExpression(): array + { + return [ + ['custom_key.twig', true, 50, 'custom_key', 'custom_key: allowed'], + ['custom_key.twig', false, 50, 'custom_key', 'custom_key: denied'], + ['exceeded_limit.twig', false, 1, 'exceed_key', 'exceeded_limit: denied'], + ['exceeded_limit.twig', true, 1, 'exceed_key', 'exceeded_limit: allowed'], + ]; + } + + public function testRateLimiterThrowsIfServiceIsNotFactory() + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get') + ->with('limiter.invalid_service') + ->willReturn(new \stdClass()); + + $requestStack = new RequestStack(); + $requestStack->push(new Request()); + + $runtime = new RateLimiterRuntime($container, $requestStack); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The service "invalid_service" is not an instance of'); + + $runtime->rateLimit('invalid_service'); + } + + private function doTestRateLimiter( + string $templateFile, + bool $isAccepted, + int|string $expectedConsume, + int|string $expectedLimiterKey, + string $expectedOutput, + RequestStack $requestStack, + ): void { + $rateLimit = $this->createMock(RateLimit::class); + $rateLimit->expects($this->once()) + ->method('isAccepted') + ->willReturn($isAccepted); + + $limiter = $this->createMock(LimiterInterface::class); + $limiter->expects($this->once()) + ->method('consume') + ->with($expectedConsume) + ->willReturn($rateLimit); + + $factory = $this->createMock(RateLimiterFactoryInterface::class); + $factory->expects($this->once()) + ->method('create') + ->with($expectedLimiterKey) + ->willReturn($limiter); + + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->once()) + ->method('get') + ->with('limiter.anonymous_api') + ->willReturn($factory); + + $runtime = new RateLimiterRuntime($container, $requestStack); + $twig = $this->createTwigEnvironment($runtime); + + $templateCode = file_get_contents(__DIR__.'/Fixtures/templates/rate_limiter/'.$templateFile); + $twig->setLoader(new ArrayLoader(['template' => $templateCode])); + + $this->assertSame($expectedOutput, trim($twig->render('template'))); + } + + private function createTwigEnvironment(RateLimiterRuntime $rateLimiterRuntime): Environment + { + $twig = new Environment(new ArrayLoader([]), ['debug' => true, 'cache' => false]); + $twig->addExtension(new RateLimiterExtension()); + $twig->addExtension(new ExpressionExtension()); + + $loader = $this->createMock(RuntimeLoaderInterface::class); + $loader->method('load') + ->willReturnMap([ + [RateLimiterRuntime::class, $rateLimiterRuntime], + ]); + + $twig->addRuntimeLoader($loader); + + return $twig; + } +} diff --git a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php index 16421eaf504d4..d1071e61a550a 100644 --- a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php +++ b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php @@ -58,6 +58,7 @@ class UndefinedCallableHandler 'field_help' => 'form', 'field_errors' => 'form', 'field_choices' => 'form', + 'rate_limit' => 'rate-limiter', 'logout_url' => 'security-http', 'logout_path' => 'security-http', 'is_granted' => 'security-core', diff --git a/src/Symfony/Bridge/Twig/composer.json b/src/Symfony/Bridge/Twig/composer.json index 9fafcd55a0984..cdb180ec304ac 100644 --- a/src/Symfony/Bridge/Twig/composer.json +++ b/src/Symfony/Bridge/Twig/composer.json @@ -54,7 +54,8 @@ "symfony/workflow": "^6.4|^7.0|^8.0", "twig/cssinliner-extra": "^3", "twig/inky-extra": "^3", - "twig/markdown-extra": "^3" + "twig/markdown-extra": "^3", + "symfony/rate-limiter": "^7.3|^8.0" }, "conflict": { "phpdocumentor/reflection-docblock": "<3.2.2", diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 53361e3127e34..6cd45f8d4a0cf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -76,6 +76,7 @@ class UnusedTagsPass implements CompilerPassInterface 'property_info.list_extractor', 'property_info.type_extractor', 'proxy', + 'rate_limiter', 'remote_event.consumer', 'routing.condition_service', 'routing.expression_language_function', diff --git a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md index 40d5be350afe7..811a2a856c9fe 100644 --- a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md @@ -1,6 +1,10 @@ CHANGELOG ========= +7.4 +--- + * Add support for `rate_limit()` Twig function + 7.3 --- diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php index 9b5e8d633014e..36ce4095f05b4 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/ExtensionPass.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\TwigBundle\DependencyInjection\Compiler; use Symfony\Bridge\Twig\Extension\FormExtension; +use Symfony\Bridge\Twig\Extension\RateLimiterExtension; use Symfony\Component\Asset\Packages; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -146,5 +147,19 @@ public function process(ContainerBuilder $container): void $container->getDefinition('twig.runtime.serializer')->addTag('twig.runtime'); $container->getDefinition('twig.extension.serializer')->addTag('twig.extension'); } + + if (!$container->hasDefinition('cache.system')) { + $container->removeDefinition('cache.twig.runtime.rate_limiter_expression_language'); + } + + if ($container->has('limiter') && class_exists(RateLimiterExtension::class)) { + $container->getDefinition('twig.runtime.rate_limiter')->addTag('twig.runtime'); + $container->getDefinition('twig.extension.rate_limiter')->addTag('twig.extension'); + } else { + $container->removeDefinition('twig.runtime.rate_limiter'); + $container->removeDefinition('twig.extension.rate_limiter'); + $container->removeDefinition('cache.twig.runtime.rate_limiter_expression_language'); + $container->removeDefinition('twig.runtime.rate_limiter_expression_language'); + } } } diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index ccd546b93ca70..1850a1fd8882a 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -192,6 +192,10 @@ public function load(array $configs, ContainerBuilder $container): void } } + if (!$container->hasDefinition('cache.system')) { + $container->removeDefinition('cache.twig.runtime.rate_limiter_expression_language'); + } + if (isset($config['autoescape_service'])) { $config['autoescape'] = [new Reference($config['autoescape_service']), $config['autoescape_service_method'] ?? '__invoke']; } else { diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php index 3ea59d07fa469..90eb4f9eec865 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php @@ -24,6 +24,8 @@ use Symfony\Bridge\Twig\Extension\HttpKernelExtension; use Symfony\Bridge\Twig\Extension\HttpKernelRuntime; use Symfony\Bridge\Twig\Extension\ProfilerExtension; +use Symfony\Bridge\Twig\Extension\RateLimiterExtension; +use Symfony\Bridge\Twig\Extension\RateLimiterRuntime; use Symfony\Bridge\Twig\Extension\RoutingExtension; use Symfony\Bridge\Twig\Extension\SerializerExtension; use Symfony\Bridge\Twig\Extension\SerializerRuntime; @@ -36,6 +38,7 @@ use Symfony\Bundle\TwigBundle\CacheWarmer\TemplateCacheWarmer; use Symfony\Bundle\TwigBundle\DependencyInjection\Configurator\EnvironmentConfigurator; use Symfony\Bundle\TwigBundle\TemplateIterator; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Twig\Cache\ChainCache; use Twig\Cache\FilesystemCache; use Twig\Cache\ReadOnlyFilesystemCache; @@ -189,6 +192,22 @@ ->set('twig.extension.serializer', SerializerExtension::class) + ->set('twig.runtime.rate_limiter', RateLimiterRuntime::class) + ->args([ + tagged_locator('rate_limiter'), + service('request_stack'), + service('twig.runtime.rate_limiter_expression_language')->nullOnInvalid(), + ]) + + ->set('twig.extension.rate_limiter', RateLimiterExtension::class) + + ->set('twig.runtime.rate_limiter_expression_language', ExpressionLanguage::class) + ->args([service('cache.twig.runtime.rate_limiter_expression_language')->nullOnInvalid()]) + + ->set('cache.twig.runtime.rate_limiter_expression_language') + ->parent('cache.system') + ->tag('cache.pool') + ->set('controller.template_attribute_listener', TemplateAttributeListener::class) ->args([service('twig')]) ->tag('kernel.event_subscriber')