From 373b94efabdde934a2b9f05d4fa9142f1639f53b Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 17:29:23 +0000 Subject: [PATCH] feat: extract storage providers from app/Plug/Storage Bunny Storage (Browse, Delete, Download, Upload, VBucket), StorageManager, domain contracts (Browseable, Deletable, Downloadable, Uploadable) with Core\Plug\Storage namespace alignment. Co-Authored-By: Claude Opus 4.6 --- composer.json | 17 ++ src/Bunny/Browse.php | 144 ++++++++++++++++ src/Bunny/Delete.php | 146 ++++++++++++++++ src/Bunny/Download.php | 142 ++++++++++++++++ src/Bunny/Upload.php | 139 +++++++++++++++ src/Bunny/VBucket.php | 308 ++++++++++++++++++++++++++++++++++ src/Contract/Browseable.php | 28 ++++ src/Contract/Deletable.php | 25 +++ src/Contract/Downloadable.php | 23 +++ src/Contract/Uploadable.php | 23 +++ src/StorageManager.php | 196 ++++++++++++++++++++++ 11 files changed, 1191 insertions(+) create mode 100644 composer.json create mode 100644 src/Bunny/Browse.php create mode 100644 src/Bunny/Delete.php create mode 100644 src/Bunny/Download.php create mode 100644 src/Bunny/Upload.php create mode 100644 src/Bunny/VBucket.php create mode 100644 src/Contract/Browseable.php create mode 100644 src/Contract/Deletable.php create mode 100644 src/Contract/Downloadable.php create mode 100644 src/Contract/Uploadable.php create mode 100644 src/StorageManager.php diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5b2c44b --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "core/php-plug-storage", + "description": "Storage 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\\Storage\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/Bunny/Browse.php b/src/Bunny/Browse.php new file mode 100644 index 0000000..6afd9e7 --- /dev/null +++ b/src/Bunny/Browse.php @@ -0,0 +1,144 @@ +apiKey = $apiKey ?? config("cdn.bunny.{$zone}.api_key", ''); + $this->storageZone = $storageZone ?? config("cdn.bunny.{$zone}.storage_zone", ''); + $this->region = $region ?? config("cdn.bunny.{$zone}.region", Client::STORAGE_ZONE_FS_EU); + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return ! empty($this->apiKey) && ! empty($this->storageZone); + } + + /** + * Get the Bunny Storage client. + */ + protected function client(): ?Client + { + if ($this->client === null && $this->isConfigured()) { + $this->client = new Client($this->apiKey, $this->storageZone, $this->region); + } + + return $this->client; + } + + /** + * List files in a path. + */ + public function list(string $path = '/'): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + try { + $files = $this->client()->listFiles($path); + + return $this->ok([ + 'path' => $path, + 'files' => $files, + 'count' => count($files), + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage: List failed', [ + 'path' => $path, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Check if a file exists. + */ + public function exists(string $path): bool + { + if (! $this->isConfigured()) { + return false; + } + + try { + // Try to get the file contents - if it fails, file doesn't exist + $this->client()->getContents($path); + + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Get file size in bytes. + */ + public function size(string $path): ?int + { + if (! $this->isConfigured()) { + return null; + } + + try { + $contents = $this->client()->getContents($path); + + return strlen($contents); + } catch (\Exception $e) { + return null; + } + } + + /** + * Create a browser for the public storage zone. + */ + public static function public(): self + { + return new self(zone: 'public'); + } + + /** + * Create a browser for the private storage zone. + */ + public static function private(): self + { + return new self(zone: 'private'); + } +} diff --git a/src/Bunny/Delete.php b/src/Bunny/Delete.php new file mode 100644 index 0000000..d26133b --- /dev/null +++ b/src/Bunny/Delete.php @@ -0,0 +1,146 @@ +apiKey = $apiKey ?? config("cdn.bunny.{$zone}.api_key", ''); + $this->storageZone = $storageZone ?? config("cdn.bunny.{$zone}.storage_zone", ''); + $this->region = $region ?? config("cdn.bunny.{$zone}.region", Client::STORAGE_ZONE_FS_EU); + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return ! empty($this->apiKey) && ! empty($this->storageZone); + } + + /** + * Get the Bunny Storage client. + */ + protected function client(): ?Client + { + if ($this->client === null && $this->isConfigured()) { + $this->client = new Client($this->apiKey, $this->storageZone, $this->region); + } + + return $this->client; + } + + /** + * Delete a single file. + */ + public function path(string $remotePath): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + try { + $this->client()->delete($remotePath); + + return $this->ok([ + 'deleted' => true, + 'path' => $remotePath, + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage: Delete failed', [ + 'remote' => $remotePath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Delete multiple files. + * + * @param array $remotePaths + */ + public function paths(array $remotePaths): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + $failed = []; + $deleted = []; + + foreach ($remotePaths as $path) { + try { + $this->client()->delete($path); + $deleted[] = $path; + } catch (\Exception $e) { + $failed[] = $path; + Log::error('Bunny Storage: Delete failed', [ + 'remote' => $path, + 'error' => $e->getMessage(), + ]); + } + } + + if (! empty($failed)) { + return $this->error('Some files failed to delete', [ + 'deleted' => $deleted, + 'failed' => $failed, + 'total' => count($remotePaths), + ]); + } + + return $this->ok([ + 'deleted' => count($deleted), + 'paths' => $deleted, + ]); + } + + /** + * Create a deleter for the public storage zone. + */ + public static function public(): self + { + return new self(zone: 'public'); + } + + /** + * Create a deleter for the private storage zone. + */ + public static function private(): self + { + return new self(zone: 'private'); + } +} diff --git a/src/Bunny/Download.php b/src/Bunny/Download.php new file mode 100644 index 0000000..3542d30 --- /dev/null +++ b/src/Bunny/Download.php @@ -0,0 +1,142 @@ +apiKey = $apiKey ?? config("cdn.bunny.{$zone}.api_key", ''); + $this->storageZone = $storageZone ?? config("cdn.bunny.{$zone}.storage_zone", ''); + $this->region = $region ?? config("cdn.bunny.{$zone}.region", Client::STORAGE_ZONE_FS_EU); + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return ! empty($this->apiKey) && ! empty($this->storageZone); + } + + /** + * Get the Bunny Storage client. + */ + protected function client(): ?Client + { + if ($this->client === null && $this->isConfigured()) { + $this->client = new Client($this->apiKey, $this->storageZone, $this->region); + } + + return $this->client; + } + + /** + * Get file contents as string. + */ + public function contents(string $remotePath): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + try { + $contents = $this->client()->getContents($remotePath); + + return $this->ok([ + 'contents' => $contents, + 'remote_path' => $remotePath, + 'size' => strlen($contents), + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage: getContents failed', [ + 'remote' => $remotePath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Download file to local path. + */ + public function toFile(string $remotePath, string $localPath): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + try { + $contents = $this->client()->getContents($remotePath); + + $directory = dirname($localPath); + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + file_put_contents($localPath, $contents); + + return $this->ok([ + 'downloaded' => true, + 'remote_path' => $remotePath, + 'local_path' => $localPath, + 'size' => strlen($contents), + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage: Download to file failed', [ + 'remote' => $remotePath, + 'local' => $localPath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Create a downloader for the public storage zone. + */ + public static function public(): self + { + return new self(zone: 'public'); + } + + /** + * Create a downloader for the private storage zone. + */ + public static function private(): self + { + return new self(zone: 'private'); + } +} diff --git a/src/Bunny/Upload.php b/src/Bunny/Upload.php new file mode 100644 index 0000000..c37dc73 --- /dev/null +++ b/src/Bunny/Upload.php @@ -0,0 +1,139 @@ +apiKey = $apiKey ?? config("cdn.bunny.{$zone}.api_key", ''); + $this->storageZone = $storageZone ?? config("cdn.bunny.{$zone}.storage_zone", ''); + $this->region = $region ?? config("cdn.bunny.{$zone}.region", Client::STORAGE_ZONE_FS_EU); + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return ! empty($this->apiKey) && ! empty($this->storageZone); + } + + /** + * Get the Bunny Storage client. + */ + protected function client(): ?Client + { + if ($this->client === null && $this->isConfigured()) { + $this->client = new Client($this->apiKey, $this->storageZone, $this->region); + } + + return $this->client; + } + + /** + * Upload a file from local path. + */ + public function file(string $localPath, string $remotePath): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + if (! file_exists($localPath)) { + return $this->error('Local file not found', ['local_path' => $localPath]); + } + + try { + $this->client()->upload($localPath, $remotePath); + + return $this->ok([ + 'uploaded' => true, + 'local_path' => $localPath, + 'remote_path' => $remotePath, + 'size' => filesize($localPath), + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage: Upload failed', [ + 'local' => $localPath, + 'remote' => $remotePath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Upload content directly. + */ + public function contents(string $remotePath, string $contents): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + try { + $this->client()->putContents($remotePath, $contents); + + return $this->ok([ + 'uploaded' => true, + 'remote_path' => $remotePath, + 'size' => strlen($contents), + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage: putContents failed', [ + 'remote' => $remotePath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Create an uploader for the public storage zone. + */ + public static function public(): self + { + return new self(zone: 'public'); + } + + /** + * Create an uploader for the private storage zone. + */ + public static function private(): self + { + return new self(zone: 'private'); + } +} diff --git a/src/Bunny/VBucket.php b/src/Bunny/VBucket.php new file mode 100644 index 0000000..957d01a --- /dev/null +++ b/src/Bunny/VBucket.php @@ -0,0 +1,308 @@ +upload('/local/file.jpg', 'images/hero.jpg'); + * // Uploads to: /{vbucket_id}/images/hero.jpg + */ +class VBucket +{ + use BuildsResponse; + + protected string $domain; + + protected string $vBucketId; + + protected string $apiKey; + + protected string $storageZone; + + protected string $region; + + protected ?Client $client = null; + + public function __construct( + string $domain, + ?string $apiKey = null, + ?string $storageZone = null, + ?string $region = null, + string $zone = 'public' + ) { + $this->domain = $domain; + $this->vBucketId = LthnHash::vBucketId($domain); + $this->apiKey = $apiKey ?? config("cdn.bunny.{$zone}.api_key", ''); + $this->storageZone = $storageZone ?? config("cdn.bunny.{$zone}.storage_zone", ''); + $this->region = $region ?? config("cdn.bunny.{$zone}.region", Client::STORAGE_ZONE_FS_EU); + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return ! empty($this->apiKey) && ! empty($this->storageZone); + } + + /** + * Get the Bunny Storage client. + */ + protected function client(): ?Client + { + if ($this->client === null && $this->isConfigured()) { + $this->client = new Client($this->apiKey, $this->storageZone, $this->region); + } + + return $this->client; + } + + /** + * Get the vBucket ID for this domain. + */ + public function id(): string + { + return $this->vBucketId; + } + + /** + * Get the domain for this vBucket. + */ + public function domain(): string + { + return $this->domain; + } + + /** + * Build a vBucket-scoped path. + */ + public function path(string $path): string + { + return $this->vBucketId.'/'.ltrim($path, '/'); + } + + /** + * Upload a file to vBucket-scoped path. + */ + public function upload(string $localPath, string $remotePath): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + if (! file_exists($localPath)) { + return $this->error('Local file not found', ['local_path' => $localPath]); + } + + $scopedPath = $this->path($remotePath); + + try { + $this->client()->upload($localPath, $scopedPath); + + return $this->ok([ + 'uploaded' => true, + 'domain' => $this->domain, + 'vbucket_id' => $this->vBucketId, + 'local_path' => $localPath, + 'remote_path' => $scopedPath, + 'size' => filesize($localPath), + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage vBucket: Upload failed', [ + 'domain' => $this->domain, + 'local' => $localPath, + 'remote' => $scopedPath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Upload content directly to vBucket-scoped path. + */ + public function putContents(string $remotePath, string $contents): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + $scopedPath = $this->path($remotePath); + + try { + $this->client()->putContents($scopedPath, $contents); + + return $this->ok([ + 'uploaded' => true, + 'domain' => $this->domain, + 'vbucket_id' => $this->vBucketId, + 'remote_path' => $scopedPath, + 'size' => strlen($contents), + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage vBucket: putContents failed', [ + 'domain' => $this->domain, + 'remote' => $scopedPath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Get file contents from vBucket-scoped path. + */ + public function getContents(string $remotePath): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + $scopedPath = $this->path($remotePath); + + try { + $contents = $this->client()->getContents($scopedPath); + + return $this->ok([ + 'contents' => $contents, + 'domain' => $this->domain, + 'vbucket_id' => $this->vBucketId, + 'remote_path' => $scopedPath, + 'size' => strlen($contents), + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage vBucket: getContents failed', [ + 'domain' => $this->domain, + 'remote' => $scopedPath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Delete file from vBucket-scoped path. + */ + public function delete(string $remotePath): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + $scopedPath = $this->path($remotePath); + + try { + $this->client()->delete($scopedPath); + + return $this->ok([ + 'deleted' => true, + 'domain' => $this->domain, + 'vbucket_id' => $this->vBucketId, + 'path' => $scopedPath, + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage vBucket: Delete failed', [ + 'domain' => $this->domain, + 'remote' => $scopedPath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * List files in vBucket-scoped path. + */ + public function list(string $path = ''): Response + { + if (! $this->isConfigured()) { + return $this->error('Bunny Storage not configured'); + } + + $scopedPath = $this->path($path); + + try { + $files = $this->client()->listFiles($scopedPath); + + return $this->ok([ + 'path' => $scopedPath, + 'domain' => $this->domain, + 'vbucket_id' => $this->vBucketId, + 'files' => $files, + 'count' => count($files), + ]); + } catch (\Exception $e) { + Log::error('Bunny Storage vBucket: List failed', [ + 'domain' => $this->domain, + 'path' => $scopedPath, + 'error' => $e->getMessage(), + ]); + + return $this->error($e->getMessage()); + } + } + + /** + * Check if file exists in vBucket-scoped path. + */ + public function exists(string $path): bool + { + if (! $this->isConfigured()) { + return false; + } + + $scopedPath = $this->path($path); + + try { + $this->client()->getContents($scopedPath); + + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * Create a vBucket for a workspace (convenience method). + */ + public static function forWorkspace(string $workspaceUuid, string $zone = 'public'): self + { + return new self($workspaceUuid, zone: $zone); + } + + /** + * Create a public zone vBucket. + */ + public static function public(string $domain): self + { + return new self($domain, zone: 'public'); + } + + /** + * Create a private zone vBucket. + */ + public static function private(string $domain): self + { + return new self($domain, zone: 'private'); + } +} diff --git a/src/Contract/Browseable.php b/src/Contract/Browseable.php new file mode 100644 index 0000000..e20a671 --- /dev/null +++ b/src/Contract/Browseable.php @@ -0,0 +1,28 @@ + $remotePaths + */ + public function paths(array $remotePaths): Response; +} diff --git a/src/Contract/Downloadable.php b/src/Contract/Downloadable.php new file mode 100644 index 0000000..26415a8 --- /dev/null +++ b/src/Contract/Downloadable.php @@ -0,0 +1,23 @@ +upload(); // Returns Uploadable for default driver + * $downloader = $storage->download(); // Returns Downloadable + * + * // Specific driver + * $uploader = $storage->driver('bunny')->upload(); + * + * // Zone switching (for Bunny) + * $uploader = $storage->zone('private')->upload(); + * + * // vBucket for workspace isolation + * $vbucket = $storage->vbucket('example.com'); + */ +class StorageManager +{ + protected string $defaultDriver; + + protected string $zone = 'public'; + + /** + * @var array, download: class-string, delete: class-string, browse: class-string, vbucket?: class-string}> + */ + protected array $drivers = [ + 'bunny' => [ + 'upload' => Bunny\Upload::class, + 'download' => Bunny\Download::class, + 'delete' => Bunny\Delete::class, + 'browse' => Bunny\Browse::class, + 'vbucket' => Bunny\VBucket::class, + ], + ]; + + public function __construct() + { + $this->defaultDriver = config('cdn.storage_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; + } + + /** + * Set the storage zone (public/private). + */ + public function zone(string $zone): self + { + $this->zone = $zone; + + return $this; + } + + /** + * Get the upload operation for the current driver. + */ + public function upload(): Uploadable + { + return $this->resolve('upload'); + } + + /** + * Get the download operation for the current driver. + */ + public function download(): Downloadable + { + return $this->resolve('download'); + } + + /** + * Get the delete operation for the current driver. + */ + public function delete(): Deletable + { + return $this->resolve('delete'); + } + + /** + * Get the browse operation for the current driver. + */ + public function browse(): Browseable + { + return $this->resolve('browse'); + } + + /** + * Get a vBucket for workspace-isolated storage. + */ + public function vbucket(string $domain): Bunny\VBucket + { + if (! isset($this->drivers[$this->defaultDriver]['vbucket'])) { + throw new InvalidArgumentException( + "vBucket not available for storage driver [{$this->defaultDriver}]." + ); + } + + $class = $this->drivers[$this->defaultDriver]['vbucket']; + + return new $class($domain, zone: $this->zone); + } + + /** + * 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{upload?: class-string, download?: class-string, delete?: class-string, browse?: class-string, vbucket?: 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. + */ + protected function resolve(string $operation): object + { + if (! isset($this->drivers[$this->defaultDriver])) { + throw new InvalidArgumentException("Storage driver [{$this->defaultDriver}] not registered."); + } + + if (! isset($this->drivers[$this->defaultDriver][$operation])) { + throw new InvalidArgumentException( + "Operation [{$operation}] not available for storage driver [{$this->defaultDriver}]." + ); + } + + $class = $this->drivers[$this->defaultDriver][$operation]; + + return new $class(zone: $this->zone); + } +}