From ab5fa7a2479816feb46a95306d7e4a9da5e654ff Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 20 Sep 2022 19:42:00 +0200 Subject: [PATCH 1/4] [4.x] Optionally delete storage after tenant deletion (#938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add test for deleting storage after tenant deletion * Save `storage_path()` in a variable after initializing tenant in test Co-authored-by: Samuel Štancl * Add DeleteTenantStorage listener * Update test name * Remove storage deletion config key * Remove tenant storage deletion events * Move tenant storage deletion to the DeletingTenant event Co-authored-by: Samuel Štancl --- assets/TenancyServiceProvider.stub.php | 2 ++ src/Listeners/DeleteTenantStorage.php | 16 +++++++++++++++ tests/BootstrapperTest.php | 28 +++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/Listeners/DeleteTenantStorage.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 865bb93d..d8e76e6f 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -46,6 +46,8 @@ class TenancyServiceProvider extends ServiceProvider ])->send(function (Events\DeletingTenant $event) { return $event->tenant; })->shouldBeQueued(false), + + // Listeners\DeleteTenantStorage::class, ], Events\TenantDeleted::class => [ JobPipeline::make([ diff --git a/src/Listeners/DeleteTenantStorage.php b/src/Listeners/DeleteTenantStorage.php new file mode 100644 index 00000000..ce1a4203 --- /dev/null +++ b/src/Listeners/DeleteTenantStorage.php @@ -0,0 +1,16 @@ +tenant->run(fn () => storage_path())); + } +} diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index 96afbc83..ada6b964 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; use Stancl\JobPipeline\JobPipeline; +use Illuminate\Support\Facades\File; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Events\TenantCreated; +use Illuminate\Filesystem\FilesystemAdapter; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; @@ -21,6 +22,8 @@ use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; +use Stancl\Tenancy\Events\DeletingTenant; +use Stancl\Tenancy\Listeners\DeleteTenantStorage; beforeEach(function () { $this->mockConsoleOutput = false; @@ -184,6 +187,29 @@ test('filesystem data is separated', function () { expect($new_storage_path)->toEqual($expected_storage_path); }); +test('tenant storage can get deleted after the tenant when DeletingTenant listens to DeleteTenantStorage', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + ]); + + Event::listen(DeletingTenant::class, DeleteTenantStorage::class); + + tenancy()->initialize(Tenant::create()); + $tenantStoragePath = storage_path(); + + Storage::fake('test'); + + expect(File::isDirectory($tenantStoragePath))->toBeTrue(); + + Storage::put('test.txt', 'testing file'); + + tenant()->delete(); + + expect(File::isDirectory($tenantStoragePath))->toBeFalse(); +}); + function getDiskPrefix(string $disk): string { /** @var FilesystemAdapter $disk */ From 8e3b74f9d13a7ec6fb413e70631764c2da47fd22 Mon Sep 17 00:00:00 2001 From: Abrar Ahmad Date: Sat, 24 Sep 2022 07:08:44 +0500 Subject: [PATCH 2/4] [4.x] Finish incomplete and missing tests (#947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * complete test sqlite manager customize path * complete test seed command works * complete uniqe exists test * Update SingleDatabaseTenancyTest.php * refactor the ternary into if condition * custom path * simplify if condition * random dir name * Update SingleDatabaseTenancyTest.php * Update CommandsTest.php * prefix random DB name with custom_ Co-authored-by: Samuel Štancl --- .../SQLiteDatabaseManager.php | 20 +++++++++++++--- tests/CommandsTest.php | 24 +++++++++++++++---- tests/SingleDatabaseTenancyTest.php | 8 +++---- tests/TenantDatabaseManagerTest.php | 20 +++++++++++++++- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 59c373a9..ada5d642 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -10,10 +10,15 @@ use Throwable; class SQLiteDatabaseManager implements TenantDatabaseManager { + /** + * SQLite Database path without ending slash. + */ + public static string|null $path = null; + public function createDatabase(TenantWithDatabase $tenant): bool { try { - return file_put_contents(database_path($tenant->database()->getName()), ''); + return (bool) file_put_contents($this->getPath($tenant->database()->getName()), ''); } catch (Throwable) { return false; } @@ -22,7 +27,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function deleteDatabase(TenantWithDatabase $tenant): bool { try { - return unlink(database_path($tenant->database()->getName())); + return unlink($this->getPath($tenant->database()->getName())); } catch (Throwable) { return false; } @@ -30,7 +35,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function databaseExists(string $name): bool { - return file_exists(database_path($name)); + return file_exists($this->getPath($name)); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array @@ -44,4 +49,13 @@ class SQLiteDatabaseManager implements TenantDatabaseManager { // } + + public function getPath(string $name): string + { + if (static::$path) { + return static::$path . DIRECTORY_SEPARATOR . $name; + } + + return database_path($name); + } } diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 19018c9a..ebabdb36 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Database\DatabaseManager; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; @@ -16,6 +17,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Tests\Etc\ExampleSeeder; use Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\Tests\Etc\TestSeeder; use Stancl\Tenancy\Tests\Etc\User; beforeEach(function () { @@ -41,9 +43,9 @@ afterEach(function () { test('migrate command doesnt change the db connection', function () { expect(Schema::hasTable('users'))->toBeFalse(); - $old_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName(); + $old_connection_name = app(DatabaseManager::class)->connection()->getName(); Artisan::call('tenants:migrate'); - $new_connection_name = app(\Illuminate\Database\DatabaseManager::class)->connection()->getName(); + $new_connection_name = app(DatabaseManager::class)->connection()->getName(); expect(Schema::hasTable('users'))->toBeFalse(); expect($new_connection_name)->toEqual($old_connection_name); @@ -116,8 +118,22 @@ test('rollback command works', function () { expect(Schema::hasTable('users'))->toBeFalse(); }); -// Incomplete test -test('seed command works'); +test('seed command works', function (){ + $tenant = Tenant::create(); + Artisan::call('tenants:migrate'); + + $tenant->run(function (){ + expect(DB::table('users')->count())->toBe(0); + }); + + Artisan::call('tenants:seed', ['--class' => TestSeeder::class]); + + $tenant->run(function (){ + $user = DB::table('users'); + expect($user->count())->toBe(1) + ->and($user->first()->email)->toBe('seeded@user'); + }); +}); test('database connection is switched to default', function () { databaseConnectionSwitchedToDefault(); diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index 34b12383..e980e4eb 100644 --- a/tests/SingleDatabaseTenancyTest.php +++ b/tests/SingleDatabaseTenancyTest.php @@ -207,13 +207,13 @@ test('the model returned by the tenant helper has unique and exists validation r $uniqueFails = Validator::make($data, [ 'slug' => 'unique:posts', ])->fails(); - $existsFails = Validator::make($data, [ + $existsPass = Validator::make($data, [ 'slug' => 'exists:posts', - ])->fails(); + ])->passes(); // Assert that 'unique' and 'exists' aren't scoped by default - // pest()->assertFalse($uniqueFails); // todo get these two assertions to pass. for some reason, the validator is passing for both 'unique' and 'exists' - // pest()->assertTrue($existsFails); // todo get these two assertions to pass. for some reason, the validator is passing for both 'unique' and 'exists' + expect($uniqueFails)->toBeTrue(); // Expect unique rule failed to pass because slug 'foo' already exists + expect($existsPass)->toBeTrue(); // Expect exists rule pass because slug 'foo' exists $uniqueFails = Validator::make($data, [ 'slug' => tenant()->unique('posts'), diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index ab25310c..d6a5b369 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -225,7 +225,25 @@ test('tenant database can be created on a foreign server', function () { }); test('path used by sqlite manager can be customized', function () { - pest()->markTestIncomplete(); + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + // Set custom path for SQLite file + SQLiteDatabaseManager::$path = $customPath = database_path('custom_' . Str::random(8)); + + if (! is_dir($customPath)) { + // Create custom directory + mkdir($customPath); + } + + $name = Str::random(8). '.sqlite'; + Tenant::create([ + 'tenancy_db_name' => $name, + 'tenancy_db_connection' => 'sqlite', + ]); + + expect(file_exists( $customPath . '/' . $name))->toBeTrue(); }); // Datasets From b78320b882a1836a704eb5b83ebf6538ef026868 Mon Sep 17 00:00:00 2001 From: Riley19280 Date: Mon, 26 Sep 2022 08:13:58 -0400 Subject: [PATCH 3/4] [4.x] Add batch tenancy queue bootstrapper (#874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * exclude master from CI * Add batch tenancy queue bootstrapper * add test case * skip tests for old versions * variable docblocks * use Laravel's connection getter and setter * convert test to pest * bottom space * singleton regis in TestCase * Update src/Bootstrappers/BatchTenancyBootstrapper.php Co-authored-by: Samuel Štancl * convert batch class resolution to property level * enabled BatchTenancyBootstrapper by default * typehint DatabaseBatchRepository * refactore name * DI DB manager * typehint * Update config.php * use initialize() twice without end()ing tenancy to assert that previousConnection logic works correctly Co-authored-by: Samuel Štancl Co-authored-by: Abrar Ahmad Co-authored-by: Samuel Štancl --- assets/config.php | 1 + .../BatchTenancyBootstrapper.php | 41 +++++++++++++++++ tests/BatchTest.php | 44 +++++++++++++++++++ tests/TestCase.php | 25 +++++++---- 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 src/Bootstrappers/BatchTenancyBootstrapper.php create mode 100644 tests/BatchTest.php diff --git a/assets/config.php b/assets/config.php index 2a54e0b9..68a95440 100644 --- a/assets/config.php +++ b/assets/config.php @@ -32,6 +32,7 @@ return [ Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, + Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class, // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed ], diff --git a/src/Bootstrappers/BatchTenancyBootstrapper.php b/src/Bootstrappers/BatchTenancyBootstrapper.php new file mode 100644 index 00000000..ccd1c00a --- /dev/null +++ b/src/Bootstrappers/BatchTenancyBootstrapper.php @@ -0,0 +1,41 @@ +previousConnection = $this->batchRepository->getConnection(); + $this->batchRepository->setConnection($this->databaseManager->connection('tenant')); + } + + public function revert() + { + if ($this->previousConnection) { + // Replace batch repository connection with the previously replaced one + $this->batchRepository->setConnection($this->previousConnection); + $this->previousConnection = null; + } + } +} diff --git a/tests/BatchTest.php b/tests/BatchTest.php new file mode 100644 index 00000000..a168deb2 --- /dev/null +++ b/tests/BatchTest.php @@ -0,0 +1,44 @@ + [ + DatabaseTenancyBootstrapper::class, + BatchTenancyBootstrapper::class, + ], + ]); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); +}); + +test('batch repository is set to tenant connection and reverted', function () { + $tenant = Tenant::create(); + $tenant2 = Tenant::create(); + + expect(getBatchRepositoryConnectionName())->toBe('central'); + + tenancy()->initialize($tenant); + expect(getBatchRepositoryConnectionName())->toBe('tenant'); + + tenancy()->initialize($tenant2); + expect(getBatchRepositoryConnectionName())->toBe('tenant'); + + tenancy()->end(); + expect(getBatchRepositoryConnectionName())->toBe('central'); +})->skip(fn() => version_compare(app()->version(), '8.0', '<'), 'Job batches are only supported in Laravel 8+'); + +function getBatchRepositoryConnectionName() +{ + return app(BatchRepository::class)->getConnection()->getName(); +} diff --git a/tests/TestCase.php b/tests/TestCase.php index ed567497..f7f8b9ad 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,8 +4,15 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; +use Dotenv\Dotenv; +use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Redis; use PDO; +use Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper; +use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; +use Stancl\Tenancy\Facades\GlobalCache; +use Stancl\Tenancy\Facades\Tenancy; +use Stancl\Tenancy\TenancyServiceProvider; use Stancl\Tenancy\Tests\Etc\Tenant; abstract class TestCase extends \Orchestra\Testbench\TestCase @@ -42,13 +49,13 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase /** * Define environment setup. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return void */ protected function getEnvironmentSetUp($app) { if (file_exists(__DIR__ . '/../.env')) { - \Dotenv\Dotenv::createImmutable(__DIR__ . '/..')->load(); + Dotenv::createImmutable(__DIR__ . '/..')->load(); } $app['config']->set([ @@ -96,7 +103,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase '--realpath' => true, '--force' => true, ], - 'tenancy.bootstrappers.redis' => \Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // todo0 change this to []? two tests in TenantDatabaseManagerTest are failing with that + 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo0 change this to []? two tests in TenantDatabaseManagerTest are failing with that 'queue.connections.central' => [ 'driver' => 'sync', 'central' => true, @@ -105,28 +112,28 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase 'tenancy.tenant_model' => Tenant::class, // Use test tenant w/ DBs & domains ]); - $app->singleton(\Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class); + $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration } protected function getPackageProviders($app) { return [ - \Stancl\Tenancy\TenancyServiceProvider::class, + TenancyServiceProvider::class, ]; } protected function getPackageAliases($app) { return [ - 'Tenancy' => \Stancl\Tenancy\Facades\Tenancy::class, - 'GlobalCache' => \Stancl\Tenancy\Facades\GlobalCache::class, + 'Tenancy' => Tenancy::class, + 'GlobalCache' => GlobalCache::class, ]; } /** * Resolve application HTTP Kernel implementation. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return void */ protected function resolveApplicationHttpKernel($app) @@ -137,7 +144,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase /** * Resolve application Console Kernel implementation. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return void */ protected function resolveApplicationConsoleKernel($app) From 7bacc50b27993ea70812201ba51c0697177c4420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 28 Sep 2022 05:09:45 +0200 Subject: [PATCH 4/4] [4.x] Storage::url() support (modified #689) (#909) * This adds support for tenancy aware Storage::url() method * Trigger CI build * Fixed Link command for Laravel v6, added StorageLink Events, more StorageLink tests, added RemoveStorageSymlinks Job, added Storage Jobs to TenancyServiceProvider stub, renamed misleading config example. * Fix typo * Fix code style (php-cs-fixer) * Update config comments * Format code in Link command, make writing more concise * Change "symLinks" to "symlinks" * Refactor Link command * Fix test name typo * Test fetching files using the public URL * Extract Link command logic into actions * Fix code style (php-cs-fixer) * Check if closure is null in CreateStorageSymlinksAction * Stop using command terminology in CreateStorageSymlinksAction * Separate the Storage::url() test cases * Update url_override comments * Remove afterLink closures, add types, move actions, add usage explanation to the symlink trait * Fix code style (php-cs-fixer) * Update public storage URL test * Fix issue with using str() * Improve url_override comment, add todos * add todo comment * fix docblock style * Add link command tests back * Add types to $tenants in the action handle() methods * Fix typo, update variable name formatting * Add tests for the symlink actions * Change possibleTenantSymlinks not to prefix the paths twice while tenancy is initialized * Fix code style (php-cs-fixer) * Stop testing storage directory existence in symlink test * Don't specify full namespace for Tenant model annotation * Don't specify full namespace in ActionTest * Remove "change to DI" todo * Remove possibleTenantSymlinks return annotation * Remove symlink-related jobs, instantiate and use actions * Revert "Remove symlink-related jobs, instantiate and use actions" This reverts commit 547440c887dd86d75c7a5543fec576e233487eff. * Add a comment line about the possible tenant symlinks * Correct storagePath and publicPath variables * Revert "Correct storagePath and publicPath variables" This reverts commit e3aa8e208686e5fdf8e15a3bdb88d6f9853316fe. * add a todo Co-authored-by: Martin Vlcek Co-authored-by: lukinovec Co-authored-by: PHP CS Fixer --- assets/TenancyServiceProvider.stub.php | 8 ++ assets/config.php | 18 +++ src/Actions/CreateStorageSymlinksAction.php | 55 +++++++ src/Actions/RemoveStorageSymlinksAction.php | 40 ++++++ .../FilesystemTenancyBootstrapper.php | 30 +++- src/Commands/Link.php | 73 ++++++++++ src/Concerns/DealsWithTenantSymlinks.php | 44 ++++++ src/Events/CreatingStorageSymlink.php | 9 ++ src/Events/RemovingStorageSymlink.php | 9 ++ src/Events/StorageSymlinkCreated.php | 9 ++ src/Events/StorageSymlinkRemoved.php | 9 ++ src/Jobs/CreateStorageSymlinks.php | 40 ++++++ src/Jobs/RemoveStorageSymlinks.php | 40 ++++++ src/TenancyServiceProvider.php | 1 + tests/ActionTest.php | 69 +++++++++ tests/BootstrapperTest.php | 134 ++++++++++++++++-- tests/CommandsTest.php | 46 ++++++ tests/SingleDatabaseTenancyTest.php | 2 +- 18 files changed, 622 insertions(+), 14 deletions(-) create mode 100644 src/Actions/CreateStorageSymlinksAction.php create mode 100644 src/Actions/RemoveStorageSymlinksAction.php create mode 100644 src/Commands/Link.php create mode 100644 src/Concerns/DealsWithTenantSymlinks.php create mode 100644 src/Events/CreatingStorageSymlink.php create mode 100644 src/Events/RemovingStorageSymlink.php create mode 100644 src/Events/StorageSymlinkCreated.php create mode 100644 src/Events/StorageSymlinkRemoved.php create mode 100644 src/Jobs/CreateStorageSymlinks.php create mode 100644 src/Jobs/RemoveStorageSymlinks.php create mode 100644 tests/ActionTest.php diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index d8e76e6f..a3626225 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -28,6 +28,7 @@ class TenancyServiceProvider extends ServiceProvider Jobs\CreateDatabase::class, Jobs\MigrateDatabase::class, // Jobs\SeedDatabase::class, + Jobs\CreateStorageSymlinks::class, // Your own jobs to prepare the tenant. // Provision API keys, create S3 buckets, anything you want! @@ -52,6 +53,7 @@ class TenancyServiceProvider extends ServiceProvider Events\TenantDeleted::class => [ JobPipeline::make([ Jobs\DeleteDatabase::class, + Jobs\RemoveStorageSymlinks::class, ])->send(function (Events\TenantDeleted $event) { return $event->tenant; })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. @@ -95,6 +97,12 @@ class TenancyServiceProvider extends ServiceProvider Listeners\UpdateSyncedResource::class, ], + // Storage symlinks + Events\CreatingStorageSymlink::class => [], + Events\StorageSymlinkCreated::class => [], + Events\RemovingStorageSymlink::class => [], + Events\StorageSymlinkRemoved::class => [], + // Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops) Events\SyncedResourceChangedInForeignDatabase::class => [], ]; diff --git a/assets/config.php b/assets/config.php index 68a95440..7aff2b65 100644 --- a/assets/config.php +++ b/assets/config.php @@ -119,6 +119,24 @@ return [ 'public' => '%storage_path%/app/public/', ], + /* + * Tenant-aware Storage::disk()->url() can be enabled for specific local disks here + * by mapping the disk's name to a name with '%tenant_id%' (this will be used as the public name of the disk). + * Doing that will override the disk's default URL with a URL containing the current tenant's key. + * + * For example, Storage::disk('public')->url('') will return https://your-app.test/storage/ by default. + * After adding 'public' => 'public-%tenant_id%' to 'url_override', + * the returned URL will be https://your-app.test/public-1/ (%tenant_id% gets substitued by the current tenant's ID). + * + * Use `php artisan tenants:link` to create a symbolic link from the tenant's storage to its public directory. + */ + 'url_override' => [ + // Note that the local disk you add must exist in the tenancy.filesystem.root_override config + // todo@v4 Rename %tenant_id% to %tenant_key% + // todo@v4 Rename url_override to something that describes the config key better + 'public' => 'public-%tenant_id%', + ], + /** * Should storage_path() be suffixed. * diff --git a/src/Actions/CreateStorageSymlinksAction.php b/src/Actions/CreateStorageSymlinksAction.php new file mode 100644 index 00000000..779a42af --- /dev/null +++ b/src/Actions/CreateStorageSymlinksAction.php @@ -0,0 +1,55 @@ + $storagePath) { + static::createLink($publicPath, $storagePath, $tenant, $relativeLink, $force); + } + } + } + + protected static function createLink(string $publicPath, string $storagePath, Tenant $tenant, bool $relativeLink, bool $force): void + { + event(new CreatingStorageSymlink($tenant)); + + if (static::symlinkExists($publicPath)) { + // If $force isn't passed, don't overwrite the existing symlink + throw_if(! $force, new Exception("The [$publicPath] link already exists.")); + + app()->make('files')->delete($publicPath); + } + + // Make sure the storage path exists before we create a symlink + if (! is_dir($storagePath)) { + mkdir($storagePath, 0777, true); + } + + if ($relativeLink) { + app()->make('files')->relativeLink($storagePath, $publicPath); + } else { + app()->make('files')->link($storagePath, $publicPath); + } + + event((new StorageSymlinkCreated($tenant))); + } +} diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php new file mode 100644 index 00000000..bfbcfa0a --- /dev/null +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -0,0 +1,40 @@ + $storagePath) { + static::removeLink($publicPath, $tenant); + } + } + } + + protected static function removeLink(string $publicPath, Tenant $tenant): void + { + if (static::symlinkExists($publicPath)) { + event(new RemovingStorageSymlink($tenant)); + + app()->make('files')->delete($publicPath); + + event(new StorageSymlinkRemoved($tenant)); + } + } +} diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 6f720e7c..d90d36d0 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -57,9 +57,10 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { // todo@v4 \League\Flysystem\PathPrefixer is making this a lot more painful in flysystem v2 + $diskConfig = $this->app['config']["filesystems.disks.{$disk}"]; + $originalRoot = $diskConfig['root'] ?? null; - $originalRoot = $this->app['config']["filesystems.disks.{$disk}.root"]; - $this->originalPaths['disks'][$disk] = $originalRoot; + $this->originalPaths['disks']['path'][$disk] = $originalRoot; $finalPrefix = str_replace( ['%storage_path%', '%tenant%'], @@ -74,6 +75,19 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper } $this->app['config']["filesystems.disks.{$disk}.root"] = $finalPrefix; + + // Storage Url + if ($diskConfig['driver'] === 'local') { + $this->originalPaths['disks']['url'][$disk] = $diskConfig['url'] ?? null; + + if ($url = str_replace( + '%tenant_id%', + $tenant->getTenantKey(), + $this->app['config']["tenancy.filesystem.url_override.{$disk}"] ?? '' + )) { + $this->app['config']["filesystems.disks.{$disk}.url"] = url($url); + } + } } } @@ -88,8 +102,16 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper // Storage facade Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']); - foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) { - $this->app['config']["filesystems.disks.{$disk}.root"] = $this->originalPaths['disks'][$disk]; + foreach ($this->app['config']['tenancy.filesystem.disks'] as $diskName) { + $this->app['config']["filesystems.disks.$diskName.root"] = $this->originalPaths['disks']['path'][$diskName]; + $diskConfig = $this->app['config']['filesystems.disks.' . $diskName]; + + // Storage Url + $url = $this->originalPaths['disks.url.' . $diskName] ?? null; + + if ($diskConfig['driver'] === 'local' && ! is_null($url)) { + $$this->app['config']["filesystems.disks.$diskName.url"] = $url; + } } } } diff --git a/src/Commands/Link.php b/src/Commands/Link.php new file mode 100644 index 00000000..061f2d3d --- /dev/null +++ b/src/Commands/Link.php @@ -0,0 +1,73 @@ +getTenants(); + + try { + if ($this->option('remove')) { + $this->removeLinks($tenants); + } else { + $this->createLinks($tenants); + } + } catch (Exception $exception) { + $this->error($exception->getMessage()); + } + } + + protected function removeLinks(LazyCollection $tenants): void + { + RemoveStorageSymlinksAction::handle($tenants); + + $this->info('The links have been removed.'); + } + + protected function createLinks(LazyCollection $tenants): void + { + CreateStorageSymlinksAction::handle( + $tenants, + $this->option('relative') ?? false, + $this->option('force') ?? false, + ); + + $this->info('The links have been created.'); + } +} diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php new file mode 100644 index 00000000..a4c972bb --- /dev/null +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -0,0 +1,44 @@ + 'storage path']). + * + * Tenants can have a symlink for each disk registered in the tenancy.filesystem.url_override config. + * + * This is used for creating all possible tenant symlinks and removing all existing tenant symlinks. + */ + protected static function possibleTenantSymlinks(Tenant $tenant): Collection + { + $diskUrls = config('tenancy.filesystem.url_override'); + $disks = config('tenancy.filesystem.root_override'); + $suffixBase = config('tenancy.filesystem.suffix_base'); + $symlinks = collect(); + $tenantKey = $tenant->getTenantKey(); + + foreach ($diskUrls as $disk => $publicPath) { + $storagePath = str_replace('%storage_path%', $suffixBase . $tenantKey, $disks[$disk]); + $publicPath = str_replace('%tenant_id%', $tenantKey, $publicPath); + + tenancy()->central(function () use ($symlinks, $publicPath, $storagePath) { + $symlinks->push([public_path($publicPath) => storage_path($storagePath)]); + }); + } + + return $symlinks->mapWithKeys(fn ($item) => $item); + } + + /** Determine if the provided path is an existing symlink. */ + protected static function symlinkExists(string $link): bool + { + return file_exists($link) && is_link($link); + } +} diff --git a/src/Events/CreatingStorageSymlink.php b/src/Events/CreatingStorageSymlink.php new file mode 100644 index 00000000..13937174 --- /dev/null +++ b/src/Events/CreatingStorageSymlink.php @@ -0,0 +1,9 @@ +tenant = $tenant; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + CreateStorageSymlinksAction::handle($this->tenant); + } +} diff --git a/src/Jobs/RemoveStorageSymlinks.php b/src/Jobs/RemoveStorageSymlinks.php new file mode 100644 index 00000000..3022da79 --- /dev/null +++ b/src/Jobs/RemoveStorageSymlinks.php @@ -0,0 +1,40 @@ +tenant = $tenant; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + RemoveStorageSymlinksAction::handle($this->tenant); + } +} diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 3850720c..b8eee487 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -78,6 +78,7 @@ class TenancyServiceProvider extends ServiceProvider { $this->commands([ Commands\Run::class, + Commands\Link::class, Commands\Seed::class, Commands\Install::class, Commands\Migrate::class, diff --git a/tests/ActionTest.php b/tests/ActionTest.php new file mode 100644 index 00000000..cc0950ea --- /dev/null +++ b/tests/ActionTest.php @@ -0,0 +1,69 @@ + [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + $this->assertDirectoryDoesNotExist($publicPath = public_path("public-$tenantKey")); + + CreateStorageSymlinksAction::handle($tenant); + + $this->assertDirectoryExists($publicPath); + $this->assertEquals(storage_path("app/public/"), readlink($publicPath)); +}); + +test('remove storage symlinks action works', function() { + 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_id%' + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + tenancy()->initialize($tenant); + + CreateStorageSymlinksAction::handle($tenant); + + $this->assertDirectoryExists($publicPath = public_path("public-$tenantKey")); + + RemoveStorageSymlinksAction::handle($tenant); + + $this->assertDirectoryDoesNotExist($publicPath); +}); diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index ada6b964..a610fbd2 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -14,16 +14,19 @@ use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Jobs\CreateDatabase; use Stancl\Tenancy\Events\TenantCreated; +use Stancl\Tenancy\Events\TenantDeleted; +use Stancl\Tenancy\Events\DeletingTenant; use Illuminate\Filesystem\FilesystemAdapter; use Stancl\Tenancy\Events\TenancyInitialized; +use Stancl\Tenancy\Jobs\CreateStorageSymlinks; +use Stancl\Tenancy\Jobs\RemoveStorageSymlinks; use Stancl\Tenancy\Listeners\BootstrapTenancy; +use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; -use Stancl\Tenancy\Events\DeletingTenant; -use Stancl\Tenancy\Listeners\DeleteTenantStorage; beforeEach(function () { $this->mockConsoleOutput = false; @@ -192,8 +195,121 @@ test('tenant storage can get deleted after the tenant when DeletingTenant listen 'tenancy.bootstrappers' => [ FilesystemTenancyBootstrapper::class, ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' ]); + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + $tenant1StorageUrl = 'http://localhost/public-' . $tenant1->getKey().'/'; + $tenant2StorageUrl = 'http://localhost/public-' . $tenant2->getKey().'/'; + + tenancy()->initialize($tenant1); + + $this->assertEquals( + $tenant1StorageUrl, + Storage::disk('public')->url('') + ); + + Storage::disk('public')->put($tenant1FileName = 'tenant1.txt', 'text'); + + $this->assertEquals( + $tenant1StorageUrl . $tenant1FileName, + Storage::disk('public')->url($tenant1FileName) + ); + + tenancy()->initialize($tenant2); + + $this->assertEquals( + $tenant2StorageUrl, + Storage::disk('public')->url('') + ); + + Storage::disk('public')->put($tenant2FileName = 'tenant2.txt', 'text'); + + $this->assertEquals( + $tenant2StorageUrl . $tenant2FileName, + Storage::disk('public')->url($tenant2FileName) + ); +}); + +test('files can get fetched using the storage url', function() { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' + ]); + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + pest()->artisan('tenants:link'); + + // First tenant + tenancy()->initialize($tenant1); + Storage::disk('public')->put($tenantFileName = 'tenant1.txt', $tenantKey = $tenant1->getTenantKey()); + + $url = Storage::disk('public')->url($tenantFileName); + $tenantDiskName = Str::of(config('tenancy.filesystem.url_override.public'))->replace('%tenant_id%', $tenantKey); + $hostname = Str::of($url)->before($tenantDiskName); + $parsedUrl = Str::of($url)->after($hostname); + + expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey); + + // Second tenant + tenancy()->initialize($tenant2); + Storage::disk('public')->put($tenantFileName = 'tenant2.txt', $tenantKey = $tenant2->getTenantKey()); + + $url = Storage::disk('public')->url($tenantFileName); + $tenantDiskName = Str::of(config('tenancy.filesystem.url_override.public'))->replace('%tenant_id%', $tenantKey); + $hostname = Str::of($url)->before($tenantDiskName); + $parsedUrl = Str::of($url)->after($hostname); + + expect(file_get_contents(public_path($parsedUrl)))->toBe($tenantKey); +}); + +test('create and delete storage symlinks jobs work', function() { + Event::listen( + TenantCreated::class, + JobPipeline::make([CreateStorageSymlinks::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener() + ); + + Event::listen( + TenantDeleted::class, + JobPipeline::make([RemoveStorageSymlinks::class])->send(function (TenantDeleted $event) { + return $event->tenant; + })->toListener() + ); + + 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_id%' + ]); + + /** @var Tenant $tenant */ + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + $tenantKey = $tenant->getTenantKey(); + + $this->assertDirectoryExists(storage_path("app/public")); + $this->assertEquals(storage_path("app/public/"), readlink(public_path("public-$tenantKey"))); + + $tenant->delete(); + + $this->assertDirectoryDoesNotExist(public_path("public-$tenantKey")); +}); + +test('local storage public urls are generated correctly', function() { Event::listen(DeletingTenant::class, DeleteTenantStorage::class); tenancy()->initialize(Tenant::create()); @@ -220,14 +336,14 @@ function getDiskPrefix(string $disk): string return $adapter->getPathPrefix(); } - $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); - $prefixer->setAccessible(true); + $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); + $prefixer->setAccessible(true); - // reflection -> instance - $prefixer = $prefixer->getValue($adapter); + // reflection -> instance + $prefixer = $prefixer->getValue($adapter); - $prefix = (new ReflectionProperty($prefixer, 'prefix')); - $prefix->setAccessible(true); + $prefix = (new ReflectionProperty($prefixer, 'prefix')); + $prefix->setAccessible(true); - return $prefix->getValue($prefixer); + return $prefix->getValue($prefixer); } diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index ebabdb36..f7785bf2 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -29,6 +29,15 @@ beforeEach(function () { DatabaseTenancyBootstrapper::class, ]]); + config([ + 'tenancy.bootstrappers' => [ + DatabaseTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_base' => 'tenant-', + 'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', + 'tenancy.filesystem.url_override.public' => 'public-%tenant_id%' + ]); + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); @@ -196,6 +205,43 @@ test('run command with array of tenants works', function () { ->expectsOutput('Tenant: ' . $tenantId2); }); +test('link command works', function() { + $tenantId1 = Tenant::create()->getTenantKey(); + $tenantId2 = Tenant::create()->getTenantKey(); + pest()->artisan('tenants:link'); + + $this->assertDirectoryExists(storage_path("tenant-$tenantId1/app/public")); + $this->assertEquals(storage_path("tenant-$tenantId1/app/public/"), readlink(public_path("public-$tenantId1"))); + + $this->assertDirectoryExists(storage_path("tenant-$tenantId2/app/public")); + $this->assertEquals(storage_path("tenant-$tenantId2/app/public/"), readlink(public_path("public-$tenantId2"))); + + pest()->artisan('tenants:link', [ + '--remove' => true, + ]); + + $this->assertDirectoryDoesNotExist(public_path("public-$tenantId1")); + $this->assertDirectoryDoesNotExist(public_path("public-$tenantId2")); +}); + +test('link command works with a specified tenant', function() { + $tenantKey = Tenant::create()->getTenantKey(); + + pest()->artisan('tenants:link', [ + '--tenants' => [$tenantKey], + ]); + + $this->assertDirectoryExists(storage_path("tenant-$tenantKey/app/public")); + $this->assertEquals(storage_path("tenant-$tenantKey/app/public/"), readlink(public_path("public-$tenantKey"))); + + pest()->artisan('tenants:link', [ + '--remove' => true, + '--tenants' => [$tenantKey], + ]); + + $this->assertDirectoryDoesNotExist(public_path("public-$tenantKey")); +}); + test('run command works when sub command asks questions and accepts arguments', function () { $tenant = Tenant::create(); $id = $tenant->getTenantKey(); diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index e980e4eb..8914a6d7 100644 --- a/tests/SingleDatabaseTenancyTest.php +++ b/tests/SingleDatabaseTenancyTest.php @@ -61,7 +61,7 @@ test('secondary models are not scoped to the current tenant when accessed direct expect(Comment::count())->toBe(2); }); -test('secondary models a r e scoped to the current tenant when accessed directly and parent relationship traitis used', function () { +test('secondary models ARE scoped to the current tenant when accessed directly and parent relationship trait is used', function () { $acme = Tenant::create([ 'id' => 'acme', ]);