basePath = rtrim($basePath, '/'); } public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo { $bucketName = BucketName::fromString($bucket); $objectKey = ObjectKey::fromString($key); $path = $this->buildPath($bucket, $key); try { // Ensure bucket directory exists $bucketPath = $this->buildBucketPath($bucket); if (! $this->storage->exists($bucketPath)) { $this->storage->createDirectory($bucketPath); } // Store content $this->storage->put($path, $body); // Get file metadata $size = FileSize::fromBytes($this->storage->size($path)); $lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path)); $mimeTypeString = $this->storage->getMimeType($path); // Generate ETag (SHA256 hash of content) $etag = Hash::sha256($body); // Convert MIME type string to Value Object $contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString); $metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []); return new ObjectInfo( bucket: $bucketName, key: $objectKey, etag: $etag, size: $size, contentType: $contentType, lastModified: $lastModified, metadata: $metadata, versionId: null ); } catch (\Throwable $e) { throw StorageOperationException::for('put', $bucket, $key, $e->getMessage(), $e); } } public function get(string $bucket, string $key): string { $path = $this->buildPath($bucket, $key); try { if (! $this->storage->exists($path)) { throw ObjectNotFoundException::for($bucket, $key); } return $this->storage->get($path); } catch (ObjectNotFoundException $e) { throw $e; } catch (\Throwable $e) { throw StorageOperationException::for('get', $bucket, $key, $e->getMessage(), $e); } } public function stream(string $bucket, string $key) { // Backward compatibility: returns temporary stream return $this->openReadStream($bucket, $key); } public function getToStream(string $bucket, string $key, $destination, array $opts = []): int { $path = $this->buildPath($bucket, $key); try { if (! $this->storage->exists($path)) { throw ObjectNotFoundException::for($bucket, $key); } // Validate destination stream if (! is_resource($destination)) { throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream'); } // Use Storage's readStream if available if (method_exists($this->storage, 'readStream')) { $sourceStream = $this->storage->readStream($path); $bufferSize = $opts['bufferSize'] ?? 8192; try { $bytesWritten = stream_copy_to_stream($sourceStream, $destination, null, $bufferSize); if ($bytesWritten === false) { throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to copy stream'); } return $bytesWritten; } finally { fclose($sourceStream); } } // Fallback: read content and write to stream $content = $this->storage->get($path); $bytesWritten = fwrite($destination, $content); if ($bytesWritten === false) { throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream'); } return $bytesWritten; } catch (ObjectNotFoundException $e) { throw $e; } catch (StorageOperationException $e) { throw $e; } catch (\Throwable $e) { throw StorageOperationException::for('getToStream', $bucket, $key, $e->getMessage(), $e); } } public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo { $path = $this->buildPath($bucket, $key); try { // Validate source stream if (! is_resource($source)) { throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream'); } // Ensure bucket directory exists $bucketPath = $this->buildBucketPath($bucket); if (! $this->storage->exists($bucketPath)) { $this->storage->createDirectory($bucketPath); } // Use Storage's putStream if available if (method_exists($this->storage, 'putStream')) { $this->storage->putStream($path, $source); } else { // Fallback: read stream content and use put() $content = stream_get_contents($source); if ($content === false) { throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream'); } $this->storage->put($path, $content); } // Reuse head() logic to get ObjectInfo return $this->head($bucket, $key); } catch (StorageOperationException $e) { throw $e; } catch (\Throwable $e) { throw StorageOperationException::for('putFromStream', $bucket, $key, $e->getMessage(), $e); } } public function openReadStream(string $bucket, string $key) { $path = $this->buildPath($bucket, $key); try { if (! $this->storage->exists($path)) { throw ObjectNotFoundException::for($bucket, $key); } // Use Storage's readStream if available if (method_exists($this->storage, 'readStream')) { return $this->storage->readStream($path); } // Fallback: create stream from file content $content = $this->storage->get($path); $stream = fopen('php://temp', 'r+'); if ($stream === false) { throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream'); } fwrite($stream, $content); rewind($stream); return $stream; } catch (ObjectNotFoundException $e) { throw $e; } catch (\Throwable $e) { throw StorageOperationException::for('openReadStream', $bucket, $key, $e->getMessage(), $e); } } public function head(string $bucket, string $key): ObjectInfo { $bucketName = BucketName::fromString($bucket); $objectKey = ObjectKey::fromString($key); $path = $this->buildPath($bucket, $key); try { if (! $this->storage->exists($path)) { throw ObjectNotFoundException::for($bucket, $key); } $size = FileSize::fromBytes($this->storage->size($path)); $lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path)); $mimeTypeString = $this->storage->getMimeType($path); // Read content to generate ETag (could be optimized to read only if needed) $content = $this->storage->get($path); $etag = Hash::sha256($content); // Convert MIME type string to Value Object $contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString); return new ObjectInfo( bucket: $bucketName, key: $objectKey, etag: $etag, size: $size, contentType: $contentType, lastModified: $lastModified, metadata: ObjectMetadata::empty(), versionId: null ); } catch (ObjectNotFoundException $e) { throw $e; } catch (\Throwable $e) { throw StorageOperationException::for('head', $bucket, $key, $e->getMessage(), $e); } } public function delete(string $bucket, string $key): void { $path = $this->buildPath($bucket, $key); try { if (! $this->storage->exists($path)) { // Object doesn't exist, but that's OK for delete operations return; } $this->storage->delete($path); } catch (\Throwable $e) { throw StorageOperationException::for('delete', $bucket, $key, $e->getMessage(), $e); } } public function exists(string $bucket, string $key): bool { $path = $this->buildPath($bucket, $key); return $this->storage->exists($path); } public function url(string $bucket, string $key): ?string { // Filesystem storage doesn't have public URLs return null; } public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string { // Filesystem storage doesn't support presigned URLs throw StorageOperationException::for( 'temporaryUrl', $bucket, $key, 'Temporary URLs are not supported for filesystem storage' ); } /** * Build filesystem path for bucket/key */ private function buildPath(string $bucket, string $key): string { // Sanitize bucket name (prevent path traversal) $bucket = $this->sanitizeBucketName($bucket); $key = ltrim($key, '/'); // Sanitize key (prevent path traversal) $key = $this->sanitizeKey($key); return $this->basePath . '/' . $bucket . '/' . $key; } /** * Build filesystem path for bucket directory */ private function buildBucketPath(string $bucket): string { $bucket = $this->sanitizeBucketName($bucket); return $this->basePath . '/' . $bucket; } /** * Sanitize bucket name to prevent path traversal */ private function sanitizeBucketName(string $bucket): string { // Remove any path separators and dangerous characters $bucket = str_replace(['/', '\\', '..'], '', $bucket); $bucket = trim($bucket, '.'); if ($bucket === '' || $bucket === '.') { throw StorageOperationException::for('sanitize', $bucket, '', 'Invalid bucket name'); } return $bucket; } /** * Sanitize key to prevent path traversal */ private function sanitizeKey(string $key): string { // Remove leading slashes but preserve internal structure $key = ltrim($key, '/'); // Prevent directory traversal if (str_contains($key, '..')) { throw StorageOperationException::for('sanitize', '', $key, 'Path traversal detected in key'); } return $key; } }