diff --git a/src/BunnyCDNAdapter.php b/src/BunnyCDNAdapter.php index cbc4ac2..f67ec84 100644 --- a/src/BunnyCDNAdapter.php +++ b/src/BunnyCDNAdapter.php @@ -2,6 +2,7 @@ namespace PlatformCommunity\Flysystem\BunnyCDN; +use DateTimeInterface; use Exception; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Pool; @@ -20,6 +21,7 @@ use League\Flysystem\UnableToCreateDirectory; use League\Flysystem\UnableToDeleteDirectory; use League\Flysystem\UnableToDeleteFile; +use League\Flysystem\UnableToGenerateTemporaryUrl; use League\Flysystem\UnableToMoveFile; use League\Flysystem\UnableToProvideChecksum; use League\Flysystem\UnableToReadFile; @@ -27,18 +29,22 @@ use League\Flysystem\UnableToSetVisibility; use League\Flysystem\UnableToWriteFile; use League\Flysystem\UrlGeneration\PublicUrlGenerator; +use League\Flysystem\UrlGeneration\TemporaryUrlGenerator; use League\Flysystem\Visibility; use League\MimeTypeDetection\FinfoMimeTypeDetector; use PlatformCommunity\Flysystem\BunnyCDN\Exceptions\NotFoundException; use RuntimeException; use TypeError; -class BunnyCDNAdapter implements FilesystemAdapter, PublicUrlGenerator, ChecksumProvider +class BunnyCDNAdapter implements FilesystemAdapter, PublicUrlGenerator, ChecksumProvider, TemporaryUrlGenerator { use CalculateChecksumFromStream; - public function __construct(private BunnyCDNClient $client, private string $pullzone_url = '') - { + public function __construct( + private BunnyCDNClient $client, + private string $pullzone_url = '', + private string $token_auth_key = '' + ) { if (\func_num_args() > 2 && (string) \func_get_arg(2) !== '') { throw new \RuntimeException('PrefixPath is no longer supported directly. Use PathPrefixedAdapter instead: https://flysystem.thephpleague.com/docs/adapter/path-prefixing/'); } @@ -554,6 +560,47 @@ public function publicUrl(string $path, Config $config): string return rtrim($this->pullzone_url, '/').'/'.ltrim($path, '/'); } + public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string + { + if ($this->token_auth_key === '') { + throw new UnableToGenerateTemporaryUrl('In order to generate temporary URLs for a BunnyCDN object, you must pass the "token_auth_key" parameter to the BunnyCDNAdapter.', $path); + } + + // convert our expiration to a unix timestamp + $expiration = $expiresAt->getTimestamp(); + + // extract elements from our path + $parts = parse_url($path); + $path = $parts['path']; + + // extract our query params + parse_str($parts['query'] ?? '', $params); + ksort($params); + + // concatenate all of our data + return $path + .(str_contains($path, '?') ? '&' : '?') + .'token='.$this->buildSigningKey($path, $expiration, $params) + .'&expires='.$expiration + .($params ? '&'.http_build_query($params) : null); + } + + private function buildSigningKey($path, int $expiration, array $params): string + { + // prefix our path + $path = str_starts_with($path, '/') ? $path : '/'.$path; + + // process our query params + $query = implode('&', array_map(fn ($k, $v) => $k.'='.$v, array_keys($params), $params)); + + // now generate and hash our payload + $payload = $this->token_auth_key.$path.(string) $expiration.$query; + $hash = hash('sha256', $payload, true); + + // sanitise and base64 encode it + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($hash)); + } + private static function parse_bunny_timestamp(string $timestamp): int { return (date_create_from_format('Y-m-d\TH:i:s.u', $timestamp) ?: date_create_from_format('Y-m-d\TH:i:s', $timestamp))->getTimestamp(); diff --git a/tests/FlysystemAdapterTest.php b/tests/FlysystemAdapterTest.php index 1ca379e..95c52bd 100644 --- a/tests/FlysystemAdapterTest.php +++ b/tests/FlysystemAdapterTest.php @@ -13,6 +13,7 @@ use League\Flysystem\FilesystemException; use League\Flysystem\UnableToCopyFile; use League\Flysystem\UnableToDeleteFile; +use League\Flysystem\UnableToGenerateTemporaryUrl; use League\Flysystem\UnableToMoveFile; use League\Flysystem\UnableToProvideChecksum; use League\Flysystem\UnableToRetrieveMetadata; @@ -104,7 +105,21 @@ public function setting_visibility(): void public function generating_a_temporary_url(): void { - $this->markTestSkipped('No temporary URL support is provided for BunnyCDN'); + $adapter = new BunnyCDNAdapter(self::bunnyCDNClient(), '', 'test-key'); + + $expiresAt = new \DateTimeImmutable('+1 hour'); + $url = $adapter->temporaryUrl('path.txt', $expiresAt, new Config()); + + $this->assertStringContainsString('path.txt?token=', $url); + $this->assertStringContainsString('&expires=', $url); + } + + public function test_temporary_url_throws_exception_if_not_configured(): void + { + $this->expectException(UnableToGenerateTemporaryUrl::class); + $this->expectExceptionMessage('In order to generate temporary URLs for a BunnyCDN object, you must pass the "token_auth_key" parameter to the BunnyCDNAdapter.'); + + $this->adapter()->temporaryUrl('path.txt', new \DateTimeImmutable('+1 hour'), new Config()); } /** diff --git a/tests/PrefixTest.php b/tests/PrefixTest.php index c920ba3..8d2a600 100644 --- a/tests/PrefixTest.php +++ b/tests/PrefixTest.php @@ -67,7 +67,14 @@ public function setting_visibility(): void public function generating_a_temporary_url(): void { - $this->markTestSkipped('No temporary URL support is provided for BunnyCDN'); + $adapter = new BunnyCDNAdapter(self::bunnyCDNClient(), '', 'test-key'); + $prefixAdapter = new PathPrefixedAdapter($adapter, self::PREFIX_PATH); + + $expiresAt = new \DateTimeImmutable('+1 hour'); + $url = $prefixAdapter->temporaryUrl('path.txt', $expiresAt, new Config()); + + $this->assertStringContainsString(self::PREFIX_PATH.'/path.txt?token=', $url); + $this->assertStringContainsString('&expires=', $url); } /**