|
| 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