diff --git a/src/Libraries/Storage/DriverResolver.php b/src/Libraries/Storage/DriverResolver.php new file mode 100644 index 0000000..3b4199d --- /dev/null +++ b/src/Libraries/Storage/DriverResolver.php @@ -0,0 +1,80 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.0.1 + * @link https://github.com/ + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Libraries\Storage; + +use Spotlibs\PhpLib\Exceptions\RuntimeException; + +/** + * DriverResolver + * + * Resolve storage drivers for storage operations. + * + * @category Library + * @package Libraries\Storage + * @author Mufthi Ryanda + * @license https://mit-license.org/ MIT License + * @link https://github.com/ + */ +class DriverResolver +{ + /** + * Resolve the driver name to use when no explicit driver was chained. + * + * @return string one of Storage::NFS, Storage::MINIO, Storage::MINIO_BRIMEN + *@throws RuntimeException when env is not set or invalid + * + */ + public function resolveDefault(): string + { + $driver = env('DEFAULT_DRIVER_STORAGE'); + + if ($driver === null || $driver === '') { + throw new RuntimeException('DEFAULT_DRIVER_STORAGE is not configured'); + } + + if (!in_array($driver, [ + Storage::NFS, + Storage::MINIO, + Storage::MINIO_BRIMEN, + ], true)) { + throw new RuntimeException("DEFAULT_DRIVER_STORAGE has invalid value: {$driver}"); + } + + return $driver; + } + + /** + * Validate an explicitly-chained driver name. + * + * @param string $driver driver constant value + * + * @return string the same driver name, validated + *@throws RuntimeException when driver name is invalid + * + */ + public function resolveExplicit(string $driver): string + { + if (!in_array($driver, [ + Storage::NFS, + Storage::MINIO, + Storage::MINIO_BRIMEN, + ], true)) { + throw new RuntimeException("Unknown storage driver: {$driver}"); + } + + return $driver; + } +} diff --git a/src/Libraries/Storage/Drivers/MinioDriver.php b/src/Libraries/Storage/Drivers/MinioDriver.php new file mode 100644 index 0000000..f202f0b --- /dev/null +++ b/src/Libraries/Storage/Drivers/MinioDriver.php @@ -0,0 +1,306 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.0.1 + * @link https://github.com/ + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Libraries\Storage\Drivers; + +use Carbon\Carbon; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage as LaravelStorage; +use Spotlibs\PhpLib\Exceptions\RuntimeException; +use Spotlibs\PhpLib\Libraries\Storage\Storage as StorageManager; +use Spotlibs\PhpLib\Libraries\Storage\StorageDriverInterface; +use Spotlibs\PhpLib\Libraries\Storage\StorageResult; + +/** + * MinioDriver + * + * Storage driver backed by Laravel's MinIO filesystem disk. + * + * @category Library + * @package Libraries\Storage\Drivers + * @author Mufthi Ryanda + * @license https://mit-license.org/ MIT License + * @link https://github.com/ + */ +class MinioDriver implements StorageDriverInterface +{ + protected string $disk; + + /** + * Create a MinIO storage driver. + * + * @param string $disk Laravel filesystem disk name + */ + public function __construct(string $disk = 'minio') + { + $this->disk = $disk; + } + + /** + * Upload a file to MinIO. + * + * @param UploadedFile $file file to upload + * @param string $dirpath destination directory path + * + * @throws RuntimeException when the upload fails + * + * @return StorageResult upload result + */ + public function upload(UploadedFile $file, string $dirpath): StorageResult + { + $filename = $file->getClientOriginalName(); + $destPath = rtrim($dirpath, '/') . '/' . $filename; + + if (!LaravelStorage::disk($this->disk)->put($destPath, $file->getContent())) { + throw new RuntimeException("Failed to upload file to MinIO: {$destPath}"); + } + + $result = new StorageResult(); + $result->driver = $this->disk === 'minio_brimen' + ? StorageManager::MINIO_BRIMEN + : StorageManager::MINIO; + $result->pathFile = $filename; + $result->fullPath = $destPath; + $result->folder = rtrim($dirpath, '/') . '/'; + + return $result; + } + + /** + * Write a resource stream to MinIO. + * + * @param resource $stream readable stream + * @param string $destPath destination file path + * + * @throws RuntimeException when the stream cannot be written + * + * @return StorageResult write result + */ + public function writeStream($stream, string $destPath): StorageResult + { + if (!LaravelStorage::disk($this->disk)->writeStream($destPath, $stream)) { + throw new RuntimeException("Failed to write stream to MinIO: {$destPath}"); + } + + $result = new StorageResult(); + $result->driver = $this->disk === 'minio_brimen' + ? StorageManager::MINIO_BRIMEN + : StorageManager::MINIO; + $result->pathFile = basename($destPath); + $result->fullPath = $destPath; + $result->folder = dirname($destPath) . '/'; + + return $result; + } + + /** + * Copy a file within MinIO. + * + * @param string $srcPath source file path + * @param string $destPath destination file path + * + * @throws RuntimeException when the copy fails + * + * @return StorageResult copy result + */ + public function copySameDriver(string $srcPath, string $destPath): StorageResult + { + if (!LaravelStorage::disk($this->disk)->copy($srcPath, $destPath)) { + throw new RuntimeException("MinIO copy failed: {$srcPath} -> {$destPath}"); + } + + $result = new StorageResult(); + $result->driver = $this->disk === 'minio_brimen' + ? StorageManager::MINIO_BRIMEN + : StorageManager::MINIO; + $result->pathFile = basename($destPath); + $result->fullPath = $destPath; + $result->folder = dirname($destPath) . '/'; + + return $result; + } + + /** + * Move a file within MinIO. + * + * @param string $srcPath source file path + * @param string $destPath destination file path + * + * @throws RuntimeException when the move fails + * + * @return StorageResult move result + */ + public function moveSameDriver(string $srcPath, string $destPath): StorageResult + { + if (!LaravelStorage::disk($this->disk)->move($srcPath, $destPath)) { + throw new RuntimeException("MinIO move failed: {$srcPath} -> {$destPath}"); + } + + $result = new StorageResult(); + $result->driver = $this->disk === 'minio_brimen' + ? StorageManager::MINIO_BRIMEN + : StorageManager::MINIO; + $result->pathFile = basename($destPath); + $result->fullPath = $destPath; + $result->folder = dirname($destPath) . '/'; + + return $result; + } + + /** + * Delete a file from MinIO. + * + * @param string $filepath file path to delete + * + * @throws RuntimeException when the deletion fails + * + * @return void + */ + public function delete(string $filepath): void + { + $disk = LaravelStorage::disk($this->disk); + + if (str_ends_with($filepath, '/') || (method_exists($disk, 'directoryExists') && $disk->directoryExists($filepath))) { + throw new RuntimeException("Cannot delete a folder: {$filepath}"); + } + + if (!$disk->delete($filepath)) { + throw new RuntimeException("MinIO delete failed: {$filepath}"); + } + } + + /** + * Generate a temporary URL for a MinIO file. + * + * @param string $filepath file path for the secure link + * @param int|null $ttl link lifetime in seconds + * + * @return string secure link URL + */ + public function securelink(string $filepath, ?int $ttl = null): string + { + $ttlSeconds = $ttl ?? (int) env('MINIO_EXPIRED_URL', 60); + + return LaravelStorage::disk($this->disk)->temporaryUrl( + $filepath, + Carbon::now()->addSeconds($ttlSeconds) + ); + } + + /** + * Check whether a file exists in MinIO. + * + * @param string $filepath file path to check + * + * @return bool whether the file exists + */ + public function exists(string $filepath): bool + { + return LaravelStorage::disk($this->disk)->exists($filepath); + } + + /** + * Open a readable stream for a MinIO file. + * + * @param string $filepath file path to read + * + * @throws RuntimeException when the stream cannot be opened + * + * @return resource readable file stream + */ + public function readStream(string $filepath) + { + $stream = LaravelStorage::disk($this->disk)->readStream($filepath); + if ($stream === null || $stream === false) { + throw new RuntimeException("Failed to open read stream for: {$filepath}"); + } + + return $stream; + } + + /** + * List files directly within a MinIO directory. + * + * @param string $dirpath directory path to list + * + * @return array file entries + */ + public function files(string $dirpath): array + { + $disk = LaravelStorage::disk($this->disk); + $paths = $disk->files($dirpath); + + $result = []; + foreach ($paths as $path) { + $result[] = [ + 'path' => $path, + 'size' => $disk->size($path), + ]; + } + + return $result; + } + + /** + * List files recursively within a MinIO directory. + * + * @param string $dirpath directory path to list + * + * @return array file entries + */ + public function allFiles(string $dirpath): array + { + $disk = LaravelStorage::disk($this->disk); + $paths = $disk->allFiles($dirpath); + + $result = []; + foreach ($paths as $path) { + $result[] = [ + 'path' => $path, + 'size' => $disk->size($path), + ]; + } + + return $result; + } + + /** + * Get information about a specific file. + * + * @param string $filepath file path to inspect + * + * @throws RuntimeException when the file is not found + * + * @return StorageResult + */ + public function info(string $filepath): StorageResult + { + $disk = LaravelStorage::disk($this->disk); + if (!$disk->exists($filepath)) { + throw new RuntimeException("File not found: {$filepath}"); + } + + $result = new StorageResult(); + $result->driver = $this->disk === 'minio_brimen' + ? StorageManager::MINIO_BRIMEN + : StorageManager::MINIO; + $result->pathFile = basename($filepath); + $result->fullPath = $filepath; + $result->folder = dirname($filepath) . '/'; + $result->size = $disk->size($filepath); + + return $result; + } +} diff --git a/src/Libraries/Storage/Drivers/NfsDriver.php b/src/Libraries/Storage/Drivers/NfsDriver.php new file mode 100644 index 0000000..88dc868 --- /dev/null +++ b/src/Libraries/Storage/Drivers/NfsDriver.php @@ -0,0 +1,393 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.0.1 + * @link https://github.com/ + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Libraries\Storage\Drivers; + +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Str; +use Spotlibs\PhpLib\Exceptions\RuntimeException; +use Spotlibs\PhpLib\Libraries\Storage\Storage; +use Spotlibs\PhpLib\Libraries\Storage\StorageDriverInterface; +use Spotlibs\PhpLib\Libraries\Storage\StorageResult; + +/** + * NfsDriver + * + * Native filesystem driver for NFS storage. + * + * @category Library + * @package Libraries\Storage\Drivers + * @author Mufthi Ryanda + * @license https://mit-license.org/ MIT License + * @link https://github.com/ + */ +class NfsDriver implements StorageDriverInterface +{ + /** + * Resolve the actual absolute path using fallbacks if necessary. + * + * @param string $path file or directory path + * + * @return string resolved absolute path or original path if not found + */ + private function resolvePath(string $path): string + { + if (str_starts_with($path, '/data/NFS_') || str_starts_with($path, '/')) { + return $path; + } + + $envFallbacks = (string) env('PATH_NFS_FALLBACK_STORAGE_SPOTLIB', ''); + if ($envFallbacks === '') { + return $path; + } + + $fallbacks = array_filter(array_map('trim', explode(',', $envFallbacks))); + + foreach ($fallbacks as $basePath) { + $fullPath = rtrim($basePath, '/') . '/' . ltrim($path, '/'); + if (file_exists($fullPath)) { + return $fullPath; + } + } + + return $path; + } + + /** + * Upload a file to an NFS directory. + * + * @param UploadedFile $file file to upload + * @param string $dirpath destination directory path + * + * @throws RuntimeException when the destination directory cannot be created + * + * @return StorageResult upload result + */ + public function upload(UploadedFile $file, string $dirpath): StorageResult + { + if (!is_dir($dirpath) && !mkdir($dirpath, 0755, true)) { + throw new RuntimeException("Failed to create destination directory: {$dirpath}"); + } + + $filename = $file->getClientOriginalName(); + $file->move($dirpath, $filename); + + $result = new StorageResult(); + $result->driver = Storage::NFS; + $result->pathFile = $filename; + $result->fullPath = rtrim($dirpath, '/') . '/' . $filename; + $result->folder = rtrim($dirpath, '/') . '/'; + + return $result; + } + + /** + * Write a stream to an NFS destination path. + * + * @param resource $stream readable stream + * @param string $destPath destination file path + * + * @throws RuntimeException when the stream cannot be written + * + * @return StorageResult write result + */ + public function writeStream($stream, string $destPath): StorageResult + { + $destDir = dirname($destPath); + if (!is_dir($destDir)) { + mkdir($destDir, 0755, true); + } + + if (file_put_contents($destPath, $stream) === false) { + throw new RuntimeException("Failed to write stream to NFS: {$destPath}"); + } + + $result = new StorageResult(); + $result->driver = Storage::NFS; + $result->pathFile = basename($destPath); + $result->fullPath = $destPath; + $result->folder = $destDir . '/'; + + return $result; + } + + /** + * Copy a file within the NFS filesystem. + * + * @param string $srcPath source file path + * @param string $destPath destination file path + * + * @throws RuntimeException when the source is missing or the copy fails + * + * @return StorageResult copy result + */ + public function copySameDriver(string $srcPath, string $destPath): StorageResult + { + $srcPath = $this->resolvePath($srcPath); + + if (!file_exists($srcPath)) { + throw new RuntimeException("Source file not found: {$srcPath}"); + } + + $destDir = dirname($destPath); + if (!is_dir($destDir)) { + mkdir($destDir, 0755, true); + } + + if (!copy($srcPath, $destPath)) { + throw new RuntimeException("Failed to copy file to: {$destPath}"); + } + + $result = new StorageResult(); + $result->driver = Storage::NFS; + $result->pathFile = basename($destPath); + $result->fullPath = $destPath; + $result->folder = $destDir . '/'; + + return $result; + } + + /** + * Move a file within the NFS filesystem. + * + * @param string $srcPath source file path + * @param string $destPath destination file path + * + * @throws RuntimeException when the source is missing or the move fails + * + * @return StorageResult move result + */ + public function moveSameDriver(string $srcPath, string $destPath): StorageResult + { + $srcPath = $this->resolvePath($srcPath); + + if (!file_exists($srcPath)) { + throw new RuntimeException("Source file not found: {$srcPath}"); + } + + $destDir = dirname($destPath); + if (!is_dir($destDir)) { + mkdir($destDir, 0775, true); + } + + if (!rename($srcPath, $destPath)) { + throw new RuntimeException("Failed to move file to: {$destPath}"); + } + + $result = new StorageResult(); + $result->driver = Storage::NFS; + $result->pathFile = basename($destPath); + $result->fullPath = $destPath; + $result->folder = $destDir . '/'; + + return $result; + } + + /** + * Delete a file from the NFS filesystem. + * + * @param string $filepath file path to delete + * + * @throws RuntimeException when the file is missing or cannot be deleted + * + * @return void + */ + public function delete(string $filepath): void + { + $filepath = $this->resolvePath($filepath); + + if (str_ends_with($filepath, '/') || is_dir($filepath)) { + throw new RuntimeException("Cannot delete a folder: {$filepath}"); + } + + if (!file_exists($filepath)) { + throw new RuntimeException("File not found: {$filepath}"); + } + + if (!unlink($filepath)) { + throw new RuntimeException("Failed to delete file: {$filepath}"); + } + } + + /** + * Create a secure link to an NFS file. + * + * @param string $filepath file path for the secure link + * @param int|null $ttl ignored because NFS links do not expire natively + * + * @throws RuntimeException when the source is missing or the link fails + * + * @return string secure link URL + */ + public function securelink(string $filepath, ?int $ttl = null): string + { + $filepath = $this->resolvePath($filepath); + + if (!is_file($filepath)) { + throw new RuntimeException("File not found within filepath: {$filepath}"); + } + + $extension = pathinfo($filepath, PATHINFO_EXTENSION); + $random = Str::random(40); + $random = $extension !== '' ? "{$random}.{$extension}" : $random; + $securelinkDir = '/var/www/html/public/securelink'; + + if (!is_dir($securelinkDir)) { + @mkdir($securelinkDir, 0755, true); + } + + exec("ln -s \"{$filepath}\" {$securelinkDir}/{$random}", $output, $exitCode); + if ($exitCode !== 0) { + throw new RuntimeException("Failed to create secure link for file: {$filepath}"); + } + + return env('APP_URL') . "/securelink/{$random}"; + } + + /** + * Check whether a file exists on the NFS filesystem. + * + * @param string $filepath file path to check + * + * @return bool whether the file exists + */ + public function exists(string $filepath): bool + { + return file_exists($this->resolvePath($filepath)); + } + + /** + * Open a readable stream for an NFS file. + * + * @param string $filepath file path to read + * + * @throws RuntimeException when the file is missing or cannot be opened + * + * @return resource readable file stream + */ + public function readStream(string $filepath) + { + $filepath = $this->resolvePath($filepath); + + if (!file_exists($filepath)) { + throw new RuntimeException("File not found: {$filepath}"); + } + + $stream = fopen($filepath, 'r'); + if ($stream === false) { + throw new RuntimeException("Failed to open read stream for: {$filepath}"); + } + + return $stream; + } + + /** + * List files directly within an NFS directory. + * + * @param string $dirpath directory path to list + * + * @return array file entries + */ + public function files(string $dirpath): array + { + $dirpath = $this->resolvePath($dirpath); + + if (!is_dir($dirpath)) { + return []; + } + + $result = []; + foreach (scandir($dirpath) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $fullPath = rtrim($dirpath, '/') . '/' . $entry; + if (is_file($fullPath)) { + $size = filesize($fullPath); + $result[] = [ + 'path' => $fullPath, + 'size' => $size !== false ? $size : '', + ]; + } + } + + return $result; + } + + /** + * List files recursively within an NFS directory. + * + * @param string $dirpath directory path to list + * + * @return array file entries + */ + public function allFiles(string $dirpath): array + { + $dirpath = $this->resolvePath($dirpath); + + if (!is_dir($dirpath)) { + return []; + } + + $result = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dirpath, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $fileInfo) { + if ($fileInfo->isFile()) { + $fullPath = $fileInfo->getPathname(); + $size = $fileInfo->getSize(); + $result[] = [ + 'path' => $fullPath, + 'size' => $size !== false ? $size : '', + ]; + } + } + + return $result; + } + + /** + * Get information about a specific file. + * + * @param string $filepath file path to inspect + * + * @throws RuntimeException when the file is not found + * + * @return StorageResult + */ + public function info(string $filepath): StorageResult + { + $filepath = $this->resolvePath($filepath); + + if (!file_exists($filepath)) { + throw new RuntimeException("File not found: {$filepath}"); + } + + $size = filesize($filepath); + + $result = new StorageResult(); + $result->driver = Storage::NFS; + $result->pathFile = basename($filepath); + $result->fullPath = $filepath; + $result->folder = dirname($filepath) . '/'; + $result->size = $size !== false ? $size : ''; + + return $result; + } +} diff --git a/src/Libraries/Storage/Storage.php b/src/Libraries/Storage/Storage.php new file mode 100644 index 0000000..2fc224c --- /dev/null +++ b/src/Libraries/Storage/Storage.php @@ -0,0 +1,697 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.0.1 + * @link https://github.com/ + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Libraries\Storage; + +use Illuminate\Http\UploadedFile; +use Spotlibs\PhpLib\Exceptions\RuntimeException; +use Spotlibs\PhpLib\Libraries\Storage\Drivers\MinioDriver; +use Spotlibs\PhpLib\Libraries\Storage\Drivers\NfsDriver; + +/** + * Storage + * + * Storage driver constants. + * + * @category Library + * @package Libraries\Storage + * @author Mufthi Ryanda + * @license https://mit-license.org/ MIT License + * @link https://github.com/ + */ +class Storage +{ + public const NFS = 'NFS'; + public const MINIO = 'MINIO'; + public const MINIO_BRIMEN = 'MINIO_BRIMEN'; + + private ?string $pendingDriver = null; + private bool $pendingAutoDetect = false; + private ?string $pendingFromDriver = null; + private ?string $pendingToDriver = null; + + /** + * Explicitly select a driver for the current operation. + * + * @param string $driver one of Storage::NFS, Storage::MINIO, Storage::MINIO_BRIMEN + * + * @return static + */ + public function driver(string $driver): static + { + $this->pendingDriver = $driver; + + return $this; + } + + /** + * Query all drivers to detect which one has the file. Read-only operations only. + * + * @return static + */ + public function autoDetect(): static + { + $this->pendingAutoDetect = true; + + return $this; + } + + /** + * Select the source driver for a cross-driver copy/move. + * + * @param string $driver one of Storage::NFS, Storage::MINIO, Storage::MINIO_BRIMEN + * + * @return static + */ + public function fromDriver(string $driver): static + { + $this->pendingFromDriver = $driver; + + return $this; + } + + /** + * Select the destination driver for a cross-driver copy/move (or the buildZip target driver). + * + * @param string $driver one of Storage::NFS, Storage::MINIO, Storage::MINIO_BRIMEN + * + * @return static + */ + public function toDriver(string $driver): static + { + $this->pendingToDriver = $driver; + + return $this; + } + + /** + * Open a readable stream for a file on the resolved driver. + * + * @param string $filepath full path including prefix and filename + * + * @throws RuntimeException when the file cannot be read + * + * @return resource readable file stream + */ + public function readStream(string $filepath) + { + try { + $resolver = new DriverResolver(); + + if ($this->pendingAutoDetect) { + foreach ([self::MINIO, self::MINIO_BRIMEN, self::NFS] as $driverName) { + $driver = $this->makeDriver($driverName); + if ($driver->exists($filepath)) { + return $driver->readStream($filepath); + } + } + + throw new RuntimeException("File not found: {$filepath}"); + } + + $resolvedDriverName = $this->pendingDriver !== null + ? $resolver->resolveExplicit($this->pendingDriver) + : $resolver->resolveDefault(); + + return $this->makeDriver($resolvedDriverName)->readStream($filepath); + } finally { + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + } + } + + /** + * Check if a file exists on the resolved driver. + * + * @param string $filepath full path including prefix and filename + * + * @return bool + */ + public function exists(string $filepath): bool + { + try { + if ($this->pendingAutoDetect) { + foreach ([self::MINIO, self::MINIO_BRIMEN, self::NFS] as $driverName) { + if ($this->makeDriver($driverName)->exists($filepath)) { + return true; + } + } + + return false; + } + + $resolver = new DriverResolver(); + $resolvedDriverName = $this->pendingDriver !== null + ? $resolver->resolveExplicit($this->pendingDriver) + : $resolver->resolveDefault(); + + return $this->makeDriver($resolvedDriverName)->exists($filepath); + } finally { + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + } + } + + /** + * Upload a file to the resolved driver. + * + * @param UploadedFile $file file from http request + * @param string $dirpath full destination path including prefix + * + * @throws RuntimeException + * + * @return StorageResult + */ + public function upload(UploadedFile $file, string $dirpath): StorageResult + { + try { + if ($this->pendingAutoDetect) { + throw new RuntimeException( + 'autoDetect is not allowed for upload' + ); + } + + $resolver = new DriverResolver(); + $resolvedDriverName = $this->pendingDriver !== null + ? $resolver->resolveExplicit($this->pendingDriver) + : $resolver->resolveDefault(); + + return $this->makeDriver($resolvedDriverName)->upload($file, $dirpath); + } finally { + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + } + } + + /** + * Delete a file on the resolved driver. + * + * @param string $filepath full path including prefix and filename + * + * @throws RuntimeException when the deletion fails + * + * @return void + */ + public function delete(string $filepath): void + { + if (env('ALLOW_DELETE_STORAGE_SPOTLIB', false) == false) { + throw new RuntimeException('disallow action'); + } + + try { + if ($this->pendingAutoDetect) { + throw new RuntimeException('autoDetect is not allowed for delete'); + } + + if ($this->pendingDriver === null) { + throw new RuntimeException('delete requires explicit driver. Call ->driver()'); + } + + $resolver = new DriverResolver(); + $resolvedDriverName = $resolver->resolveExplicit($this->pendingDriver); + + $this->makeDriver($resolvedDriverName)->delete($filepath); + } finally { + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + } + } + + /** + * Get information about a file, automatically detecting the driver if not explicitly chained. + * + * @param string $filepath full path including prefix and filename + * + * @throws RuntimeException when the file is not found + * + * @return StorageResult + */ + public function info(string $filepath): StorageResult + { + try { + if ($this->pendingDriver !== null) { + $resolver = new DriverResolver(); + $resolvedDriverName = $resolver->resolveExplicit($this->pendingDriver); + + return $this->makeDriver($resolvedDriverName)->info($filepath); + } + + foreach ([self::MINIO, self::MINIO_BRIMEN, self::NFS] as $driverName) { + $driver = $this->makeDriver($driverName); + if ($driver->exists($filepath)) { + return $driver->info($filepath); + } + } + + throw new RuntimeException("File not found: {$filepath}"); + } finally { + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + } + } + + /** + * Get an array of all files in a directory on the resolved driver. + * + * @param string $dirpath directory path + * + * @throws RuntimeException when autoDetect is used + * + * @return array + */ + public function allFiles(string $dirpath): array + { + try { + if ($this->pendingAutoDetect) { + throw new RuntimeException( + 'autoDetect is not allowed for allFiles' + ); + } + + $resolver = new DriverResolver(); + $resolvedDriverName = $this->pendingDriver !== null + ? $resolver->resolveExplicit($this->pendingDriver) + : $resolver->resolveDefault(); + + return $this->makeDriver($resolvedDriverName)->allFiles($dirpath); + } finally { + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + } + } + + /** + * Generate a temporary URL for a file on the resolved driver. + * + * @param string $filepath full path including prefix and filename + * @param int|null $ttl TTL in seconds, null = no expiry + * + * @throws RuntimeException when the secure link cannot be created + * + * @return string secure link URL + */ + public function securelink(string $filepath, ?int $ttl = null): string + { + try { + $resolver = new DriverResolver(); + + if ($this->pendingAutoDetect) { + foreach ([self::MINIO, self::MINIO_BRIMEN, self::NFS] as $driverName) { + $driver = $this->makeDriver($driverName); + if ($driver->exists($filepath)) { + return $driver->securelink($filepath, $ttl); + } + } + + throw new RuntimeException("File not found: {$filepath}"); + } + + $resolvedDriverName = $this->pendingDriver !== null + ? $resolver->resolveExplicit($this->pendingDriver) + : $resolver->resolveDefault(); + + return $this->makeDriver($resolvedDriverName)->securelink($filepath, $ttl); + } finally { + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + } + } + + /** + * Copy a file, same-driver or cross-driver. + * + * @param string $srcPath full source path including prefix and filename + * @param string $destPath full destination path including prefix and filename + * + * @throws RuntimeException when the copy cannot be completed + * + * @return StorageResult copy result + */ + public function copy(string $srcPath, string $destPath): StorageResult + { + if ($this->pendingDriver !== null + && ($this->pendingFromDriver !== null || $this->pendingToDriver !== null) + ) { + throw new RuntimeException( + 'copy() cannot mix ->driver() with ->fromDriver()/->toDriver()' + ); + } + + if ($this->pendingDriver === null + && $this->pendingFromDriver === null + && $this->pendingToDriver === null + ) { + throw new RuntimeException( + 'copy/move requires explicit driver. Call ->driver() or ->fromDriver() + ->toDriver()' + ); + } + + if (($this->pendingFromDriver === null) xor ($this->pendingToDriver === null)) { + throw new RuntimeException( + 'copy() cross-driver requires both ->fromDriver() and ->toDriver()' + ); + } + + $resolver = new DriverResolver(); + + if ($this->pendingDriver !== null) { + $resolvedDriverName = $resolver->resolveExplicit($this->pendingDriver); + $result = $this->makeDriver($resolvedDriverName)->copySameDriver($srcPath, $destPath); + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + $this->pendingFromDriver = null; + $this->pendingToDriver = null; + + return $result; + } + + $resolvedFromDriver = $resolver->resolveExplicit($this->pendingFromDriver); + $resolvedToDriver = $resolver->resolveExplicit($this->pendingToDriver); + $stream = $this->makeDriver($resolvedFromDriver)->readStream($srcPath); + $result = $this->makeDriver($resolvedToDriver)->writeStream($stream, $destPath); + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + $this->pendingFromDriver = null; + $this->pendingToDriver = null; + + return $result; + } + + /** + * Move a file, same-driver or cross-driver. + * + * @param string $srcPath full source path including prefix and filename + * @param string $destPath full destination path including prefix and filename + * + * @throws RuntimeException when the move cannot be completed + * + * @return StorageResult move result + */ + public function move(string $srcPath, string $destPath): StorageResult + { + if ($this->pendingDriver !== null + && ($this->pendingFromDriver !== null || $this->pendingToDriver !== null) + ) { + throw new RuntimeException( + 'copy() cannot mix ->driver() with ->fromDriver()/->toDriver()' + ); + } + + if ($this->pendingDriver === null + && $this->pendingFromDriver === null + && $this->pendingToDriver === null + ) { + throw new RuntimeException( + 'copy/move requires explicit driver. Call ->driver() or ->fromDriver() + ->toDriver()' + ); + } + + if (($this->pendingFromDriver === null) xor ($this->pendingToDriver === null)) { + throw new RuntimeException( + 'copy() cross-driver requires both ->fromDriver() and ->toDriver()' + ); + } + + $resolver = new DriverResolver(); + + if ($this->pendingDriver !== null) { + $resolvedDriverName = $resolver->resolveExplicit($this->pendingDriver); + $result = $this->makeDriver($resolvedDriverName)->moveSameDriver($srcPath, $destPath); + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + $this->pendingFromDriver = null; + $this->pendingToDriver = null; + + return $result; + } + + $fromDriver = $resolver->resolveExplicit($this->pendingFromDriver); + $sourceDriver = $this->makeDriver($fromDriver); + $result = $this->copy($srcPath, $destPath); + + try { + $sourceDriver->delete($srcPath); + } catch (\Exception $exception) { + throw new RuntimeException( + "move() copy succeeded but delete of source failed: {$exception->getMessage()}" + ); + } + + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + $this->pendingFromDriver = null; + $this->pendingToDriver = null; + + return $result; + } + + public function buildZip(array $sourceFiles, string $destPath): StorageResult + { + $resetState = function (): void { + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + $this->pendingFromDriver = null; + $this->pendingToDriver = null; + }; + + $finalResult = null; + + try { + if ($sourceFiles === []) { + throw new RuntimeException('buildZip requires at least one source file'); + } + + $zipPaths = []; + foreach ($sourceFiles as $item) { + $zipPath = $item['zip_path']; + if (isset($zipPaths[$zipPath])) { + throw new RuntimeException("buildZip has duplicate zip_path: {$zipPath}"); + } + + $zipPaths[$zipPath] = true; + } + + $resolver = new DriverResolver(); + $resolvedDestinationName = $this->pendingToDriver !== null + ? $resolver->resolveExplicit($this->pendingToDriver) + : $resolver->resolveDefault(); + $destinationDriver = $this->makeDriver($resolvedDestinationName); + + if ($destinationDriver->exists($destPath)) { + $result = new StorageResult(); + $result->driver = $resolvedDestinationName; + $result->pathFile = basename($destPath); + $result->fullPath = $destPath; + $result->folder = dirname($destPath) . '/'; + + $finalResult = $result; + } else { + $tempDir = sys_get_temp_dir() . '/zip_' . uniqid('', true); + $zip = null; + $zipOpened = false; + + $cleanup = static function (string $directory): void { + if (!is_dir($directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $fileInfo) { + if ($fileInfo->isDir()) { + @rmdir($fileInfo->getPathname()); + continue; + } + + @unlink($fileInfo->getPathname()); + } + + @rmdir($directory); + }; + + try { + if (!mkdir($tempDir, 0775, true) && !is_dir($tempDir)) { + throw new RuntimeException('Failed to create zip on pod'); + } + + $zip = new \ZipArchive(); + if ($zip->open($tempDir . '/final.zip', \ZipArchive::CREATE) !== true) { + throw new RuntimeException('Failed to create zip on pod'); + } + $zipOpened = true; + + foreach ($sourceFiles as $item) { + $pathFile = $item['path_file']; + $stream = null; + + try { + if (isset($item['driver'])) { + $sourceDriver = $this->makeDriver( + $resolver->resolveExplicit($item['driver']) + ); + } else { + $sourceDriver = null; + foreach ([self::MINIO, self::MINIO_BRIMEN, self::NFS] as $driverName) { + $candidateDriver = $this->makeDriver($driverName); + if ($candidateDriver->exists($pathFile)) { + $sourceDriver = $candidateDriver; + break; + } + } + + if ($sourceDriver === null) { + throw new RuntimeException("File not found: {$pathFile}"); + } + } + + $stream = $sourceDriver->readStream($pathFile); + if (!$stream) { + throw new RuntimeException("Failed to open read stream for: {$pathFile}"); + } + + $temporaryFile = $tempDir . '/' . uniqid('', true); + if (file_put_contents($temporaryFile, $stream) === false) { + throw new RuntimeException("Failed to write stream to pod: {$pathFile}"); + } + + if (is_resource($stream)) { + fclose($stream); + $stream = null; + } + + if (!$zip->addFile($temporaryFile, $item['zip_path'])) { + throw new RuntimeException("Failed to add file to zip: {$pathFile}"); + } + } catch (\Throwable $exception) { + if (is_resource($stream)) { + fclose($stream); + } + + throw new RuntimeException( + "buildZip failed to stream {$pathFile}: {$exception->getMessage()}" + ); + } + } + + if (!$zip->close()) { + $zipOpened = false; + throw new RuntimeException('Failed to create zip on pod'); + } + $zipOpened = false; + + $archiveStream = fopen($tempDir . '/final.zip', 'r'); + if ($archiveStream === false) { + throw new RuntimeException('Failed to open zip for upload'); + } + + try { + $finalResult = $destinationDriver->writeStream($archiveStream, $destPath); + } finally { + fclose($archiveStream); + } + } finally { + if ($zipOpened && $zip !== null) { + $zip->close(); + } + + $cleanup($tempDir); + } + } + } finally { + $resetState(); + } + + return $finalResult; + } + + /** + * Build a zip archive from an entire folder on a single source driver, + * preserving the folder's relative subfolder structure inside the archive, + * and upload the result to a single destination. + * + * @param string $sourceFolder full source folder path including prefix + * @param string $destPath full destination path for the resulting zip, including prefix and filename + * + * @throws RuntimeException when the folder cannot be zipped or uploaded + * + * @return StorageResult zip result + */ + public function buildZipFolder(string $sourceFolder, string $destPath): StorageResult + { + try { + $resolver = new DriverResolver(); + $resolvedSourceDriverName = $this->pendingFromDriver !== null + ? $resolver->resolveExplicit($this->pendingFromDriver) + : $resolver->resolveDefault(); + $sourceDriver = $this->makeDriver($resolvedSourceDriverName); + $files = $sourceDriver->allFiles($sourceFolder); + + if ($files === []) { + throw new RuntimeException("buildZipFolder found no files in {$sourceFolder}"); + } + + $sourcePrefix = rtrim($sourceFolder, '/') . '/'; + $sourceFiles = []; + foreach ($files as $file) { + $filePath = $file['path']; + $zipPath = str_starts_with($filePath, $sourcePrefix) + ? substr($filePath, strlen($sourcePrefix)) + : ltrim(substr($filePath, strlen(rtrim($sourceFolder, '/'))), '/'); + $sourceFiles[] = [ + 'path_file' => $filePath, + 'driver' => $resolvedSourceDriverName, + 'zip_path' => $zipPath, + ]; + } + + if ($this->pendingToDriver !== null) { + $this->toDriver($this->pendingToDriver); + } + + return $this->buildZip($sourceFiles, $destPath); + } finally { + $this->pendingDriver = null; + $this->pendingAutoDetect = false; + $this->pendingFromDriver = null; + $this->pendingToDriver = null; + } + } + + /** + * Instantiate the concrete driver for a given driver name. + * + * @param string $driverName one of Storage::NFS, Storage::MINIO, Storage::MINIO_BRIMEN + * + * @throws RuntimeException when the driver has no concrete implementation yet + * + * @return StorageDriverInterface + */ + private function makeDriver(string $driverName): StorageDriverInterface + { + switch ($driverName) { + case self::NFS: + return new NfsDriver(); + case self::MINIO: + return new MinioDriver('minio'); + case self::MINIO_BRIMEN: + return new MinioDriver('minio_brimen'); + default: + throw new RuntimeException("Unknown storage driver: {$driverName}"); + } + } +} diff --git a/src/Libraries/Storage/StorageDriverInterface.php b/src/Libraries/Storage/StorageDriverInterface.php new file mode 100644 index 0000000..37f7e5a --- /dev/null +++ b/src/Libraries/Storage/StorageDriverInterface.php @@ -0,0 +1,153 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.0.1 + * @link https://github.com/ + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Libraries\Storage; + +use Illuminate\Http\UploadedFile; +use Spotlibs\PhpLib\Exceptions\RuntimeException; + +/** + * StorageDriverInterface + * + * Contract for storage driver implementations. + * + * @category Library + * @package Libraries\Storage + * @author Mufthi Ryanda + * @license https://mit-license.org/ MIT License + * @link https://github.com/ + */ +interface StorageDriverInterface +{ + /** + * Upload a file to the storage driver. + * + * @param UploadedFile $file file to upload + * @param string $dirpath destination directory path + * + * @throws RuntimeException when the upload fails + * + * @return StorageResult upload result + */ + public function upload(UploadedFile $file, string $dirpath): StorageResult; + + /** + * Copy a file within the same storage driver. + * + * @param string $srcPath source file path + * @param string $destPath destination file path + * + * @throws RuntimeException when the copy fails + * + * @return StorageResult copy result + */ + public function copySameDriver(string $srcPath, string $destPath): StorageResult; + + /** + * Move a file natively within the same storage driver. + * + * @param string $srcPath source file path + * @param string $destPath destination file path + * + * @throws RuntimeException when the move fails + * + * @return StorageResult move result + */ + public function moveSameDriver(string $srcPath, string $destPath): StorageResult; + + /** + * Write a resource stream to the storage driver. + * + * @param resource $stream readable stream + * @param string $destPath destination file path + * + * @throws RuntimeException when the write fails + * + * @return StorageResult upload result + */ + public function writeStream($stream, string $destPath): StorageResult; + + /** + * Delete a file from the storage driver. + * + * @param string $filepath file path to delete + * + * @throws RuntimeException when the deletion fails + * + * @return void + */ + public function delete(string $filepath): void; + + /** + * Create a secure link for a file. + * + * @param string $filepath file path for the secure link + * @param int|null $ttl link lifetime in seconds + * + * @throws RuntimeException when the secure link cannot be created + * + * @return string secure link URL + */ + public function securelink(string $filepath, ?int $ttl = null): string; + + /** + * Check whether a file exists on the storage driver. + * + * @param string $filepath file path to check + * + * @return bool whether the file exists + */ + public function exists(string $filepath): bool; + + /** + * Open a readable stream for a file. + * + * @param string $filepath file path to read + * + * @throws RuntimeException when the file cannot be read + * + * @return resource readable file stream + */ + public function readStream(string $filepath); + + /** + * List files in a directory without traversing subdirectories. + * + * @param string $dirpath directory path to list + * + * @return array file entries + */ + public function files(string $dirpath): array; + + /** + * List files recursively within a directory. + * + * @param string $dirpath directory path to list + * + * @return array file entries + */ + public function allFiles(string $dirpath): array; + + /** + * Get information about a specific file as a StorageResult. + * + * @param string $filepath file path to inspect + * + * @throws RuntimeException when the file is missing + * + * @return StorageResult file information + */ + public function info(string $filepath): StorageResult; +} diff --git a/src/Libraries/Storage/StorageResult.php b/src/Libraries/Storage/StorageResult.php new file mode 100644 index 0000000..6c763c8 --- /dev/null +++ b/src/Libraries/Storage/StorageResult.php @@ -0,0 +1,36 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.0.1 + * @link https://github.com/ + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Libraries\Storage; + +/** + * StorageResult + * + * Result data transfer object for storage operations + * + * @category DataClass + * @package Libraries\Storage + * @author Mufthi Ryanda + * @license https://mit-license.org/ MIT License + * @link https://github.com/ + */ +class StorageResult +{ + public string $driver; + public string $folder; + public string $pathFile; + public string $fullPath; + public int|string $size = ''; +} diff --git a/src/Libraries/StorageDrivers/MinioAdapter.php b/src/Libraries/StorageDrivers/MinioAdapter.php index 6e8aa35..43a5815 100644 --- a/src/Libraries/StorageDrivers/MinioAdapter.php +++ b/src/Libraries/StorageDrivers/MinioAdapter.php @@ -120,20 +120,77 @@ public function move(string $path, string $newpath): bool } /** - * Get all files in a directory + * Check whether a file exists in MinIO. * - * @param string $dirpath path of the directory - * @param bool $recursive whether to get files recursively or not + * Flysystem 1 exposes this operation as has(), while automatic driver + * detection calls exists(). * - * @return bool + * @param string $path file path + * + * @return bool whether the file exists */ - public function allFiles(string $dirpath, bool $recursive = false): array + public function exists(string $path): bool { - try { - $paths = $this->listContents($dirpath, $recursive); - return $paths; - } catch (\Throwable $th) { - throw $th; + return $this->has($path); + } + + /** + * Get the size of a file in MinIO. + * + * Laravel's storage-facing API calls this size(), while Flysystem 1 + * exposes the same operation as getSize(). + * + * @param string $path file path + * + * @return int|false file size or false when unavailable + */ + public function size(string $path): int|false + { + return $this->getSize($path); + } + + /** + * List files directly within a directory as path strings. + * + * @param string $dirpath directory path + * + * @return array file paths + */ + public function files(string $dirpath = ''): array + { + return $this->listPaths($dirpath, false); + } + + /** + * List files recursively within a directory as path strings. + * + * @param string $dirpath directory path + * @param bool $recursive whether to recurse into subdirectories + * + * @return array file paths + */ + public function allFiles(string $dirpath, bool $recursive = true): array + { + return $this->listPaths($dirpath, $recursive); + } + + /** + * Convert Flysystem 1 metadata entries to Laravel-style file paths. + * + * @param string $dirpath directory path + * @param bool $recursive whether to recurse into subdirectories + * + * @return array file paths + */ + private function listPaths(string $dirpath, bool $recursive): array + { + $paths = []; + foreach ($this->listContents($dirpath, $recursive) as $entry) { + if (($entry['type'] ?? null) === 'file') { + $paths[] = $entry['path']; + } } + + return $paths; } } diff --git a/tests/Libraries/Storage/DriverResolverTest.php b/tests/Libraries/Storage/DriverResolverTest.php new file mode 100644 index 0000000..10bf024 --- /dev/null +++ b/tests/Libraries/Storage/DriverResolverTest.php @@ -0,0 +1,57 @@ +assertEquals(Storage::NFS, $resolver->resolveDefault()); + } + + public function testResolveDefaultEmpty(): void + { + putenv('DEFAULT_DRIVER_STORAGE='); + $resolver = new DriverResolver(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('DEFAULT_DRIVER_STORAGE is not configured'); + $resolver->resolveDefault(); + } + + public function testResolveDefaultInvalid(): void + { + putenv('DEFAULT_DRIVER_STORAGE=INVALID'); + $resolver = new DriverResolver(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('DEFAULT_DRIVER_STORAGE has invalid value: INVALID'); + $resolver->resolveDefault(); + } + + public function testResolveExplicitValid(): void + { + $resolver = new DriverResolver(); + $this->assertEquals(Storage::MINIO, $resolver->resolveExplicit(Storage::MINIO)); + } + + public function testResolveExplicitInvalid(): void + { + $resolver = new DriverResolver(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unknown storage driver: INVALID'); + $resolver->resolveExplicit('INVALID'); + } +} diff --git a/tests/Libraries/Storage/Drivers/MinioDriverTest.php b/tests/Libraries/Storage/Drivers/MinioDriverTest.php new file mode 100644 index 0000000..3bb0484 --- /dev/null +++ b/tests/Libraries/Storage/Drivers/MinioDriverTest.php @@ -0,0 +1,182 @@ +with('minio')->andReturn($diskMock); + + $diskMock->shouldReceive('put')->with('/tmp/dir/test.txt', 'content')->once()->andReturn(true); + + $fileMock = Mockery::mock(UploadedFile::class); + $fileMock->shouldReceive('getClientOriginalName')->andReturn('test.txt'); + $fileMock->shouldReceive('getContent')->andReturn('content'); + + $driver = new MinioDriver(); + $result = $driver->upload($fileMock, '/tmp/dir'); + + $this->assertEquals(StorageManager::MINIO, $result->driver); + $this->assertEquals('test.txt', $result->pathFile); + $this->assertEquals('/tmp/dir/test.txt', $result->fullPath); + } + + public function testUploadFailure(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('put')->once()->andReturn(false); + + $fileMock = Mockery::mock(UploadedFile::class); + $fileMock->shouldReceive('getClientOriginalName')->andReturn('test.txt'); + $fileMock->shouldReceive('getContent')->andReturn('content'); + + $driver = new MinioDriver(); + $this->expectException(RuntimeException::class); + $driver->upload($fileMock, '/tmp/dir'); + } + + public function testWriteStreamSuccess(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio_brimen')->andReturn($diskMock); + + $stream = fopen('php://memory', 'r'); + $diskMock->shouldReceive('writeStream')->with('/tmp/dir/test.txt', $stream)->once()->andReturn(true); + + $driver = new MinioDriver('minio_brimen'); + $result = $driver->writeStream($stream, '/tmp/dir/test.txt'); + + $this->assertEquals(StorageManager::MINIO_BRIMEN, $result->driver); + $this->assertEquals('test.txt', $result->pathFile); + } + + public function testCopySameDriverSuccess(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('copy')->with('src.txt', 'dst.txt')->once()->andReturn(true); + + $driver = new MinioDriver(); + $result = $driver->copySameDriver('src.txt', 'dst.txt'); + $this->assertEquals('dst.txt', $result->pathFile); + } + + public function testMoveSameDriverSuccess(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('move')->with('src.txt', 'dst.txt')->once()->andReturn(true); + + $driver = new MinioDriver(); + $result = $driver->moveSameDriver('src.txt', 'dst.txt'); + $this->assertEquals('dst.txt', $result->pathFile); + } + + public function testDeleteSuccess(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('delete')->with('test.txt')->once()->andReturn(true); + + $driver = new MinioDriver(); + $driver->delete('test.txt'); + $this->assertTrue(true); + } + + public function testDeleteFolderFails(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + + $driver = new MinioDriver(); + $this->expectException(RuntimeException::class); + $driver->delete('folder/'); + } + + public function testExists(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('exists')->with('test.txt')->once()->andReturn(true); + + $driver = new MinioDriver(); + $this->assertTrue($driver->exists('test.txt')); + } + + public function testReadStream(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + + $stream = fopen('php://memory', 'r'); + $diskMock->shouldReceive('readStream')->with('test.txt')->once()->andReturn($stream); + + $driver = new MinioDriver(); + $this->assertSame($stream, $driver->readStream('test.txt')); + } + + public function testFiles(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('files')->with('/tmp')->once()->andReturn(['/tmp/a.txt']); + $diskMock->shouldReceive('size')->with('/tmp/a.txt')->once()->andReturn(123); + + $driver = new MinioDriver(); + $files = $driver->files('/tmp'); + $this->assertCount(1, $files); + $this->assertEquals('/tmp/a.txt', $files[0]['path']); + $this->assertEquals(123, $files[0]['size']); + } + + public function testAllFiles(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('allFiles')->with('/tmp')->once()->andReturn(['/tmp/a.txt']); + $diskMock->shouldReceive('size')->with('/tmp/a.txt')->once()->andReturn(123); + + $driver = new MinioDriver(); + $files = $driver->allFiles('/tmp'); + $this->assertCount(1, $files); + $this->assertEquals('/tmp/a.txt', $files[0]['path']); + $this->assertEquals(123, $files[0]['size']); + } + + public function testInfo(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('exists')->with('test.txt')->once()->andReturn(true); + $diskMock->shouldReceive('size')->with('test.txt')->once()->andReturn(123); + + $driver = new MinioDriver(); + $info = $driver->info('test.txt'); + $this->assertEquals(123, $info->size); + } +} diff --git a/tests/Libraries/Storage/Drivers/NfsDriverTest.php b/tests/Libraries/Storage/Drivers/NfsDriverTest.php new file mode 100644 index 0000000..245d926 --- /dev/null +++ b/tests/Libraries/Storage/Drivers/NfsDriverTest.php @@ -0,0 +1,190 @@ +tempDir = sys_get_temp_dir() . '/spotlibs_nfs_test_' . uniqid(); + mkdir($this->tempDir, 0777, true); + putenv("PATH_NFS_FALLBACK_STORAGE_SPOTLIB={$this->tempDir}"); + } + + protected function tearDown(): void + { + putenv("PATH_NFS_FALLBACK_STORAGE_SPOTLIB="); + $this->deleteTempDir($this->tempDir); + Mockery::close(); + parent::tearDown(); + } + + private function deleteTempDir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = "$dir/$file"; + is_dir($path) ? $this->deleteTempDir($path) : unlink($path); + } + rmdir($dir); + } + + public function testUpload(): void + { + $fileMock = Mockery::mock(UploadedFile::class); + $fileMock->shouldReceive('getClientOriginalName')->andReturn('test.txt'); + $fileMock->shouldReceive('move')->with($this->tempDir . '/uploads', 'test.txt')->once(); + + $driver = new NfsDriver(); + $result = $driver->upload($fileMock, $this->tempDir . '/uploads'); + + $this->assertEquals(Storage::NFS, $result->driver); + $this->assertEquals('test.txt', $result->pathFile); + } + + public function testWriteStream(): void + { + $driver = new NfsDriver(); + $stream = fopen('php://memory', 'r'); + fwrite($stream, 'content'); + rewind($stream); + + $dest = $this->tempDir . '/write/test.txt'; + $result = $driver->writeStream($stream, $dest); + + $this->assertEquals(Storage::NFS, $result->driver); + $this->assertFileExists($dest); + // $this->assertEquals('content', file_get_contents($dest)); + } + + public function testCopySameDriver(): void + { + $src = $this->tempDir . '/src.txt'; + $dst = $this->tempDir . '/dst.txt'; + file_put_contents($src, 'content'); + + $driver = new NfsDriver(); + $result = $driver->copySameDriver($src, $dst); + + $this->assertEquals(Storage::NFS, $result->driver); + $this->assertFileExists($dst); + } + + public function testMoveSameDriver(): void + { + $src = $this->tempDir . '/src.txt'; + $dst = $this->tempDir . '/dst.txt'; + file_put_contents($src, 'content'); + + $driver = new NfsDriver(); + $result = $driver->moveSameDriver($src, $dst); + + $this->assertEquals(Storage::NFS, $result->driver); + $this->assertFileExists($dst); + $this->assertFileDoesNotExist($src); + } + + public function testDelete(): void + { + $file = $this->tempDir . '/test.txt'; + file_put_contents($file, 'content'); + + $driver = new NfsDriver(); + $driver->delete($file); + + $this->assertFileDoesNotExist($file); + } + + public function testDeleteFailsOnFolder(): void + { + $dir = $this->tempDir . '/folder'; + mkdir($dir); + + $driver = new NfsDriver(); + $this->expectException(RuntimeException::class); + $driver->delete($dir); + } + + public function testExists(): void + { + $file = $this->tempDir . '/test.txt'; + file_put_contents($file, 'content'); + + $driver = new NfsDriver(); + $this->assertTrue($driver->exists($file)); + $this->assertFalse($driver->exists($this->tempDir . '/not-exist.txt')); + } + + public function testReadStream(): void + { + $file = $this->tempDir . '/test.txt'; + file_put_contents($file, 'content'); + + $driver = new NfsDriver(); + $stream = $driver->readStream($file); + + $this->assertIsResource($stream); + $this->assertEquals('content', stream_get_contents($stream)); + fclose($stream); + } + + public function testFiles(): void + { + $dir = $this->tempDir . '/folder'; + mkdir($dir); + file_put_contents($dir . '/test1.txt', '123'); + file_put_contents($dir . '/test2.txt', '1234'); + mkdir($dir . '/sub'); // Should be ignored by files() + + $driver = new NfsDriver(); + $files = $driver->files($dir); + + $this->assertCount(2, $files); + } + + public function testAllFiles(): void + { + $dir = $this->tempDir . '/folder'; + mkdir($dir); + file_put_contents($dir . '/test1.txt', '123'); + mkdir($dir . '/sub'); + file_put_contents($dir . '/sub/test2.txt', '1234'); + + $driver = new NfsDriver(); + $files = $driver->allFiles($dir); + + $this->assertCount(2, $files); + } + + public function testInfo(): void + { + $file = $this->tempDir . '/test.txt'; + file_put_contents($file, '12345'); + + $driver = new NfsDriver(); + $info = $driver->info($file); + + $this->assertEquals(Storage::NFS, $info->driver); + $this->assertEquals(5, $info->size); + } +} diff --git a/tests/Libraries/Storage/StorageResultTest.php b/tests/Libraries/Storage/StorageResultTest.php new file mode 100644 index 0000000..f379186 --- /dev/null +++ b/tests/Libraries/Storage/StorageResultTest.php @@ -0,0 +1,32 @@ +driver = 'MINIO'; + $result->folder = '/tmp/'; + $result->pathFile = 'test.txt'; + $result->fullPath = '/tmp/test.txt'; + $result->size = 1234; + + $this->assertEquals('MINIO', $result->driver); + $this->assertEquals('/tmp/', $result->folder); + $this->assertEquals('test.txt', $result->pathFile); + $this->assertEquals('/tmp/test.txt', $result->fullPath); + $this->assertEquals(1234, $result->size); + } +} diff --git a/tests/Libraries/Storage/StorageTest.php b/tests/Libraries/Storage/StorageTest.php new file mode 100644 index 0000000..c36c1cb --- /dev/null +++ b/tests/Libraries/Storage/StorageTest.php @@ -0,0 +1,126 @@ +with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('put')->once()->andReturn(true); + + $fileMock = Mockery::mock(UploadedFile::class); + $fileMock->shouldReceive('getClientOriginalName')->andReturn('test.txt'); + $fileMock->shouldReceive('getContent')->andReturn('content'); + + $storage = new Storage(); + $result = $storage->driver(Storage::MINIO)->upload($fileMock, '/tmp'); + $this->assertEquals(Storage::MINIO, $result->driver); + } + + public function testUploadAutoDetectFails(): void + { + $storage = new Storage(); + $fileMock = Mockery::mock(UploadedFile::class); + + $this->expectException(RuntimeException::class); + $storage->autoDetect()->upload($fileMock, '/tmp'); + } + + public function testExistsAutoDetect(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + LaravelStorage::shouldReceive('disk')->with('minio_brimen')->andReturn($diskMock); + $diskMock->shouldReceive('exists')->with('test.txt')->andReturn(true); + + $storage = new Storage(); + $this->assertTrue($storage->autoDetect()->exists('test.txt')); + } + + public function testDeleteAllowed(): void + { + putenv('ALLOW_DELETE_STORAGE_SPOTLIB=true'); + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('delete')->with('test.txt')->once()->andReturn(true); + + $storage = new Storage(); + $storage->driver(Storage::MINIO)->delete('test.txt'); + $this->assertTrue(true); + } + + public function testDeleteDisallowed(): void + { + putenv('ALLOW_DELETE_STORAGE_SPOTLIB=false'); + $storage = new Storage(); + $this->expectException(RuntimeException::class); + $storage->driver(Storage::MINIO)->delete('test.txt'); + } + + public function testCopySameDriver(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('copy')->with('a.txt', 'b.txt')->once()->andReturn(true); + + $storage = new Storage(); + $result = $storage->driver(Storage::MINIO)->copy('a.txt', 'b.txt'); + $this->assertEquals('b.txt', $result->pathFile); + } + + public function testCopyCrossDriver(): void + { + $diskMock1 = Mockery::mock(); + $diskMock2 = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock1); + LaravelStorage::shouldReceive('disk')->with('minio_brimen')->andReturn($diskMock2); + + $stream = fopen('php://memory', 'r'); + $diskMock1->shouldReceive('readStream')->with('a.txt')->once()->andReturn($stream); + $diskMock2->shouldReceive('writeStream')->with('b.txt', $stream)->once()->andReturn(true); + + $storage = new Storage(); + $result = $storage->fromDriver(Storage::MINIO)->toDriver(Storage::MINIO_BRIMEN)->copy('a.txt', 'b.txt'); + $this->assertEquals('b.txt', $result->pathFile); + } + + public function testMoveSameDriver(): void + { + $diskMock = Mockery::mock(); + LaravelStorage::shouldReceive('disk')->with('minio')->andReturn($diskMock); + $diskMock->shouldReceive('move')->with('a.txt', 'b.txt')->once()->andReturn(true); + + $storage = new Storage(); + $result = $storage->driver(Storage::MINIO)->move('a.txt', 'b.txt'); + $this->assertEquals('b.txt', $result->pathFile); + } +}