From fa26caf253b8307e80b436b47b4130596329af2e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 10 Aug 2026 09:42:45 +0100 Subject: [PATCH 1/3] encode asset urls `Asset::url()` and `Asset::absoluteUrl()` returned the raw path, so filenames containing spaces or accents produced invalid urls. Co-Authored-By: Claude Opus 5 --- src/Assets/Asset.php | 4 ++-- tests/Assets/AssetTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index fd8b0f9d5ea..3affe70c62e 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -417,7 +417,7 @@ public function url() return null; } - return URL::assemble($this->container()->url(), $this->path()); + return URL::assemble($this->container()->url(), URL::encode($this->path())); } public function absoluteUrl() @@ -426,7 +426,7 @@ public function absoluteUrl() return null; } - return URL::assemble($this->container()->absoluteUrl(), $this->path()); + return URL::assemble($this->container()->absoluteUrl(), URL::encode($this->path())); } public function thumbnailUrl($preset = null) diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index 516df9febdf..7302ddb7598 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -2375,6 +2375,31 @@ public function it_gets_the_absolute_url() $this->assertEquals('http://example.com/path/to/test.txt', $asset->absoluteUrl()); } + #[Test] + #[DataProvider('urlEncodingProvider')] + public function it_encodes_the_url($path, $expected) + { + $container = $this->mock(AssetContainer::class); + $container->shouldReceive('private')->andReturnFalse(); + $container->shouldReceive('url')->andReturn('http://example.com/container'); + $container->shouldReceive('absoluteUrl')->andReturn('http://example.com/container'); + $asset = (new Asset)->container($container)->path($path); + + $this->assertEquals('http://example.com/container'.$expected, $asset->url()); + $this->assertEquals('http://example.com/container'.$expected, $asset->absoluteUrl()); + $this->assertEquals('http://example.com/container'.$expected, (string) $asset); + } + + public static function urlEncodingProvider() + { + return [ + 'nothing to encode' => ['path/to/test.txt', '/path/to/test.txt'], + 'spaces' => ['path/to/Image X - Whatever_17.jpg', '/path/to/Image%20X%20-%20Whatever_17.jpg'], + 'accents' => ['path/to/Dún Laoghaire_18 2.jpg', '/path/to/D%C3%BAn%20Laoghaire_18%202.jpg'], + 'spaces in folders' => ['path to/my folder/test.txt', '/path%20to/my%20folder/test.txt'], + ]; + } + #[Test] public function there_is_no_url_for_a_private_asset() { From 4b89c0413c1a881c8cad5c5358a04b71ce3474f0 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 10 Aug 2026 09:42:45 +0100 Subject: [PATCH 2/3] decode urls in `AssetRepository::findByUrl()` Keeps encoded urls resolving back to their asset. Co-Authored-By: Claude Opus 5 --- src/Assets/AssetRepository.php | 2 +- tests/Assets/AssetRepositoryTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Assets/AssetRepository.php b/src/Assets/AssetRepository.php index 786b107f087..609ee2ed997 100644 --- a/src/Assets/AssetRepository.php +++ b/src/Assets/AssetRepository.php @@ -59,7 +59,7 @@ public function findByUrl(string $url) $url = $siteUrl.$url; } - $path = Str::after($url, $containerUrl); + $path = rawurldecode(Str::after($url, $containerUrl)); return $container->asset($path); } diff --git a/tests/Assets/AssetRepositoryTest.php b/tests/Assets/AssetRepositoryTest.php index be2ea47f8a3..6c19a041591 100644 --- a/tests/Assets/AssetRepositoryTest.php +++ b/tests/Assets/AssetRepositoryTest.php @@ -106,6 +106,23 @@ public function it_finds_assets_using_find_or_fail() $this->assertEquals($assetShortUrl->id(), $asset->id()); } + #[Test] + public function it_finds_assets_by_an_encoded_url() + { + Storage::fake('test', ['url' => 'test']); + Storage::disk('test')->put('foo/Dún Laoghaire_18 2.jpg', UploadedFile::fake()->image('bar.jpg')->getContent()); + + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + $asset = tap($container->makeAsset('foo/Dún Laoghaire_18 2.jpg'))->save(); + + $this->assertEquals('/test/foo/D%C3%BAn%20Laoghaire_18%202.jpg', $asset->url()); + + $found = Asset::findByUrl($asset->url()); + + $this->assertInstanceOf(AssetContract::class, $found); + $this->assertEquals($asset->id(), $found->id()); + } + #[Test] public function it_finds_assets_by_id_when_the_path_contains_windows_separators() { From 4754f1d424c09866ea21d903e3eff42c82d7aa7e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 19 Aug 2026 23:28:19 +0200 Subject: [PATCH 3/3] encode asset paths per segment instead of using `URL::encode()` `URL::encode()` assumes its input may already be percent-encoded, so a filename containing a literal `%XX` sequence passed through untouched and no longer resolved once `findByUrl()` decoded it. Encoding each path segment with `rawurlencode()` round-trips for all inputs. Co-Authored-By: Claude Fable 5 --- src/Assets/Asset.php | 9 +++++++-- tests/Assets/AssetRepositoryTest.php | 18 ++++++++++++++---- tests/Assets/AssetTest.php | 1 + 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index 3affe70c62e..1ac0766f30b 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -417,7 +417,7 @@ public function url() return null; } - return URL::assemble($this->container()->url(), URL::encode($this->path())); + return URL::assemble($this->container()->url(), $this->encodedPath()); } public function absoluteUrl() @@ -426,7 +426,12 @@ public function absoluteUrl() return null; } - return URL::assemble($this->container()->absoluteUrl(), URL::encode($this->path())); + return URL::assemble($this->container()->absoluteUrl(), $this->encodedPath()); + } + + private function encodedPath() + { + return implode('/', array_map('rawurlencode', explode('/', $this->path()))); } public function thumbnailUrl($preset = null) diff --git a/tests/Assets/AssetRepositoryTest.php b/tests/Assets/AssetRepositoryTest.php index 6c19a041591..342f5d096af 100644 --- a/tests/Assets/AssetRepositoryTest.php +++ b/tests/Assets/AssetRepositoryTest.php @@ -5,6 +5,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Assets\AssetRepository; use Statamic\Contracts\Assets\Asset as AssetContract; @@ -107,15 +108,16 @@ public function it_finds_assets_using_find_or_fail() } #[Test] - public function it_finds_assets_by_an_encoded_url() + #[DataProvider('encodedUrlProvider')] + public function it_finds_assets_by_an_encoded_url($path, $expectedUrl) { Storage::fake('test', ['url' => 'test']); - Storage::disk('test')->put('foo/Dún Laoghaire_18 2.jpg', UploadedFile::fake()->image('bar.jpg')->getContent()); + Storage::disk('test')->put($path, UploadedFile::fake()->image('bar.jpg')->getContent()); $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); - $asset = tap($container->makeAsset('foo/Dún Laoghaire_18 2.jpg'))->save(); + $asset = tap($container->makeAsset($path))->save(); - $this->assertEquals('/test/foo/D%C3%BAn%20Laoghaire_18%202.jpg', $asset->url()); + $this->assertEquals($expectedUrl, $asset->url()); $found = Asset::findByUrl($asset->url()); @@ -123,6 +125,14 @@ public function it_finds_assets_by_an_encoded_url() $this->assertEquals($asset->id(), $found->id()); } + public static function encodedUrlProvider() + { + return [ + 'spaces and accents' => ['foo/Dún Laoghaire_18 2.jpg', '/test/foo/D%C3%BAn%20Laoghaire_18%202.jpg'], + 'literal percent sequences' => ['foo/photo%20one.jpg', '/test/foo/photo%2520one.jpg'], + ]; + } + #[Test] public function it_finds_assets_by_id_when_the_path_contains_windows_separators() { diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index 7302ddb7598..b94ef66cc85 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -2397,6 +2397,7 @@ public static function urlEncodingProvider() 'spaces' => ['path/to/Image X - Whatever_17.jpg', '/path/to/Image%20X%20-%20Whatever_17.jpg'], 'accents' => ['path/to/Dún Laoghaire_18 2.jpg', '/path/to/D%C3%BAn%20Laoghaire_18%202.jpg'], 'spaces in folders' => ['path to/my folder/test.txt', '/path%20to/my%20folder/test.txt'], + 'literal percent sequences' => ['path/to/photo%20one.jpg', '/path/to/photo%2520one.jpg'], ]; }