From 4da5fa62526ad399db62dee6894e5c468efb502c Mon Sep 17 00:00:00 2001 From: Chris Page Date: Fri, 23 Jan 2026 13:31:27 +0000 Subject: [PATCH 1/3] Added temporary URL method --- src/BunnyCDNAdapter.php | 52 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/src/BunnyCDNAdapter.php b/src/BunnyCDNAdapter.php index cbc4ac2..fc25cbd 100644 --- a/src/BunnyCDNAdapter.php +++ b/src/BunnyCDNAdapter.php @@ -2,6 +2,8 @@ namespace PlatformCommunity\Flysystem\BunnyCDN; +use Carbon\CarbonInterface; +use DateTimeInterface; use Exception; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Pool; @@ -28,16 +30,21 @@ use League\Flysystem\UnableToWriteFile; use League\Flysystem\UrlGeneration\PublicUrlGenerator; use League\Flysystem\Visibility; +use League\Flysystem\UrlGeneration\TemporaryUrlGenerator; 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 +561,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 RuntimeException('In order to generate temporary URLs for a BunnyCDN object, you must pass the "token_auth_key" parameter to the BunnyCDNAdapter.'); + } + + // 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(); From 7fba00a937964986cff87ba9313de002458e038e Mon Sep 17 00:00:00 2001 From: Chris Page Date: Fri, 23 Jan 2026 13:37:14 +0000 Subject: [PATCH 2/3] Added testing --- tests/FlysystemAdapterTest.php | 16 +++++++++++++++- tests/PrefixTest.php | 9 ++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/FlysystemAdapterTest.php b/tests/FlysystemAdapterTest.php index 1ca379e..e43e093 100644 --- a/tests/FlysystemAdapterTest.php +++ b/tests/FlysystemAdapterTest.php @@ -104,7 +104,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(\RuntimeException::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..3d94601 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); } /** From cc88f820e5a67335f6cc1cc3f472ac19cf323d0b Mon Sep 17 00:00:00 2001 From: Chris Page Date: Fri, 23 Jan 2026 13:42:38 +0000 Subject: [PATCH 3/3] Pint cleanup Improved temporary URL generation --- src/BunnyCDNAdapter.php | 23 +++++++++++------------ tests/FlysystemAdapterTest.php | 3 ++- tests/PrefixTest.php | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/BunnyCDNAdapter.php b/src/BunnyCDNAdapter.php index fc25cbd..f67ec84 100644 --- a/src/BunnyCDNAdapter.php +++ b/src/BunnyCDNAdapter.php @@ -2,7 +2,6 @@ namespace PlatformCommunity\Flysystem\BunnyCDN; -use Carbon\CarbonInterface; use DateTimeInterface; use Exception; use GuzzleHttp\Exception\RequestException; @@ -22,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; @@ -29,8 +29,8 @@ use League\Flysystem\UnableToSetVisibility; use League\Flysystem\UnableToWriteFile; use League\Flysystem\UrlGeneration\PublicUrlGenerator; -use League\Flysystem\Visibility; use League\Flysystem\UrlGeneration\TemporaryUrlGenerator; +use League\Flysystem\Visibility; use League\MimeTypeDetection\FinfoMimeTypeDetector; use PlatformCommunity\Flysystem\BunnyCDN\Exceptions\NotFoundException; use RuntimeException; @@ -44,8 +44,7 @@ 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/'); } @@ -564,7 +563,7 @@ public function publicUrl(string $path, Config $config): string public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string { if ($this->token_auth_key === '') { - throw new RuntimeException('In order to generate temporary URLs for a BunnyCDN object, you must pass the "token_auth_key" parameter to the BunnyCDNAdapter.'); + 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 @@ -580,22 +579,22 @@ public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config // concatenate all of our data return $path - . (str_contains($path, '?') ? '&' : '?') - . 'token=' . $this->buildSigningKey($path, $expiration, $params) - . '&expires=' . $expiration - . ($params ? '&' . http_build_query($params) : null); + .(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; + $path = str_starts_with($path, '/') ? $path : '/'.$path; // process our query params - $query = implode('&', array_map(fn($k, $v) => $k . '=' . $v, array_keys($params), $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; + $payload = $this->token_auth_key.$path.(string) $expiration.$query; $hash = hash('sha256', $payload, true); // sanitise and base64 encode it diff --git a/tests/FlysystemAdapterTest.php b/tests/FlysystemAdapterTest.php index e43e093..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; @@ -115,7 +116,7 @@ public function generating_a_temporary_url(): void public function test_temporary_url_throws_exception_if_not_configured(): void { - $this->expectException(\RuntimeException::class); + $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 3d94601..8d2a600 100644 --- a/tests/PrefixTest.php +++ b/tests/PrefixTest.php @@ -73,7 +73,7 @@ public function generating_a_temporary_url(): void $expiresAt = new \DateTimeImmutable('+1 hour'); $url = $prefixAdapter->temporaryUrl('path.txt', $expiresAt, new Config()); - $this->assertStringContainsString(self::PREFIX_PATH . '/path.txt?token=', $url); + $this->assertStringContainsString(self::PREFIX_PATH.'/path.txt?token=', $url); $this->assertStringContainsString('&expires=', $url); }