| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Modules\BlockPatterns; |
| 4 |
|
| 5 |
use Templately\Utils\Base; |
| 6 |
use Templately\Utils\Helper; |
| 7 |
|
| 8 |
/** |
| 9 |
* Save-time media localization for natively-inserted patterns. |
| 10 |
* |
| 11 |
* A pattern inserted from the block inserter is pure client-side work — no |
| 12 |
* plugin code runs — so its Templately-hosted images would otherwise stay |
| 13 |
* hot-linked in user content forever. On save this scans for allow-listed |
| 14 |
* hosts ONLY (user media is never touched), sideloads what it finds, and |
| 15 |
* rewrites the content to the local copies. |
| 16 |
* |
| 17 |
* Dedup is PERSISTENT, keyed by the size-suffix-normalized source URL in |
| 18 |
* `_templately_source_url` attachment meta: the FSI import path dedups only |
| 19 |
* within a single request (a static array), so the same pattern inserted on |
| 20 |
* five posts would otherwise import the same hero image five times |
| 21 |
* (spec 051 FR-008; research D6/§6.5). |
| 22 |
*/ |
| 23 |
class MediaLocalizer extends Base { |
| 24 |
|
| 25 |
const EVENT_LOCALIZE_REST = 'templately_block_patterns_localize_rest'; |
| 26 |
const SOURCE_URL_META = '_templately_source_url'; |
| 27 |
const DEFAULT_BUDGET = 10; |
| 28 |
|
| 29 |
const IMAGE_URL_PATTERN = '/(https?:\/\/[^\s<>"\']+\.(jpg|jpeg|gif|png|svg|webp|ico|tiff|tif)(?:\?[^\s<>"\']*)?)/i'; |
| 30 |
|
| 31 |
/** |
| 32 |
* Guard against re-entry: this class calls wp_update_post(), which fires |
| 33 |
* wp_after_insert_post again. |
| 34 |
* |
| 35 |
* @var bool |
| 36 |
*/ |
| 37 |
private $localizing = false; |
| 38 |
|
| 39 |
/** |
| 40 |
* @param int $post_id |
| 41 |
* @param \WP_Post|null $post |
| 42 |
*/ |
| 43 |
public function maybe_localize( $post_id, $post = null ): void { |
| 44 |
if ( $this->localizing ) { |
| 45 |
return; |
| 46 |
} |
| 47 |
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { |
| 48 |
return; |
| 49 |
} |
| 50 |
|
| 51 |
$post = $post instanceof \WP_Post ? $post : get_post( $post_id ); |
| 52 |
if ( ! $post instanceof \WP_Post || 'attachment' === $post->post_type ) { |
| 53 |
return; |
| 54 |
} |
| 55 |
|
| 56 |
$this->localize( $post, $this->budget() ); |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Deferred remainder pass (cron): same work, no budget. |
| 61 |
*/ |
| 62 |
public function localize_deferred( $post_id ): void { |
| 63 |
$post = get_post( (int) $post_id ); |
| 64 |
if ( $post instanceof \WP_Post ) { |
| 65 |
$this->localize( $post, PHP_INT_MAX ); |
| 66 |
} |
| 67 |
} |
| 68 |
|
| 69 |
/** |
| 70 |
* Import the media referenced by a content string and return a |
| 71 |
* remote-URL => local-URL map. |
| 72 |
* |
| 73 |
* Deliberately separate from the content fetch: holding the pattern back |
| 74 |
* while up to ten images download makes insertion feel broken. The editor |
| 75 |
* shows the design immediately with its demo URLs, then swaps them for local |
| 76 |
* ones once this returns. |
| 77 |
* |
| 78 |
* @return array<string, string> Keyed by the exact URL as it appears in the markup. |
| 79 |
*/ |
| 80 |
public function build_url_map( string $content, int $post_id = 0 ): array { |
| 81 |
$hosts = $this->allowed_hosts(); |
| 82 |
if ( empty( $hosts ) || '' === $content ) { |
| 83 |
return []; |
| 84 |
} |
| 85 |
|
| 86 |
$map = []; |
| 87 |
$budget = $this->budget(); |
| 88 |
$done = 0; |
| 89 |
|
| 90 |
foreach ( $this->find_localizable_urls( $content, $hosts ) as $url ) { |
| 91 |
if ( $done >= $budget ) { |
| 92 |
break; |
| 93 |
} |
| 94 |
$done++; |
| 95 |
|
| 96 |
$attachment_id = $this->resolve_attachment( $url, $post_id ); |
| 97 |
if ( ! $attachment_id ) { |
| 98 |
continue; |
| 99 |
} |
| 100 |
|
| 101 |
$local_url = wp_get_attachment_url( $attachment_id ); |
| 102 |
if ( ! $local_url ) { |
| 103 |
continue; |
| 104 |
} |
| 105 |
|
| 106 |
// Every size-suffixed variant in the markup maps to the same local file. |
| 107 |
foreach ( $this->variants_in( $content, $url ) as $variant ) { |
| 108 |
$map[ $variant ] = $local_url; |
| 109 |
} |
| 110 |
} |
| 111 |
|
| 112 |
return $map; |
| 113 |
} |
| 114 |
|
| 115 |
/** |
| 116 |
* Localize a content string in one pass and hand it back. |
| 117 |
* |
| 118 |
* Kept for callers that genuinely want to block until the media is local |
| 119 |
* (the save-time path); the INSERT path uses build_url_map instead so the |
| 120 |
* design never waits on downloads. |
| 121 |
*/ |
| 122 |
public function localize_content( string $content, int $post_id = 0 ): string { |
| 123 |
$hosts = $this->allowed_hosts(); |
| 124 |
if ( empty( $hosts ) || '' === $content ) { |
| 125 |
return $content; |
| 126 |
} |
| 127 |
|
| 128 |
$urls = $this->find_localizable_urls( $content, $hosts ); |
| 129 |
if ( empty( $urls ) ) { |
| 130 |
return $content; |
| 131 |
} |
| 132 |
|
| 133 |
$budget = $this->budget(); |
| 134 |
$processed = 0; |
| 135 |
|
| 136 |
foreach ( $urls as $url ) { |
| 137 |
if ( $processed >= $budget ) { |
| 138 |
break; |
| 139 |
} |
| 140 |
$processed++; |
| 141 |
|
| 142 |
$attachment_id = $this->resolve_attachment( $url, $post_id ); |
| 143 |
if ( ! $attachment_id ) { |
| 144 |
continue; |
| 145 |
} |
| 146 |
|
| 147 |
$local_url = wp_get_attachment_url( $attachment_id ); |
| 148 |
if ( ! $local_url ) { |
| 149 |
continue; |
| 150 |
} |
| 151 |
|
| 152 |
foreach ( $this->variants_in( $content, $url ) as $variant ) { |
| 153 |
$content = str_replace( $variant, $local_url, $content ); |
| 154 |
} |
| 155 |
} |
| 156 |
|
| 157 |
return $content; |
| 158 |
} |
| 159 |
|
| 160 |
private function localize( \WP_Post $post, int $budget ): void { |
| 161 |
$hosts = $this->allowed_hosts(); |
| 162 |
if ( empty( $hosts ) ) { |
| 163 |
return; // localizer disabled |
| 164 |
} |
| 165 |
|
| 166 |
$content = (string) $post->post_content; |
| 167 |
$urls = $this->find_localizable_urls( $content, $hosts ); |
| 168 |
if ( empty( $urls ) ) { |
| 169 |
return; |
| 170 |
} |
| 171 |
|
| 172 |
$processed = 0; |
| 173 |
$replaced = false; |
| 174 |
foreach ( $urls as $url ) { |
| 175 |
if ( $processed >= $budget ) { |
| 176 |
// Never block or fail the user's save — finish the rest later. |
| 177 |
$this->schedule_remainder( $post->ID ); |
| 178 |
break; |
| 179 |
} |
| 180 |
|
| 181 |
$attachment_id = $this->resolve_attachment( $url, $post->ID ); |
| 182 |
$processed++; |
| 183 |
|
| 184 |
if ( ! $attachment_id ) { |
| 185 |
continue; |
| 186 |
} |
| 187 |
|
| 188 |
$local_url = wp_get_attachment_url( $attachment_id ); |
| 189 |
if ( ! $local_url ) { |
| 190 |
continue; |
| 191 |
} |
| 192 |
|
| 193 |
// Replace the base URL and every size-suffixed variant of it. |
| 194 |
foreach ( $this->variants_in( $content, $url ) as $variant ) { |
| 195 |
$content = str_replace( $variant, $local_url, $content ); |
| 196 |
$replaced = true; |
| 197 |
} |
| 198 |
} |
| 199 |
|
| 200 |
if ( ! $replaced ) { |
| 201 |
return; |
| 202 |
} |
| 203 |
|
| 204 |
$this->localizing = true; |
| 205 |
wp_update_post( [ |
| 206 |
'ID' => $post->ID, |
| 207 |
'post_content' => wp_slash( $content ), |
| 208 |
] ); |
| 209 |
$this->localizing = false; |
| 210 |
} |
| 211 |
|
| 212 |
/** |
| 213 |
* Distinct, size-suffix-normalized source URLs on allow-listed hosts. |
| 214 |
* |
| 215 |
* @return string[] |
| 216 |
*/ |
| 217 |
private function find_localizable_urls( string $content, array $hosts ): array { |
| 218 |
preg_match_all( self::IMAGE_URL_PATTERN, $content, $matches ); |
| 219 |
|
| 220 |
$urls = []; |
| 221 |
foreach ( (array) $matches[0] as $url ) { |
| 222 |
$host = wp_parse_url( $url, PHP_URL_HOST ); |
| 223 |
if ( ! $host || ! $this->host_allowed( (string) $host, $hosts ) ) { |
| 224 |
continue; |
| 225 |
} |
| 226 |
$base = $this->normalize_url( $url ); |
| 227 |
if ( ! in_array( $base, $urls, true ) ) { |
| 228 |
$urls[] = $base; |
| 229 |
} |
| 230 |
} |
| 231 |
|
| 232 |
return $urls; |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* Every literal occurrence in the content that maps to this base URL — |
| 237 |
* the base itself plus any `-300x200`-style size variants. |
| 238 |
* |
| 239 |
* @return string[] |
| 240 |
*/ |
| 241 |
private function variants_in( string $content, string $base_url ): array { |
| 242 |
preg_match_all( self::IMAGE_URL_PATTERN, $content, $matches ); |
| 243 |
|
| 244 |
$variants = []; |
| 245 |
foreach ( (array) $matches[0] as $url ) { |
| 246 |
if ( $this->normalize_url( $url ) === $base_url && ! in_array( $url, $variants, true ) ) { |
| 247 |
$variants[] = $url; |
| 248 |
} |
| 249 |
} |
| 250 |
|
| 251 |
return $variants; |
| 252 |
} |
| 253 |
|
| 254 |
private function normalize_url( string $url ): string { |
| 255 |
$url = strtok( $url, '?' ); |
| 256 |
|
| 257 |
return preg_replace( '/-\d+x\d+(?=\.[a-zA-Z]+$)/', '', $url ); |
| 258 |
} |
| 259 |
|
| 260 |
/** |
| 261 |
* Existing attachment for this source URL, or a freshly sideloaded one. |
| 262 |
*/ |
| 263 |
private function resolve_attachment( string $source_url, int $post_id ): ?int { |
| 264 |
$existing = $this->find_existing( $source_url ); |
| 265 |
if ( $existing ) { |
| 266 |
return $existing; |
| 267 |
} |
| 268 |
|
| 269 |
/** |
| 270 |
* Short-circuit the download+sideload step. Returning an attachment id |
| 271 |
* (or null) skips the built-in implementation. |
| 272 |
* |
| 273 |
* @param int|null $pre |
| 274 |
* @param string $source_url |
| 275 |
* @param int $post_id |
| 276 |
*/ |
| 277 |
$pre = apply_filters( 'templately_block_patterns_sideload', null, $source_url, $post_id ); |
| 278 |
$attachment_id = null === $pre ? $this->sideload( $source_url, $post_id ) : $pre; |
| 279 |
|
| 280 |
if ( ! $attachment_id || is_wp_error( $attachment_id ) ) { |
| 281 |
Helper::log( "block-patterns: sideload failed for {$source_url}" ); |
| 282 |
|
| 283 |
return null; |
| 284 |
} |
| 285 |
|
| 286 |
update_post_meta( (int) $attachment_id, self::SOURCE_URL_META, $source_url ); |
| 287 |
|
| 288 |
return (int) $attachment_id; |
| 289 |
} |
| 290 |
|
| 291 |
private function find_existing( string $source_url ): ?int { |
| 292 |
$found = get_posts( [ |
| 293 |
'post_type' => 'attachment', |
| 294 |
'post_status' => 'inherit', |
| 295 |
'numberposts' => 1, |
| 296 |
'fields' => 'ids', |
| 297 |
'meta_key' => self::SOURCE_URL_META, |
| 298 |
'meta_value' => $source_url, |
| 299 |
'suppress_filters' => false, |
| 300 |
] ); |
| 301 |
|
| 302 |
return ! empty( $found ) ? (int) $found[0] : null; |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* @return int|\WP_Error |
| 307 |
*/ |
| 308 |
private function sideload( string $source_url, int $post_id ) { |
| 309 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 310 |
require_once ABSPATH . 'wp-admin/includes/media.php'; |
| 311 |
require_once ABSPATH . 'wp-admin/includes/image.php'; |
| 312 |
|
| 313 |
$tmp = download_url( $source_url ); |
| 314 |
if ( is_wp_error( $tmp ) ) { |
| 315 |
return $tmp; |
| 316 |
} |
| 317 |
|
| 318 |
$file_array = [ |
| 319 |
'name' => basename( strtok( $source_url, '?' ) ), |
| 320 |
'tmp_name' => $tmp, |
| 321 |
]; |
| 322 |
|
| 323 |
$attachment_id = media_handle_sideload( $file_array, $post_id ); |
| 324 |
|
| 325 |
if ( is_wp_error( $attachment_id ) && file_exists( $tmp ) ) { |
| 326 |
wp_delete_file( $tmp ); |
| 327 |
} |
| 328 |
|
| 329 |
return $attachment_id; |
| 330 |
} |
| 331 |
|
| 332 |
/** |
| 333 |
* Remove media this module imported for a post that is being deleted. |
| 334 |
* |
| 335 |
* Insert-time import means a user who tries a pattern and then discards the |
| 336 |
* draft would otherwise leave its images behind in the media library. Only |
| 337 |
* attachments this module created (they carry the source-URL meta), only |
| 338 |
* those parented to the post, and only when no OTHER post still references |
| 339 |
* them — a shared image stays. |
| 340 |
*/ |
| 341 |
public function cleanup_for_post( $post_id ): void { |
| 342 |
$post_id = (int) $post_id; |
| 343 |
if ( ! $post_id || wp_is_post_revision( $post_id ) ) { |
| 344 |
return; |
| 345 |
} |
| 346 |
|
| 347 |
$attachments = get_posts( [ |
| 348 |
'post_type' => 'attachment', |
| 349 |
'post_status' => 'inherit', |
| 350 |
'post_parent' => $post_id, |
| 351 |
'numberposts' => -1, |
| 352 |
'fields' => 'ids', |
| 353 |
'meta_key' => self::SOURCE_URL_META, |
| 354 |
'suppress_filters' => false, |
| 355 |
] ); |
| 356 |
|
| 357 |
foreach ( (array) $attachments as $attachment_id ) { |
| 358 |
$url = wp_get_attachment_url( (int) $attachment_id ); |
| 359 |
if ( $url && $this->url_used_elsewhere( $url, $post_id ) ) { |
| 360 |
continue; |
| 361 |
} |
| 362 |
wp_delete_attachment( (int) $attachment_id, true ); |
| 363 |
} |
| 364 |
} |
| 365 |
|
| 366 |
/** |
| 367 |
* Whether any post other than $exclude_id still references this URL. |
| 368 |
*/ |
| 369 |
private function url_used_elsewhere( string $url, int $exclude_id ): bool { |
| 370 |
global $wpdb; |
| 371 |
|
| 372 |
$found = $wpdb->get_var( |
| 373 |
$wpdb->prepare( |
| 374 |
"SELECT ID FROM {$wpdb->posts} |
| 375 |
WHERE post_content LIKE %s |
| 376 |
AND ID != %d |
| 377 |
AND post_type NOT IN ( 'revision', 'attachment' ) |
| 378 |
AND post_status != 'trash' |
| 379 |
LIMIT 1", |
| 380 |
'%' . $wpdb->esc_like( $url ) . '%', |
| 381 |
$exclude_id |
| 382 |
) |
| 383 |
); |
| 384 |
|
| 385 |
return ! empty( $found ); |
| 386 |
} |
| 387 |
|
| 388 |
private function schedule_remainder( int $post_id ): void { |
| 389 |
if ( ! wp_next_scheduled( self::EVENT_LOCALIZE_REST, [ $post_id ] ) ) { |
| 390 |
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::EVENT_LOCALIZE_REST, [ $post_id ] ); |
| 391 |
} |
| 392 |
} |
| 393 |
|
| 394 |
private function budget(): int { |
| 395 |
return (int) apply_filters( 'templately_block_patterns_media_budget', self::DEFAULT_BUDGET ); |
| 396 |
} |
| 397 |
|
| 398 |
/** |
| 399 |
* Host suffixes whose images this module may import. An empty list disables |
| 400 |
* the localizer entirely. |
| 401 |
* |
| 402 |
* Suffixes, not exact hosts: real pattern content serves its media from |
| 403 |
* `demo.assets.templately.com`, and an earlier hardcoded list of guessed |
| 404 |
* hostnames (app./cdn.) matched nothing, so localization silently did |
| 405 |
* nothing at all. Matching the domain covers whichever subdomain the cloud |
| 406 |
* uses without another round of guessing. |
| 407 |
* |
| 408 |
* @return string[] |
| 409 |
*/ |
| 410 |
private function allowed_hosts(): array { |
| 411 |
$hosts = apply_filters( 'templately_block_patterns_media_hosts', [ |
| 412 |
'templately.com', |
| 413 |
'templately.dev', |
| 414 |
] ); |
| 415 |
|
| 416 |
return array_map( 'strtolower', array_filter( (array) $hosts ) ); |
| 417 |
} |
| 418 |
|
| 419 |
/** |
| 420 |
* Whether a host is covered by the allow-list — an exact match, or a |
| 421 |
* subdomain of a listed domain. Deliberately anchored on a leading dot so |
| 422 |
* `nottemplately.com` cannot match `templately.com`. |
| 423 |
*/ |
| 424 |
private function host_allowed( string $host, array $hosts ): bool { |
| 425 |
$host = strtolower( $host ); |
| 426 |
|
| 427 |
foreach ( $hosts as $allowed ) { |
| 428 |
if ( $host === $allowed || substr( $host, -strlen( '.' . $allowed ) ) === '.' . $allowed ) { |
| 429 |
return true; |
| 430 |
} |
| 431 |
} |
| 432 |
|
| 433 |
return false; |
| 434 |
} |
| 435 |
} |
| 436 |
|