Skip to content

Commit e44dcb8

Browse files
committed
MC-16152: Login Only B2B breaks Page Builder preview
- Ensure admin rendered content is filtered with Page Builder
1 parent 8581a42 commit e44dcb8

File tree

5 files changed

+371
-302
lines changed

5 files changed

+371
-302
lines changed
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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\PageBuilder\Model\Filter;
9+
10+
/**
11+
* Plugin to the template filter to process any background images added by Page Builder
12+
*/
13+
class Template
14+
{
15+
/**
16+
* @var \Magento\Framework\View\ConfigInterface
17+
*/
18+
private $viewConfig;
19+
20+
/**
21+
* @var \Psr\Log\LoggerInterface
22+
*/
23+
private $logger;
24+
25+
/**
26+
* @var \DOMDocument
27+
*/
28+
private $domDocument;
29+
30+
/**
31+
* @var \Magento\Framework\Math\Random
32+
*/
33+
private $mathRandom;
34+
35+
/**
36+
* @var \Magento\Framework\Serialize\Serializer\Json
37+
*/
38+
private $json;
39+
40+
/**
41+
* @param \Psr\Log\LoggerInterface $logger
42+
* @param \Magento\Framework\View\ConfigInterface $viewConfig
43+
* @param \Magento\Framework\Math\Random $mathRandom
44+
* @param \Magento\Framework\Serialize\Serializer\Json $json
45+
*/
46+
public function __construct(
47+
\Psr\Log\LoggerInterface $logger,
48+
\Magento\Framework\View\ConfigInterface $viewConfig,
49+
\Magento\Framework\Math\Random $mathRandom,
50+
\Magento\Framework\Serialize\Serializer\Json $json
51+
) {
52+
$this->logger = $logger;
53+
$this->viewConfig = $viewConfig;
54+
$this->mathRandom = $mathRandom;
55+
$this->json = $json;
56+
}
57+
58+
/**
59+
* After filter of template data apply transformations
60+
*
61+
* @param string $result
62+
*
63+
* @return string
64+
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
65+
*/
66+
public function filter(string $result) : string
67+
{
68+
$this->domDocument = false;
69+
70+
// Validate if the filtered result requires background image processing
71+
if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::BACKGROUND_IMAGE_PATTERN, $result)) {
72+
$document = $this->getDomDocument($result);
73+
$this->generateBackgroundImageStyles($document);
74+
}
75+
76+
// Process any HTML content types, they need to be decoded on the front-end
77+
if (preg_match(\Magento\PageBuilder\Plugin\Filter\TemplatePlugin::HTML_CONTENT_TYPE_PATTERN, $result)) {
78+
$document = $this->getDomDocument($result);
79+
$uniqueNodeNameToDecodedOuterHtmlMap = $this->generateDecodedHtmlPlaceholderMappingInDocument($document);
80+
}
81+
82+
// If a document was retrieved we've modified the output so need to retrieve it from within the document
83+
if (isset($document)) {
84+
// Match the contents of the body from our generated document
85+
preg_match(
86+
'/<body>(.+)<\/body><\/html>$/si',
87+
$document->saveHTML(),
88+
$matches
89+
);
90+
91+
if (!empty($matches)) {
92+
$docHtml = $matches[1];
93+
94+
if (isset($uniqueNodeNameToDecodedOuterHtmlMap)) {
95+
foreach ($uniqueNodeNameToDecodedOuterHtmlMap as $uniqueNodeName => $decodedOuterHtml) {
96+
$docHtml = str_replace(
97+
'<' . $uniqueNodeName . '>' . '</' . $uniqueNodeName . '>',
98+
$decodedOuterHtml,
99+
$docHtml
100+
);
101+
}
102+
}
103+
104+
$result = $docHtml;
105+
}
106+
}
107+
108+
return $result;
109+
}
110+
111+
/**
112+
* Create a DOM document from a given string
113+
*
114+
* @param string $html
115+
*
116+
* @return \DOMDocument
117+
*/
118+
private function getDomDocument(string $html) : \DOMDocument
119+
{
120+
if (!$this->domDocument) {
121+
$this->domDocument = $this->createDomDocument($html);
122+
}
123+
124+
return $this->domDocument;
125+
}
126+
127+
/**
128+
* Create a DOMDocument from a string
129+
*
130+
* @param string $html
131+
*
132+
* @return \DOMDocument
133+
*/
134+
private function createDomDocument(string $html) : \DOMDocument
135+
{
136+
$domDocument = new \DOMDocument('1.0', 'UTF-8');
137+
set_error_handler(
138+
function ($errorNumber, $errorString) {
139+
throw new \DOMException($errorString, $errorNumber);
140+
}
141+
);
142+
$string = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
143+
try {
144+
libxml_use_internal_errors(true);
145+
$domDocument->loadHTML(
146+
'<html><body>' . $string . '</body></html>'
147+
);
148+
libxml_clear_errors();
149+
} catch (\Exception $e) {
150+
restore_error_handler();
151+
$this->logger->critical($e);
152+
}
153+
restore_error_handler();
154+
155+
return $domDocument;
156+
}
157+
158+
/**
159+
* Convert encoded HTML content types to placeholders and generate decoded outer html map for future replacement
160+
*
161+
* @param \DOMDocument $document
162+
* @return array
163+
* @throws \Magento\Framework\Exception\LocalizedException
164+
*/
165+
private function generateDecodedHtmlPlaceholderMappingInDocument(\DOMDocument $document): array
166+
{
167+
$xpath = new \DOMXPath($document);
168+
169+
// construct xpath query to fetch top-level ancestor html content type nodes
170+
/** @var $htmlContentTypeNodes \DOMNode[] */
171+
$htmlContentTypeNodes = $xpath->query(
172+
'//*[@data-content-type="html" and not(@data-decoded="true")]' .
173+
'[not(ancestor::*[@data-content-type="html"])]'
174+
);
175+
176+
$uniqueNodeNameToDecodedOuterHtmlMap = [];
177+
178+
foreach ($htmlContentTypeNodes as $htmlContentTypeNode) {
179+
// Set decoded attribute on all encoded html content types so we don't double decode;
180+
$htmlContentTypeNode->setAttribute('data-decoded', 'true');
181+
182+
// if nothing exists inside the node, continue
183+
if (!strlen(trim($htmlContentTypeNode->nodeValue))) {
184+
continue;
185+
}
186+
187+
// clone html code content type to save reference to its attributes/outerHTML, which we are not going to
188+
// decode
189+
$clonedHtmlContentTypeNode = clone $htmlContentTypeNode;
190+
191+
// clear inner contents of cloned node for replacement later with $decodedInnerHtml using sprintf;
192+
// we want to retain html content type node and avoid doing any manipulation on it
193+
$clonedHtmlContentTypeNode->nodeValue = '%s';
194+
195+
// remove potentially harmful attributes on html content type node itself
196+
while ($htmlContentTypeNode->attributes->length) {
197+
$htmlContentTypeNode->removeAttribute($htmlContentTypeNode->attributes->item(0)->name);
198+
}
199+
200+
// decode outerHTML safely
201+
$preDecodedOuterHtml = $document->saveHTML($htmlContentTypeNode);
202+
203+
// clear empty <div> wrapper around outerHTML to replace with $clonedHtmlContentTypeNode
204+
// phpcs:ignore Magento2.Functions.DiscouragedFunction
205+
$decodedInnerHtml = preg_replace('#^<[^>]*>|</[^>]*>$#', '', html_entity_decode($preDecodedOuterHtml));
206+
207+
// Use $clonedHtmlContentTypeNode's placeholder to inject decoded inner html
208+
$decodedOuterHtml = sprintf($document->saveHTML($clonedHtmlContentTypeNode), $decodedInnerHtml);
209+
210+
// generate unique node name element to replace with decoded html contents at end of processing;
211+
// goal is to create a document as few times as possible to prevent inadvertent parsing of contents as html
212+
// by the dom library
213+
$uniqueNodeName = $this->mathRandom->getRandomString(32, $this->mathRandom::CHARS_LOWERS);
214+
215+
$uniqueNode = new \DOMElement($uniqueNodeName);
216+
$htmlContentTypeNode->parentNode->replaceChild($uniqueNode, $htmlContentTypeNode);
217+
218+
$uniqueNodeNameToDecodedOuterHtmlMap[$uniqueNodeName] = $decodedOuterHtml;
219+
}
220+
221+
return $uniqueNodeNameToDecodedOuterHtmlMap;
222+
}
223+
224+
/**
225+
* Generate the CSS for any background images on the page
226+
*
227+
* @param \DOMDocument $document
228+
*/
229+
private function generateBackgroundImageStyles(\DOMDocument $document) : void
230+
{
231+
$xpath = new \DOMXPath($document);
232+
$nodes = $xpath->query('//*[@data-background-images]');
233+
foreach ($nodes as $node) {
234+
/* @var \DOMElement $node */
235+
$backgroundImages = $node->attributes->getNamedItem('data-background-images');
236+
if ($backgroundImages->nodeValue !== '') {
237+
$elementClass = uniqid('background-image-');
238+
// phpcs:ignore Magento2.Functions.DiscouragedFunction
239+
$images = $this->json->unserialize(stripslashes($backgroundImages->nodeValue));
240+
if (count($images) > 0) {
241+
$style = $xpath->document->createElement(
242+
'style',
243+
$this->generateCssFromImages($elementClass, $images)
244+
);
245+
$style->setAttribute('type', 'text/css');
246+
$node->parentNode->appendChild($style);
247+
248+
// Append our new class to the DOM element
249+
$classes = '';
250+
if ($node->attributes->getNamedItem('class')) {
251+
$classes = $node->attributes->getNamedItem('class')->nodeValue . ' ';
252+
}
253+
$node->setAttribute('class', $classes . $elementClass);
254+
}
255+
}
256+
}
257+
}
258+
259+
/**
260+
* Generate CSS based on the images array from our attribute
261+
*
262+
* @param string $elementClass
263+
* @param array $images
264+
*
265+
* @return string
266+
*/
267+
private function generateCssFromImages(string $elementClass, array $images) : string
268+
{
269+
$css = [];
270+
if (isset($images['desktop_image'])) {
271+
$css['.' . $elementClass] = [
272+
'background-image' => 'url(' . $images['desktop_image'] . ')',
273+
];
274+
}
275+
if (isset($images['mobile_image']) && $this->getMobileMediaQuery()) {
276+
$css[$this->getMobileMediaQuery()]['.' . $elementClass] = [
277+
'background-image' => 'url(' . $images['mobile_image'] . ')',
278+
];
279+
}
280+
return $this->cssFromArray($css);
281+
}
282+
283+
/**
284+
* Generate a CSS string from an array
285+
*
286+
* @param array $css
287+
*
288+
* @return string
289+
*/
290+
private function cssFromArray(array $css) : string
291+
{
292+
$output = '';
293+
foreach ($css as $selector => $body) {
294+
if (is_array($body)) {
295+
$output .= $selector . ' {';
296+
$output .= $this->cssFromArray($body);
297+
$output .= '}';
298+
} else {
299+
$output .= $selector . ': ' . $body . ';';
300+
}
301+
}
302+
return $output;
303+
}
304+
305+
/**
306+
* Generate the mobile media query from view configuration
307+
*
308+
* @return null|string
309+
*/
310+
private function getMobileMediaQuery() : ?string
311+
{
312+
$breakpoints = $this->viewConfig->getViewConfig()->getVarValue(
313+
'Magento_PageBuilder',
314+
'breakpoints/mobile/conditions'
315+
);
316+
if ($breakpoints && count($breakpoints) > 0) {
317+
$mobileBreakpoint = '@media only screen ';
318+
foreach ($breakpoints as $key => $value) {
319+
$mobileBreakpoint .= 'and (' . $key . ': ' . $value . ') ';
320+
}
321+
return rtrim($mobileBreakpoint);
322+
}
323+
return null;
324+
}
325+
}

