commit e5155e11231b119ae7a5440b9b4ca285d09242b8 Author: MrRaph_ Date: Tue Jun 18 09:25:38 2024 +0200 fix: includes diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..79207a4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# WordPress Coding Standards +# https://make.wordpress.org/core/handbook/coding-standards/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +indent_size = 4 + +[{.jshintrc,*.json,*.yml}] +indent_style = space +indent_size = 2 + +[{*.txt,wp-config-sample.php}] +end_of_line = crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..385f0df --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +phpunit.xml +Thumbs.db +wp-cli.local.yml +node_modules/ +*.sql +*.tar.gz +*.zip +/vendor +.idea +composer.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..787f3ef --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 eighteen73 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2bec52 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Thumbor for WordPress + +**This plugin is in early development and may yet undergo changes that affect its functionality. Use it at your own risk until we publish a versioned release.** + +This plugin modifies Wordpress image URLs so they can be served from [Thumbor](https://www.thumbor.org/). + +A well configured Thumbor server can deliver highly optimised images to improve website load times, and take a large of your web server by removing the need for WordPress to generate image derivatives for itself. + +## Prerequisites + +You need access to a Thumbor service to use this plugin. + +Note this typically only works on remote WordPress websites because Thumbor needs web access to the uploaded images. With some environment configuration you may be able to make it work in a development environment but that setup is outside the scope of this plugin. + +## Installation + +Install and activate the plugin as normal then add the following configuration to your website. + +```php +define( 'THUMBOR_URL', 'https://media.example.com' ); +define( 'THUMBOR_SECRET_KEY', 'your_thumbor_secret_key' ); +``` + +If you Thumbor server runs in unsafe mode (which is _highly_ discouraged) you may set `THUMBOR_SECRET_KEY` to `null`. + +### Optional: Disabling “big image” resizing + +WordPress can automatically resize large image uploads which will save unnecessarily large requests from your Thumbor server. This plugin can change that limit with the following configuration. + +```php +# Disable “big image” resizing +define( 'THUMBOR_UPLOAD_IMAGE_THRESHOLD', false ); + +# Set the longest image edge +define( 'THUMBOR_UPLOAD_IMAGE_THRESHOLD', 2000 ); +``` + +See [`big_image_size_threshold`](https://developer.wordpress.org/reference/hooks/big_image_size_threshold/) docs for more information. + +### Optional: Delete prior image files + +Once the plugin is enabled you can make use of a WP-CLI command to delete any image derivatives that have already been created. + +**Use this command with great care because it will delete media files from your server.** + +```shell +wp media regenerate +``` + +## Deactivating/pausing the plugin + +The plugin automatically deactivates itself when `THUMBOR_URL` is not set. So in your development environment you can remove the above configuration to make WordPress return to its default behaviour. + +While enabled the plugin has prevented WordPress from making it's own resized versions of images. You can use the following WP-CLI command to generate any missing images after disabling the plugin: + +```shell +wp media regenerate --only-missing +``` + +## Credits + +This plugin is heavily based on code that was forked from the [Tachyon plugin](https://github.com/humanmade/tachyon-plugin) by Human Made. All due credit to the authors of that plugin. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d2e8465 --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "name": "eighteen73/wordpress-thumbor", + "description": "A WordPress plugin to serve media via a Thumbor server", + "type": "wordpress-plugin", + "license": "MIT", + "minimum-stability": "stable", + "require": { + "php": ">=8.0" + }, + "autoload": { + "psr-4": { + "Eighteen73\\Thumbor\\": "includes/classes/" + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "require-dev": { + "eighteen73/wordpress-coding-standards": "^2.0.0" + } +} diff --git a/includes/classes/Thumbor/MediaOverrides.php b/includes/classes/Thumbor/MediaOverrides.php new file mode 100644 index 0000000..b6bab7a --- /dev/null +++ b/includes/classes/Thumbor/MediaOverrides.php @@ -0,0 +1,99 @@ +setup(); + } + + return self::$instance; + } + + /** + * Silence is golden. + */ + private function __construct() {} + + /** + * Register actions and filters, but only if basic Thumbor functions are available. + * The basic functions are found in ./wordpress-thumbor.php. + * + * @uses add_action, add_filter + * @return null + */ + private function setup() { + + if ( ! function_exists( 'thumbor_url' ) ) { + return; + } + + // Don't scale down big images + add_filter( 'big_image_size_threshold', [ $this, 'image_threshold' ], 999, 1 ); + + // Don't resize any images + add_filter( 'intermediate_image_sizes_advanced', [ $this, 'prevent_resizing' ], 10, 5 ); + } + + /** + * Prevents any images from being automatically created. + * + * @param int $threshold The threshold value in pixels. + * + * @return bool|int The new “big image” threshold value. + */ + public static function image_threshold( $threshold ) { + + if ( ! defined( 'THUMBOR_UPLOAD_IMAGE_THRESHOLD' ) ) { + return $threshold; + } + + if ( THUMBOR_UPLOAD_IMAGE_THRESHOLD === false ) { + return false; + } elseif ( is_int( THUMBOR_UPLOAD_IMAGE_THRESHOLD ) ) { + return THUMBOR_UPLOAD_IMAGE_THRESHOLD; + } + + return $threshold; + } + + /** + * Prevents any images from being automatically created. + * + * @param array $sizes Associative array of image sizes to be created. + * @param array $image_meta The image meta data: width, height, file, sizes, etc. + * + * @return array Associative array of image sizes to be created. + */ + public static function prevent_resizing( $sizes, $image_meta ) { + return []; + } +} diff --git a/includes/classes/Thumbor/ThumborImage.php b/includes/classes/Thumbor/ThumborImage.php new file mode 100644 index 0000000..0567ba1 --- /dev/null +++ b/includes/classes/Thumbor/ThumborImage.php @@ -0,0 +1,1031 @@ +setup(); + } + + return self::$instance; + } + + /** + * Silence is golden. + */ + private function __construct() {} + + /** + * Register actions and filters, but only if basic Thumbor functions are available. + * The basic functions are found in ./wordpress-thumbor.php. + * + * @uses add_action, add_filter + * @return null + */ + private function setup() { + + if ( ! function_exists( 'thumbor_url' ) ) { + return; + } + + // Images in post content and galleries. + add_filter( 'the_content', [ __CLASS__, 'filter_the_content' ], 999999 ); + add_filter( 'get_post_galleries', [ __CLASS__, 'filter_the_galleries' ], 999999 ); + + // Core image retrieval. + add_filter( 'image_downsize', [ $this, 'filter_image_downsize' ], 10, 3 ); + add_filter( 'rest_request_before_callbacks', [ $this, 'should_rest_image_downsize' ], 10, 3 ); + add_filter( 'rest_request_after_callbacks', [ $this, 'cleanup_rest_image_downsize' ] ); + + // Responsive image srcset substitution. + add_filter( 'wp_calculate_image_srcset', [ $this, 'filter_srcset_array' ], 10, 5 ); + } + + /** + * * IN-CONTENT IMAGE MANIPULATION FUNCTIONS + **/ + + /** + * Match all images and any relevant tags in a block of HTML. + * + * @param string $content Some HTML. + * @return array An array of $images matches, where $images[0] is + * an array of full matches, and the link_url, img_tag, + * and img_url keys are arrays of those matches. + */ + public static function parse_images_from_html( $content ) { + $images = []; + + if ( preg_match_all( '#(?:]+?href=["|\'](?P[^\s]+?)["|\'][^>]*?>\s*)?(?P]+?src=["|\'](?P[^\s]+?)["|\'].*?>){1}(?:\s*)?#is', $content, $images ) ) { + foreach ( $images as $key => $unused ) { + // Simplify the output as much as possible, mostly for confirming test results. + if ( is_numeric( $key ) && $key > 0 ) { + unset( $images[ $key ] ); + } + } + + return $images; + } + + return []; + } + + /** + * Try to determine height and width from strings WP appends to resized image filenames. + * + * @param string $src The image URL. + * @return array An array consisting of width and height. + */ + public static function parse_dimensions_from_filename( $src ) { + $width_height_string = []; + + if ( preg_match( '#-(\d+)x(\d+)\.(?:' . implode( '|', self::$extensions ) . '){1}$#i', $src, $width_height_string ) ) { + $width = (int) $width_height_string[1]; + $height = (int) $width_height_string[2]; + + if ( $width && $height ) { + return [ $width, $height ]; + } + } + + return [ false, false ]; + } + + /** + * Identify images in post content, and if images are local (uploaded to the current site), pass through Thumbor. + * + * @param string $content Post content. + * @uses self::validate_image_url, apply_filters, thumbor_url, esc_url + * @filter the_content + * @return string + */ + public static function filter_the_content( $content ) { + $images = static::parse_images_from_html( $content ); + + if ( ! empty( $images ) ) { + $content_width = isset( $GLOBALS['content_width'] ) ? $GLOBALS['content_width'] : false; + + $image_sizes = self::image_sizes(); + $upload_dir = wp_upload_dir(); + $attachment_ids = []; + + foreach ( $images[0] as $tag ) { + if ( preg_match( '/wp-image-([0-9]+)/i', $tag, $class_id ) && absint( $class_id[1] ) ) { + // Overwrite the ID when the same image is included more than once. + $attachment_ids[ $class_id[1] ] = true; + } + } + + if ( count( $attachment_ids ) > 1 ) { + /* + * Warm the object cache with post and meta information for all found + * images to avoid making individual database calls. + */ + _prime_post_caches( array_keys( $attachment_ids ), false, true ); + } + + foreach ( $images[0] as $index => $tag ) { + // Default to resize, though fit may be used in certain cases where a dimension cannot be ascertained. + $transform = 'resize'; + + // Start with a clean size and attachment ID each time. + $attachment_id = false; + unset( $size ); + + // Flag if we need to munge a fullsize URL. + $fullsize_url = false; + + // Identify image source. + $src = $images['img_url'][ $index ]; + $src_orig = $src; + + /** + * Allow specific images to be skipped by Thumbor. + * + * @since 2.0.3 + * + * @param bool false Should Thumbor ignore this image. Default to false. + * @param string $src Image URL. + * @param string $tag Image Tag (Image HTML output). + */ + if ( apply_filters( 'thumbor_skip_image', false, $src, $tag ) ) { + continue; + } + + // Support Automattic's Lazy Load plugin. + // Can't modify $tag yet as we need unadulterated version later. + if ( preg_match( '#data-lazy-src=["|\'](.+?)["|\']#i', $images['img_tag'][ $index ], $lazy_load_src ) ) { + $placeholder_src = $src; + $placeholder_src_orig = $placeholder_src; + $src = $lazy_load_src[1]; + $src_orig = $src; + } elseif ( preg_match( '#data-lazy-original=["|\'](.+?)["|\']#i', $images['img_tag'][ $index ], $lazy_load_src ) ) { + $placeholder_src = $src; + $placeholder_src_orig = $placeholder_src; + $src = $lazy_load_src[1]; + $src_orig = $src; + } + + // Check if image URL should be used with Thumbor. + if ( self::validate_image_url( $src ) ) { + // Find the width and height attributes. + $width = false; + $height = false; + + // First, check the image tag. + if ( preg_match( '#width=["|\']?([\d%]+)["|\']?#i', $images['img_tag'][ $index ], $width_string ) ) { + $width = $width_string[1]; + } + + if ( preg_match( '#height=["|\']?([\d%]+)["|\']?#i', $images['img_tag'][ $index ], $height_string ) ) { + $height = $height_string[1]; + } + + // If image tag lacks width or height arguments, try to determine from strings WP appends to resized image filenames. + if ( ! $width || ! $height ) { + $size_from_file = static::parse_dimensions_from_filename( $src ); + $width = $width ?: $size_from_file[0]; + $height = $height ?: $size_from_file[1]; + } + + // Can't pass both a relative width and height, so unset the height in favor of not breaking the horizontal layout. + if ( false !== strpos( $width, '%' ) && false !== strpos( $height, '%' ) ) { + $width = false; + $height = false; + } + + // Detect WP registered image size from HTML class. + if ( preg_match( '#class=["|\']?[^"\']*size-([^"\'\s]+)[^"\']*["|\']?#i', $images['img_tag'][ $index ], $matches ) ) { + $size = array_pop( $matches ); + + if ( false === $width && false === $height && isset( $size ) && array_key_exists( $size, $image_sizes ) ) { + $size_from_wp = wp_get_attachment_image_src( $attachment_id, $size ); + $width = $size_from_wp[1]; + $height = $size_from_wp[2]; + $transform = $image_sizes[ $size ]['crop'] ? 'resize' : 'fit'; + } + } + + // WP Attachment ID, if uploaded to this site. + if ( + preg_match( '#class=["|\']?[^"\']*wp-image-([\d]+)[^"\']*["|\']?#i', $images['img_tag'][ $index ], $class_attachment_id ) && + ( + 0 === strpos( $src, $upload_dir['baseurl'] ) || + /** + * Filter whether an image using an attachment ID in its class has to be uploaded to the local site to go through Thumbor. + * + * @since 2.0.3 + * + * @param bool false Was the image uploaded to the local site. Default to false. + * @param array $args { + * Array of image details. + * + * @type $src Image URL. + * @type tag Image tag (Image HTML output). + * @type $images Array of information about the image. + * @type $index Image index. + * } + */ + apply_filters( 'thumbor_image_is_local', false, compact( 'src', 'tag', 'images', 'index' ) ) + ) + ) { + $class_attachment_id = intval( array_pop( $class_attachment_id ) ); + + if ( $class_attachment_id ) { + $attachment = get_post( $class_attachment_id ); + // Basic check on returned post object. + if ( is_object( $attachment ) && ! is_wp_error( $attachment ) && 'attachment' === $attachment->post_type ) { + $attachment_id = $attachment->ID; + + // If we still don't have a size for the image, use the attachment_id + // to lookup the size for the image in the URL. + if ( ! isset( $size ) ) { + $meta = wp_get_attachment_metadata( $attachment_id ); + if ( $meta['sizes'] ) { + $sizes = wp_list_filter( $meta['sizes'], [ 'file' => basename( $src ) ] ); + if ( $sizes ) { + $size_names = array_keys( $sizes ); + $size = array_pop( $size_names ); + } + } + } + + // If we still don't have a size for the image but know the dimensions, + // use the attachment sources to determine the size. Thumbor modifies + // wp_get_attachment_image_src() to account for sizes created after upload. + if ( ! isset( $size ) && $width && $height ) { + $sizes = array_keys( $image_sizes ); + foreach ( $sizes as $size ) { + $size_per_wp = wp_get_attachment_image_src( $attachment_id, $size ); + if ( $width === $size_per_wp[1] && $height === $size_per_wp[2] ) { + $transform = $image_sizes[ $size ]['crop'] ? 'resize' : 'fit'; + break; + } + unset( $size ); // Prevent loop from polluting $size if it's incorrect. + } + } + + if ( isset( $size ) && false === $width && false === $height && array_key_exists( $size, $image_sizes ) ) { + $width = (int) $image_sizes[ $size ]['width']; + $height = (int) $image_sizes[ $size ]['height']; + $transform = $image_sizes[ $size ]['crop'] ? 'resize' : 'fit'; + } + + /* + * If size is still not set the dimensions were not provided by either + * a class or by parsing the URL. Only the full sized image should return + * no dimensions when returning the URL so it's safe to assume the $size is full. + */ + $size = isset( $size ) ? $size : 'full'; + + $src_per_wp = wp_get_attachment_image_src( $attachment_id, $size ); + + if ( self::validate_image_url( $src_per_wp[0] ) ) { + $src = $src_per_wp[0]; + $fullsize_url = true; + + // Prevent image distortion if a detected dimension exceeds the image's natural dimensions. + if ( ( false !== $width && $width > $src_per_wp[1] ) || ( false !== $height && $height > $src_per_wp[2] ) ) { + $width = false === $width ? false : min( $width, $src_per_wp[1] ); + $height = false === $height ? false : min( $height, $src_per_wp[2] ); + } + + // If no width and height are found, max out at source image's natural dimensions. + // Otherwise, respect registered image sizes' cropping setting. + if ( false === $width && false === $height ) { + $width = $src_per_wp[1]; + $height = $src_per_wp[2]; + $transform = 'fit'; + } elseif ( isset( $size ) && array_key_exists( $size, $image_sizes ) && isset( $image_sizes[ $size ]['crop'] ) ) { + $transform = (bool) $image_sizes[ $size ]['crop'] ? 'resize' : 'fit'; + } + } + } else { + unset( $attachment ); + } + } + } + + // If width is available, constrain to $content_width. + if ( false !== $width && false === strpos( $width, '%' ) && is_numeric( $content_width ) ) { + if ( $width > $content_width && false !== $height && false === strpos( $height, '%' ) ) { + $height = round( ( $content_width * $height ) / $width ); + $width = $content_width; + } elseif ( $width > $content_width ) { + $width = $content_width; + } + } + + // Set a width if none is found and $content_width is available. + // If width is set in this manner and height is available, use `fit` instead of `resize` to prevent skewing. + if ( false === $width && is_numeric( $content_width ) ) { + $width = (int) $content_width; + + if ( false !== $height ) { + $transform = 'fit'; + } + } + + // Detect if image source is for a custom-cropped thumbnail and prevent further URL manipulation. + if ( ! $fullsize_url && preg_match_all( '#-e[a-z0-9]+(-\d+x\d+)?\.(' . implode( '|', self::$extensions ) . '){1}$#i', basename( $src ), $filename ) ) { + $fullsize_url = true; + } + + // Build URL, first maybe removing WP's resized string so we pass the original image to Thumbor. + if ( ! $fullsize_url ) { + $src = self::strip_image_dimensions_maybe( $src ); + } + + // Build array of Thumbor args and expose to filter before passing to Thumbor URL function. + $args = []; + + if ( false !== $width && false !== $height && false === strpos( $width, '%' ) && false === strpos( $height, '%' ) ) { + if ( ! isset( $size ) || $size !== 'full' ) { + $args[ $transform ] = [ $width, $height ]; + } + + // Set the gravity from the registered image size. + // Crop weight array values are in x, y order but the value `westsouth` will + // cause Sharp to error and Thumbor to return a 404, it needs to be `southwest` + // so we reverse the crop array to y, x order. + if ( 'resize' === $transform && isset( $size ) && $size !== 'full' && array_key_exists( $size, $image_sizes ) && is_array( $image_sizes[ $size ]['crop'] ) ) { + $args['gravity'] = implode( + '', + array_map( + function ( $v ) { + $map = [ + 'top' => 'north', + 'center' => '', + 'bottom' => 'south', + 'left' => 'west', + 'right' => 'east', + ]; + return $map[ $v ]; + }, + array_reverse( $image_sizes[ $size ]['crop'] ) + ) + ); + } + } elseif ( false !== $width ) { + $args['w'] = $width; + } elseif ( false !== $height ) { + $args['h'] = $height; + } + + // Final logic check to determine the size for an unknown attachment ID. + if ( ! isset( $size ) ) { + if ( $width ) { + $filter['width'] = $width; + } + if ( $height ) { + $filter['height'] = $height; + } + + if ( ! empty( $filter ) ) { + $sizes = wp_list_filter( $image_sizes, $filter ); + if ( empty( $sizes ) ) { + $sizes = wp_list_filter( $image_sizes, $filter, 'OR' ); + } + if ( ! empty( $sizes ) ) { + $size = reset( $sizes ); + } + } + } + + if ( ! isset( $size ) ) { + // Custom size, send an array. + $size = [ $width, $height ]; + } + + /** + * Filter the array of Thumbor arguments added to an image when it goes through Thumbor. + * By default, only includes width and height values. + * + * @see https://developer.wordpress.com/docs/photon/api/ + * + * @param array $args Array of Thumbor Arguments. + * @param array $args { + * Array of image details. + * + * @type $tag Image tag (Image HTML output). + * @type $src Image URL. + * @type $src_orig Original Image URL. + * @type $width Image width. + * @type $height Image height. + * @type $attachment_id Attachment ID. + * } + */ + $args = apply_filters( 'thumbor_post_image_args', $args, compact( 'tag', 'src', 'src_orig', 'width', 'height', 'attachment_id', 'size' ) ); + + $thumbor_url = thumbor_url( $src, $args ); + + // Modify image tag if Thumbor function provides a URL + // Ensure changes are only applied to the current image by copying and modifying the matched tag, then replacing the entire tag with our modified version. + if ( $src !== $thumbor_url ) { + $new_tag = $tag; + + // If present, replace the link href with a Thumbored URL for the full-size image. + if ( ! empty( $images['link_url'][ $index ] ) && self::validate_image_url( $images['link_url'][ $index ] ) ) { + $new_tag = preg_replace( '#(href=["|\'])' . $images['link_url'][ $index ] . '(["|\'])#i', '\1' . thumbor_url( $images['link_url'][ $index ] ) . '\2', $new_tag, 1 ); + } + + // Supplant the original source value with our Thumbor URL. + $thumbor_url = esc_url( $thumbor_url ); + $new_tag = str_replace( $src_orig, $thumbor_url, $new_tag ); + + // If Lazy Load is in use, pass placeholder image through Thumbor. + if ( isset( $placeholder_src ) && self::validate_image_url( $placeholder_src ) ) { + $placeholder_src = thumbor_url( $placeholder_src ); + + if ( $placeholder_src !== $placeholder_src_orig ) { + $new_tag = str_replace( $placeholder_src_orig, esc_url( $placeholder_src ), $new_tag ); + } + + unset( $placeholder_src ); + } + + // Remove the width and height arguments from the tag to prevent distortion. + if ( apply_filters( 'thumbor_remove_size_attributes', true ) ) { + $new_tag = preg_replace( '#(?<=\s)(width|height)=["|\']?[\d%]+["|\']?\s?#i', '', $new_tag ); + } + + // Tag an image for dimension checking. + $new_tag = preg_replace( '#(\s?/)?>(\s*)?$#i', ' data-recalc-dims="1"\1>\2', $new_tag ); + + // Replace original tag with modified version. + $content = str_replace( $tag, $new_tag, $content ); + } + } elseif ( preg_match( '#^http(s)?://i[\d]{1}.wp.com#', $src ) && ! empty( $images['link_url'][ $index ] ) && self::validate_image_url( $images['link_url'][ $index ] ) ) { + $new_tag = preg_replace( '#(href=["|\'])' . $images['link_url'][ $index ] . '(["|\'])#i', '\1' . thumbor_url( $images['link_url'][ $index ] ) . '\2', $tag, 1 ); + + $content = str_replace( $tag, $new_tag, $content ); + } + } + } + + return $content; + } + + /** + * Ensure galleries use Thumbor. + * + * @param array $galleries The post's galleries. + * @return array + */ + public static function filter_the_galleries( $galleries ) { + if ( empty( $galleries ) || ! is_array( $galleries ) ) { + return $galleries; + } + + // Pass by reference, so we can modify them in place. + foreach ( $galleries as &$this_gallery ) { + if ( is_string( $this_gallery ) ) { + $this_gallery = self::filter_the_content( $this_gallery ); + } + } + unset( $this_gallery ); // break the reference. + + return $galleries; + } + + /** + * * CORE IMAGE RETRIEVAL + **/ + + /** + * Filter post thumbnail image retrieval, passing images through Thumbor. + * + * @param string|bool $image Image array. + * @param int $attachment_id The attachment ID. + * @param string|array $size The target image size. + * @uses is_admin, apply_filters, wp_get_attachment_url, self::validate_image_url, this::image_sizes, thumbor_url + * @filter image_downsize + * @return string|bool + */ + public function filter_image_downsize( $image, $attachment_id, $size ) { + /** + * Provide plugins a way of enable use of Thumbor in the admin context. + * + * @since 0.9.2 + * + * @param bool true Disable the use of Thumbor in the admin. + * @param array $args { + * Array of image details. + * + * @type $image Image URL. + * @type $attachment_id Attachment ID of the image. + * @type $size Image size. Can be a string (name of the image size, e.g. full), integer or an array e.g. [ width, height ]. + * } + */ + $disable_in_admin = is_admin() && apply_filters( 'thumbor_disable_in_admin', true, compact( 'image', 'attachment_id', 'size' ) ); + + /** + * Provide plugins a way of preventing Thumbor from being applied to images retrieved from WordPress Core. + * + * @since 0.9.2 + * + * @param bool false Stop Thumbor from being applied to the image. Default to false. + * @param array $args { + * Array of image details. + * + * @type $image Image URL. + * @type $attachment_id Attachment ID of the image. + * @type $size Image size. Can be a string (name of the image size, e.g. full), integer or an array e.g. [ width, height ]. + * } + */ + $override_image_downsize = apply_filters( 'thumbor_override_image_downsize', false, compact( 'image', 'attachment_id', 'size' ) ); + + if ( $disable_in_admin || $override_image_downsize ) { + return $image; + } + + // Get the image URL and proceed with Thumbor-ification if successful. + $image_url = wp_get_attachment_url( $attachment_id ); + $full_size_meta = wp_get_attachment_metadata( $attachment_id ); + $is_intermediate = false; + + if ( $image_url ) { + // Check if image URL should be used with Thumbor. + if ( ! self::validate_image_url( $image_url ) ) { + return $image; + } + + // If an image is requested with a size known to WordPress, use that size's settings with Thumbor. + if ( ! empty( $full_size_meta ) && ( is_string( $size ) || is_int( $size ) ) && array_key_exists( $size, self::image_sizes() ) ) { + $image_args = self::image_sizes(); + $image_args = $image_args[ $size ]; + + $thumbor_args = []; + + $image_meta = image_get_intermediate_size( $attachment_id, $size ); + + // 'full' is a special case: We need consistent data regardless of the requested size. + if ( 'full' === $size ) { + $image_meta = $full_size_meta; + } elseif ( ! $image_meta ) { + // If we still don't have any image meta at this point, it's probably from a custom thumbnail size + // for an image that was uploaded before the custom image was added to the theme. Try to determine the size manually. + $image_meta = $full_size_meta; + if ( isset( $image_meta['width'] ) && isset( $image_meta['height'] ) ) { + $image_resized = image_resize_dimensions( $image_meta['width'], $image_meta['height'], $image_args['width'], $image_args['height'], $image_args['crop'] ); + if ( $image_resized ) { // This could be false when the requested image size is larger than the full-size image. + $image_meta['width'] = $image_resized[6]; + $image_meta['height'] = $image_resized[7]; + $is_intermediate = true; + } + } + } else { + $is_intermediate = true; + } + + // Expose determined arguments to a filter before passing to Thumbor. + $transform = $image_args['crop'] ? 'resize' : 'fit'; + + // If we can't get the width from the image size args, use the width of the + // image metadata. We only do this is image_args['width'] is not set, because + // we don't want to lose this data. $image_args is used as the Thumbor URL param + // args, so we want to keep the original image sizes args. For example, if the image + // size is 300x300px, non-cropped, we want to pass `fit=300,300` to Thumbor, instead + // of say `resize=300,225`, because semantically, the image size is registered as + // 300x300 un-cropped, not 300x225 cropped. + if ( empty( $image_args['width'] ) && $transform !== 'resize' ) { + $image_args['width'] = isset( $image_meta['width'] ) ? $image_meta['width'] : 0; + } + + if ( empty( $image_args['height'] ) && $transform !== 'resize' ) { + $image_args['height'] = isset( $image_meta['height'] ) ? $image_meta['height'] : 0; + } + + // Prevent upscaling. + $image_args['width'] = min( (int) $image_args['width'], (int) $full_size_meta['width'] ); + $image_args['height'] = min( (int) $image_args['height'], (int) $full_size_meta['height'] ); + + // Respect $content_width settings. + list( $width, $height ) = image_constrain_size_for_editor( $image_meta['width'], $image_meta['height'], $size, 'display' ); + + // Check specified image dimensions and account for possible zero values; Thumbor fails to resize if a dimension is zero. + if ( ( 0 === $image_args['width'] || 0 === $image_args['height'] ) && $transform !== 'fit' ) { + if ( 0 === $image_args['width'] && 0 < $image_args['height'] ) { + $thumbor_args['h'] = $image_args['height']; + } elseif ( 0 === $image_args['height'] && 0 < $image_args['width'] ) { + $thumbor_args['w'] = $image_args['width']; + } + } else { + // Fit accepts a zero value for either dimension so we allow that. + // If resizing: + // Both width & height are required, image args should be exact dimensions. + if ( $transform === 'resize' ) { + $image_args['width'] = $image_args['width'] ?: $width; + $image_args['height'] = $image_args['height'] ?: $height; + } + + $is_intermediate = ( $image_args['width'] < $full_size_meta['width'] || $image_args['height'] < $full_size_meta['height'] ); + + // Add transform args if size is intermediate. + if ( $is_intermediate ) { + $thumbor_args[ $transform ] = [ $image_args['width'], $image_args['height'] ]; + } + + if ( $is_intermediate && 'resize' === $transform && is_array( $image_args['crop'] ) ) { + $thumbor_args['gravity'] = implode( + '', + array_map( + function ( $v ) { + $map = [ + 'top' => 'north', + 'center' => '', + 'bottom' => 'south', + 'left' => 'west', + 'right' => 'east', + ]; + return $map[ $v ]; + }, + array_reverse( $image_args['crop'] ) + ) + ); + } + } + + /** + * Filter the Thumbor Arguments added to an image when going through Thumbor, when that image size is a string. + * Image size will be a string (e.g. "full", "medium") when it is known to WordPress. + * + * @param array $thumbor_args Array of Thumbor arguments. + * @param array $args { + * Array of image details. + * + * @type $image_args Array of Image arguments (width, height, crop). + * @type $image_url Image URL. + * @type $attachment_id Attachment ID of the image. + * @type $size Image size. Can be a string (name of the image size, e.g. full) or an integer. + * @type $transform Value can be resize or fit. + * @see https://developer.wordpress.com/docs/photon/api + * } + */ + $thumbor_args = apply_filters( 'thumbor_image_downsize_string', $thumbor_args, compact( 'image_args', 'image_url', 'attachment_id', 'size', 'transform' ) ); + + // Generate Thumbor URL. + $image = [ + thumbor_url( $image_url, $thumbor_args ), + $width, + $height, + $is_intermediate, + ]; + } elseif ( is_array( $size ) ) { + // Pull width and height values from the provided array, if possible. + $width = isset( $size[0] ) ? (int) $size[0] : false; + $height = isset( $size[1] ) ? (int) $size[1] : false; + + // Don't bother if necessary parameters aren't passed. + if ( ! $width || ! $height ) { + return $image; + } + + $image_meta = wp_get_attachment_metadata( $attachment_id ); + if ( isset( $image_meta['width'] ) && isset( $image_meta['height'] ) ) { + $image_resized = image_resize_dimensions( $image_meta['width'], $image_meta['height'], $width, $height ); + if ( $image_resized ) { + // Use the resized image dimensions. + $width = $image_resized[6]; + $height = $image_resized[7]; + $is_intermediate = true; + } else { + // Resized image would be larger than original. + $width = $image_meta['width']; + $height = $image_meta['height']; + } + } + + list( $width, $height ) = image_constrain_size_for_editor( $width, $height, $size ); + + $thumbor_args = []; + + // Expose arguments to a filter before passing to Thumbor. + if ( $is_intermediate ) { + $thumbor_args['fit'] = [ $width, $height ]; + } + + /** + * Filter the Thumbor Arguments added to an image when going through Thumbor, + * when the image size is an array of height and width values. + * + * @param array $thumbor_args Array of Thumbor arguments. + * @param array $args { + * Array of image details. + * + * @type $width Image width. + * @type height Image height. + * @type $image_url Image URL. + * @type $attachment_id Attachment ID of the image. + * } + */ + $thumbor_args = apply_filters( 'thumbor_image_downsize_array', $thumbor_args, compact( 'width', 'height', 'image_url', 'attachment_id' ) ); + + // Generate Thumbor URL. + $image = [ + thumbor_url( $image_url, $thumbor_args ), + $width, + $height, + $is_intermediate, + ]; + } + } + + return $image; + } + + /** + * Filters an array of image `srcset` values, replacing each URL with its Thumbor equivalent. + * + * @since 3.8.0 + * @param array $sources An array of image urls and widths. + * @param array $size_array List of sizes for the srcset. + * @param string $image_src Current image URL. + * @param array $image_meta Image meta data. + * @param int $attachment_id The attachment ID. + * @uses self::validate_image_url, thumbor_url + * @return array An array of Thumbor image urls and widths. + */ + public function filter_srcset_array( $sources, $size_array, $image_src, $image_meta, $attachment_id ) { + $upload_dir = wp_upload_dir(); + + foreach ( $sources as $i => $source ) { + if ( ! self::validate_image_url( $source['url'] ) ) { + continue; + } + + $url = $source['url']; + list( $width, $height ) = static::parse_dimensions_from_filename( $url ); + + // It's quicker to get the full size with the data we have already, if available. + if ( isset( $image_meta['file'] ) ) { + $url = trailingslashit( $upload_dir['baseurl'] ) . $image_meta['file']; + } else { + $url = static::strip_image_dimensions_maybe( $url ); + } + + $args = []; + if ( 'w' === $source['descriptor'] ) { + if ( $height && ( intval( $source['value'] ) === intval( $width ) ) ) { + $args['resize'] = [ $width, $height ]; + } else { + $args['w'] = $source['value']; + } + } + + // If the image_src is a tahcyon url, add it's params + // to the srcset images too. + if ( strpos( $image_src, THUMBOR_URL ) === 0 ) { + parse_str( parse_url( $image_src, PHP_URL_QUERY ) ?? '', $image_src_args ); + $args = array_merge( $args, array_intersect_key( $image_src_args, [ 'gravity' => true ] ) ); + } + + /** + * Filter the array of Thumbor arguments added to an image when it goes through Thumbor. + * By default, contains only resize or width params + * + * @param array $args Array of Thumbor Arguments. + * @param array $args { + * Array of image details. + * + * @type $source Array containing URL and target dimensions. + * @type $image_meta Array containing attachment metadata. + * @type $width Image width. + * @type $height Image height. + * @type $attachment_id Image ID. + * } + */ + $args = apply_filters( 'thumbor_srcset_image_args', $args, compact( 'source', 'image_meta', 'width', 'height', 'attachment_id' ) ); + + $sources[ $i ]['url'] = thumbor_url( $url, $args ); + } + + return $sources; + } + + /** + * * GENERAL FUNCTIONS + **/ + + /** + * Ensure image URL is valid for Thumbor. + * + * Though Thumbor functions address some of the URL issues, we should avoid unnecessary processing if we know early on that the image isn't supported. + * + * @param string $url An image URL. + * @uses wp_parse_args + * @return bool + */ + public static function validate_image_url( $url ) { + $parsed_url = @parse_url( $url ); + + if ( ! $parsed_url ) { + return false; + } + + // only replace urls with supported file extensions. + if ( ! in_array( strtolower( pathinfo( $parsed_url['path'], PATHINFO_EXTENSION ) ), static::$extensions, true ) ) { + return false; + } + + $upload_dir = wp_upload_dir(); + $upload_baseurl = $upload_dir['baseurl']; + + if ( is_multisite() ) { + $upload_baseurl = preg_replace( '#/sites/[\d]+#', '', $upload_baseurl ); + } + + if ( strpos( $url, $upload_baseurl ) !== 0 ) { + return false; + } + + return apply_filters( 'thumbor_validate_image_url', true, $url, $parsed_url ); + } + + /** + * Checks if the file exists before it passes the file to Thumbor + * + * @param string $src The image URL. + * @return string + **/ + protected static function strip_image_dimensions_maybe( $src ) { + // Build URL, first removing WP's resized string so we pass the original image to Thumbor. + if ( preg_match( '#(-\d+x\d+)\.(' . implode( '|', self::$extensions ) . '){1}$#i', $src, $src_parts ) ) { + $src = str_replace( $src_parts[1], '', $src ); + } + + return $src; + } + + /** + * Provide an array of available image sizes and corresponding dimensions. + * Similar to get_intermediate_image_sizes() except that it includes image sizes' dimensions, not just their names. + * + * @global $wp_additional_image_sizes + * @uses get_option + * @return array + */ + protected static function image_sizes() { + if ( null === self::$image_sizes ) { + global $_wp_additional_image_sizes; + + // Populate an array matching the data structure of $_wp_additional_image_sizes so we have a consistent structure for image sizes. + $images = [ + 'thumb' => [ + 'width' => intval( get_option( 'thumbnail_size_w' ) ), + 'height' => intval( get_option( 'thumbnail_size_h' ) ), + 'crop' => (bool) get_option( 'thumbnail_crop' ), + ], + 'medium' => [ + 'width' => intval( get_option( 'medium_size_w' ) ), + 'height' => intval( get_option( 'medium_size_h' ) ), + 'crop' => false, + ], + 'medium_large' => [ + 'width' => intval( get_option( 'medium_large_size_w' ) ), + 'height' => intval( get_option( 'medium_large_size_h' ) ), + 'crop' => false, + ], + 'large' => [ + 'width' => intval( get_option( 'large_size_w' ) ), + 'height' => intval( get_option( 'large_size_h' ) ), + 'crop' => false, + ], + 'full' => [ + 'width' => null, + 'height' => null, + 'crop' => false, + ], + ]; + + // Compatibility mapping as found in wp-includes/media.php. + $images['thumbnail'] = $images['thumb']; + + // Update class variable, merging in $_wp_additional_image_sizes if any are set. + if ( is_array( $_wp_additional_image_sizes ) && ! empty( $_wp_additional_image_sizes ) ) { + self::$image_sizes = array_merge( $images, $_wp_additional_image_sizes ); + } else { + self::$image_sizes = $images; + } + } + + return is_array( self::$image_sizes ) ? self::$image_sizes : []; + } + + /** + * Determine if image_downsize should utilize Thumbor via REST API. + * + * The WordPress Block Editor (Gutenberg) and other REST API consumers using the wp/v2/media endpoint, especially in the "edit" + * context is more akin to the is_admin usage of Thumbor (see filter_image_downsize). Since consumers are trying to edit content in posts, + * Thumbor should not fire as it will fire later on display. By aborting an attempt to change an image here, we + * prevents issues like https://github.com/Automattic/jetpack/issues/10580 + * + * To determine if we're using the wp/v2/media endpoint, we hook onto the `rest_request_before_callbacks` filter and + * if determined we are using it in the edit context, we'll false out the `thumbor_override_image_downsize` filter. + * + * @author JetPack Photo / Automattic + * @param null|WP_Error $response Result to send to the client. Usually a WP_REST_Response or WP_Error. + * @param array $endpoint_data Route handler used for the request. + * @param WP_REST_Request $request Request used to generate the response. + * + * @return null|WP_Error The original response object without modification. + */ + public function should_rest_image_downsize( $response, $endpoint_data, $request ) { + if ( ! is_a( $request, 'WP_REST_Request' ) ) { + return $response; // Something odd is happening. Do nothing and return the response. + } + + if ( is_wp_error( $response ) ) { + // If we're going to return an error, we don't need to do anything with Photon. + return $response; + } + + $route = $request->get_route(); + + if ( false !== strpos( $route, 'wp/v2/media' ) && 'edit' === $request['context'] ) { + // Don't use `__return_true()`: Use something unique. See ::override_image_downsize_in_rest_edit_context() + // Late execution to avoid conflict with other plugins as we really don't want to run in this situation. + add_filter( 'thumbor_override_image_downsize', [ $this, 'override_image_downsize_in_rest_edit_context' ], 999999 ); + } + + return $response; + } + + /** + * Remove the override we may have added in ::should_rest_image_downsize() + * Since ::override_image_downsize_in_rest_edit_context() is only + * every used here, we can always remove it without ever worrying + * about breaking any other configuration. + * + * @param mixed $response The result to send to the client. + * @return mixed Unchanged $response + */ + public function cleanup_rest_image_downsize( $response ) { + remove_filter( 'thumbor_override_image_downsize', [ $this, 'override_image_downsize_in_rest_edit_context' ], 999999 ); + return $response; + } + + /** + * Used internally by ::should_rest_image_downsize() to not process + * image URLs in ?context=edit REST requests. + * MUST NOT be used anywhere else. + * We use a unique function instead of __return_true so that we can clean up + * after ourselves without breaking anyone else's filters. + * + * @internal + * @return true + */ + public function override_image_downsize_in_rest_edit_context() { + return true; + } +} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..fb2f309 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,23 @@ + + + WordPress Thumbor + + + . + lib/packages/* + + + + + + + + + + + + + + + + diff --git a/wordpress-thumbor.php b/wordpress-thumbor.php new file mode 100644 index 0000000..7dcb243 --- /dev/null +++ b/wordpress-thumbor.php @@ -0,0 +1,138 @@ + '300', 'resize' => array( 123, 456 ) ), or in string form (w=123&h=456). + * @param string|null $scheme One of http or https. + * @return string The raw final URL. You should run this through esc_url() before displaying it. + */ +function thumbor_url( $image_url, $args = [], $scheme = null ) { + + if ( ! defined( 'THUMBOR_URL' ) || empty( THUMBOR_URL ) ) { + return; + } + + /* + * Cache result for unique set of args to save reruns. This is because we're seeing the same image being re-run within a single + * request and there's a chance that filters applied within are expensive. A short TTL is used in case persistent cache is used + * but it doesn't need to be longed lived. + */ + $cache_key = md5( $image_url . json_encode( $args ) . ( $scheme ?? '' ) ); + $cache_ttl = 60; + $cached_url = wp_cache_get( $cache_key, 'thumbor_url' ); + if ( $cached_url ) { + return $cached_url; + } + + $upload_dir = wp_upload_dir(); + $upload_baseurl = $upload_dir['baseurl']; + + if ( is_multisite() ) { + $upload_baseurl = preg_replace( '#/sites/[\d]+#', '', $upload_baseurl ); + } + + $image_url = trim( $image_url ); + + $image_file = basename( parse_url( $image_url, PHP_URL_PATH ) ); + $image_url = str_replace( $image_file, urlencode( $image_file ), $image_url ); + + if ( strpos( $image_url, $upload_baseurl ) !== 0 ) { + return $image_url; + } + + if ( false !== apply_filters( 'thumbor_skip_for_url', false, $image_url, $args, $scheme ) ) { + return $image_url; + } + + $image_url = apply_filters( 'thumbor_pre_image_url', $image_url, $args, $scheme ); + $args = apply_filters( 'thumbor_pre_args', $args, $image_url, $scheme ); + + if ( isset( $args['fit'] ) ) { + $scale = 'fit-in'; + $width = $args['fit'][0]; + $height = $args['fit'][1]; + } elseif ( isset( $args['resize'] ) ) { + $scale = null; + $width = $args['resize'][0]; + $height = $args['resize'][1]; + } elseif ( isset( $args['w'] ) ) { + $scale = 'fit-in'; + $width = $args['w']; + $height = 'orig'; + } elseif ( isset( $args['h'] ) ) { + $scale = 'fit-in'; + $width = 'orig'; + $height = $args['h']; + } else { + $scale = 'fit-in'; + $width = 'orig'; + $height = 'orig'; + } + + $url_parts = [ + 'scale' => $scale, + 'size' => "{$width}x{$height}", + 'filters' => null, + 'smart' => null, + ]; + + $thumbor_url = implode( '/', array_filter( $url_parts ) ) . '/' . urlencode( $image_url ); + + if ( defined( 'THUMBOR_SECRET_KEY' ) && ! empty( THUMBOR_SECRET_KEY ) ) { + $signature = hash_hmac( 'sha1', $thumbor_url, THUMBOR_SECRET_KEY, true ); + $thumbor_url = rtrim( THUMBOR_URL, '/' ) . '/' . strtr( base64_encode( $signature ), '/+', '_-' ) . '/' . $thumbor_url; + } else { + $thumbor_url = rtrim( THUMBOR_URL, '/' ) . '/unsafe/' . $thumbor_url; + } + + /** + * Allows a final modification of the generated Thumbor URL. + * + * @param string $thumbor_url The final Thumbor image URL including query args. + * @param string $image_url The image URL without query args. + * @param array $args A key value array of the query args appended to $image_url. + */ + $final_thumbor_url = apply_filters( 'thumbor_url', $thumbor_url, $image_url, $args ); + + // Cache result to save reruns + wp_cache_set( $cache_key, $final_thumbor_url, 'thumbor_url', $cache_ttl ); + + return $final_thumbor_url; +}