diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ccc581e --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "core/php-plug-cdn", + "description": "CDN provider integrations for the Plug framework", + "type": "library", + "license": "EUPL-1.2", + "require": { + "php": "^8.2", + "core/php": "^1.0" + }, + "autoload": { + "psr-4": { + "Core\\Plug\\Cdn\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/Bunny/Purge.php b/src/Bunny/Purge.php new file mode 100644 index 0000000..9e4885c --- /dev/null +++ b/src/Bunny/Purge.php @@ -0,0 +1,166 @@ +apiKey = $apiKey ?? config('cdn.bunny.api_key') ?? ''; + $this->pullZoneId = $pullZoneId ?? config('cdn.bunny.pull_zone_id') ?? ''; + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return ! empty($this->apiKey) && ! empty($this->pullZoneId); + } + + /** + * Purge a single URL from cache. + */ + public function url(string $url): Response + { + return $this->urls([$url]); + } + + /** + * Purge multiple URLs from cache. + * + * @param array $urls + */ + public function urls(array $urls): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny CDN not configured'); + } + + try { + $failed = []; + + foreach ($urls as $url) { + $response = $this->http() + ->withHeaders(['AccessKey' => $this->apiKey]) + ->post("{$this->baseUrl}/purge", ['url' => $url]); + + if (! $response->successful()) { + $failed[] = $url; + Log::error('Bunny CDN: Purge failed', [ + 'url' => $url, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } + } + + if (! empty($failed)) { + return $this->error('Some URLs failed to purge', [ + 'failed' => $failed, + 'total' => count($urls), + 'failed_count' => count($failed), + ]); + } + + return $this->ok([ + 'purged' => count($urls), + 'urls' => $urls, + ]); + } catch (\Exception $e) { + Log::error('Bunny CDN: Purge exception', ['error' => $e->getMessage()]); + + return $this->error($e->getMessage()); + } + } + + /** + * Purge entire pull zone cache. + */ + public function all(): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny CDN not configured'); + } + + try { + $response = $this->http() + ->withHeaders(['AccessKey' => $this->apiKey]) + ->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache"); + + return $this->fromHttp($response, fn () => ['purged' => 'all']); + } catch (\Exception $e) { + Log::error('Bunny CDN: PurgeAll exception', ['error' => $e->getMessage()]); + + return $this->error($e->getMessage()); + } + } + + /** + * Purge by cache tag. + * + * Useful for workspace isolation - tag content with workspace ID. + */ + public function tag(string $tag): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny CDN not configured'); + } + + try { + $response = $this->http() + ->withHeaders(['AccessKey' => $this->apiKey]) + ->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache", [ + 'CacheTag' => $tag, + ]); + + return $this->fromHttp($response, fn () => [ + 'purged' => 'tag', + 'tag' => $tag, + ]); + } catch (\Exception $e) { + Log::error('Bunny CDN: PurgeByTag exception', [ + 'tag' => $tag, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Purge all cached content for a workspace. + * + * Convenience method using tag-based purging. + */ + public function workspace(string $workspaceUuid): Response + { + return $this->tag("workspace-{$workspaceUuid}"); + } +} diff --git a/src/Bunny/Stats.php b/src/Bunny/Stats.php new file mode 100644 index 0000000..9aaea93 --- /dev/null +++ b/src/Bunny/Stats.php @@ -0,0 +1,109 @@ +apiKey = $apiKey ?? config('cdn.bunny.api_key') ?? ''; + $this->pullZoneId = $pullZoneId ?? config('cdn.bunny.pull_zone_id') ?? ''; + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return ! empty($this->apiKey) && ! empty($this->pullZoneId); + } + + /** + * Get CDN statistics for date range. + * + * @param string|null $from Start date (ISO 8601) + * @param string|null $to End date (ISO 8601) + */ + public function stats(?string $from = null, ?string $to = null): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny CDN not configured'); + } + + try { + $params = ['pullZone' => $this->pullZoneId]; + + if ($from) { + $params['dateFrom'] = $from; + } + if ($to) { + $params['dateTo'] = $to; + } + + $response = $this->http() + ->withHeaders(['AccessKey' => $this->apiKey]) + ->get("{$this->baseUrl}/statistics", $params); + + return $this->fromHttp($response, fn (array $data) => [ + 'total_bandwidth' => $data['TotalBandwidthUsed'] ?? 0, + 'cache_hit_rate' => $data['CacheHitRate'] ?? 0, + 'origin_traffic' => $data['TotalOriginTraffic'] ?? 0, + 'requests_served' => $data['TotalRequestsServed'] ?? 0, + 'error_4xx' => $data['Error4xxCount'] ?? 0, + 'error_5xx' => $data['Error5xxCount'] ?? 0, + 'raw' => $data, + ]); + } catch (\Exception $e) { + Log::error('Bunny CDN: Stats exception', ['error' => $e->getMessage()]); + + return $this->error($e->getMessage()); + } + } + + /** + * Get bandwidth usage for date range. + * + * @param string|null $from Start date (ISO 8601) + * @param string|null $to End date (ISO 8601) + */ + public function bandwidth(?string $from = null, ?string $to = null): Response + { + $statsResponse = $this->stats($from, $to); + + if ($statsResponse->hasError()) { + return $statsResponse; + } + + return $this->ok([ + 'total_bandwidth' => $statsResponse->get('total_bandwidth', 0), + 'cached_bandwidth' => $statsResponse->get('cache_hit_rate', 0), + 'origin_bandwidth' => $statsResponse->get('origin_traffic', 0), + ]); + } +} diff --git a/src/CdnManager.php b/src/CdnManager.php new file mode 100644 index 0000000..c9d6dfa --- /dev/null +++ b/src/CdnManager.php @@ -0,0 +1,144 @@ +purge(); // Returns Purgeable for default driver + * $stats = $cdn->stats(); // Returns HasStats for default driver + * + * // Specific driver + * $purger = $cdn->driver('bunny')->purge(); + */ +class CdnManager +{ + protected string $defaultDriver; + + /** + * @var array, stats: class-string}> + */ + protected array $drivers = [ + 'bunny' => [ + 'purge' => Bunny\Purge::class, + 'stats' => Bunny\Stats::class, + ], + ]; + + public function __construct() + { + $this->defaultDriver = config('cdn.driver', 'bunny'); + } + + /** + * Get the default driver name. + */ + public function getDefaultDriver(): string + { + return $this->defaultDriver; + } + + /** + * Set the default driver. + */ + public function setDefaultDriver(string $driver): self + { + $this->defaultDriver = $driver; + + return $this; + } + + /** + * Get a driver instance. + */ + public function driver(?string $driver = null): self + { + if ($driver !== null) { + $this->defaultDriver = $driver; + } + + return $this; + } + + /** + * Get the purge operation for the current driver. + */ + public function purge(): Purgeable + { + return $this->resolve('purge'); + } + + /** + * Get the stats operation for the current driver. + */ + public function stats(): HasStats + { + return $this->resolve('stats'); + } + + /** + * Check if a driver is registered. + */ + public function hasDriver(string $driver): bool + { + return isset($this->drivers[$driver]); + } + + /** + * Get all registered driver names. + * + * @return array + */ + public function drivers(): array + { + return array_keys($this->drivers); + } + + /** + * Register a custom driver. + * + * @param array{purge?: class-string, stats?: class-string} $operations + */ + public function extend(string $driver, array $operations): self + { + $this->drivers[$driver] = array_merge($this->drivers[$driver] ?? [], $operations); + + return $this; + } + + /** + * Resolve an operation class for the current driver. + * + * @template T + * + * @return T + */ + protected function resolve(string $operation): object + { + if (! isset($this->drivers[$this->defaultDriver])) { + throw new InvalidArgumentException("CDN driver [{$this->defaultDriver}] not registered."); + } + + if (! isset($this->drivers[$this->defaultDriver][$operation])) { + throw new InvalidArgumentException( + "Operation [{$operation}] not available for CDN driver [{$this->defaultDriver}]." + ); + } + + $class = $this->drivers[$this->defaultDriver][$operation]; + + return new $class; + } +} diff --git a/src/Contract/HasStats.php b/src/Contract/HasStats.php new file mode 100644 index 0000000..74eba77 --- /dev/null +++ b/src/Contract/HasStats.php @@ -0,0 +1,29 @@ + $urls + */ + public function urls(array $urls): Response; + + /** + * Purge entire cache. + */ + public function all(): Response; + + /** + * Purge by cache tag. + */ + public function tag(string $tag): Response; +}