From 62d22735577a856930ecf62f3a88aedef612c724 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 14 Apr 2026 10:20:55 +0200 Subject: [PATCH] Initial release --- .gitattributes | 10 + .gitignore | 13 + LICENSE | 21 + README.md | 102 ++++ composer.json | 72 +++ config/files.php | 118 +++++ .../create_blax_filables_table.php.stub | 33 ++ .../create_blax_files_table.php.stub | 30 ++ docs/artisan-commands.md | 48 ++ docs/attaching-files.md | 260 ++++++++++ docs/configuration.md | 205 ++++++++ docs/file-operations.md | 180 +++++++ docs/image-optimization.md | 162 ++++++ docs/installation.md | 80 +++ docs/serving-files.md | 145 ++++++ docs/uploading.md | 198 ++++++++ phpunit.xml | 17 + pint.json | 3 + routes/files.php | 32 ++ src/Console/CleanupOrphanedFilesCommand.php | 44 ++ src/Enums/FileLinkType.php | 75 +++ src/Events/ChunkUploadProgress.php | 31 ++ src/FilesServiceProvider.php | 76 +++ src/Http/Controllers/FileUploadController.php | 79 +++ src/Http/Controllers/WarehouseController.php | 48 ++ src/Models/Filable.php | 105 ++++ src/Models/File.php | 453 +++++++++++++++++ src/Services/ChunkUploadService.php | 142 ++++++ src/Services/WarehouseService.php | 137 +++++ src/Traits/HasFiles.php | 312 ++++++++++++ tests/Unit/ChunkUploadServiceTest.php | 210 ++++++++ tests/Unit/FilableModelTest.php | 249 +++++++++ tests/Unit/FileLinkTypeTest.php | 94 ++++ tests/Unit/FileModelTest.php | 424 ++++++++++++++++ tests/Unit/HasFilesTest.php | 480 ++++++++++++++++++ tests/Unit/WarehouseServiceTest.php | 173 +++++++ 36 files changed, 4861 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/files.php create mode 100644 database/migrations/create_blax_filables_table.php.stub create mode 100644 database/migrations/create_blax_files_table.php.stub create mode 100644 docs/artisan-commands.md create mode 100644 docs/attaching-files.md create mode 100644 docs/configuration.md create mode 100644 docs/file-operations.md create mode 100644 docs/image-optimization.md create mode 100644 docs/installation.md create mode 100644 docs/serving-files.md create mode 100644 docs/uploading.md create mode 100644 phpunit.xml create mode 100644 pint.json create mode 100644 routes/files.php create mode 100644 src/Console/CleanupOrphanedFilesCommand.php create mode 100644 src/Enums/FileLinkType.php create mode 100644 src/Events/ChunkUploadProgress.php create mode 100644 src/FilesServiceProvider.php create mode 100644 src/Http/Controllers/FileUploadController.php create mode 100644 src/Http/Controllers/WarehouseController.php create mode 100644 src/Models/Filable.php create mode 100644 src/Models/File.php create mode 100644 src/Services/ChunkUploadService.php create mode 100644 src/Services/WarehouseService.php create mode 100644 src/Traits/HasFiles.php create mode 100644 tests/Unit/ChunkUploadServiceTest.php create mode 100644 tests/Unit/FilableModelTest.php create mode 100644 tests/Unit/FileLinkTypeTest.php create mode 100644 tests/Unit/FileModelTest.php create mode 100644 tests/Unit/HasFilesTest.php create mode 100644 tests/Unit/WarehouseServiceTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..624aa48 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +/.vscode export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.phpunit.cache export-ignore +/.phpunit.result.cache export-ignore +/phpunit.xml export-ignore +/pint.json export-ignore +/workbench export-ignore +/tests export-ignore +/docs export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3028cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/vendor/ +/node_modules/ +composer.lock +.phpunit.cache/ +.phpunit.result.cache +.php-cs-fixer.cache +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db +workbench/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..adf7df3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Blax Software + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..af6f0f5 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software) + +# Laravel Files + +A universal, plug-and-play file management system for Laravel. Upload, optimize, serve, and attach files to any Eloquent model — with disk-agnostic storage, automatic image optimization, chunked uploads, and a built-in warehouse endpoint. + +--- + +## Features + +- **Attach files to any model** — polymorphic MorphToMany relationship via the `HasFiles` trait +- **Role-based attachments** — tag files as `avatar`, `gallery`, `document`, etc. using the `FileLinkType` enum or custom strings +- **Disk-agnostic** — works with any Laravel filesystem disk (local, S3, GCS, …) +- **Automatic image optimization** — on-the-fly resizing and WebP conversion via [spatie/image](https://github.com/spatie/image) +- **Chunked uploads** — upload large files in pieces with real-time progress broadcasting +- **Warehouse endpoint** — a single route that resolves and serves any file by UUID, encrypted ID, or asset path +- **UUID primary keys** — every file gets a unique, non-sequential identifier +- **Artisan cleanup** — remove orphaned files that are no longer attached to any model + +--- + +## Quick Start + +```bash +composer require blax-software/laravel-files +``` + +Publish the config and migrations: + +```bash +php artisan vendor:publish --tag=files-config +php artisan vendor:publish --tag=files-migrations +php artisan migrate +``` + +Add the trait to any model: + +```php +use Blax\Files\Traits\HasFiles; + +class User extends Model +{ + use HasFiles; +} +``` + +Attach a file: + +```php +use Blax\Files\Enums\FileLinkType; + +$user = User::find(1); + +// Upload and attach in one call +$file = $user->uploadFile($request->file('avatar'), as: FileLinkType::Avatar, replace: true); + +// Get the avatar back +$avatar = $user->getAvatar(); +echo $avatar->url; // → /warehouse/019d8ab8-… +echo $avatar->size_human; // → "2.4 MB" +``` + +--- + +## Documentation + +| Guide | Description | +|--------------------------------------------------|---------------------------------------------------------------| +| [Installation](docs/installation.md) | Requirements, installation, configuration | +| [Attaching Files](docs/attaching-files.md) | The `HasFiles` trait, roles, attach/detach, reordering | +| [File Operations](docs/file-operations.md) | Creating files, reading/writing contents, duplication, scopes | +| [Uploading](docs/uploading.md) | Single uploads, chunked uploads, progress events | +| [Serving Files](docs/serving-files.md) | The Warehouse, inline responses, downloads | +| [Image Optimization](docs/image-optimization.md) | On-the-fly resizing, WebP, quality control | +| [Configuration](docs/configuration.md) | Full reference for `config/files.php` | +| [Artisan Commands](docs/artisan-commands.md) | `files:cleanup` and maintenance | + +--- + +## Optional Dependencies + +| Package | Purpose | +|------------------------------------|------------------------------------------------| +| `spatie/image ^3.8` | Image optimization and resizing | +| `blax-software/laravel-roles` | Access control on the warehouse endpoint | +| `blax-software/laravel-websockets` | Real-time chunk upload progress via WebSockets | + +--- + +## Testing + +```bash +composer test +# or +./vendor/bin/phpunit +``` + +--- + +## License + +MIT — see [LICENSE](LICENSE) for details. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0461add --- /dev/null +++ b/composer.json @@ -0,0 +1,72 @@ +{ + "name": "blax-software/laravel-files", + "type": "library", + "description": "Universal Laravel file management system — upload, optimize, serve, and attach files to any model.", + "keywords": [ + "files", + "upload", + "media", + "image", + "optimization", + "warehouse", + "chunk-upload", + "laravel", + "blax" + ], + "homepage": "http://www.blax.at", + "license": "MIT", + "authors": [ + { + "name": "Fabian Wagner", + "email": "fabian@blax.at", + "homepage": "https://www.blax.at", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "Blax\\Files\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Blax\\Files\\Tests\\": "tests", + "Workbench\\App\\": "workbench/app", + "Workbench\\Database\\Factories\\": "workbench/database/factories" + } + }, + "config": { + "sort-packages": true + }, + "require": { + "php": "^8.1", + "blax-software/laravel-workkit": "*", + "illuminate/container": "^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", + "illuminate/database": "^9.0|^10.0|^11.0|^12.0", + "illuminate/http": "^9.0|^10.0|^11.0|^12.0", + "illuminate/routing": "^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0" + }, + "require-dev": { + "laravel/framework": "*", + "laravel/pint": "^1.22", + "orchestra/testbench": "^10.4", + "phpunit/phpunit": "^12.2", + "spatie/image": "^3.8" + }, + "suggest": { + "blax-software/laravel-roles": "For access control on files via roles & permissions.", + "blax-software/laravel-websockets": "For real-time chunk upload progress via WebSockets.", + "spatie/image": "Required for automatic image optimization and resizing (^3.8)." + }, + "extra": { + "laravel": { + "providers": [ + "Blax\\Files\\FilesServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} \ No newline at end of file diff --git a/config/files.php b/config/files.php new file mode 100644 index 0000000..d05e64b --- /dev/null +++ b/config/files.php @@ -0,0 +1,118 @@ + [ + 'file' => \Blax\Files\Models\File::class, + 'filable' => \Blax\Files\Models\Filable::class, + ], + + /* + |-------------------------------------------------------------------------- + | Table Names + |-------------------------------------------------------------------------- + */ + + 'table_names' => [ + 'files' => 'files', + 'filables' => 'filables', + ], + + /* + |-------------------------------------------------------------------------- + | Default Disk + |-------------------------------------------------------------------------- + | + | The filesystem disk used for storing files. Any disk configured in + | config/filesystems.php is supported (local, s3, gcs, …). + | + */ + + 'disk' => env('FILES_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Storage Path Template + |-------------------------------------------------------------------------- + | + | The relative path template for new uploads. Placeholders: + | {user_id} – authenticated user ID or "anonymous" + | {uuid} – generated UUID of the file + | {date} – Y/m/d subdirectory + | + */ + + 'storage_path' => 'files/{date}/{uuid}', + + /* + |-------------------------------------------------------------------------- + | Warehouse Route + |-------------------------------------------------------------------------- + | + | These settings control the public warehouse route that serves files. + | + */ + + 'warehouse' => [ + 'enabled' => true, + 'prefix' => 'warehouse', + 'middleware' => ['web'], + ], + + /* + |-------------------------------------------------------------------------- + | Upload Settings + |-------------------------------------------------------------------------- + */ + + 'upload' => [ + 'max_size' => 50 * 1024, // KB (50 MB) + 'chunk_size' => 1024, // KB per chunk (1 MB) + 'allowed_mimes' => [], // empty = allow all + 'route_prefix' => 'api/files', + 'middleware' => ['api', 'auth:sanctum'], + ], + + /* + |-------------------------------------------------------------------------- + | Image Optimization + |-------------------------------------------------------------------------- + | + | Requires spatie/image ^3.8. Optimization is skipped if not installed. + | + */ + + 'optimization' => [ + 'enabled' => true, + 'default_quality' => 85, + 'webp_conversion' => true, + 'round_dimensions' => true, + 'round_to' => 50, + 'skip_formats' => ['gif', 'svg', 'svg+xml'], + 'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'], + ], + + /* + |-------------------------------------------------------------------------- + | Access Control + |-------------------------------------------------------------------------- + | + | When laravel-roles is installed, files can be protected via access + | checks. Set 'enabled' to false to serve all files publicly. + | + */ + + 'access_control' => [ + 'enabled' => false, + ], + +]; diff --git a/database/migrations/create_blax_filables_table.php.stub b/database/migrations/create_blax_filables_table.php.stub new file mode 100644 index 0000000..aa80c6e --- /dev/null +++ b/database/migrations/create_blax_filables_table.php.stub @@ -0,0 +1,33 @@ +id(); + $table->uuid('file_id'); + $table->morphs('filable'); + $table->string('as')->nullable()->index(); + $table->smallInteger('order')->nullable()->default(null); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->foreign('file_id') + ->references('id') + ->on(config('files.table_names.files', 'files')) + ->cascadeOnDelete(); + + $table->unique(['file_id', 'filable_type', 'filable_id', 'as'], 'filables_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists(config('files.table_names.filables', 'filables')); + } +}; diff --git a/database/migrations/create_blax_files_table.php.stub b/database/migrations/create_blax_files_table.php.stub new file mode 100644 index 0000000..8c51246 --- /dev/null +++ b/database/migrations/create_blax_files_table.php.stub @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->string('name')->nullable(); + $table->string('type')->nullable(); + $table->string('extension')->nullable(); + $table->unsignedBigInteger('size')->nullable(); + $table->string('disk')->default(config('files.disk', 'local')); + $table->string('relativepath')->nullable(); + $table->json('meta')->nullable(); + $table->timestamp('last_accessed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists(config('files.table_names.files', 'files')); + } +}; diff --git a/docs/artisan-commands.md b/docs/artisan-commands.md new file mode 100644 index 0000000..391c71a --- /dev/null +++ b/docs/artisan-commands.md @@ -0,0 +1,48 @@ +# Artisan Commands + +## `files:cleanup` + +Remove orphaned files — files that are not attached to any model. + +```bash +php artisan files:cleanup +``` + +### Options + +| Option | Default | Description | +|-------------|---------|---------------------------------------------------------| +| `--days=N` | `30` | Only delete orphans older than N days | +| `--dry-run` | — | Preview what would be deleted without actually deleting | + +### Examples + +```bash +# Preview orphaned files older than 30 days +php artisan files:cleanup --dry-run + +# Delete orphaned files older than 7 days +php artisan files:cleanup --days=7 + +# Delete all orphans older than 90 days +php artisan files:cleanup --days=90 +``` + +### What Counts as Orphaned? + +A file is orphaned when it has **zero** entries in the `filables` pivot table — meaning no model references it. This uses the `File::orphaned()` scope internally. + +### Scheduling + +Add to your `app/Console/Kernel.php` (or `routes/console.php` in Laravel 11+): + +```php +Schedule::command('files:cleanup --days=30')->daily(); +``` + +### Safety + +- Only files older than the `--days` threshold are eligible +- Each file's disk contents and resized variants are deleted along with the model +- Pivot entries (if any remained) are also cleaned up +- Use `--dry-run` first to verify the impact diff --git a/docs/attaching-files.md b/docs/attaching-files.md new file mode 100644 index 0000000..21d3cbb --- /dev/null +++ b/docs/attaching-files.md @@ -0,0 +1,260 @@ +# Attaching Files to Models + +The `HasFiles` trait connects any Eloquent model to the file system. Add the trait, and your model gains a polymorphic many-to-many relationship with the `File` model. + +## Setup + +```php +use Blax\Files\Traits\HasFiles; + +class Product extends Model +{ + use HasFiles; +} +``` + +No further configuration needed — the trait registers a `files()` relationship automatically. + +## The `files()` Relationship + +```php +$product->files; // Collection of all attached File models +$product->files()->count(); // number of files +$product->files()->get(); // query builder — add your own constraints +``` + +Every pivot row carries three extra columns: `as` (role), `order`, and `meta` (JSON). + +--- + +## Attaching Files + +### `attachFile()` + +```php +$product->attachFile($file, as: FileLinkType::Gallery, order: 0); +``` + +| Parameter | Type | Default | Description | +|------------|------------------------------|---------|------------------------------------------------------------------| +| `$file` | `File\|string` | — | A File model or its UUID | +| `$as` | `string\|FileLinkType\|null` | `null` | Role / category for this attachment | +| `$order` | `?int` | `null` | Sort position | +| `$meta` | `?array` | `null` | Arbitrary metadata stored as JSON on the pivot | +| `$replace` | `bool` | `false` | If `true`, removes existing attachments with the same role first | + +Duplicate prevention is built-in — attaching the same file with the same role twice is a no-op. + +#### Replace Mode + +Use `replace: true` for singular fields like avatars: + +```php +$user->attachFile($newAvatar, as: FileLinkType::Avatar, replace: true); +// The old avatar is detached; the new one takes its place. +``` + +#### Storing Metadata + +```php +$product->attachFile($file, as: 'document', meta: [ + 'description' => 'Product specification sheet', + 'version' => '2.1', +]); +``` + +### Method Chaining + +All attach/detach methods return `$this`, so you can chain: + +```php +$product + ->attachFile($logo, as: FileLinkType::Logo, replace: true) + ->attachFile($hero, as: FileLinkType::Banner, replace: true) + ->attachFile($spec, as: FileLinkType::Document); +``` + +--- + +## Detaching Files + +### `detachFile()` + +Remove a specific file (optionally scoped by role): + +```php +$product->detachFile($file); +$product->detachFile($file, as: 'gallery'); +``` + +### `detachFilesAs()` + +Remove **all** files with a given role: + +```php +$product->detachFilesAs(FileLinkType::Gallery); +$product->detachFilesAs('document'); +``` + +### `detachAllFiles()` + +Remove every file attachment from the model: + +```php +$product->detachAllFiles(); +``` + +> **Note:** Detaching does not delete the `File` record or its contents — it only removes the pivot link. To delete a file permanently, call `$file->delete()`. + +--- + +## Querying Attached Files + +### By Role + +```php +// Get all gallery images +$images = $product->filesAs(FileLinkType::Gallery)->get(); + +// Get the first document +$doc = $product->fileAs('document'); +``` + +### Convenience Getters + +These methods resolve common roles with built-in fallback logic: + +```php +$user->getAvatar(); // tries Avatar, then ProfileImage +$user->getThumbnail(); +$user->getBanner(); +$user->getCoverImage(); +$user->getBackground(); +$user->getLogo(); +$user->getGallery(); // returns a Collection +``` + +Each returns a `?File` (or a `Collection` for `getGallery()`). + +--- + +## Pivot Access + +### Reading Pivot Data + +```php +$pivot = $product->getFilePivot($file); // returns ?Filable + +$pivot->as; // 'gallery' +$pivot->order; // 2 +$pivot->meta; // ['description' => '...'] +``` + +### Updating Pivot Data + +The `Filable` model provides convenient setters: + +```php +$pivot->setAs(FileLinkType::Banner); // updates and saves +$pivot->setOrder(5); // updates and saves +$pivot->getLinkType(); // returns ?FileLinkType enum +``` + +--- + +## Reordering Files + +Reorder a set of files by passing their IDs in the desired order: + +```php +$product->reorderFiles([ + $fileC->id, + $fileA->id, + $fileB->id, +]); +``` + +Scope to a specific role: + +```php +$product->reorderFiles($ids, as: FileLinkType::Gallery); +``` + +The pivot `order` column is set to the array index (0, 1, 2, …). Files are auto-sorted by `order` thanks to a global scope on the `Filable` model. + +--- + +## Upload Helpers + +The trait includes three convenience methods that create a file **and** attach it in a single call: + +### `uploadFile()` + +Upload from a Laravel `UploadedFile` (e.g. `$request->file('photo')`): + +```php +$file = $product->uploadFile( + $request->file('photo'), + as: FileLinkType::Gallery, + order: 0, + replace: false, +); +``` + +### `uploadFileFromContents()` + +Create a file from raw string content: + +```php +$file = $product->uploadFileFromContents( + contents: $pdfBinary, + name: 'invoice-2024', + extension: 'pdf', + as: FileLinkType::Invoice, + replace: true, +); +``` + +### `uploadFileFromUrl()` + +Download from a remote URL and attach: + +```php +$file = $product->uploadFileFromUrl( + url: 'https://example.com/photo.jpg', + name: 'product-hero', + as: FileLinkType::Banner, + replace: true, +); +``` + +All three return the created `File` model. + +--- + +## FileLinkType Enum + +The `FileLinkType` enum provides 19 predefined roles grouped into categories: + +| Group | Cases | +|-----------------|---------------------------------------------------------------------------------------------| +| Visual Identity | `Avatar`, `ProfileImage`, `CoverImage`, `Banner`, `Background`, `Logo`, `Icon`, `Thumbnail` | +| Documents | `Document`, `Invoice`, `Contract`, `Certificate`, `Report` | +| Media | `Gallery`, `Video`, `Audio` | +| Attachments | `Attachment`, `Download` | +| Catch-All | `Other` | + +```php +use Blax\Files\Enums\FileLinkType; + +FileLinkType::Avatar->value; // 'avatar' +FileLinkType::Avatar->label(); // 'Avatar' +FileLinkType::Avatar->isImage(); // true +FileLinkType::Document->isImage(); // false +``` + +You can also pass plain strings as the `$as` parameter if the built-in enum doesn't fit your use case. + +--- + +Next: [File Operations](file-operations.md) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..c202e33 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,205 @@ +# Configuration Reference + +After publishing the config file (`php artisan vendor:publish --tag=files-config`), you'll find `config/files.php` with the following sections. + +--- + +## Models + +Override the default models with your own implementations: + +```php +'models' => [ + 'file' => \Blax\Files\Models\File::class, + 'filable' => \Blax\Files\Models\Filable::class, +], +``` + +Custom models should extend the package models. + +--- + +## Table Names + +```php +'table_names' => [ + 'files' => 'files', + 'filables' => 'filables', +], +``` + +Change these **before** running migrations if you need custom table names. + +--- + +## Disk + +```php +'disk' => env('FILES_DISK', 'local'), +``` + +Any disk from `config/filesystems.php` works: `local`, `public`, `s3`, etc. Set via `FILES_DISK` in your `.env`. + +--- + +## Storage Path Template + +```php +'storage_path' => 'files/{date}/{uuid}', +``` + +Defines how uploaded files are organized on disk. Available placeholders: + +| Placeholder | Value | +|-------------|-----------------------------------------| +| `{user_id}` | Authenticated user ID, or `"anonymous"` | +| `{uuid}` | File's UUID | +| `{date}` | Current date as `Y/m/d` | + +Examples: + +```php +'storage_path' => 'uploads/{user_id}/{date}/{uuid}', +'storage_path' => 'files/{uuid}', +``` + +--- + +## Warehouse + +```php +'warehouse' => [ + 'enabled' => true, + 'prefix' => 'warehouse', + 'middleware' => ['web'], +], +``` + +| Key | Type | Default | Description | +|--------------|----------|---------------|---------------------------------------| +| `enabled` | `bool` | `true` | Register the warehouse route | +| `prefix` | `string` | `'warehouse'` | URL prefix for the route | +| `middleware` | `array` | `['web']` | Middleware group applied to the route | + +--- + +## Upload + +```php +'upload' => [ + 'max_size' => 50 * 1024, // 50 MB in KB + 'chunk_size' => 1024, // 1 MB per chunk + 'allowed_mimes' => [], // empty = allow all + 'route_prefix' => 'api/files', + 'middleware' => ['api', 'auth:sanctum'], +], +``` + +| Key | Type | Default | Description | +|-----------------|----------|---------------------------|-----------------------------------------------------| +| `max_size` | `int` | `51200` | Max file size in KB | +| `chunk_size` | `int` | `1024` | Chunk size in KB | +| `allowed_mimes` | `array` | `[]` | Allowed MIME types/extensions. Empty = all allowed. | +| `route_prefix` | `string` | `'api/files'` | URL prefix for upload routes | +| `middleware` | `array` | `['api', 'auth:sanctum']` | Middleware for upload routes | + +### Restricting MIME Types + +```php +'allowed_mimes' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'docx'], +``` + +This is enforced at the controller level via Laravel's `mimes` validation rule. + +--- + +## Image Optimization + +```php +'optimization' => [ + 'enabled' => true, + 'default_quality' => 85, + 'webp_conversion' => true, + 'round_dimensions' => true, + 'round_to' => 50, + 'skip_formats' => ['gif', 'svg', 'svg+xml'], + 'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'], +], +``` + +| Key | Type | Default | Description | +|------------------------|---------|-----------------------------------------|--------------------------------------------------| +| `enabled` | `bool` | `true` | Enable image optimization features | +| `default_quality` | `int` | `85` | Default JPEG/WebP quality (1–100) | +| `webp_conversion` | `bool` | `true` | Convert images to WebP by default | +| `round_dimensions` | `bool` | `true` | Round resize dimensions to reduce cache variants | +| `round_to` | `int` | `50` | Rounding step in pixels | +| `skip_formats` | `array` | `['gif', 'svg', 'svg+xml']` | Formats that bypass optimization | +| `preferred_extensions` | `array` | `['svg', 'webp', 'png', 'jpg', 'jpeg']` | Extension order for auto-resolution | + +See [Image Optimization](image-optimization.md) for detailed usage. + +--- + +## Access Control + +```php +'access_control' => [ + 'enabled' => false, +], +``` + +| Key | Type | Default | Description | +|-----------|--------|---------|--------------------------------------------------------| +| `enabled` | `bool` | `false` | Enable role-based access checks on the warehouse route | + +Requires [laravel-roles](https://github.com/blax-software/laravel-roles). See [Serving Files](serving-files.md#access-control). + +--- + +## Full Default Config + +```php + [ + 'file' => \Blax\Files\Models\File::class, + 'filable' => \Blax\Files\Models\Filable::class, + ], + 'table_names' => [ + 'files' => 'files', + 'filables' => 'filables', + ], + 'disk' => env('FILES_DISK', 'local'), + 'storage_path' => 'files/{date}/{uuid}', + 'warehouse' => [ + 'enabled' => true, + 'prefix' => 'warehouse', + 'middleware' => ['web'], + ], + 'upload' => [ + 'max_size' => 50 * 1024, + 'chunk_size' => 1024, + 'allowed_mimes' => [], + 'route_prefix' => 'api/files', + 'middleware' => ['api', 'auth:sanctum'], + ], + 'optimization' => [ + 'enabled' => true, + 'default_quality' => 85, + 'webp_conversion' => true, + 'round_dimensions' => true, + 'round_to' => 50, + 'skip_formats' => ['gif', 'svg', 'svg+xml'], + 'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'], + ], + 'access_control' => [ + 'enabled' => false, + ], +]; +``` + +--- + +Next: [Artisan Commands](artisan-commands.md) diff --git a/docs/file-operations.md b/docs/file-operations.md new file mode 100644 index 0000000..28d8045 --- /dev/null +++ b/docs/file-operations.md @@ -0,0 +1,180 @@ +# File Operations + +The `File` model is the central entity. It uses UUID primary keys, stores metadata, and wraps disk operations for reading, writing, and deleting file contents. + +## Creating a File + +```php +use Blax\Files\Models\File; + +$file = new File; +$file->name = 'report'; +$file->extension = 'pdf'; +$file->save(); +``` + +On save, the model automatically sets: +- `disk` — from `config('files.disk')` (default: `local`) +- `relativepath` — generated from the `storage_path` template (default: `files/{date}/{uuid}`) +- `user_id` — from the authenticated user, if available + +## Writing Content + +### From a string + +```php +$file->putContents('Hello, world!'); +``` + +### From a local file path + +```php +$file->putContentsFromPath('/tmp/export.csv'); +``` + +### From a URL + +```php +$file->putContentsFromUrl('https://example.com/data.json'); +``` + +### From an `UploadedFile` + +```php +$file->putContentsFromUpload($request->file('document')); +``` + +This also sets `name`, `extension`, and `type` from the upload metadata if they aren't already set. + +All `putContents*` methods auto-detect `extension`, `type` (MIME), and `size`, then persist the model. + +--- + +## Reading Content + +```php +$contents = $file->getContents(); // raw string or null +$exists = $file->hasContents(); // bool +``` + +--- + +## Deleting Content + +```php +$file->deleteContents(); // removes file from disk (and resized variants) +$file->delete(); // deletes the model + contents + pivot entries +``` + +When a `File` model is deleted, the `deleting` event automatically calls `deleteContents()` and removes all `filables` pivot rows. + +--- + +## Serving Files + +### Inline Response + +```php +return $file->respond(); // serves the file inline +return $file->respond($request); // uses request params for resizing +``` + +When the request includes a `size` parameter and the file is an image, `respond()` automatically serves a resized variant. See [Image Optimization](image-optimization.md). + +### Download Response + +```php +return $file->download(); // downloads as "name.ext" +return $file->download('custom-name.pdf'); // custom filename +``` + +--- + +## Duplicating a File + +```php +$copy = $file->duplicate(); // "report (copy)" +$copy = $file->duplicate('report-backup'); // custom name +``` + +Creates a new `File` record with a new UUID and copies the disk contents. The copy is independent — changing one does not affect the other. + +--- + +## Checking Image Status + +```php +$file->isImage(); // true if MIME starts with "image" or extension is an image type +``` + +Recognized image extensions: `jpg`, `jpeg`, `png`, `gif`, `webp`, `svg`, `bmp`, `ico`. + +--- + +## Accessors + +| Accessor | Returns | Example | +|---------------------|----------------------|--------------------------------------| +| `$file->path` | Absolute local path | `/storage/files/2024/06/15/abc-123` | +| `$file->url` | Public warehouse URL | `https://app.test/warehouse/abc-123` | +| `$file->size_human` | Human-readable size | `2.4 MB` | + +`url` and `size_human` are also included when the model is serialized via `toArray()` or `toJson()`. + +--- + +## Scopes + +```php +// Only images +File::images()->get(); + +// By extension +File::byExtension('pdf', 'docx')->get(); + +// By disk +File::byDisk('s3')->get(); + +// Orphaned files (not attached to any model) +File::orphaned()->get(); + +// Recent files (last N days, default 7) +File::recent()->get(); +File::recent(30)->get(); +``` + +--- + +## Metadata + +The `meta` column is cast to JSON. Use it for arbitrary structured data: + +```php +$file->meta = ['source' => 'import', 'batch_id' => 42]; +$file->save(); + +$file->meta['source']; // 'import' +``` + +--- + +## Model Attributes + +| Column | Type | Description | +|--------------------|----------------|------------------------------------------------| +| `id` | UUID string | Primary key (auto-generated) | +| `user_id` | string\|null | Owner (auto-filled from auth) | +| `name` | string\|null | Display name (without extension) | +| `extension` | string\|null | File extension (`pdf`, `jpg`, …) | +| `type` | string\|null | MIME type (`application/pdf`, `image/jpeg`, …) | +| `size` | int\|null | Size in bytes | +| `disk` | string | Filesystem disk name | +| `relativepath` | string | Relative path on the disk | +| `meta` | json\|null | Arbitrary metadata | +| `last_accessed_at` | datetime\|null | Tracking field | +| `created_at` | datetime | | +| `updated_at` | datetime | | + +--- + +Next: [Uploading Files](uploading.md) diff --git a/docs/image-optimization.md b/docs/image-optimization.md new file mode 100644 index 0000000..25e0b4e --- /dev/null +++ b/docs/image-optimization.md @@ -0,0 +1,162 @@ +# Image Optimization + +The package provides on-the-fly image resizing with caching, WebP conversion, and configurable quality. Requires `spatie/image`. + +## Requirements + +```bash +composer require spatie/image "^3.8" +``` + +If `spatie/image` is not installed, all resizing calls gracefully return the original file path. + +--- + +## Resizing via URL + +When serving images through the [Warehouse](serving-files.md), append query parameters: + +``` +/warehouse/{id}?size=300x200 +/warehouse/{id}?size=400&quality=90 +/warehouse/{id}?size=800x600&webp=false&position=contain +``` + +### Query Parameters + +| Param | Type | Default | Description | +|------------|----------|---------|---------------------------------------------------------------------------------------------| +| `size` | `string` | — | Dimensions as `WIDTHxHEIGHT` (e.g. `300x200`). Use a single number for square (e.g. `300`). | +| `quality` | `int` | `85` | JPEG/WebP quality (1–100) | +| `webp` | `bool` | `true` | Convert output to WebP | +| `position` | `string` | `cover` | Fit mode: `cover`, `contain`, `fill`, `max`, `stretch` | +| `cached` | `bool` | `true` | Use cached resize if available | +| `rounding` | `bool` | `true` | Round dimensions to nearest step | + +--- + +## Resizing Programmatically + +Use the `resizedPath()` method to generate a resized variant: + +```php +$path = $file->resizedPath( + width: 300, + height: 200, + toWebp: true, + quality: 85, + position: 'cover', +); +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-------------|---------------------|-----------|-----------------------------------------------| +| `$width` | `string\|int\|null` | — | Target width. Use `'auto'` for proportional. | +| `$height` | `string\|int\|null` | — | Target height. Use `'auto'` for proportional. | +| `$rounding` | `bool` | `true` | Round to nearest step (default: 50px) | +| `$toWebp` | `bool` | `true` | Convert to WebP format | +| `$cached` | `bool` | `true` | Return cached version if available | +| `$quality` | `?int` | `null` | Quality (1–100). `null` uses config default. | +| `$position` | `string` | `'cover'` | Fit mode | + +The method returns an absolute file path to the resized image. + +--- + +## Fit Modes + +| Mode | Behavior | +|-----------|------------------------------------------------| +| `cover` | Crop to fill exact dimensions (default) | +| `contain` | Fit within dimensions, preserving aspect ratio | +| `fill` | Fill dimensions, padding if necessary | +| `max` | Resize within dimensions, no upscaling | +| `stretch` | Stretch to exact dimensions | + +--- + +## Caching + +Resized images are cached on disk in a `resized/` subdirectory next to the original: + +``` +files/2024/06/15/abc-123 ← original +files/2024/06/15/resized/ + abc-123.300x200.jpg.webp ← resized variant + abc-123.800x600.contain.q90.jpg ← another variant +``` + +Cache key components: `{width}x{height}`, `.{position}` (if not cover), `.q{quality}` (if not default). Requesting the same size again serves from cache instantly. + +When a file is deleted, all resized variants are cleaned up automatically. + +--- + +## Dimension Rounding + +To reduce cache fragmentation, dimensions are rounded **up** to the nearest step (default: 50px): + +| Requested | Rounded to | +|-----------|------------| +| `280x180` | `300x200` | +| `310x310` | `350x350` | +| `50x50` | `50x50` | + +Configure the step size: + +```php +// config/files.php +'optimization' => [ + 'round_to' => 100, // round to nearest 100px +], +``` + +Disable rounding per request with `?rounding=false` or `rounding: false`. + +--- + +## Skipped Formats + +Some formats cannot be processed by image libraries and are served as-is: + +```php +'optimization' => [ + 'skip_formats' => ['gif', 'svg', 'svg+xml'], +], +``` + +--- + +## Preferred Extensions (Asset Auto-Resolution) + +When the warehouse receives a path without an extension (e.g. `/warehouse/images/logo`), it tries these extensions in order: + +```php +'optimization' => [ + 'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'], +], +``` + +--- + +## Configuration Reference + +All optimization settings in `config/files.php`: + +```php +'optimization' => [ + 'enabled' => true, + 'default_quality' => 85, // default JPEG/WebP quality + 'webp_conversion' => true, // convert to WebP by default + 'round_dimensions' => true, // round sizes to reduce cache variants + 'round_to' => 50, // rounding step in pixels + 'skip_formats' => ['gif', 'svg', 'svg+xml'], + 'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'], +], +``` + +--- + +Next: [Configuration](configuration.md) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..666a266 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,80 @@ +# Installation + +## Requirements + +- PHP 8.1+ +- Laravel 9, 10, 11, or 12 + +## Install via Composer + +```bash +composer require blax-software/laravel-files +``` + +The service provider is auto-discovered. No manual registration needed. + +## Publish Config + +```bash +php artisan vendor:publish --tag=files-config +``` + +This creates `config/files.php` where you can customize models, table names, disk, storage paths, upload limits, optimization settings, and more. See the [Configuration Reference](configuration.md) for details. + +## Publish & Run Migrations + +```bash +php artisan vendor:publish --tag=files-migrations +php artisan migrate +``` + +This creates two tables: + +| Table | Purpose | +|------------|----------------------------------------------------------------------------------------| +| `files` | Stores file metadata (UUID primary key, name, extension, type, size, disk, path, meta) | +| `filables` | Polymorphic pivot — links files to any model with a role (`as`), order, and meta | + +## Environment Variables + +Add to your `.env` if you want to change the storage disk: + +```env +FILES_DISK=s3 +``` + +By default, files are stored on the `local` disk. + +## Optional: Image Optimization + +To enable automatic image resizing and WebP conversion: + +```bash +composer require spatie/image "^3.8" +``` + +No further configuration needed — the package detects `spatie/image` at runtime. + +## Optional: Access Control + +To protect files behind role-based access checks, install [laravel-roles](https://github.com/blax-software/laravel-roles) and enable it in `config/files.php`: + +```php +'access_control' => [ + 'enabled' => true, +], +``` + +## Optional: WebSocket Progress + +For real-time chunk upload progress, install [laravel-websockets](https://github.com/blax-software/laravel-websockets): + +```bash +composer require blax-software/laravel-websockets +``` + +The `ChunkUploadProgress` event is broadcast automatically when websockets are available. + +--- + +Next: [Attaching Files](attaching-files.md) diff --git a/docs/serving-files.md b/docs/serving-files.md new file mode 100644 index 0000000..6b1b30b --- /dev/null +++ b/docs/serving-files.md @@ -0,0 +1,145 @@ +# Serving Files (Warehouse) + +The **Warehouse** is a public-facing route that resolves file identifiers and serves the contents. It acts as a unified file-serving gateway. + +## Warehouse Route + +``` +GET /warehouse/{identifier?} +``` + +**Middleware:** `web` (configurable) + +The identifier can be: + +| Format | Example | +|--------------|-------------------------------------------------| +| UUID | `9c3a7b2e-...` | +| Encrypted ID | `eyJpdiI6...` (legacy support) | +| Asset path | `images/logo` (auto-tries preferred extensions) | +| Storage path | `files/2024/06/15/abc-123` | + +### Resolution Order + +The `WarehouseService::searchFile()` method tries each strategy in order: + +1. **UUID** — direct `File::find($identifier)` +2. **Encrypted ID** — `decrypt()` → `File::find()` +3. **Asset path** — check if file exists on disk; if no extension is provided, try `svg`, `webp`, `png`, `jpg`, `jpeg` in order +4. **Raw storage path** — strip `storage/` prefix and check disk + +The first match wins. If nothing is found, a `404` is returned. + +### Example URLs + +``` +https://app.test/warehouse/9c3a7b2e-1234-5678-abcd-ef0123456789 +https://app.test/warehouse/images/logo +https://app.test/warehouse/images/logo?size=200x200 +``` + +--- + +## Serving Files Programmatically + +### Inline Response + +```php +return $file->respond(); // serves inline with correct content type +return $file->respond($request); // enables image resizing via query params +``` + +### Download Response + +```php +return $file->download(); // "filename.ext" +return $file->download('custom-name.pdf'); // override download name +``` + +### Generating URLs + +```php +use Blax\Files\Services\WarehouseService; + +// From a File model +$url = $file->url; // https://app.test/warehouse/{uuid} + +// From a service +$url = WarehouseService::url($file); // same as above +$url = WarehouseService::url($fileId); // pass UUID string +``` + +--- + +## Image Resizing via Query Params + +When serving images through the warehouse, you can request resized variants: + +``` +/warehouse/{id}?size=300x200 +/warehouse/{id}?size=400x400&quality=90&webp=true +/warehouse/{id}?size=800x600&position=contain +``` + +See [Image Optimization](image-optimization.md) for all parameters. + +--- + +## Access Control + +By default, all files are served publicly. To restrict access: + +### 1. Enable in config + +```php +// config/files.php +'access_control' => [ + 'enabled' => true, +], +``` + +### 2. Install laravel-roles + +```bash +composer require blax-software/laravel-roles +``` + +### 3. Behavior + +When enabled, the `WarehouseController`: + +1. Checks if the request user is authenticated (403 if not) +2. Calls `$user->hasAccess($file)` via the `HasAccess` trait from laravel-roles (403 if denied) +3. Serves the file if access is granted + +Access control only applies to persisted `File` records. Non-persisted asset-path lookups bypass the check. + +--- + +## Disabling the Warehouse + +```php +// config/files.php +'warehouse' => [ + 'enabled' => false, +], +``` + +When disabled, no warehouse routes are registered. You can still serve files manually using `$file->respond()` or `$file->download()`. + +--- + +## Customizing the Route + +```php +// config/files.php +'warehouse' => [ + 'enabled' => true, + 'prefix' => 'files', // changes URL to /files/{id} + 'middleware' => ['web', 'auth'], // add authentication +], +``` + +--- + +Next: [Image Optimization](image-optimization.md) diff --git a/docs/uploading.md b/docs/uploading.md new file mode 100644 index 0000000..d7deb9d --- /dev/null +++ b/docs/uploading.md @@ -0,0 +1,198 @@ +# Uploading Files + +The package provides a complete upload API with support for single-file uploads and chunked uploads for large files. + +## Single File Upload + +### Endpoint + +``` +POST /api/files/upload +``` + +**Middleware:** `api`, `auth:sanctum` (configurable) + +### Request + +Send a multipart form upload with a `file` field: + +```bash +curl -X POST https://app.test/api/files/upload \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@photo.jpg" +``` + +### Validation + +- **Max size:** 50 MB (configurable via `files.upload.max_size`) +- **Allowed MIME types:** all by default; restrict via `files.upload.allowed_mimes` + +```php +// config/files.php +'upload' => [ + 'max_size' => 50 * 1024, // KB + 'allowed_mimes' => ['jpg', 'png', 'pdf', 'docx'], // empty = allow all +], +``` + +### Response (201) + +```json +{ + "id": "9c3a...", + "name": "photo", + "type": "image/jpeg", + "extension": "jpg", + "size": 245760, + "size_human": "240 KB", + "url": "https://app.test/warehouse/9c3a..." +} +``` + +--- + +## Chunked Upload + +For large files that exceed browser or server limits, use the chunked upload flow. + +### Step 1: Initialize + +``` +POST /api/files/chunk/init +``` + +**Body (JSON):** + +```json +{ + "filename": "video.mp4", + "filesize": 104857600, + "total_chunks": 100, + "mime_type": "video/mp4", + "extension": "mp4" +} +``` + +**Response (201):** + +```json +{ + "upload_id": "9c3a...", + "file_id": "9c3a...", + "total_chunks": 100 +} +``` + +### Step 2: Upload Chunks + +``` +POST /api/files/chunk/upload +``` + +Send each chunk as a multipart upload or raw body: + +```bash +curl -X POST https://app.test/api/files/chunk/upload \ + -H "Authorization: Bearer $TOKEN" \ + -F "upload_id=9c3a..." \ + -F "chunk_index=0" \ + -F "chunk=@chunk_0.bin" +``` + +**Response:** + +```json +{ + "upload_id": "9c3a...", + "chunk_index": 0, + "received": 1, + "total_chunks": 100, + "complete": false +} +``` + +When the last chunk is received (`complete: true`), all chunks are automatically assembled into the final file. + +### Step 3: Done + +No finalization call needed. The file is ready to use once `complete` is `true`. The temporary chunk files are cleaned up automatically. + +--- + +## Upload Sessions + +Chunk upload sessions are stored in the application cache and expire after **24 hours**. If a session expires before all chunks are received, the upload must be restarted. + +--- + +## Real-Time Progress + +If [laravel-websockets](https://github.com/blax-software/laravel-websockets) is installed, a `ChunkUploadProgress` event is broadcast after each chunk: + +```php +// Event payload +[ + 'uploadId' => '9c3a...', + 'chunkIndex' => 42, + 'totalChunks' => 100, + 'complete' => false, +] +``` + +Listen on the client side to show upload progress bars. + +--- + +## Upload via the HasFiles Trait + +When working with models that use `HasFiles`, you can upload and attach in a single call: + +```php +// From a form upload +$file = $product->uploadFile( + $request->file('photo'), + as: FileLinkType::Gallery, +); + +// From raw content +$file = $product->uploadFileFromContents( + contents: $csvData, + name: 'export', + extension: 'csv', + as: FileLinkType::Document, +); + +// From a URL +$file = $product->uploadFileFromUrl( + url: 'https://cdn.example.com/image.jpg', + as: FileLinkType::Banner, + replace: true, +); +``` + +See [Attaching Files](attaching-files.md#upload-helpers) for full parameter details. + +--- + +## Route Configuration + +Customize upload route prefix and middleware in `config/files.php`: + +```php +'upload' => [ + 'route_prefix' => 'api/files', + 'middleware' => ['api', 'auth:sanctum'], +], +``` + +**Routes registered:** + +| Method | URI | Name | +|--------|-------------------------|----------------------| +| `POST` | `{prefix}/upload` | `files.upload` | +| `POST` | `{prefix}/chunk/init` | `files.chunk.init` | +| `POST` | `{prefix}/chunk/upload` | `files.chunk.upload` | + +--- + +Next: [Serving Files](serving-files.md) diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..bf62396 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..661e522 --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "laravel" +} \ No newline at end of file diff --git a/routes/files.php b/routes/files.php new file mode 100644 index 0000000..f124337 --- /dev/null +++ b/routes/files.php @@ -0,0 +1,32 @@ +get(config('files.warehouse.prefix', 'warehouse') . '/{identifier?}', WarehouseController::class) + ->name('files.warehouse') + ->where('identifier', '[\/\w\.\-\=&@]*'); +} + +/* +|-------------------------------------------------------------------------- +| File Upload API +|-------------------------------------------------------------------------- +*/ + +Route::prefix(config('files.upload.route_prefix', 'api/files')) + ->middleware(config('files.upload.middleware', ['api', 'auth:sanctum'])) + ->group(function () { + Route::post('upload', [FileUploadController::class, 'upload'])->name('files.upload'); + Route::post('chunk/init', [FileUploadController::class, 'chunkInit'])->name('files.chunk.init'); + Route::post('chunk/upload', [FileUploadController::class, 'chunkUpload'])->name('files.chunk.upload'); + }); diff --git a/src/Console/CleanupOrphanedFilesCommand.php b/src/Console/CleanupOrphanedFilesCommand.php new file mode 100644 index 0000000..f56acb9 --- /dev/null +++ b/src/Console/CleanupOrphanedFilesCommand.php @@ -0,0 +1,44 @@ +option('days'); + $dryRun = $this->option('dry-run'); + + $query = File::orphaned() + ->where('created_at', '<', now()->subDays($days)); + + $count = $query->count(); + + if ($count === 0) { + $this->info('No orphaned files found.'); + + return self::SUCCESS; + } + + $this->info(($dryRun ? '[DRY RUN] Would delete' : 'Deleting') . " {$count} orphaned file(s) older than {$days} days…"); + + if (! $dryRun) { + $query->each(function (File $file) { + $file->delete(); + }); + + $this->info("Deleted {$count} file(s)."); + } + + return self::SUCCESS; + } +} diff --git a/src/Enums/FileLinkType.php b/src/Enums/FileLinkType.php new file mode 100644 index 0000000..843d111 --- /dev/null +++ b/src/Enums/FileLinkType.php @@ -0,0 +1,75 @@ + 'Avatar', + self::ProfileImage => 'Profile Image', + self::CoverImage => 'Cover Image', + self::Banner => 'Banner', + self::Background => 'Background', + self::Logo => 'Logo', + self::Icon => 'Icon', + self::Thumbnail => 'Thumbnail', + self::Document => 'Document', + self::Invoice => 'Invoice', + self::Contract => 'Contract', + self::Certificate => 'Certificate', + self::Report => 'Report', + self::Gallery => 'Gallery', + self::Video => 'Video', + self::Audio => 'Audio', + self::Attachment => 'Attachment', + self::Download => 'Download', + self::Other => 'Other', + }; + } + + public function isImage(): bool + { + return in_array($this, [ + self::Avatar, + self::ProfileImage, + self::CoverImage, + self::Banner, + self::Background, + self::Logo, + self::Icon, + self::Thumbnail, + self::Gallery, + ]); + } +} diff --git a/src/Events/ChunkUploadProgress.php b/src/Events/ChunkUploadProgress.php new file mode 100644 index 0000000..3b86696 --- /dev/null +++ b/src/Events/ChunkUploadProgress.php @@ -0,0 +1,31 @@ +uploadId}")]; + } + + public function broadcastAs(): string + { + return 'chunk.progress'; + } +} diff --git a/src/FilesServiceProvider.php b/src/FilesServiceProvider.php new file mode 100644 index 0000000..6b84b44 --- /dev/null +++ b/src/FilesServiceProvider.php @@ -0,0 +1,76 @@ +mergeConfigFrom( + __DIR__ . '/../config/files.php', + 'files', + ); + } + + public function boot() + { + $this->offerPublishing(); + $this->registerModelBindings(); + $this->registerRoutes(); + $this->registerCommands(); + } + + protected function offerPublishing() + { + if (! $this->app->runningInConsole()) { + return; + } + + $this->publishes([ + __DIR__ . '/../config/files.php' => $this->app->configPath('files.php'), + ], 'files-config'); + + $this->publishes([ + __DIR__ . '/../database/migrations/create_blax_files_table.php.stub' => $this->getMigrationFileName('create_blax_files_table.php'), + __DIR__ . '/../database/migrations/create_blax_filables_table.php.stub' => $this->getMigrationFileName('create_blax_filables_table.php'), + ], 'files-migrations'); + } + + protected function getMigrationFileName(string $migrationFileName): string + { + $timestamp = date('Y_m_d_His'); + $filesystem = $this->app->make(\Illuminate\Filesystem\Filesystem::class); + + return \Illuminate\Support\Collection::make([$this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR]) + ->flatMap(fn($path) => $filesystem->glob($path . '*_' . $migrationFileName)) + ->push($this->app->databasePath() . "/migrations/{$timestamp}_{$migrationFileName}") + ->first(); + } + + protected function registerModelBindings(): void + { + $fileModel = $this->app->config['files.models.file'] ?? Models\File::class; + $filableModel = $this->app->config['files.models.filable'] ?? Models\Filable::class; + + if ($fileModel !== Models\File::class) { + $this->app->bind(Models\File::class, $fileModel); + } + if ($filableModel !== Models\Filable::class) { + $this->app->bind(Models\Filable::class, $filableModel); + } + } + + protected function registerRoutes(): void + { + $this->loadRoutesFrom(__DIR__ . '/../routes/files.php'); + } + + protected function registerCommands(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + Console\CleanupOrphanedFilesCommand::class, + ]); + } + } +} diff --git a/src/Http/Controllers/FileUploadController.php b/src/Http/Controllers/FileUploadController.php new file mode 100644 index 0000000..d7cfef2 --- /dev/null +++ b/src/Http/Controllers/FileUploadController.php @@ -0,0 +1,79 @@ + 'required|file|max:' . config('files.upload.max_size', 51200), + ]; + + $allowedMimes = config('files.upload.allowed_mimes', []); + if (! empty($allowedMimes)) { + $rules['file'] .= '|mimes:' . implode(',', $allowedMimes); + } + + $request->validate($rules); + + $uploaded = $request->file('file'); + + $fileModel = config('files.models.file', File::class); + $file = new $fileModel; + $file->save(); + $file->putContentsFromUpload($uploaded); + + return response()->json([ + 'id' => $file->id, + 'name' => $file->name, + 'type' => $file->type, + 'extension' => $file->extension, + 'size' => $file->size, + 'size_human' => $file->size_human, + 'url' => $file->url, + ], 201); + } + + /** + * Initialize a chunked upload. + */ + public function chunkInit(Request $request): JsonResponse + { + $request->validate([ + 'filename' => 'required|string', + 'filesize' => 'required|integer', + 'total_chunks' => 'required|integer|min:1', + 'mime_type' => 'nullable|string', + 'extension' => 'nullable|string', + ]); + + $result = ChunkUploadService::initialize($request); + + return response()->json($result, 201); + } + + /** + * Receive a chunk. + */ + public function chunkUpload(Request $request): JsonResponse + { + $request->validate([ + 'upload_id' => 'required|string', + 'chunk_index' => 'required|integer|min:0', + ]); + + $result = ChunkUploadService::receiveChunk($request); + + return response()->json($result); + } +} diff --git a/src/Http/Controllers/WarehouseController.php b/src/Http/Controllers/WarehouseController.php new file mode 100644 index 0000000..4eec8d2 --- /dev/null +++ b/src/Http/Controllers/WarehouseController.php @@ -0,0 +1,48 @@ +get('id'); + + $file = WarehouseService::searchFile($request, $identifier); + + if (! $file) { + abort(404); + } + + // Access control check (optional, via laravel-roles) + if (config('files.access_control.enabled') && $file->exists) { + $this->checkAccess($request, $file); + } + + return $file->respond($request); + } + + protected function checkAccess(Request $request, File $file): void + { + // If laravel-roles is installed, check HasAccess + if ( + trait_exists(\Blax\Roles\Traits\HasAccess::class) + && method_exists($file, 'hasAccess') + ) { + $user = $request->user(); + + if (! $user) { + abort(403, 'Authentication required.'); + } + + if (! $user->hasAccess($file)) { + abort(403, 'Access denied.'); + } + } + } +} diff --git a/src/Models/Filable.php b/src/Models/Filable.php new file mode 100644 index 0000000..bdfb61c --- /dev/null +++ b/src/Models/Filable.php @@ -0,0 +1,105 @@ + 'json', + 'order' => 'integer', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->table = config('files.table_names.filables') ?: 'filables'; + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + public function file() + { + return $this->belongsTo(config('files.models.file', File::class)); + } + + public function filable() + { + return $this->morphTo(); + } + + /* + |-------------------------------------------------------------------------- + | Scopes + |-------------------------------------------------------------------------- + */ + + public function scopeOrdered($query) + { + return $query->orderByRaw('CASE WHEN "order" IS NULL THEN 1 ELSE 0 END, "order" ASC'); + } + + public function scopeAs($query, string|FileLinkType $type) + { + $value = $type instanceof FileLinkType ? $type->value : $type; + + return $query->where('as', $value); + } + + public static function boot() + { + parent::boot(); + + static::addGlobalScope('ordered', function ($query) { + $query->ordered(); + }); + } + + /* + |-------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------- + */ + + public function setAs(string|FileLinkType $value, bool $save = true): static + { + $this->as = $value instanceof FileLinkType ? $value->value : $value; + + if ($save) { + $this->save(); + } + + return $this; + } + + public function setOrder(int $value, bool $save = true): static + { + $this->order = $value; + + if ($save) { + $this->save(); + } + + return $this; + } + + public function getLinkType(): ?FileLinkType + { + return FileLinkType::tryFrom($this->as); + } +} diff --git a/src/Models/File.php b/src/Models/File.php new file mode 100644 index 0000000..03db6dc --- /dev/null +++ b/src/Models/File.php @@ -0,0 +1,453 @@ + 'string', + 'meta' => 'json', + 'last_accessed_at' => 'datetime', + ]; + + public $incrementing = false; + + protected $keyType = 'string'; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->table = config('files.table_names.files') ?: parent::getTable(); + } + + /* + |-------------------------------------------------------------------------- + | Boot + |-------------------------------------------------------------------------- + */ + + public static function booted() + { + static::saving(function (self $file) { + $file->disk ??= config('files.disk', 'local'); + + if (! $file->relativepath) { + $file->relativepath = static::buildStoragePath($file); + } + + $file->user_id ??= optional(optional(auth())->user())->id; + }); + } + + protected static function buildStoragePath(self $file): string + { + $template = config('files.storage_path', 'files/{date}/{uuid}'); + + return str_replace( + ['{user_id}', '{uuid}', '{date}'], + [ + optional(optional(auth())->user())->id ?? 'anonymous', + $file->id ?? (string) \Illuminate\Support\Str::uuid(), + now()->format('Y/m/d'), + ], + $template, + ); + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + public function user() + { + return $this->belongsTo( + config('auth.providers.users.model', 'App\\Models\\User'), + 'user_id', + ); + } + + public function filables() + { + return $this->hasMany(config('files.models.filable', Filable::class)); + } + + /* + |-------------------------------------------------------------------------- + | Scopes + |-------------------------------------------------------------------------- + */ + + public function scopeImages($query) + { + return $query->where(function ($q) { + $q->where('type', 'like', 'image/%') + ->orWhereIn('extension', ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico']); + }); + } + + public function scopeByExtension($query, string ...$extensions) + { + return $query->whereIn('extension', $extensions); + } + + public function scopeByDisk($query, string $disk) + { + return $query->where('disk', $disk); + } + + public function scopeOrphaned($query) + { + return $query->whereDoesntHave('filables'); + } + + public function scopeRecent($query, int $days = 7) + { + return $query->where('created_at', '>=', now()->subDays($days)); + } + + /* + |-------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------- + */ + + public function getSizeHumanAttribute(): string + { + $bytes = $this->size ?? 0; + if ($bytes >= 1073741824) { + return round($bytes / 1073741824, 1) . ' GB'; + } elseif ($bytes >= 1048576) { + return round($bytes / 1048576, 1) . ' MB'; + } elseif ($bytes >= 1024) { + return round($bytes / 1024, 1) . ' KB'; + } + + return $bytes . ' B'; + } + + public function getPathAttribute(): string + { + return Storage::disk($this->disk)->path($this->relativepath); + } + + public function getUrlAttribute(): string + { + $prefix = config('files.warehouse.prefix', 'warehouse'); + + return url("{$prefix}/{$this->id}"); + } + + /* + |-------------------------------------------------------------------------- + | File Content Operations + |-------------------------------------------------------------------------- + */ + + public function putContents(string $contents): static + { + Storage::disk($this->disk)->put($this->relativepath, $contents); + + if (! $this->extension) { + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->buffer($contents); + $this->extension = explode('/', $mimeType)[1] ?? null; + } + + if (! $this->type) { + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $this->type = $finfo->buffer($contents); + } + + $this->size = Storage::disk($this->disk)->size($this->relativepath); + $this->save(); + + return $this; + } + + public function putContentsFromPath(string $absolutePath): static + { + return $this->putContents(file_get_contents($absolutePath)); + } + + public function putContentsFromUrl(string $url): static + { + return $this->putContents(file_get_contents($url)); + } + + public function putContentsFromUpload(\Illuminate\Http\UploadedFile $upload): static + { + $this->name ??= pathinfo($upload->getClientOriginalName(), PATHINFO_FILENAME); + $this->extension ??= $upload->getClientOriginalExtension(); + $this->type ??= $upload->getMimeType(); + $this->save(); + + return $this->putContents($upload->getContent()); + } + + public function getContents(): ?string + { + return Storage::disk($this->disk)->get($this->relativepath); + } + + public function hasContents(): bool + { + return Storage::disk($this->disk)->exists($this->relativepath); + } + + public function deleteContents(): static + { + Storage::disk($this->disk)->delete($this->relativepath); + + // Remove resized variants + $dir = pathinfo($this->path, PATHINFO_DIRNAME); + $resizedDir = $dir . '/resized'; + if (is_dir($resizedDir)) { + $files = glob($resizedDir . '/' . basename($this->relativepath) . '*'); + foreach ($files as $f) { + @unlink($f); + } + } + + return $this; + } + + /* + |-------------------------------------------------------------------------- + | Response / Serving + |-------------------------------------------------------------------------- + */ + + public function respond(?\Illuminate\Http\Request $request = null): \Symfony\Component\HttpFoundation\Response + { + $request ??= request(); + + // If a size is requested and optimization is available, serve resized + if ($request->has('size') && $this->isImage()) { + $path = $this->resolveResizedPath($request); + } else { + $path = $this->path; + } + + if (! file_exists($path)) { + abort(404); + } + + return response()->file($path); + } + + public function download(?string $filename = null): \Symfony\Component\HttpFoundation\BinaryFileResponse + { + $path = $this->path; + + if (! file_exists($path)) { + abort(404); + } + + $name = $filename ?? ($this->name . '.' . $this->extension); + + return response()->download($path, $name); + } + + public function isImage(): bool + { + if ($this->type && str_starts_with($this->type, 'image')) { + return true; + } + + $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico']; + + return in_array(strtolower($this->extension ?? ''), $imageExtensions); + } + + /* + |-------------------------------------------------------------------------- + | Image Optimization / Resizing + |-------------------------------------------------------------------------- + */ + + public function resolveResizedPath(\Illuminate\Http\Request $request): string + { + if (! class_exists(\Spatie\Image\Image::class)) { + return $this->path; + } + + $config = config('files.optimization', []); + $skipFormats = $config['skip_formats'] ?? ['gif', 'svg', 'svg+xml']; + $ext = strtolower($this->extension ?? ''); + + // Skip non-optimizable formats + if (in_array($ext, $skipFormats) || str_contains($ext, 'svg')) { + return $this->path; + } + + $size = $request->get('size', ''); + $parts = explode('x', $size); + $width = $parts[0] ?? null; + $height = $parts[1] ?? $width; + + $quality = $request->has('quality') ? (int) $request->get('quality') : ($config['default_quality'] ?? 85); + $webp = filter_var($request->get('webp', $config['webp_conversion'] ?? true), FILTER_VALIDATE_BOOLEAN); + $cached = filter_var($request->get('cached', true), FILTER_VALIDATE_BOOLEAN); + $rounding = filter_var($request->get('rounding', $config['round_dimensions'] ?? true), FILTER_VALIDATE_BOOLEAN); + $position = $request->get('position', 'cover'); + + return $this->resizedPath( + $width, + $height, + rounding: $rounding, + toWebp: $webp, + cached: $cached, + quality: $quality, + position: $position, + ); + } + + public function resizedPath( + string|int|null $width, + string|int|null $height, + bool $rounding = true, + bool $toWebp = true, + bool $cached = true, + ?int $quality = null, + string $position = 'cover', + ): string { + $path = $this->path; + $ext = strtolower($this->extension ?? pathinfo($path, PATHINFO_EXTENSION)); + + // Normalize dimensions + if ($width !== null && strtolower((string) $width) !== 'auto') { + $width = max(1, (int) $width); + } + if ($height !== null && strtolower((string) $height) !== 'auto') { + $height = max(1, (int) $height); + } + + $width = $width ?: $height; + $height = $height ?: $width; + + // Round to nearest step + $roundTo = config('files.optimization.round_to', 50); + if ($rounding) { + $width = ($width === 'auto') ? $width : (int) (ceil((int) $width / $roundTo) * $roundTo); + $height = ($height === 'auto') ? $height : (int) (ceil((int) $height / $roundTo) * $roundTo); + } + + // Build cache key + $dir = pathinfo($path, PATHINFO_DIRNAME); + $resizedDir = $dir . '/resized'; + if (! is_dir($resizedDir)) { + @mkdir($resizedDir, 0755, true); + } + + $cacheKey = $width . 'x' . $height; + if ($position !== 'cover') { + $cacheKey .= '.' . $position; + } + if ($quality !== null && $quality > 0 && $quality < 100) { + $cacheKey .= '.q' . $quality; + } + + $cachedPath = $resizedDir . '/' . basename($path, '.' . $ext) . '.' . $cacheKey . '.' . $ext; + if ($toWebp && $this->isImage()) { + $cachedPath .= '.webp'; + } + + // Return cached version if available + if ($cached && file_exists($cachedPath)) { + return $cachedPath; + } + + // Generate resized version + copy($path, $cachedPath); + + $fit = match ($position) { + 'contain' => \Spatie\Image\Enums\Fit::Contain, + 'fill' => \Spatie\Image\Enums\Fit::Fill, + 'max' => \Spatie\Image\Enums\Fit::Max, + 'stretch' => \Spatie\Image\Enums\Fit::Stretch, + default => \Spatie\Image\Enums\Fit::Crop, + }; + + $image = \Spatie\Image\Image::load($cachedPath) + ->fit( + $fit, + ($width === 'auto') ? null : (int) $width, + ($height === 'auto') ? null : (int) $height, + ); + + if ($quality !== null && $quality > 0) { + $image->quality(min(100, max(1, $quality))); + } + + $image->save($cachedPath); + + return $cachedPath; + } + + /* + |-------------------------------------------------------------------------- + | Cleanup + |-------------------------------------------------------------------------- + */ + + protected static function booting() + { + static::deleting(function (self $file) { + $file->deleteContents(); + $file->filables()->delete(); + }); + } + + /* + |-------------------------------------------------------------------------- + | Duplication + |-------------------------------------------------------------------------- + */ + + public function duplicate(?string $newName = null): static + { + $clone = $this->replicate(['id', 'relativepath']); + $clone->name = $newName ?? ($this->name . ' (copy)'); + $clone->save(); + + if ($this->hasContents()) { + $clone->putContents($this->getContents()); + } + + return $clone; + } + + /* + |-------------------------------------------------------------------------- + | Serialization + |-------------------------------------------------------------------------- + */ + + public function toArray(): array + { + $array = parent::toArray(); + $array['url'] = $this->url; + $array['size_human'] = $this->size_human; + + return $array; + } +} diff --git a/src/Services/ChunkUploadService.php b/src/Services/ChunkUploadService.php new file mode 100644 index 0000000..1a0d33e --- /dev/null +++ b/src/Services/ChunkUploadService.php @@ -0,0 +1,142 @@ +input('filename', 'upload'); + $fileSize = $request->input('filesize'); + $mimeType = $request->input('mime_type'); + $extension = $request->input('extension') ?? pathinfo($fileName, PATHINFO_EXTENSION); + $totalChunks = $request->input('total_chunks', 1); + + $fileModel = config('files.models.file', File::class); + $file = new $fileModel; + $file->name = pathinfo($fileName, PATHINFO_FILENAME); + $file->extension = $extension; + $file->type = $mimeType; + $file->size = $fileSize; + $file->save(); + + // Store upload metadata in cache + $uploadId = $file->id; + cache()->put("chunk_upload:{$uploadId}", [ + 'file_id' => $file->id, + 'total_chunks' => (int) $totalChunks, + 'received' => [], + 'temp_dir' => "chunk_uploads/{$uploadId}", + ], now()->addHours(24)); + + return [ + 'upload_id' => $uploadId, + 'file_id' => $file->id, + 'total_chunks' => (int) $totalChunks, + ]; + } + + /** + * Receive a single chunk. + */ + public static function receiveChunk(Request $request): array + { + $uploadId = $request->input('upload_id'); + $chunkIndex = (int) $request->input('chunk_index', 0); + $meta = cache()->get("chunk_upload:{$uploadId}"); + + if (! $meta) { + abort(404, 'Upload session not found or expired.'); + } + + $tempDir = $meta['temp_dir']; + $disk = config('files.disk', 'local'); + + // Accept chunk as file upload or raw body + if ($request->hasFile('chunk')) { + $content = $request->file('chunk')->getContent(); + } else { + $content = $request->getContent(); + } + + Storage::disk($disk)->put("{$tempDir}/{$chunkIndex}", $content); + + $meta['received'][] = $chunkIndex; + $meta['received'] = array_unique($meta['received']); + cache()->put("chunk_upload:{$uploadId}", $meta, now()->addHours(24)); + + $complete = count($meta['received']) >= $meta['total_chunks']; + + if ($complete) { + static::assembleChunks($uploadId); + } + + // Broadcast progress if websockets available + static::broadcastProgress($uploadId, $chunkIndex, $meta['total_chunks'], $complete); + + return [ + 'upload_id' => $uploadId, + 'chunk_index' => $chunkIndex, + 'received' => count($meta['received']), + 'total_chunks' => $meta['total_chunks'], + 'complete' => $complete, + ]; + } + + /** + * Assemble all chunks into the final file. + */ + protected static function assembleChunks(string $uploadId): void + { + $meta = cache()->get("chunk_upload:{$uploadId}"); + if (! $meta) { + return; + } + + $disk = config('files.disk', 'local'); + $file = File::findOrFail($meta['file_id']); + $tempDir = $meta['temp_dir']; + + // Concatenate chunks in order + $assembled = ''; + for ($i = 0; $i < $meta['total_chunks']; $i++) { + $chunkPath = "{$tempDir}/{$i}"; + if (Storage::disk($disk)->exists($chunkPath)) { + $assembled .= Storage::disk($disk)->get($chunkPath); + } + } + + $file->putContents($assembled); + + // Cleanup temp chunks + for ($i = 0; $i < $meta['total_chunks']; $i++) { + Storage::disk($disk)->delete("{$tempDir}/{$i}"); + } + Storage::disk($disk)->deleteDirectory($tempDir); + + cache()->forget("chunk_upload:{$uploadId}"); + } + + /** + * Broadcast upload progress via event (works with or without websockets). + */ + protected static function broadcastProgress(string $uploadId, int $chunkIndex, int $total, bool $complete): void + { + try { + if (class_exists(\Illuminate\Support\Facades\Broadcast::class)) { + event(new \Blax\Files\Events\ChunkUploadProgress($uploadId, $chunkIndex, $total, $complete)); + } + } catch (\Throwable $e) { + // Websockets not available — ignore silently + } + } +} diff --git a/src/Services/WarehouseService.php b/src/Services/WarehouseService.php new file mode 100644 index 0000000..2bb520a --- /dev/null +++ b/src/Services/WarehouseService.php @@ -0,0 +1,137 @@ +exists($path)) { + return static::fileInstanceFromPath($path, $disk); + } + + // Try with preferred extensions if no extension detected + if (! pathinfo($path, PATHINFO_EXTENSION)) { + foreach ($extensions as $ext) { + if (Storage::disk($disk)->exists($path . '.' . $ext)) { + return static::fileInstanceFromPath($path . '.' . $ext, $disk); + } + } + } + + return null; + } + + /** + * Search by raw storage path. + */ + protected static function searchStoragePath(string $path): ?File + { + $disk = config('files.disk', 'local'); + $path = str_replace('storage/', '', $path); + + if (Storage::disk($disk)->exists($path)) { + return static::fileInstanceFromPath($path, $disk); + } + + return null; + } + + /** + * Create a non-persisted File model instance for serving a storage path. + */ + protected static function fileInstanceFromPath(string $relativePath, string $disk): File + { + $file = new File; + $file->name = basename($relativePath); + $file->relativepath = $relativePath; + $file->disk = $disk; + $file->extension = pathinfo($relativePath, PATHINFO_EXTENSION); + $file->exists = false; // not persisted + + return $file; + } + + /** + * Generate the public warehouse URL for a file. + */ + public static function url(File|string $file): string + { + $id = $file instanceof File ? $file->id : $file; + $prefix = config('files.warehouse.prefix', 'warehouse'); + + return url("{$prefix}/{$id}"); + } +} diff --git a/src/Traits/HasFiles.php b/src/Traits/HasFiles.php new file mode 100644 index 0000000..6136f03 --- /dev/null +++ b/src/Traits/HasFiles.php @@ -0,0 +1,312 @@ +morphToMany( + config('files.models.file', File::class), + 'filable', + config('files.table_names.filables', 'filables'), + ) + ->using(config('files.models.filable', Filable::class)) + ->withPivot(['id', 'as', 'order', 'meta']) + ->withTimestamps(); + } + + public function getFilePivot(File|string $file): ?Filable + { + $fileId = $file instanceof File ? $file->id : $file; + + return $this->files() + ->where('file_id', $fileId) + ->withPivot(['id', 'as', 'order', 'meta']) + ->first()?->pivot; + } + + /* + |-------------------------------------------------------------------------- + | Query Helpers + |-------------------------------------------------------------------------- + */ + + /** + * Get files attached with a specific role (e.g. 'avatar', 'thumbnail', …). + * Accepts a string or a FileLinkType enum. + */ + public function filesAs(string|FileLinkType $type): MorphToMany + { + $value = $type instanceof FileLinkType ? $type->value : $type; + + return $this->files()->wherePivot('as', $value); + } + + /** + * Get the first file attached with a specific role. + */ + public function fileAs(string|FileLinkType $type): ?File + { + return $this->filesAs($type)->first(); + } + + /** + * Convenience: get profile image / avatar. + */ + public function getAvatar(): ?File + { + return $this->fileAs(FileLinkType::Avatar) + ?? $this->fileAs(FileLinkType::ProfileImage); + } + + /** + * Convenience: get thumbnail. + */ + public function getThumbnail(): ?File + { + return $this->fileAs(FileLinkType::Thumbnail); + } + + /** + * Convenience: get banner. + */ + public function getBanner(): ?File + { + return $this->fileAs(FileLinkType::Banner); + } + + /** + * Convenience: get cover image. + */ + public function getCoverImage(): ?File + { + return $this->fileAs(FileLinkType::CoverImage); + } + + /** + * Convenience: get background. + */ + public function getBackground(): ?File + { + return $this->fileAs(FileLinkType::Background); + } + + /** + * Convenience: get logo. + */ + public function getLogo(): ?File + { + return $this->fileAs(FileLinkType::Logo); + } + + /** + * Convenience: get gallery images. + */ + public function getGallery(): \Illuminate\Database\Eloquent\Collection + { + return $this->filesAs(FileLinkType::Gallery)->get(); + } + + /* + |-------------------------------------------------------------------------- + | Attach / Detach + |-------------------------------------------------------------------------- + */ + + /** + * Attach an existing File to this model with a role. + * + * If $replace is true (default for singular types like avatar), the + * previous attachment with that role is removed first. + */ + public function attachFile( + File|string $file, + string|FileLinkType|null $as = null, + ?int $order = null, + ?array $meta = null, + bool $replace = false, + ): static { + $fileId = $file instanceof File ? $file->id : $file; + $asValue = $as instanceof FileLinkType ? $as->value : $as; + + if ($replace && $asValue) { + $this->detachFilesAs($asValue); + } + + // Prevent duplicate pivot entries + $existing = $this->files() + ->where('file_id', $fileId) + ->wherePivot('as', $asValue) + ->exists(); + + if (! $existing) { + $this->files()->attach($fileId, array_filter([ + 'as' => $asValue, + 'order' => $order, + 'meta' => $meta ? json_encode($meta) : null, + ], fn($v) => $v !== null)); + } + + return $this; + } + + /** + * Detach a specific file from this model. + */ + public function detachFile(File|string $file, ?string $as = null): static + { + $fileId = $file instanceof File ? $file->id : $file; + + $query = $this->files()->newPivotQuery() + ->where('file_id', $fileId) + ->where('filable_type', static::class) + ->where('filable_id', $this->getKey()); + + if ($as !== null) { + $query->where('as', $as); + } + + $query->delete(); + + return $this; + } + + /** + * Detach all files with a specific role. + */ + public function detachFilesAs(string|FileLinkType $type): static + { + $value = $type instanceof FileLinkType ? $type->value : $type; + + $this->files()->newPivotQuery() + ->where('filable_type', static::class) + ->where('filable_id', $this->getKey()) + ->where('as', $value) + ->delete(); + + return $this; + } + + /** + * Detach all files from this model. + */ + public function detachAllFiles(): static + { + $this->files()->detach(); + + return $this; + } + + /* + |-------------------------------------------------------------------------- + | Upload Helpers + |-------------------------------------------------------------------------- + */ + + /** + * Upload a file and attach it to this model in one call. + */ + public function uploadFile( + UploadedFile $upload, + string|FileLinkType|null $as = null, + ?int $order = null, + ?array $meta = null, + bool $replace = false, + ): File { + $fileModel = config('files.models.file', File::class); + $file = new $fileModel; + $file->save(); + $file->putContentsFromUpload($upload); + + $this->attachFile($file, $as, $order, $meta, $replace); + + return $file; + } + + /** + * Create a file from raw content and attach. + */ + public function uploadFileFromContents( + string $contents, + ?string $name = null, + ?string $extension = null, + string|FileLinkType|null $as = null, + ?int $order = null, + bool $replace = false, + ): File { + $fileModel = config('files.models.file', File::class); + $file = new $fileModel; + $file->name = $name; + $file->extension = $extension; + $file->save(); + $file->putContents($contents); + + $this->attachFile($file, $as, $order, replace: $replace); + + return $file; + } + + /** + * Download a file from URL and attach. + */ + public function uploadFileFromUrl( + string $url, + ?string $name = null, + string|FileLinkType|null $as = null, + ?int $order = null, + bool $replace = false, + ): File { + $fileModel = config('files.models.file', File::class); + $file = new $fileModel; + $file->name = $name ?? basename(parse_url($url, PHP_URL_PATH)); + $file->save(); + $file->putContentsFromUrl($url); + + $this->attachFile($file, $as, $order, replace: $replace); + + return $file; + } + + /* + |-------------------------------------------------------------------------- + | Reorder + |-------------------------------------------------------------------------- + */ + + /** + * Reorder files — accepts array of file IDs in desired order. + * Optionally scoped to a specific role. + */ + public function reorderFiles(array $fileIds, string|FileLinkType|null $as = null): static + { + $asValue = $as instanceof FileLinkType ? $as->value : $as; + + foreach ($fileIds as $index => $fileId) { + $query = $this->files()->newPivotQuery() + ->where('file_id', $fileId) + ->where('filable_type', static::class) + ->where('filable_id', $this->getKey()); + + if ($asValue !== null) { + $query->where('as', $asValue); + } + + $query->update(['order' => $index]); + } + + return $this; + } +} diff --git a/tests/Unit/ChunkUploadServiceTest.php b/tests/Unit/ChunkUploadServiceTest.php new file mode 100644 index 0000000..9d10afe --- /dev/null +++ b/tests/Unit/ChunkUploadServiceTest.php @@ -0,0 +1,210 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); + } + + protected function setUp(): void + { + parent::setUp(); + Storage::fake('local'); + } + + // ─── initialize ──────────────────────────────────────────────── + + public function test_initialize_creates_file_and_cache() + { + $request = Request::create('/', 'POST', [ + 'filename' => 'video.mp4', + 'filesize' => 5000000, + 'total_chunks' => 5, + 'mime_type' => 'video/mp4', + 'extension' => 'mp4', + ]); + + $result = ChunkUploadService::initialize($request); + + $this->assertArrayHasKey('upload_id', $result); + $this->assertArrayHasKey('file_id', $result); + $this->assertEquals(5, $result['total_chunks']); + + // File should exist in database + $file = File::find($result['file_id']); + $this->assertNotNull($file); + $this->assertEquals('video', $file->name); + $this->assertEquals('mp4', $file->extension); + $this->assertEquals('video/mp4', $file->type); + + // Cache should have upload metadata + $meta = cache()->get("chunk_upload:{$result['upload_id']}"); + $this->assertNotNull($meta); + $this->assertEquals(5, $meta['total_chunks']); + $this->assertEmpty($meta['received']); + } + + public function test_initialize_derives_extension_from_filename() + { + $request = Request::create('/', 'POST', [ + 'filename' => 'document.pdf', + 'filesize' => 1000, + 'total_chunks' => 1, + ]); + + $result = ChunkUploadService::initialize($request); + + $file = File::find($result['file_id']); + $this->assertEquals('pdf', $file->extension); + } + + // ─── receiveChunk ────────────────────────────────────────────── + + public function test_receive_chunk_stores_data() + { + $request = Request::create('/', 'POST', [ + 'filename' => 'data.bin', + 'filesize' => 200, + 'total_chunks' => 2, + ]); + $init = ChunkUploadService::initialize($request); + + // Send first chunk + $chunkRequest = Request::create('/', 'POST', [ + 'upload_id' => $init['upload_id'], + 'chunk_index' => 0, + ], [], [], [], 'chunk-0-data'); + + $result = ChunkUploadService::receiveChunk($chunkRequest); + + $this->assertEquals(0, $result['chunk_index']); + $this->assertEquals(1, $result['received']); + $this->assertEquals(2, $result['total_chunks']); + $this->assertFalse($result['complete']); + } + + public function test_receive_all_chunks_assembles_file() + { + $request = Request::create('/', 'POST', [ + 'filename' => 'assembled.txt', + 'filesize' => 10, + 'total_chunks' => 3, + 'mime_type' => 'text/plain', + 'extension' => 'txt', + ]); + $init = ChunkUploadService::initialize($request); + + // 3 chunks + $chunks = ['AAA', 'BBB', 'CCC']; + $lastResult = null; + + for ($i = 0; $i < 3; $i++) { + $chunkRequest = Request::create('/', 'POST', [ + 'upload_id' => $init['upload_id'], + 'chunk_index' => $i, + ], [], [], [], $chunks[$i]); + + $lastResult = ChunkUploadService::receiveChunk($chunkRequest); + } + + $this->assertTrue($lastResult['complete']); + + // File should have assembled content + $file = File::find($init['file_id']); + $this->assertEquals('AAABBBCCC', $file->getContents()); + + // Cache should be cleaned up + $this->assertNull(cache()->get("chunk_upload:{$init['upload_id']}")); + } + + public function test_receive_chunk_with_expired_session_aborts() + { + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); + + $chunkRequest = Request::create('/', 'POST', [ + 'upload_id' => 'non-existent-id', + 'chunk_index' => 0, + ], [], [], [], 'data'); + + ChunkUploadService::receiveChunk($chunkRequest); + } + + public function test_chunks_received_out_of_order_still_assemble_correctly() + { + $request = Request::create('/', 'POST', [ + 'filename' => 'shuffled.txt', + 'filesize' => 9, + 'total_chunks' => 3, + 'mime_type' => 'text/plain', + ]); + $init = ChunkUploadService::initialize($request); + + // Send out of order: 2, 0, 1 + $order = [2 => 'CCC', 0 => 'AAA', 1 => 'BBB']; + + foreach ($order as $index => $data) { + $chunkRequest = Request::create('/', 'POST', [ + 'upload_id' => $init['upload_id'], + 'chunk_index' => $index, + ], [], [], [], $data); + + ChunkUploadService::receiveChunk($chunkRequest); + } + + $file = File::find($init['file_id']); + // Even though chunks arrived out of order, assembly is index-based + $this->assertEquals('AAABBBCCC', $file->getContents()); + } + + public function test_duplicate_chunk_index_is_deduplicated() + { + $request = Request::create('/', 'POST', [ + 'filename' => 'dup.txt', + 'filesize' => 3, + 'total_chunks' => 1, + 'mime_type' => 'text/plain', + ]); + $init = ChunkUploadService::initialize($request); + + // Send same chunk twice + $chunkRequest = Request::create('/', 'POST', [ + 'upload_id' => $init['upload_id'], + 'chunk_index' => 0, + ], [], [], [], 'AAA'); + + ChunkUploadService::receiveChunk($chunkRequest); + + // It should have complete=true after first, so this is just verifying + // second call doesn't break anything + $file = File::find($init['file_id']); + $this->assertNotNull($file->getContents()); + } +} diff --git a/tests/Unit/FilableModelTest.php b/tests/Unit/FilableModelTest.php new file mode 100644 index 0000000..6fa393e --- /dev/null +++ b/tests/Unit/FilableModelTest.php @@ -0,0 +1,249 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); + } + + protected function setUp(): void + { + parent::setUp(); + Storage::fake('local'); + } + + // ─── scopeAs ─────────────────────────────────────────────────── + + public function test_scope_as_filters_by_role() + { + $file = File::create([ + 'name' => 'test', + 'extension' => 'png', + 'type' => 'image/png', + 'disk' => 'local', + ]); + + Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'avatar', + 'order' => 0, + ]); + + Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'banner', + 'order' => 0, + ]); + + $avatars = Filable::as('avatar')->get(); + $this->assertCount(1, $avatars); + $this->assertEquals('avatar', $avatars->first()->as); + } + + // ─── set helpers ─────────────────────────────────────────────── + + public function test_set_as_updates_in_memory() + { + $file = File::create([ + 'name' => 'test', + 'extension' => 'png', + 'type' => 'image/png', + 'disk' => 'local', + ]); + + $filable = Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'avatar', + 'order' => 0, + ]); + + $filable->setAs('thumbnail', save: false); + $this->assertEquals('thumbnail', $filable->as); + } + + public function test_set_order_updates_in_memory() + { + $file = File::create([ + 'name' => 'test', + 'extension' => 'png', + 'type' => 'image/png', + 'disk' => 'local', + ]); + + $filable = Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'gallery', + 'order' => 0, + ]); + + $filable->setOrder(5, save: false); + $this->assertEquals(5, $filable->order); + } + + // ─── getLinkType ─────────────────────────────────────────────── + + public function test_get_link_type_returns_enum() + { + $file = File::create([ + 'name' => 'test', + 'extension' => 'png', + 'type' => 'image/png', + 'disk' => 'local', + ]); + + $filable = Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'avatar', + 'order' => 0, + ]); + + $linkType = $filable->getLinkType(); + $this->assertEquals(FileLinkType::Avatar, $linkType); + } + + public function test_get_link_type_returns_null_for_unknown() + { + $file = File::create([ + 'name' => 'test', + 'extension' => 'png', + 'type' => 'image/png', + 'disk' => 'local', + ]); + + $filable = Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'something_custom', + 'order' => 0, + ]); + + $this->assertNull($filable->getLinkType()); + } + + // ─── meta cast ───────────────────────────────────────────────── + + public function test_meta_is_json_cast() + { + $file = File::create([ + 'name' => 'test', + 'extension' => 'png', + 'type' => 'image/png', + 'disk' => 'local', + ]); + + $filable = Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'gallery', + 'order' => 0, + 'meta' => ['width' => 800, 'height' => 600], + ]); + + $refreshed = Filable::where('file_id', $file->id)->where('as', 'gallery')->first(); + $this->assertIsArray($refreshed->meta); + $this->assertEquals(800, $refreshed->meta['width']); + } + + // ─── global scope ordering ───────────────────────────────────── + + public function test_default_ordering_by_order_column() + { + $file = File::create([ + 'name' => 'test', + 'extension' => 'png', + 'type' => 'image/png', + 'disk' => 'local', + ]); + + Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'gallery', + 'order' => 3, + ]); + + Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'gallery', + 'order' => 1, + ]); + + Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'gallery', + 'order' => 2, + ]); + + $filables = Filable::where('filable_type', 'App\Models\User')->get(); + $this->assertEquals(1, $filables[0]->order); + $this->assertEquals(2, $filables[1]->order); + $this->assertEquals(3, $filables[2]->order); + } + + // ─── file relationship ───────────────────────────────────────── + + public function test_filable_belongs_to_file() + { + $file = File::create([ + 'name' => 'linked', + 'extension' => 'pdf', + 'type' => 'application/pdf', + 'disk' => 'local', + ]); + + $filable = Filable::create([ + 'file_id' => $file->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'document', + 'order' => 0, + ]); + + $this->assertEquals($file->id, $filable->file->id); + } +} diff --git a/tests/Unit/FileLinkTypeTest.php b/tests/Unit/FileLinkTypeTest.php new file mode 100644 index 0000000..9ff3537 --- /dev/null +++ b/tests/Unit/FileLinkTypeTest.php @@ -0,0 +1,94 @@ +label(); + $this->assertNotEmpty($label); + $this->assertIsString($label); + } + } + + public function test_specific_labels() + { + $this->assertEquals('Avatar', FileLinkType::Avatar->label()); + $this->assertEquals('Profile Image', FileLinkType::ProfileImage->label()); + $this->assertEquals('Cover Image', FileLinkType::CoverImage->label()); + $this->assertEquals('Other', FileLinkType::Other->label()); + } + + // ─── isImage() ───────────────────────────────────────────────── + + public function test_image_types_return_true() + { + $imageTypes = [ + FileLinkType::Avatar, + FileLinkType::ProfileImage, + FileLinkType::CoverImage, + FileLinkType::Banner, + FileLinkType::Background, + FileLinkType::Logo, + FileLinkType::Icon, + FileLinkType::Thumbnail, + FileLinkType::Gallery, + ]; + + foreach ($imageTypes as $type) { + $this->assertTrue($type->isImage(), "{$type->value} should be an image type"); + } + } + + public function test_non_image_types_return_false() + { + $nonImageTypes = [ + FileLinkType::Document, + FileLinkType::Invoice, + FileLinkType::Contract, + FileLinkType::Certificate, + FileLinkType::Report, + FileLinkType::Video, + FileLinkType::Audio, + FileLinkType::Attachment, + FileLinkType::Download, + FileLinkType::Other, + ]; + + foreach ($nonImageTypes as $type) { + $this->assertFalse($type->isImage(), "{$type->value} should NOT be an image type"); + } + } + + // ─── tryFrom / from ──────────────────────────────────────────── + + public function test_try_from_valid_value() + { + $this->assertEquals(FileLinkType::Avatar, FileLinkType::tryFrom('avatar')); + $this->assertEquals(FileLinkType::Document, FileLinkType::tryFrom('document')); + } + + public function test_try_from_invalid_value_returns_null() + { + $this->assertNull(FileLinkType::tryFrom('nonexistent')); + } + + // ─── count ───────────────────────────────────────────────────── + + public function test_has_expected_number_of_cases() + { + $this->assertCount(19, FileLinkType::cases()); + } +} diff --git a/tests/Unit/FileModelTest.php b/tests/Unit/FileModelTest.php new file mode 100644 index 0000000..a2293ab --- /dev/null +++ b/tests/Unit/FileModelTest.php @@ -0,0 +1,424 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); + } + + protected function setUp(): void + { + parent::setUp(); + Storage::fake('local'); + } + + // ─── Creation & UUID ─────────────────────────────────────────── + + public function test_file_is_created_with_uuid() + { + $file = File::create(['name' => 'test']); + + $this->assertNotNull($file->id); + $this->assertIsString($file->id); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', + $file->id, + ); + } + + public function test_file_sets_default_disk_on_save() + { + $file = File::create(['name' => 'test']); + + $this->assertEquals('local', $file->disk); + } + + public function test_file_generates_relativepath_on_save() + { + $file = File::create(['name' => 'test']); + + $this->assertNotNull($file->relativepath); + $this->assertStringContainsString(now()->format('Y/m/d'), $file->relativepath); + } + + public function test_file_preserves_explicit_disk() + { + $file = File::create(['name' => 'test', 'disk' => 's3']); + + $this->assertEquals('s3', $file->disk); + } + + public function test_file_preserves_explicit_relativepath() + { + $file = File::create([ + 'name' => 'test', + 'relativepath' => 'custom/path/file.txt', + ]); + + $this->assertEquals('custom/path/file.txt', $file->relativepath); + } + + // ─── putContents ─────────────────────────────────────────────── + + public function test_put_contents_stores_file_on_disk() + { + $file = File::create(['name' => 'hello']); + $file->putContents('Hello, World!'); + + Storage::disk('local')->assertExists($file->relativepath); + $this->assertEquals('Hello, World!', $file->getContents()); + } + + public function test_put_contents_detects_extension_when_missing() + { + $file = File::create(['name' => 'test']); + + // Plain text content + $file->putContents('plain text content'); + + $this->assertNotNull($file->extension); + } + + public function test_put_contents_detects_mime_type_when_missing() + { + $file = File::create(['name' => 'test']); + $file->putContents('plain text content'); + + $this->assertNotNull($file->type); + } + + public function test_put_contents_calculates_size() + { + $file = File::create(['name' => 'test']); + $content = str_repeat('x', 1234); + $file->putContents($content); + + $this->assertEquals(1234, $file->size); + } + + public function test_put_contents_does_not_overwrite_existing_extension() + { + $file = File::create(['name' => 'test', 'extension' => 'pdf']); + $file->putContents('fake pdf content'); + + $this->assertEquals('pdf', $file->extension); + } + + // ─── getContents / hasContents ───────────────────────────────── + + public function test_get_contents_returns_stored_data() + { + $file = File::create(['name' => 'data']); + $file->putContents('binary data here'); + + $this->assertEquals('binary data here', $file->getContents()); + } + + public function test_has_contents_returns_true_when_file_exists() + { + $file = File::create(['name' => 'data']); + $file->putContents('content'); + + $this->assertTrue($file->hasContents()); + } + + public function test_has_contents_returns_false_when_file_missing() + { + $file = File::create(['name' => 'ghost']); + + $this->assertFalse($file->hasContents()); + } + + // ─── deleteContents ──────────────────────────────────────────── + + public function test_delete_contents_removes_file_from_disk() + { + $file = File::create(['name' => 'doomed']); + $file->putContents('to be deleted'); + + $this->assertTrue($file->hasContents()); + + $file->deleteContents(); + + Storage::disk('local')->assertMissing($file->relativepath); + } + + // ─── Accessors ───────────────────────────────────────────────── + + public function test_size_human_bytes() + { + $file = new File(['size' => 500]); + $this->assertEquals('500 B', $file->size_human); + } + + public function test_size_human_kilobytes() + { + $file = new File(['size' => 2048]); + $this->assertEquals('2 KB', $file->size_human); + } + + public function test_size_human_megabytes() + { + $file = new File(['size' => 5 * 1048576]); + $this->assertEquals('5 MB', $file->size_human); + } + + public function test_size_human_gigabytes() + { + $file = new File(['size' => 2 * 1073741824]); + $this->assertEquals('2 GB', $file->size_human); + } + + public function test_size_human_zero() + { + $file = new File(['size' => null]); + $this->assertEquals('0 B', $file->size_human); + } + + public function test_url_attribute_uses_warehouse_prefix() + { + $file = File::create(['name' => 'test']); + + $this->assertStringContainsString('warehouse/' . $file->id, $file->url); + } + + // ─── isImage ─────────────────────────────────────────────────── + + public function test_is_image_by_type() + { + $file = new File(['type' => 'image/png']); + $this->assertTrue($file->isImage()); + } + + public function test_is_image_by_extension() + { + foreach (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] as $ext) { + $file = new File(['extension' => $ext]); + $this->assertTrue($file->isImage(), "Extension '{$ext}' should be detected as image."); + } + } + + public function test_is_not_image_for_non_image() + { + $file = new File(['type' => 'application/pdf', 'extension' => 'pdf']); + $this->assertFalse($file->isImage()); + } + + // ─── meta (JSON) ────────────────────────────────────────────── + + public function test_meta_is_cast_to_array() + { + $file = File::create([ + 'name' => 'test', + 'meta' => ['width' => 100, 'height' => 200], + ]); + + $file->refresh(); + + $this->assertIsArray($file->meta); + $this->assertEquals(100, $file->meta['width']); + $this->assertEquals(200, $file->meta['height']); + } + + // ─── Deletion cascades ───────────────────────────────────────── + + public function test_deleting_file_removes_contents_and_filables() + { + $user = \Workbench\App\Models\User::create(['name' => 'Jane', 'email' => 'jane@test.com']); + $file = File::create(['name' => 'bye']); + $file->putContents('farewell'); + + $user->attachFile($file, 'avatar'); + + $this->assertDatabaseHas('filables', ['file_id' => $file->id]); + + $file->delete(); + + $this->assertDatabaseMissing('filables', ['file_id' => $file->id]); + Storage::disk('local')->assertMissing($file->relativepath); + } + + // ─── putContentsFromUpload ──────────────────────────────────── + + public function test_put_contents_from_upload_sets_attributes() + { + $file = File::create([]); + + $upload = \Illuminate\Http\UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); + $file->putContentsFromUpload($upload); + + $this->assertEquals('document', $file->name); + $this->assertEquals('pdf', $file->extension); + $this->assertEquals('application/pdf', $file->type); + $this->assertTrue($file->hasContents()); + } + + // ─── Configurable table name ────────────────────────────────── + + public function test_file_uses_configured_table_name() + { + $file = new File; + $this->assertEquals('files', $file->getTable()); + } + + // ─── Scopes ─────────────────────────────────────────────────── + + public function test_scope_images_filters_by_type_and_extension() + { + File::create(['name' => 'photo', 'type' => 'image/jpeg', 'extension' => 'jpg']); + File::create(['name' => 'logo', 'type' => null, 'extension' => 'png']); + File::create(['name' => 'doc', 'type' => 'application/pdf', 'extension' => 'pdf']); + + $images = File::images()->get(); + $this->assertCount(2, $images); + } + + public function test_scope_by_extension() + { + File::create(['name' => 'a', 'extension' => 'pdf']); + File::create(['name' => 'b', 'extension' => 'png']); + File::create(['name' => 'c', 'extension' => 'pdf']); + + $pdfs = File::byExtension('pdf')->get(); + $this->assertCount(2, $pdfs); + } + + public function test_scope_by_extension_multiple() + { + File::create(['name' => 'a', 'extension' => 'pdf']); + File::create(['name' => 'b', 'extension' => 'png']); + File::create(['name' => 'c', 'extension' => 'doc']); + + $results = File::byExtension('pdf', 'doc')->get(); + $this->assertCount(2, $results); + } + + public function test_scope_by_disk() + { + File::create(['name' => 'local-file', 'disk' => 'local']); + File::create(['name' => 's3-file', 'disk' => 's3']); + + $local = File::byDisk('local')->get(); + $this->assertCount(1, $local); + $this->assertEquals('local-file', $local->first()->name); + } + + public function test_scope_orphaned() + { + $attached = File::create(['name' => 'attached']); + $orphan = File::create(['name' => 'orphan']); + + // Simulate a filable attachment for the first file + \Blax\Files\Models\Filable::create([ + 'file_id' => $attached->id, + 'filable_id' => 1, + 'filable_type' => 'App\Models\User', + 'as' => 'avatar', + 'order' => 0, + ]); + + $orphans = File::orphaned()->get(); + $this->assertCount(1, $orphans); + $this->assertEquals('orphan', $orphans->first()->name); + } + + public function test_scope_recent() + { + $recent = File::create(['name' => 'recent']); + $old = File::create(['name' => 'old']); + + // Backdate the old file + File::where('id', $old->id)->update(['created_at' => now()->subDays(30)]); + + $recentFiles = File::recent(7)->get(); + $this->assertCount(1, $recentFiles); + $this->assertEquals('recent', $recentFiles->first()->name); + } + + // ─── Download ───────────────────────────────────────────────── + + public function test_download_returns_binary_file_response() + { + $file = File::create(['name' => 'doc', 'extension' => 'pdf', 'type' => 'application/pdf']); + $file->putContents('PDF_CONTENT_HERE'); + + $response = $file->download(); + + $this->assertInstanceOf(\Symfony\Component\HttpFoundation\BinaryFileResponse::class, $response); + } + + public function test_download_with_custom_filename() + { + $file = File::create(['name' => 'doc', 'extension' => 'pdf', 'type' => 'application/pdf']); + $file->putContents('PDF_CONTENT_HERE'); + + $response = $file->download('custom-name.pdf'); + + $this->assertInstanceOf(\Symfony\Component\HttpFoundation\BinaryFileResponse::class, $response); + } + + // ─── Duplicate ──────────────────────────────────────────────── + + public function test_duplicate_creates_copy() + { + $original = File::create(['name' => 'original', 'extension' => 'txt', 'type' => 'text/plain']); + $original->putContents('Hello World'); + + $clone = $original->duplicate(); + + $this->assertNotEquals($original->id, $clone->id); + $this->assertEquals('original (copy)', $clone->name); + $this->assertEquals('txt', $clone->extension); + $this->assertEquals('Hello World', $clone->getContents()); + } + + public function test_duplicate_with_custom_name() + { + $original = File::create(['name' => 'original', 'extension' => 'txt', 'type' => 'text/plain']); + $original->putContents('Content'); + + $clone = $original->duplicate('renamed'); + $this->assertEquals('renamed', $clone->name); + } + + // ─── toArray ────────────────────────────────────────────────── + + public function test_to_array_includes_computed_attributes() + { + $file = File::create(['name' => 'test', 'extension' => 'pdf', 'size' => 2048]); + + $array = $file->toArray(); + + $this->assertArrayHasKey('url', $array); + $this->assertArrayHasKey('size_human', $array); + $this->assertEquals('2 KB', $array['size_human']); + $this->assertStringContainsString('warehouse', $array['url']); + } +} diff --git a/tests/Unit/HasFilesTest.php b/tests/Unit/HasFilesTest.php new file mode 100644 index 0000000..5bfbd3f --- /dev/null +++ b/tests/Unit/HasFilesTest.php @@ -0,0 +1,480 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); + } + + protected function setUp(): void + { + parent::setUp(); + Storage::fake('local'); + } + + // ─── files() relationship ────────────────────────────────────── + + public function test_files_returns_morph_to_many() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + + $this->assertInstanceOf( + \Illuminate\Database\Eloquent\Relations\MorphToMany::class, + $user->files(), + ); + } + + public function test_files_returns_empty_collection_by_default() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + + $this->assertCount(0, $user->files); + } + + // ─── attachFile ──────────────────────────────────────────────── + + public function test_attach_file_creates_pivot_entry() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'photo']); + + $user->attachFile($file, 'avatar'); + + $this->assertDatabaseHas('filables', [ + 'file_id' => $file->id, + 'filable_id' => $user->id, + 'filable_type' => User::class, + 'as' => 'avatar', + ]); + } + + public function test_attach_file_accepts_file_id_string() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'photo']); + + $user->attachFile($file->id, 'avatar'); + + $this->assertDatabaseHas('filables', [ + 'file_id' => $file->id, + 'filable_id' => $user->id, + ]); + } + + public function test_attach_file_accepts_enum() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'photo']); + + $user->attachFile($file, FileLinkType::Avatar); + + $this->assertDatabaseHas('filables', [ + 'file_id' => $file->id, + 'as' => 'avatar', + ]); + } + + public function test_attach_file_with_order() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'photo']); + + $user->attachFile($file, 'gallery', order: 5); + + $pivot = $user->getFilePivot($file); + $this->assertEquals(5, $pivot->order); + } + + public function test_attach_file_with_meta() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'photo']); + + $user->attachFile($file, 'gallery', meta: ['caption' => 'Nice view']); + + $pivot = $user->getFilePivot($file); + $decoded = json_decode($pivot->meta, true); + $this->assertEquals('Nice view', $decoded['caption']); + } + + public function test_attach_file_prevents_duplicate_pivot() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'photo']); + + $user->attachFile($file, 'avatar'); + $user->attachFile($file, 'avatar'); // duplicate + + $this->assertCount(1, $user->files()->where('file_id', $file->id)->get()); + } + + public function test_attach_file_allows_same_file_with_different_role() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'photo']); + + $user->attachFile($file, 'avatar'); + $user->attachFile($file, 'thumbnail'); + + $this->assertCount(2, $user->files); + } + + public function test_attach_file_replace_removes_previous_attachment() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $old = File::create(['name' => 'old']); + $new = File::create(['name' => 'new']); + + $user->attachFile($old, FileLinkType::Avatar); + $user->attachFile($new, FileLinkType::Avatar, replace: true); + + $user->load('files'); + $avatars = $user->filesAs(FileLinkType::Avatar)->get(); + + $this->assertCount(1, $avatars); + $this->assertEquals($new->id, $avatars->first()->id); + } + + // ─── detachFile ──────────────────────────────────────────────── + + public function test_detach_file_removes_pivot_entry() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'photo']); + + $user->attachFile($file, 'avatar'); + $user->detachFile($file); + + $this->assertDatabaseMissing('filables', [ + 'file_id' => $file->id, + 'filable_id' => $user->id, + ]); + } + + public function test_detach_file_scoped_by_as() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'multipurpose']); + + $user->attachFile($file, 'avatar'); + $user->attachFile($file, 'thumbnail'); + + $user->detachFile($file, 'avatar'); + + $this->assertDatabaseMissing('filables', ['file_id' => $file->id, 'as' => 'avatar']); + $this->assertDatabaseHas('filables', ['file_id' => $file->id, 'as' => 'thumbnail']); + } + + // ─── detachFilesAs ───────────────────────────────────────────── + + public function test_detach_files_as_removes_all_files_with_role() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $f1 = File::create(['name' => 'a']); + $f2 = File::create(['name' => 'b']); + $f3 = File::create(['name' => 'c']); + + $user->attachFile($f1, 'gallery'); + $user->attachFile($f2, 'gallery'); + $user->attachFile($f3, 'avatar'); + + $user->detachFilesAs('gallery'); + + $this->assertDatabaseMissing('filables', ['as' => 'gallery', 'filable_id' => $user->id]); + $this->assertDatabaseHas('filables', ['as' => 'avatar', 'filable_id' => $user->id]); + } + + // ─── detachAllFiles ──────────────────────────────────────────── + + public function test_detach_all_files() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $f1 = File::create(['name' => 'a']); + $f2 = File::create(['name' => 'b']); + + $user->attachFile($f1, 'avatar'); + $user->attachFile($f2, 'banner'); + + $user->detachAllFiles(); + + $this->assertCount(0, $user->files()->get()); + } + + // ─── filesAs / fileAs ────────────────────────────────────────── + + public function test_files_as_returns_only_matching_role() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $avatar = File::create(['name' => 'avatar']); + $banner = File::create(['name' => 'banner']); + + $user->attachFile($avatar, 'avatar'); + $user->attachFile($banner, 'banner'); + + $avatars = $user->filesAs('avatar')->get(); + + $this->assertCount(1, $avatars); + $this->assertEquals($avatar->id, $avatars->first()->id); + } + + public function test_file_as_returns_single_file() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'avatar']); + $user->attachFile($file, FileLinkType::Avatar); + + $result = $user->fileAs(FileLinkType::Avatar); + + $this->assertNotNull($result); + $this->assertEquals($file->id, $result->id); + } + + public function test_file_as_returns_null_when_none_attached() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + + $this->assertNull($user->fileAs('avatar')); + } + + // ─── Convenience getters ─────────────────────────────────────── + + public function test_get_avatar_returns_avatar_file() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'face']); + $user->attachFile($file, FileLinkType::Avatar); + + $this->assertEquals($file->id, $user->getAvatar()->id); + } + + public function test_get_avatar_falls_back_to_profile_image() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'face']); + $user->attachFile($file, FileLinkType::ProfileImage); + + $this->assertEquals($file->id, $user->getAvatar()->id); + } + + public function test_get_thumbnail_returns_thumbnail() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'thumb']); + $user->attachFile($file, FileLinkType::Thumbnail); + + $this->assertEquals($file->id, $user->getThumbnail()->id); + } + + public function test_get_banner_returns_banner() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'banner-img']); + $user->attachFile($file, FileLinkType::Banner); + + $this->assertEquals($file->id, $user->getBanner()->id); + } + + public function test_get_cover_image_returns_cover() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'cover']); + $user->attachFile($file, FileLinkType::CoverImage); + + $this->assertEquals($file->id, $user->getCoverImage()->id); + } + + public function test_get_background_returns_background() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'bg']); + $user->attachFile($file, FileLinkType::Background); + + $this->assertEquals($file->id, $user->getBackground()->id); + } + + public function test_get_logo_returns_logo() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'logo']); + $user->attachFile($file, FileLinkType::Logo); + + $this->assertEquals($file->id, $user->getLogo()->id); + } + + public function test_get_gallery_returns_multiple_files() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $f1 = File::create(['name' => 'g1']); + $f2 = File::create(['name' => 'g2']); + $f3 = File::create(['name' => 'g3']); + + $user->attachFile($f1, FileLinkType::Gallery, order: 0); + $user->attachFile($f2, FileLinkType::Gallery, order: 1); + $user->attachFile($f3, FileLinkType::Gallery, order: 2); + + $gallery = $user->getGallery(); + + $this->assertCount(3, $gallery); + } + + // ─── Polymorphism (multiple models) ──────────────────────────── + + public function test_same_file_attached_to_different_models() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $article = Article::create(['title' => 'My Post']); + $file = File::create(['name' => 'shared']); + + $user->attachFile($file, 'avatar'); + $article->attachFile($file, 'thumbnail'); + + $this->assertCount(1, $user->files); + $this->assertCount(1, $article->files); + $this->assertCount(2, $file->filables); + } + + public function test_different_models_files_are_independent() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $article = Article::create(['title' => 'Post']); + + $userFile = File::create(['name' => 'user-pic']); + $articleFile = File::create(['name' => 'article-pic']); + + $user->attachFile($userFile, 'avatar'); + $article->attachFile($articleFile, 'thumbnail'); + + $user->detachAllFiles(); + + $this->assertCount(0, $user->files()->get()); + $this->assertCount(1, $article->files()->get()); + } + + // ─── uploadFile ──────────────────────────────────────────────── + + public function test_upload_file_creates_and_attaches() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $upload = \Illuminate\Http\UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf'); + + $file = $user->uploadFile($upload, FileLinkType::Document); + + $this->assertNotNull($file->id); + $this->assertTrue($file->hasContents()); + $this->assertEquals('doc', $file->name); + $this->assertCount(1, $user->files()->get()); + $this->assertDatabaseHas('filables', [ + 'file_id' => $file->id, + 'as' => 'document', + ]); + } + + public function test_upload_file_with_replace() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + + $u1 = \Illuminate\Http\UploadedFile::fake()->create('old.jpg', 50, 'image/jpeg'); + $u2 = \Illuminate\Http\UploadedFile::fake()->create('new.jpg', 50, 'image/jpeg'); + + $user->uploadFile($u1, FileLinkType::Avatar); + $newFile = $user->uploadFile($u2, FileLinkType::Avatar, replace: true); + + $this->assertCount(1, $user->filesAs(FileLinkType::Avatar)->get()); + $this->assertEquals($newFile->id, $user->getAvatar()->id); + } + + // ─── uploadFileFromContents ──────────────────────────────────── + + public function test_upload_file_from_contents() + { + $article = Article::create(['title' => 'Post']); + + $file = $article->uploadFileFromContents( + 'raw content here', + name: 'readme', + extension: 'txt', + as: FileLinkType::Attachment, + ); + + $this->assertEquals('readme', $file->name); + $this->assertEquals('txt', $file->extension); + $this->assertTrue($file->hasContents()); + $this->assertEquals('raw content here', $file->getContents()); + } + + // ─── getFilePivot ────────────────────────────────────────────── + + public function test_get_file_pivot_returns_pivot() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'photo']); + $user->attachFile($file, 'avatar', order: 3); + + $pivot = $user->getFilePivot($file); + + $this->assertNotNull($pivot); + $this->assertEquals('avatar', $pivot->as); + $this->assertEquals(3, $pivot->order); + } + + public function test_get_file_pivot_returns_null_when_not_attached() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $file = File::create(['name' => 'orphan']); + + $this->assertNull($user->getFilePivot($file)); + } + + // ─── reorderFiles ────────────────────────────────────────────── + + public function test_reorder_files_sets_order() + { + $user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']); + $f1 = File::create(['name' => 'a']); + $f2 = File::create(['name' => 'b']); + $f3 = File::create(['name' => 'c']); + + $user->attachFile($f1, 'gallery'); + $user->attachFile($f2, 'gallery'); + $user->attachFile($f3, 'gallery'); + + // Reorder: c, a, b + $user->reorderFiles([$f3->id, $f1->id, $f2->id], 'gallery'); + + $reloaded = $user->filesAs('gallery')->orderByPivot('order')->get(); + + $this->assertEquals($f3->id, $reloaded[0]->id); + $this->assertEquals($f1->id, $reloaded[1]->id); + $this->assertEquals($f2->id, $reloaded[2]->id); + } +} diff --git a/tests/Unit/WarehouseServiceTest.php b/tests/Unit/WarehouseServiceTest.php new file mode 100644 index 0000000..7e4325d --- /dev/null +++ b/tests/Unit/WarehouseServiceTest.php @@ -0,0 +1,173 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + $app['config']->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations'); + } + + protected function setUp(): void + { + parent::setUp(); + Storage::fake('local'); + } + + // ─── searchFile — UUID lookup ────────────────────────────────── + + public function test_search_finds_file_by_uuid() + { + $file = File::create(['name' => 'found']); + $file->putContents('content'); + + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, $file->id); + + $this->assertNotNull($result); + $this->assertEquals($file->id, $result->id); + } + + // ─── searchFile — encrypted ID ───────────────────────────────── + + public function test_search_finds_file_by_encrypted_id() + { + $file = File::create(['name' => 'encrypted']); + $file->putContents('content'); + + $encrypted = encrypt($file->id); + + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, $encrypted); + + $this->assertNotNull($result); + $this->assertEquals($file->id, $result->id); + } + + // ─── searchFile — null / empty ───────────────────────────────── + + public function test_search_returns_null_for_null_identifier() + { + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, null); + + $this->assertNull($result); + } + + public function test_search_returns_null_for_nonexistent_id() + { + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, 'nonexistent-uuid-here'); + + $this->assertNull($result); + } + + // ─── searchFile — query string stripping ────────────────────── + + public function test_search_strips_query_string_from_identifier() + { + $file = File::create(['name' => 'qs']); + $file->putContents('content'); + + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, $file->id . '?size=100x100'); + + $this->assertNotNull($result); + $this->assertEquals($file->id, $result->id); + } + + // ─── searchFile — asset path ────────────────────────────────── + + public function test_search_finds_asset_by_exact_path() + { + Storage::disk('local')->put('images/logo.png', 'png-data'); + + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, 'images/logo.png'); + + $this->assertNotNull($result); + $this->assertEquals('logo.png', $result->name); + $this->assertEquals('png', $result->extension); + } + + public function test_search_finds_asset_with_auto_extension() + { + Storage::disk('local')->put('icons/arrow.svg', ''); + + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, 'icons/arrow'); + + $this->assertNotNull($result); + $this->assertEquals('svg', $result->extension); + } + + public function test_search_prefers_svg_over_png_when_both_exist() + { + Storage::disk('local')->put('icons/logo.svg', ''); + Storage::disk('local')->put('icons/logo.png', 'png-data'); + + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, 'icons/logo'); + + $this->assertNotNull($result); + // svg comes first in preferred_extensions + $this->assertEquals('svg', $result->extension); + } + + // ─── searchFile — storage path ───────────────────────────────── + + public function test_search_finds_by_storage_path() + { + Storage::disk('local')->put('audio/clip.mp3', 'audio-data'); + + $request = new \Illuminate\Http\Request; + $result = WarehouseService::searchFile($request, 'storage/audio/clip.mp3'); + + $this->assertNotNull($result); + $this->assertEquals('clip.mp3', $result->name); + } + + // ─── url() ───────────────────────────────────────────────────── + + public function test_url_generates_warehouse_path() + { + $file = File::create(['name' => 'test']); + + $url = WarehouseService::url($file); + + $this->assertStringContainsString('warehouse/' . $file->id, $url); + } + + public function test_url_accepts_string_id() + { + $url = WarehouseService::url('some-uuid'); + + $this->assertStringContainsString('warehouse/some-uuid', $url); + } +}