diff --git a/README.md b/README.md index 080d255..9f1e92d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Official [WordPress plugin](https://wordpress.org/plugins/gumlet/) to automatica * [Features](#features) * [Getting Started](#getting-started) +* [Signed URLs](#signed-urls) * [Testing](#testing) @@ -17,6 +18,7 @@ Features * Use arbitrary [Gumlet API params](https://docs.gumlet.com/reference/image-formats) when editing `` tags in "Text mode" and they will pass through. * No lock in! Disable the plugin and your images will be served as they were before installation. * Added support for Gumlet Video Embed. +* Experimental [signed URLs](#signed-urls), with an optional expiry, so unsigned or modified image requests are rejected. Auto Resize stays off while this is enabled. Getting Started --------------- @@ -30,3 +32,16 @@ Getting Started 4. Return to the "Plugins" page and ensure the "gumlet plugin" is activated. Once activated, click the "settings" link and populate the "Gumlet Host" field (e.g., `https://yourcompany.gumlet.io`). This is the full host of the gumlet source you created in step #1. Optionally, you can also turn on `Auto Format` or `Auto Compress`. Finally, click "Save Options" when you're done. 5. Go to a post on your WordPress blog and ensure your images are now serving through gumlet. + + +Signed URLs (experimental) +-------------------------- + +Use this when a client should not be able to reuse their Gumlet host, or change width and other parameters, and consume CDN bandwidth. + +1. In the Gumlet dashboard, open the image source and the Security tab. Turn on Secure URLs and copy the secure token. Leave the source setting off until the plugin is saving signatures, or existing images will return 403. +2. In WordPress, open Settings → Gumlet → Experimental. Turn on Signed URLs, paste the token, and save. +3. Expiry is optional. Leave it blank and signed URLs do not expire. Set a duration in seconds (3600 is one hour) to add Gumlet's `expires` timestamp. Gumlet rejects the URL after that and caches the image only until then. Keep any full-page cache shorter than this duration. +4. Turn Secure URLs on for the source, then clear any page cache. + +Auto Resize does not run while Signed URLs is enabled. Gumlet.js adds the width in the browser after the URL is signed, and Gumlet rejects the changed URL. Images are served from the signed URL directly, and Gumlet.js lazy loading stays off. The token stays on the server. diff --git a/changelog.txt b/changelog.txt index d52fb57..670a029 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,10 @@ == Changelog == += 1.4.5 = +* Experimental: Signed URLs. Image URLs are signed with the source secure token, with an optional expiry duration in seconds. Auto Resize stays off while this is enabled, because Gumlet.js would change the URL after it is signed. +* Sign every Gumlet image URL, including images already on the Gumlet host, gallery links, and data-srcset. Previously only fetchpriority="high" images were signed. +* With Signed URLs on, picture source srcset stays in place and each candidate is signed. Existing signatures are replaced, and ampersands stay literal inside style and script. + = 1.4.4 = * Experimental things to enhance your user experience, checkout Plugin settings > Experimental diff --git a/gumlet.php b/gumlet.php index 7545f2c..fd4dcba 100644 --- a/gumlet.php +++ b/gumlet.php @@ -11,7 +11,7 @@ * Plugin Name: Gumlet * Plugin URI: https://github.com/gumlet/wordpress-plugin * Description: A WordPress plugin to automatically load all your existing (and future) WordPress images via the Gumlet service for smaller, faster, and better looking images. - * Version: 1.4.4 + * Version: 1.4.5 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: Gumlet @@ -33,6 +33,7 @@ include('includes/compability.php'); include('includes/logger.php'); include('includes/gumlet-html-fragment.php'); +include('includes/gumlet-sign-url.php'); include('includes/class-gumlet.php'); include('includes/options-page.php'); diff --git a/includes/class-gumlet.php b/includes/class-gumlet.php index b578f27..1461eeb 100644 --- a/includes/class-gumlet.php +++ b/includes/class-gumlet.php @@ -30,6 +30,14 @@ class Gumlet */ protected $buffer_started = false; + /** + * Signature expiry for this request. + * false until resolved, null when no expiry is configured, otherwise a unix timestamp. + * + * @var int|null|false + */ + protected $signed_url_expires_at = false; + private $doingAjax = false; public static $excludedAjaxActions = array( @@ -234,14 +242,57 @@ public function get_option($key, $default = null) /** * Auto Resize: placeholder + gumlet.js viewport sizing. When false, src is set to the Gumlet URL and gumlet.js is not loaded. + * Signed URLs force this off, because Gumlet.js would change the query string after the signature is created. * * @return bool */ protected function is_auto_resize_enabled() { + if (!empty($this->options['signed_urls'])) { + return false; + } + return (int) $this->get_option('auto_resize', 1) === 1; } + /** + * Unix timestamp written into signed URLs, or null when expiry is left blank. + * One value is reused for every image on this request. + * + * @return int|null + */ + protected function get_signed_url_expires() + { + if ($this->signed_url_expires_at !== false) { + return $this->signed_url_expires_at; + } + + $seconds = (int) $this->get_option('signed_url_expiry', 0); + $this->signed_url_expires_at = $seconds > 0 ? time() + $seconds : null; + + return $this->signed_url_expires_at; + } + + /** + * Sign a Gumlet image URL when Signed URLs is enabled and a token is saved. + * + * @param string $url + * @return string + */ + protected function sign_image_url($url) + { + if (empty($this->options['signed_urls'])) { + return $url; + } + + $token = trim((string) $this->get_option('secure_token', '')); + if ($token === '') { + return $url; + } + + return gumlet_sign_image_url($url, $token, $this->get_signed_url_expires()); + } + /** * Override options from settings. * Used in unit tests. @@ -251,6 +302,7 @@ protected function is_auto_resize_enabled() public function set_options($options) { $this->options = $options; + $this->signed_url_expires_at = false; } /** @@ -264,36 +316,142 @@ public function replace_image_url($url) { if (! empty($this->options['cdn_link'])) { $parsed_url = parse_url($url); + $cdn = parse_url($this->options['cdn_link']); + $gumlet_host = isset($cdn['host']) ? $cdn['host'] : ''; + $on_gumlet = $gumlet_host !== '' && isset($parsed_url['host']) && strcasecmp($parsed_url['host'], $gumlet_host) === 0; //Check if image is hosted on current site url -OR- the CDN url specified. Using strpos because we're comparing the host to a full CDN url. + //URLs already on the Gumlet host still need global params and a signature. Only priority images were signed before, because that path calls sign_image_url on its own. if ( isset($parsed_url['host'], $parsed_url['path']) - && ($parsed_url['host'] === parse_url(home_url('/'), PHP_URL_HOST) + && ($on_gumlet + || $parsed_url['host'] === parse_url(home_url('/'), PHP_URL_HOST) || (isset($this->options['external_cdn_link']) && ! empty($this->options['external_cdn_link']) && strpos($this->options['external_cdn_link'], $parsed_url['host']) !== false)) && preg_match('/\.(jpg|jpeg|png|gif|svg|webp)$/i', $parsed_url['path']) ) { - $cdn = parse_url($this->options['cdn_link']); - - foreach ([ 'scheme', 'host', 'port' ] as $url_part){ - if (isset($cdn[ $url_part ])) { - $parsed_url[ $url_part ] = $cdn[ $url_part ]; - } else { - unset($parsed_url[ $url_part ]); + if (!$on_gumlet) { + foreach ([ 'scheme', 'host', 'port' ] as $url_part){ + if (isset($cdn[ $url_part ])) { + $parsed_url[ $url_part ] = $cdn[ $url_part ]; + } else { + unset($parsed_url[ $url_part ]); + } } - } - if (! empty($this->options['external_cdn_link'])) { - $cdn_path = parse_url($this->options['external_cdn_link'], PHP_URL_PATH); + if (! empty($this->options['external_cdn_link'])) { + $cdn_path = parse_url($this->options['external_cdn_link'], PHP_URL_PATH); - if (isset($cdn_path, $parsed_url['path']) && $cdn_path !== '/' && ! empty($parsed_url['path'])) { - $parsed_url['path'] = str_replace($cdn_path, '', $parsed_url['path']); + if (isset($cdn_path, $parsed_url['path']) && $cdn_path !== '/' && ! empty($parsed_url['path'])) { + $parsed_url['path'] = str_replace($cdn_path, '', $parsed_url['path']); + } } } $url = http_build_url($parsed_url); $url = add_query_arg($this->get_global_params(), $url); + $url = $this->sign_image_url($url); } } return $url; + } + + /** + * Sign Gumlet image URLs that were not rebuilt as img src. + * Covers links, data-srcset, and files already stored on the Gumlet host. + * + * @param string $content + * @return string + */ + protected function sign_unsigned_gumlet_urls($content) + { + if (empty($this->options['signed_urls']) || empty($this->options['cdn_link'])) { + return $content; + } + if (trim((string) $this->get_option('secure_token', '')) === '') { + return $content; + } + + $gumlet_host = parse_url($this->options['cdn_link'], PHP_URL_HOST); + if (!$gumlet_host) { + return $content; + } + + $pattern = '#https?://' . preg_quote($gumlet_host, '#') . '/[^"\'\s>]*?\.(?:jpe?g|png|gif|svg|webp)(?:\?[^"\'\s>]*)?#i'; + $count = 0; + + return preg_replace_callback($pattern, function ($match) use ($content) { + $original = $match[0][0]; + $raw = html_entity_decode($original, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + // Always re-sign. A stored s can belong to an old token or an expiry that has already passed. + $signed = $this->replace_image_url($raw); + if ($signed === $raw) { + return $original; + } + // style and script contents are not HTML-decoded, so & would be sent to Gumlet as the parameter name. + if ($this->html_offset_is_raw_text($content, $match[0][1])) { + return $signed; + } + + return str_replace('&', '&', $signed); + }, $content, -1, $count, PREG_OFFSET_CAPTURE); + } + + /** + * True when the offset sits inside a style or script element. + * + * @param string $content + * @param int $offset + * @return bool + */ + protected function html_offset_is_raw_text($content, $offset) + { + $before = substr($content, 0, (int) $offset); + + return $this->html_tag_is_open($before, 'style') || $this->html_tag_is_open($before, 'script'); + } + + /** + * @param string $before + * @param string $tag + * @return bool + */ + protected function html_tag_is_open($before, $tag) + { + $open = strripos($before, '<' . $tag); + if ($open === false) { + return false; + } + $close = strripos($before, 'replace_image_url($url); + $rewritten[] = $url . (isset($candidate[2]) && $candidate[2] !== '' ? ' ' . $candidate[2] : ''); + } + + return implode(', ', $rewritten); } public function replace_wc_gallery_thumbs($matches) { @@ -416,7 +574,7 @@ public function replace_images_in_amp_instant_article($content) $this->logger->log("img srcset",$matches); $content = $this->replace_in_amp_srcset($matches,$content,$gumlet_host,$going_to_be_replaced_host); } - return $content; + return $this->sign_unsigned_gumlet_urls($content); } /** @@ -455,8 +613,8 @@ public function replace_images_in_content($content) $content = $this->replace_src_in_imgtag($matches, $content, $gumlet_host, $going_to_be_replaced_host, $excluded_urls, $is_s3_host); } - // now we will replace srcset in SOURCE tags to data-srcset. - if (preg_match_all('/]*srcset=([\"\']??)([^\" >]*?)\1[^>]*>/iU', $content, $matches)) { + // Source srcset: Gumlet.js reads data-srcset. With Auto Resize off, keep a real srcset and sign each candidate. + if (preg_match_all('/"\']|"[^"]*"|\'[^\']*\')*>/i', $content, $matches)) { $content = $this->replace_srcset_in_source($matches,$content); } @@ -491,8 +649,8 @@ public function replace_images_in_content($content) } } $content = $this->replace_src_in_css($final_matches,$content,$going_to_be_replaced_host,$gumlet_host,$excluded_urls); - - return $content; + + return $this->sign_unsigned_gumlet_urls($content); } /** @@ -697,10 +855,10 @@ protected function set_priority_image_attributes($imageTag, $gumlet_url) if ($w > $max_width) { break; } - $srcset[] = add_query_arg('w', $w, $gumlet_url) . ' ' . $w . 'w'; + $srcset[] = $this->sign_image_url(add_query_arg('w', $w, $gumlet_url)) . ' ' . $w . 'w'; } - $imageTag->setAttribute("src", add_query_arg('w', min(1280, $max_width), $gumlet_url)); + $imageTag->setAttribute("src", $this->sign_image_url(add_query_arg('w', min(1280, $max_width), $gumlet_url))); if ($srcset) { $imageTag->setAttribute("srcset", implode(', ', $srcset)); if (!$imageTag->getAttribute('sizes')) { @@ -754,8 +912,16 @@ public function replace_srcset_in_source($matches,$content) { if($sourceTag->length > 0){ $sourceTag = $sourceTag[0]; $src = $sourceTag->getAttribute('srcset'); - $sourceTag->removeAttribute("srcset"); - $sourceTag->setAttribute("data-srcset", $src); + if ($src === '') { + continue; + } + if (!$this->is_auto_resize_enabled()) { + $sourceTag->setAttribute("srcset", $this->rewrite_srcset($src)); + $sourceTag->removeAttribute("data-srcset"); + } else { + $sourceTag->removeAttribute("srcset"); + $sourceTag->setAttribute("data-srcset", $src); + } $new_source_tag = $doc->saveHTML($sourceTag); $content = str_replace($unconverted_img_tag, $new_source_tag, $content); } @@ -785,10 +951,18 @@ public function replace_src_in_style($matches,$content,$going_to_be_replaced_hos // don't process excluded URLs continue; } + $original_bg = $bg['image']; preg_match_all('/-\d+x\d+(?=\.(jpg|jpeg|png|gif|svg))/i', $bg['image'], $size_matches); if ($size_matches[0] && strlen($size_matches[0][0]) > 4 && $this->get_option("original_images")) { $bg['image'] = preg_replace('/-\d+x\d+(?=\.(jpg|jpeg|png|gif|svg))/i', '', $bg['image']); } + // Gumlet.js reads data-bg. With Auto Resize off (including Signed URLs), keep a real CSS url so the background still loads and can be signed. + if (!$this->is_auto_resize_enabled()) { + $bg['image'] = $this->replace_image_url($bg['image']); + $final_bg_style = str_replace($original_bg, $bg['image'], $match); + $content = str_replace(array($match.';', $match), array($final_bg_style, $final_bg_style), $content); + continue; + } $bg_less_match = str_replace($bg[0], '', $match); $data_match = 'data-bg="'.$bg['image'].'" '.$bg_less_match; $content = str_replace(array($match.';', $match), array( $data_match, $data_match), $content); diff --git a/includes/gumlet-sign-url.php b/includes/gumlet-sign-url.php new file mode 100644 index 0000000..478f3eb --- /dev/null +++ b/includes/gumlet-sign-url.php @@ -0,0 +1,130 @@ + 0) { + $query = gumlet_sign_upsert_query_param($query, 'expires', (string) (int) $expires); + } + + $payload = $token . '/' . ltrim($parts['path'], '/'); + if ($query !== '') { + $payload .= '?' . $query; + } + + $parts['query'] = ($query === '' ? '' : $query . '&') . 's=' . md5($payload); + + return http_build_url($parts); + } +} + +if (!function_exists('gumlet_sign_strip_query_param')) { + /** + * Remove one query parameter without reordering the rest. + * + * @param string $query + * @param string $name + * @return string + */ + function gumlet_sign_strip_query_param($query, $name) + { + if ($query === '') { + return ''; + } + + $kept = array(); + foreach (explode('&', $query) as $part) { + if ($part === '') { + continue; + } + $key = $part; + $eq = strpos($part, '='); + if ($eq !== false) { + $key = substr($part, 0, $eq); + } + if (rawurldecode($key) === $name) { + continue; + } + $kept[] = $part; + } + + return implode('&', $kept); + } +} + +if (!function_exists('gumlet_sign_upsert_query_param')) { + /** + * Set a query parameter, replacing it in place when it already exists. + * + * @param string $query + * @param string $name + * @param string $value + * @return string + */ + function gumlet_sign_upsert_query_param($query, $name, $value) + { + $parts = ($query === '') ? array() : explode('&', $query); + $assignment = $name . '=' . $value; + $replaced = false; + + foreach ($parts as $index => $part) { + if ($part === '') { + continue; + } + $key = $part; + $eq = strpos($part, '='); + if ($eq !== false) { + $key = substr($part, 0, $eq); + } + if (rawurldecode($key) === $name) { + $parts[$index] = $assignment; + $replaced = true; + } + } + + if (!$replaced) { + $parts[] = $assignment; + } + + $kept = array(); + foreach ($parts as $part) { + if ($part !== '') { + $kept[] = $part; + } + } + + return implode('&', $kept); + } +} diff --git a/includes/options-page.php b/includes/options-page.php index ebed524..48e1dd4 100644 --- a/includes/options-page.php +++ b/includes/options-page.php @@ -59,6 +59,16 @@ public function gumlet_options_page()

Heads up! Clear cache: We recommend you clear cache after enabling Gumlet.

+ options['signed_urls'])) { + ?> +
+

Signed URLs are on. Auto Resize stays off. Gumlet.js would change the image URL after it is signed, and Gumlet would reject that request. Images load from the signed URL. Enable Secure URLs on the Gumlet source after the token below is saved, then clear any page cache.

+ get_option('signed_url_expiry') > 0) { ?> +

These URLs expire get_option('signed_url_expiry'); ?> seconds after the page is rendered. Keep any full-page cache shorter than that.

+ +
@@ -162,6 +172,7 @@ class="regular-text code" /> name="gumlet_settings[auto_resize]" value="1" get_option('auto_resize', true)) ?> />

