Skip to content

Commit 8e1b26a

Browse files
Merge remote-tracking branch 'upstream/v2.1.5'
2 parents 417320d + f805830 commit 8e1b26a

12 files changed

Lines changed: 447 additions & 23 deletions

File tree

Controller/Adminhtml/Ajax/UpdateAdminImage.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,23 @@ public function execute()
107107
}
108108

109109
$cleanUrl = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . $parsedUrl['path'];
110-
$baseUrl = $this->storeManager->getStore()->getBaseUrl();
111-
$relativePath = str_replace($baseUrl, '', $cleanUrl);
112-
$relativePath = ltrim($relativePath, '/');
110+
111+
// Derive the relative path from the URL path component directly — the admin
112+
// store's base URL doesn't always match the public storefront URL used by the
113+
// media gallery, so str_replace($baseUrl, ...) can leave the host in the path.
114+
$relativePath = ltrim($parsedUrl['path'], '/');
115+
116+
// Strip leading `pub/` when Magento is served from <docroot>/pub/.
117+
$relativePath = preg_replace('#^pub/#', '', $relativePath);
118+
119+
// Strip the `.renditions/` segment so Cloudinary fetches the original image
120+
// instead of the local PageBuilder/admin-gallery rendition derivative, which
121+
// 404s through the auto-upload mapping (mapping points at the original path).
122+
$relativePath = preg_replace('#(^|/)\.renditions/#', '$1', $relativePath);
123+
$cleanUrl = preg_replace('#/\.renditions/#', '/', $cleanUrl);
113124

114125
// Create Image object and use UrlGenerator (same as storefront)
115-
$image = Image::fromPath($remoteImageUrl, $relativePath);
126+
$image = Image::fromPath($cleanUrl, $relativePath);
116127

117128
// Use UrlGenerator which handles all the logic including database mapping
118129
$cloudinaryUrl = $this->urlGenerator->generateFor($image, $this->transformation);
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace Cloudinary\Cloudinary\Model\Config\Backend;
4+
5+
use Magento\Framework\App\Config\Value;
6+
use Magento\Framework\Exception\ValidatorException;
7+
8+
class CustomMediaPaths extends Value
9+
{
10+
/**
11+
* Paths already handled by existing Cloudinary plugins
12+
*/
13+
private const RESERVED_PATHS = [
14+
'catalog/product',
15+
'catalog/category',
16+
'wysiwyg',
17+
];
18+
19+
/**
20+
* @return CustomMediaPaths
21+
* @throws ValidatorException
22+
*/
23+
public function beforeSave()
24+
{
25+
$value = (string) $this->getValue();
26+
if ($value === '') {
27+
return parent::beforeSave();
28+
}
29+
30+
$lines = explode("\n", $value);
31+
$cleaned = [];
32+
33+
foreach ($lines as $line) {
34+
$path = trim($line);
35+
$path = trim($path, '/');
36+
37+
if ($path === '') {
38+
continue;
39+
}
40+
41+
if (strpos($path, '..') !== false) {
42+
throw new ValidatorException(
43+
__('Invalid path "%1": path traversal ("..") is not allowed.', $path)
44+
);
45+
}
46+
47+
if (!preg_match('#^[a-zA-Z0-9_\-/]+$#', $path)) {
48+
throw new ValidatorException(
49+
__('Invalid path "%1": only alphanumeric characters, hyphens, underscores, and forward slashes are allowed.', $path)
50+
);
51+
}
52+
53+
foreach (self::RESERVED_PATHS as $reserved) {
54+
if (strpos($path, $reserved) === 0) {
55+
throw new ValidatorException(
56+
__('Path "%1" is already handled by Cloudinary and cannot be added as a custom path.', $path)
57+
);
58+
}
59+
}
60+
61+
$cleaned[] = $path;
62+
}
63+
64+
$this->setValue(implode("\n", $cleaned));
65+
66+
return parent::beforeSave();
67+
}
68+
}

Model/Configuration.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ class Configuration implements ConfigurationInterface
7373

7474
const CONFIG_PATH_CUSTOM_PLACEHOLDER_IMAGE = 'cloudinary/advanced/custom_placeholder_image';
7575

