Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<a name="features"></a>
Expand All @@ -17,6 +18,7 @@ Features
* Use arbitrary [Gumlet API params](https://docs.gumlet.com/reference/image-formats) when editing `<img>` 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
---------------
Expand All @@ -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.

<a name="signed-urls"></a>
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.
5 changes: 5 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion gumlet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="http://www.gumlet.com" target="_blank">Gumlet</a> 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
Expand All @@ -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');

Expand Down
218 changes: 196 additions & 22 deletions includes/class-gumlet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Comment on lines +251 to +252
}

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.
Expand All @@ -251,6 +302,7 @@ protected function is_auto_resize_enabled()
public function set_options($options)
{
$this->options = $options;
$this->signed_url_expires_at = false;
}

/**
Expand All @@ -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 &amp; 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('&', '&amp;', $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, '</' . $tag);

return $close === false || $close < $open;
}

/**
* Rewrite each candidate in a srcset and keep its width or density descriptor.
*
* @param string $srcset
* @return string
*/
protected function rewrite_srcset($srcset)
{
$rewritten = array();
foreach (explode(',', $srcset) as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
if (!preg_match('/^(\S+)(?:\s+(.*))?$/', $part, $candidate)) {
$rewritten[] = $part;
continue;
}
$url = $candidate[1];
if (!parse_url($url, PHP_URL_HOST)) {
$url = self::absoluteUrl($url);
}
$url = $this->replace_image_url($url);
$rewritten[] = $url . (isset($candidate[2]) && $candidate[2] !== '' ? ' ' . $candidate[2] : '');
}

return implode(', ', $rewritten);
}

public function replace_wc_gallery_thumbs($matches) {
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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('/<source\s[^>]*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('/<source\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>/i', $content, $matches)) {
$content = $this->replace_srcset_in_source($matches,$content);
}

Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
Loading