+

@@ -301,6 +312,47 @@ class="regular-text code" /> when the browser window is resized.

+ + + + + + get_option('signed_urls')) ?> /> +

Signs each image URL with your Gumlet source secure token. Unsigned or modified URLs are rejected, which stops other sites from using your images or CDN and consuming bandwidth.

+

Auto Resize will not run while this is on. Gumlet.js adds the width in the browser after the URL is signed, and Gumlet rejects the changed URL. Images are served from the signed URL directly. Gumlet.js lazy loading stays off as well. In the Gumlet dashboard, open the image source, Security tab, and turn on Secure URLs. Paste the token below and save here before enabling it on the source, then clear any page cache.

+ + + + + + + + +

From the source Security tab. Used only on the server to sign URLs. Leave Signed URLs off until this is saved.

+ + + + + + + + +

Optional. Leave blank and signed URLs do not expire. A number is how many seconds the URL stays valid after the page is rendered (3600 is one hour). Gumlet rejects it after that and caches the image only until then. Keep any full-page cache shorter than this value.

+ + @@ -337,6 +389,17 @@ class="regular-text code" /> .mytabs input[type="radio"]:checked + label { background: #fff; } + + .gumlet-warning, + .notice.gumlet-warning, + .notice.gumlet-warning p { + color: #d63638; + } + + .notice.gumlet-warning { + border-left-color: #d63638; + background: #fcf0f1; + } diff --git a/readme.txt b/readme.txt index 3145300..d45d2da 100644 --- a/readme.txt +++ b/readme.txt @@ -8,7 +8,7 @@ Author URI: https://www.gumlet.com Requires at least: 6.0 Requires PHP: 7.4 Tested up to: 7.1 -Stable tag: 1.4.4 +Stable tag: 1.4.5 License: BSD-2 License URI: http://opensource.org/licenses/BSD-2-Clause @@ -94,6 +94,7 @@ More details on our [website](https://www.gumlet.com/pricing) * **Image Metadata** - Keep (for photographers) or Remove (for optimisation), you choose! * **GDPR compliant** - We do not record or store any personal information about your users. * **Video Embed** - Added support for Gumlet Video Embeds. +* **Signed URLs (experimental)** - Sign image URLs so unsigned or modified requests are rejected. Optional expiry in seconds. Auto Resize stays off while this is on. = 15 MINUTE INSTALLATION GUIDE = @@ -130,6 +131,9 @@ A: The limit is not hard enforced. When you cross the limit, we will send you in = Q: Does Gumlet delete or replace my original full-size images? = A: Nope. Your original images always remain as they are and we never touch them. We only resize and compress images on-the-fly and cache it in our servers. You retain full control over your original images and they are never overwritten. += Q: Why is Auto Resize off when Signed URLs is enabled? = +A: Signed URLs are experimental (Settings → Experimental). The plugin signs each image URL on the server. Gumlet.js would then add the width in the browser, which changes the URL, and Gumlet would reject it. Leave the expiry field blank for URLs that do not expire, or set a duration in seconds. Keep any full-page cache shorter than that duration. Enable Secure URLs on the Gumlet source only after the token is saved, then clear the page cache. + = Q: I’m a photographer, can I keep all my EXIF data? = A: Yes! EXIF data stores camera settings, focal length, date, time and location information in image files. Since we never overwrite your original images, your all EXIF data will always be preserved. We only remove the EXIF data when we deliver images to your users. @@ -141,6 +145,14 @@ If you need any help, you can reach out to us at support@gumlet.com. == Changelog == += 1.4.5 = +* Experimental: Signed URLs. Image URLs are signed with the source secure token, with an optional expiry duration in seconds. Auto Resize stays off while this is enabled, because Gumlet.js would change the URL after it is signed. +* Sign every Gumlet image URL, including images already on the Gumlet host, gallery links, and data-srcset. Previously only fetchpriority="high" images were signed. +* With Signed URLs on, picture source srcset stays in place and each candidate is signed. Existing signatures are replaced, and ampersands stay literal inside style and script. + += 1.4.4 = +* Experimental things to enhance your user experience, checkout Plugin settings > Experimental + = 1.4.3 = * WordPress 7.1 compatibility: tested up to 7.1.