Initial release
This commit is contained in:
commit
62d2273557
|
|
@ -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
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) Blax Software <office@blax.at>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
[](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.
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Models
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Override these to use your own model classes.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'models' => [
|
||||||
|
'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,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create(config('files.table_names.filables', 'filables'), function (Blueprint $table) {
|
||||||
|
$table->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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create(config('files.table_names.files', 'files'), function (Blueprint $table) {
|
||||||
|
$table->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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'models' => [
|
||||||
|
'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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Blax\Files\Http\Controllers\FileUploadController;
|
||||||
|
use Blax\Files\Http\Controllers\WarehouseController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Warehouse (file serving)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (config('files.warehouse.enabled', true)) {
|
||||||
|
Route::middleware(config('files.warehouse.middleware', ['web']))
|
||||||
|
->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');
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Console;
|
||||||
|
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CleanupOrphanedFilesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'files:cleanup
|
||||||
|
{--days=30 : Remove orphaned files older than N days}
|
||||||
|
{--dry-run : Show what would be deleted without deleting}';
|
||||||
|
|
||||||
|
protected $description = 'Remove files that are not attached to any model';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Enums;
|
||||||
|
|
||||||
|
enum FileLinkType: string
|
||||||
|
{
|
||||||
|
// Visual Identity
|
||||||
|
case Avatar = 'avatar';
|
||||||
|
case ProfileImage = 'profile_image';
|
||||||
|
case CoverImage = 'cover_image';
|
||||||
|
case Banner = 'banner';
|
||||||
|
case Background = 'background';
|
||||||
|
case Logo = 'logo';
|
||||||
|
case Icon = 'icon';
|
||||||
|
case Thumbnail = 'thumbnail';
|
||||||
|
|
||||||
|
// Documents
|
||||||
|
case Document = 'document';
|
||||||
|
case Invoice = 'invoice';
|
||||||
|
case Contract = 'contract';
|
||||||
|
case Certificate = 'certificate';
|
||||||
|
case Report = 'report';
|
||||||
|
|
||||||
|
// Media
|
||||||
|
case Gallery = 'gallery';
|
||||||
|
case Video = 'video';
|
||||||
|
case Audio = 'audio';
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
case Attachment = 'attachment';
|
||||||
|
case Download = 'download';
|
||||||
|
|
||||||
|
// Catch-All
|
||||||
|
case Other = 'other';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Avatar => '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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Events;
|
||||||
|
|
||||||
|
use Illuminate\Broadcasting\Channel;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ChunkUploadProgress implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $uploadId,
|
||||||
|
public int $chunkIndex,
|
||||||
|
public int $totalChunks,
|
||||||
|
public bool $complete,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new Channel("chunk-upload.{$this->uploadId}")];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'chunk.progress';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files;
|
||||||
|
|
||||||
|
class FilesServiceProvider extends \Illuminate\Support\ServiceProvider
|
||||||
|
{
|
||||||
|
public function register()
|
||||||
|
{
|
||||||
|
$this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Http\Controllers;
|
||||||
|
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Blax\Files\Services\ChunkUploadService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
class FileUploadController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Standard single-file upload.
|
||||||
|
*/
|
||||||
|
public function upload(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$rules = [
|
||||||
|
'file' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Http\Controllers;
|
||||||
|
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Blax\Files\Services\WarehouseService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
class WarehouseController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, ?string $identifier = null)
|
||||||
|
{
|
||||||
|
$identifier ??= $request->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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Models;
|
||||||
|
|
||||||
|
use Blax\Files\Enums\FileLinkType;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphPivot;
|
||||||
|
|
||||||
|
class Filable extends MorphPivot
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'file_id',
|
||||||
|
'filable_id',
|
||||||
|
'filable_type',
|
||||||
|
'as',
|
||||||
|
'order',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'meta' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,453 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class File extends Model
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'name',
|
||||||
|
'extension',
|
||||||
|
'type',
|
||||||
|
'size',
|
||||||
|
'disk',
|
||||||
|
'relativepath',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'id' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Services;
|
||||||
|
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ChunkUploadService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Initialize a chunked upload session.
|
||||||
|
*
|
||||||
|
* Returns a File model and a temporary upload identifier.
|
||||||
|
*/
|
||||||
|
public static function initialize(Request $request): array
|
||||||
|
{
|
||||||
|
$fileName = $request->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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Services;
|
||||||
|
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class WarehouseService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Search for a file based on the request and identifier.
|
||||||
|
*/
|
||||||
|
public static function searchFile(Request $request, ?string $identifier): ?File
|
||||||
|
{
|
||||||
|
if (! $identifier) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip query string if present
|
||||||
|
if (str_contains($identifier, '?')) {
|
||||||
|
$identifier = explode('?', $identifier)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Try UUID lookup
|
||||||
|
$file = static::searchByUuid($identifier);
|
||||||
|
if ($file) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try encrypted ID
|
||||||
|
$file = static::searchByEncryptedId($identifier);
|
||||||
|
if ($file) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try as static asset path
|
||||||
|
$file = static::searchAssetPath($identifier);
|
||||||
|
if ($file) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try raw storage path
|
||||||
|
return static::searchStoragePath($identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search by UUID (direct File ID).
|
||||||
|
*/
|
||||||
|
protected static function searchByUuid(string $identifier): ?File
|
||||||
|
{
|
||||||
|
return File::find($identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search by encrypted (legacy) ID.
|
||||||
|
*/
|
||||||
|
protected static function searchByEncryptedId(string $identifier): ?File
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$decryptedId = decrypt($identifier);
|
||||||
|
if ($decryptedId) {
|
||||||
|
return File::find($decryptedId);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Not an encrypted ID — ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a static asset, trying preferred extensions.
|
||||||
|
*/
|
||||||
|
protected static function searchAssetPath(string $path): ?File
|
||||||
|
{
|
||||||
|
$disk = config('files.disk', 'local');
|
||||||
|
$extensions = config('files.optimization.preferred_extensions', ['svg', 'webp', 'png', 'jpg', 'jpeg']);
|
||||||
|
|
||||||
|
// Try exact path
|
||||||
|
if (Storage::disk($disk)->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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Traits;
|
||||||
|
|
||||||
|
use Blax\Files\Enums\FileLinkType;
|
||||||
|
use Blax\Files\Models\Filable;
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
trait HasFiles
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Relationships
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function files(): MorphToMany
|
||||||
|
{
|
||||||
|
return $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Tests\Unit;
|
||||||
|
|
||||||
|
use Blax\Files\FilesServiceProvider;
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Blax\Files\Services\ChunkUploadService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
class ChunkUploadServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [FilesServiceProvider::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defineEnvironment($app): void
|
||||||
|
{
|
||||||
|
$app['config']->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,249 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Tests\Unit;
|
||||||
|
|
||||||
|
use Blax\Files\Enums\FileLinkType;
|
||||||
|
use Blax\Files\FilesServiceProvider;
|
||||||
|
use Blax\Files\Models\Filable;
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
class FilableModelTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [FilesServiceProvider::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defineEnvironment($app): void
|
||||||
|
{
|
||||||
|
$app['config']->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Tests\Unit;
|
||||||
|
|
||||||
|
use Blax\Files\Enums\FileLinkType;
|
||||||
|
use Blax\Files\FilesServiceProvider;
|
||||||
|
use Blax\Files\Models\Filable;
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
class FileLinkTypeTest extends TestCase
|
||||||
|
{
|
||||||
|
// ─── label() ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_all_cases_have_labels()
|
||||||
|
{
|
||||||
|
foreach (FileLinkType::cases() as $case) {
|
||||||
|
$label = $case->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,424 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Tests\Unit;
|
||||||
|
|
||||||
|
use Blax\Files\Enums\FileLinkType;
|
||||||
|
use Blax\Files\FilesServiceProvider;
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
class FileModelTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [FilesServiceProvider::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defineEnvironment($app): void
|
||||||
|
{
|
||||||
|
$app['config']->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,480 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Tests\Unit;
|
||||||
|
|
||||||
|
use Blax\Files\Enums\FileLinkType;
|
||||||
|
use Blax\Files\FilesServiceProvider;
|
||||||
|
use Blax\Files\Models\Filable;
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
use Workbench\App\Models\Article;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
class HasFilesTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [FilesServiceProvider::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defineEnvironment($app): void
|
||||||
|
{
|
||||||
|
$app['config']->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Files\Tests\Unit;
|
||||||
|
|
||||||
|
use Blax\Files\FilesServiceProvider;
|
||||||
|
use Blax\Files\Models\File;
|
||||||
|
use Blax\Files\Services\WarehouseService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
class WarehouseServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [FilesServiceProvider::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defineEnvironment($app): void
|
||||||
|
{
|
||||||
|
$app['config']->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', '<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', '<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue