309 lines
7.9 KiB
PHP
309 lines
7.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace CK\LibxmlPainInTheAss\Observer;
|
|
|
|
use Magento\Framework\App\Response\Http;
|
|
use Magento\Framework\Event\Observer;
|
|
use Magento\Framework\Event\ObserverInterface;
|
|
|
|
class RepairMageInitAttributes implements ObserverInterface {
|
|
|
|
// libxml2 2.15.x changed HTML attribute serialization.
|
|
// 2.15.3 is the version currently exhibiting the problem.
|
|
private const BUG_LIBXML_VERSION = 21503;
|
|
|
|
// Set this to true to log every repaired response.
|
|
private const LOG_EXECUTION = false;
|
|
|
|
/**
|
|
* @var \CK\Logger\Logger
|
|
*/
|
|
protected $logger;
|
|
|
|
/**
|
|
* @var \CK\Logger\LoggerFactory
|
|
*/
|
|
protected $loggerFactory;
|
|
|
|
public function __construct(
|
|
\CK\Logger\LoggerFactory $loggerFactory
|
|
) {
|
|
$this->loggerFactory = $loggerFactory;
|
|
$this->logger = $this->loggerFactory->create();
|
|
}
|
|
|
|
public function execute(Observer $observer): void {
|
|
self::LOG_EXECUTION && $this->logger->info(
|
|
'CK_LibxmlPainInTheAss observer FIRED. libxml=' .
|
|
(defined('LIBXML_LOADED_VERSION') ? LIBXML_LOADED_VERSION : 'undefined')
|
|
);
|
|
|
|
$loadedVersion = '';
|
|
// Only apply the workaround to libxml2 versions affected
|
|
// by the changed HTML serialization behavior.
|
|
if (!defined('LIBXML_LOADED_VERSION') || (int)LIBXML_LOADED_VERSION !== self::BUG_LIBXML_VERSION) {
|
|
if (self::LOG_EXECUTION) {
|
|
$loadedVersion = defined('LIBXML_LOADED_VERSION') ? (string)LIBXML_LOADED_VERSION : 'undefined';
|
|
$this->logger->info(
|
|
sprintf(
|
|
'CK_LibxmlPainInTheAss bypassed for mismatched libxml version. ' .
|
|
'Loaded libxml2: %s; expected: %d',
|
|
$loadedVersion,
|
|
self::BUG_LIBXML_VERSION
|
|
)
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
self::LOG_EXECUTION && $this->logger->warning(
|
|
'CK_LibxmlPainInTheAss detected bug version of libxml=' . $loadedVersion . '. Patching in progress.'
|
|
);
|
|
|
|
// Work only on HTML responses.
|
|
$response = $observer->getEvent()->getResponse();
|
|
if (!$response instanceof Http) {
|
|
return;
|
|
}
|
|
|
|
// Check HTML body contains data-mage-init attribute
|
|
$body = (string)$response->getContent();
|
|
if ($body === '' || stripos($body, 'data-mage-init=') === false) {
|
|
return;
|
|
}
|
|
|
|
$repaired = $this->repairDataMageInitAttributes($body);
|
|
if ($repaired === $body) {
|
|
return;
|
|
}
|
|
$response->setContent($repaired);
|
|
|
|
// Log repairs if asked to
|
|
if (self::LOG_EXECUTION) {
|
|
$this->logger->warning(
|
|
sprintf(
|
|
'CK_LibxmlPainInTheAss repaired malformed data-mage-init attributes. ' .
|
|
'Loaded libxml2: %s',
|
|
LIBXML_LOADED_VERSION
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Repair only malformed data-mage-init attributes.
|
|
*
|
|
* Expected broken input:
|
|
*
|
|
* data-mage-init="{"key":"value"}"
|
|
*
|
|
* Preferred output:
|
|
*
|
|
* data-mage-init='{"key":"value"}'
|
|
*
|
|
* If the JSON itself contains a literal apostrophe, use:
|
|
*
|
|
* data-mage-init="{"..."}"
|
|
*
|
|
* instead.
|
|
*/
|
|
private function repairDataMageInitAttributes(string $html): string {
|
|
$length = strlen($html);
|
|
$offset = 0;
|
|
$output = '';
|
|
$changed = false;
|
|
|
|
while ($offset < $length) {
|
|
$match = preg_match(
|
|
'/data-mage-init\s*=\s*"/i',
|
|
$html,
|
|
$matches,
|
|
PREG_OFFSET_CAPTURE,
|
|
$offset
|
|
);
|
|
|
|
if ($match !== 1) {
|
|
$output .= substr($html, $offset);
|
|
break;
|
|
}
|
|
|
|
$attributeOffset = $matches[0][1];
|
|
$attributeToken = $matches[0][0];
|
|
$attributeTokenLength = strlen($attributeToken);
|
|
|
|
// Only process data-mage-init when it appears to be an actual attribute boundary, not as part of another token.
|
|
// Example: foo-data-mage-init="..."
|
|
if ($attributeOffset === 0) {
|
|
$output .= substr($html, $offset, $attributeTokenLength);
|
|
$offset = $attributeTokenLength;
|
|
continue;
|
|
}
|
|
$previousCharacter = $html[$attributeOffset - 1];
|
|
if (!ctype_space($previousCharacter)) {
|
|
$output .= substr(
|
|
$html,
|
|
$offset,
|
|
($attributeOffset + $attributeTokenLength) - $offset
|
|
);
|
|
$offset = $attributeOffset + $attributeTokenLength;
|
|
continue;
|
|
}
|
|
|
|
// Copy everything before data-mage-init="...
|
|
$output .= substr(
|
|
$html,
|
|
$offset,
|
|
$attributeOffset - $offset
|
|
);
|
|
|
|
$jsonStart = $attributeOffset + $attributeTokenLength;
|
|
|
|
// The malformed value must begin with a JSON object or array.
|
|
if ($jsonStart >= $length || ($html[$jsonStart] !== '{' && $html[$jsonStart] !== '[')) {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
|
|
$jsonEnd = $this->findJsonEnd($html, $jsonStart);
|
|
|
|
// Could not safely identify a JSON value. Leave this occurrence untouched.
|
|
if ($jsonEnd === null || $jsonEnd >= $length) {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
|
|
// The malformed attribute should end with a literal ".
|
|
//
|
|
// Example:
|
|
// data-mage-init="{"key":"value"}"
|
|
// ^
|
|
if ($html[$jsonEnd] !== '"') {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
|
|
// Ensure it's the end of the HTML attribute.
|
|
$afterJson = $jsonEnd + 1;
|
|
if ($afterJson < $length) {
|
|
$nextCharacter = $html[$afterJson];
|
|
if ($nextCharacter !== '>' && $nextCharacter !== '/' && !ctype_space($nextCharacter)) {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Extract the JSON from $html
|
|
$json = substr(
|
|
$html,
|
|
$jsonStart,
|
|
$jsonEnd - $jsonStart
|
|
);
|
|
|
|
// Validate that this really is JSON.
|
|
try {
|
|
json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (\JsonException $e) {
|
|
$output .= $attributeToken;
|
|
$offset = $jsonStart;
|
|
continue;
|
|
}
|
|
|
|
// JSON normally contains double quotes and no apostrophes, so single quotes are the safest HTML attribute delimiter.
|
|
if (strpos($json, "'") === false) {
|
|
$output .= 'data-mage-init=\'' . $json . '\'';
|
|
} else {
|
|
// The JSON contains a literal apostrophe, so use double quotes and HTML-escape its double quotes.
|
|
$escapedJson = htmlspecialchars(
|
|
$json,
|
|
ENT_COMPAT | ENT_SUBSTITUTE,
|
|
'UTF-8',
|
|
false
|
|
);
|
|
|
|
$output .= 'data-mage-init="' . $escapedJson . '"';
|
|
}
|
|
|
|
$changed = true;
|
|
|
|
// Consume the final quote of the malformed attribute.
|
|
$offset = $afterJson;
|
|
}
|
|
|
|
return $changed ? $output : $html;
|
|
}
|
|
|
|
/**
|
|
* Find the end of a JSON object or array.
|
|
*
|
|
* Handles:
|
|
* - nested objects
|
|
* - nested arrays
|
|
* - quoted strings
|
|
* - escaped quotes
|
|
*
|
|
* Returns the position immediately AFTER the final } or ].
|
|
*/
|
|
private function findJsonEnd(string $html, int $start): ?int {
|
|
$length = strlen($html);
|
|
$stack = [];
|
|
|
|
$inString = false;
|
|
$escaped = false;
|
|
|
|
for ($i = $start; $i < $length; $i++) {
|
|
$char = $html[$i];
|
|
|
|
if ($inString) {
|
|
if ($escaped) {
|
|
$escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if ($char === '\\') {
|
|
$escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if ($char === '"') {
|
|
$inString = false;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($char === '"') {
|
|
$inString = true;
|
|
continue;
|
|
}
|
|
|
|
if ($char === '{' || $char === '[') {
|
|
$stack[] = $char;
|
|
continue;
|
|
}
|
|
|
|
if ($char === '}' || $char === ']') {
|
|
if (empty($stack)) {
|
|
return null;
|
|
}
|
|
|
|
$open = array_pop($stack);
|
|
if (($open === '{' && $char !== '}') || ($open === '[' && $char !== ']')) {
|
|
return null;
|
|
}
|
|
|
|
if (empty($stack)) {
|
|
return $i + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
}
|