diff --git a/app/Shared/Storage/Services/TemporaryUrlService.php b/app/Shared/Storage/Services/TemporaryUrlService.php new file mode 100644 index 0000000..0affebf --- /dev/null +++ b/app/Shared/Storage/Services/TemporaryUrlService.php @@ -0,0 +1,60 @@ +cacheKey($path, $expiresInMinutes), + now()->addSeconds($cacheTtlInSeconds), + function () use ($path, $expiresInMinutes): array { + $expiresAt = now()->addMinutes($expiresInMinutes); + + return [ + 'temporary_url' => Storage::disk('s3')->temporaryUrl( + $path, + $expiresAt, + [ + 'ResponseCacheControl' => 'private, max-age='.($expiresInMinutes * 60), + ], + ), + 'temporary_url_expires_at' => $expiresAt->toIso8601String(), + ]; + }, + ); + } + + public function forget(string $path, int $expiresInMinutes = 10): void + { + Cache::forget($this->cacheKey($path, $expiresInMinutes)); + } + + protected function cacheKey(string $path, int $expiresInMinutes): string + { + return sprintf( + 's3:temporary-url:%s:%d', + hash('sha256', $path), + $expiresInMinutes, + ); + } +} diff --git a/tests/Unit/Storage/TemporaryUrlServiceTest.php b/tests/Unit/Storage/TemporaryUrlServiceTest.php new file mode 100644 index 0000000..ac2c1b6 --- /dev/null +++ b/tests/Unit/Storage/TemporaryUrlServiceTest.php @@ -0,0 +1,85 @@ +travelTo(Carbon::parse('2026-09-22 12:00:00', 'UTC')); + } + + protected function tearDown(): void + { + Cache::flush(); + $this->travelBack(); + + parent::tearDown(); + } + + public function test_it_reuses_the_signed_url_and_its_original_expiration(): void + { + $disk = Mockery::mock(); + + Storage::shouldReceive('disk') + ->once() + ->with('s3') + ->andReturn($disk); + $disk->shouldReceive('temporaryUrl') + ->once() + ->with( + 'images/product.png', + Mockery::on(fn ($expiration): bool => $expiration->equalTo(now()->addMinutes(10))), + ['ResponseCacheControl' => 'private, max-age=600'], + ) + ->andReturn('https://s3.example.test/product.png?signed=first'); + + $service = app(TemporaryUrlService::class); + $first = $service->generate('images/product.png', 10); + + $this->travel(5)->minutes(); + + $second = $service->generate('images/product.png', 10); + + $this->assertSame($first, $second); + $this->assertSame('https://s3.example.test/product.png?signed=first', $second['temporary_url']); + $this->assertSame('2026-09-22T12:10:00+00:00', $second['temporary_url_expires_at']); + } + + public function test_it_refreshes_the_signed_url_before_it_expires(): void + { + $disk = Mockery::mock(); + + Storage::shouldReceive('disk') + ->twice() + ->with('s3') + ->andReturn($disk); + $disk->shouldReceive('temporaryUrl') + ->twice() + ->andReturn( + 'https://s3.example.test/product.png?signed=first', + 'https://s3.example.test/product.png?signed=second', + ); + + $service = app(TemporaryUrlService::class); + $first = $service->generate('images/product.png', 10); + + $this->travel(9)->minutes(); + $this->travel(1)->seconds(); + + $second = $service->generate('images/product.png', 10); + + $this->assertNotSame($first['temporary_url'], $second['temporary_url']); + $this->assertSame('2026-09-22T12:19:01+00:00', $second['temporary_url_expires_at']); + } +}