PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / includes / admin / class-wdkit-import-images.php

class-wdkit-import-images.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/admin/class-wdkit-import-images.php

866 lines 29.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * This file is used when importing media files.
4 *
5 * @link https://posimyth.com/
6 * @since 1.0.0
7 *
8 * @package Wdesignkit
9 */
10
11 /**Exit if accessed directly.*/
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
17
18 /**
19 * Template Import Images Here
20 *
21 * @since 1.0.0
22 */
23 class Wdkit_Import_Images {
24
25 /**
26 * Replaced images IDs.
27 *
28 * The old attachment ID and the new attachment ID generated after the import.
29 *
30 * @since 1.0.0
31 * @access private
32 *
33 * @var array
34 */
35 private static $new_image_ids = array();
36
37 /**
38 * Source URL => local URL for everything localised this request.
39 *
40 * Block markup keeps a rendered copy of the image in innerHTML, and rewriting only the
41 * attributes leaves that copy pointing at the site the template came from.
42 *
43 * @since 2.6.2
44 *
45 * @var array
46 */
47 private static $url_map = array();
48
49 /**
50 * Source URL => local URL for media localised during this request.
51 *
52 * @since 2.6.2
53 *
54 * @return array
55 */
56 public static function get_url_map() {
57 return self::$url_map;
58 }
59
60 /**
61 * Record that a source URL now lives locally.
62 *
63 * @param string $from Source URL.
64 * @param string $to Local URL.
65 */
66 private static function remember_url( $from, $to ) {
67 if ( is_string( $from ) && is_string( $to ) && '' !== $from && '' !== $to && $from !== $to ) {
68 self::$url_map[ $from ] = $to;
69 }
70 }
71
72 /**
73 * Source URLs deferred this request - raster images left pointing at the source CDN
74 * because $defer_media was true. Not populated from the cloud's per-template
75 * `site_images` field, which many templates never have populated - the importer records
76 * these itself, at the exact point it decides to defer, so the background sideload pass
77 * always has an accurate list regardless of what (if anything) the cloud sent.
78 *
79 * @since 2.7.1
80 *
81 * @var array
82 */
83 private static $deferred_urls = array();
84
85 /**
86 * Every image this request could not localise, one entry per failure.
87 *
88 * `log_failed_download()` writes to `Wdkit_Import_Log` for developers reading the
89 * debug log after the fact, but nothing summed those entries into a count a caller
90 * could act on — an import that failed to download ten images still reported full
91 * success everywhere a person or the website actually looks. This is that count,
92 * read the same read-and-clear way `$deferred_urls` above is: once per page/section,
93 * by `Wdkit_Page_Importer::import()`, so it ends up on that page's own result and
94 * from there in the run's aggregate the same way `failed_pages` already is. See
95 * ClickUp 14ynqxywnae.
96 *
97 * @since 2.7.4
98 *
99 * @var array
100 */
101 private static $failed_downloads = array();
102
103 /**
104 * Hand back every image URL that failed to localise so far this request, and empty
105 * the list. Read-and-clear, matching get_and_clear_deferred_urls() above and for the
106 * same reason: each page must only see its own failures, not a previous page's.
107 *
108 * @since 2.7.4
109 *
110 * @return array
111 */
112 public static function get_and_clear_failed_downloads() {
113 $failed = self::$failed_downloads;
114 self::$failed_downloads = array();
115
116 return $failed;
117 }
118
119 /**
120 * Hand back every URL deferred so far this request, and empty the list.
121 *
122 * Read-and-clear in one call so a caller can never forget to reset it for the next item -
123 * each page/section in a kit must only see the URLs its own content deferred, not a
124 * previous item's.
125 *
126 * @since 2.7.1
127 *
128 * @return array
129 */
130 public static function get_and_clear_deferred_urls() {
131 $urls = array_values( array_unique( self::$deferred_urls ) );
132 self::$deferred_urls = array();
133
134 return $urls;
135 }
136
137 /**
138 * Same list, without emptying it.
139 *
140 * The deferral decision is made per-image deep inside the media walk, but the content
141 * rewrite that has to react to it happens back at the top of that walk - which needs to
142 * read the list while the owning caller still has its own read-and-clear to make.
143 *
144 * @since 2.7.1
145 *
146 * @return array
147 */
148 public static function peek_deferred_urls() {
149 return array_values( array_unique( self::$deferred_urls ) );
150 }
151
152 /**
153 * Record URLs that a caller deferred itself, outside the per-image media walk.
154 *
155 * The Gutenberg walk reaches wdkit_Import_media() once per image and can flag each one
156 * as it goes. The Elementor walk cannot: it hands every control to Elementor's own
157 * on_import(), which downloads and rewrites in a single step, so deferring means skipping
158 * that walk entirely and there is no per-image hook left to observe. The Elementor branch
159 * therefore scans the element tree for foreign media itself and reports the result here,
160 * so both builders end up feeding the same list that get_and_clear_deferred_urls()
161 * returns to the import response.
162 *
163 * @since 2.7.1
164 *
165 * @param array $urls Source media URLs left pointing at the template's own site.
166 * @return void
167 */
168 public static function record_deferred_urls( $urls ) {
169 if ( ! is_array( $urls ) ) {
170 return;
171 }
172
173 foreach ( $urls as $url ) {
174 if ( is_string( $url ) && '' !== $url ) {
175 self::$deferred_urls[] = $url;
176 }
177 }
178 }
179
180 /**
181 * How many files the prefetch pass fetches at once, and its safety bounds.
182 *
183 * Concurrency is deliberately modest: this points at one origin (the template CDN),
184 * and the win here is eliminating per-file connection setup, not saturating the host.
185 */
186 const PREFETCH_CONCURRENCY = 8;
187 const PREFETCH_MAX_FILES = 250;
188 const PREFETCH_MAX_BYTES = 2097152;
189
190 /**
191 * Ceiling on the bodies held in self::$prefetched at once.
192 *
193 * The per-file cap alone does not bound this: 250 files x 2 MB is 500 MB of retained
194 * strings, on top of whatever the import itself needs, and the cap was only applied
195 * after the whole body had already been buffered. Warming stops once this is reached;
196 * everything past it falls through to the sequential path, which is the slow case, not
197 * a broken one.
198 */
199 const PREFETCH_MAX_TOTAL_BYTES = 33554432;
200
201 /**
202 * Response bodies already fetched by prefetch_remote_files(), keyed by source URL.
203 *
204 * @since 2.7.1
205 *
206 * @var array
207 */
208 private static $prefetched = array();
209
210 /**
211 * Fetch a batch of remote files concurrently, so wdkit_Import_media() below can take
212 * them from memory instead of paying a blocking round trip each.
213 *
214 * Why this exists: importing a kit walks the block tree one node at a time, and every
215 * node that carries a foreign image used to make its own sequential HTTP request. On a
216 * real 13-template kit that is 65 SVGs at roughly 0.7s apiece - about 44 seconds, of
217 * which almost none is work: it is connection setup and latency, repeated 65 times
218 * against a single origin.
219 *
220 * Strictly an optimisation, never a dependency:
221 *
222 * - Every URL is put through wdesignkit_validate_external_url() first, the same DNS
223 * resolution + reserved-range check wdesignkit_safe_remote_get() performs, so this
224 * cannot become an SSRF hole that the sequential path would have refused.
225 * - Redirects are NOT followed. wp_safe_remote_get() re-validates each hop; curl would
226 * not, so a redirect to an internal address would slip past the check above. A
227 * redirecting URL is simply left uncached and falls through to the safe path.
228 * - Anything that is not a clean, size-capped 200 is left uncached and falls through
229 * the same way. A failed prefetch costs nothing but the original request.
230 * - The site's own HTTP policy is honoured before dialling: WP_HTTP_BLOCK_EXTERNAL
231 * with WP_ACCESSIBLE_HOSTS, and a configured proxy. curl here would otherwise reach
232 * hosts wp_safe_remote_get() refuses, or leave the proxy - which on a locked-down
233 * host is the only route out - unused.
234 * - Transfers abort mid-flight once they pass PREFETCH_MAX_BYTES, and warming stops
235 * at PREFETCH_MAX_TOTAL_BYTES retained, so neither a single large file nor a large
236 * kit can buffer without bound.
237 *
238 * @since 2.7.1
239 *
240 * @param array $urls Absolute URLs to warm.
241 * @return void
242 */
243 public static function prefetch_remote_files( $urls ) {
244 if ( ! is_array( $urls ) || empty( $urls ) || ! function_exists( 'curl_multi_init' ) ) {
245 return;
246 }
247
248 $queue = array();
249
250 foreach ( $urls as $url ) {
251 if ( ! is_string( $url ) || '' === $url || isset( self::$prefetched[ $url ] ) || isset( $queue[ $url ] ) ) {
252 continue;
253 }
254
255 // Same gate the sequential path applies, applied before anything is dialled.
256 if ( ! function_exists( 'wdesignkit_validate_external_url' ) || ! wdesignkit_validate_external_url( $url ) ) {
257 continue;
258 }
259
260 // And the same policy WP_Http would apply to this host.
261 if ( ! self::prefetch_allowed_by_http_policy( $url ) ) {
262 continue;
263 }
264
265 $queue[ $url ] = true;
266
267 if ( count( $queue ) >= self::PREFETCH_MAX_FILES ) {
268 break;
269 }
270 }
271
272 $queue = array_keys( $queue );
273
274 if ( empty( $queue ) ) {
275 return;
276 }
277
278 $user_agent = 'WordPress/' . get_bloginfo( 'version' ) . '; ' . home_url( '/' );
279 $retained = self::prefetched_bytes();
280 $has_progress = defined( 'CURLOPT_XFERINFOFUNCTION' );
281
282 foreach ( array_chunk( $queue, self::PREFETCH_CONCURRENCY ) as $chunk ) {
283 if ( $retained >= self::PREFETCH_MAX_TOTAL_BYTES ) {
284 break;
285 }
286
287 $multi = curl_multi_init();
288
289 if ( false === $multi ) {
290 return;
291 }
292
293 $handles = array();
294
295 foreach ( $chunk as $url ) {
296 $handle = curl_init();
297
298 // curl_init() can fail, and every call below would then be handed false.
299 // On PHP 8 that is a TypeError, i.e. a fatal in the middle of an import.
300 if ( false === $handle ) {
301 continue;
302 }
303
304 $options = array(
305 CURLOPT_URL => $url,
306 CURLOPT_RETURNTRANSFER => true,
307 // See the docblock: following a redirect here would skip the
308 // per-hop validation wp_safe_remote_get() does for us.
309 CURLOPT_FOLLOWLOCATION => false,
310 CURLOPT_CONNECTTIMEOUT => 10,
311 CURLOPT_TIMEOUT => 30,
312 CURLOPT_SSL_VERIFYPEER => true,
313 CURLOPT_SSL_VERIFYHOST => 2,
314 CURLOPT_USERAGENT => $user_agent,
315 // Refuses up front when the server declares an oversized body.
316 CURLOPT_MAXFILESIZE => self::PREFETCH_MAX_BYTES,
317 );
318
319 // And catches the servers that declare nothing: without this a chunked
320 // response of any size is buffered in full before being measured and
321 // thrown away.
322 if ( $has_progress ) {
323 $options[ CURLOPT_NOPROGRESS ] = false;
324 $options[ CURLOPT_XFERINFOFUNCTION ] = static function ( $handle, $expected, $received ) {
325 unset( $handle, $expected );
326
327 return ( $received > self::PREFETCH_MAX_BYTES ) ? 1 : 0;
328 };
329 }
330
331 curl_setopt_array( $handle, $options );
332
333 curl_multi_add_handle( $multi, $handle );
334 $handles[ $url ] = $handle;
335 }
336
337 if ( empty( $handles ) ) {
338 curl_multi_close( $multi );
339 continue;
340 }
341
342 $running = null;
343 $iterations = 0;
344
345 // Belt and braces on top of the status check: 30s CURLOPT_TIMEOUT against a
346 // 0.5s select is ~60 turns per handle, so this can only fire if the loop is
347 // not waiting at all - the exact shape of the busy-loop being guarded against.
348 $max_iterations = 2000;
349
350 do {
351 $status = curl_multi_exec( $multi, $running );
352
353 // A handle-level failure leaves $running untouched, so a loop that only
354 // looks at $running spins forever on it - and curl_multi_select() returns
355 // -1 immediately on error, so the 0.5s wait does not apply either.
356 if ( CURLM_OK !== $status && CURLM_CALL_MULTI_PERFORM !== $status ) {
357 break;
358 }
359
360 if ( ++$iterations > $max_iterations ) {
361 break;
362 }
363
364 if ( $running > 0 && CURLM_CALL_MULTI_PERFORM !== $status ) {
365 curl_multi_select( $multi, 0.5 );
366 }
367 } while ( $running > 0 );
368
369 foreach ( $handles as $url => $handle ) {
370 $body = curl_multi_getcontent( $handle );
371 $code = (int) curl_getinfo( $handle, CURLINFO_RESPONSE_CODE );
372
373 if ( 200 === $code && is_string( $body ) && '' !== $body && strlen( $body ) <= self::PREFETCH_MAX_BYTES ) {
374 if ( ( $retained + strlen( $body ) ) <= self::PREFETCH_MAX_TOTAL_BYTES ) {
375 self::$prefetched[ $url ] = $body;
376 $retained += strlen( $body );
377 }
378 }
379
380 unset( $body );
381
382 curl_multi_remove_handle( $multi, $handle );
383 curl_close( $handle );
384 }
385
386 curl_multi_close( $multi );
387 }
388 }
389
390 /**
391 * Bytes currently held in the warm cache.
392 *
393 * @since 2.7.1
394 *
395 * @return int
396 */
397 private static function prefetched_bytes() {
398 $bytes = 0;
399
400 foreach ( self::$prefetched as $body ) {
401 $bytes += strlen( (string) $body );
402 }
403
404 return $bytes;
405 }
406
407 /**
408 * Would WP_Http let this request out?
409 *
410 * The prefetch pass talks to curl directly, which means none of the site's own HTTP
411 * configuration applies to it. Two cases matter, and in both the answer is to skip
412 * warming and let the sequential wp_safe_remote_get() path handle the URL properly:
413 *
414 * - WP_HTTP_BLOCK_EXTERNAL (with WP_ACCESSIBLE_HOSTS as the allowlist). A site that
415 * blocks outbound HTTP means it; reaching the host anyway because we happen to hold
416 * our own curl handle is not a decision this optimisation gets to make.
417 * - A configured proxy. On a host where the proxy is the only route out, curl would
418 * simply fail - so this is also the difference between a slow import and no images.
419 *
420 * @since 2.7.1
421 *
422 * @param string $url URL about to be warmed.
423 * @return bool True when the request may be made directly.
424 */
425 private static function prefetch_allowed_by_http_policy( $url ) {
426 $host = (string) wp_parse_url( $url, PHP_URL_HOST );
427
428 if ( '' === $host ) {
429 return false;
430 }
431
432 if ( defined( 'WP_HTTP_BLOCK_EXTERNAL' ) && WP_HTTP_BLOCK_EXTERNAL ) {
433 $accessible = defined( 'WP_ACCESSIBLE_HOSTS' ) ? preg_split( '|,\s*|', WP_ACCESSIBLE_HOSTS ) : array();
434 $allowed = false;
435
436 foreach ( (array) $accessible as $pattern ) {
437 $pattern = trim( (string) $pattern );
438
439 if ( '' === $pattern ) {
440 continue;
441 }
442
443 // WP_ACCESSIBLE_HOSTS supports a leading wildcard, e.g. *.example.com.
444 $regex = '|^' . str_replace( '\*\.', '([a-zA-Z0-9-]+\.)*', preg_quote( $pattern, '|' ) ) . '$|i';
445
446 if ( preg_match( $regex, $host ) ) {
447 $allowed = true;
448 break;
449 }
450 }
451
452 if ( ! $allowed ) {
453 return false;
454 }
455 }
456
457 if ( class_exists( 'WP_HTTP_Proxy' ) ) {
458 $proxy = new WP_HTTP_Proxy();
459
460 if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) {
461 return false;
462 }
463 }
464
465 return true;
466 }
467
468 /**
469 * Drop every warmed body. Called once a kit's walk is done so the bodies do not sit in
470 * memory for the rest of the request.
471 *
472 * @since 2.7.1
473 *
474 * @return void
475 */
476 public static function clear_prefetched() {
477 self::$prefetched = array();
478 }
479
480 /**
481 * Get attachment url image hash sha1.
482 *
483 * Retrieve the sha1 hash of the image URL.
484 *
485 * @since 1.0.0
486 * @access private
487 *
488 * @param string $attachment_url The attachment URL.
489 */
490 private static function get_attachment_url_hash_image( $attachment_url ) {
491 return sha1( $attachment_url );
492 }
493
494 /**
495 * Is the file behind this attachment something we can actually show?
496 *
497 * Guards against reusing a previously stored file that turned out empty. An SVG needs at
498 * least one drawing element - a sanitiser that fails mid-way leaves a valid but blank
499 * `<svg></svg>`, which renders as nothing and is easy to mistake for a missing import.
500 * Raster files are checked by asking whether they can be read as an image at all.
501 *
502 * @since 2.6.2
503 *
504 * @param int $attachment_id Attachment to check.
505 * @return bool True when the file is present and has real content.
506 */
507 private static function attachment_file_is_usable( $attachment_id ) {
508 $file = get_attached_file( $attachment_id );
509
510 if ( empty( $file ) || ! file_exists( $file ) || ! filesize( $file ) ) {
511 return false;
512 }
513
514 if ( preg_match( '/\.svg$/i', $file ) ) {
515 $svg = (string) file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file, no HTTP involved.
516
517 return (bool) preg_match( '/<(?:path|circle|rect|polygon|polyline|ellipse|line|g|use|image|text)[\s>\/]/i', $svg );
518 }
519
520 return false !== @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable file is the case being detected.
521 }
522
523 /**
524 * Resolve one of our own upload URLs back to its attachment ID.
525 *
526 * Mirrors the size suffixes WordPress adds but does not record in _wp_attached_file: the
527 * "-scaled" copy kept for oversized originals, and any "-1920x1280" intermediate size.
528 * attachment_url_to_postid() misses both.
529 *
530 * @since 2.6.2
531 *
532 * @param string $url Local upload URL.
533 * @return int Attachment ID, or 0.
534 */
535 private static function attachment_id_from_local_url( $url ) {
536 $id = (int) attachment_url_to_postid( $url );
537
538 if ( $id ) {
539 return $id;
540 }
541
542 $original = preg_replace( '/-scaled(\.[a-z0-9]+)$/i', '$1', $url );
543 $original = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', (string) $original );
544
545 if ( $original && $original !== $url ) {
546 return (int) attachment_url_to_postid( $original );
547 }
548
549 return 0;
550 }
551
552 /**
553 * Does this downloaded body actually contain an image?
554 *
555 * A server answering with an HTML error page, a login redirect or a JSON error still
556 * returns a perfectly valid HTTP body. Written to disk under the requested name it
557 * becomes an attachment WordPress believes is an image, which then renders broken and is
558 * far harder to trace back than a refused import.
559 *
560 * SVG is checked separately because it is markup: getimagesize() cannot read it.
561 *
562 * @since 2.6.2
563 *
564 * @param string $body Downloaded file contents.
565 * @param string $file_name Name the file will be stored under.
566 * @return bool True when the body looks like the image it claims to be.
567 */
568 private static function is_image_payload( $body, $file_name ) {
569
570 if ( preg_match( '/\.svg$/i', $file_name ) ) {
571 // Cheap structural check: an SVG has to contain an <svg> element.
572 return (bool) preg_match( '/<svg[\s>]/i', substr( $body, 0, 4096 ) );
573 }
574
575 return false !== @getimagesizefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed input is the case being detected.
576 }
577
578 /**
579 * Media Import image.
580 *
581 * Import a single image from a remote server, upload the image WordPress
582 * uploads folder, create a new attachment in the database and updates the
583 * attachment metadata.
584 *
585 * @since 1.0.0
586 *
587 * @param array $attachment The attachment.
588 * @param bool $defer_media Record the URL for the cron sideload instead of downloading
589 * now. Video always defers regardless; SVG never does, because
590 * the cron has no user and so no `svg` mime type.
591 */
592 public static function wdkit_Import_media( $attachment, $defer_media = false ) {
593 $stored_image = self::get_store_image_saved( $attachment );
594
595 if ( $stored_image ) {
596 self::remember_url( $attachment['url'] ?? '', $stored_image['url'] ?? '' );
597 return $stored_image;
598 }
599
600 if ( empty( $attachment['url'] ) || ! is_string( $attachment['url'] ) ) {
601 return $attachment;
602 }
603
604 // Deferred imports skip the network fetch for anything that safely tolerates staying
605 // remote until the background pass localises it - except SVGs. Nexter's animated icon
606 // blocks embed an SVG via <object data="..."> and read object.contentDocument in JS to
607 // drive the draw-on-scroll effect; a cross-origin <object> cannot be read that way, so
608 // the icon never reveals itself and the block renders as an empty box until the
609 // background pass catches up. SVGs are also the cheapest node to import synchronously
610 // (no raster decode - see the processing_resize timing on any .svg in this importer's
611 // own investigation log), so keeping them eager here does not reintroduce the cost this
612 // was written to remove.
613 if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
614 require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
615 }
616
617 $media_path = (string) wp_parse_url( $attachment['url'], PHP_URL_PATH );
618 $is_svg = (bool) preg_match( '/\.svg$/i', $media_path );
619
620 // Video defers whether or not the caller asked for deferral. It is the largest thing
621 // a kit references and this function runs inside the request the user is waiting on,
622 // so fetching one here is exactly the cost the deferred path exists to avoid. The
623 // background pass handles it (see wdkit_sideload_deferred_media()); a caller that
624 // never schedules that pass leaves the video remote, which is what it did before
625 // video was importable at all.
626 $is_video = (bool) preg_match( '/\.(?:' . Wdkit_Image_Guard::VIDEO_EXTENSIONS . ')$/i', $media_path );
627
628 if ( ( $defer_media || $is_video ) && ! $is_svg ) {
629 self::$deferred_urls[] = $attachment['url'];
630 return $attachment;
631 }
632
633 // Take the file name from the URL *path*. basename() on the whole URL keeps any query
634 // string, and "photo.jpeg?w=1920&auto=compress" is not a filename WordPress will
635 // accept - wp_upload_bits() rejects it and every step after that worked on nothing.
636 $file_name = Wdkit_Image_Guard::filename_from_url( $attachment['url'] );
637
638 if ( '' === $file_name ) {
639 return $attachment;
640 }
641
642 // Already one of ours: resolve the ID from the URL instead of fetching it.
643 //
644 // Media can arrive local for good reasons - the generated site logo, and stock images
645 // the wizard copies in before the import starts - and downloading our own URL is both
646 // pointless and fragile: it needs a working loopback request, which plenty of hosts
647 // block, and on failure this used to hand back false and wipe the attribute, taking
648 // the image off the page. It also stored a second copy of a file already on disk.
649 $uploads = wp_get_upload_dir();
650
651 if ( ! Wdkit_Image_Guard::is_foreign_media( $attachment['url'], isset( $uploads['baseurl'] ) ? $uploads['baseurl'] : '' ) ) {
652 $local_id = self::attachment_id_from_local_url( $attachment['url'] );
653
654 if ( $local_id ) {
655 return array(
656 'id' => $local_id,
657 'url' => $attachment['url'],
658 );
659 }
660
661 // A local URL we cannot match to an attachment is left exactly as it is; there is
662 // nothing to import and the URL itself still works.
663 return $attachment;
664 }
665
666 // wdesignkit_safe_remote_get(), not wp_safe_remote_get(): this importer ingests the
667 // largest volume of externally supplied URLs in the plugin, and wp_safe_remote_get()
668 // never resolves the hostname — it only rejects a literal-IP host, so a name that
669 // resolves to 127.0.0.1 or 169.254.169.254 passes straight through (SSRF, CWE-918,
670 // ClickUp 86d41ce9w). The wrapper resolves the host and blocks the reserved ranges.
671 // Already pulled down by the concurrent warm-up (see prefetch_remote_files())? Use
672 // that copy. Only a validated, non-redirected, size-capped 200 ever lands there, so
673 // this is the same payload this request would have fetched - just without paying for
674 // the round trip a second time. A miss simply falls through to the request below,
675 // which is what happens for every URL when curl_multi is unavailable.
676 if ( isset( self::$prefetched[ $attachment['url'] ] ) ) {
677 $file_content = self::$prefetched[ $attachment['url'] ];
678 } else {
679 $response = wdesignkit_safe_remote_get( $attachment['url'] );
680
681 // A 404 body is still a body: without this check the error page was written to disk
682 // as a .png and inserted as a real attachment, giving a broken image rather than an
683 // honest failure.
684 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
685 self::log_failed_download(
686 $attachment,
687 'fetch_failed',
688 is_wp_error( $response ) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code( $response )
689 );
690
691 return $attachment;
692 }
693
694 $file_content = wp_remote_retrieve_body( $response );
695 }
696
697 if ( empty( $file_content ) || ! self::is_image_payload( $file_content, $file_name ) ) {
698 // Returning the attachment unchanged keeps the remote URL working. Returning
699 // false - as this used to - made the caller overwrite the whole media attribute,
700 // so a single failed download removed the image from the page entirely.
701 self::log_failed_download( $attachment, 'not_an_image', 'empty body or unrecognised image payload' );
702
703 return $attachment;
704 }
705
706 $upload_data = wp_upload_bits( $file_name, null, $file_content );
707
708 if ( ! empty( $upload_data['error'] ) || empty( $upload_data['file'] ) || empty( $upload_data['url'] ) ) {
709 self::log_failed_download( $attachment, 'upload_failed', ! empty( $upload_data['error'] ) ? (string) $upload_data['error'] : 'wp_upload_bits() returned no file' );
710
711 return $attachment;
712 }
713
714 $info = wp_check_filetype( $upload_data['file'] );
715
716 // wp_check_filetype() always returns an array - array( 'ext' => false, 'type' => false )
717 // when it recognises nothing - so testing the array itself never failed and a file of
718 // unknown type was inserted with an empty post_mime_type.
719 if ( empty( $info['type'] ) ) {
720 wp_delete_file( $upload_data['file'] );
721
722 self::log_failed_download( $attachment, 'unrecognised_filetype', $file_name );
723
724 return $attachment;
725 }
726
727 $post_image = array(
728 'post_title' => $file_name,
729 'guid' => $upload_data['url'],
730 'post_mime_type' => $info['type'],
731 );
732
733 $post_id = wp_insert_attachment( $post_image, $upload_data['file'] );
734
735 if ( is_wp_error( $post_id ) || ! $post_id ) {
736 self::log_failed_download( $attachment, 'insert_failed', is_wp_error( $post_id ) ? $post_id->get_error_message() : 'wp_insert_attachment() returned no id' );
737
738 return $attachment;
739 }
740
741 // On REST requests.
742 if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) {
743 require_once ABSPATH . '/wp-admin/includes/image.php';
744 }
745
746 if ( ! function_exists( 'wp_read_video_metadata' ) ) {
747 require_once ABSPATH . '/wp-admin/includes/media.php';
748 }
749
750 wp_update_attachment_metadata( $post_id, wp_generate_attachment_metadata( $post_id, $upload_data['file'] ) );
751 update_post_meta( $post_id, 'tpgb_source_image_key', self::get_attachment_url_hash_image( $attachment['url'] ) );
752
753 $new_attachment_img = array(
754 'id' => $post_id,
755 'url' => $upload_data['url'],
756 );
757
758 if ( isset( $attachment['id'] ) ) {
759 self::$new_image_ids[ $attachment['id'] ] = $new_attachment_img;
760 }
761
762 self::remember_url( $attachment['url'], $upload_data['url'] );
763
764 return $new_attachment_img;
765 }
766
767 /**
768 * Record that this image could not be localised, so the failure is diagnosable instead
769 * of invisible. The caller still keeps the original remote URL on the page - that part is
770 * deliberate (see the callers' own comments) - this only makes sure someone can find out
771 * it happened, via `wp eval 'print_r( Wdkit_Import_Log::read() );'` or
772 * `Wdkit_Import_Log::report()`. See ClickUp 14ynqxywnae.
773 *
774 * @since 2.7.3
775 *
776 * @param array $attachment The attachment that failed to import.
777 * @param string $code Short machine-readable reason.
778 * @param string $message Detail for a human reading the log.
779 * @return void
780 */
781 private static function log_failed_download( $attachment, $code, $message = '' ) {
782 if ( ! class_exists( 'Wdkit_Import_Log' ) ) {
783 require_once WDKIT_INCLUDES . 'admin/import/class-wdkit-import-log.php';
784 }
785
786 Wdkit_Import_Log::add(
787 'failure',
788 array(
789 'step' => 'media_import',
790 'code' => $code,
791 'message' => $message,
792 'url' => isset( $attachment['url'] ) ? (string) $attachment['url'] : '',
793 )
794 );
795
796 self::$failed_downloads[] = array(
797 'url' => isset( $attachment['url'] ) ? (string) $attachment['url'] : '',
798 'code' => $code,
799 'message' => $message,
800 );
801 }
802
803 /**
804 * Get store saved image.
805 *
806 * Retrieve new image ID, if the image has a new ID after the import.
807 *
808 * @since 1.0.0
809 * @access private
810 *
811 * @param array $attachment The attachment.
812 */
813 private static function get_store_image_saved( $attachment ) {
814 global $wpdb;
815
816 if ( isset( $attachment['id'] ) && isset( self::$new_image_ids[ $attachment['id'] ] ) ) {
817 return self::$new_image_ids[ $attachment['id'] ];
818 }
819
820 $post_id = $wpdb->get_var(
821 $wpdb->prepare( 'SELECT `post_id` FROM `' . $wpdb->postmeta . '` WHERE `meta_key` = \'tpgb_source_image_key\' AND `meta_value` = %s;', self::get_attachment_url_hash_image( $attachment['url'] ) )
822 );
823
824 if ( ! empty( $post_id ) ) {
825
826 // Only reuse a copy that is actually usable. This lookup is what makes importing
827 // the same image across many pages cheap, but it also means a single bad file is
828 // handed out for every later import of that URL - one empty SVG stored during a
829 // failed run kept a logo blank through every subsequent import. Rejecting it here
830 // lets the importer fetch the file again instead.
831 if ( ! self::attachment_file_is_usable( (int) $post_id ) ) {
832
833 return false;
834 }
835
836 $new_attachment_img = array(
837 'id' => $post_id,
838 'url' => wp_get_attachment_url( $post_id ),
839 );
840
841 if ( isset( $attachment['id'] ) ) {
842 self::$new_image_ids[ $attachment['id'] ] = $new_attachment_img;
843 }
844
845 return $new_attachment_img;
846 }
847
848 return false;
849 }
850
851 /**
852 * Import images Constructor.
853 *
854 * @since 1.0.0
855 */
856 public function __construct() {
857 if ( ! function_exists( 'WP_Filesystem' ) ) {
858 require_once ABSPATH . 'wp-admin/includes/file.php';
859 }
860
861 WP_Filesystem();
862 }
863 }
864
865 }
866