From 8a5e9cf3cdf52d6372d472c447a173bef67a51ea Mon Sep 17 00:00:00 2001 From: Davide Lunardon Date: Wed, 16 Sep 2026 08:58:11 +0200 Subject: [PATCH 1/2] Export widget image fields that use a bare pub/media relative path Widget parameters were only scanned for media assets inside repeatable_*/conditions_encoded rows, and only when the value was a full absolute URL. A plain (non-repeatable) image field, or any image field storing the bare relative path the media browser actually fills in (e.g. "wysiwyg/x.jpg"), was skipped entirely and silently missing after re-import elsewhere. Add collectMediaAsset(), which resolves any string widget parameter value against the real pub/media filesystem (absolute URL, root-relative, or bare relative form) and registers it as a template asset when it exists. Co-Authored-By: Claude Sonnet 5 --- DataConverter/CmsConverter.php | 93 +++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/DataConverter/CmsConverter.php b/DataConverter/CmsConverter.php index 04fc86b..d6acaaf 100644 --- a/DataConverter/CmsConverter.php +++ b/DataConverter/CmsConverter.php @@ -4,9 +4,12 @@ namespace MageOS\PageBuilderTemplateImportExport\DataConverter; use Magento\Framework\App\DeploymentConfig; +use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\Data\Wysiwyg\Normalizer; use Magento\Framework\DB\DataConverter\DataConversionException; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Filesystem; +use Magento\Framework\Filesystem\Directory\ReadInterface; use Magento\Framework\Filter\Template\Tokenizer\Parameter; use Magento\Framework\Filter\Template\Tokenizer\ParameterFactory; use Magento\Framework\DB\DataConverter\SerializedToJson; @@ -31,6 +34,11 @@ class CmsConverter extends SerializedToJson */ protected $cmsBlocks = []; + /** + * @var ReadInterface|null + */ + private ?ReadInterface $mediaDirectory = null; + /** * @param Normalizer $normalizer * @param ParameterFactory $parameterFactory @@ -41,6 +49,7 @@ class CmsConverter extends SerializedToJson * @param ManagerInterface $messageManager * @param DeploymentConfig $deploymentConfig * @param PathValidator $pathValidator + * @param Filesystem $filesystem */ public function __construct( protected Normalizer $normalizer, @@ -51,7 +60,8 @@ public function __construct( protected StoreManagerInterface $storeManager, protected ManagerInterface $messageManager, protected DeploymentConfig $deploymentConfig, - protected PathValidator $pathValidator + protected PathValidator $pathValidator, + protected Filesystem $filesystem ) { parent::__construct($serialize, $json); } @@ -139,6 +149,66 @@ public function convert($value, bool $child = false) : array|string return ["value" => $convertedValue, "assets" => $this->assets, "children" => $this->cmsBlocks]; } + /** + * The pub/media directory, resolved lazily since not every convert() call needs it. + * + * @return ReadInterface + */ + private function getMediaDirectory(): ReadInterface + { + if ($this->mediaDirectory === null) { + $this->mediaDirectory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); + } + return $this->mediaDirectory; + } + + /** + * Register $value as a template asset when it resolves to a real file under pub/media. + * + * Widget image fields (the plain "Banner image" field as much as an item inside a + * repeatable row) do not necessarily store an absolute URL - the stock media browser + * dialog fills them in with a bare path relative to pub/media (e.g. "wysiwyg/x.jpg"), + * the same convention {{media url="..."}} directives use. The previous asset scan only + * ever looked inside repeatable_* / conditions_encoded parameters and only recognised a + * full "https://host/media/..." URL, so a plain image parameter - or any image field + * storing the bare relative form - was never added to the export archive at all, and + * silently missing once re-imported on another environment. Checking the filesystem + * instead of guessing from a naming convention or a URL shape catches every form + * (absolute URL, root-relative "/media/...", or bare relative path) and works for any + * widget's image field, not just the ones whose parameter names this converter knows. + * + * @param string $value + * @return void + */ + private function collectMediaAsset(string $value): void + { + $value = trim($value); + if ($value === '' || strlen($value) > 2048) { + return; + } + + $candidate = $value; + if (preg_match('#^https?://[^/\s]+(/.*)$#i', $value, $matches)) { + $candidate = $matches[1]; + } + $candidate = ltrim(str_replace('\\', '/', $candidate), '/'); + if (str_starts_with($candidate, 'media/')) { + $candidate = substr($candidate, strlen('media/')); + } + + if ($candidate === '' + || !$this->pathValidator->isSafeRelativePath($candidate) + || !$this->getMediaDirectory()->isFile($candidate) + ) { + return; + } + + $asset = 'media/' . $candidate; + if (!in_array($asset, $this->assets, true)) { + $this->assets[] = $asset; + } + } + /** * Whether an extracted asset path is a safe file under pub/media. * @@ -246,6 +316,27 @@ private function convertWidgetParams($paramsString) : string } } + // Scan every parameter for media assets, not just the ones the rewrite pass below + // touches: a plain (non-repeatable) image parameter never reaches that pass at all, + // and it only recognises absolute URLs, so a bare relative path - the format the + // media browser actually stores - was skipped even inside a repeatable row. + foreach ($widgetParameters as $key => $value) { + if (in_array($key, $keysToUnserialize, true)) { + if ($this->isValidJsonValue($value)) { + $decodedRows = $this->json->unserialize($this->normalizer->restoreReservedCharacters($value)); + foreach ((array)$decodedRows as $row) { + foreach ((array)$row as $fieldValue) { + if (is_string($fieldValue)) { + $this->collectMediaAsset($fieldValue); + } + } + } + } + } elseif (is_string($value)) { + $this->collectMediaAsset($value); + } + } + if (!empty($keysToUnserialize)) { foreach ($keysToUnserialize as $key) { if ($this->isValidJsonValue($widgetParameters[$key])) { From 2ed11e180b7be566a24dc2343e04654c43142860 Mon Sep 17 00:00:00 2001 From: Davide Lunardon Date: Wed, 16 Sep 2026 12:23:25 +0200 Subject: [PATCH 2/2] Update CHANGELOG for version 1.10.0 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e1b99..1ad41d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## 1.10.0 - 2026-09-16 +### Fixed +- Export widget image fields (advanced-widget included) that store a bare `pub/media`-relative path instead of an + absolute URL, or that are not inside a `repeatable_*`/`conditions_encoded` row. These were previously skipped + entirely and went missing on re-import elsewhere. + ## 1.9.0 - 2026-08-05 ### Security - Restrict PageBuilder template import to `.zip` uploads