| @@ -51,16 +51,57 @@ | ||
| 51 | 51 | */ |
| 52 | 52 | const IMAGE_EXTENSIONS = 'jpg|jpeg|jpe|gif|png|webp|avif|bmp'; |
| 53 | 53 | |
| 54 | 54 | /** |
| 55 | - * Everything a media control can legitimately point at, SVG included. | |
| 55 | + * Video file extensions a media control can point at. | |
| 56 | 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. | |
| 57 | + * Kept out of IMAGE_EXTENSIONS for the same reason SVG is: that list governs what may be | |
| 58 | + * *decoded*, and none of these ever are. Only the extensions WordPress already allows | |
| 59 | + * with no user (mp4/m4v, webm, mov/qt, ogv), so a background sideload of one succeeds | |
| 60 | + * where an SVG's cannot. | |
| 59 | 61 | */ |
| 60 | - const MEDIA_EXTENSIONS = self::IMAGE_EXTENSIONS . '|svg'; | |
| 62 | + const VIDEO_EXTENSIONS = 'mp4|m4v|webm|mov|qt|ogv'; | |
| 61 | 63 | |
| 62 | 64 | /** |
| 65 | + * Everything a media control can legitimately point at, SVG and video included. | |
| 66 | + * | |
| 67 | + * SVG and video are kept apart from IMAGE_EXTENSIONS because that list governs what may | |
| 68 | + * be *decoded* - neither is ever rasterised, so the memory arithmetic does not apply to | |
| 69 | + * them. This list is the other question: "is this a media file we import at all", which | |
| 70 | + * is what filename_from_url() and is_foreign_media() need. Leaving video out of it meant | |
| 71 | + * filename_from_url() returned '' for every video, and callers read that as "cannot | |
| 72 | + * handle this" - the same way SVGs were being skipped before they were added here. | |
| 73 | + */ | |
| 74 | + const MEDIA_EXTENSIONS = self::IMAGE_EXTENSIONS . '|svg|' . self::VIDEO_EXTENSIONS; | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * Extensions the deferred-media pass owns, end to end. | |
| 78 | + * | |
| 79 | + * One list, because three separate ones drifted apart and each divergence was a bug: | |
| 80 | + * the collector deferred `avif` but the id-blanking pass did not recognise it, so an AVIF | |
| 81 | + * kept a dangling id and rendered empty for the whole deferred window. Anything added | |
| 82 | + * here must be deferrable, blankable AND remappable - those three passes have to agree or | |
| 83 | + * an id is cleared that nothing will ever restore. | |
| 84 | + * | |
| 85 | + * SVG is deliberately absent: it is imported synchronously (the background pass runs with | |
| 86 | + * no user, where `svg` is not in get_allowed_mime_types()) and already carries a real id. | |
| 87 | + * | |
| 88 | + * @since 2.7.1 | |
| 89 | + */ | |
| 90 | + const DEFERRABLE_EXTENSIONS = 'png|jpe?g|webp|gif|avif|' . self::VIDEO_EXTENSIONS; | |
| 91 | + | |
| 92 | + /** | |
| 93 | + * The same list as a plain array, for in_array() call sites. | |
| 94 | + * | |
| 95 | + * @since 2.7.1 | |
| 96 | + * | |
| 97 | + * @return array<string> | |
| 98 | + */ | |
| 99 | + public static function deferrable_extensions() { | |
| 100 | + return array( 'png', 'jpg', 'jpeg', 'webp', 'gif', 'avif', 'mp4', 'm4v', 'webm', 'mov', 'qt', 'ogv' ); | |
| 101 | + } | |
| 102 | + | |
| 103 | + /** | |
| 63 | 104 | * Whether an image of the given dimensions can be decoded in the memory available. |
| 64 | 105 | * |
| 65 | 106 | * Pure arithmetic — no WordPress, no filesystem, no globals — so it is directly |
| 66 | 107 | * unit-testable. |
| @@ -164,9 +205,9 @@ | ||
| 164 | 205 | * @param string $url URL from a media control. |
| 165 | 206 | * @param string $upload_baseurl This site's upload base URL. |
| 166 | 207 | * @return bool True when the URL is media belonging somewhere else. |
| 167 | 208 | */ |
| 168 | - public static function is_foreign_media( $url, $upload_baseurl ) { | |
| 209 | + public static function is_foreign_media( $url, $upload_baseurl, $require_extension = true ) { | |
| 169 | 210 | if ( ! is_string( $url ) || ! is_string( $upload_baseurl ) || '' === $upload_baseurl ) { |
| 170 | 211 | return false; |
| 171 | 212 | } |
| 172 | 213 | |
| @@ -182,11 +223,191 @@ | ||
| 182 | 223 | if ( 0 === strpos( $strip( $url ), $strip( $upload_baseurl ) ) ) { |
| 183 | 224 | return false; |
| 184 | 225 | } |
| 185 | 226 | |
| 227 | + // A caller that found this URL by scanning text has to prove it is media somehow, | |
| 228 | + // and the extension is the only evidence available. A caller that found it in a | |
| 229 | + // `{ url, id }` media control already knows, and passes false: the template CDN | |
| 230 | + // serves extensionless URLs and resolves the type by Content-Type, so requiring an | |
| 231 | + // extension there rejected real media and left it hotlinked forever. | |
| 232 | + if ( ! $require_extension ) { | |
| 233 | + return true; | |
| 234 | + } | |
| 235 | + | |
| 186 | 236 | $path = (string) parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- no WordPress dependency by design. |
| 187 | 237 | |
| 188 | 238 | return (bool) preg_match( '/\.(?:' . self::MEDIA_EXTENSIONS . ')$/i', $path ); |
| 239 | + } | |
| 240 | + | |
| 241 | + /** | |
| 242 | + * Width requested for a Pexels source image before it is downloaded. | |
| 243 | + * | |
| 244 | + * 1920px is wide enough for any section background a kit page uses; the originals this | |
| 245 | + * caps run up to 6336x9504 (see the class docblock). | |
| 246 | + */ | |
| 247 | + const PEXELS_MAX_WIDTH = 1920; | |
| 248 | + | |
| 249 | + /** | |
| 250 | + * Cap a Pexels source image at a sane width before it is downloaded. | |
| 251 | + * | |
| 252 | + * Pexels photo URLs resolve to full-resolution originals by default, but accept a `w` | |
| 253 | + * query parameter that returns a server-resized rendition instead. A kit page never | |
| 254 | + * needs more than PEXELS_MAX_WIDTH, and asking for it up front means a smaller network | |
| 255 | + * transfer AND a smaller bitmap for the decode-memory guard above to worry about, | |
| 256 | + * instead of downloading the original just to discard the resolution afterward. | |
| 257 | + * | |
| 258 | + * Pure string handling - no WordPress dependency, unit-testable like the rest of this | |
| 259 | + * class. | |
| 260 | + * | |
| 261 | + * @param string $url Candidate image URL. | |
| 262 | + * @param int $max_width Width to request. Default self::PEXELS_MAX_WIDTH. | |
| 263 | + * @return string The URL unchanged if it is not a Pexels image URL, otherwise capped. | |
| 264 | + */ | |
| 265 | + public static function cap_pexels_source( $url, $max_width = self::PEXELS_MAX_WIDTH ) { | |
| 266 | + if ( ! is_string( $url ) || '' === $url ) { | |
| 267 | + return $url; | |
| 268 | + } | |
| 269 | + | |
| 270 | + $parts = parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- no WordPress dependency by design. | |
| 271 | + | |
| 272 | + if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { | |
| 273 | + return $url; | |
| 274 | + } | |
| 275 | + | |
| 276 | + $host = strtolower( (string) $parts['host'] ); | |
| 277 | + | |
| 278 | + // Exact host, or a real subdomain of pexels.com. A substring test (the previous | |
| 279 | + // `stripos( $host, 'pexels.com' )`) also accepted `pexels.com.evil.tld` and | |
| 280 | + // `notpexels.com`, which is the wrong shape for a host check even where the | |
| 281 | + // consequence is only a rewritten query string - the next reader will take it for a | |
| 282 | + // host allowlist. | |
| 283 | + if ( 'pexels.com' !== $host && ! self::str_ends_with_ci( $host, '.pexels.com' ) ) { | |
| 284 | + return $url; | |
| 285 | + } | |
| 286 | + | |
| 287 | + $max_width = (int) $max_width > 0 ? (int) $max_width : self::PEXELS_MAX_WIDTH; | |
| 288 | + | |
| 289 | + $query = isset( $parts['query'] ) ? (string) $parts['query'] : ''; | |
| 290 | + | |
| 291 | + $params = array(); | |
| 292 | + parse_str( $query, $params ); | |
| 293 | + | |
| 294 | + // parse_str() is lossy: it rewrites any parameter name containing a dot or a space | |
| 295 | + // into an underscore, so `X-Amz-Sig.nature=abc` is re-emitted as | |
| 296 | + // `X-Amz-Sig_nature=abc`. On a signed URL that invalidates the signature, the CDN | |
| 297 | + // answers 403, media_sideload_image() returns WP_Error and the image is dropped with | |
| 298 | + // nothing to retry it. Rather than guess which parameters matter, check whether the | |
| 299 | + // round trip is lossless at all and decline to rewrite when it is not - serving the | |
| 300 | + // original at full width is always the safer failure. | |
| 301 | + if ( '' !== $query && ! self::query_round_trips( $query, $params ) ) { | |
| 302 | + return $url; | |
| 303 | + } | |
| 304 | + | |
| 305 | + // Already capped at or below the target width - leave it alone. `is_numeric` first: | |
| 306 | + // `(int) 'abc'` is 0, which compares as "already small enough" and skipped the cap | |
| 307 | + // entirely, so a junk width was a second route to a full-resolution download. | |
| 308 | + if ( isset( $params['w'] ) && is_numeric( $params['w'] ) && (int) $params['w'] > 0 | |
| 309 | + && (int) $params['w'] <= $max_width | |
| 310 | + ) { | |
| 311 | + return $url; | |
| 312 | + } | |
| 313 | + | |
| 314 | + $params['w'] = $max_width; | |
| 315 | + | |
| 316 | + // `auto=compress` is what makes Pexels honour `w` on some endpoints; harmless to set | |
| 317 | + // unconditionally since it only affects encoding, never dimensions. | |
| 318 | + if ( empty( $params['auto'] ) ) { | |
| 319 | + $params['auto'] = 'compress'; | |
| 320 | + } | |
| 321 | + | |
| 322 | + // Reassembled from components rather than `strtok( $url, '?' )`, which kept any | |
| 323 | + // `#fragment` in the base and so appended the new query *after* it - producing | |
| 324 | + // `…/a.jpg#frag?w=1920`, which every server ignores. That silently defeated the cap | |
| 325 | + // on any fragment-carrying URL. | |
| 326 | + $rebuilt = ''; | |
| 327 | + | |
| 328 | + if ( ! empty( $parts['scheme'] ) ) { | |
| 329 | + $rebuilt .= $parts['scheme'] . '://'; | |
| 330 | + } | |
| 331 | + | |
| 332 | + if ( ! empty( $parts['user'] ) ) { | |
| 333 | + $rebuilt .= $parts['user'] . ( empty( $parts['pass'] ) ? '' : ':' . $parts['pass'] ) . '@'; | |
| 334 | + } | |
| 335 | + | |
| 336 | + $rebuilt .= $parts['host']; | |
| 337 | + | |
| 338 | + if ( ! empty( $parts['port'] ) ) { | |
| 339 | + $rebuilt .= ':' . (int) $parts['port']; | |
| 340 | + } | |
| 341 | + | |
| 342 | + $rebuilt .= isset( $parts['path'] ) ? $parts['path'] : ''; | |
| 343 | + $rebuilt .= '?' . http_build_query( $params ); | |
| 344 | + | |
| 345 | + // Fragment last, where it belongs. | |
| 346 | + if ( isset( $parts['fragment'] ) && '' !== $parts['fragment'] ) { | |
| 347 | + $rebuilt .= '#' . $parts['fragment']; | |
| 348 | + } | |
| 349 | + | |
| 350 | + return $rebuilt; | |
| 351 | + } | |
| 352 | + | |
| 353 | + /** | |
| 354 | + * Case-insensitive suffix test. | |
| 355 | + * | |
| 356 | + * str_ends_with() is PHP 8.0+; this plugin still supports 7.4. | |
| 357 | + * | |
| 358 | + * @since 2.7.1 | |
| 359 | + * | |
| 360 | + * @param string $haystack Subject. | |
| 361 | + * @param string $needle Suffix to look for. | |
| 362 | + * @return bool | |
| 363 | + */ | |
| 364 | + private static function str_ends_with_ci( $haystack, $needle ) { | |
| 365 | + $len = strlen( $needle ); | |
| 366 | + | |
| 367 | + if ( 0 === $len ) { | |
| 368 | + return true; | |
| 369 | + } | |
| 370 | + | |
| 371 | + return strlen( $haystack ) >= $len && 0 === substr_compare( $haystack, $needle, -$len, $len, true ); | |
| 372 | + } | |
| 373 | + | |
| 374 | + /** | |
| 375 | + * Can this query string survive parse_str() + http_build_query() unchanged? | |
| 376 | + * | |
| 377 | + * Compares the parameter names the parser produced against the names actually present in | |
| 378 | + * the raw query. Any mismatch means rebuilding would emit a different query than it was | |
| 379 | + * given - the dot/space-to-underscore rewrite is the common case - and the caller should | |
| 380 | + * leave the URL alone. Name-based rather than provider-based, so an unfamiliar signing | |
| 381 | + * scheme fails safe without needing to be listed. | |
| 382 | + * | |
| 383 | + * @since 2.7.1 | |
| 384 | + * | |
| 385 | + * @param string $query Raw query string. | |
| 386 | + * @param array $params Result of parse_str() on that query. | |
| 387 | + * @return bool True when a rewrite would preserve every parameter name. | |
| 388 | + */ | |
| 389 | + private static function query_round_trips( $query, array $params ) { | |
| 390 | + | |
| 391 | + foreach ( explode( '&', $query ) as $pair ) { | |
| 392 | + if ( '' === $pair ) { | |
| 393 | + continue; | |
| 394 | + } | |
| 395 | + | |
| 396 | + $raw_key = urldecode( strtok( $pair, '=' ) ); | |
| 397 | + | |
| 398 | + // PHP maps `a[b]=1` to a nested array; the bracket form is expected to differ | |
| 399 | + // from the flat key, and it is not the case being guarded against. | |
| 400 | + if ( false !== strpos( $raw_key, '[' ) ) { | |
| 401 | + continue; | |
| 402 | + } | |
| 403 | + | |
| 404 | + if ( ! array_key_exists( $raw_key, $params ) ) { | |
| 405 | + return false; | |
| 406 | + } | |
| 407 | + } | |
| 408 | + | |
| 409 | + return true; | |
| 189 | 410 | } |
| 190 | 411 | |
| 191 | 412 | } |
| 192 | 413 | } |