From b2b8d50edba3148dc25504dd414bba48b6ca8676 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 9 Sep 2026 12:18:04 +0200 Subject: [PATCH] Add symlink support for prefixed disks Disk prefixes are no longer ignored by tenants:link. possibleTenantSymlinks() now appends them to both the public path and the disk root. CreateStorageSymlinksAction now creates parent directories for the symlinks in the public/ directory (e.g. for a disk with 'abc/def' prefix, the 'abc/def' subdirectory will be created inside public/). RemoveStorageSymlinksAction removes the directories that CreateStorageSymlinksAction creates for the symlinks. --- src/Actions/CreateStorageSymlinksAction.php | 6 + src/Actions/RemoveStorageSymlinksAction.php | 14 ++- src/Concerns/DealsWithTenantSymlinks.php | 10 +- tests/ActionTest.php | 116 ++++++++++++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/Actions/CreateStorageSymlinksAction.php b/src/Actions/CreateStorageSymlinksAction.php index 2de9e915..94917c10 100644 --- a/src/Actions/CreateStorageSymlinksAction.php +++ b/src/Actions/CreateStorageSymlinksAction.php @@ -47,6 +47,12 @@ class CreateStorageSymlinksAction mkdir($storagePath, 0777, true); } + // The public path of a prefixed disk includes the prefix, + // and its parent directories may not exist yet. + if (! is_dir($publicParent = dirname($publicPath))) { + mkdir($publicParent, 0777, true); + } + if ($relativeLink) { app()->make('files')->relativeLink($storagePath, $publicPath); } else { diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php index 7b7b04d5..78b27e74 100644 --- a/src/Actions/RemoveStorageSymlinksAction.php +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -32,12 +32,24 @@ class RemoveStorageSymlinksAction protected function removeLink(string $publicPath, Tenant $tenant): void { + $files = app()->make('files'); + if ($this->symlinkExists($publicPath)) { event(new RemovingStorageSymlink($tenant)); - app()->make('files')->delete($publicPath); + $files->delete($publicPath); event(new StorageSymlinkRemoved($tenant)); } + + // Remove the directories CreateStorageSymlinksAction created for the symlink + // until a non-empty one is reached. + $directory = dirname($publicPath); + + while ($directory !== public_path() && $files->isEmptyDirectory($directory)) { + $files->deleteDirectory($directory); + + $directory = dirname($directory); + } } } diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index 578bda01..3bb267c8 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -58,8 +58,16 @@ trait DealsWithTenantSymlinks } $publicPath = str_replace('%tenant%', (string) $tenantKey, $publicPath); + $diskRoot = $tenantDisks[$disk]['root']; - $symlinks[public_path($publicPath)] = $tenantDisks[$disk]['root']; + if ($prefix = trim($disks[$disk]['prefix'] ?? '', '/\\')) { + $diskRoot = rtrim($diskRoot, '/\\') . DIRECTORY_SEPARATOR . $prefix; + + // Storage::url() appends the disk's prefix to the url, so the prefix has to be in the public path as well + $publicPath .= DIRECTORY_SEPARATOR . $prefix; + } + + $symlinks[public_path($publicPath)] = $diskRoot; } return $symlinks; diff --git a/tests/ActionTest.php b/tests/ActionTest.php index b2076475..4b364603 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -149,3 +149,119 @@ test('removing tenant symlinks works even if the symlinks are invalid', function expect(is_link($publicPath))->toBeFalse(); }); + +test('disks with a prefix are symlinked correctly', function (string $prefix) { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%', + 'filesystems.disks.public.prefix' => $prefix, + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + // possibleTenantSymlinks() trims the prefix, do the same here for the assertions to be accurate + $prefix = trim($prefix, '/'); + + tenancy()->initialize($tenant); + + Storage::disk('public')->put('foo.txt', 'tenant file'); + + (new CreateStorageSymlinksAction)($tenant); + + expect(Storage::disk('public')->url('foo.txt'))->toBe("http://localhost/public-{$tenantKey}/{$prefix}/foo.txt"); + expect(readlink(public_path("public-{$tenantKey}/{$prefix}")))->toBe(storage_path("app/public/{$prefix}")); + expect(file_get_contents(public_path("public-{$tenantKey}/{$prefix}/foo.txt")))->toBe('tenant file'); +})->with(['abc', 'abc/def', '/abc/def/']); + +test('symlinks of prefixed disks only expose the prefixed directory', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%', + 'filesystems.disks.public.prefix' => 'abc', + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + Storage::disk('public')->put('foo.txt', 'tenant file'); + + // The disk cannot reach this file, neither should the symlink + File::put(storage_path('app/public/sibling.txt'), 'file next to the prefixed directory'); + + (new CreateStorageSymlinksAction)($tenant); + + expect(file_exists(public_path("public-{$tenantKey}/sibling.txt")))->toBeFalse(); + expect(file_get_contents(public_path("public-{$tenantKey}/abc/foo.txt")))->toBe('tenant file'); +}); + +test('removing a prefixed disk symlink removes the directories created for it', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%', + 'filesystems.disks.public.prefix' => 'abc/def', + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + Storage::disk('public')->put('foo.txt', 'tenant file'); + + (new CreateStorageSymlinksAction)($tenant); + + $symlink = public_path("public-{$tenantKey}/abc/def"); + $diskRoot = readlink($symlink); + + (new RemoveStorageSymlinksAction)($tenant); + + // The symlink and every directory created for it are deleted + expect(is_link($symlink))->toBeFalse(); + expect(file_exists(public_path("public-{$tenantKey}")))->toBeFalse(); + // public_path() itself is not deleted + expect(is_dir(public_path()))->toBeTrue(); + // The directory that the symlink points to is untouched + expect(file_get_contents($diskRoot . '/foo.txt'))->toBe('tenant file'); +}); + +test('non-empty directories are not removed with the symlink', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant%', + 'filesystems.disks.public.prefix' => 'abc/def', + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + (new CreateStorageSymlinksAction)($tenant); + + File::put(public_path("public-{$tenantKey}/abc/actual-file.txt"), 'foo'); + + (new RemoveStorageSymlinksAction)($tenant); + + expect(is_link(public_path("public-{$tenantKey}/abc/def")))->toBeFalse(); + expect(file_get_contents(public_path("public-{$tenantKey}/abc/actual-file.txt")))->toBe('foo'); +});