diff --git a/README.md b/README.md index 709ab95..f4930c3 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ Pathwise is a robust PHP library designed as streamlined file and directory mana - [PathHelper](#pathhelper) - [PermissionsHelper](#permissionshelper) - [MetadataHelper](#metadatahelper) -9. [Handy Functions](#handy-functions) +9. [Storage Adapter Setup](#storage-adapter-setup) +10. [Handy Functions](#handy-functions) - [File and Directory Utilities](#file-and-directory-utilities) -10. [Support](#support) -11. [License](#license) +11. [Support](#support) +12. [License](#license) ## **Prerequisites** - Language: PHP 8.4/+ @@ -57,12 +58,85 @@ Requirements: - Filesystem operations across core modules. - Mount support with scheme paths (`name://path`) and default filesystem support for relative paths. +- Config-driven storage bootstrap via `StorageFactory` for local/custom/adapter-based filesystems. - Advanced file APIs: checksum verification, visibility controls, URL passthrough (`publicUrl`, `temporaryUrl`). - Directory automation: sync with diff report, recursive copy/move/delete, mounted-path ZIP/unzip bridging. -- Upload pipelines: chunked/resumable uploads, validation profiles (image/video/document), extension allow/deny controls, strict MIME/signature checks, upload-id safety validation, malware-scan hook. +- Upload/download pipelines: chunked/resumable uploads, validation profiles (image/video/document), extension allow/deny controls, strict MIME/signature checks, upload-id safety validation, malware-scan hook, secure download metadata + range handling. - Compression workflows: include/exclude glob patterns, ignore files, progress callbacks, hooks, optional native acceleration. - Operational tooling: `AuditTrail`, `FileJobQueue`, `FileWatcher`, `RetentionManager` and policy engine support. +## **Storage Adapter Setup** + +Pathwise supports any Flysystem adapter. You can mount storages through `StorageFactory` and use them with all modules (`UploadProcessor`, `DownloadProcessor`, `FileOperations`, etc.). + +`StorageFactory` supports: +- `['driver' => 'local', 'root' => '/path']` +- `['driver' => 'aws-s3', 'adapter' => $adapter]` +- `['driver' => 'aws-s3', 'constructor' => [...]]` +- `['filesystem' => $filesystemOperator]` +- custom drivers via `StorageFactory::registerDriver()` + +Official adapter driver keys covered: +- `local`, `ftp`, `inmemory` (`in-memory`) +- `read-only`, `path-prefixing` +- `aws-s3` (`s3`), `async-aws-s3` +- `azure-blob-storage`, `google-cloud-storage`, `mongodb-gridfs` +- `sftp-v2`, `sftp-v3`, `webdav`, `ziparchive` + +### **Local Driver** + +```php +use Infocyph\Pathwise\Storage\StorageFactory; +use Infocyph\Pathwise\Utils\FlysystemHelper; + +StorageFactory::mount('assets', [ + 'driver' => 'local', + 'root' => '/srv/storage/assets', +]); + +FlysystemHelper::write('assets://reports/a.txt', 'hello'); +``` + +### **Any Adapter (Example: S3)** + +```php +use Aws\S3\S3Client; +use Infocyph\Pathwise\Storage\StorageFactory; +use League\Flysystem\AwsS3V3\AwsS3V3Adapter; + +$client = new S3Client([ + 'version' => 'latest', + 'region' => 'us-east-1', + 'credentials' => [ + 'key' => getenv('AWS_ACCESS_KEY_ID'), + 'secret' => getenv('AWS_SECRET_ACCESS_KEY'), + ], +]); + +$adapter = new AwsS3V3Adapter($client, 'my-bucket', 'app-prefix'); + +StorageFactory::mount('s3', ['adapter' => $adapter]); +// Use s3://... paths in processors and managers. +``` + +### **Custom Driver Registration** + +```php +use Infocyph\Pathwise\Storage\StorageFactory; +use League\Flysystem\Filesystem; +use League\Flysystem\Local\LocalFilesystemAdapter; + +StorageFactory::registerDriver('tenant-local', function (array $config): Filesystem { + $tenant = (string) ($config['tenant'] ?? 'default'); + return new Filesystem(new LocalFilesystemAdapter('/srv/tenants/' . $tenant)); +}); + +StorageFactory::mount('tenant', [ + 'driver' => 'tenant-local', + 'tenant' => 'acme', +]); +``` + ## **FileManager** The `FileManager` module provides classes for handling files, including reading, writing, compressing and general file operations. diff --git a/composer.json b/composer.json index aeb47e3..2de240e 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,20 @@ "ext-pcntl": "required if you want to use long-running watch loops.", "ext-posix": "required if you want to use permissions.", "ext-xmlreader": "required if you want to use XML parsing.", - "ext-simplexml": "required if you want to use XML parsing." + "ext-simplexml": "required if you want to use XML parsing.", + "league/flysystem-aws-s3-v3": "required for AWS S3 adapter support.", + "league/flysystem-async-aws-s3": "required for AsyncAWS S3 adapter support.", + "league/flysystem-azure-blob-storage": "required for Azure Blob Storage adapter support.", + "league/flysystem-google-cloud-storage": "required for Google Cloud Storage adapter support.", + "league/flysystem-gridfs": "required for MongoDB GridFS adapter support.", + "league/flysystem-sftp-v3": "required for SFTP (v3) adapter support.", + "league/flysystem-sftp-v2": "required for SFTP (v2) adapter support.", + "league/flysystem-ftp": "required for FTP adapter support.", + "league/flysystem-webdav": "required for WebDAV adapter support.", + "league/flysystem-ziparchive": "required for ZipArchive adapter support.", + "league/flysystem-memory": "required for in-memory adapter support.", + "league/flysystem-read-only": "required for read-only adapter wrapper support.", + "league/flysystem-path-prefixing": "required for path-prefixing adapter wrapper support." }, "require-dev": { "captainhook/captainhook": "^5.29.2", diff --git a/docs/capabilities.rst b/docs/capabilities.rst index 1a4d6b8..e031cc1 100644 --- a/docs/capabilities.rst +++ b/docs/capabilities.rst @@ -61,6 +61,22 @@ What you get: * Strict content checks (MIME-extension agreement and file signature checks). * Optional or required malware scan callback. +Downloads (``Infocyph\Pathwise\StreamHandler``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Class: + +* ``DownloadProcessor`` + +What you get: + +* Secure download metadata generation for HTTP adapters. +* Extension allowlist/blocklist controls. +* Allowed-root restriction to prevent path breakout. +* Hidden-file blocking and max-size limits. +* Optional range request handling and partial-download metadata. +* Stream copy into caller-provided output resources. + Security and Operations ^^^^^^^^^^^^^^^^^^^^^^^ @@ -84,6 +100,9 @@ Pathwise accepts: Mounting is done through ``FlysystemHelper::mount()``. Once mounted, most high-level modules can use the scheme path directly. +For config-driven adapter bootstrap, use +``Infocyph\Pathwise\Storage\StorageFactory`` (see ``storage-adapters``). + Runtime and Extensions ---------------------- diff --git a/docs/download-processing.rst b/docs/download-processing.rst new file mode 100644 index 0000000..7c34b60 --- /dev/null +++ b/docs/download-processing.rst @@ -0,0 +1,85 @@ +Download Processing +=================== + +Namespace: ``Infocyph\Pathwise\StreamHandler`` + +Where it fits: + +* Use this module when you need secure download metadata and controlled stream + delivery for local or mounted filesystems. + +``DownloadProcessor`` supports: + +* Download metadata generation with headers suitable for HTTP adapters. +* Safe download filename handling for ``Content-Disposition``. +* Extension allowlist/blocklist controls. +* Allowed-root restrictions to prevent serving files outside trusted paths. +* Hidden-file blocking. +* Optional max download size enforcement. +* Optional range requests with byte-range parsing and partial metadata. +* Stream copy to caller-provided output resource. +* Mounted/default filesystem paths (e.g. ``s3://...``) via Flysystem routing. + +Security controls +----------------- + +``DownloadProcessor`` exposes explicit hardening options: + +* ``setAllowedRoots(array $roots)`` +* ``setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = [])`` +* ``setBlockHiddenFiles(bool $block = true)`` +* ``setMaxDownloadSize(int $maxDownloadSize = 0)`` +* ``setRangeRequestsEnabled(bool $enabled = true)`` +* ``setForceAttachment(bool $enabled = true)`` +* ``setDefaultDownloadName(string $name)`` +* ``setChunkSize(int $chunkSize)`` + +Examples +-------- + +Prepare secure metadata: + +.. code-block:: php + + use Infocyph\Pathwise\StreamHandler\DownloadProcessor; + + $downloads = new DownloadProcessor(); + $downloads->setAllowedRoots(['/srv/app/downloads']); + $downloads->setExtensionPolicy(['pdf', 'zip'], ['php', 'phar', 'exe']); + + $manifest = $downloads->prepareDownload( + path: '/srv/app/downloads/report.pdf', + downloadName: 'monthly-report.pdf', + rangeHeader: null, + ); + + // Use $manifest['status'] and $manifest['headers'] in your framework response. + +Stream output with range support: + +.. code-block:: php + + $output = fopen('php://output', 'wb'); + + $manifest = $downloads->streamDownload( + path: '/srv/app/downloads/video.mp4', + outputStream: $output, + downloadName: 'video.mp4', + rangeHeader: $_SERVER['HTTP_RANGE'] ?? null, + ); + + // $manifest includes status, headers, rangeStart/rangeEnd and bytesSent. + +Mounted storage example: + +.. code-block:: php + + use Infocyph\Pathwise\Storage\StorageFactory; + use Infocyph\Pathwise\StreamHandler\DownloadProcessor; + + StorageFactory::mount('s3', ['adapter' => $myS3Adapter]); + + $downloads = new DownloadProcessor(); + $downloads->setAllowedRoots(['s3://downloads']); + + $manifest = $downloads->prepareDownload('s3://downloads/report.pdf'); diff --git a/docs/helper-functions.rst b/docs/helper-functions.rst index d949b12..fc1a2db 100644 --- a/docs/helper-functions.rst +++ b/docs/helper-functions.rst @@ -12,6 +12,9 @@ Available helpers (brief): * ``createDirectory(string $directoryPath, int $permissions = 0755): bool`` * ``listFiles(string $directoryPath): array`` * ``copyDirectory(string $source, string $destination): bool`` +* ``createFilesystem(array $config): FilesystemOperator`` +* ``mountStorage(string $name, array $config): FilesystemOperator`` +* ``mountStorages(array $mounts): void`` Notes: @@ -28,3 +31,12 @@ Example $size = getDirectorySize('/tmp/demo'); $files = listFiles('/tmp/demo'); + +Storage setup helper example: + +.. code-block:: php + + mountStorage('assets', [ + 'driver' => 'local', + 'root' => '/srv/storage/assets', + ]); diff --git a/docs/index.rst b/docs/index.rst index 600d70a..03b1ebb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,11 +12,13 @@ queue/audit tooling, and operational helpers. overview installation capabilities + storage-adapters quickstart recipes file-manager directory-manager upload-processing + download-processing security queue observability diff --git a/docs/installation.rst b/docs/installation.rst index c21083c..787e571 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -20,6 +20,24 @@ Optional extensions: * ``ext-posix`` for richer Unix ownership details. * ``ext-xmlreader`` and ``ext-simplexml`` for XML helpers. +Optional adapter packages (choose per driver): + +* AWS S3: ``league/flysystem-aws-s3-v3`` + ``aws/aws-sdk-php`` +* Async AWS S3: ``league/flysystem-async-aws-s3`` + ``async-aws/s3`` +* Azure Blob Storage: ``league/flysystem-azure-blob-storage`` +* Google Cloud Storage: ``league/flysystem-google-cloud-storage`` +* MongoDB GridFS: ``league/flysystem-gridfs`` +* SFTP: ``league/flysystem-sftp-v3`` +* SFTP (V2): ``league/flysystem-sftp-v2`` +* FTP: ``league/flysystem-ftp`` +* WebDAV: ``league/flysystem-webdav`` +* ZIP archive: ``league/flysystem-ziparchive`` +* In-memory: ``league/flysystem-memory`` +* Read-only wrapper: ``league/flysystem-read-only`` +* Path prefixing wrapper: ``league/flysystem-path-prefixing`` + +See ``storage-adapters`` for setup patterns. + Where to Use First ------------------ diff --git a/docs/overview.rst b/docs/overview.rst index 0cc63a2..9ca78dc 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -18,6 +18,7 @@ Main namespaces: * ``Infocyph\Pathwise\FileManager`` * ``Infocyph\Pathwise\DirectoryManager`` * ``Infocyph\Pathwise\StreamHandler`` +* ``Infocyph\Pathwise\Storage`` * ``Infocyph\Pathwise\Security`` * ``Infocyph\Pathwise\Queue`` * ``Infocyph\Pathwise\Observability`` diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 801cf5e..ee06ae4 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -29,13 +29,13 @@ This quickstart shows the fastest way to understand what Pathwise can do. .. code-block:: php + use Infocyph\Pathwise\Storage\StorageFactory; use Infocyph\Pathwise\Utils\FlysystemHelper; - use League\Flysystem\Filesystem; - use League\Flysystem\Local\LocalFilesystemAdapter; - FlysystemHelper::mount('assets', new Filesystem( - new LocalFilesystemAdapter('/srv/storage/assets') - )); + StorageFactory::mount('assets', [ + 'driver' => 'local', + 'root' => '/srv/storage/assets', + ]); FlysystemHelper::write('assets://reports/a.txt', "hello\n"); $text = FlysystemHelper::read('assets://reports/a.txt'); diff --git a/docs/recipes.rst b/docs/recipes.rst index 8f157bd..bfa58e5 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -82,13 +82,13 @@ Goal: .. code-block:: php use Infocyph\Pathwise\FileManager\FileCompression; + use Infocyph\Pathwise\Storage\StorageFactory; use Infocyph\Pathwise\Utils\FlysystemHelper; - use League\Flysystem\Filesystem; - use League\Flysystem\Local\LocalFilesystemAdapter; - FlysystemHelper::mount('mnt', new Filesystem( - new LocalFilesystemAdapter('/srv/storage') - )); + StorageFactory::mount('mnt', [ + 'driver' => 'local', + 'root' => '/srv/storage', + ]); FlysystemHelper::write('mnt://source/a.txt', 'A'); FlysystemHelper::write('mnt://source/b.txt', 'B'); diff --git a/docs/storage-adapters.rst b/docs/storage-adapters.rst new file mode 100644 index 0000000..0a5e47e --- /dev/null +++ b/docs/storage-adapters.rst @@ -0,0 +1,196 @@ +Storage Adapters +================ + +Pathwise is built on Flysystem 3, so you can use **any Flysystem adapter** +as soon as its package is installed and mounted. + +Use ``Infocyph\Pathwise\Storage\StorageFactory`` to standardize setup. + +What ``StorageFactory`` Supports +-------------------------------- + +``StorageFactory::createFilesystem(array $config)`` accepts: + +* local driver config: ``['driver' => 'local', 'root' => '/srv/storage']`` +* prebuilt filesystem: ``['filesystem' => $filesystemOperator]`` +* adapter instance: ``['adapter' => $adapter, 'options' => [...]]`` +* custom named drivers registered at runtime. + +``StorageFactory::mount(string $name, array $config)`` creates and mounts in one step. + +``StorageFactory::mountMany(array $mounts)`` mounts multiple storages at once. + +Driver config modes: + +* direct adapter object: + ``['driver' => 'aws-s3', 'adapter' => $adapter]`` +* constructor arguments for official adapter classes: + ``['driver' => 'aws-s3', 'constructor' => [$client, $bucket, $prefix]]`` + +``StorageFactory`` also exposes: + +* ``StorageFactory::officialDrivers()`` for official driver metadata. +* ``StorageFactory::suggestedPackage($driver)`` for install guidance. + +Official Adapter Coverage +------------------------- + +The following official Flysystem adapters are mapped by driver key: + +* ``local`` -> ``league/flysystem-local`` -> ``League\Flysystem\Local\LocalFilesystemAdapter`` +* ``ftp`` -> ``league/flysystem-ftp`` -> ``League\Flysystem\Ftp\FtpAdapter`` +* ``inmemory`` (alias: ``in-memory``) -> ``league/flysystem-memory`` -> ``League\Flysystem\InMemory\InMemoryFilesystemAdapter`` +* ``read-only`` (alias: ``readonly``) -> ``league/flysystem-read-only`` -> ``League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter`` +* ``path-prefixing`` (alias: ``path-prefix``) -> ``league/flysystem-path-prefixing`` -> ``League\Flysystem\PathPrefixing\PathPrefixedAdapter`` +* ``aws-s3`` (aliases: ``s3``, ``aws``) -> ``league/flysystem-aws-s3-v3`` -> ``League\Flysystem\AwsS3V3\AwsS3V3Adapter`` +* ``async-aws-s3`` -> ``league/flysystem-async-aws-s3`` -> ``League\Flysystem\AsyncAwsS3\AsyncAwsS3Adapter`` +* ``azure-blob-storage`` (alias: ``azure``) -> ``league/flysystem-azure-blob-storage`` -> ``League\Flysystem\AzureBlobStorage\AzureBlobStorageAdapter`` +* ``google-cloud-storage`` (alias: ``gcs``) -> ``league/flysystem-google-cloud-storage`` -> ``League\Flysystem\GoogleCloudStorage\GoogleCloudStorageAdapter`` +* ``mongodb-gridfs`` (alias: ``gridfs``) -> ``league/flysystem-gridfs`` -> ``League\Flysystem\GridFS\GridFSAdapter`` +* ``sftp-v2`` (alias: ``sftp2``) -> ``league/flysystem-sftp-v2`` -> ``League\Flysystem\PhpseclibV2\SftpAdapter`` +* ``sftp-v3`` (alias: ``sftp3``) -> ``league/flysystem-sftp-v3`` -> ``League\Flysystem\PhpseclibV3\SftpAdapter`` +* ``webdav`` -> ``league/flysystem-webdav`` -> ``League\Flysystem\WebDAV\WebDAVAdapter`` +* ``ziparchive`` (alias: ``zip``) -> ``league/flysystem-ziparchive`` -> ``League\Flysystem\ZipArchive\ZipArchiveAdapter`` + +If a package is missing, ``StorageFactory`` throws an install hint with the package name. + +Basic Local Example +------------------- + +.. code-block:: php + + use Infocyph\Pathwise\Storage\StorageFactory; + use Infocyph\Pathwise\Utils\FlysystemHelper; + + StorageFactory::mount('assets', [ + 'driver' => 'local', + 'root' => '/srv/storage/assets', + ]); + + FlysystemHelper::write('assets://images/logo.txt', 'ok'); + +Any Adapter Example (S3) +------------------------ + +Install adapter package first (example): + +.. code-block:: bash + + composer require league/flysystem-aws-s3-v3 aws/aws-sdk-php + +Then pass the adapter directly: + +.. code-block:: php + + use Infocyph\Pathwise\Storage\StorageFactory; + use League\Flysystem\AwsS3V3\AwsS3V3Adapter; + use Aws\S3\S3Client; + + $client = new S3Client([ + 'version' => 'latest', + 'region' => 'us-east-1', + 'credentials' => [ + 'key' => getenv('AWS_ACCESS_KEY_ID'), + 'secret' => getenv('AWS_SECRET_ACCESS_KEY'), + ], + ]); + + $adapter = new AwsS3V3Adapter($client, 'my-bucket', 'app-prefix'); + + StorageFactory::mount('s3', [ + 'adapter' => $adapter, + ]); + + // Works with all Pathwise modules that accept paths: + // s3://uploads/a.pdf + +Constructor mode example (official drivers): + +.. code-block:: php + + StorageFactory::mount('s3', [ + 'driver' => 's3', + 'constructor' => [$client, 'my-bucket', 'app-prefix'], + ]); + +Read-only/path-prefix wrappers (official adapters): + +.. code-block:: php + + use League\Flysystem\Local\LocalFilesystemAdapter; + + StorageFactory::mount('readonly', [ + 'driver' => 'read-only', + 'constructor' => [new LocalFilesystemAdapter('/srv/storage')], + ]); + + StorageFactory::mount('prefixed', [ + 'driver' => 'path-prefixing', + 'constructor' => [new LocalFilesystemAdapter('/srv/storage'), 'tenant-a'], + ]); + +Custom Driver Registration +-------------------------- + +If you want environment-driven config, register a custom driver once: + +.. code-block:: php + + use Infocyph\Pathwise\Storage\StorageFactory; + use League\Flysystem\Filesystem; + use League\Flysystem\Local\LocalFilesystemAdapter; + + StorageFactory::registerDriver('tenant-local', function (array $config): Filesystem { + $tenant = (string) ($config['tenant'] ?? 'default'); + $root = '/srv/tenants/' . $tenant; + + return new Filesystem(new LocalFilesystemAdapter($root)); + }); + + StorageFactory::mount('tenant', [ + 'driver' => 'tenant-local', + 'tenant' => 'acme', + ]); + + // tenant://docs/report.txt + +Helper Functions (autoloaded) +----------------------------- + +Global helpers mirror the factory: + +* ``createFilesystem(array $config): FilesystemOperator`` +* ``mountStorage(string $name, array $config): FilesystemOperator`` +* ``mountStorages(array $mounts): void`` + +Example: + +.. code-block:: php + + mountStorage('media', [ + 'driver' => 'local', + 'root' => '/srv/media', + ]); + +Processor Integration Notes +--------------------------- + +``UploadProcessor`` and ``DownloadProcessor`` already work with mounted paths. + +Examples: + +* upload destination: ``$uploader->setDirectorySettings('s3://uploads')`` +* chunk temp dir on mounted storage: ``$uploader->setDirectorySettings('s3://uploads', false, 's3://tmp')`` +* download root restriction for mounted storage: + ``$downloads->setAllowedRoots(['s3://uploads'])`` + +Recommended Operational Pattern +------------------------------- + +For remote object stores, common production setup is: + +* receive chunks on fast local temp storage +* finalize and write merged object to remote mount + +This reduces object churn and upload latency compared with writing every chunk +as a separate remote object. diff --git a/docs/upload-processing.rst b/docs/upload-processing.rst index 481db90..7f1b71c 100644 --- a/docs/upload-processing.rst +++ b/docs/upload-processing.rst @@ -28,6 +28,7 @@ Storage notes: * Uses Flysystem operations for chunk manifests and destination writes. * Supports mounted/default filesystem routing through helper resolution. +* For adapter setup (S3/SFTP/FTP/custom), see ``storage-adapters``. Security Hardening Controls --------------------------- @@ -100,3 +101,18 @@ Hardened chunk upload: totalChunks: 4, originalFilename: 'video.mp4', ); + +Mounted destination example: + +.. code-block:: php + + use Infocyph\Pathwise\Storage\StorageFactory; + use Infocyph\Pathwise\StreamHandler\UploadProcessor; + + StorageFactory::mount('s3', ['adapter' => $myS3Adapter]); + + $uploader = new UploadProcessor(); + $uploader->setDirectorySettings('s3://uploads', false, 's3://tmp'); + $uploader->setValidationProfile('document'); + + $finalPath = $uploader->processUpload($_FILES['file']); diff --git a/src/Exceptions/DownloadException.php b/src/Exceptions/DownloadException.php new file mode 100644 index 0000000..7a81fb7 --- /dev/null +++ b/src/Exceptions/DownloadException.php @@ -0,0 +1,5 @@ + */ + private const array DRIVER_ALIASES = [ + 'aws' => 'aws-s3', + 's3' => 'aws-s3', + 'asyncaws-s3' => 'async-aws-s3', + 'in-memory' => 'inmemory', + 'memory' => 'inmemory', + 'readonly' => 'read-only', + 'path-prefix' => 'path-prefixing', + 'pathprefixed' => 'path-prefixing', + 'azure' => 'azure-blob-storage', + 'gcs' => 'google-cloud-storage', + 'gridfs' => 'mongodb-gridfs', + 'sftp2' => 'sftp-v2', + 'sftp3' => 'sftp-v3', + 'zip' => 'ziparchive', + 'zip-archive' => 'ziparchive', + ]; + /** + * @var array + */ + private const array OFFICIAL_DRIVERS = [ + 'local' => [ + 'package' => 'league/flysystem-local', + 'adapter_class' => LocalFilesystemAdapter::class, + ], + 'ftp' => [ + 'package' => 'league/flysystem-ftp', + 'adapter_class' => 'League\\Flysystem\\Ftp\\FtpAdapter', + ], + 'inmemory' => [ + 'package' => 'league/flysystem-memory', + 'adapter_class' => 'League\\Flysystem\\InMemory\\InMemoryFilesystemAdapter', + ], + 'read-only' => [ + 'package' => 'league/flysystem-read-only', + 'adapter_class' => 'League\\Flysystem\\ReadOnly\\ReadOnlyFilesystemAdapter', + ], + 'path-prefixing' => [ + 'package' => 'league/flysystem-path-prefixing', + 'adapter_class' => 'League\\Flysystem\\PathPrefixing\\PathPrefixedAdapter', + ], + 'aws-s3' => [ + 'package' => 'league/flysystem-aws-s3-v3', + 'adapter_class' => 'League\\Flysystem\\AwsS3V3\\AwsS3V3Adapter', + ], + 'async-aws-s3' => [ + 'package' => 'league/flysystem-async-aws-s3', + 'adapter_class' => 'League\\Flysystem\\AsyncAwsS3\\AsyncAwsS3Adapter', + ], + 'azure-blob-storage' => [ + 'package' => 'league/flysystem-azure-blob-storage', + 'adapter_class' => 'League\\Flysystem\\AzureBlobStorage\\AzureBlobStorageAdapter', + ], + 'google-cloud-storage' => [ + 'package' => 'league/flysystem-google-cloud-storage', + 'adapter_class' => 'League\\Flysystem\\GoogleCloudStorage\\GoogleCloudStorageAdapter', + ], + 'mongodb-gridfs' => [ + 'package' => 'league/flysystem-gridfs', + 'adapter_class' => 'League\\Flysystem\\GridFS\\GridFSAdapter', + ], + 'sftp-v2' => [ + 'package' => 'league/flysystem-sftp-v2', + 'adapter_class' => 'League\\Flysystem\\PhpseclibV2\\SftpAdapter', + ], + 'sftp-v3' => [ + 'package' => 'league/flysystem-sftp-v3', + 'adapter_class' => 'League\\Flysystem\\PhpseclibV3\\SftpAdapter', + ], + 'webdav' => [ + 'package' => 'league/flysystem-webdav', + 'adapter_class' => 'League\\Flysystem\\WebDAV\\WebDAVAdapter', + ], + 'ziparchive' => [ + 'package' => 'league/flysystem-ziparchive', + 'adapter_class' => 'League\\Flysystem\\ZipArchive\\ZipArchiveAdapter', + ], + ]; + + /** @var array): FilesystemOperator> */ + private static array $drivers = []; + + public static function clearDrivers(): void + { + self::$drivers = []; + } + + /** + * @param array $config + */ + public static function createFilesystem(array $config): FilesystemOperator + { + $provided = self::resolveProvidedFilesystem($config); + if ($provided !== null) { + return $provided; + } + + if (!array_key_exists('driver', $config)) { + $adapter = self::resolveAdapter($config); + if ($adapter !== null) { + return new Filesystem($adapter, self::resolveOptions($config)); + } + } + + $driver = self::resolveDriver($config); + if ($driver === 'local') { + return self::createLocalFilesystemFromConfig($config); + } + + $custom = self::createFromRegisteredDriver($driver, $config); + if ($custom !== null) { + return $custom; + } + + if (self::isOfficialDriver($driver)) { + return self::createOfficialFilesystem($driver, $config); + } + + throw new \InvalidArgumentException( + "Unsupported storage driver '{$driver}'. Register it via StorageFactory::registerDriver().", + ); + } + + public static function driverNames(): array + { + return array_keys(self::$drivers); + } + + public static function hasDriver(string $name): bool + { + return isset(self::$drivers[self::canonicalDriverName($name)]); + } + + public static function isOfficialDriver(string $driver): bool + { + return isset(self::OFFICIAL_DRIVERS[self::canonicalDriverName($driver)]); + } + + /** + * @param array $config + */ + public static function mount(string $name, array $config): FilesystemOperator + { + $filesystem = self::createFilesystem($config); + FlysystemHelper::mount($name, $filesystem); + + return $filesystem; + } + + /** + * @param array> $mounts + */ + public static function mountMany(array $mounts): void + { + foreach ($mounts as $name => $config) { + self::mount((string) $name, $config); + } + } + + /** + * @return array + */ + public static function officialDrivers(): array + { + return self::OFFICIAL_DRIVERS; + } + + /** + * @param callable(array): FilesystemOperator $factory + */ + public static function registerDriver(string $name, callable $factory): void + { + $driver = self::canonicalDriverName($name); + if ($driver === '') { + throw new \InvalidArgumentException('Driver name is required.'); + } + + self::$drivers[$driver] = $factory; + } + + public static function suggestedPackage(string $driver): ?string + { + $normalized = self::canonicalDriverName($driver); + + return self::OFFICIAL_DRIVERS[$normalized]['package'] ?? null; + } + + public static function unregisterDriver(string $name): void + { + unset(self::$drivers[self::canonicalDriverName($name)]); + } + + private static function canonicalDriverName(string $name): string + { + $normalized = self::normalizeDriverName($name); + + return self::DRIVER_ALIASES[$normalized] ?? $normalized; + } + + /** + * @param array $config + */ + private static function createFromRegisteredDriver(string $driver, array $config): ?FilesystemOperator + { + if (!isset(self::$drivers[$driver])) { + return null; + } + + $filesystem = self::$drivers[$driver]($config); + if (!$filesystem instanceof FilesystemOperator) { + throw new \InvalidArgumentException("Driver '{$driver}' factory must return a FilesystemOperator."); + } + + return $filesystem; + } + + /** + * @param array $config + */ + private static function createLocalFilesystem(array $config): FilesystemOperator + { + $root = (string) ($config['root'] ?? ''); + if ($root === '') { + throw new \InvalidArgumentException('Local driver requires a non-empty "root" path.'); + } + + return new Filesystem(new LocalFilesystemAdapter($root), self::resolveOptions($config)); + } + + /** + * @param array $config + */ + private static function createLocalFilesystemFromConfig(array $config): FilesystemOperator + { + $adapter = self::resolveAdapter($config); + if ($adapter !== null) { + return new Filesystem($adapter, self::resolveOptions($config)); + } + + return self::createLocalFilesystem($config); + } + + /** + * @param array $config + */ + private static function createOfficialFilesystem(string $driver, array $config): FilesystemOperator + { + $driver = self::canonicalDriverName($driver); + $metadata = self::OFFICIAL_DRIVERS[$driver] ?? null; + if ($metadata === null) { + throw new \InvalidArgumentException("Unsupported official storage driver '{$driver}'."); + } + + $adapter = self::resolveAdapter($config); + if ($adapter !== null) { + return new Filesystem($adapter, self::resolveOptions($config)); + } + + $adapterClass = $metadata['adapter_class']; + if (!class_exists($adapterClass)) { + throw new \InvalidArgumentException( + "Storage driver '{$driver}' requires package '{$metadata['package']}'. " + . "Install it and provide either 'adapter' or 'constructor' config.", + ); + } + + if ($driver === 'inmemory') { + /** @var FilesystemAdapter $adapter */ + $adapter = new $adapterClass(); + + return new Filesystem($adapter, self::resolveOptions($config)); + } + + $constructor = $config['constructor'] ?? null; + if (!is_array($constructor)) { + throw new \InvalidArgumentException( + "Storage driver '{$driver}' requires either 'adapter' or 'constructor' config.", + ); + } + + $arguments = array_is_list($constructor) ? $constructor : array_values($constructor); + $adapter = new $adapterClass(...$arguments); + + return new Filesystem($adapter, self::resolveOptions($config)); + } + + private static function normalizeDriverName(string $name): string + { + return strtolower(trim($name)); + } + + /** + * @param array $config + */ + private static function resolveAdapter(array $config): ?FilesystemAdapter + { + if (!array_key_exists('adapter', $config)) { + return null; + } + + $adapter = $config['adapter']; + if (!$adapter instanceof FilesystemAdapter) { + throw new \InvalidArgumentException('The "adapter" config value must implement FilesystemAdapter.'); + } + + return $adapter; + } + + /** + * @param array $config + */ + private static function resolveDriver(array $config): string + { + $driver = self::canonicalDriverName((string) ($config['driver'] ?? 'local')); + if ($driver === '') { + throw new \InvalidArgumentException('Storage "driver" must be a non-empty string.'); + } + + return $driver; + } + + /** + * @param array $config + * @return array + */ + private static function resolveOptions(array $config): array + { + $options = $config['options'] ?? []; + if (!is_array($options)) { + throw new \InvalidArgumentException('Storage "options" must be an array.'); + } + + return $options; + } + + /** + * @param array $config + */ + private static function resolveProvidedFilesystem(array $config): ?FilesystemOperator + { + if (!array_key_exists('filesystem', $config)) { + return null; + } + + $filesystem = $config['filesystem']; + if (!$filesystem instanceof FilesystemOperator) { + throw new \InvalidArgumentException('The "filesystem" config value must implement FilesystemOperator.'); + } + + return $filesystem; + } +} diff --git a/src/StreamHandler/DownloadProcessor.php b/src/StreamHandler/DownloadProcessor.php new file mode 100644 index 0000000..22abb86 --- /dev/null +++ b/src/StreamHandler/DownloadProcessor.php @@ -0,0 +1,504 @@ + + * } + */ + public function prepareDownload(string $path, ?string $downloadName = null, ?string $rangeHeader = null): array + { + $normalizedPath = PathHelper::normalize($path); + $this->validateDownloadPath($normalizedPath); + + $size = FlysystemHelper::size($normalizedPath); + if ($this->maxDownloadSize > 0 && $size > $this->maxDownloadSize) { + throw new FileSizeExceededException('Download exceeds configured size limit.'); + } + + $extension = pathinfo($normalizedPath, PATHINFO_EXTENSION); + $this->validateExtension($extension); + + $mimeType = MetadataHelper::getMimeType($normalizedPath) ?? 'application/octet-stream'; + $lastModified = FlysystemHelper::lastModified($normalizedPath); + [$rangeStart, $rangeEnd, $isPartial] = $this->resolveRange($rangeHeader, $size); + $contentLength = ($rangeEnd - $rangeStart) + 1; + + $resolvedFileName = $this->resolveDownloadName($downloadName, $normalizedPath); + $disposition = $this->forceAttachment ? 'attachment' : 'inline'; + $etag = $this->buildEtag($normalizedPath, $size, $lastModified); + + $headers = [ + 'Accept-Ranges' => $this->rangeRequestsEnabled ? 'bytes' : 'none', + 'Cache-Control' => 'private, no-transform', + 'Content-Disposition' => $this->buildContentDisposition($disposition, $resolvedFileName), + 'Content-Length' => (string) $contentLength, + 'Content-Type' => $mimeType, + 'ETag' => $etag, + 'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT', + 'X-Content-Type-Options' => 'nosniff', + ]; + + if ($isPartial) { + $headers['Content-Range'] = sprintf('bytes %d-%d/%d', $rangeStart, $rangeEnd, $size); + } + + return [ + 'path' => $normalizedPath, + 'fileName' => $resolvedFileName, + 'mimeType' => $mimeType, + 'size' => $size, + 'lastModified' => $lastModified, + 'etag' => $etag, + 'status' => $isPartial ? 206 : 200, + 'rangeStart' => $rangeStart, + 'rangeEnd' => $rangeEnd, + 'contentLength' => $contentLength, + 'headers' => $headers, + ]; + } + + /** + * @param array $roots + */ + public function setAllowedRoots(array $roots): void + { + $this->allowedRoots = []; + foreach ($roots as $root) { + $candidate = PathHelper::normalize($root); + if ($candidate !== '') { + $this->allowedRoots[] = $candidate; + } + } + } + + public function setBlockHiddenFiles(bool $block = true): void + { + $this->blockHiddenFiles = $block; + } + + public function setChunkSize(int $chunkSize): void + { + $this->chunkSize = max(1024, $chunkSize); + } + + public function setDefaultDownloadName(string $name): void + { + $safe = $this->sanitizeFilename($name); + $this->defaultDownloadName = $safe !== '' ? $safe : 'download.bin'; + } + + /** + * @param array $allowedExtensions + * @param array $blockedExtensions + */ + public function setExtensionPolicy(array $allowedExtensions = [], array $blockedExtensions = []): void + { + $this->allowedExtensions = $this->normalizeExtensions($allowedExtensions); + $this->blockedExtensions = $blockedExtensions === [] + ? ['php', 'phtml', 'phar', 'exe', 'sh', 'bat', 'cmd', 'com'] + : $this->normalizeExtensions($blockedExtensions); + } + + public function setForceAttachment(bool $enabled = true): void + { + $this->forceAttachment = $enabled; + } + + public function setMaxDownloadSize(int $maxDownloadSize = 0): void + { + $this->maxDownloadSize = max(0, $maxDownloadSize); + } + + public function setRangeRequestsEnabled(bool $enabled = true): void + { + $this->rangeRequestsEnabled = $enabled; + } + + /** + * Stream a secure download to a writable resource and return the manifest. + * + * @return array{ + * path: string, + * fileName: string, + * mimeType: string, + * size: int, + * lastModified: int, + * etag: string, + * status: int, + * rangeStart: int, + * rangeEnd: int, + * contentLength: int, + * bytesSent: int, + * headers: array + * } + */ + public function streamDownload( + string $path, + mixed $outputStream, + ?string $downloadName = null, + ?string $rangeHeader = null, + ): array { + if (!is_resource($outputStream)) { + throw new DownloadException('Invalid output stream.'); + } + + $manifest = $this->prepareDownload($path, $downloadName, $rangeHeader); + + $inputStream = FlysystemHelper::readStream($manifest['path']); + if (!is_resource($inputStream)) { + throw new DownloadException('Unable to open input stream for download.'); + } + + try { + $this->seekStreamToOffset($inputStream, $manifest['rangeStart']); + + $remaining = $manifest['contentLength']; + $bytesSent = 0; + while ($remaining > 0) { + $chunk = fread($inputStream, min($this->chunkSize, $remaining)); + if (!is_string($chunk) || $chunk === '') { + break; + } + + $written = $this->writeFully($outputStream, $chunk); + $bytesSent += $written; + $remaining -= $written; + } + } finally { + fclose($inputStream); + } + + if ($bytesSent !== $manifest['contentLength']) { + throw new DownloadException('Incomplete download stream copy.'); + } + + $manifest['bytesSent'] = $bytesSent; + + return $manifest; + } + + private function buildContentDisposition(string $disposition, string $fileName): string + { + $asciiFallback = preg_replace('/[^\x20-\x7E]/', '_', $fileName) ?? 'download.bin'; + $asciiFallback = str_replace(['\\', '"'], '_', $asciiFallback); + $asciiFallback = trim($asciiFallback); + if ($asciiFallback === '') { + $asciiFallback = 'download.bin'; + } + + return sprintf( + '%s; filename="%s"; filename*=UTF-8\'\'%s', + $disposition, + $asciiFallback, + rawurlencode($fileName), + ); + } + + private function buildEtag(string $path, int $size, int $lastModified): string + { + $fingerprint = substr(hash('sha1', $path), 0, 8); + + return sprintf('W/"%x-%x-%s"', $size, $lastModified, $fingerprint); + } + + private function discardBytes(mixed $stream, int $bytes): void + { + $remaining = $bytes; + while ($remaining > 0 && !feof($stream)) { + $chunk = fread($stream, min($this->chunkSize, $remaining)); + if (!is_string($chunk) || $chunk === '') { + break; + } + + $remaining -= strlen($chunk); + } + + if ($remaining > 0) { + throw new DownloadException('Unable to seek download stream to requested range start.'); + } + } + + private function isHiddenFile(string $path): bool + { + $basename = basename($path); + + return $basename !== '' && str_starts_with($basename, '.'); + } + + private function normalizeExtension(string $extension): string + { + return strtolower(ltrim(trim($extension), '.')); + } + + /** + * @param array $extensions + * @return array + */ + private function normalizeExtensions(array $extensions): array + { + $normalized = []; + foreach ($extensions as $extension) { + $candidate = $this->normalizeExtension($extension); + if ($candidate === '') { + continue; + } + + $normalized[] = $candidate; + } + + return array_values(array_unique($normalized)); + } + + private function pathStartsWith(string $path, string $prefix): bool + { + if ($path === $prefix) { + return true; + } + + $pathNormalized = rtrim($path, '/\\'); + $prefixNormalized = rtrim($prefix, '/\\'); + if ($pathNormalized === $prefixNormalized) { + return true; + } + + $needle = $prefixNormalized . DIRECTORY_SEPARATOR; + if (PHP_OS_FAMILY === 'Windows') { + return str_starts_with(strtolower($pathNormalized), strtolower($needle)); + } + + return str_starts_with($pathNormalized, $needle); + } + + private function pathWithinAllowedRoot(string $path): bool + { + if ($this->allowedRoots === []) { + return true; + } + + $pathIsScheme = PathHelper::hasScheme($path); + foreach ($this->allowedRoots as $root) { + $rootIsScheme = PathHelper::hasScheme($root); + if ($pathIsScheme || $rootIsScheme) { + if (!$pathIsScheme || !$rootIsScheme) { + continue; + } + + $normalizedPath = rtrim(str_replace('\\', '/', $path), '/'); + $normalizedRoot = rtrim(str_replace('\\', '/', $root), '/'); + if ($normalizedPath === $normalizedRoot || str_starts_with($normalizedPath, $normalizedRoot . '/')) { + return true; + } + + continue; + } + + $pathAbsolute = PathHelper::isAbsolute($path) + ? $path + : PathHelper::toAbsolutePath($path); + $rootAbsolute = PathHelper::isAbsolute($root) + ? $root + : PathHelper::toAbsolutePath($root); + + $resolvedPath = realpath($pathAbsolute) ?: PathHelper::normalize($pathAbsolute); + $resolvedRoot = realpath($rootAbsolute) ?: PathHelper::normalize($rootAbsolute); + if ($this->pathStartsWith($resolvedPath, $resolvedRoot)) { + return true; + } + } + + return false; + } + + private function resolveDownloadName(?string $downloadName, string $path): string + { + $fallback = basename($path); + $candidate = $downloadName ?? $fallback; + $safe = $this->sanitizeFilename($candidate); + if ($safe === '') { + $safe = $this->sanitizeFilename($fallback); + } + + return $safe !== '' ? $safe : $this->defaultDownloadName; + } + + /** + * @return array{int, int, bool} + */ + private function resolveRange(?string $rangeHeader, int $size): array + { + if ($size < 1) { + throw new DownloadException('Cannot prepare download for empty file.'); + } + + if (!$this->rangeRequestsEnabled || $rangeHeader === null || trim($rangeHeader) === '') { + return [0, $size - 1, false]; + } + + if (preg_match('/^\s*bytes=(\d*)-(\d*)\s*$/', $rangeHeader, $matches) !== 1) { + throw new DownloadException('Invalid range header.'); + } + + $startRaw = $matches[1] ?? ''; + $endRaw = $matches[2] ?? ''; + + if ($startRaw === '' && $endRaw === '') { + throw new DownloadException('Invalid range header.'); + } + + if ($startRaw === '') { + $suffixLength = (int) $endRaw; + if ($suffixLength <= 0) { + throw new DownloadException('Invalid range header.'); + } + + $start = max(0, $size - $suffixLength); + $end = $size - 1; + + return [$start, $end, true]; + } + + $start = (int) $startRaw; + if ($start < 0 || $start >= $size) { + throw new DownloadException('Invalid range header.'); + } + + if ($endRaw === '') { + return [$start, $size - 1, true]; + } + + $end = (int) $endRaw; + if ($end < $start) { + throw new DownloadException('Invalid range header.'); + } + + return [$start, min($end, $size - 1), true]; + } + + private function sanitizeFilename(string $name): string + { + $candidate = trim($name); + if ($candidate === '' || str_contains($candidate, "\0")) { + return ''; + } + + $candidate = preg_replace('/[\/\\\\]+/', '_', $candidate) ?? ''; + $candidate = preg_replace('/[\x00-\x1F\x7F<>:"|?*]/', '', $candidate) ?? ''; + $candidate = trim($candidate, " .\t\n\r\0\x0B"); + if ($candidate === '' || $candidate === '.' || $candidate === '..') { + return ''; + } + + if (strlen($candidate) > 255) { + $extension = pathinfo($candidate, PATHINFO_EXTENSION); + $filename = pathinfo($candidate, PATHINFO_FILENAME); + if ($extension !== '') { + $maxFilenameLength = max(1, 255 - strlen($extension) - 1); + $candidate = substr($filename, 0, $maxFilenameLength) . '.' . $extension; + } else { + $candidate = substr($candidate, 0, 255); + } + } + + return $candidate; + } + + private function seekStreamToOffset(mixed $stream, int $offset): void + { + if ($offset < 1) { + return; + } + + $metadata = stream_get_meta_data($stream); + $seekable = is_array($metadata) && ($metadata['seekable'] ?? false); + if ($seekable && @fseek($stream, $offset, SEEK_SET) === 0) { + return; + } + + $this->discardBytes($stream, $offset); + } + + private function validateDownloadPath(string $path): void + { + if (!FlysystemHelper::fileExists($path)) { + throw new FileNotFoundException("File not found at {$path}."); + } + + if ($this->blockHiddenFiles && $this->isHiddenFile($path)) { + throw new DownloadException('Hidden file downloads are blocked.'); + } + + if (!$this->pathWithinAllowedRoot($path)) { + throw new DownloadException('Download path is outside allowed roots.'); + } + } + + private function validateExtension(string $extension): void + { + $normalized = $this->normalizeExtension($extension); + if ($normalized === '') { + if ($this->allowedExtensions !== []) { + throw new DownloadException('File extension is required for download.'); + } + + return; + } + + if (in_array($normalized, $this->blockedExtensions, true)) { + throw new DownloadException('Blocked file extension for download.'); + } + + if ($this->allowedExtensions !== [] && !in_array($normalized, $this->allowedExtensions, true)) { + throw new DownloadException('File extension is not allowed for download.'); + } + } + + private function writeFully(mixed $stream, string $payload): int + { + $totalWritten = 0; + $payloadLength = strlen($payload); + while ($totalWritten < $payloadLength) { + $chunk = substr($payload, $totalWritten); + $written = fwrite($stream, $chunk); + if (!is_int($written) || $written <= 0) { + throw new DownloadException('Failed to write to output stream.'); + } + + $totalWritten += $written; + } + + return $totalWritten; + } +} diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php index 52f672f..d5a0a6b 100644 --- a/src/StreamHandler/UploadProcessor.php +++ b/src/StreamHandler/UploadProcessor.php @@ -548,6 +548,18 @@ private function moveIncomingFile(string $source, string $destination): void throw new UploadException('Failed to move uploaded file.'); } + if (PathHelper::hasScheme($source) || PathHelper::hasScheme($destination)) { + try { + FlysystemHelper::copy($source, $destination); + } catch (\Throwable) { + throw new UploadException('Failed to move incoming file.'); + } + + FlysystemHelper::delete($source); + + return; + } + if (!@rename($source, $destination)) { try { FlysystemHelper::copy($source, $destination); diff --git a/src/functions.php b/src/functions.php index 69ac46e..502b15e 100644 --- a/src/functions.php +++ b/src/functions.php @@ -1,8 +1,10 @@ 'local', 'root' => '/path'] + * - ['filesystem' => $filesystemOperator] + * - ['adapter' => $flysystemAdapter] + * - ['driver' => 'custom', ...] after StorageFactory::registerDriver() + * + * @param array $config + */ + function createFilesystem(array $config): FilesystemOperator + { + return StorageFactory::createFilesystem($config); + } +} + +if (!function_exists('mountStorage')) { + /** + * Build and mount a filesystem under a scheme name. + * + * @param array $config + */ + function mountStorage(string $name, array $config): FilesystemOperator + { + return StorageFactory::mount($name, $config); + } +} + +if (!function_exists('mountStorages')) { + /** + * Build and mount multiple filesystems. + * + * @param array> $mounts + */ + function mountStorages(array $mounts): void + { + StorageFactory::mountMany($mounts); + } +} diff --git a/tests/Feature/DownloadProcessorTest.php b/tests/Feature/DownloadProcessorTest.php new file mode 100644 index 0000000..16386d5 --- /dev/null +++ b/tests/Feature/DownloadProcessorTest.php @@ -0,0 +1,254 @@ +downloadProcessor = new DownloadProcessor(); + $this->workingDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_download_', true); + mkdir($this->workingDir, 0777, true); +}); + +afterEach(function () { + if (!is_dir($this->workingDir)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->workingDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($this->workingDir); + FlysystemHelper::reset(); +}); + +test('it prepares secure download metadata for a local file', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'report.txt'; + file_put_contents($path, 'secure-content'); + + $this->downloadProcessor->setAllowedRoots([$this->workingDir]); + $manifest = $this->downloadProcessor->prepareDownload($path, 'report final.txt'); + + expect($manifest['status'])->toBe(200) + ->and($manifest['contentLength'])->toBe(strlen('secure-content')) + ->and($manifest['fileName'])->toBe('report final.txt') + ->and($manifest['headers'])->toHaveKeys([ + 'Accept-Ranges', + 'Cache-Control', + 'Content-Disposition', + 'Content-Length', + 'Content-Type', + 'ETag', + 'Last-Modified', + 'X-Content-Type-Options', + ]); +}); + +test('it blocks downloads outside allowed roots', function () { + $allowedRoot = $this->workingDir . DIRECTORY_SEPARATOR . 'allowed'; + $outsideRoot = $this->workingDir . DIRECTORY_SEPARATOR . 'outside'; + mkdir($allowedRoot, 0777, true); + mkdir($outsideRoot, 0777, true); + + $path = $outsideRoot . DIRECTORY_SEPARATOR . 'file.txt'; + file_put_contents($path, 'data'); + + $this->downloadProcessor->setAllowedRoots([$allowedRoot]); + + expect(fn() => $this->downloadProcessor->prepareDownload($path)) + ->toThrow(DownloadException::class, 'Download path is outside allowed roots'); +}); + +test('it blocks hidden files by default', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . '.secret.txt'; + file_put_contents($path, 'hidden'); + + expect(fn() => $this->downloadProcessor->prepareDownload($path)) + ->toThrow(DownloadException::class, 'Hidden file downloads are blocked'); +}); + +test('it supports disabling hidden file blocking', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . '.secret.txt'; + file_put_contents($path, 'hidden'); + + $this->downloadProcessor->setBlockHiddenFiles(false); + $manifest = $this->downloadProcessor->prepareDownload($path); + + expect($manifest['status'])->toBe(200); +}); + +test('it blocks disallowed download extensions', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'payload.php'; + file_put_contents($path, ' $this->downloadProcessor->prepareDownload($path)) + ->toThrow(DownloadException::class, 'Blocked file extension for download'); +}); + +test('it enforces extension allowlists', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'archive.zip'; + file_put_contents($path, 'zipdata'); + + $this->downloadProcessor->setExtensionPolicy(['txt'], ['php', 'phtml']); + + expect(fn() => $this->downloadProcessor->prepareDownload($path)) + ->toThrow(DownloadException::class, 'File extension is not allowed for download'); +}); + +test('it sanitizes unsafe download file names', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'report.txt'; + file_put_contents($path, 'payload'); + + $manifest = $this->downloadProcessor->prepareDownload($path, '..\\../evil".txt'); + + expect($manifest['fileName'])->not->toContain('/') + ->and($manifest['fileName'])->not->toContain('\\') + ->and($manifest['fileName'])->not->toContain('"') + ->and($manifest['fileName'])->toEndWith('.txt') + ->and($manifest['headers']['Content-Disposition'])->toContain('filename='); +}); + +test('it returns partial metadata for valid byte ranges', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'range.txt'; + file_put_contents($path, 'hello-world'); + + $manifest = $this->downloadProcessor->prepareDownload($path, null, 'bytes=6-10'); + + expect($manifest['status'])->toBe(206) + ->and($manifest['rangeStart'])->toBe(6) + ->and($manifest['rangeEnd'])->toBe(10) + ->and($manifest['contentLength'])->toBe(5) + ->and($manifest['headers']['Content-Range'])->toBe('bytes 6-10/11'); +}); + +test('it rejects invalid byte ranges', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'range.txt'; + file_put_contents($path, 'hello-world'); + + expect(fn() => $this->downloadProcessor->prepareDownload($path, null, 'bytes=99-100')) + ->toThrow(DownloadException::class, 'Invalid range header'); +}); + +test('it ignores range headers when range support is disabled', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'range.txt'; + file_put_contents($path, 'hello-world'); + + $this->downloadProcessor->setRangeRequestsEnabled(false); + $manifest = $this->downloadProcessor->prepareDownload($path, null, 'bytes=1-3'); + + expect($manifest['status'])->toBe(200) + ->and($manifest['contentLength'])->toBe(11) + ->and($manifest['headers']['Accept-Ranges'])->toBe('none') + ->and($manifest['headers'])->not->toHaveKey('Content-Range'); +}); + +test('it streams complete download content', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'content.txt'; + file_put_contents($path, 'streamed-content'); + + $output = fopen('php://temp', 'rb+'); + $manifest = $this->downloadProcessor->streamDownload($path, $output); + rewind($output); + $downloaded = stream_get_contents($output); + fclose($output); + + expect($manifest['status'])->toBe(200) + ->and($manifest['bytesSent'])->toBe(strlen('streamed-content')) + ->and($downloaded)->toBe('streamed-content'); +}); + +test('it streams ranged download content', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'content.txt'; + file_put_contents($path, 'streamed-content'); + + $output = fopen('php://temp', 'rb+'); + $manifest = $this->downloadProcessor->streamDownload($path, $output, null, 'bytes=9-15'); + rewind($output); + $downloaded = stream_get_contents($output); + fclose($output); + + expect($manifest['status'])->toBe(206) + ->and($manifest['bytesSent'])->toBe(7) + ->and($downloaded)->toBe('content'); +}); + +test('it enforces maximum download size', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'large.txt'; + file_put_contents($path, '1234567890'); + + $this->downloadProcessor->setMaxDownloadSize(5); + + expect(fn() => $this->downloadProcessor->prepareDownload($path)) + ->toThrow(FileSizeExceededException::class, 'Download exceeds configured size limit'); +}); + +test('it throws when file does not exist', function () { + $path = $this->workingDir . DIRECTORY_SEPARATOR . 'missing.txt'; + + expect(fn() => $this->downloadProcessor->prepareDownload($path)) + ->toThrow(FileNotFoundException::class); +}); + +test('it prepares and streams downloads from a mounted filesystem path', function () { + $mountRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('download_mount_', true); + mkdir($mountRoot, 0755, true); + FlysystemHelper::mount('mnt', new Filesystem(new LocalFilesystemAdapter($mountRoot))); + + $path = 'mnt://downloads/report.txt'; + FlysystemHelper::write($path, 'mounted-download-content'); + $this->downloadProcessor->setAllowedRoots(['mnt://downloads']); + + try { + $manifest = $this->downloadProcessor->prepareDownload($path, 'report.txt'); + + $output = fopen('php://temp', 'rb+'); + $streamedManifest = $this->downloadProcessor->streamDownload($path, $output); + rewind($output); + $downloaded = stream_get_contents($output); + fclose($output); + + expect($manifest['status'])->toBe(200) + ->and($manifest['contentLength'])->toBe(strlen('mounted-download-content')) + ->and($streamedManifest['bytesSent'])->toBe(strlen('mounted-download-content')) + ->and($downloaded)->toBe('mounted-download-content'); + } finally { + FlysystemHelper::unmount('mnt'); + FlysystemHelper::deleteDirectory($mountRoot); + } +}); + +test('it supports relative download paths with a default filesystem', function () { + $defaultRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('download_default_', true); + mkdir($defaultRoot, 0755, true); + FlysystemHelper::setDefaultFilesystem(new Filesystem(new LocalFilesystemAdapter($defaultRoot))); + + $path = 'docs/relative.txt'; + FlysystemHelper::write($path, 'default-download-content'); + $this->downloadProcessor->setAllowedRoots(['docs']); + + try { + $output = fopen('php://temp', 'rb+'); + $manifest = $this->downloadProcessor->streamDownload($path, $output); + rewind($output); + $downloaded = stream_get_contents($output); + fclose($output); + + expect($manifest['status'])->toBe(200) + ->and($manifest['bytesSent'])->toBe(strlen('default-download-content')) + ->and($downloaded)->toBe('default-download-content'); + } finally { + FlysystemHelper::clearDefaultFilesystem(); + FlysystemHelper::deleteDirectory($defaultRoot); + } +}); diff --git a/tests/Feature/FunctionsFlysystemTest.php b/tests/Feature/FunctionsFlysystemTest.php index 7e5cd3b..7c30748 100644 --- a/tests/Feature/FunctionsFlysystemTest.php +++ b/tests/Feature/FunctionsFlysystemTest.php @@ -29,3 +29,35 @@ ->and(FlysystemHelper::fileExists('mnt://helpers-copy/a.txt'))->toBeTrue() ->and(deleteDirectory('mnt://helpers-copy'))->toBeTrue(); }); + +test('storage helper functions build and mount filesystems', function () { + $rootA = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('func_storage_a_', true); + $rootB = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('func_storage_b_', true); + mkdir($rootA, 0755, true); + mkdir($rootB, 0755, true); + + try { + $filesystem = createFilesystem([ + 'driver' => 'local', + 'root' => $rootA, + ]); + $filesystem->write('from_factory.txt', 'factory'); + + mountStorage('alpha', ['driver' => 'local', 'root' => $rootA]); + mountStorages([ + 'beta' => ['driver' => 'local', 'root' => $rootB], + ]); + + FlysystemHelper::write('alpha://hello.txt', 'A'); + FlysystemHelper::write('beta://hello.txt', 'B'); + + expect($filesystem->read('from_factory.txt'))->toBe('factory') + ->and(FlysystemHelper::read('alpha://hello.txt'))->toBe('A') + ->and(FlysystemHelper::read('beta://hello.txt'))->toBe('B'); + } finally { + FlysystemHelper::unmount('alpha'); + FlysystemHelper::unmount('beta'); + FlysystemHelper::deleteDirectory($rootA); + FlysystemHelper::deleteDirectory($rootB); + } +}); diff --git a/tests/Feature/StorageFactoryTest.php b/tests/Feature/StorageFactoryTest.php new file mode 100644 index 0000000..5a55851 --- /dev/null +++ b/tests/Feature/StorageFactoryTest.php @@ -0,0 +1,201 @@ + 'local', + 'root' => $root, + ]); + + $filesystem->write('a.txt', 'hello'); + + expect($filesystem->read('a.txt'))->toBe('hello'); + } finally { + FlysystemHelper::deleteDirectory($root); + } +}); + +test('it mounts a filesystem from local driver config', function () { + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_mount_', true); + mkdir($root, 0755, true); + + try { + StorageFactory::mount('assets', [ + 'driver' => 'local', + 'root' => $root, + ]); + + FlysystemHelper::write('assets://reports/q1.txt', 'Q1'); + + expect(FlysystemHelper::read('assets://reports/q1.txt'))->toBe('Q1'); + } finally { + FlysystemHelper::unmount('assets'); + FlysystemHelper::deleteDirectory($root); + } +}); + +test('it creates a filesystem from a provided adapter', function () { + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_adapter_', true); + mkdir($root, 0755, true); + + try { + $filesystem = StorageFactory::createFilesystem([ + 'adapter' => new LocalFilesystemAdapter($root), + ]); + + $filesystem->write('b.txt', 'world'); + + expect($filesystem->read('b.txt'))->toBe('world'); + } finally { + FlysystemHelper::deleteDirectory($root); + } +}); + +test('it returns the provided filesystem instance as-is', function () { + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_passthrough_', true); + mkdir($root, 0755, true); + + try { + $filesystem = new Filesystem(new LocalFilesystemAdapter($root)); + $resolved = StorageFactory::createFilesystem(['filesystem' => $filesystem]); + + expect($resolved)->toBe($filesystem); + } finally { + FlysystemHelper::deleteDirectory($root); + } +}); + +test('it supports custom registered drivers', function () { + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_custom_', true); + mkdir($root, 0755, true); + + StorageFactory::registerDriver('custom-local', function (array $config) use ($root): Filesystem { + $base = (string) ($config['root'] ?? $root); + + return new Filesystem(new LocalFilesystemAdapter($base)); + }); + + try { + StorageFactory::mount('custom', [ + 'driver' => 'custom-local', + 'root' => $root, + ]); + + FlysystemHelper::write('custom://nested/file.txt', 'custom-data'); + + expect(FlysystemHelper::read('custom://nested/file.txt'))->toBe('custom-data') + ->and(StorageFactory::hasDriver('custom-local'))->toBeTrue() + ->and(StorageFactory::driverNames())->toContain('custom-local'); + } finally { + FlysystemHelper::unmount('custom'); + FlysystemHelper::deleteDirectory($root); + } +}); + +test('it exposes official adapter metadata and package lookup', function () { + $official = StorageFactory::officialDrivers(); + + expect($official)->toHaveKeys([ + 'local', + 'ftp', + 'inmemory', + 'read-only', + 'path-prefixing', + 'aws-s3', + 'async-aws-s3', + 'azure-blob-storage', + 'google-cloud-storage', + 'mongodb-gridfs', + 'sftp-v2', + 'sftp-v3', + 'webdav', + 'ziparchive', + ]) + ->and(StorageFactory::suggestedPackage('s3'))->toBe('league/flysystem-aws-s3-v3') + ->and(StorageFactory::suggestedPackage('in-memory'))->toBe('league/flysystem-memory') + ->and(StorageFactory::suggestedPackage('zip'))->toBe('league/flysystem-ziparchive'); +}); + +test('it mounts multiple storages from config map', function () { + $rootA = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_many_a_', true); + $rootB = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('storage_many_b_', true); + mkdir($rootA, 0755, true); + mkdir($rootB, 0755, true); + + try { + StorageFactory::mountMany([ + 'a' => ['driver' => 'local', 'root' => $rootA], + 'b' => ['driver' => 'local', 'root' => $rootB], + ]); + + FlysystemHelper::write('a://one.txt', 'A'); + FlysystemHelper::write('b://two.txt', 'B'); + + expect(FlysystemHelper::read('a://one.txt'))->toBe('A') + ->and(FlysystemHelper::read('b://two.txt'))->toBe('B'); + } finally { + FlysystemHelper::unmount('a'); + FlysystemHelper::unmount('b'); + FlysystemHelper::deleteDirectory($rootA); + FlysystemHelper::deleteDirectory($rootB); + } +}); + +test('it throws for unsupported driver', function () { + expect(fn () => StorageFactory::createFilesystem(['driver' => 'made-up-driver'])) + ->toThrow(InvalidArgumentException::class, 'Unsupported storage driver'); +}); + +test('it throws for local driver without root', function () { + expect(fn () => StorageFactory::createFilesystem(['driver' => 'local'])) + ->toThrow(InvalidArgumentException::class, 'Local driver requires a non-empty "root" path'); +}); + +test('it provides package guidance for missing official drivers', function () { + $adapterClass = StorageFactory::officialDrivers()['aws-s3']['adapter_class']; + + if (!class_exists($adapterClass)) { + expect(fn () => StorageFactory::createFilesystem(['driver' => 's3'])) + ->toThrow(InvalidArgumentException::class, 'league/flysystem-aws-s3-v3'); + + return; + } + + expect(fn () => StorageFactory::createFilesystem(['driver' => 's3'])) + ->toThrow(InvalidArgumentException::class, "requires either 'adapter' or 'constructor'"); +}); + +test('it supports in-memory driver when adapter package exists', function () { + $metadata = StorageFactory::officialDrivers()['inmemory']; + $adapterClass = $metadata['adapter_class']; + + if (!class_exists($adapterClass)) { + expect(fn () => StorageFactory::createFilesystem(['driver' => 'in-memory'])) + ->toThrow(InvalidArgumentException::class, $metadata['package']); + + return; + } + + $filesystem = StorageFactory::createFilesystem(['driver' => 'in-memory']); + $filesystem->write('memory.txt', 'memory-data'); + + expect($filesystem->read('memory.txt'))->toBe('memory-data'); +}); diff --git a/tests/Feature/UploadProcessorTest.php b/tests/Feature/UploadProcessorTest.php index e274ca2..d239ca7 100644 --- a/tests/Feature/UploadProcessorTest.php +++ b/tests/Feature/UploadProcessorTest.php @@ -3,8 +3,12 @@ use Infocyph\Pathwise\Exceptions\FileSizeExceededException; use Infocyph\Pathwise\Exceptions\UploadException; use Infocyph\Pathwise\StreamHandler\UploadProcessor; +use Infocyph\Pathwise\Utils\FlysystemHelper; +use League\Flysystem\Filesystem; +use League\Flysystem\Local\LocalFilesystemAdapter; beforeEach(function () { + FlysystemHelper::reset(); $this->uploadProcessor = new UploadProcessor(); $this->uploadDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('upload_dir_', true); }); @@ -20,6 +24,8 @@ } rmdir($this->uploadDir); } + + FlysystemHelper::reset(); }); test('it throws when naming strategy is invalid', function () { @@ -285,3 +291,111 @@ } } }); + +test('it processes upload to a mounted filesystem path', function () { + $mountRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('upload_mount_', true); + mkdir($mountRoot, 0755, true); + FlysystemHelper::mount('mnt', new Filesystem(new LocalFilesystemAdapter($mountRoot))); + + $this->uploadProcessor->setDirectorySettings('mnt://uploads'); + $this->uploadProcessor->setValidationSettings(['text/plain'], 1024 * 1024); + + $tmpFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('upload_mount_file_', true) . '.txt'; + file_put_contents($tmpFile, 'mounted-upload-content'); + + try { + $destination = $this->uploadProcessor->processUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tmpFile), + 'tmp_name' => $tmpFile, + 'name' => 'note.txt', + ]); + + expect($destination)->toStartWith('mnt://uploads/') + ->and(FlysystemHelper::fileExists($destination))->toBeTrue() + ->and(FlysystemHelper::read($destination))->toBe('mounted-upload-content'); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + + FlysystemHelper::unmount('mnt'); + FlysystemHelper::deleteDirectory($mountRoot); + } +}); + +test('it finalizes chunk upload on a mounted filesystem path', function () { + $mountRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('chunk_mount_', true); + mkdir($mountRoot, 0755, true); + FlysystemHelper::mount('mnt', new Filesystem(new LocalFilesystemAdapter($mountRoot))); + + $this->uploadProcessor->setDirectorySettings('mnt://uploads', false, 'mnt://tmp'); + $this->uploadProcessor->setValidationSettings(['text/plain'], 1024 * 1024); + + $uploadId = 'session_mounted_chunks'; + $parts = ['alpha ', 'beta ', 'gamma']; + + $tempParts = []; + + try { + foreach ($parts as $index => $content) { + $tmpPart = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("chunk_mount_{$index}_", true) . '.part'; + file_put_contents($tmpPart, $content); + $tempParts[] = $tmpPart; + + $this->uploadProcessor->processChunkUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tmpPart), + 'tmp_name' => $tmpPart, + 'name' => "chunk_{$index}.part", + ], $uploadId, $index, count($parts), 'assembled.txt'); + } + + $destination = $this->uploadProcessor->finalizeChunkUpload($uploadId); + + expect($destination)->toStartWith('mnt://uploads/') + ->and(FlysystemHelper::fileExists($destination))->toBeTrue() + ->and(FlysystemHelper::read($destination))->toBe('alpha beta gamma'); + } finally { + foreach ($tempParts as $part) { + if (file_exists($part)) { + unlink($part); + } + } + + FlysystemHelper::unmount('mnt'); + FlysystemHelper::deleteDirectory($mountRoot); + } +}); + +test('it processes upload with default filesystem using relative paths', function () { + $defaultRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('upload_default_', true); + mkdir($defaultRoot, 0755, true); + FlysystemHelper::setDefaultFilesystem(new Filesystem(new LocalFilesystemAdapter($defaultRoot))); + + $this->uploadProcessor->setDirectorySettings('uploads'); + $this->uploadProcessor->setValidationSettings(['text/plain'], 1024 * 1024); + + $tmpFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('upload_default_file_', true) . '.txt'; + file_put_contents($tmpFile, 'default-filesystem-content'); + + try { + $destination = $this->uploadProcessor->processUpload([ + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tmpFile), + 'tmp_name' => $tmpFile, + 'name' => 'relative.txt', + ]); + + expect(str_starts_with(str_replace('\\', '/', $destination), 'uploads/'))->toBeTrue() + ->and(FlysystemHelper::fileExists($destination))->toBeTrue() + ->and(FlysystemHelper::read($destination))->toBe('default-filesystem-content'); + } finally { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + + FlysystemHelper::clearDefaultFilesystem(); + FlysystemHelper::deleteDirectory($defaultRoot); + } +});