Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2e3d8fa
wip
duncanmcclean Apr 29, 2026
4408ec7
remove unneccessary comments in test
duncanmcclean Apr 29, 2026
05afbd9
fix failing test on windows 🤞
duncanmcclean Apr 29, 2026
697c950
call it `hybrid` instead
duncanmcclean Apr 29, 2026
bf202d6
remove query parameters from url to avoid seo issues
duncanmcclean Apr 29, 2026
bd2be92
formatting
duncanmcclean Apr 29, 2026
a448b59
Merge branch '6.x' into glide-half-measure-caching
duncanmcclean May 14, 2026
ea457e9
Fix hybrid URL builder mangling asset ID strings
duncanmcclean May 14, 2026
6bfec72
Merge remote-tracking branch 'origin/6.x' into glide-half-measure-cac…
jasonvarga Sep 14, 2026
b0b8138
throw `AssetNotFoundException` when a hybrid asset id cannot be resolved
duncanmcclean Sep 14, 2026
aa44e90
run hybrid caching tests with the default secure setting
duncanmcclean Sep 14, 2026
56f1f5b
only register the cache path glide route in hybrid mode
duncanmcclean Sep 14, 2026
8fe77e4
stop `RemoteUrlValidator::parse()` resolving dns
duncanmcclean Sep 14, 2026
a1de853
predict watermarked cache paths using the `mark` param glide hashes
duncanmcclean Sep 14, 2026
773e2eb
return 404 when a hybrid glide path traverses outside the cache
duncanmcclean Sep 14, 2026
0088bae
warn when the hybrid cache path is not served by the glide route
duncanmcclean Sep 14, 2026
ff8fb4b
test hybrid caching of remote urls
duncanmcclean Sep 14, 2026
96868a3
tidy up hybrid caching tests and builder
duncanmcclean Sep 14, 2026
08f2956
assert hybrid urls point at the file the generator writes
duncanmcclean Sep 16, 2026
7038912
encode each segment of the hybrid cache path in the url
duncanmcclean Sep 16, 2026
ffb172b
serve hybrid images from the root on every site
duncanmcclean Sep 16, 2026
a38ff33
predict the thumbnail cache path for video assets
duncanmcclean Sep 16, 2026
48b3cbc
drop the watermark when its asset no longer exists
duncanmcclean Sep 16, 2026
cf0ac87
only warn about a misconfigured hybrid cache path once
duncanmcclean Sep 16, 2026
89f5a7e
simplify the hybrid url assembly
duncanmcclean Sep 16, 2026
223e888
warn once when an existing hybrid image is served through php
jasonvarga Sep 22, 2026
7e539c2
Merge remote-tracking branch 'origin/6.x' into glide-half-measure-cac…
jasonvarga Sep 22, 2026
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
13 changes: 10 additions & 3 deletions config/assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,16 @@
| Save Cached Images
|--------------------------------------------------------------------------
|
| Enabling this will make Glide save publicly accessible images. It will
| increase performance at the cost of the dynamic nature of HTTP based
| image manipulation. You will need to invalidate images manually.
| This controls how manipulated images are cached and served.
|
| false - Images are generated on each HTTP request via Glide routes.
| true - Images are eagerly generated during template rendering and
| saved to a publicly accessible location.
| 'hybrid' - Images are generated on-demand on the first HTTP request,
| then saved to a publicly accessible location so the web
| server can serve them directly on subsequent requests.
|
| When using true or 'hybrid', you should configure the cache_path below.
|
*/

Expand Down
8 changes: 8 additions & 0 deletions routes/glide.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
use Statamic\Facades\URL;
use Statamic\Http\Controllers\GlideController;

if (Glide::isUsingHybridCaching()) {
Route::group(['prefix' => Glide::route()], function () {
Route::get('{path}', [GlideController::class, 'generateByPath'])->where('path', '.*');
});

return;
}

Site::all()->map(function ($site) {
return trim(URL::makeRelative($site->url()), '/');
})->unique()->each(function ($sitePrefix) {
Expand Down
2 changes: 1 addition & 1 deletion routes/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
});
}

