*/ public static function deferrable_extensions() { return array( 'png', 'jpg', 'jpeg', 'webp', 'gif', 'avif', 'mp4', 'm4v', 'webm', 'mov', 'qt', 'ogv' ); } /** * Whether an image of the given dimensions can be decoded in the memory available. * * Pure arithmetic — no WordPress, no filesystem, no globals — so it is directly * unit-testable. * * @param int $width Image width in pixels. * @param int $height Image height in pixels. * @param int $available_bytes Bytes of headroom left. 0 or less means "unlimited". * @param int $bytes_per_pixel Bytes per decoded pixel. Default 4 (RGBA). * @param float $overhead Peak multiplier. Default self::PEAK_OVERHEAD. * @return bool True when it is safe to decode, false when it should be skipped. */ public static function decode_fits( $width, $height, $available_bytes, $bytes_per_pixel = self::BYTES_PER_PIXEL, $overhead = self::PEAK_OVERHEAD ) { $width = (int) $width; $height = (int) $height; $available_bytes = (int) $available_bytes; // Unknown or non-raster dimensions: not ours to judge, let WordPress proceed. if ( $width < 1 || $height < 1 ) { return true; } // No ceiling (memory_limit = -1): nothing to protect against. if ( $available_bytes <= 0 ) { return true; } $bytes_per_pixel = (int) $bytes_per_pixel > 0 ? (int) $bytes_per_pixel : self::BYTES_PER_PIXEL; $overhead = (float) $overhead > 0 ? (float) $overhead : self::PEAK_OVERHEAD; // Multiply in float space: 6336 * 9504 * 4 * 1.6 overflows nothing here, but very // large synthetic dimensions would wrap a 32-bit int. $needed = (float) $width * (float) $height * (float) $bytes_per_pixel * $overhead; return $needed <= (float) $available_bytes; } /** * Extract a usable filename from an image URL, ignoring any query string. * * Elementor's Import_Images::import() does `basename( $url )` and then bails silently * when wp_check_filetype() finds no extension. A sized CDN URL such as * * https://images.pexels.com/photos/1/pexels-photo-1.jpeg?w=1920&auto=compress * * has the basename "pexels-photo-1.jpeg?w=1920&auto=compress", which ends in * "compress" — so Elementor silently imports nothing, no attachment is created, and * every widget that resolves its image through an attachment ID renders blank. * * WordPress core gets this right in media_sideload_image() by matching `[^\?]+` up to * the query string. This mirrors that so callers can localise such URLs before * Elementor ever sees them. * * @param string $url Image URL. * @return string Bare filename including extension, or '' when the URL is not an image. */ public static function filename_from_url( $url ) { if ( ! is_string( $url ) || '' === trim( $url ) ) { return ''; } // Match the path portion only — [^\?] stops at the query string, exactly as // media_sideload_image() does. // // MEDIA_EXTENSIONS, not IMAGE_EXTENSIONS: an SVG is a file we import like any other, // even though it is never decoded and so plays no part in the memory arithmetic. // Leaving it out here returned an empty filename for every SVG, and callers that // treat that as "cannot handle this" skipped logos and icons entirely. if ( ! preg_match( '/[^\?]+\.(?:' . self::MEDIA_EXTENSIONS . ')\b/i', $url, $matches ) ) { return ''; } $filename = $matches[0]; // Take the last path segment without depending on WordPress's wp_basename(). $filename = preg_replace( '#^.*/#', '', str_replace( '\\', '/', $filename ) ); return (string) $filename; } /** * Is this media reference still pointing off-site? * * Template content arrives holding whatever URL the media had wherever it came from, * and alongside it that site's attachment ID - which means nothing here. Controls left * in that state render nothing at all whenever the control resolves through the ID * rather than the URL: Elementor inlines an SVG icon by reading the file off the * attachment, and addon widgets fetch their thumbnail by ID, so both come out empty. * Worse, a source ID can collide with a real local post - IDs seen in practice landed * on a revision and on an Elementor template. * * Two things make this narrow enough to act on: * * - The URL must be absolute http(s). Relative and data: URLs are not ours to judge. * - The path must end in a media extension. Media controls share the {url,id} shape * with other controls, and this is what keeps a link from being mistaken for one. * * Compared without the scheme, because one site is routinely reachable over both http * and https and a mismatch there would make every local URL look foreign. * * @param string $url URL from a media control. * @param string $upload_baseurl This site's upload base URL. * @return bool True when the URL is media belonging somewhere else. */ public static function is_foreign_media( $url, $upload_baseurl, $require_extension = true ) { if ( ! is_string( $url ) || ! is_string( $upload_baseurl ) || '' === $upload_baseurl ) { return false; } if ( ! preg_match( '#^https?://#i', $url ) ) { return false; } $strip = static function ( $value ) { return preg_replace( '#^https?://#i', '', $value ); }; // Already ours. if ( 0 === strpos( $strip( $url ), $strip( $upload_baseurl ) ) ) { return false; } // A caller that found this URL by scanning text has to prove it is media somehow, // and the extension is the only evidence available. A caller that found it in a // `{ url, id }` media control already knows, and passes false: the template CDN // serves extensionless URLs and resolves the type by Content-Type, so requiring an // extension there rejected real media and left it hotlinked forever. if ( ! $require_extension ) { return true; } $path = (string) parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- no WordPress dependency by design. return (bool) preg_match( '/\.(?:' . self::MEDIA_EXTENSIONS . ')$/i', $path ); } /** * Width requested for a Pexels source image before it is downloaded. * * 1920px is wide enough for any section background a kit page uses; the originals this * caps run up to 6336x9504 (see the class docblock). */ const PEXELS_MAX_WIDTH = 1920; /** * Cap a Pexels source image at a sane width before it is downloaded. * * Pexels photo URLs resolve to full-resolution originals by default, but accept a `w` * query parameter that returns a server-resized rendition instead. A kit page never * needs more than PEXELS_MAX_WIDTH, and asking for it up front means a smaller network * transfer AND a smaller bitmap for the decode-memory guard above to worry about, * instead of downloading the original just to discard the resolution afterward. * * Pure string handling - no WordPress dependency, unit-testable like the rest of this * class. * * @param string $url Candidate image URL. * @param int $max_width Width to request. Default self::PEXELS_MAX_WIDTH. * @return string The URL unchanged if it is not a Pexels image URL, otherwise capped. */ public static function cap_pexels_source( $url, $max_width = self::PEXELS_MAX_WIDTH ) { if ( ! is_string( $url ) || '' === $url ) { return $url; } $parts = parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- no WordPress dependency by design. if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return $url; } $host = strtolower( (string) $parts['host'] ); // Exact host, or a real subdomain of pexels.com. A substring test (the previous // `stripos( $host, 'pexels.com' )`) also accepted `pexels.com.evil.tld` and // `notpexels.com`, which is the wrong shape for a host check even where the // consequence is only a rewritten query string - the next reader will take it for a // host allowlist. if ( 'pexels.com' !== $host && ! self::str_ends_with_ci( $host, '.pexels.com' ) ) { return $url; } $max_width = (int) $max_width > 0 ? (int) $max_width : self::PEXELS_MAX_WIDTH; $query = isset( $parts['query'] ) ? (string) $parts['query'] : ''; $params = array(); parse_str( $query, $params ); // parse_str() is lossy: it rewrites any parameter name containing a dot or a space // into an underscore, so `X-Amz-Sig.nature=abc` is re-emitted as // `X-Amz-Sig_nature=abc`. On a signed URL that invalidates the signature, the CDN // answers 403, media_sideload_image() returns WP_Error and the image is dropped with // nothing to retry it. Rather than guess which parameters matter, check whether the // round trip is lossless at all and decline to rewrite when it is not - serving the // original at full width is always the safer failure. if ( '' !== $query && ! self::query_round_trips( $query, $params ) ) { return $url; } // Already capped at or below the target width - leave it alone. `is_numeric` first: // `(int) 'abc'` is 0, which compares as "already small enough" and skipped the cap // entirely, so a junk width was a second route to a full-resolution download. if ( isset( $params['w'] ) && is_numeric( $params['w'] ) && (int) $params['w'] > 0 && (int) $params['w'] <= $max_width ) { return $url; } $params['w'] = $max_width; // `auto=compress` is what makes Pexels honour `w` on some endpoints; harmless to set // unconditionally since it only affects encoding, never dimensions. if ( empty( $params['auto'] ) ) { $params['auto'] = 'compress'; } // Reassembled from components rather than `strtok( $url, '?' )`, which kept any // `#fragment` in the base and so appended the new query *after* it - producing // `…/a.jpg#frag?w=1920`, which every server ignores. That silently defeated the cap // on any fragment-carrying URL. $rebuilt = ''; if ( ! empty( $parts['scheme'] ) ) { $rebuilt .= $parts['scheme'] . '://'; } if ( ! empty( $parts['user'] ) ) { $rebuilt .= $parts['user'] . ( empty( $parts['pass'] ) ? '' : ':' . $parts['pass'] ) . '@'; } $rebuilt .= $parts['host']; if ( ! empty( $parts['port'] ) ) { $rebuilt .= ':' . (int) $parts['port']; } $rebuilt .= isset( $parts['path'] ) ? $parts['path'] : ''; $rebuilt .= '?' . http_build_query( $params ); // Fragment last, where it belongs. if ( isset( $parts['fragment'] ) && '' !== $parts['fragment'] ) { $rebuilt .= '#' . $parts['fragment']; } return $rebuilt; } /** * Case-insensitive suffix test. * * str_ends_with() is PHP 8.0+; this plugin still supports 7.4. * * @since 2.7.1 * * @param string $haystack Subject. * @param string $needle Suffix to look for. * @return bool */ private static function str_ends_with_ci( $haystack, $needle ) { $len = strlen( $needle ); if ( 0 === $len ) { return true; } return strlen( $haystack ) >= $len && 0 === substr_compare( $haystack, $needle, -$len, $len, true ); } /** * Can this query string survive parse_str() + http_build_query() unchanged? * * Compares the parameter names the parser produced against the names actually present in * the raw query. Any mismatch means rebuilding would emit a different query than it was * given - the dot/space-to-underscore rewrite is the common case - and the caller should * leave the URL alone. Name-based rather than provider-based, so an unfamiliar signing * scheme fails safe without needing to be listed. * * @since 2.7.1 * * @param string $query Raw query string. * @param array $params Result of parse_str() on that query. * @return bool True when a rewrite would preserve every parameter name. */ private static function query_round_trips( $query, array $params ) { foreach ( explode( '&', $query ) as $pair ) { if ( '' === $pair ) { continue; } $raw_key = urldecode( strtok( $pair, '=' ) ); // PHP maps `a[b]=1` to a nested array; the bracket form is expected to differ // from the flat key, and it is not the case being guarded against. if ( false !== strpos( $raw_key, '[' ) ) { continue; } if ( ! array_key_exists( $raw_key, $params ) ) { return false; } } return true; } } }