From 097c6b797b784a1bc2af16de05083b38a0c66e78 Mon Sep 17 00:00:00 2001 From: valtzu Date: Wed, 27 Nov 2024 22:28:12 +0200 Subject: [PATCH 01/20] Generate url-safe signatures --- Tests/Extension/HttpKernelExtensionTest.php | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Extension/HttpKernelExtensionTest.php b/Tests/Extension/HttpKernelExtensionTest.php index d9079b1c..be617723 100644 --- a/Tests/Extension/HttpKernelExtensionTest.php +++ b/Tests/Extension/HttpKernelExtensionTest.php @@ -80,7 +80,7 @@ public function testGenerateFragmentUri() ]); $twig->addRuntimeLoader($loader); - $this->assertSame('/_fragment?_hash=XCg0hX8QzSwik8Xuu9aMXhoCeI4oJOob7lUVacyOtyY%3D&_path=template%3Dfoo.html.twig%26_format%3Dhtml%26_locale%3Den%26_controller%3DSymfony%255CBundle%255CFrameworkBundle%255CController%255CTemplateController%253A%253AtemplateAction', $twig->render('index')); + $this->assertSame('/_fragment?_hash=XCg0hX8QzSwik8Xuu9aMXhoCeI4oJOob7lUVacyOtyY&_path=template%3Dfoo.html.twig%26_format%3Dhtml%26_locale%3Den%26_controller%3DSymfony%255CBundle%255CFrameworkBundle%255CController%255CTemplateController%253A%253AtemplateAction', $twig->render('index')); } protected function getFragmentHandler($returnOrException): FragmentHandler diff --git a/composer.json b/composer.json index 3af8ccbb..ca751c3f 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "symfony/finder": "^6.4|^7.0", "symfony/form": "^6.4|^7.0", "symfony/html-sanitizer": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-foundation": "^7.3", "symfony/http-kernel": "^6.4|^7.0", "symfony/intl": "^6.4|^7.0", "symfony/mime": "^6.4|^7.0", From f72ff4cae50a434697d72ed716fce0c10e1b2867 Mon Sep 17 00:00:00 2001 From: Nate Wiebe Date: Wed, 9 Nov 2022 11:16:55 -0500 Subject: [PATCH 02/20] Add is_granted_for_user() function to twig --- CHANGELOG.md | 5 +++++ Extension/SecurityExtension.php | 24 +++++++++++++++++++++++- UndefinedCallableHandler.php | 1 + 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b18e2745..156b29ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + + * Add `is_granted_for_user()` Twig function + 7.2 --- diff --git a/Extension/SecurityExtension.php b/Extension/SecurityExtension.php index 863df156..9bf346ca 100644 --- a/Extension/SecurityExtension.php +++ b/Extension/SecurityExtension.php @@ -13,7 +13,9 @@ use Symfony\Component\Security\Acl\Voter\FieldVote; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; +use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Impersonate\ImpersonateUrlGenerator; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -28,6 +30,7 @@ final class SecurityExtension extends AbstractExtension public function __construct( private ?AuthorizationCheckerInterface $securityChecker = null, private ?ImpersonateUrlGenerator $impersonateUrlGenerator = null, + private ?UserAuthorizationCheckerInterface $userSecurityChecker = null, ) { } @@ -48,6 +51,19 @@ public function isGranted(mixed $role, mixed $object = null, ?string $field = nu } } + public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null): bool + { + if (!$this->userSecurityChecker) { + throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', UserAuthorizationCheckerInterface::class, __METHOD__)); + } + + if ($field) { + $subject = new FieldVote($subject, $field); + } + + return $this->userSecurityChecker->isGrantedForUser($user, $attribute, $subject); + } + public function getImpersonateExitUrl(?string $exitTo = null): string { if (null === $this->impersonateUrlGenerator) { @@ -86,12 +102,18 @@ public function getImpersonatePath(string $identifier): string public function getFunctions(): array { - return [ + $functions = [ new TwigFunction('is_granted', $this->isGranted(...)), new TwigFunction('impersonation_exit_url', $this->getImpersonateExitUrl(...)), new TwigFunction('impersonation_exit_path', $this->getImpersonateExitPath(...)), new TwigFunction('impersonation_url', $this->getImpersonateUrl(...)), new TwigFunction('impersonation_path', $this->getImpersonatePath(...)), ]; + + if ($this->userSecurityChecker) { + $functions[] = new TwigFunction('is_granted_for_user', $this->isGrantedForUser(...)); + } + + return $functions; } } diff --git a/UndefinedCallableHandler.php b/UndefinedCallableHandler.php index 5da9a148..16421eaf 100644 --- a/UndefinedCallableHandler.php +++ b/UndefinedCallableHandler.php @@ -61,6 +61,7 @@ class UndefinedCallableHandler 'logout_url' => 'security-http', 'logout_path' => 'security-http', 'is_granted' => 'security-core', + 'is_granted_for_user' => 'security-core', 'impersonation_path' => 'security-http', 'impersonation_url' => 'security-http', 'impersonation_exit_path' => 'security-http', From 5bc928e2471f2f8c84e64a684c82f4a558eb461b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Wed, 8 Jan 2025 06:16:29 +0100 Subject: [PATCH 03/20] [TwigBridge] Align isGrantedForUser on isGranted with falsy $field --- Extension/SecurityExtension.php | 10 +- Tests/Extension/SecurityExtensionTest.php | 108 ++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 Tests/Extension/SecurityExtensionTest.php diff --git a/Extension/SecurityExtension.php b/Extension/SecurityExtension.php index 9bf346ca..d0193730 100644 --- a/Extension/SecurityExtension.php +++ b/Extension/SecurityExtension.php @@ -41,6 +41,10 @@ public function isGranted(mixed $role, mixed $object = null, ?string $field = nu } if (null !== $field) { + if (!class_exists(FieldVote::class)) { + throw new \LogicException('Passing a $field to the "is_granted()" function requires symfony/acl. Try running "composer require symfony/acl-bundle" if you need field-level access control.'); + } + $object = new FieldVote($object, $field); } @@ -57,7 +61,11 @@ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $s throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', UserAuthorizationCheckerInterface::class, __METHOD__)); } - if ($field) { + if (null !== $field) { + if (!class_exists(FieldVote::class)) { + throw new \LogicException('Passing a $field to the "is_granted_for_user()" function requires symfony/acl. Try running "composer require symfony/acl-bundle" if you need field-level access control.'); + } + $subject = new FieldVote($subject, $field); } diff --git a/Tests/Extension/SecurityExtensionTest.php b/Tests/Extension/SecurityExtensionTest.php new file mode 100644 index 00000000..2afa868f --- /dev/null +++ b/Tests/Extension/SecurityExtensionTest.php @@ -0,0 +1,108 @@ + + * + * 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\TestCase; +use Symfony\Bridge\PhpUnit\ClassExistsMock; +use Symfony\Bridge\Twig\Extension\SecurityExtension; +use Symfony\Component\Security\Acl\Voter\FieldVote; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +class SecurityExtensionTest extends TestCase +{ + /** + * @dataProvider provideObjectFieldAclCases + */ + public function testIsGrantedCreatesFieldVoteObjectWhenFieldNotNull($object, $field, $expectedSubject) + { + $securityChecker = $this->createMock(AuthorizationCheckerInterface::class); + $securityChecker + ->expects($this->once()) + ->method('isGranted') + ->with('ROLE', $expectedSubject) + ->willReturn(true); + + $securityExtension = new SecurityExtension($securityChecker); + $this->assertTrue($securityExtension->isGranted('ROLE', $object, $field)); + } + + public function testIsGrantedThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist() + { + if (!class_exists(UserAuthorizationCheckerInterface::class)) { + $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); + } + + $securityChecker = $this->createMock(AuthorizationCheckerInterface::class); + + ClassExistsMock::register(SecurityExtension::class); + ClassExistsMock::withMockedClasses([FieldVote::class => false]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessageMatches('Passing a $field to the "is_granted()" function requires symfony/acl.'); + + $securityExtension = new SecurityExtension($securityChecker); + $securityExtension->isGranted('ROLE', 'object', 'bar'); + } + + /** + * @dataProvider provideObjectFieldAclCases + */ + public function testIsGrantedForUserCreatesFieldVoteObjectWhenFieldNotNull($object, $field, $expectedSubject) + { + if (!class_exists(UserAuthorizationCheckerInterface::class)) { + $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); + } + + $user = $this->createMock(UserInterface::class); + $userSecurityChecker = $this->createMock(UserAuthorizationCheckerInterface::class); + $userSecurityChecker + ->expects($this->once()) + ->method('isGrantedForUser') + ->with($user, 'ROLE', $expectedSubject) + ->willReturn(true); + + $securityExtension = new SecurityExtension(null, null, $userSecurityChecker); + $this->assertTrue($securityExtension->isGrantedForUser($user, 'ROLE', $object, $field)); + } + + public function testIsGrantedForUserThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist() + { + if (!class_exists(UserAuthorizationCheckerInterface::class)) { + $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); + } + + $securityChecker = $this->createMock(UserAuthorizationCheckerInterface::class); + + ClassExistsMock::register(SecurityExtension::class); + ClassExistsMock::withMockedClasses([FieldVote::class => false]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessageMatches('Passing a $field to the "is_granted_for_user()" function requires symfony/acl.'); + + $securityExtension = new SecurityExtension(null, null, $securityChecker); + $securityExtension->isGrantedForUser($this->createMock(UserInterface::class), 'object', 'bar'); + } + + public static function provideObjectFieldAclCases() + { + return [ + [null, null, null], + ['object', null, 'object'], + ['object', false, new FieldVote('object', false)], + ['object', 0, new FieldVote('object', 0)], + ['object', '', new FieldVote('object', '')], + ['object', 'field', new FieldVote('object', 'field')], + ]; + } +} From 94561b624ef4b8670c50090e1289a643920470e3 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 14 Feb 2025 16:28:14 +0100 Subject: [PATCH 04/20] [TwigBridge] Remove legacy code --- TokenParser/DumpTokenParser.php | 4 +--- TokenParser/FormThemeTokenParser.php | 10 +++------- TokenParser/StopwatchTokenParser.php | 4 +--- TokenParser/TransDefaultDomainTokenParser.php | 4 +--- TokenParser/TransTokenParser.php | 11 ++++------- composer.json | 2 +- 6 files changed, 11 insertions(+), 24 deletions(-) diff --git a/TokenParser/DumpTokenParser.php b/TokenParser/DumpTokenParser.php index 9c12dc23..1fdfb172 100644 --- a/TokenParser/DumpTokenParser.php +++ b/TokenParser/DumpTokenParser.php @@ -35,9 +35,7 @@ public function parse(Token $token): Node { $values = null; if (!$this->parser->getStream()->test(Token::BLOCK_END_TYPE)) { - $values = method_exists($this->parser, 'parseExpression') ? - $this->parseMultitargetExpression() : - $this->parser->getExpressionParser()->parseMultitargetExpression(); + $values = $this->parseMultitargetExpression(); } $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); diff --git a/TokenParser/FormThemeTokenParser.php b/TokenParser/FormThemeTokenParser.php index 0988eae5..347634bd 100644 --- a/TokenParser/FormThemeTokenParser.php +++ b/TokenParser/FormThemeTokenParser.php @@ -29,16 +29,12 @@ public function parse(Token $token): Node $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $parseExpression = method_exists($this->parser, 'parseExpression') - ? $this->parser->parseExpression(...) - : $this->parser->getExpressionParser()->parseExpression(...); - - $form = $parseExpression(); + $form = $this->parser->parseExpression(); $only = false; if ($this->parser->getStream()->test(Token::NAME_TYPE, 'with')) { $this->parser->getStream()->next(); - $resources = $parseExpression(); + $resources = $this->parser->parseExpression(); if ($this->parser->getStream()->nextIf(Token::NAME_TYPE, 'only')) { $only = true; @@ -46,7 +42,7 @@ public function parse(Token $token): Node } else { $resources = new ArrayExpression([], $stream->getCurrent()->getLine()); do { - $resources->addElement($parseExpression()); + $resources->addElement($this->parser->parseExpression()); } while (!$stream->test(Token::BLOCK_END_TYPE)); } diff --git a/TokenParser/StopwatchTokenParser.php b/TokenParser/StopwatchTokenParser.php index d77cbbf4..b57dcf48 100644 --- a/TokenParser/StopwatchTokenParser.php +++ b/TokenParser/StopwatchTokenParser.php @@ -36,9 +36,7 @@ public function parse(Token $token): Node $stream = $this->parser->getStream(); // {% stopwatch 'bar' %} - $name = method_exists($this->parser, 'parseExpression') ? - $this->parser->parseExpression() : - $this->parser->getExpressionParser()->parseExpression(); + $name = $this->parser->parseExpression(); $stream->expect(Token::BLOCK_END_TYPE); diff --git a/TokenParser/TransDefaultDomainTokenParser.php b/TokenParser/TransDefaultDomainTokenParser.php index a64a2332..edaad0d2 100644 --- a/TokenParser/TransDefaultDomainTokenParser.php +++ b/TokenParser/TransDefaultDomainTokenParser.php @@ -25,9 +25,7 @@ final class TransDefaultDomainTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $expr = method_exists($this->parser, 'parseExpression') ? - $this->parser->parseExpression() : - $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); diff --git a/TokenParser/TransTokenParser.php b/TokenParser/TransTokenParser.php index f522356b..d4353742 100644 --- a/TokenParser/TransTokenParser.php +++ b/TokenParser/TransTokenParser.php @@ -36,33 +36,30 @@ public function parse(Token $token): Node $vars = new ArrayExpression([], $lineno); $domain = null; $locale = null; - $parseExpression = method_exists($this->parser, 'parseExpression') - ? $this->parser->parseExpression(...) - : $this->parser->getExpressionParser()->parseExpression(...); if (!$stream->test(Token::BLOCK_END_TYPE)) { if ($stream->test('count')) { // {% trans count 5 %} $stream->next(); - $count = $parseExpression(); + $count = $this->parser->parseExpression(); } if ($stream->test('with')) { // {% trans with vars %} $stream->next(); - $vars = $parseExpression(); + $vars = $this->parser->parseExpression(); } if ($stream->test('from')) { // {% trans from "messages" %} $stream->next(); - $domain = $parseExpression(); + $domain = $this->parser->parseExpression(); } if ($stream->test('into')) { // {% trans into "fr" %} $stream->next(); - $locale = $parseExpression(); + $locale = $this->parser->parseExpression(); } elseif (!$stream->test(Token::BLOCK_END_TYPE)) { throw new SyntaxError('Unexpected token. Twig was looking for the "with", "from", or "into" keyword.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); } diff --git a/composer.json b/composer.json index ca751c3f..24b8210b 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/translation-contracts": "^2.5|^3", - "twig/twig": "^3.12" + "twig/twig": "^3.21" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", From 46c19c776dd736a9e048f7a725419e5f72dee4d9 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 27 Aug 2024 19:49:01 +0200 Subject: [PATCH 05/20] [Security] Add ability for voters to explain their vote --- Extension/SecurityExtension.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Extension/SecurityExtension.php b/Extension/SecurityExtension.php index d0193730..6bd8e764 100644 --- a/Extension/SecurityExtension.php +++ b/Extension/SecurityExtension.php @@ -12,6 +12,7 @@ namespace Symfony\Bridge\Twig\Extension; use Symfony\Component\Security\Acl\Voter\FieldVote; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; @@ -34,7 +35,7 @@ public function __construct( ) { } - public function isGranted(mixed $role, mixed $object = null, ?string $field = null): bool + public function isGranted(mixed $role, mixed $object = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool { if (null === $this->securityChecker) { return false; @@ -49,13 +50,13 @@ public function isGranted(mixed $role, mixed $object = null, ?string $field = nu } try { - return $this->securityChecker->isGranted($role, $object); + return $this->securityChecker->isGranted($role, $object, $accessDecision); } catch (AuthenticationCredentialsNotFoundException) { return false; } } - public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null): bool + public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool { if (!$this->userSecurityChecker) { throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', UserAuthorizationCheckerInterface::class, __METHOD__)); @@ -69,7 +70,11 @@ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $s $subject = new FieldVote($subject, $field); } - return $this->userSecurityChecker->isGrantedForUser($user, $attribute, $subject); + try { + return $this->userSecurityChecker->isGrantedForUser($user, $attribute, $subject, $accessDecision); + } catch (AuthenticationCredentialsNotFoundException) { + return false; + } } public function getImpersonateExitUrl(?string $exitTo = null): string From 169497f90e99075c03667fd3604bbcb5ebb4151f Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 17 Feb 2025 16:31:27 +0100 Subject: [PATCH 06/20] [Security] Improve DX of recent additions --- Extension/SecurityExtension.php | 13 ++-- Tests/Extension/SecurityExtensionTest.php | 87 ++++++++++++++++------- 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/Extension/SecurityExtension.php b/Extension/SecurityExtension.php index 6bd8e764..e0bb2425 100644 --- a/Extension/SecurityExtension.php +++ b/Extension/SecurityExtension.php @@ -31,7 +31,6 @@ final class SecurityExtension extends AbstractExtension public function __construct( private ?AuthorizationCheckerInterface $securityChecker = null, private ?ImpersonateUrlGenerator $impersonateUrlGenerator = null, - private ?UserAuthorizationCheckerInterface $userSecurityChecker = null, ) { } @@ -58,8 +57,12 @@ public function isGranted(mixed $role, mixed $object = null, ?string $field = nu public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool { - if (!$this->userSecurityChecker) { - throw new \LogicException(\sprintf('An instance of "%s" must be provided to use "%s()".', UserAuthorizationCheckerInterface::class, __METHOD__)); + if (null === $this->securityChecker) { + return false; + } + + if (!$this->securityChecker instanceof UserAuthorizationCheckerInterface) { + throw new \LogicException(\sprintf('You cannot use "%s()" if the authorization checker doesn\'t implement "%s".%s', __METHOD__, UserAuthorizationCheckerInterface::class, interface_exists(UserAuthorizationCheckerInterface::class) ? ' Try upgrading the "symfony/security-core" package to v7.3 minimum.' : '')); } if (null !== $field) { @@ -71,7 +74,7 @@ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $s } try { - return $this->userSecurityChecker->isGrantedForUser($user, $attribute, $subject, $accessDecision); + return $this->securityChecker->isGrantedForUser($user, $attribute, $subject, $accessDecision); } catch (AuthenticationCredentialsNotFoundException) { return false; } @@ -123,7 +126,7 @@ public function getFunctions(): array new TwigFunction('impersonation_path', $this->getImpersonatePath(...)), ]; - if ($this->userSecurityChecker) { + if ($this->securityChecker instanceof UserAuthorizationCheckerInterface) { $functions[] = new TwigFunction('is_granted_for_user', $this->isGrantedForUser(...)); } diff --git a/Tests/Extension/SecurityExtensionTest.php b/Tests/Extension/SecurityExtensionTest.php index 2afa868f..e0ca4dcb 100644 --- a/Tests/Extension/SecurityExtensionTest.php +++ b/Tests/Extension/SecurityExtensionTest.php @@ -15,12 +15,23 @@ use Symfony\Bridge\PhpUnit\ClassExistsMock; use Symfony\Bridge\Twig\Extension\SecurityExtension; use Symfony\Component\Security\Acl\Voter\FieldVote; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; class SecurityExtensionTest extends TestCase { + public static function setUpBeforeClass(): void + { + ClassExistsMock::register(SecurityExtension::class); + } + + protected function tearDown(): void + { + ClassExistsMock::withMockedClasses([FieldVote::class => true]); + } + /** * @dataProvider provideObjectFieldAclCases */ @@ -39,17 +50,16 @@ public function testIsGrantedCreatesFieldVoteObjectWhenFieldNotNull($object, $fi public function testIsGrantedThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist() { - if (!class_exists(UserAuthorizationCheckerInterface::class)) { + if (!interface_exists(UserAuthorizationCheckerInterface::class)) { $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); } $securityChecker = $this->createMock(AuthorizationCheckerInterface::class); - ClassExistsMock::register(SecurityExtension::class); ClassExistsMock::withMockedClasses([FieldVote::class => false]); $this->expectException(\LogicException::class); - $this->expectExceptionMessageMatches('Passing a $field to the "is_granted()" function requires symfony/acl.'); + $this->expectExceptionMessage('Passing a $field to the "is_granted()" function requires symfony/acl.'); $securityExtension = new SecurityExtension($securityChecker); $securityExtension->isGranted('ROLE', 'object', 'bar'); @@ -60,49 +70,74 @@ public function testIsGrantedThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist */ public function testIsGrantedForUserCreatesFieldVoteObjectWhenFieldNotNull($object, $field, $expectedSubject) { - if (!class_exists(UserAuthorizationCheckerInterface::class)) { + if (!interface_exists(UserAuthorizationCheckerInterface::class)) { $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); } $user = $this->createMock(UserInterface::class); - $userSecurityChecker = $this->createMock(UserAuthorizationCheckerInterface::class); - $userSecurityChecker - ->expects($this->once()) - ->method('isGrantedForUser') - ->with($user, 'ROLE', $expectedSubject) - ->willReturn(true); + $securityChecker = $this->createMockAuthorizationChecker(); - $securityExtension = new SecurityExtension(null, null, $userSecurityChecker); + $securityExtension = new SecurityExtension($securityChecker); $this->assertTrue($securityExtension->isGrantedForUser($user, 'ROLE', $object, $field)); + $this->assertSame($user, $securityChecker->user); + $this->assertSame('ROLE', $securityChecker->attribute); + + if (null === $field) { + $this->assertSame($object, $securityChecker->subject); + } else { + $this->assertEquals($expectedSubject, $securityChecker->subject); + } + } + + public static function provideObjectFieldAclCases() + { + return [ + [null, null, null], + ['object', null, 'object'], + ['object', false, new FieldVote('object', false)], + ['object', 0, new FieldVote('object', 0)], + ['object', '', new FieldVote('object', '')], + ['object', 'field', new FieldVote('object', 'field')], + ]; } public function testIsGrantedForUserThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist() { - if (!class_exists(UserAuthorizationCheckerInterface::class)) { + if (!interface_exists(UserAuthorizationCheckerInterface::class)) { $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); } - $securityChecker = $this->createMock(UserAuthorizationCheckerInterface::class); + $securityChecker = $this->createMockAuthorizationChecker(); - ClassExistsMock::register(SecurityExtension::class); ClassExistsMock::withMockedClasses([FieldVote::class => false]); $this->expectException(\LogicException::class); - $this->expectExceptionMessageMatches('Passing a $field to the "is_granted_for_user()" function requires symfony/acl.'); + $this->expectExceptionMessage('Passing a $field to the "is_granted_for_user()" function requires symfony/acl.'); - $securityExtension = new SecurityExtension(null, null, $securityChecker); - $securityExtension->isGrantedForUser($this->createMock(UserInterface::class), 'object', 'bar'); + $securityExtension = new SecurityExtension($securityChecker); + $securityExtension->isGrantedForUser($this->createMock(UserInterface::class), 'ROLE', 'object', 'bar'); } - public static function provideObjectFieldAclCases() + private function createMockAuthorizationChecker(): AuthorizationCheckerInterface&UserAuthorizationCheckerInterface { - return [ - [null, null, null], - ['object', null, 'object'], - ['object', false, new FieldVote('object', false)], - ['object', 0, new FieldVote('object', 0)], - ['object', '', new FieldVote('object', '')], - ['object', 'field', new FieldVote('object', 'field')], - ]; + return new class implements AuthorizationCheckerInterface, UserAuthorizationCheckerInterface { + public UserInterface $user; + public mixed $attribute; + public mixed $subject; + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + throw new \BadMethodCallException('This method should not be called.'); + } + + public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + $this->user = $user; + $this->attribute = $attribute; + $this->subject = $subject; + + return true; + } + }; } } From 11d8b9455533b1394baf3a2895ed92c1e676ee94 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Sun, 2 Mar 2025 13:03:26 +0100 Subject: [PATCH 07/20] remove no longer needed BC layers --- .../TranslationDefaultDomainNodeVisitor.php | 15 +- Tests/Node/SearchAndRenderBlockNodeTest.php | 194 +++++------------- Tests/NodeVisitor/TwigNodeProvider.php | 19 +- .../TokenParser/FormThemeTokenParserTest.php | 13 +- 4 files changed, 68 insertions(+), 173 deletions(-) diff --git a/NodeVisitor/TranslationDefaultDomainNodeVisitor.php b/NodeVisitor/TranslationDefaultDomainNodeVisitor.php index 3b8196fa..938d6439 100644 --- a/NodeVisitor/TranslationDefaultDomainNodeVisitor.php +++ b/NodeVisitor/TranslationDefaultDomainNodeVisitor.php @@ -17,10 +17,8 @@ use Twig\Node\BlockNode; use Twig\Node\EmptyNode; use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\AssignNameExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\AssignContextVariable; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\ModuleNode; @@ -60,17 +58,10 @@ public function enterNode(Node $node, Environment $env): Node $var = '__internal_trans_default_domain'.hash('xxh128', $templateName); - if (class_exists(Nodes::class)) { - $name = new AssignContextVariable($var, $node->getTemplateLine()); - $this->scope->set('domain', new ContextVariable($var, $node->getTemplateLine())); + $name = new AssignContextVariable($var, $node->getTemplateLine()); + $this->scope->set('domain', new ContextVariable($var, $node->getTemplateLine())); - return new SetNode(false, new Nodes([$name]), new Nodes([$node->getNode('expr')]), $node->getTemplateLine()); - } - - $name = new AssignNameExpression($var, $node->getTemplateLine()); - $this->scope->set('domain', new NameExpression($var, $node->getTemplateLine())); - - return new SetNode(false, new Node([$name]), new Node([$node->getNode('expr')]), $node->getTemplateLine()); + return new SetNode(false, new Nodes([$name]), new Nodes([$node->getNode('expr')]), $node->getTemplateLine()); } if (!$this->scope->has('domain')) { diff --git a/Tests/Node/SearchAndRenderBlockNodeTest.php b/Tests/Node/SearchAndRenderBlockNodeTest.php index ab9113ac..668a6a92 100644 --- a/Tests/Node/SearchAndRenderBlockNodeTest.php +++ b/Tests/Node/SearchAndRenderBlockNodeTest.php @@ -20,10 +20,8 @@ use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConditionalExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Ternary\ConditionalTernary; use Twig\Node\Expression\Variable\ContextVariable; -use Twig\Node\Node; use Twig\Node\Nodes; use Twig\TwigFunction; @@ -31,15 +29,9 @@ class SearchAndRenderBlockNodeTest extends TestCase { public function testCompileWidget() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0); @@ -56,23 +48,13 @@ public function testCompileWidget() public function testCompileWidgetWithVariables() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - ], 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - ], 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ArrayExpression([ + new ConstantExpression('foo', 0), + new ConstantExpression('bar', 0), + ], 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0); @@ -89,17 +71,10 @@ public function testCompileWidgetWithVariables() public function testCompileLabelWithLabel() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression('my label', 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression('my label', 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression('my label', 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -116,17 +91,10 @@ public function testCompileLabelWithLabel() public function testCompileLabelWithNullLabel() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression(null, 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression(null, 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression(null, 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -145,17 +113,10 @@ public function testCompileLabelWithNullLabel() public function testCompileLabelWithEmptyStringLabel() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression('', 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression('', 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression('', 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -174,15 +135,9 @@ public function testCompileLabelWithEmptyStringLabel() public function testCompileLabelWithDefaultLabel() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -199,25 +154,14 @@ public function testCompileLabelWithDefaultLabel() public function testCompileLabelWithAttributes() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression(null, 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - ], 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression(null, 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - ], 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression(null, 0), + new ArrayExpression([ + new ConstantExpression('foo', 0), + new ConstantExpression('bar', 0), + ], 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -237,29 +181,16 @@ public function testCompileLabelWithAttributes() public function testCompileLabelWithLabelAndAttributes() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression('value in argument', 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - new ConstantExpression('label', 0), - new ConstantExpression('value in attributes', 0), - ], 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression('value in argument', 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - new ConstantExpression('label', 0), - new ConstantExpression('value in attributes', 0), - ], 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression('value in argument', 0), + new ArrayExpression([ + new ConstantExpression('foo', 0), + new ConstantExpression('bar', 0), + new ConstantExpression('label', 0), + new ConstantExpression('value in attributes', 0), + ], 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -298,11 +229,7 @@ public function testCompileLabelWithLabelThatEvaluatesToNull() ); } - if (class_exists(Nodes::class)) { - $arguments = new Nodes([new ContextVariable('form', 0), $conditional]); - } else { - $arguments = new Node([new NameExpression('form', 0), $conditional]); - } + $arguments = new Nodes([new ContextVariable('form', 0), $conditional]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -345,29 +272,16 @@ public function testCompileLabelWithLabelThatEvaluatesToNullAndAttributes() ); } - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - $conditional, - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - new ConstantExpression('label', 0), - new ConstantExpression('value in attributes', 0), - ], 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - $conditional, - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - new ConstantExpression('label', 0), - new ConstantExpression('value in attributes', 0), - ], 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + $conditional, + new ArrayExpression([ + new ConstantExpression('foo', 0), + new ConstantExpression('bar', 0), + new ConstantExpression('label', 0), + new ConstantExpression('value in attributes', 0), + ], 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); diff --git a/Tests/NodeVisitor/TwigNodeProvider.php b/Tests/NodeVisitor/TwigNodeProvider.php index 64ce92bc..4a0f11b3 100644 --- a/Tests/NodeVisitor/TwigNodeProvider.php +++ b/Tests/NodeVisitor/TwigNodeProvider.php @@ -19,7 +19,6 @@ use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; use Twig\Node\ModuleNode; -use Twig\Node\Node; use Twig\Node\Nodes; use Twig\Source; use Twig\TwigFilter; @@ -28,15 +27,13 @@ class TwigNodeProvider { public static function getModule($content) { - $emptyNodeExists = class_exists(EmptyNode::class); - return new ModuleNode( new BodyNode([new ConstantExpression($content, 0)]), null, - $emptyNodeExists ? new EmptyNode() : new ArrayExpression([], 0), - $emptyNodeExists ? new EmptyNode() : new ArrayExpression([], 0), - $emptyNodeExists ? new EmptyNode() : new ArrayExpression([], 0), - $emptyNodeExists ? new EmptyNode() : null, + new EmptyNode(), + new EmptyNode(), + new EmptyNode(), + new EmptyNode(), new Source('', '') ); } @@ -50,16 +47,10 @@ public static function getTransFilter($message, $domain = null, $arguments = nul ] : []; } - if (class_exists(Nodes::class)) { - $args = new Nodes($arguments); - } else { - $args = new Node($arguments); - } - return new FilterExpression( new ConstantExpression($message, 0), new TwigFilter('trans'), - $args, + new Nodes($arguments), 0 ); } diff --git a/Tests/TokenParser/FormThemeTokenParserTest.php b/Tests/TokenParser/FormThemeTokenParserTest.php index 4e8209ef..0c4bcdf6 100644 --- a/Tests/TokenParser/FormThemeTokenParserTest.php +++ b/Tests/TokenParser/FormThemeTokenParserTest.php @@ -18,7 +18,6 @@ use Twig\Loader\LoaderInterface; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Parser; use Twig\Source; @@ -48,7 +47,7 @@ public static function getTestsForFormTheme() [ '{% form_theme form "tpl1" %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), @@ -59,7 +58,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form "tpl1" "tpl2" %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), @@ -72,7 +71,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form with "tpl1" %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ConstantExpression('tpl1', 1), 1 ), @@ -80,7 +79,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form with ["tpl1"] %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), @@ -91,7 +90,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form with ["tpl1", "tpl2"] %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), @@ -104,7 +103,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form with ["tpl1", "tpl2"] only %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), From 65aba6833f578ae76aab9a53c41802dfe8b08f6c Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 2 Mar 2025 13:32:44 +0100 Subject: [PATCH 08/20] =?UTF-8?q?[Twig]=C2=A0Remove=20support=20for=20Twig?= =?UTF-8?q?=202.x=20extra=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 24b8210b..19ab66ae 100644 --- a/composer.json +++ b/composer.json @@ -51,9 +51,9 @@ "symfony/expression-language": "^6.4|^7.0", "symfony/web-link": "^6.4|^7.0", "symfony/workflow": "^6.4|^7.0", - "twig/cssinliner-extra": "^2.12|^3", - "twig/inky-extra": "^2.12|^3", - "twig/markdown-extra": "^2.12|^3" + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" }, "conflict": { "phpdocumentor/reflection-docblock": "<3.2.2", From e7f3db6cbd4a35c8d254f2fe66c590d5238e10c4 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Sun, 2 Mar 2025 13:03:26 +0100 Subject: [PATCH 09/20] remove remaining BC layers --- Node/TransNode.php | 3 +- Tests/Command/LintCommandTest.php | 6 +- Tests/Node/DumpNodeTest.php | 30 +++------ Tests/Node/FormThemeTest.php | 21 ++----- Tests/Node/SearchAndRenderBlockNodeTest.php | 61 ++++++------------- Tests/Node/TransNodeTest.php | 3 +- .../TranslationNodeVisitorTest.php | 16 ++--- TokenParser/DumpTokenParser.php | 2 +- TokenParser/StopwatchTokenParser.php | 3 +- TokenParser/TransDefaultDomainTokenParser.php | 2 +- 10 files changed, 41 insertions(+), 106 deletions(-) diff --git a/Node/TransNode.php b/Node/TransNode.php index 4064491f..c675db56 100644 --- a/Node/TransNode.php +++ b/Node/TransNode.php @@ -16,7 +16,6 @@ use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; use Twig\Node\TextNode; @@ -120,7 +119,7 @@ private function compileString(Node $body, ArrayExpression $vars, bool $ignoreSt if ('count' === $var && $this->hasNode('count')) { $vars->addElement($this->getNode('count'), $key); } else { - $varExpr = class_exists(ContextVariable::class) ? new ContextVariable($var, $body->getTemplateLine()) : new NameExpression($var, $body->getTemplateLine()); + $varExpr = new ContextVariable($var, $body->getTemplateLine()); $varExpr->setAttribute('ignore_strict_check', $ignoreStrictCheck); $vars->addElement($varExpr, $key); } diff --git a/Tests/Command/LintCommandTest.php b/Tests/Command/LintCommandTest.php index 3b0b453d..9fa3646e 100644 --- a/Tests/Command/LintCommandTest.php +++ b/Tests/Command/LintCommandTest.php @@ -160,11 +160,7 @@ private function createCommandTester(): CommandTester private function createCommand(): Command { $environment = new Environment(new FilesystemLoader(\dirname(__DIR__).'/Fixtures/templates/')); - if (class_exists(DeprecatedCallableInfo::class)) { - $options = ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')]; - } else { - $options = ['deprecated' => true]; - } + $options = ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')]; $environment->addFilter(new TwigFilter('deprecated_filter', fn ($v) => $v, $options)); $command = new LintCommand($environment); diff --git a/Tests/Node/DumpNodeTest.php b/Tests/Node/DumpNodeTest.php index 6d584c89..33297ae4 100644 --- a/Tests/Node/DumpNodeTest.php +++ b/Tests/Node/DumpNodeTest.php @@ -16,9 +16,7 @@ use Twig\Compiler; use Twig\Environment; use Twig\Loader\LoaderInterface; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; -use Twig\Node\Node; use Twig\Node\Nodes; class DumpNodeTest extends TestCase @@ -73,15 +71,9 @@ public function testIndented() public function testOneVar() { - if (class_exists(Nodes::class)) { - $vars = new Nodes([ - new ContextVariable('foo', 7), - ]); - } else { - $vars = new Node([ - new NameExpression('foo', 7), - ]); - } + $vars = new Nodes([ + new ContextVariable('foo', 7), + ]); $node = new DumpNode('bar', $vars, 7); @@ -103,18 +95,10 @@ public function testOneVar() public function testMultiVars() { - if (class_exists(Nodes::class)) { - $vars = new Nodes([ - new ContextVariable('foo', 7), - new ContextVariable('bar', 7), - ]); - } else { - $vars = new Node([ - new NameExpression('foo', 7), - new NameExpression('bar', 7), - ]); - } - + $vars = new Nodes([ + new ContextVariable('foo', 7), + new ContextVariable('bar', 7), + ]); $node = new DumpNode('bar', $vars, 7); $env = new Environment($this->createMock(LoaderInterface::class)); diff --git a/Tests/Node/FormThemeTest.php b/Tests/Node/FormThemeTest.php index f98b93da..e581ff28 100644 --- a/Tests/Node/FormThemeTest.php +++ b/Tests/Node/FormThemeTest.php @@ -21,9 +21,7 @@ use Twig\Loader\LoaderInterface; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; -use Twig\Node\Node; use Twig\Node\Nodes; class FormThemeTest extends TestCase @@ -32,18 +30,11 @@ class FormThemeTest extends TestCase public function testConstructor() { - $form = class_exists(ContextVariable::class) ? new ContextVariable('form', 0) : new NameExpression('form', 0); - if (class_exists(Nodes::class)) { - $resources = new Nodes([ - new ConstantExpression('tpl1', 0), - new ConstantExpression('tpl2', 0), - ]); - } else { - $resources = new Node([ - new ConstantExpression('tpl1', 0), - new ConstantExpression('tpl2', 0), - ]); - } + $form = new ContextVariable('form', 0); + $resources = new Nodes([ + new ConstantExpression('tpl1', 0), + new ConstantExpression('tpl2', 0), + ]); $node = new FormThemeNode($form, $resources, 0); @@ -54,7 +45,7 @@ public function testConstructor() public function testCompile() { - $form = class_exists(ContextVariable::class) ? new ContextVariable('form', 0) : new NameExpression('form', 0); + $form = new ContextVariable('form', 0); $resources = new ArrayExpression([ new ConstantExpression(1, 0), new ConstantExpression('tpl1', 0), diff --git a/Tests/Node/SearchAndRenderBlockNodeTest.php b/Tests/Node/SearchAndRenderBlockNodeTest.php index 668a6a92..0c0afbfa 100644 --- a/Tests/Node/SearchAndRenderBlockNodeTest.php +++ b/Tests/Node/SearchAndRenderBlockNodeTest.php @@ -18,7 +18,6 @@ use Twig\Extension\CoreExtension; use Twig\Loader\LoaderInterface; use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\ConditionalExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\Ternary\ConditionalTernary; use Twig\Node\Expression\Variable\ContextVariable; @@ -207,27 +206,15 @@ public function testCompileLabelWithLabelAndAttributes() public function testCompileLabelWithLabelThatEvaluatesToNull() { - if (class_exists(ConditionalTernary::class)) { - $conditional = new ConditionalTernary( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ); - } else { - $conditional = new ConditionalExpression( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ); - } + $conditional = new ConditionalTernary( + // if + new ConstantExpression(true, 0), + // then + new ConstantExpression(null, 0), + // else + new ConstantExpression(null, 0), + 0 + ); $arguments = new Nodes([new ContextVariable('form', 0), $conditional]); @@ -250,27 +237,15 @@ public function testCompileLabelWithLabelThatEvaluatesToNull() public function testCompileLabelWithLabelThatEvaluatesToNullAndAttributes() { - if (class_exists(ConditionalTernary::class)) { - $conditional = new ConditionalTernary( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ); - } else { - $conditional = new ConditionalExpression( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ); - } + $conditional = new ConditionalTernary( + // if + new ConstantExpression(true, 0), + // then + new ConstantExpression(null, 0), + // else + new ConstantExpression(null, 0), + 0 + ); $arguments = new Nodes([ new ContextVariable('form', 0), diff --git a/Tests/Node/TransNodeTest.php b/Tests/Node/TransNodeTest.php index 24fa4d25..5a55a0c8 100644 --- a/Tests/Node/TransNodeTest.php +++ b/Tests/Node/TransNodeTest.php @@ -16,7 +16,6 @@ use Twig\Compiler; use Twig\Environment; use Twig\Loader\LoaderInterface; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\TextNode; @@ -28,7 +27,7 @@ class TransNodeTest extends TestCase public function testCompileStrict() { $body = new TextNode('trans %var%', 0); - $vars = class_exists(ContextVariable::class) ? new ContextVariable('foo', 0) : new NameExpression('foo', 0); + $vars = new ContextVariable('foo', 0); $node = new TransNode($body, null, null, $vars); $env = new Environment($this->createMock(LoaderInterface::class), ['strict_variables' => true]); diff --git a/Tests/NodeVisitor/TranslationNodeVisitorTest.php b/Tests/NodeVisitor/TranslationNodeVisitorTest.php index 2d52c4ea..fc48beb6 100644 --- a/Tests/NodeVisitor/TranslationNodeVisitorTest.php +++ b/Tests/NodeVisitor/TranslationNodeVisitorTest.php @@ -18,7 +18,6 @@ use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; use Twig\Node\Nodes; @@ -41,17 +40,10 @@ public function testMessageExtractionWithInvalidDomainNode() { $message = 'new key'; - if (class_exists(Nodes::class)) { - $n = new Nodes([ - new ArrayExpression([], 0), - new ContextVariable('variable', 0), - ]); - } else { - $n = new Node([ - new ArrayExpression([], 0), - new NameExpression('variable', 0), - ]); - } + $n = new Nodes([ + new ArrayExpression([], 0), + new ContextVariable('variable', 0), + ]); $node = new FilterExpression( new ConstantExpression($message, 0), diff --git a/TokenParser/DumpTokenParser.php b/TokenParser/DumpTokenParser.php index 1fdfb172..457edece 100644 --- a/TokenParser/DumpTokenParser.php +++ b/TokenParser/DumpTokenParser.php @@ -39,7 +39,7 @@ public function parse(Token $token): Node } $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); - return new DumpNode(class_exists(LocalVariable::class) ? new LocalVariable(null, $token->getLine()) : $this->parser->getVarName(), $values, $token->getLine(), $this->getTag()); + return new DumpNode(new LocalVariable(null, $token->getLine()), $values, $token->getLine()); } private function parseMultitargetExpression(): Node diff --git a/TokenParser/StopwatchTokenParser.php b/TokenParser/StopwatchTokenParser.php index b57dcf48..ea0382bc 100644 --- a/TokenParser/StopwatchTokenParser.php +++ b/TokenParser/StopwatchTokenParser.php @@ -12,7 +12,6 @@ namespace Symfony\Bridge\Twig\TokenParser; use Symfony\Bridge\Twig\Node\StopwatchNode; -use Twig\Node\Expression\AssignNameExpression; use Twig\Node\Expression\Variable\LocalVariable; use Twig\Node\Node; use Twig\Token; @@ -45,7 +44,7 @@ public function parse(Token $token): Node $stream->expect(Token::BLOCK_END_TYPE); if ($this->stopwatchIsAvailable) { - return new StopwatchNode($name, $body, class_exists(LocalVariable::class) ? new LocalVariable(null, $token->getLine()) : new AssignNameExpression($this->parser->getVarName(), $token->getLine()), $lineno, $this->getTag()); + return new StopwatchNode($name, $body, new LocalVariable(null, $token->getLine()), $lineno); } return $body; diff --git a/TokenParser/TransDefaultDomainTokenParser.php b/TokenParser/TransDefaultDomainTokenParser.php index edaad0d2..b9eb5f51 100644 --- a/TokenParser/TransDefaultDomainTokenParser.php +++ b/TokenParser/TransDefaultDomainTokenParser.php @@ -29,7 +29,7 @@ public function parse(Token $token): Node $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); - return new TransDefaultDomainNode($expr, $token->getLine(), $this->getTag()); + return new TransDefaultDomainNode($expr, $token->getLine()); } public function getTag(): string From 4be9852f1f6fe6f4c6e6f2340c09772a84453384 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Sun, 2 Mar 2025 16:03:52 +0100 Subject: [PATCH 10/20] replace assertEmpty() with stricter assertions --- Tests/Extension/AbstractLayoutTestCase.php | 4 ++-- Tests/Extension/DumpExtensionTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/Extension/AbstractLayoutTestCase.php b/Tests/Extension/AbstractLayoutTestCase.php index 5a541d7b..2f7410d1 100644 --- a/Tests/Extension/AbstractLayoutTestCase.php +++ b/Tests/Extension/AbstractLayoutTestCase.php @@ -1592,7 +1592,7 @@ public function testDateErrorBubbling() $form->get('date')->addError(new FormError('[trans]Error![/trans]')); $view = $form->createView(); - $this->assertEmpty($this->renderErrors($view)); + $this->assertSame('', $this->renderErrors($view)); $this->assertNotEmpty($this->renderErrors($view['date'])); } @@ -2213,7 +2213,7 @@ public function testTimeErrorBubbling() $form->get('time')->addError(new FormError('[trans]Error![/trans]')); $view = $form->createView(); - $this->assertEmpty($this->renderErrors($view)); + $this->assertSame('', $this->renderErrors($view)); $this->assertNotEmpty($this->renderErrors($view['time'])); } diff --git a/Tests/Extension/DumpExtensionTest.php b/Tests/Extension/DumpExtensionTest.php index 8fe455e5..01817ce5 100644 --- a/Tests/Extension/DumpExtensionTest.php +++ b/Tests/Extension/DumpExtensionTest.php @@ -142,6 +142,6 @@ public function testCustomDumper() 'Custom dumper should be used to dump data.' ); - $this->assertEmpty($output, 'Dumper output should be ignored.'); + $this->assertSame('', $output, 'Dumper output should be ignored.'); } } From 1e3c91d0884fbd5809345d4915bf90036c4f4aed Mon Sep 17 00:00:00 2001 From: RichardGuilland Date: Sun, 23 Feb 2025 12:12:18 +0100 Subject: [PATCH 11/20] [TwigBridge] Add Twig `field_id()` form helper --- CHANGELOG.md | 1 + Extension/FormExtension.php | 6 ++++++ Tests/Extension/FormExtensionFieldHelpersTest.php | 6 ++++++ 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 156b29ab..9695fe09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add `is_granted_for_user()` Twig function + * Add `field_id()` Twig form helper function 7.2 --- diff --git a/Extension/FormExtension.php b/Extension/FormExtension.php index ec552d7c..f1ae7068 100644 --- a/Extension/FormExtension.php +++ b/Extension/FormExtension.php @@ -62,6 +62,7 @@ public function getFunctions(): array new TwigFunction('csrf_token', [FormRenderer::class, 'renderCsrfToken']), new TwigFunction('form_parent', 'Symfony\Bridge\Twig\Extension\twig_get_form_parent'), new TwigFunction('field_name', $this->getFieldName(...)), + new TwigFunction('field_id', $this->getFieldId(...)), new TwigFunction('field_value', $this->getFieldValue(...)), new TwigFunction('field_label', $this->getFieldLabel(...)), new TwigFunction('field_help', $this->getFieldHelp(...)), @@ -93,6 +94,11 @@ public function getFieldName(FormView $view): string return $view->vars['full_name']; } + public function getFieldId(FormView $view): string + { + return $view->vars['id']; + } + public function getFieldValue(FormView $view): string|array { return $view->vars['value']; diff --git a/Tests/Extension/FormExtensionFieldHelpersTest.php b/Tests/Extension/FormExtensionFieldHelpersTest.php index efedc871..320b8551 100644 --- a/Tests/Extension/FormExtensionFieldHelpersTest.php +++ b/Tests/Extension/FormExtensionFieldHelpersTest.php @@ -119,6 +119,12 @@ public function testFieldName() $this->assertTrue($this->view->children['username']->isRendered()); } + public function testFieldId() + { + $this->assertSame('register_username', $this->rawExtension->getFieldId($this->view->children['username'])); + $this->assertSame('register_choice_multiple', $this->rawExtension->getFieldId($this->view->children['choice_multiple'])); + } + public function testFieldValue() { $this->assertSame('tgalopin', $this->rawExtension->getFieldValue($this->view->children['username'])); From fe936851e35871c79f4e3a0c2fe6d33118b2d50c Mon Sep 17 00:00:00 2001 From: Tomas Date: Thu, 13 Mar 2025 10:19:53 +0200 Subject: [PATCH 12/20] [Mailer][TwigBridge] Add support for translatable subject --- CHANGELOG.md | 1 + Mime/TemplatedEmail.php | 21 ++++++++++++++++++++- Tests/Mime/TemplatedEmailTest.php | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9695fe09..c34616f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add `is_granted_for_user()` Twig function * Add `field_id()` Twig form helper function + * Add `TemplatedEmail::getTranslatableSubject()` method 7.2 --- diff --git a/Mime/TemplatedEmail.php b/Mime/TemplatedEmail.php index 2d308947..b3b891e4 100644 --- a/Mime/TemplatedEmail.php +++ b/Mime/TemplatedEmail.php @@ -18,11 +18,29 @@ */ class TemplatedEmail extends Email { + private string|\Stringable|null $subject = null; private ?string $htmlTemplate = null; private ?string $textTemplate = null; private ?string $locale = null; private array $context = []; + /** + * @return $this + */ + public function subject(string|\Stringable $subject): static + { + parent::subject($subject); + + $this->subject = $subject; + + return $this; + } + + public function getTranslatableSubject(): string|\Stringable|null + { + return $this->subject; + } + /** * @return $this */ @@ -100,7 +118,7 @@ public function markAsRendered(): void */ public function __serialize(): array { - return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale]; + return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale, $this->subject]; } /** @@ -110,6 +128,7 @@ public function __unserialize(array $data): void { [$this->htmlTemplate, $this->textTemplate, $this->context, $parentData] = $data; $this->locale = $data[4] ?? null; + $this->subject = $data[5] ?? null; parent::__unserialize($parentData); } diff --git a/Tests/Mime/TemplatedEmailTest.php b/Tests/Mime/TemplatedEmailTest.php index f77b3ad4..f36fed89 100644 --- a/Tests/Mime/TemplatedEmailTest.php +++ b/Tests/Mime/TemplatedEmailTest.php @@ -20,7 +20,10 @@ use Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; +use Symfony\Component\Serializer\Normalizer\TranslatableNormalizer; use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Translation\IdentityTranslator; +use Symfony\Component\Translation\TranslatableMessage; class TemplatedEmailTest extends TestCase { @@ -44,6 +47,7 @@ public function testSerialize() ->htmlTemplate('text.html.twig') ->context($context = ['a' => 'b']) ->locale($locale = 'fr_FR') + ->subject($subject = new TranslatableMessage('hello {{ name }}', ['name' => 'John'], 'greetings')) ; $email = unserialize(serialize($email)); @@ -51,12 +55,14 @@ public function testSerialize() $this->assertEquals('text.html.twig', $email->getHtmlTemplate()); $this->assertEquals($context, $email->getContext()); $this->assertEquals($locale, $email->getLocale()); + $this->assertEquals($subject, $email->getTranslatableSubject()); } public function testSymfonySerialize() { // we don't add from/sender to check that validation is not triggered to serialize an email $e = new TemplatedEmail(); + $e->subject(new TranslatableMessage('hello.world')); $e->to('you@example.com'); $e->textTemplate('email.txt.twig'); $e->htmlTemplate('email.html.twig'); @@ -67,6 +73,7 @@ public function testSymfonySerialize() $expectedJson = << Date: Wed, 26 Mar 2025 12:16:31 -0400 Subject: [PATCH 13/20] Revert "[Mailer][TwigBridge] Add support for translatable subject" This reverts commit 97eb49be33232745a793084dcef8f0ef4674e8e6. --- CHANGELOG.md | 1 - Mime/TemplatedEmail.php | 21 +-------------------- Tests/Mime/TemplatedEmailTest.php | 17 ----------------- 3 files changed, 1 insertion(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c34616f3..9695fe09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ CHANGELOG * Add `is_granted_for_user()` Twig function * Add `field_id()` Twig form helper function - * Add `TemplatedEmail::getTranslatableSubject()` method 7.2 --- diff --git a/Mime/TemplatedEmail.php b/Mime/TemplatedEmail.php index b3b891e4..2d308947 100644 --- a/Mime/TemplatedEmail.php +++ b/Mime/TemplatedEmail.php @@ -18,29 +18,11 @@ */ class TemplatedEmail extends Email { - private string|\Stringable|null $subject = null; private ?string $htmlTemplate = null; private ?string $textTemplate = null; private ?string $locale = null; private array $context = []; - /** - * @return $this - */ - public function subject(string|\Stringable $subject): static - { - parent::subject($subject); - - $this->subject = $subject; - - return $this; - } - - public function getTranslatableSubject(): string|\Stringable|null - { - return $this->subject; - } - /** * @return $this */ @@ -118,7 +100,7 @@ public function markAsRendered(): void */ public function __serialize(): array { - return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale, $this->subject]; + return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale]; } /** @@ -128,7 +110,6 @@ public function __unserialize(array $data): void { [$this->htmlTemplate, $this->textTemplate, $this->context, $parentData] = $data; $this->locale = $data[4] ?? null; - $this->subject = $data[5] ?? null; parent::__unserialize($parentData); } diff --git a/Tests/Mime/TemplatedEmailTest.php b/Tests/Mime/TemplatedEmailTest.php index f36fed89..f77b3ad4 100644 --- a/Tests/Mime/TemplatedEmailTest.php +++ b/Tests/Mime/TemplatedEmailTest.php @@ -20,10 +20,7 @@ use Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; -use Symfony\Component\Serializer\Normalizer\TranslatableNormalizer; use Symfony\Component\Serializer\Serializer; -use Symfony\Component\Translation\IdentityTranslator; -use Symfony\Component\Translation\TranslatableMessage; class TemplatedEmailTest extends TestCase { @@ -47,7 +44,6 @@ public function testSerialize() ->htmlTemplate('text.html.twig') ->context($context = ['a' => 'b']) ->locale($locale = 'fr_FR') - ->subject($subject = new TranslatableMessage('hello {{ name }}', ['name' => 'John'], 'greetings')) ; $email = unserialize(serialize($email)); @@ -55,14 +51,12 @@ public function testSerialize() $this->assertEquals('text.html.twig', $email->getHtmlTemplate()); $this->assertEquals($context, $email->getContext()); $this->assertEquals($locale, $email->getLocale()); - $this->assertEquals($subject, $email->getTranslatableSubject()); } public function testSymfonySerialize() { // we don't add from/sender to check that validation is not triggered to serialize an email $e = new TemplatedEmail(); - $e->subject(new TranslatableMessage('hello.world')); $e->to('you@example.com'); $e->textTemplate('email.txt.twig'); $e->htmlTemplate('email.html.twig'); @@ -73,7 +67,6 @@ public function testSymfonySerialize() $expectedJson = << Date: Wed, 26 Mar 2025 12:57:46 -0400 Subject: [PATCH 14/20] code review --- Mime/TemplatedEmail.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mime/TemplatedEmail.php b/Mime/TemplatedEmail.php index 2d308947..68b3913e 100644 --- a/Mime/TemplatedEmail.php +++ b/Mime/TemplatedEmail.php @@ -100,7 +100,7 @@ public function markAsRendered(): void */ public function __serialize(): array { - return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale]; + return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale]; } /** From fb32c5be206cbd399a69de81fd2a616fa8b3dd6b Mon Sep 17 00:00:00 2001 From: Mokhtar Tlili Date: Sun, 10 Nov 2024 18:13:39 +0100 Subject: [PATCH 15/20] Add the twig constraint and its validator --- CHANGELOG.md | 1 + Tests/Validator/Constraints/TwigTest.php | 56 ++++++++ .../Constraints/TwigValidatorTest.php | 126 ++++++++++++++++++ Validator/Constraints/Twig.php | 38 ++++++ Validator/Constraints/TwigValidator.php | 81 +++++++++++ composer.json | 1 + 6 files changed, 303 insertions(+) create mode 100644 Tests/Validator/Constraints/TwigTest.php create mode 100644 Tests/Validator/Constraints/TwigValidatorTest.php create mode 100644 Validator/Constraints/Twig.php create mode 100644 Validator/Constraints/TwigValidator.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9695fe09..d9fe3b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add `is_granted_for_user()` Twig function * Add `field_id()` Twig form helper function + * Add a `Twig` constraint that validates Twig templates 7.2 --- diff --git a/Tests/Validator/Constraints/TwigTest.php b/Tests/Validator/Constraints/TwigTest.php new file mode 100644 index 00000000..cac1b316 --- /dev/null +++ b/Tests/Validator/Constraints/TwigTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Validator\Constraints; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\Validator\Constraints\Twig; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +/** + * @author Mokhtar Tlili + */ +class TwigTest extends TestCase +{ + public function testAttributes() + { + $metadata = new ClassMetadata(TwigDummy::class); + $loader = new AttributeLoader(); + self::assertTrue($loader->loadClassMetadata($metadata)); + + [$bConstraint] = $metadata->properties['b']->getConstraints(); + self::assertSame('myMessage', $bConstraint->message); + self::assertSame(['Default', 'TwigDummy'], $bConstraint->groups); + + [$cConstraint] = $metadata->properties['c']->getConstraints(); + self::assertSame(['my_group'], $cConstraint->groups); + self::assertSame('some attached data', $cConstraint->payload); + + [$dConstraint] = $metadata->properties['d']->getConstraints(); + self::assertFalse($dConstraint->skipDeprecations); + } +} + +class TwigDummy +{ + #[Twig] + private $a; + + #[Twig(message: 'myMessage')] + private $b; + + #[Twig(groups: ['my_group'], payload: 'some attached data')] + private $c; + + #[Twig(skipDeprecations: false)] + private $d; +} diff --git a/Tests/Validator/Constraints/TwigValidatorTest.php b/Tests/Validator/Constraints/TwigValidatorTest.php new file mode 100644 index 00000000..f5c4253e --- /dev/null +++ b/Tests/Validator/Constraints/TwigValidatorTest.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Validator\Constraints; + +use Symfony\Bridge\Twig\Validator\Constraints\Twig; +use Symfony\Bridge\Twig\Validator\Constraints\TwigValidator; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; +use Twig\DeprecatedCallableInfo; +use Twig\Environment; +use Twig\Loader\ArrayLoader; +use Twig\TwigFilter; + +/** + * @author Mokhtar Tlili + */ +class TwigValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): TwigValidator + { + $environment = new Environment(new ArrayLoader()); + $environment->addFilter(new TwigFilter('humanize_filter', fn ($v) => $v)); + if (class_exists(DeprecatedCallableInfo::class)) { + $options = ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')]; + } else { + $options = ['deprecated' => true]; + } + + $environment->addFilter(new TwigFilter('deprecated_filter', fn ($v) => $v, $options)); + + return new TwigValidator($environment); + } + + /** + * @dataProvider getValidValues + */ + public function testTwigIsValid($value) + { + $this->validator->validate($value, new Twig()); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getInvalidValues + */ + public function testInvalidValues($value, $message, $line) + { + $constraint = new Twig('myMessageTest'); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMessageTest') + ->setParameter('{{ error }}', $message) + ->setParameter('{{ line }}', $line) + ->setCode(Twig::INVALID_TWIG_ERROR) + ->assertRaised(); + } + + /** + * When deprecations are skipped by the validator, the testsuite reporter will catch them so we need to mark the test as legacy. + * + * @group legacy + */ + public function testTwigWithSkipDeprecation() + { + $constraint = new Twig(skipDeprecations: true); + + $this->validator->validate('{{ name|deprecated_filter }}', $constraint); + + $this->assertNoViolation(); + } + + public function testTwigWithoutSkipDeprecation() + { + $constraint = new Twig(skipDeprecations: false); + + $this->validator->validate('{{ name|deprecated_filter }}', $constraint); + + $line = 1; + $error = 'Twig Filter "deprecated_filter" is deprecated in at line 1 at line 1.'; + if (class_exists(DeprecatedCallableInfo::class)) { + $line = 0; + $error = 'Since foo/bar 1.1: Twig Filter "deprecated_filter" is deprecated.'; + } + $this->buildViolation($constraint->message) + ->setParameter('{{ error }}', $error) + ->setParameter('{{ line }}', $line) + ->setCode(Twig::INVALID_TWIG_ERROR) + ->assertRaised(); + } + + public static function getValidValues() + { + return [ + ['Hello {{ name }}'], + ['{% if condition %}Yes{% else %}No{% endif %}'], + ['{# Comment #}'], + ['Hello {{ "world"|upper }}'], + ['{% for i in 1..3 %}Item {{ i }}{% endfor %}'], + ['{{ name|humanize_filter }}'], + ]; + } + + public static function getInvalidValues() + { + return [ + // Invalid syntax example (missing end tag) + ['{% if condition %}Oops', 'Unexpected end of template at line 1.', 1], + // Another syntax error example (unclosed variable) + ['Hello {{ name', 'Unexpected token "end of template" ("end of print statement" expected) at line 1.', 1], + // Unknown filter error + ['Hello {{ name|unknown_filter }}', 'Unknown "unknown_filter" filter at line 1.', 1], + // Invalid variable syntax + ['Hello {{ .name }}', 'Unexpected token "punctuation" of value "." at line 1.', 1], + ]; + } +} diff --git a/Validator/Constraints/Twig.php b/Validator/Constraints/Twig.php new file mode 100644 index 00000000..7cf050e8 --- /dev/null +++ b/Validator/Constraints/Twig.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Validator\Constraints; + +use Symfony\Component\Validator\Attribute\HasNamedArguments; +use Symfony\Component\Validator\Constraint; + +/** + * @author Mokhtar Tlili + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class Twig extends Constraint +{ + public const INVALID_TWIG_ERROR = 'e7fc55d5-e586-4cc1-924e-d27ee7fcd1b5'; + + protected const ERROR_NAMES = [ + self::INVALID_TWIG_ERROR => 'INVALID_TWIG_ERROR', + ]; + + #[HasNamedArguments] + public function __construct( + public string $message = 'This value is not a valid Twig template.', + public bool $skipDeprecations = true, + ?array $groups = null, + mixed $payload = null, + ) { + parent::__construct(null, $groups, $payload); + } +} diff --git a/Validator/Constraints/TwigValidator.php b/Validator/Constraints/TwigValidator.php new file mode 100644 index 00000000..de92a362 --- /dev/null +++ b/Validator/Constraints/TwigValidator.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use Twig\Environment; +use Twig\Error\Error; +use Twig\Loader\ArrayLoader; +use Twig\Source; + +/** + * @author Mokhtar Tlili + */ +class TwigValidator extends ConstraintValidator +{ + public function __construct(private Environment $twig) + { + } + + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof Twig) { + throw new UnexpectedTypeException($constraint, Twig::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!\is_scalar($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException($value, 'string'); + } + + $value = (string) $value; + + if (!$constraint->skipDeprecations) { + $prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) { + if (\E_USER_DEPRECATED !== $level) { + return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; + } + + $templateLine = 0; + if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { + $templateLine = $matches[1]; + } + + throw new Error($message, $templateLine); + }); + } + + $realLoader = $this->twig->getLoader(); + try { + $temporaryLoader = new ArrayLoader([$value]); + $this->twig->setLoader($temporaryLoader); + $this->twig->parse($this->twig->tokenize(new Source($value, ''))); + } catch (Error $e) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ error }}', $e->getMessage()) + ->setParameter('{{ line }}', $e->getTemplateLine()) + ->setCode(Twig::INVALID_TWIG_ERROR) + ->addViolation(); + } finally { + $this->twig->setLoader($realLoader); + if (!$constraint->skipDeprecations) { + restore_error_handler(); + } + } + } +} diff --git a/composer.json b/composer.json index 19ab66ae..f6b5b402 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "symfony/property-info": "^6.4|^7.0", "symfony/routing": "^6.4|^7.0", "symfony/translation": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0", "symfony/security-acl": "^2.8|^3.0", "symfony/security-core": "^6.4|^7.0", From b39722d1867fae13e5edcf65efac77b763d67caf Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 28 Mar 2025 10:32:37 +0100 Subject: [PATCH 16/20] [TwigBridge] Fix `TwigValidatorTest` expectation --- Tests/Validator/Constraints/TwigValidatorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Validator/Constraints/TwigValidatorTest.php b/Tests/Validator/Constraints/TwigValidatorTest.php index f5c4253e..da5597ad 100644 --- a/Tests/Validator/Constraints/TwigValidatorTest.php +++ b/Tests/Validator/Constraints/TwigValidatorTest.php @@ -120,7 +120,7 @@ public static function getInvalidValues() // Unknown filter error ['Hello {{ name|unknown_filter }}', 'Unknown "unknown_filter" filter at line 1.', 1], // Invalid variable syntax - ['Hello {{ .name }}', 'Unexpected token "punctuation" of value "." at line 1.', 1], + ['Hello {{ .name }}', 'Unexpected token "operator" of value "." at line 1.', 1], ]; } } From 5eb0b3c72cbf7a66845b790e00f6bb1c5046d0d3 Mon Sep 17 00:00:00 2001 From: Pierre Ambroise Date: Tue, 25 Mar 2025 20:46:52 +0100 Subject: [PATCH 17/20] [TwigBridge] Collect all deprecations with `lint:twig` command --- CHANGELOG.md | 1 + Command/LintCommand.php | 77 +++++++++++++++++++------------ Tests/Command/LintCommandTest.php | 15 +++++- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fe3b55..8029cb4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add `is_granted_for_user()` Twig function * Add `field_id()` Twig form helper function * Add a `Twig` constraint that validates Twig templates + * Make `lint:twig` collect all deprecations instead of stopping at the first one 7.2 --- diff --git a/Command/LintCommand.php b/Command/LintCommand.php index 54720952..cacc7e44 100644 --- a/Command/LintCommand.php +++ b/Command/LintCommand.php @@ -89,7 +89,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->format = $input->getOption('format') ?? (GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt'); if (['-'] === $filenames) { - return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), 'Standard Input')]); + return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), 'Standard Input', $showDeprecations)]); } if (!$filenames) { @@ -107,38 +107,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - if ($showDeprecations) { - $prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) { - if (\E_USER_DEPRECATED === $level) { - $templateLine = 0; - if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { - $templateLine = $matches[1]; - } - - throw new Error($message, $templateLine); - } - - return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; - }); - } - - try { - $filesInfo = $this->getFilesInfo($filenames); - } finally { - if ($showDeprecations) { - restore_error_handler(); - } - } - - return $this->display($input, $output, $io, $filesInfo); + return $this->display($input, $output, $io, $this->getFilesInfo($filenames, $showDeprecations)); } - private function getFilesInfo(array $filenames): array + private function getFilesInfo(array $filenames, bool $showDeprecations): array { $filesInfo = []; foreach ($filenames as $filename) { foreach ($this->findFiles($filename) as $file) { - $filesInfo[] = $this->validate(file_get_contents($file), $file); + $filesInfo[] = $this->validate(file_get_contents($file), $file, $showDeprecations); } } @@ -156,8 +133,26 @@ protected function findFiles(string $filename): iterable throw new RuntimeException(\sprintf('File or directory "%s" is not readable.', $filename)); } - private function validate(string $template, string $file): array + private function validate(string $template, string $file, bool $collectDeprecation): array { + $deprecations = []; + if ($collectDeprecation) { + $prevErrorHandler = set_error_handler(static function ($level, $message, $fileName, $line) use (&$prevErrorHandler, &$deprecations, $file) { + if (\E_USER_DEPRECATED === $level) { + $templateLine = 0; + if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { + $templateLine = $matches[1]; + } + + $deprecations[] = ['message' => $message, 'file' => $file, 'line' => $templateLine]; + + return true; + } + + return $prevErrorHandler ? $prevErrorHandler($level, $message, $fileName, $line) : false; + }); + } + $realLoader = $this->twig->getLoader(); try { $temporaryLoader = new ArrayLoader([$file => $template]); @@ -169,9 +164,13 @@ private function validate(string $template, string $file): array $this->twig->setLoader($realLoader); return ['template' => $template, 'file' => $file, 'line' => $e->getTemplateLine(), 'valid' => false, 'exception' => $e]; + } finally { + if ($collectDeprecation) { + restore_error_handler(); + } } - return ['template' => $template, 'file' => $file, 'valid' => true]; + return ['template' => $template, 'file' => $file, 'deprecations' => $deprecations, 'valid' => true]; } private function display(InputInterface $input, OutputInterface $output, SymfonyStyle $io, array $files): int @@ -188,6 +187,11 @@ private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $fi { $errors = 0; $githubReporter = $errorAsGithubAnnotations ? new GithubActionReporter($output) : null; + $deprecations = array_merge(...array_column($filesInfo, 'deprecations')); + + foreach ($deprecations as $deprecation) { + $this->renderDeprecation($io, $deprecation['line'], $deprecation['message'], $deprecation['file'], $githubReporter); + } foreach ($filesInfo as $info) { if ($info['valid'] && $output->isVerbose()) { @@ -204,7 +208,7 @@ private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $fi $io->warning(\sprintf('%d Twig files have valid syntax and %d contain errors.', \count($filesInfo) - $errors, $errors)); } - return min($errors, 1); + return !$deprecations && !$errors ? 0 : 1; } private function displayJson(OutputInterface $output, array $filesInfo): int @@ -226,6 +230,19 @@ private function displayJson(OutputInterface $output, array $filesInfo): int return min($errors, 1); } + private function renderDeprecation(SymfonyStyle $output, int $line, string $message, string $file, ?GithubActionReporter $githubReporter): void + { + $githubReporter?->error($message, $file, $line <= 0 ? null : $line); + + if ($file) { + $output->text(\sprintf(' DEPRECATION in %s (line %s)', $file, $line)); + } else { + $output->text(\sprintf(' DEPRECATION (line %s)', $line)); + } + + $output->text(\sprintf(' >> %s ', $message)); + } + private function renderException(SymfonyStyle $output, string $template, Error $exception, ?string $file = null, ?GithubActionReporter $githubReporter = null): void { $line = $exception->getTemplateLine(); diff --git a/Tests/Command/LintCommandTest.php b/Tests/Command/LintCommandTest.php index 9fa3646e..9e4e23a8 100644 --- a/Tests/Command/LintCommandTest.php +++ b/Tests/Command/LintCommandTest.php @@ -94,7 +94,20 @@ public function testLintFileWithReportedDeprecation() $ret = $tester->execute(['filename' => [$filename], '--show-deprecations' => true], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE, 'decorated' => false]); $this->assertEquals(1, $ret, 'Returns 1 in case of error'); - $this->assertMatchesRegularExpression('/ERROR in \S+ \(line 1\)/', trim($tester->getDisplay())); + $this->assertMatchesRegularExpression('/DEPRECATION in \S+ \(line 1\)/', trim($tester->getDisplay())); + $this->assertStringContainsString('Filter "deprecated_filter" is deprecated', trim($tester->getDisplay())); + } + + public function testLintFileWithMultipleReportedDeprecation() + { + $tester = $this->createCommandTester(); + $filename = $this->createFile("{{ foo|deprecated_filter }}\n{{ bar|deprecated_filter }}"); + + $ret = $tester->execute(['filename' => [$filename], '--show-deprecations' => true], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE, 'decorated' => false]); + + $this->assertEquals(1, $ret, 'Returns 1 in case of error'); + $this->assertMatchesRegularExpression('/DEPRECATION in \S+ \(line 1\)/', trim($tester->getDisplay())); + $this->assertMatchesRegularExpression('/DEPRECATION in \S+ \(line 2\)/', trim($tester->getDisplay())); $this->assertStringContainsString('Filter "deprecated_filter" is deprecated', trim($tester->getDisplay())); } From 6c442c84adac67d1025ff44c203bcb7ee1fc1cb0 Mon Sep 17 00:00:00 2001 From: Alexander Hofbauer Date: Mon, 31 Mar 2025 16:48:29 +0200 Subject: [PATCH 18/20] [TwigBridge] Allow attachment name to be set for inline images --- CHANGELOG.md | 1 + Mime/WrappedTemplatedEmail.php | 8 +- Tests/Fixtures/assets/images/logo1.png | Bin 0 -> 1613 bytes Tests/Fixtures/assets/images/logo2.png | 1 + .../Fixtures/templates/email/attach.html.twig | 3 + .../Fixtures/templates/email/image.html.twig | 2 + Tests/Mime/WrappedTemplatedEmailTest.php | 103 ++++++++++++++++++ 7 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 Tests/Fixtures/assets/images/logo1.png create mode 120000 Tests/Fixtures/assets/images/logo2.png create mode 100644 Tests/Fixtures/templates/email/attach.html.twig create mode 100644 Tests/Fixtures/templates/email/image.html.twig create mode 100644 Tests/Mime/WrappedTemplatedEmailTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8029cb4e..d6d929cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * Add `field_id()` Twig form helper function * Add a `Twig` constraint that validates Twig templates * Make `lint:twig` collect all deprecations instead of stopping at the first one + * Add `name` argument to `email.image` to override the attachment file name being set as the file path 7.2 --- diff --git a/Mime/WrappedTemplatedEmail.php b/Mime/WrappedTemplatedEmail.php index a327e94b..1feedc20 100644 --- a/Mime/WrappedTemplatedEmail.php +++ b/Mime/WrappedTemplatedEmail.php @@ -39,14 +39,16 @@ public function toName(): string * some Twig namespace for email images (e.g. '@email/images/logo.png'). * @param string|null $contentType The media type (i.e. MIME type) of the image file (e.g. 'image/png'). * Some email clients require this to display embedded images. + * @param string|null $name A custom file name that overrides the original name (filepath) of the image */ - public function image(string $image, ?string $contentType = null): string + public function image(string $image, ?string $contentType = null, ?string $name = null): string { $file = $this->twig->getLoader()->getSourceContext($image); $body = $file->getPath() ? new File($file->getPath()) : $file->getCode(); - $this->message->addPart((new DataPart($body, $image, $contentType))->asInline()); + $name = $name ?: $image; + $this->message->addPart((new DataPart($body, $name, $contentType))->asInline()); - return 'cid:'.$image; + return 'cid:'.$name; } /** diff --git a/Tests/Fixtures/assets/images/logo1.png b/Tests/Fixtures/assets/images/logo1.png new file mode 100644 index 0000000000000000000000000000000000000000..519ab7c691ba91ea20fdb5aa70386eddc52848af GIT binary patch literal 1613 zcmV-T2D15yP)P+)L?kDsr<&)@GO8iI-d00p^8L_t(|+TEM!dZRiFh9%=g7;pD~ zwI}6ZzB(8;nKJD&yuYTMVCl0KFQjyi)<#6Mp6LE4>r(;ITc-QME|w#UrF)|0T>4~T zx*aTKKz4L9bSh)wM0a8?U*qUDa4fIjneM@q%4{5WD0L7g3u)_D4#1)Wz1Bk z#1XoAW&(90lBP>34+4R|-LX)PxvY3eXH}Xo370YbN`T`@Q%ir}H(l%KTI8s4771lT zpB1?|`u1-KPNgMGL`v7AaX>?QV);h}Po-tvx??a?toF9PET!S#o2BuuZ6+^5=z~p|JQ9!92I2 zVF)gI9S-Ad))Rrad6S=ea-9Rrkl$>&(h!J%)bLcQ*Q(6|yL~q10x~xOr49#F=YSI# zf?Xpcb5(&5tGbiuh`?+GIPTfx(ZaO3lN`4@L)UKTn4rdM;{QF3A5U5)6RW>YMEv?8 zd7$^8C#y)=P!(6fAES7~HR*k|KBK@k>$(>;+h(nPKqi!D`g>_y=W?6VY4d~xyHhye z8S`d4O-q_Gas_fWvzp+0rY(0<6Od)M2N+LPYg2eoI;j4Fj?=5(K81`n_~06PM2-mu zH-8k&8G7NNf?(4WlCIXL&lznyn<#0{6Y!yjrWQ2Hz>o7BNdP0H;9>yv^7R)DIQq`u_J7Wn&DcA&Iq?J&Aio0P|C%2Cpoi{U9``ryk z5KQk27W$0TU!g!S0@l;G`ym~{1`TBQ*_1P=3mRqZHLAv1T`5?97A#%Cwi;EiPR)vh zY&82*b2%Z=8E61pb9zQfUKwFG5)A9oHc#wf_hM%B)L2dkbOL`lZXL zy#8~>{^@POh>Ll)5osV8trGQA>ls9BBQq@Z$kYefhOAl*o9_s=C?b5WxUU4yjde^y zmoUy~KfapD%@!Ix?hgf1>g=U6j|9Vd8y+{TQ7LDb@g0gZ7r0m|-xrJ@Fo=T-L%|eV z>%$o8;aiw=Sc!rdt<9X8(>bP0eM2xbCed2Egd7_RMmVRhFXHn!z_zp301@&O?oz%b zn517*W5L`Bk2VT?L`H{K z15CyzjG#L20J#yIpo7{93;0j{d`ZSGRU6VAJ{E2T2Zm#vZ9nHWuD*uQm3MYoN;?sw zxw{Qn=o*v}5i@=BjhsPz_q;cwJx5mbBDk&mB^0c8k{ahoKhXPz`?%ZrEu{ZiGa|w? zrFB(tFNR3j<*Kh~sRb$VV~njs z@oR+F_1X~e>f@(@bwyn`6mUuXD%{!r0{rzcR?3)nzY?eMO75qD^{l`11@WNXtDq3V zVwo=Edh3$QImy!fU`W2XT+m&UyrCPzW)1~}8ES8g(*JX>zgGGWdRYnkHtkA|00000 LNkvXXu0mjf1C89g literal 0 HcmV?d00001 diff --git a/Tests/Fixtures/assets/images/logo2.png b/Tests/Fixtures/assets/images/logo2.png new file mode 120000 index 00000000..e9f523cb --- /dev/null +++ b/Tests/Fixtures/assets/images/logo2.png @@ -0,0 +1 @@ +logo1.png \ No newline at end of file diff --git a/Tests/Fixtures/templates/email/attach.html.twig b/Tests/Fixtures/templates/email/attach.html.twig new file mode 100644 index 00000000..e70e32fb --- /dev/null +++ b/Tests/Fixtures/templates/email/attach.html.twig @@ -0,0 +1,3 @@ +

Attachments

+{{ email.attach('@assets/images/logo1.png') }} +{{ email.attach('@assets/images/logo2.png', name='image.png') }} diff --git a/Tests/Fixtures/templates/email/image.html.twig b/Tests/Fixtures/templates/email/image.html.twig new file mode 100644 index 00000000..074edf4c --- /dev/null +++ b/Tests/Fixtures/templates/email/image.html.twig @@ -0,0 +1,2 @@ + + diff --git a/Tests/Mime/WrappedTemplatedEmailTest.php b/Tests/Mime/WrappedTemplatedEmailTest.php new file mode 100644 index 00000000..db8d6bef --- /dev/null +++ b/Tests/Mime/WrappedTemplatedEmailTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Mime; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\Mime\BodyRenderer; +use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Twig\Environment; +use Twig\Error\LoaderError; +use Twig\Loader\FilesystemLoader; + +/** + * @author Alexander Hofbauer buildEmail('email/image.html.twig'); + $body = $email->toString(); + $contentId1 = $email->getAttachments()[0]->getContentId(); + $contentId2 = $email->getAttachments()[1]->getContentId(); + + $part1 = str_replace("\n", "\r\n", + << + Content-Type: image/png; name="$contentId1" + Content-Transfer-Encoding: base64 + Content-Disposition: inline; + name="$contentId1"; + filename="@assets/images/logo1.png" + + PART + ); + + $part2 = str_replace("\n", "\r\n", + << + Content-Type: image/png; name="$contentId2" + Content-Transfer-Encoding: base64 + Content-Disposition: inline; + name="$contentId2"; filename=image.png + + PART + ); + + self::assertStringContainsString('![](cid:@assets/images/logo1.png)![](cid:image.png)', $body); + self::assertStringContainsString($part1, $body); + self::assertStringContainsString($part2, $body); + } + + public function testEmailAttach() + { + $email = $this->buildEmail('email/attach.html.twig'); + $body = $email->toString(); + + $part1 = str_replace("\n", "\r\n", + <<from('a.hofbauer@fify.at') + ->htmlTemplate($template); + + $loader = new FilesystemLoader(\dirname(__DIR__).'/Fixtures/templates/'); + $loader->addPath(\dirname(__DIR__).'/Fixtures/assets', 'assets'); + + $environment = new Environment($loader); + $renderer = new BodyRenderer($environment); + $renderer->render($email); + + return $email; + } +} From 37ed97aaac41d169fbeb3510a39a980380db95fb Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 27 Apr 2025 15:39:08 +0200 Subject: [PATCH 19/20] Remove unneeded use statements --- Tests/Mime/WrappedTemplatedEmailTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/Mime/WrappedTemplatedEmailTest.php b/Tests/Mime/WrappedTemplatedEmailTest.php index db8d6bef..428ebc93 100644 --- a/Tests/Mime/WrappedTemplatedEmailTest.php +++ b/Tests/Mime/WrappedTemplatedEmailTest.php @@ -15,7 +15,6 @@ use Symfony\Bridge\Twig\Mime\BodyRenderer; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Twig\Environment; -use Twig\Error\LoaderError; use Twig\Loader\FilesystemLoader; /** From 3333c52d4569a4948c59d12532537d1740488c97 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Wed, 30 Apr 2025 12:53:06 +0200 Subject: [PATCH 20/20] use deprecation catching error handler only when parsing Twig templates --- Validator/Constraints/TwigValidator.php | 42 ++++++++++++++----------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/Validator/Constraints/TwigValidator.php b/Validator/Constraints/TwigValidator.php index de92a362..3064341f 100644 --- a/Validator/Constraints/TwigValidator.php +++ b/Validator/Constraints/TwigValidator.php @@ -45,26 +45,33 @@ public function validate(mixed $value, Constraint $constraint): void $value = (string) $value; - if (!$constraint->skipDeprecations) { - $prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) { - if (\E_USER_DEPRECATED !== $level) { - return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; - } - - $templateLine = 0; - if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { - $templateLine = $matches[1]; - } - - throw new Error($message, $templateLine); - }); - } - $realLoader = $this->twig->getLoader(); try { $temporaryLoader = new ArrayLoader([$value]); $this->twig->setLoader($temporaryLoader); - $this->twig->parse($this->twig->tokenize(new Source($value, ''))); + + if (!$constraint->skipDeprecations) { + $prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) { + if (\E_USER_DEPRECATED !== $level) { + return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; + } + + $templateLine = 0; + if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { + $templateLine = $matches[1]; + } + + throw new Error($message, $templateLine); + }); + } + + try { + $this->twig->parse($this->twig->tokenize(new Source($value, ''))); + } finally { + if (!$constraint->skipDeprecations) { + restore_error_handler(); + } + } } catch (Error $e) { $this->context->buildViolation($constraint->message) ->setParameter('{{ error }}', $e->getMessage()) @@ -73,9 +80,6 @@ public function validate(mixed $value, Constraint $constraint): void ->addViolation(); } finally { $this->twig->setLoader($realLoader); - if (!$constraint->skipDeprecations) { - restore_error_handler(); - } } } }