From 20b1c91e6ee3a3b8390c4a853d50f7ceeae322d4 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 23 Jun 2026 11:08:28 +0200 Subject: [PATCH] feat(access): serve-time file access control middleware Add an opt-in FileAccessControl middleware that resolves the warehouse file and enforces File::canBeAccessedBy() (default public, so existing consumers are unaffected) when files.access_control.enabled is set. Resolution is delegated to a configurable files.warehouse.resolver (ResolvesWarehouseFiles) and the resolved file is stashed on the request so the controller reuses it. Exposed as the `files.access` route-middleware alias and auto-attached to the package warehouse route. Adds the FileAccessControl unit suite. --- config/files.php | 20 ++- routes/files.php | 11 +- src/Contracts/ResolvesWarehouseFiles.php | 19 +++ src/FilesServiceProvider.php | 15 ++ src/Http/Controllers/WarehouseController.php | 33 +---- src/Http/Middleware/FileAccessControl.php | 74 ++++++++++ src/Models/File.php | 18 +++ tests/Unit/FileAccessControlTest.php | 146 +++++++++++++++++++ 8 files changed, 306 insertions(+), 30 deletions(-) create mode 100644 src/Contracts/ResolvesWarehouseFiles.php create mode 100644 src/Http/Middleware/FileAccessControl.php create mode 100644 tests/Unit/FileAccessControlTest.php diff --git a/config/files.php b/config/files.php index 3a28df1..bb848ae 100644 --- a/config/files.php +++ b/config/files.php @@ -83,6 +83,17 @@ return [ 'enabled' => true, 'prefix' => 'warehouse', 'middleware' => ['web'], + + /* + | Resolver used by the access-control middleware to find the file for a + | warehouse request. A class implementing + | Blax\Files\Contracts\ResolvesWarehouseFiles (or any invokable). When + | null, the package WarehouseService is used. Host apps with a custom + | lookup flow (encrypted ids, client/server assets, …) point this at + | their own resolver so the middleware reuses it. Keep this a class + | string — closures here would break `config:cache`. + */ + 'resolver' => env('FILES_WAREHOUSE_RESOLVER'), ], /* @@ -123,13 +134,16 @@ return [ | Access Control |-------------------------------------------------------------------------- | - | When laravel-roles is installed, files can be protected via access - | checks. Set 'enabled' to false to serve all files publicly. + | When enabled, the FileAccessControl middleware enforces the File model's + | canBeAccessedBy() decision on every warehouse request and 403s anyone who + | is not allowed. Default false = serve all files publicly (backwards + | compatible). Override canBeAccessedBy() on your File model to define the + | per-file policy. | */ 'access_control' => [ - 'enabled' => false, + 'enabled' => env('FILES_ACCESS_CONTROL', false), ], ]; diff --git a/routes/files.php b/routes/files.php index f124337..21370a2 100644 --- a/routes/files.php +++ b/routes/files.php @@ -2,6 +2,7 @@ use Blax\Files\Http\Controllers\FileUploadController; use Blax\Files\Http\Controllers\WarehouseController; +use Blax\Files\Http\Middleware\FileAccessControl; use Illuminate\Support\Facades\Route; /* @@ -11,7 +12,15 @@ use Illuminate\Support\Facades\Route; */ if (config('files.warehouse.enabled', true)) { - Route::middleware(config('files.warehouse.middleware', ['web'])) + // The access-control middleware is always attached; it no-ops unless + // `files.access_control.enabled` is true, so this stays a public file + // server by default while letting consumers opt into per-file guards. + $warehouseMiddleware = array_merge( + (array) config('files.warehouse.middleware', ['web']), + [FileAccessControl::class], + ); + + Route::middleware($warehouseMiddleware) ->get(config('files.warehouse.prefix', 'warehouse') . '/{identifier?}', WarehouseController::class) ->name('files.warehouse') ->where('identifier', '[\/\w\.\-\=&@]*'); diff --git a/src/Contracts/ResolvesWarehouseFiles.php b/src/Contracts/ResolvesWarehouseFiles.php new file mode 100644 index 0000000..8d4375a --- /dev/null +++ b/src/Contracts/ResolvesWarehouseFiles.php @@ -0,0 +1,19 @@ +offerPublishing(); $this->registerMigrations(); $this->registerModelBindings(); + $this->registerMiddleware(); $this->registerRoutes(); $this->registerCommands(); } + /** + * Expose the serve-time access-control middleware under the `files.access` + * alias so host apps that wire their own warehouse route can guard it with + * `->middleware('files.access')`. The package's own route attaches the + * middleware class directly (see routes/files.php). + */ + protected function registerMiddleware(): void + { + $this->app['router']->aliasMiddleware( + 'files.access', + Http\Middleware\FileAccessControl::class, + ); + } + /** * Auto-load the package's migrations so fresh installs work without * publishing. Disabled via `files.run_migrations = false` for projects diff --git a/src/Http/Controllers/WarehouseController.php b/src/Http/Controllers/WarehouseController.php index acb51c2..39f4b74 100644 --- a/src/Http/Controllers/WarehouseController.php +++ b/src/Http/Controllers/WarehouseController.php @@ -4,7 +4,7 @@ namespace Blax\Files\Http\Controllers; use Blax\Files\Events\FileAccessed; use Blax\Files\Events\FileNotFound; -use Blax\Files\Models\File; +use Blax\Files\Http\Middleware\FileAccessControl; use Blax\Files\Services\WarehouseService; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -15,39 +15,20 @@ class WarehouseController extends Controller { $identifier ??= $request->get('id'); - $file = WarehouseService::searchFile($request, $identifier); + // Reuse the file the access-control middleware already resolved (and + // authorized) when it ran, to avoid a second lookup. Falls back to a + // fresh search when access control is disabled (middleware no-ops). + $file = $request->attributes->has(FileAccessControl::ATTRIBUTE) + ? $request->attributes->get(FileAccessControl::ATTRIBUTE) + : WarehouseService::searchFile($request, $identifier); if (! $file) { FileNotFound::dispatch($identifier, $request); abort(404); } - // Access control check (optional, via laravel-roles) - if (config('files.access_control.enabled') && $file->exists) { - $this->checkAccess($request, $file); - } - FileAccessed::dispatch($file, $request); return $file->respond($request); } - - protected function checkAccess(Request $request, File $file): void - { - // If laravel-roles is installed, check HasAccess - if ( - trait_exists(\Blax\Roles\Traits\HasAccess::class) - && method_exists($file, 'hasAccess') - ) { - $user = $request->user(); - - if (! $user) { - abort(403, 'Authentication required.'); - } - - if (! $user->hasAccess($file)) { - abort(403, 'Access denied.'); - } - } - } } diff --git a/src/Http/Middleware/FileAccessControl.php b/src/Http/Middleware/FileAccessControl.php new file mode 100644 index 0000000..5d17575 --- /dev/null +++ b/src/Http/Middleware/FileAccessControl.php @@ -0,0 +1,74 @@ +resolveFile($request); + + // Always set the attribute when enabled so the controller can tell + // "middleware ran and resolved null (404)" from "middleware did not run". + $request->attributes->set(self::ATTRIBUTE, $file); + + if ($file && $file->exists && method_exists($file, 'canBeAccessedBy')) { + if (! $file->canBeAccessedBy($request->user())) { + abort(403, 'You are not allowed to access this file.'); + } + } + + return $next($request); + } + + protected function resolveFile(Request $request) + { + $resolver = config('files.warehouse.resolver'); + + if ($resolver) { + $instance = is_string($resolver) ? app($resolver) : $resolver; + + if ($instance instanceof ResolvesWarehouseFiles) { + return $instance->resolve($request); + } + + if (is_callable($instance)) { + return $instance($request); + } + } + + return WarehouseService::searchFile( + $request, + $request->route('identifier') ?? $request->route('encrypted_id') ?? $request->get('id'), + ); + } +} diff --git a/src/Models/File.php b/src/Models/File.php index 4e72347..87fb122 100644 --- a/src/Models/File.php +++ b/src/Models/File.php @@ -156,6 +156,24 @@ class File extends Model return url("{$prefix}/{$this->id}"); } + /* + |-------------------------------------------------------------------------- + | Access Control + |-------------------------------------------------------------------------- + */ + + /** + * Decide whether the given user (null = guest) may fetch this file. + * + * The base model treats every file as public so existing consumers are + * unaffected. Apps that need serve-time privacy override this (and enable + * `files.access_control.enabled`) to gate avatars, private uploads, etc. + */ + public function canBeAccessedBy(?\Illuminate\Contracts\Auth\Authenticatable $user): bool + { + return true; + } + /* |-------------------------------------------------------------------------- | File Content Operations diff --git a/tests/Unit/FileAccessControlTest.php b/tests/Unit/FileAccessControlTest.php new file mode 100644 index 0000000..56d1850 --- /dev/null +++ b/tests/Unit/FileAccessControlTest.php @@ -0,0 +1,146 @@ +ownerOnly) { + return $user !== null; // authenticated-level + } + + return $user !== null && (int) $user->getAuthIdentifier() === 1; // owner-level + } + }; + $file->ownerOnly = $ownerOnly; + $file->exists = true; // simulate a persisted file without touching the DB + + return $file; + } + + protected function withResolver(File $file): void + { + config(['files.warehouse.resolver' => new class($file) implements ResolvesWarehouseFiles + { + public function __construct(private File $file) {} + + public function resolve(Request $request): ?Model + { + return $this->file; + } + }]); + } + + protected function pass(FileAccessControl $mw, Request $request): mixed + { + return $mw->handle($request, fn ($req) => response('served')); + } + + public function test_disabled_serves_everything_without_checking(): void + { + config(['files.access_control.enabled' => false]); + // Resolver returns a file that would deny everyone; must be ignored. + $this->withResolver($this->gatedFile()); + + $response = $this->pass(new FileAccessControl, Request::create('/warehouse/x')); + + $this->assertEquals('served', $response->getContent()); + } + + public function test_enabled_serves_public_base_file(): void + { + config(['files.access_control.enabled' => true]); + $public = new File(['name' => 'pub']); + $public->exists = true; // base File::canBeAccessedBy() returns true + $this->withResolver($public); + + $response = $this->pass(new FileAccessControl, Request::create('/warehouse/x')); + + $this->assertEquals('served', $response->getContent()); + } + + public function test_enabled_403s_guest_on_gated_file(): void + { + config(['files.access_control.enabled' => true]); + $this->withResolver($this->gatedFile()); + + try { + $this->pass(new FileAccessControl, Request::create('/warehouse/x')); + $this->fail('Expected a 403 HttpException for a guest on a gated file.'); + } catch (HttpException $e) { + $this->assertEquals(403, $e->getStatusCode()); + } + } + + public function test_enabled_allows_owner_on_gated_file(): void + { + config(['files.access_control.enabled' => true]); + $this->withResolver($this->gatedFile()); + + $request = Request::create('/warehouse/x'); + $request->setUserResolver(fn () => new class extends \Illuminate\Foundation\Auth\User + { + public function getAuthIdentifier() + { + return 1; + } + }); + + $response = $this->pass(new FileAccessControl, $request); + + $this->assertEquals('served', $response->getContent()); + } + + public function test_enabled_403s_non_owner_on_owner_only_file(): void + { + config(['files.access_control.enabled' => true]); + $this->withResolver($this->gatedFile(ownerOnly: true)); + + $request = Request::create('/warehouse/x'); + $request->setUserResolver(fn () => new class extends \Illuminate\Foundation\Auth\User + { + public function getAuthIdentifier() + { + return 999; + } + }); + + try { + $this->pass(new FileAccessControl, $request); + $this->fail('Expected a 403 for a non-owner user on an owner-only file.'); + } catch (HttpException $e) { + $this->assertEquals(403, $e->getStatusCode()); + } + } + + public function test_resolved_file_is_stashed_on_request(): void + { + config(['files.access_control.enabled' => true]); + $public = new File(['name' => 'pub']); + $public->exists = true; + $this->withResolver($public); + + $request = Request::create('/warehouse/x'); + $this->pass(new FileAccessControl, $request); + + $this->assertTrue($request->attributes->has(FileAccessControl::ATTRIBUTE)); + $this->assertSame($public, $request->attributes->get(FileAccessControl::ATTRIBUTE)); + } +}