PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / block-patterns / MediaLocalizer.php

MediaLocalizer.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/block-patterns/MediaLocalizer.php

436 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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