if (Glide::shouldServeByHttp()) {
if (Glide::shouldServeByHttp() || Glide::isUsingHybridCaching()) {
Comment thread
duncanmcclean marked this conversation as resolved.
require __DIR__.'/glide.php';
}

Expand Down
2 changes: 2 additions & 0 deletions src/Facades/Glide.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* @method static \Illuminate\Contracts\Filesystem\Filesystem cacheDisk()
* @method static bool shouldServeDirectly()
* @method static bool shouldServeByHttp()
* @method static bool isUsingHybridCaching()
* @method static bool cachePathIsServedByRoute()
* @method static string route()
* @method static string url()
* @method static \Illuminate\Contracts\Cache\Repository cacheStore()
Expand Down
78 changes: 76 additions & 2 deletions src/Http/Controllers/GlideController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace Statamic\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use League\Flysystem\PathTraversalDetected;
use League\Flysystem\UnableToReadFile;
use League\Glide\Server;
use League\Glide\Signatures\SignatureException;
Expand All @@ -12,6 +14,7 @@
use Statamic\Facades\Asset;
use Statamic\Facades\AssetContainer;
use Statamic\Facades\Config;
use Statamic\Facades\Glide;
use Statamic\Facades\Site;
use Statamic\Imaging\ImageGenerator;
use Statamic\Support\Str;
Expand Down Expand Up @@ -51,6 +54,10 @@ public function __construct(Server $server, Request $request, ImageGenerator $ge
*/
public function generateByPath($path)
{
if (Glide::isUsingHybridCaching()) {
return $this->generateOnDemand($path);
}

$this->validateSignature();

// If the auto crop setting is enabled, we will attempt to resolve an asset from the
Expand Down Expand Up @@ -79,6 +86,73 @@ public function generateByUrl($url)
return $this->createResponse($this->generateBy('url', $url));
}

/**
* Generate an on-demand image for the hybrid caching strategy.
*
* The URL path is the predicted cache path. A mapping stored in the
* Glide cache store links it back to the source and manipulation params.
*/
private function generateOnDemand(string $path)
{
if ($this->existsInCache($path)) {
$this->warnAboutServingThroughPhp($path);

return $this->createResponse($path);
}

$mapping = Glide::cacheStore()->get('hybrid::'.$path);

throw_unless($mapping, new NotFoundHttpException);

$type = $mapping['type'];
$params = $mapping['params'];

$item = match ($type) {
'asset' => Asset::find($mapping['id']) ?? throw new NotFoundHttpException,
'url' => $mapping['url'],
'path' => $mapping['path'],
};

return $this->createResponse($this->ensureGenerated($type, $item, $params));
}

private function existsInCache(string $path): bool
{
try {
return Glide::cacheDisk()->exists($path);
} catch (PathTraversalDetected $e) {
throw new NotFoundHttpException;
}
}

/**
* The image already exists, so the web server should have served it without
* involving PHP. Warn once, since otherwise this fires on every request
* for every image, which is the situation we're complaining about.
*/
private function warnAboutServingThroughPhp(string $path): void
{
if (! Glide::cacheStore()->add('hybrid-served-through-php-warning', true)) {
return;
}

Log::warning('Glide hybrid caching: ['.$path.'] already exists but was still served by PHP. Check that the image_manipulation.cache_path is inside your public directory and reachable at the image_manipulation.route.');
}

/**
* Forget any stale cache store entry, then generate the image.
*
* In hybrid mode, the file on disk is the source of truth.
* If we're here, the file doesn't exist, so the cache store
* entry (if any) is stale and should be cleared first.
*/
private function ensureGenerated(string $type, $item, array $params)
{
Glide::cacheStore()->forget(ImageGenerator::manipulationCacheKey($type, $item, $params));

return $this->generateBy($type, $item, $params);
}

/**
* Generate a manipulated image by an asset reference.
*
Expand Down Expand Up @@ -108,12 +182,12 @@ public function generateByAsset($encoded)
*
* @return mixed
*/
private function generateBy($type, $item)
private function generateBy($type, $item, ?array $params = null)
{
$method = 'generateBy'.ucfirst($type);

try {
return $this->generator->$method($item, $this->request->all());
return $this->generator->$method($item, $params ?? $this->request->all());
} catch (InvalidRemoteUrlException $e) {
abort(400, $e->getMessage());
} catch (UnableToReadFile $e) {
Expand Down
89 changes: 89 additions & 0 deletions src/Imaging/GlideCachePathResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace Statamic\Imaging;

use League\Glide\Server;
use Statamic\Contracts\Assets\Asset;
use Statamic\Facades\URL;

class GlideCachePathResolver
{
public function __construct(private Server $server)
{
}

public function resolveForAsset(Asset $asset, array $params): string
Comment thread
duncanmcclean marked this conversation as resolved.
{
if ($asset->isVideo()) {
return $this->resolveForPath(ThumbnailExtractor::getFileName($asset), $params);
}

return $this->resolve(
$asset->basename(),
$params,
sourcePathPrefix: $asset->folder(),
cachePathPrefix: ImageGenerator::assetCachePathPrefix($asset).'/'.$asset->folder(),
asset: $asset,
);
}

public function resolveForPath(string $path, array $params): string
{
return $this->resolve(
$path,
$params,
sourcePathPrefix: '/',
cachePathPrefix: 'paths',
);
}

public function resolveForUrl(string $url, array $params): string
{
$parsed = app(RemoteUrlValidator::class)->parse($url);
Comment thread
duncanmcclean marked this conversation as resolved.
$qs = $parsed['query'];
$path = $parsed['path'].($qs ? '?'.$qs : '');

return $this->resolve(
$path,
$params,
sourcePathPrefix: '/',
cachePathPrefix: 'http',
);
}

public function resolveForItem($item, array $params): string
{
if ($item instanceof Asset) {
return $this->resolveForAsset($item, $params);
}

if (is_string($item) && URL::isAbsolute($item)) {
return $this->resolveForUrl($item, $params);
}

return $this->resolveForPath($item, $params);
}

private function resolve(string $image, array $params, string $sourcePathPrefix, string $cachePathPrefix, ?Asset $asset = null): string
{
if (isset($params['mark'])) {
$params['mark'] = ImageGenerator::watermarkParam($params['mark']);
}

$origSourcePrefix = $this->server->getSourcePathPrefix();
$origCachePrefix = $this->server->getCachePathPrefix();
$origDefaults = $this->server->getDefaults();

$this->server->setSourcePathPrefix($sourcePathPrefix);
$this->server->setCachePathPrefix($cachePathPrefix);
$this->server->setDefaults(ImageGenerator::getDefaultManipulations($asset));

try {
return $this->server->getCachePath($image, $params);
} finally {
$this->server->setSourcePathPrefix($origSourcePrefix);
$this->server->setCachePathPrefix($origCachePrefix);
$this->server->setDefaults($origDefaults);
}
}
}
32 changes: 28 additions & 4 deletions src/Imaging/GlideManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Statamic\Events\GlideAssetCacheCleared;
use Statamic\Facades\Config;
use Statamic\Facades\Image;
use Statamic\Facades\Path;
use Statamic\Facades\URL;
use Statamic\Imaging\ResponseFactory as LaravelResponseFactory;
use Statamic\Support\Str;
Expand Down Expand Up @@ -49,7 +50,9 @@ public function cacheDisk()

private function wantsCustomFilesystem()
{
return is_string(Config::get('statamic.assets.image_manipulation.cache'));
$cache = Config::get('statamic.assets.image_manipulation.cache');

return is_string($cache) && $cache !== 'hybrid';
}

private function localCacheFilesystem()
Expand Down Expand Up @@ -77,26 +80,47 @@ private function customCacheFilesystem()
*/
private function cachePath()
{
return $this->shouldServeDirectly()
return ($this->shouldServeDirectly() || $this->isUsingHybridCaching())
? Config::get('statamic.assets.image_manipulation.cache_path')
: storage_path('statamic/glide');
}

public function shouldServeDirectly()
{
return (bool) Config::get('statamic.assets.image_manipulation.cache');
$cache = Config::get('statamic.assets.image_manipulation.cache');

return $cache === true || $this->wantsCustomFilesystem();
}

public function shouldServeByHttp()
{
return ! $this->shouldServeDirectly();
return ! $this->shouldServeDirectly() && ! $this->isUsingHybridCaching();
}

public function isUsingHybridCaching()
{
return Config::get('statamic.assets.image_manipulation.cache') === 'hybrid';
}

public function route()
{
return Config::get('statamic.assets.image_manipulation.route');
}

public function cachePathIsServedByRoute()
{
$publicPath = Path::tidy(public_path());
$cachePath = Path::tidy(Config::get('statamic.assets.image_manipulation.cache_path'));

if (! Str::startsWith($cachePath, $publicPath)) {
return false;
}

$servedPath = trim(Str::after($cachePath, $publicPath), '/');

return $servedPath === trim(URL::makeRelative($this->route()), '/');
}

public function url()
{
$url = $this->wantsCustomFilesystem()
Expand Down
6 changes: 1 addition & 5 deletions src/Imaging/GlideUrlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use Exception;
use League\Glide\Urls\UrlBuilderFactory;
use Statamic\Contracts\Assets\Asset;
use Statamic\Facades\URL;
use Statamic\Support\Str;

Expand Down Expand Up @@ -58,10 +57,7 @@ public function build($item, $params)
$path .= Str::ensureLeft(URL::encode($filename), '/');
}

if (isset($params['mark']) && $params['mark'] instanceof Asset) {
$asset = $params['mark'];
$params['mark'] = 'asset::'.Str::toBase64Url($asset->containerId().'/'.$asset->path());
}
$params = $this->withEncodedWatermark($params);

return URL::makeRelative(
URL::prependSiteUrl($builder->getUrl($path, $params))
Expand Down
Loading
Loading