app/code/Magento/PageBuilder/Model/Stage/Renderer/Block.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
namespace Magento\PageBuilder\Model\Stage\Renderer;
1010

1111
use Magento\Framework\Controller\ResultFactory;
12+
use Magento\PageBuilder\Model\Filter\Template;
1213

1314
/**
1415
* Renders a block for the stage
@@ -31,20 +32,27 @@ class Block implements \Magento\PageBuilder\Model\Stage\RendererInterface
3132
private $resultFactory;
3233

3334
/**
34-
* Constructor
35-
*
35+
* @var Template
36+
*/
37+
private $templateFilter;
38+
39+
/**
3640
* @param \Magento\PageBuilder\Model\Config $config
3741
* @param \Magento\Framework\View\Element\BlockFactory $blockFactory
3842
* @param ResultFactory $resultFactory
43+
* @param Template|null $templateFilter
3944
*/
4045
public function __construct(
4146
\Magento\PageBuilder\Model\Config $config,
4247
\Magento\Framework\View\Element\BlockFactory $blockFactory,
43-
ResultFactory $resultFactory
48+
ResultFactory $resultFactory,
49+
Template $templateFilter = null
4450
) {
4551
$this->config = $config;
4652
$this->blockFactory = $blockFactory;
4753
$this->resultFactory = $resultFactory;
54+
$this->templateFilter = $templateFilter ?? \Magento\Framework\App\ObjectManager::getInstance()
55+
->get(\Magento\PageBuilder\Model\Filter\Template::class);
4856
}
4957

5058
/**
@@ -77,7 +85,7 @@ public function render(array $params): array
7785
$pageResult = $this->resultFactory->create(ResultFactory::TYPE_PAGE);
7886
$pageResult->getLayout()->addBlock($backendBlockInstance);
7987

80-
$result['content'] = $backendBlockInstance->toHtml();
88+
$result['content'] = $this->templateFilter->filter($backendBlockInstance->toHtml());
8189
}
8290

8391
return $result;

0 commit comments

Comments
 (0)