76+
const CONFIG_PATH_CUSTOM_MEDIA_PATH_ENABLED = 'cloudinary/advanced/custom_media_path_enabled';
77+
const CONFIG_PATH_CUSTOM_MEDIA_PATHS = 'cloudinary/advanced/custom_media_paths';
78+
7679

7780
//= Product Gallery
7881
const CONFIG_PATH_PG_ALL = 'cloudinary/product_gallery';
@@ -538,6 +541,26 @@ public function getCustomPlaceholderPath(): ?string
538541
return null;
539542
}
540543

544+
/**
545+
* @return bool
546+
*/
547+
public function isEnabledCustomMediaPath(): bool
548+
{
549+
return (bool) $this->configReader->getValue(self::CONFIG_PATH_CUSTOM_MEDIA_PATH_ENABLED);
550+
}
551+
552+
/**
553+
* @return array
554+
*/
555+
public function getCustomMediaPaths(): array
556+
{
557+
$value = (string) $this->configReader->getValue(self::CONFIG_PATH_CUSTOM_MEDIA_PATHS);
558+
if (!$value) {
559+
return [];
560+
}
561+
return array_filter(array_map('trim', explode("\n", $value)));
562+
}
563+
541564
public function isEnabledLazyload()
542565
{
543566
return (bool) $this->configReader->getValue(self::XML_PATH_LAZYLOAD_ENABLED);
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
<?php
2+
3+
namespace Cloudinary\Cloudinary\Plugin\CustomMediaPath;
4+
5+
use Cloudinary\Cloudinary\Core\AutoUploadMapping\AutoUploadConfigurationInterface;
6+
use Cloudinary\Cloudinary\Core\Image;
7+
use Cloudinary\Cloudinary\Core\UrlGenerator;
8+
use Cloudinary\Cloudinary\Model\Configuration;
9+
use Cloudinary\Cloudinary\Model\Logger as CloudinaryLogger;
10+
use Cloudinary\Cloudinary\Model\SynchronisationRepository;
11+
use Magento\Framework\App\ResponseInterface;
12+
use Magento\Framework\HTTP\PhpEnvironment\Response as HttpResponse;
13+
use Magento\Framework\View\Result\Layout;
14+
15+
class HtmlReplacer
16+
{
17+
/**
18+
* @var Configuration
19+
*/
20+
private $configuration;
21+
22+
/**
23+
* @var UrlGenerator
24+
*/
25+
private $urlGenerator;
26+
27+
/**
28+
* @var SynchronisationRepository
29+
*/
30+
private $synchronisationRepository;
31+
32+
/**
33+
* @var AutoUploadConfigurationInterface
34+
*/
35+
private $autoUploadConfiguration;
36+
37+
/**
38+
* @var CloudinaryLogger
39+
*/
40+
private $logger;
41+
42+
/**
43+
* @param Configuration $configuration
44+
* @param UrlGenerator $urlGenerator
45+
* @param SynchronisationRepository $synchronisationRepository
46+
* @param AutoUploadConfigurationInterface $autoUploadConfiguration
47+
* @param CloudinaryLogger $logger
48+
*/
49+
public function __construct(
50+
Configuration $configuration,
51+
UrlGenerator $urlGenerator,
52+
SynchronisationRepository $synchronisationRepository,
53+
AutoUploadConfigurationInterface $autoUploadConfiguration,
54+
CloudinaryLogger $logger
55+
) {
56+
$this->configuration = $configuration;
57+
$this->urlGenerator = $urlGenerator;
58+
$this->synchronisationRepository = $synchronisationRepository;
59+
$this->autoUploadConfiguration = $autoUploadConfiguration;
60+
$this->logger = $logger;
61+
}
62+
63+
/**
64+
* @param Layout $subject
65+
* @param callable $proceed
66+
* @param ResponseInterface $httpResponse
67+
* @return mixed
68+
*/
69+
public function aroundRenderResult(Layout $subject, callable $proceed, ResponseInterface $httpResponse)
70+
{
71+
$result = $proceed($httpResponse);
72+
73+
if (!($httpResponse instanceof HttpResponse) || !$this->shouldProcess()) {
74+
return $result;
75+
}
76+
77+
$html = $httpResponse->getBody();
78+
if (!$html) {
79+
return $result;
80+
}
81+
82+
$mediaBaseUrl = $this->configuration->getMediaBaseUrl();
83+
$customPaths = $this->configuration->getCustomMediaPaths();
84+
85+
// Quick check: does the HTML contain any of the custom paths?
86+
$matchingPaths = [];
87+
foreach ($customPaths as $path) {
88+
if (stripos($html, $path . '/') !== false) {
89+
$matchingPaths[] = $path;
90+
}
91+
}
92+
93+
if (empty($matchingPaths)) {
94+
return $result;
95+
}
96+
97+
$replacedHtml = $html;
98+
99+
// Step 1: Normalize PageBuilder rendition URLs (both local and Cloudinary forms)
100+
// so Cloudinary fetches the original image instead of the rendition derivative.
101+
$replacedHtml = $this->normalizeRenditionUrls($replacedHtml, $matchingPaths);
102+
103+
// Step 2: Revert unsynced Cloudinary URLs back to local URLs
104+
$replacedHtml = $this->revertUnsyncedCloudinaryUrls($replacedHtml, $mediaBaseUrl, $matchingPaths);
105+
106+
// Step 3: Replace local URLs with Cloudinary URLs for synced images
107+
$replacedHtml = $this->replaceLocalUrls($replacedHtml, $mediaBaseUrl, $matchingPaths);
108+
109+
if ($replacedHtml !== $html) {
110+
$httpResponse->setBody($replacedHtml);
111+
}
112+
113+
return $result;
114+
}
115+
116+
/**
117+
* @return bool
118+
*/
119+
private function shouldProcess(): bool
120+
{
121+
return $this->configuration->isEnabled()
122+
&& $this->configuration->isEnabledCustomMediaPath()
123+
&& !empty($this->configuration->getCustomMediaPaths());
124+
}
125+
126+
/**
127+
* Strip the `.renditions/` segment from URLs pointing to PageBuilder-generated
128+
* rendition derivatives under a configured custom media path.
129+
*
130+
* PageBuilder writes a resized preview to pub/media/.renditions/<original-path>/
131+
* and outputs URLs pointing at it. Because that file lives under a dot-prefixed
132+
* directory (commonly blocked at the web-server level) and the auto-upload mapping
133+
* is configured for the original path, those URLs 404. Cloudinary handles resizing
134+
* itself, so we rewrite to the original path in both local and Cloudinary form.
135+
*
136+
* @param string $html
137+
* @param array $paths
138+
* @return string
139+
*/
140+
private function normalizeRenditionUrls(string $html, array $paths): string
141+
{
142+
$pathAlternation = implode('|', array_map(function ($p) {
143+
return preg_quote($p, '~');
144+
}, $paths));
145+
146+
// `/media/.renditions/<custom-path>/…` → `/media/<custom-path>/…`
147+
// Covers both local media URLs and already-rewritten Cloudinary URLs.
148+
$pattern = '~(/media/)\.renditions/((?:' . $pathAlternation . ')/)~i';
149+
150+
return preg_replace($pattern, '$1$2', $html);
151+
}
152+
153+
/**
154+
* Revert Cloudinary URLs back to local URLs for images that aren't actually synced.
155+
* This handles cases where other plugins (e.g., Widget Template Filter) converted
156+
* {{media url=...}} directives to Cloudinary URLs based on auto-upload mapping being active,
157+
* even though the image hasn't been uploaded to Cloudinary yet.
158+
*
159+
* @param string $html
160+
* @param string $mediaBaseUrl
161+
* @param array $paths
162+
* @return string
163+
*/
164+
private function revertUnsyncedCloudinaryUrls(string $html, string $mediaBaseUrl, array $paths): string
165+
{
166+
// When auto-upload is active, Cloudinary URLs are valid even without sync — no reverting needed.
167+
if ($this->autoUploadConfiguration->isActive()) {
168+
return $html;
169+
}
170+
171+
$pathAlternation = implode('|', array_map(function ($p) {
172+
return preg_quote($p, '~');
173+
}, $paths));
174+
175+
// Match Cloudinary URLs containing custom media paths
176+
// Pattern: https://res.cloudinary.com/{cloud}/.../media/{custom_path}/...{file}.{ext}?_i=AB
177+
$pattern = '~https?://[^\s\'"]+/(?:media/(?:' . $pathAlternation . ')/[^\s\'"\\\\)?#]+\.(?:jpe?g|png|gif|webp|svg|avif))(?:\?[^\s\'"]*)?~i';
178+
179+
return preg_replace_callback($pattern, function ($matches) use ($mediaBaseUrl, $pathAlternation) {
180+
$fullUrl = $matches[0];
181+
182+
// Extract the media-relative path from the Cloudinary URL
183+
if (!preg_match('~(media/(?:' . $pathAlternation . ')/[^\s\'"\\\\)?#]+\.(?:jpe?g|png|gif|webp|svg|avif))~i', $fullUrl, $pathMatch)) {
184+
return $fullUrl;
185+
}
186+
187+
$migratedPath = $pathMatch[1];
188+
189+
$isSynced = $this->synchronisationRepository->isSynchronizedImagePath($migratedPath);
190+
$this->logger->info('[CustomMediaPath] Revert check: ' . $migratedPath . ' synced=' . ($isSynced ? 'YES' : 'NO'));
191+
192+
// If the image IS synced, keep the Cloudinary URL
193+
if ($isSynced) {
194+
return $fullUrl;
195+
}
196+
197+
// Not synced — revert to local URL
198+
$relativePath = preg_replace('#^media/#', '', $migratedPath);
199+
$this->logger->info('[CustomMediaPath] Reverting to local: ' . $mediaBaseUrl . $relativePath);
200+
return $mediaBaseUrl . $relativePath;
201+
}, $html);
202+
}
203+
204+
/**
205+
* Replace local media URLs with Cloudinary URLs for synced images.
206+
*
207+
* @param string $html
208+
* @param string $mediaBaseUrl
209+
* @param array $paths
210+
* @return string
211+
*/
212+
private function replaceLocalUrls(string $html, string $mediaBaseUrl, array $paths): string
213+
{
214+
$mediaBaseUrlEscaped = preg_quote($mediaBaseUrl, '~');
215+
$pathAlternation = implode('|', array_map(function ($p) {
216+
return preg_quote($p, '~');
217+
}, $paths));
218+
219+
$pattern = '~(' . $mediaBaseUrlEscaped . '(?:' . $pathAlternation . ')/[^\s\'"\\\\)?#]+\.(?:jpe?g|png|gif|webp|svg|avif))~i';
220+
221+
$autoUploadActive = $this->autoUploadConfiguration->isActive();
222+
223+
return preg_replace_callback($pattern, function ($matches) use ($mediaBaseUrl, $autoUploadActive) {
224+
$fullUrl = $matches[1];
225+
226+
$relativePath = str_replace($mediaBaseUrl, '', $fullUrl);
227+
$migratedPath = $this->configuration->getMigratedPath($relativePath);
228+
229+
// When auto-upload is active, Cloudinary fetches the image on first request — no sync needed.
230+
// Otherwise, only replace if the image is already synced to Cloudinary.
231+
if (!$autoUploadActive && !$this->synchronisationRepository->isSynchronizedImagePath($migratedPath)) {
232+
$this->logger->info('[CustomMediaPath] Skipping (not synced, auto-upload inactive): ' . $migratedPath);
233+
return $fullUrl;
234+
}
235+
236+
$image = Image::fromPath($relativePath, $migratedPath);
237+
$cloudinaryUrl = $this->urlGenerator->generateFor($image, $this->configuration->getDefaultTransformation());
238+
239+
$this->logger->info('[CustomMediaPath] Replaced: ' . $fullUrl . ' -> ' . ($cloudinaryUrl ?: 'FAILED'));
240+
return $cloudinaryUrl ?: $fullUrl;
241+
}, $html);
242+
}
243+
}

0 commit comments

Comments
 (0)