PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.6.4
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.6.4
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 1.0.17 All 128 releases
wdesignkit / includes / admin / class-wdkit-image-guard.php

class-wdkit-image-guard.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.6.4, at includes/admin/class-wdkit-image-guard.php

193 lines 7.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Guard against images that cannot be decoded within the available memory.
4 *
5 * Background
6 * ----------
7 * GD holds images UNCOMPRESSED — width x height x 4 bytes — so the size on disk says
8 * nothing about the cost of processing one. A 6336x9504 Pexels photo is 1.7MB as a JPEG
9 * and 229.7MB as a GD bitmap. Decoding it under a 256M memory_limit is an instant fatal
10 * inside imagecreatefromstring(), which WordPress surfaces as "There has been a critical
11 * error on this website" and a HTTP 500 — killing a kit import page with no usable error.
12 *
13 * Imagick is far more frugal and can spill to a disk-backed pixel cache, which is why this
14 * only bites on hosts without it.
15 *
16 * This class is deliberately free of WordPress dependencies: the sizing arithmetic and the
17 * URL checks are pure functions, so they can be reasoned about - and exercised - on their own.
18 *
19 * @package WDesignKit
20 * @since 2.6.2
21 */
22
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit;
25 }
26
27 if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
28
29 /**
30 * Image memory-safety helpers.
31 */
32 class Wdkit_Image_Guard {
33
34 /**
35 * Bytes per pixel for a decoded truecolour bitmap (RGBA).
36 */
37 const BYTES_PER_PIXEL = 4;
38
39 /**
40 * Multiplier applied to the decoded source size to approximate real peak usage.
41 *
42 * A resize needs the decoded source AND a destination bitmap live at the same time,
43 * plus the compressed file contents that were read to produce the source. 1.6x of the
44 * source bitmap tracks observed peaks closely enough to keep us on the safe side
45 * without rejecting images that would actually have succeeded.
46 */
47 const PEAK_OVERHEAD = 1.6;
48
49 /**
50 * Image file extensions we are willing to sideload.
51 */
52 const IMAGE_EXTENSIONS = 'jpg|jpeg|jpe|gif|png|webp|avif|bmp';
53
54 /**
55 * Everything a media control can legitimately point at, SVG included.
56 *
57 * SVG is kept apart from IMAGE_EXTENSIONS because that list governs what may be
58 * *decoded* - and an SVG is never rasterised, so the memory arithmetic does not apply.
59 */
60 const MEDIA_EXTENSIONS = self::IMAGE_EXTENSIONS . '|svg';
61
62 /**
63 * Whether an image of the given dimensions can be decoded in the memory available.
64 *
65 * Pure arithmetic — no WordPress, no filesystem, no globals — so it is directly
66 * unit-testable.
67 *
68 * @param int $width Image width in pixels.
69 * @param int $height Image height in pixels.
70 * @param int $available_bytes Bytes of headroom left. 0 or less means "unlimited".
71 * @param int $bytes_per_pixel Bytes per decoded pixel. Default 4 (RGBA).
72 * @param float $overhead Peak multiplier. Default self::PEAK_OVERHEAD.
73 * @return bool True when it is safe to decode, false when it should be skipped.
74 */
75 public static function decode_fits( $width, $height, $available_bytes, $bytes_per_pixel = self::BYTES_PER_PIXEL, $overhead = self::PEAK_OVERHEAD ) {
76
77 $width = (int) $width;
78 $height = (int) $height;
79 $available_bytes = (int) $available_bytes;
80
81 // Unknown or non-raster dimensions: not ours to judge, let WordPress proceed.
82 if ( $width < 1 || $height < 1 ) {
83 return true;
84 }
85
86 // No ceiling (memory_limit = -1): nothing to protect against.
87 if ( $available_bytes <= 0 ) {
88 return true;
89 }
90
91 $bytes_per_pixel = (int) $bytes_per_pixel > 0 ? (int) $bytes_per_pixel : self::BYTES_PER_PIXEL;
92 $overhead = (float) $overhead > 0 ? (float) $overhead : self::PEAK_OVERHEAD;
93
94 // Multiply in float space: 6336 * 9504 * 4 * 1.6 overflows nothing here, but very
95 // large synthetic dimensions would wrap a 32-bit int.
96 $needed = (float) $width * (float) $height * (float) $bytes_per_pixel * $overhead;
97
98 return $needed <= (float) $available_bytes;
99 }
100
101 /**
102 * Extract a usable filename from an image URL, ignoring any query string.
103 *
104 * Elementor's Import_Images::import() does `basename( $url )` and then bails silently
105 * when wp_check_filetype() finds no extension. A sized CDN URL such as
106 *
107 * https://images.pexels.com/photos/1/pexels-photo-1.jpeg?w=1920&auto=compress
108 *
109 * has the basename "pexels-photo-1.jpeg?w=1920&auto=compress", which ends in
110 * "compress" — so Elementor silently imports nothing, no attachment is created, and
111 * every widget that resolves its image through an attachment ID renders blank.
112 *
113 * WordPress core gets this right in media_sideload_image() by matching `[^\?]+` up to
114 * the query string. This mirrors that so callers can localise such URLs before
115 * Elementor ever sees them.
116 *
117 * @param string $url Image URL.
118 * @return string Bare filename including extension, or '' when the URL is not an image.
119 */
120 public static function filename_from_url( $url ) {
121 if ( ! is_string( $url ) || '' === trim( $url ) ) {
122 return '';
123 }
124
125 // Match the path portion only — [^\?] stops at the query string, exactly as
126 // media_sideload_image() does.
127 //
128 // MEDIA_EXTENSIONS, not IMAGE_EXTENSIONS: an SVG is a file we import like any other,
129 // even though it is never decoded and so plays no part in the memory arithmetic.
130 // Leaving it out here returned an empty filename for every SVG, and callers that
131 // treat that as "cannot handle this" skipped logos and icons entirely.
132 if ( ! preg_match( '/[^\?]+\.(?:' . self::MEDIA_EXTENSIONS . ')\b/i', $url, $matches ) ) {
133 return '';
134 }
135
136 $filename = $matches[0];
137
138 // Take the last path segment without depending on WordPress's wp_basename().
139 $filename = preg_replace( '#^.*/#', '', str_replace( '\\', '/', $filename ) );
140
141 return (string) $filename;
142 }
143
144 /**
145 * Is this media reference still pointing off-site?
146 *
147 * Template content arrives holding whatever URL the media had wherever it came from,
148 * and alongside it that site's attachment ID - which means nothing here. Controls left
149 * in that state render nothing at all whenever the control resolves through the ID
150 * rather than the URL: Elementor inlines an SVG icon by reading the file off the
151 * attachment, and addon widgets fetch their thumbnail by ID, so both come out empty.
152 * Worse, a source ID can collide with a real local post - IDs seen in practice landed
153 * on a revision and on an Elementor template.
154 *
155 * Two things make this narrow enough to act on:
156 *
157 * - The URL must be absolute http(s). Relative and data: URLs are not ours to judge.
158 * - The path must end in a media extension. Media controls share the {url,id} shape
159 * with other controls, and this is what keeps a link from being mistaken for one.
160 *
161 * Compared without the scheme, because one site is routinely reachable over both http
162 * and https and a mismatch there would make every local URL look foreign.
163 *
164 * @param string $url URL from a media control.
165 * @param string $upload_baseurl This site's upload base URL.
166 * @return bool True when the URL is media belonging somewhere else.
167 */
168 public static function is_foreign_media( $url, $upload_baseurl ) {
169 if ( ! is_string( $url ) || ! is_string( $upload_baseurl ) || '' === $upload_baseurl ) {
170 return false;
171 }
172
173 if ( ! preg_match( '#^https?://#i', $url ) ) {
174 return false;
175 }
176
177 $strip = static function ( $value ) {
178 return preg_replace( '#^https?://#i', '', $value );
179 };
180
181 // Already ours.
182 if ( 0 === strpos( $strip( $url ), $strip( $upload_baseurl ) ) ) {
183 return false;
184 }
185
186 $path = (string) parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- no WordPress dependency by design.
187
188 return (bool) preg_match( '/\.(?:' . self::MEDIA_EXTENSIONS . ')$/i', $path );
189 }
190
191 }
192 }
193