Skip to content

Commit e1bd4b6

Browse files
authored
Merge pull request #7475 from magento-cia/AC-2461
AC-2461
2 parents 3cf8909 + 5a08ad9 commit e1bd4b6

File tree

7 files changed

+523
-0
lines changed

7 files changed

+523
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Email\Console\Command;
9+
10+
use Magento\Email\Model\AbstractTemplate;
11+
use Magento\Email\Model\ResourceModel\Template\CollectionFactory;
12+
use Magento\Email\Model\Template\VariableCompatibilityChecker;
13+
use Symfony\Component\Console\Command\Command;
14+
use Symfony\Component\Console\Input\InputInterface;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
use Magento\Framework\Console\Cli;
17+
18+
/**
19+
* Scan DB templates for directive incompatibilities
20+
*/
21+
class DatabaseTemplateCompatibilityCommand extends Command
22+
{
23+
/**
24+
* @var CollectionFactory
25+
*/
26+
private CollectionFactory $templateCollection;
27+
28+
/**
29+
* @var VariableCompatibilityChecker
30+
*/
31+
private VariableCompatibilityChecker $compatibilityChecker;
32+
33+
/**
34+
* @var bool
35+
*/
36+
protected bool $hasErrors = false;
37+
38+
/**
39+
* Constructor
40+
*
41+
* @param VariableCompatibilityChecker $compatibilityChecker
42+
* @param CollectionFactory $templateCollection
43+
* @param string $name
44+
*/
45+
public function __construct(
46+
VariableCompatibilityChecker $compatibilityChecker,
47+
CollectionFactory $templateCollection,
48+
string $name = null
49+
) {
50+
parent::__construct($name);
51+
$this->templateCollection = $templateCollection;
52+
$this->compatibilityChecker = $compatibilityChecker;
53+
}
54+
55+
/**
56+
* @inheritdoc
57+
*/
58+
protected function configure()
59+
{
60+
$this->setName('dev:email:override-compatibility-check')
61+
->setDescription('Scans email template overrides for potential variable usage compatibility issues');
62+
}
63+
64+
/**
65+
* @inheritDoc
66+
*/
67+
protected function execute(InputInterface $input, OutputInterface $output)
68+
{
69+
$collection = $this->templateCollection->create();
70+
$collection->load();
71+
72+
$this->hasErrors = false;
73+
foreach ($collection as $template) {
74+
$this->checkTemplate($template, $output);
75+
}
76+
77+
if (!$this->hasErrors) {
78+
$output->writeln('<info>No errors detected</info>');
79+
}
80+
return $this->hasErrors ? Cli::RETURN_FAILURE : Cli::RETURN_SUCCESS;
81+
}
82+
83+
/**
84+
* Check the given template for compatibility issues
85+
*
86+
* @param AbstractTemplate $template
87+
* @param OutputInterface $output
88+
*/
89+
protected function checkTemplate(AbstractTemplate $template, OutputInterface $output): void
90+
{
91+
$errors = $this->compatibilityChecker->getCompatibilityIssues($template->getTemplateText());
92+
$templateName = $template->getTemplateCode();
93+
if (!empty($errors)) {
94+
$this->hasErrors = true;
95+
$output->writeln(
96+
'<error>Template "' . $templateName . '" has the following compatibility issues:</error>'
97+
);
98+
$this->renderErrors($output, $errors);
99+
}
100+
$errors = $this->compatibilityChecker->getCompatibilityIssues($template->getTemplateSubject());
101+
if (!empty($errors)) {
102+
$this->hasErrors = true;
103+
$output->writeln(
104+
'<error>Template "' . $templateName . '" subject has the following compatibility issues:</error>'
105+
);
106+
$this->renderErrors($output, $errors);
107+
}
108+
}
109+
110+
/**
111+
* Render given errors
112+
*
113+
* @param OutputInterface $output
114+
* @param array $errors
115+
*/
116+
private function renderErrors(OutputInterface $output, array $errors): void
117+
{
118+
foreach ($errors as $error) {
119+
$error = str_replace(PHP_EOL, PHP_EOL . ' ', $error);
120+
$output->writeln(
121+
'<error> - ' . $error . '</error>'
122+
);
123+
}
124+
$output->writeln('');
125+
}
126+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Email\Model\Template;
9+
10+
use Magento\Framework\Filter\Template\Tokenizer\Parameter;
11+
use Magento\Framework\Filter\Template\Tokenizer\Variable;
12+
13+
/**
14+
* Scan an email template for compatibility with the strict resolver
15+
*/
16+
class VariableCompatibilityChecker
17+
{
18+
private const CONSTRUCTION_DEPEND_PATTERN = '/{{depend\s*(.*?)}}(.*?){{\\/depend\s*}}/si';
19+
private const CONSTRUCTION_IF_PATTERN = '/{{if\s*(.*?)}}(.*?)({{else}}(.*?))?{{\\/if\s*}}/si';
20+
private const LOOP_PATTERN = '/{{for(?P<loopItem>.*? )(in)(?P<loopData>.*?)}}(?P<loopBody>.*?){{\/for}}/si';
21+
private const CONSTRUCTION_PATTERN = '/{{([a-z]{0,10})(.*?)}}(?:(.*?)(?:{{\/(?:\\1)}}))?/si';
22+
23+
/**
24+
* @var array
25+
*/
26+
private array $errors = [];
27+
28+
/**
29+
* @var Variable
30+
*/
31+
private Variable $variableTokenizer;
32+
33+
/**
34+
* @var Parameter
35+
*/
36+
private Parameter $parameterTokenizer;
37+
38+
/**
39+
* Constructor
40+
*
41+
* @param Variable $variableTokenizer
42+
* @param Parameter $parameterTokenizer
43+
*/
44+
public function __construct(Variable $variableTokenizer, Parameter $parameterTokenizer)
45+
{
46+
$this->variableTokenizer = $variableTokenizer;
47+
$this->parameterTokenizer = $parameterTokenizer;
48+
}
49+
50+
/**
51+
* Detect invalid usage of template filter directives
52+
*
53+
* @param string $template
54+
*/
55+
public function getCompatibilityIssues(string $template): array
56+
{
57+
$this->errors = [];
58+
59+
if (empty($template)) {
60+
return [];
61+
}
62+
63+
$template = $this->processIfDirectives($template);
64+
$template = $this->processDependDirectives($template);
65+
$template = $this->processForDirectives($template);
66+
$this->processVarDirectivesAndParams($template);
67+
68+
return $this->errors;
69+
}
70+
71+
/**
72+
* Process the {{if}} directives in the file
73+
*
74+
* @param string $html
75+
* @return string The processed template
76+
*/
77+
private function processIfDirectives(string $html): string
78+
{
79+
if (preg_match_all(self::CONSTRUCTION_IF_PATTERN, $html, $constructions, PREG_SET_ORDER)) {
80+
foreach ($constructions as $construction) {
81+
// validate {{if <var>}}
82+
$this->validateVariableUsage($construction[1]);
83+
$html = str_replace($construction[0], $construction[2] . ($construction[4] ?? ''), $html);
84+
}
85+
}
86+
87+
return $html;
88+
}
89+
90+
/**
91+
* Process the {{depend}} directives in the file
92+
*
93+
* @param string $html
94+
* @return string The processed template
95+
*/
96+
private function processDependDirectives(string $html): string
97+
{
98+
if (preg_match_all(self::CONSTRUCTION_DEPEND_PATTERN, $html, $constructions, PREG_SET_ORDER)) {
99+
foreach ($constructions as $construction) {
100+
// validate {{depend <var>}}
101+
$this->validateVariableUsage($construction[1]);
102+
$html = str_replace($construction[0], $construction[2], $html);
103+
}
104+
}
105+
106+
return $html;
107+
}
108+
109+
/**
110+
* Process the {{for}} directives in the file
111+
*
112+
* @param string $html
113+
* @return string The processed template
114+
*/
115+
private function processForDirectives(string $html): string
116+
{
117+
if (preg_match_all(self::LOOP_PATTERN, $html, $constructions, PREG_SET_ORDER)) {
118+
foreach ($constructions as $construction) {
119+
// validate {{for in <var>}}
120+
$this->validateVariableUsage($construction['loopData']);
121+
$html = str_replace($construction[0], $construction['loopBody'], $html);
122+
}
123+
}
124+
125+
return $html;
126+
}
127+
128+
/**
129+
* Process the all var directives and var directive params in the file
130+
*
131+
* @param string $html
132+
* @return string The processed template
133+
*/
134+
private function processVarDirectivesAndParams(string $html): string
135+
{
136+
if (preg_match_all(self::CONSTRUCTION_PATTERN, $html, $constructions, PREG_SET_ORDER)) {
137+
foreach ($constructions as $construction) {
138+
if (empty($construction[2])) {
139+
continue;
140+
}
141+
142+
if ($construction[1] === 'var') {
143+
$this->validateVariableUsage($construction[2]);
144+
} else {
145+
$this->validateDirectiveBody($construction[2]);
146+
}
147+
}
148+
}
149+
150+
return $html;
151+
}
152+
153+
/**
154+
* Validate directive body is valid. e.g. {{somedir <directive body>}}
155+
*
156+
* @param string $body
157+
*/
158+
private function validateDirectiveBody(string $body): void
159+
{
160+
$this->parameterTokenizer->setString($body);
161+
$params = $this->parameterTokenizer->tokenize();
162+
163+
foreach ($params as $param) {
164+
if (substr($param, 0, 1) === '$') {
165+
$this->validateVariableUsage(substr($param, 1));
166+
}
167+
}
168+
}
169+
170+
/**
171+
* Validate directive variable usage is valid. e.g. {{var <variable body>}} or {{somedir some_param="$foo.bar()"}}
172+
*
173+
* @param string $body
174+
*/
175+
private function validateVariableUsage(string $body): void
176+
{
177+
$this->variableTokenizer->setString($body);
178+
$stack = $this->variableTokenizer->tokenize();
179+
180+
if (empty($stack)) {
181+
return;
182+
}
183+
184+
foreach ($stack as $token) {
185+
// As a static analyzer there are no data types to know if this is a DataObject so allow all get* methods
186+
if ($token['type'] === 'method' && substr($token['name'], 0, 3) !== 'get') {
187+
$this->addError(
188+
'Template directives may not invoke methods. Only scalar array access is allowed.' . PHP_EOL
189+
. 'Found "' . trim($body) . '"'
190+
);
191+
}
192+
}
193+
}
194+
195+
/**
196+
* Add an error to the current processing template
197+
*
198+
* @param string $error
199+
*/
200+
private function addError(string $error): void
201+
{
202+
$this->errors[] = $error;
203+
}
204+
}

app/code/Magento/Email/etc/di.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,11 @@
9393
</argument>
9494
</arguments>
9595
</type>
96+
<type name="Magento\Framework\Console\CommandListInterface">
97+
<arguments>
98+
<argument name="commands" xsi:type="array">
99+
<item name="emailTemplateCheck" xsi:type="object">Magento\Email\Console\Command\DatabaseTemplateCompatibilityCommand</item>
100+
</argument>
101+
</arguments>
102+
</type>
96103
</config>

0 commit comments

Comments
 (0)