Compare commits

..

2 Commits

Author SHA1 Message Date
d9f19b42f1 refactor(storage): centralize temporary URL generation 2026-09-22 13:54:44 -03:00
90b2784859 feat(storage): cache temporary S3 URLs 2026-09-22 13:54:37 -03:00
4 changed files with 156 additions and 13 deletions

View File

@@ -3,12 +3,12 @@
namespace App\Shared\Attachable\Models; namespace App\Shared\Attachable\Models;
use App\Shared\Attachable\Enums\AttachmentType; use App\Shared\Attachable\Enums\AttachmentType;
use App\Shared\Storage\Services\TemporaryUrlService;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Fillable([ #[Fillable([
@@ -70,9 +70,9 @@ class Attachment extends Model
*/ */
public function getTemporaryUrl(int $expiresInMinutes = 10): string public function getTemporaryUrl(int $expiresInMinutes = 10): string
{ {
return Storage::disk('s3')->temporaryUrl( return app(TemporaryUrlService::class)->generate(
$this->path, $this->path,
now()->addMinutes($expiresInMinutes) $expiresInMinutes,
); )['temporary_url'];
} }
} }

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Shared\Storage\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
class TemporaryUrlService
{
protected const REFRESH_MARGIN_SECONDS = 60;
/**
* @return array{temporary_url: string, temporary_url_expires_at: string}
*/
public function generate(string $path, int $expiresInMinutes = 10): array
{
if ($expiresInMinutes < 1) {
throw new InvalidArgumentException('The temporary URL expiration must be at least one minute.');
}
$cacheTtlInSeconds = max(
1,
($expiresInMinutes * 60) - self::REFRESH_MARGIN_SECONDS,
);
return Cache::remember(
$this->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,
);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Shared\StorageTest\Services; namespace App\Shared\StorageTest\Services;
use App\Shared\Storage\Services\TemporaryUrlService;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -10,6 +11,10 @@ use RuntimeException;
class S3TestService class S3TestService
{ {
public function __construct(
protected TemporaryUrlService $temporaryUrlService,
) {}
/** /**
* @return array<string, int|string|null> * @return array<string, int|string|null>
*/ */
@@ -44,8 +49,7 @@ class S3TestService
'mime_type' => $file->getClientMimeType(), 'mime_type' => $file->getClientMimeType(),
'extension' => $file->extension(), 'extension' => $file->extension(),
'size' => $file->getSize(), 'size' => $file->getSize(),
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)), ...$this->temporaryUrlService->generate($path, $expiresInMinutes),
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
]; ];
} }
@@ -58,16 +62,10 @@ class S3TestService
'disk' => 's3', 'disk' => 's3',
'key' => $path, 'key' => $path,
'path' => $path, 'path' => $path,
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes), ...$this->temporaryUrlService->generate($path, $expiresInMinutes),
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
]; ];
} }
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
{
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
}
protected function normalizeDirectory(?string $directory): string protected function normalizeDirectory(?string $directory): string
{ {
$directory = trim((string) $directory, '/'); $directory = trim((string) $directory, '/');

View File

@@ -0,0 +1,85 @@
<?php
namespace Tests\Unit\Storage;
use App\Shared\Storage\Services\TemporaryUrlService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Mockery;
use Tests\TestCase;
class TemporaryUrlServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
$this->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']);
}
}