From d149c98f8a09da65ca13c748d5ea3e5cfd377993 Mon Sep 17 00:00:00 2001 From: Tony Murray Date: Fri, 16 Oct 2026 10:18:54 -0500 Subject: [PATCH 1/6] fix: serve commit images straight from cloud storage On S3 the image route now checks access, then redirects to a short-lived signed URL, so the bucket serves the bytes instead of the app streaming them. Local disks still stream through the app. Co-Authored-By: Claude Opus 5.5 --- .../Controllers/CommitImageController.php | 26 +++++++++++++-- app/Http/Requests/ShowCommitImageRequest.php | 6 +++- .../Controllers/CommitImageControllerTest.php | 33 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/CommitImageController.php b/app/Http/Controllers/CommitImageController.php index 943b25213..e02ad5d96 100644 --- a/app/Http/Controllers/CommitImageController.php +++ b/app/Http/Controllers/CommitImageController.php @@ -6,14 +6,36 @@ use App\Http\Requests\ShowCommitImageRequest; use App\Models\CommitImage; +use Illuminate\Filesystem\AwsS3V3Adapter; +use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; final readonly class CommitImageController { - public function show(ShowCommitImageRequest $request, CommitImage $commitImage): StreamedResponse + /** + * How long a redirect to cloud storage may be reused, kept well inside the life of the signed URL it points at. + */ + private const int REDIRECT_MAX_AGE = 3600; + + public function show(ShowCommitImageRequest $request, CommitImage $commitImage): RedirectResponse|StreamedResponse { - return Storage::disk()->response($commitImage->path, $commitImage->id.'.webp', headers: [ + $disk = Storage::disk(); + + // Cloud storage serves the bytes itself, through a short-lived signed URL, once access has been checked here. + if ($disk instanceof AwsS3V3Adapter) { + $url = $disk->temporaryUrl($commitImage->path, now()->addSeconds(self::REDIRECT_MAX_AGE * 2), [ + 'ResponseContentType' => 'image/webp', + 'ResponseContentDisposition' => 'inline', + 'ResponseCacheControl' => 'private, max-age='.self::REDIRECT_MAX_AGE, + ]); + + return redirect()->away($url)->withHeaders([ + 'Cache-Control' => 'private, max-age='.self::REDIRECT_MAX_AGE, + ]); + } + + return $disk->response($commitImage->path, $commitImage->id.'.webp', headers: [ 'Content-Type' => 'image/webp', 'Content-Security-Policy' => "default-src 'none'; sandbox", 'X-Content-Type-Options' => 'nosniff', diff --git a/app/Http/Requests/ShowCommitImageRequest.php b/app/Http/Requests/ShowCommitImageRequest.php index 218e2f9f0..69449d354 100644 --- a/app/Http/Requests/ShowCommitImageRequest.php +++ b/app/Http/Requests/ShowCommitImageRequest.php @@ -7,6 +7,7 @@ use App\Models\Commit; use App\Models\CommitImage; use Illuminate\Auth\Access\Response; +use Illuminate\Filesystem\AwsS3V3Adapter; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Storage; @@ -15,6 +16,7 @@ final class ShowCommitImageRequest extends FormRequest /** * Pending and rejected images never leave the server for anyone but their author and moderators, or the temporary * URL the API gave one of them, until the commit is deleted. + * Cloud storage answers a missing file with its own not found, so it is only asked about local files, saving a round trip on every view. */ public function authorize(): Response { @@ -23,7 +25,9 @@ public function authorize(): Response $commit = Commit::query()->find($commitImage->commit_id); - return $commit instanceof Commit && ($commit->imagesAreVisibleTo($this->user()) || (! $commit->trashed() && $this->hasValidSignature())) && Storage::disk()->exists($commitImage->path) + $disk = Storage::disk(); + + return $commit instanceof Commit && ($commit->imagesAreVisibleTo($this->user()) || (! $commit->trashed() && $this->hasValidSignature())) && ($disk instanceof AwsS3V3Adapter || $disk->exists($commitImage->path)) ? Response::allow() : Response::denyAsNotFound(); } diff --git a/tests/Feature/Controllers/CommitImageControllerTest.php b/tests/Feature/Controllers/CommitImageControllerTest.php index 9314912ce..b6c299e65 100644 --- a/tests/Feature/Controllers/CommitImageControllerTest.php +++ b/tests/Feature/Controllers/CommitImageControllerTest.php @@ -5,6 +5,7 @@ use App\Models\Commit; use App\Models\CommitImage; use App\Models\User; +use Illuminate\Filesystem\AwsS3V3Adapter; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; @@ -134,3 +135,35 @@ function storedCommitImage(Commit $commit): CommitImage expect($response->headers->get('Cache-Control'))->toContain('private')->not->toContain('public'); }); + +it('redirects to a signed cloud storage url instead of streaming the image', function (): void { + $image = CommitImage::factory()->for(Commit::factory())->create(); + + $disk = Mockery::mock(AwsS3V3Adapter::class); + $disk->shouldNotReceive('exists'); + $disk->shouldReceive('temporaryUrl') + ->once() + ->with($image->path, Mockery::type(DateTimeInterface::class), [ + 'ResponseContentType' => 'image/webp', + 'ResponseContentDisposition' => 'inline', + 'ResponseCacheControl' => 'private, max-age=3600', + ]) + ->andReturn('https://bucket.example.com/'.$image->path.'?X-Amz-Signature=abc'); + Storage::set(config('filesystems.default'), $disk); + + $response = $this->get(route('commit-image.show', $image)); + + $response->assertRedirect('https://bucket.example.com/'.$image->path.'?X-Amz-Signature=abc'); + + expect($response->headers->get('Cache-Control'))->toContain('private')->not->toContain('public'); +}); + +it('does not redirect to cloud storage for images the viewer may not see', function (): void { + $image = CommitImage::factory()->for(Commit::factory()->pending())->create(); + + $disk = Mockery::mock(AwsS3V3Adapter::class); + $disk->shouldNotReceive('temporaryUrl'); + Storage::set(config('filesystems.default'), $disk); + + $this->get(route('commit-image.show', $image))->assertNotFound(); +}); From 1d88500b8df675d1cac5ea467852ff8b3a0a52b3 Mon Sep 17 00:00:00 2001 From: Tony Murray Date: Fri, 16 Oct 2026 11:49:31 -0500 Subject: [PATCH 2/6] Signed S3 URLs now go only to public images, last 20 minutes, and are skipped when credentials would expire first; the decision lives in one new action. --- app/Actions/CreateCommitImageSignedUrl.php | 48 +++++++++++++++ .../Controllers/CommitImageController.php | 22 ++----- app/Http/Requests/ShowCommitImageRequest.php | 26 ++++++-- .../Controllers/CommitImageControllerTest.php | 61 +++++++++++++------ 4 files changed, 117 insertions(+), 40 deletions(-) create mode 100644 app/Actions/CreateCommitImageSignedUrl.php diff --git a/app/Actions/CreateCommitImageSignedUrl.php b/app/Actions/CreateCommitImageSignedUrl.php new file mode 100644 index 000000000..ae9e880ea --- /dev/null +++ b/app/Actions/CreateCommitImageSignedUrl.php @@ -0,0 +1,48 @@ +addSeconds(self::LIFETIME); + $credentials = $disk->getClient()->getCredentials()->wait(); + + if (! $credentials instanceof CredentialsInterface || ($credentials->getExpiration() !== null && $credentials->getExpiration() < $expires->getTimestamp())) { + return null; + } + + return $disk->temporaryUrl($commitImage->path, $expires, [ + 'ResponseContentType' => 'image/webp', + 'ResponseContentDisposition' => 'inline', + 'ResponseCacheControl' => 'private, max-age='.self::MAX_AGE, + ]); + } +} diff --git a/app/Http/Controllers/CommitImageController.php b/app/Http/Controllers/CommitImageController.php index e02ad5d96..096c1e5a4 100644 --- a/app/Http/Controllers/CommitImageController.php +++ b/app/Http/Controllers/CommitImageController.php @@ -4,38 +4,26 @@ namespace App\Http\Controllers; +use App\Actions\CreateCommitImageSignedUrl; use App\Http\Requests\ShowCommitImageRequest; use App\Models\CommitImage; -use Illuminate\Filesystem\AwsS3V3Adapter; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; final readonly class CommitImageController { - /** - * How long a redirect to cloud storage may be reused, kept well inside the life of the signed URL it points at. - */ - private const int REDIRECT_MAX_AGE = 3600; - public function show(ShowCommitImageRequest $request, CommitImage $commitImage): RedirectResponse|StreamedResponse { - $disk = Storage::disk(); - - // Cloud storage serves the bytes itself, through a short-lived signed URL, once access has been checked here. - if ($disk instanceof AwsS3V3Adapter) { - $url = $disk->temporaryUrl($commitImage->path, now()->addSeconds(self::REDIRECT_MAX_AGE * 2), [ - 'ResponseContentType' => 'image/webp', - 'ResponseContentDisposition' => 'inline', - 'ResponseCacheControl' => 'private, max-age='.self::REDIRECT_MAX_AGE, - ]); + $url = $request->signedUrl(); + if ($url !== null) { return redirect()->away($url)->withHeaders([ - 'Cache-Control' => 'private, max-age='.self::REDIRECT_MAX_AGE, + 'Cache-Control' => 'private, max-age='.CreateCommitImageSignedUrl::MAX_AGE, ]); } - return $disk->response($commitImage->path, $commitImage->id.'.webp', headers: [ + return Storage::disk()->response($commitImage->path, $commitImage->id.'.webp', headers: [ 'Content-Type' => 'image/webp', 'Content-Security-Policy' => "default-src 'none'; sandbox", 'X-Content-Type-Options' => 'nosniff', diff --git a/app/Http/Requests/ShowCommitImageRequest.php b/app/Http/Requests/ShowCommitImageRequest.php index 69449d354..55fedc8c6 100644 --- a/app/Http/Requests/ShowCommitImageRequest.php +++ b/app/Http/Requests/ShowCommitImageRequest.php @@ -4,30 +4,36 @@ namespace App\Http\Requests; +use App\Actions\CreateCommitImageSignedUrl; use App\Models\Commit; use App\Models\CommitImage; use Illuminate\Auth\Access\Response; -use Illuminate\Filesystem\AwsS3V3Adapter; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Storage; final class ShowCommitImageRequest extends FormRequest { + private ?string $signedUrl = null; + /** * Pending and rejected images never leave the server for anyone but their author and moderators, or the temporary - * URL the API gave one of them, until the commit is deleted. - * Cloud storage answers a missing file with its own not found, so it is only asked about local files, saving a round trip on every view. + * URL the API gave one of them, until the commit is deleted. So only images anyone may see get a signed storage URL that works without this check. + * Cloud storage answers a missing file behind a signed URL itself, so storage is only asked about files streamed from here. */ - public function authorize(): Response + public function authorize(CreateCommitImageSignedUrl $action): Response { $commitImage = $this->route('commitImage'); assert($commitImage instanceof CommitImage); $commit = Commit::query()->find($commitImage->commit_id); - $disk = Storage::disk(); + if (! $commit instanceof Commit || ! ($commit->imagesAreVisibleTo($this->user()) || (! $commit->trashed() && $this->hasValidSignature()))) { + return Response::denyAsNotFound(); + } + + $this->signedUrl = $commit->imagesAreVisibleTo(null) ? $action->handle($commitImage) : null; - return $commit instanceof Commit && ($commit->imagesAreVisibleTo($this->user()) || (! $commit->trashed() && $this->hasValidSignature())) && ($disk instanceof AwsS3V3Adapter || $disk->exists($commitImage->path)) + return $this->signedUrl !== null || Storage::disk()->exists($commitImage->path) ? Response::allow() : Response::denyAsNotFound(); } @@ -40,6 +46,14 @@ public function rules(): array return []; } + /** + * Where the image can be fetched straight from cloud storage, if it can. + */ + public function signedUrl(): ?string + { + return $this->signedUrl; + } + public function download(): bool { return $this->boolean('download'); diff --git a/tests/Feature/Controllers/CommitImageControllerTest.php b/tests/Feature/Controllers/CommitImageControllerTest.php index b6c299e65..6fdfe3339 100644 --- a/tests/Feature/Controllers/CommitImageControllerTest.php +++ b/tests/Feature/Controllers/CommitImageControllerTest.php @@ -5,6 +5,7 @@ use App\Models\Commit; use App\Models\CommitImage; use App\Models\User; +use Aws\Credentials\Credentials; use Illuminate\Filesystem\AwsS3V3Adapter; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; @@ -136,33 +137,59 @@ function storedCommitImage(Commit $commit): CommitImage expect($response->headers->get('Cache-Control'))->toContain('private')->not->toContain('public'); }); -it('redirects to a signed cloud storage url instead of streaming the image', function (): void { - $image = CommitImage::factory()->for(Commit::factory())->create(); +function cloudDisk(?Credentials $credentials = null): AwsS3V3Adapter +{ + $disk = Storage::build([ + 'driver' => 's3', + 'credentials' => $credentials ?? new Credentials('key', 'secret'), + 'region' => 'us-east-1', + 'bucket' => 'images', + ]); + assert($disk instanceof AwsS3V3Adapter); + + return $disk; +} - $disk = Mockery::mock(AwsS3V3Adapter::class); - $disk->shouldNotReceive('exists'); - $disk->shouldReceive('temporaryUrl') - ->once() - ->with($image->path, Mockery::type(DateTimeInterface::class), [ - 'ResponseContentType' => 'image/webp', - 'ResponseContentDisposition' => 'inline', - 'ResponseCacheControl' => 'private, max-age=3600', - ]) - ->andReturn('https://bucket.example.com/'.$image->path.'?X-Amz-Signature=abc'); - Storage::set(config('filesystems.default'), $disk); +it('redirects public images to a signed cloud storage url, without asking storage about the file', function (): void { + $image = CommitImage::factory()->for(Commit::factory())->create(); + Storage::set(config('filesystems.default'), cloudDisk()); $response = $this->get(route('commit-image.show', $image)); - $response->assertRedirect('https://bucket.example.com/'.$image->path.'?X-Amz-Signature=abc'); + $response->assertRedirect(); - expect($response->headers->get('Cache-Control'))->toContain('private')->not->toContain('public'); + $url = (string) $response->headers->get('Location'); + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + expect($url)->toStartWith('https://images.s3.amazonaws.com/'.$image->path.'?') + ->and($query)->toHaveKey('X-Amz-Signature') + ->and((int) $query['X-Amz-Expires'])->toBeBetween(1190, 1200) + ->and($query['response-content-type'])->toBe('image/webp') + ->and($query['response-cache-control'])->toBe('private, max-age=600') + ->and($response->headers->get('Cache-Control'))->toContain('private')->toContain('max-age=600')->not->toContain('public'); +}); + +it('streams images of pending commits instead of handing out a signed url', function (): void { + $commit = Commit::factory()->pending()->create(); + $image = CommitImage::factory()->for($commit)->create(); + + $disk = Mockery::mock(AwsS3V3Adapter::class); + $disk->shouldNotReceive('temporaryUrl'); + $disk->shouldReceive('exists')->with($image->path)->andReturnFalse(); + Storage::set(config('filesystems.default'), $disk); + + $this->actingAs($commit->user) + ->get(route('commit-image.show', $image)) + ->assertNotFound(); }); -it('does not redirect to cloud storage for images the viewer may not see', function (): void { - $image = CommitImage::factory()->for(Commit::factory()->pending())->create(); +it('streams images when the storage credentials expire before a signed url would', function (): void { + $image = CommitImage::factory()->for(Commit::factory())->create(); $disk = Mockery::mock(AwsS3V3Adapter::class); + $disk->shouldReceive('getClient')->andReturn(cloudDisk(new Credentials('key', 'secret', 'token', now()->addMinutes(5)->getTimestamp()))->getClient()); $disk->shouldNotReceive('temporaryUrl'); + $disk->shouldReceive('exists')->with($image->path)->andReturnFalse(); Storage::set(config('filesystems.default'), $disk); $this->get(route('commit-image.show', $image))->assertNotFound(); From a0021ddbdf172619a433f3b3a0ab65c900aa296d Mon Sep 17 00:00:00 2001 From: Tony Murray Date: Sat, 17 Oct 2026 00:28:34 -0500 Subject: [PATCH 3/6] restore download functionality lost in merge --- app/Actions/CreateCommitImageSignedUrl.php | 6 ++++-- app/Http/Requests/ShowCommitImageRequest.php | 4 +++- .../Controllers/CommitImageControllerTest.php | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/app/Actions/CreateCommitImageSignedUrl.php b/app/Actions/CreateCommitImageSignedUrl.php index ae9e880ea..2c4a34eeb 100644 --- a/app/Actions/CreateCommitImageSignedUrl.php +++ b/app/Actions/CreateCommitImageSignedUrl.php @@ -24,7 +24,7 @@ /** * Only cloud storage serves files itself, and only while the credentials signing the URL outlive it: role credentials can expire first, which would leave the browser holding a dead URL. */ - public function handle(CommitImage $commitImage): ?string + public function handle(CommitImage $commitImage, bool $download = false): ?string { $disk = Storage::disk(); @@ -41,7 +41,9 @@ public function handle(CommitImage $commitImage): ?string return $disk->temporaryUrl($commitImage->path, $expires, [ 'ResponseContentType' => 'image/webp', - 'ResponseContentDisposition' => 'inline', + 'ResponseContentDisposition' => $download + ? 'attachment; filename="'.$commitImage->id.'.webp"' + : 'inline', 'ResponseCacheControl' => 'private, max-age='.self::MAX_AGE, ]); } diff --git a/app/Http/Requests/ShowCommitImageRequest.php b/app/Http/Requests/ShowCommitImageRequest.php index 55fedc8c6..16e2fd614 100644 --- a/app/Http/Requests/ShowCommitImageRequest.php +++ b/app/Http/Requests/ShowCommitImageRequest.php @@ -31,7 +31,9 @@ public function authorize(CreateCommitImageSignedUrl $action): Response return Response::denyAsNotFound(); } - $this->signedUrl = $commit->imagesAreVisibleTo(null) ? $action->handle($commitImage) : null; + $this->signedUrl = $commit->imagesAreVisibleTo(null) + ? $action->handle($commitImage, $this->download()) + : null; return $this->signedUrl !== null || Storage::disk()->exists($commitImage->path) ? Response::allow() diff --git a/tests/Feature/Controllers/CommitImageControllerTest.php b/tests/Feature/Controllers/CommitImageControllerTest.php index 6fdfe3339..9fd624900 100644 --- a/tests/Feature/Controllers/CommitImageControllerTest.php +++ b/tests/Feature/Controllers/CommitImageControllerTest.php @@ -165,10 +165,29 @@ function cloudDisk(?Credentials $credentials = null): AwsS3V3Adapter ->and($query)->toHaveKey('X-Amz-Signature') ->and((int) $query['X-Amz-Expires'])->toBeBetween(1190, 1200) ->and($query['response-content-type'])->toBe('image/webp') + ->and($query['response-content-disposition'])->toBe('inline') ->and($query['response-cache-control'])->toBe('private, max-age=600') ->and($response->headers->get('Cache-Control'))->toContain('private')->toContain('max-age=600')->not->toContain('public'); }); +it('redirects download requests to a signed cloud storage url with attachment disposition', function (): void { + $image = CommitImage::factory()->for(Commit::factory())->create(); + + Storage::set(config('filesystems.default'), cloudDisk()); + + $response = $this->get(route('commit-image.show', ['commitImage' => $image, 'download' => 1])); + + $response->assertRedirect(); + + $url = (string) $response->headers->get('Location'); + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + expect($url)->toStartWith('https://images.s3.amazonaws.com/'.$image->path.'?') + ->and($query)->toHaveKey('X-Amz-Signature') + ->toHaveKey('response-content-disposition') + ->and($query['response-content-disposition'])->toBe('attachment; filename='.$image->id.'.webp'); +}); + it('streams images of pending commits instead of handing out a signed url', function (): void { $commit = Commit::factory()->pending()->create(); $image = CommitImage::factory()->for($commit)->create(); From 89e9cf2e5afddc03592c3e73e2130308db48d481 Mon Sep 17 00:00:00 2001 From: Tony Murray Date: Sun, 18 Oct 2026 09:36:53 -0500 Subject: [PATCH 4/6] Fix test --- tests/Feature/Controllers/CommitImageControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Controllers/CommitImageControllerTest.php b/tests/Feature/Controllers/CommitImageControllerTest.php index 9fd624900..29213fb89 100644 --- a/tests/Feature/Controllers/CommitImageControllerTest.php +++ b/tests/Feature/Controllers/CommitImageControllerTest.php @@ -185,7 +185,7 @@ function cloudDisk(?Credentials $credentials = null): AwsS3V3Adapter expect($url)->toStartWith('https://images.s3.amazonaws.com/'.$image->path.'?') ->and($query)->toHaveKey('X-Amz-Signature') ->toHaveKey('response-content-disposition') - ->and($query['response-content-disposition'])->toBe('attachment; filename='.$image->id.'.webp'); + ->and($query['response-content-disposition'])->toBe('attachment; filename="'.$image->id.'.webp"'); }); it('streams images of pending commits instead of handing out a signed url', function (): void { From 11a2d93dff7ed26c04f7f154677a1f866e36862a Mon Sep 17 00:00:00 2001 From: Tony Murray Date: Mon, 26 Oct 2026 16:17:52 -0500 Subject: [PATCH 5/6] docblocks --- app/Http/Controllers/CommitImageController.php | 3 +++ app/Http/Requests/ShowCommitImageRequest.php | 3 +++ tests/Feature/Controllers/CommitImageControllerTest.php | 3 +++ 3 files changed, 9 insertions(+) diff --git a/app/Http/Controllers/CommitImageController.php b/app/Http/Controllers/CommitImageController.php index 096c1e5a4..3205206e2 100644 --- a/app/Http/Controllers/CommitImageController.php +++ b/app/Http/Controllers/CommitImageController.php @@ -13,6 +13,9 @@ final readonly class CommitImageController { + /** + * Public images on cloud storage are redirected to their signed URL; the rest are streamed from here, once the request has allowed them. + */ public function show(ShowCommitImageRequest $request, CommitImage $commitImage): RedirectResponse|StreamedResponse { $url = $request->signedUrl(); diff --git a/app/Http/Requests/ShowCommitImageRequest.php b/app/Http/Requests/ShowCommitImageRequest.php index 16e2fd614..81ec6f27f 100644 --- a/app/Http/Requests/ShowCommitImageRequest.php +++ b/app/Http/Requests/ShowCommitImageRequest.php @@ -56,6 +56,9 @@ public function signedUrl(): ?string return $this->signedUrl; } + /** + * Whether the image is wanted as a file to save rather than shown in the page. + */ public function download(): bool { return $this->boolean('download'); diff --git a/tests/Feature/Controllers/CommitImageControllerTest.php b/tests/Feature/Controllers/CommitImageControllerTest.php index 29213fb89..c018e4d03 100644 --- a/tests/Feature/Controllers/CommitImageControllerTest.php +++ b/tests/Feature/Controllers/CommitImageControllerTest.php @@ -137,6 +137,9 @@ function storedCommitImage(Commit $commit): CommitImage expect($response->headers->get('Cache-Control'))->toContain('private')->not->toContain('public'); }); +/** + * A real S3 disk, which signs URLs offline, so tests can read what the browser is redirected to without reaching AWS. + */ function cloudDisk(?Credentials $credentials = null): AwsS3V3Adapter { $disk = Storage::build([ From 39751f540cee098b0842b9ea886b290259a0e643 Mon Sep 17 00:00:00 2001 From: Tony Murray Date: Mon, 26 Oct 2026 16:27:02 -0500 Subject: [PATCH 6/6] return null for any signed URL that isn't HTTPS --- .../Controllers/CommitImageControllerTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Feature/Controllers/CommitImageControllerTest.php b/tests/Feature/Controllers/CommitImageControllerTest.php index c018e4d03..d643fe761 100644 --- a/tests/Feature/Controllers/CommitImageControllerTest.php +++ b/tests/Feature/Controllers/CommitImageControllerTest.php @@ -216,3 +216,15 @@ function cloudDisk(?Credentials $credentials = null): AwsS3V3Adapter $this->get(route('commit-image.show', $image))->assertNotFound(); }); + +it('streams images when cloud storage would sign a plain http url', function (): void { + $image = CommitImage::factory()->for(Commit::factory())->create(); + + $disk = Mockery::mock(AwsS3V3Adapter::class); + $disk->shouldReceive('getClient')->andReturn(cloudDisk()->getClient()); + $disk->shouldReceive('temporaryUrl')->andReturn('http://minio.test/images/'.$image->path.'?X-Amz-Signature=abc'); + $disk->shouldReceive('exists')->with($image->path)->andReturnFalse(); + Storage::set(config('filesystems.default'), $disk); + + $this->get(route('commit-image.show', $image))->assertNotFound(); +});