Compare commits
2 Commits
6f191b27f9
...
d9f19b42f1
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f19b42f1 | |||
| 90b2784859 |
@@ -3,12 +3,12 @@
|
||||
namespace App\Shared\Attachable\Models;
|
||||
|
||||
use App\Shared\Attachable\Enums\AttachmentType;
|
||||
use App\Shared\Storage\Services\TemporaryUrlService;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
@@ -70,9 +70,9 @@ class Attachment extends Model
|
||||
*/
|
||||
public function getTemporaryUrl(int $expiresInMinutes = 10): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl(
|
||||
return app(TemporaryUrlService::class)->generate(
|
||||
$this->path,
|
||||
now()->addMinutes($expiresInMinutes)
|
||||
);
|
||||
$expiresInMinutes,
|
||||
)['temporary_url'];
|
||||
}
|
||||
}
|
||||
|
||||
60
app/Shared/Storage/Services/TemporaryUrlService.php
Normal file
60
app/Shared/Storage/Services/TemporaryUrlService.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Shared\StorageTest\Services;
|
||||
|
||||
use App\Shared\Storage\Services\TemporaryUrlService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -10,6 +11,10 @@ use RuntimeException;
|
||||
|
||||
class S3TestService
|
||||
{
|
||||
public function __construct(
|
||||
protected TemporaryUrlService $temporaryUrlService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
@@ -44,8 +49,7 @@ class S3TestService
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'extension' => $file->extension(),
|
||||
'size' => $file->getSize(),
|
||||
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
...$this->temporaryUrlService->generate($path, $expiresInMinutes),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -58,16 +62,10 @@ class S3TestService
|
||||
'disk' => 's3',
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
...$this->temporaryUrlService->generate($path, $expiresInMinutes),
|
||||
];
|
||||
}
|
||||
|
||||
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
|
||||
}
|
||||
|
||||
protected function normalizeDirectory(?string $directory): string
|
||||
{
|
||||
$directory = trim((string) $directory, '/');
|
||||
|
||||
85
tests/Unit/Storage/TemporaryUrlServiceTest.php
Normal file
85
tests/Unit/Storage/TemporaryUrlServiceTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user