diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9da7221..db2b0ffc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ on: jobs: tests: runs-on: ubuntu-latest - container: abrardev/tenancy:latest + container: archtechx/tenancy:latest strategy: matrix: diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 589838bc..7e649ea5 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -10,6 +10,7 @@ $rules = [ 'operators' => [ '=>' => null, '|' => 'no_space', + '&' => 'no_space', ] ], 'blank_line_after_namespace' => true, diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 865bb93d..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! @@ -46,10 +47,13 @@ class TenancyServiceProvider extends ServiceProvider ])->send(function (Events\DeletingTenant $event) { return $event->tenant; })->shouldBeQueued(false), + + // Listeners\DeleteTenantStorage::class, ], 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. @@ -93,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 2a54e0b9..7aff2b65 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 ], @@ -118,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/composer.json b/composer.json index ff5befd9..0dc9df09 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "php": "^8.1", "ext-json": "*", "illuminate/support": "^9.0", - "facade/ignition-contracts": "^1.0", + "spatie/ignition": "^1.4", "ramsey/uuid": "^4.0", "stancl/jobpipeline": "^1.0", "stancl/virtualcolumn": "^1.0" @@ -63,6 +63,7 @@ "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "coverage": "open coverage/phpunit/html/index.html", "phpstan": "vendor/bin/phpstan", + "cs": "php-cs-fixer fix --config=.php-cs-fixer.php", "test": "PHP_VERSION=8.1 ./test --no-coverage", "test-full": "PHP_VERSION=8.1 ./test" }, diff --git a/docker-compose.yml b/docker-compose.yml index 7b635637..116b48f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: condition: service_healthy redis: condition: service_healthy + # mssql: + # condition: service_healthy volumes: - .:/var/www/html:delegated environment: @@ -74,4 +76,8 @@ services: environment: - ACCEPT_EULA=Y - SA_PASSWORD=P@ssword # todo reuse values from env above - # todo missing health check + healthcheck: + test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P P@ssword -Q "SELECT 1" -b -o /dev/null + interval: 10s + timeout: 10s + retries: 10 diff --git a/phpstan.neon b/phpstan.neon index 9ff082dd..3e9ba51d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,16 +10,39 @@ parameters: universalObjectCratesClasses: - Illuminate\Routing\Route + - Illuminate\Database\Eloquent\Model ignoreErrors: - - - message: '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#' - paths: - - src/TenancyServiceProvider.php + - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Foundation\\Application#' + - '#Cannot access offset (.*?) on Illuminate\\Contracts\\Config\\Repository#' - message: '#invalid type Laravel\\Telescope\\IncomingEntry#' paths: - src/Features/TelescopeTags.php + - + message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getRelationshipToPrimaryModel\(\)#' + paths: + - src/Database/ParentModelScope.php + - + message: '#Parameter \#1 \$key of method Illuminate\\Contracts\\Cache\\Repository::put\(\) expects string#' + paths: + - src/helpers.php + - + message: '#PHPDoc tag \@param has invalid value \(dynamic#' + paths: + - src/helpers.php + - + message: '#Illuminate\\Routing\\UrlGenerator#' + paths: + - src/Bootstrappers/FilesystemTenancyBootstrapper.php + - + message: '#select\(\) expects string, Illuminate\\Database\\Query\\Expression given#' + paths: + - src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php + - + message: '#Trying to invoke Closure\|null but it might not be a callable#' + paths: + - src/Database/DatabaseConfig.php checkMissingIterableValueType: false treatPhpDocTypesAsCertain: false diff --git a/src/Actions/CreateStorageSymlinksAction.php b/src/Actions/CreateStorageSymlinksAction.php new file mode 100644 index 00000000..eac5d933 --- /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..a3660e7a --- /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/BatchTenancyBootstrapper.php b/src/Bootstrappers/BatchTenancyBootstrapper.php new file mode 100644 index 00000000..589bdac0 --- /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(): void + { + if ($this->previousConnection) { + // Replace batch repository connection with the previously replaced one + $this->batchRepository->setConnection($this->previousConnection); + $this->previousConnection = null; + } + } +} diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index bef156d2..29547fae 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -13,18 +13,14 @@ use Stancl\Tenancy\Contracts\Tenant; class CacheTenancyBootstrapper implements TenancyBootstrapper { - /** @var CacheManager */ - protected $originalCache; + protected ?CacheManager $originalCache = null; - /** @var Application */ - protected $app; - - public function __construct(Application $app) - { - $this->app = $app; + public function __construct( + protected Application $app + ) { } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { $this->resetFacadeCache(); @@ -34,7 +30,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper }); } - public function revert() + public function revert(): void { $this->resetFacadeCache(); @@ -50,7 +46,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper * facade has been made prior to bootstrapping tenancy. The * facade has its own cache, separate from the container. */ - public function resetFacadeCache() + public function resetFacadeCache(): void { Cache::clearResolvedInstances(); } diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index dd94bfd4..c6dba079 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -8,7 +8,7 @@ use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; -use Stancl\Tenancy\Exceptions\TenantDatabaseDoesNotExistException; +use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; class DatabaseTenancyBootstrapper implements TenancyBootstrapper { @@ -20,7 +20,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper $this->database = $database; } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { /** @var TenantWithDatabase $tenant */ @@ -35,7 +35,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper $this->database->connectToTenant($tenant); } - public function revert() + public function revert(): void { $this->database->reconnectToCentral(); } diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 6f720e7c..cb77a752 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; @@ -27,13 +28,14 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper ]; $this->app['url']->macro('setAssetRoot', function ($root) { + /** @var UrlGenerator $this */ $this->assetRoot = $root; return $this; }); } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { $suffix = $this->app['config']['tenancy.filesystem.suffix_base'] . $tenant->getTenantKey(); @@ -57,9 +59,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,10 +77,23 @@ 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); + } + } } } - public function revert() + public function revert(): void { // storage_path() $this->app->useStoragePath($this->originalPaths['storage']); @@ -88,8 +104,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/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php new file mode 100644 index 00000000..da5a921a --- /dev/null +++ b/src/Bootstrappers/Integrations/ScoutTenancyBootstrapper.php @@ -0,0 +1,38 @@ +originalScoutPrefix !== null) { + $this->originalScoutPrefix = $this->config->get('scout.prefix'); + } + + $this->config->set('scout.prefix', $this->getTenantPrefix($tenant)); + } + + public function revert(): void + { + $this->config->set('scout.prefix', $this->originalScoutPrefix); + } + + protected function getTenantPrefix(Tenant $tenant): string + { + return (string) $tenant->getTenantKey(); + } +} diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php index 2f859ecd..5b6ef4d8 100644 --- a/src/Bootstrappers/QueueTenancyBootstrapper.php +++ b/src/Bootstrappers/QueueTenancyBootstrapper.php @@ -39,7 +39,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper * However, we're registering a hook to initialize tenancy. Therefore, * we need to register the hook at service provider execution time. */ - public static function __constructStatic(Application $app) + public static function __constructStatic(Application $app): void { static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests()); } @@ -52,7 +52,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper $this->setUpPayloadGenerator(); } - protected static function setUpJobListener($dispatcher, $runningTests) + protected static function setUpJobListener(Dispatcher $dispatcher, bool $runningTests): void { $previousTenant = null; @@ -62,14 +62,11 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null); }); - if (version_compare(app()->version(), '8.64', '>=')) { - // JobRetryRequested only exists since Laravel 8.64 - $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) { - $previousTenant = tenant(); + $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) { + $previousTenant = tenant(); - static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); - }); - } + static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); + }); // If we're running tests, we make sure to clean up after any artisan('queue:work') calls $revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) { @@ -82,7 +79,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails } - protected static function initializeTenancyForQueue($tenantId) + protected static function initializeTenancyForQueue(string|int $tenantId): void { if (! $tenantId) { // The job is not tenant-aware @@ -100,7 +97,9 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper tenancy()->end(); } - tenancy()->initialize(tenancy()->find($tenantId)); + /** @var Tenant $tenant */ + $tenant = tenancy()->find($tenantId); + tenancy()->initialize($tenant); return; } @@ -115,10 +114,13 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper // Tenancy was either not initialized, or initialized for a different tenant. // Therefore, we initialize it for the correct tenant. - tenancy()->initialize(tenancy()->find($tenantId)); + + /** @var Tenant $tenant */ + $tenant = tenancy()->find($tenantId); + tenancy()->initialize($tenant); } - protected static function revertToPreviousState($event, &$previousTenant) + protected static function revertToPreviousState(JobProcessed|JobFailed $event, ?Tenant &$previousTenant): void { $tenantId = $event->job->payload()['tenant_id'] ?? null; @@ -138,7 +140,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper } } - protected function setUpPayloadGenerator() + protected function setUpPayloadGenerator(): void { $bootstrapper = &$this; @@ -149,17 +151,17 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper } } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { // } - public function revert() + public function revert(): void { // } - public function getPayload(string $connection) + public function getPayload(string $connection): array { if (! tenancy()->initialized) { return []; diff --git a/src/Bootstrappers/RedisTenancyBootstrapper.php b/src/Bootstrappers/RedisTenancyBootstrapper.php index 7536984e..975a37d5 100644 --- a/src/Bootstrappers/RedisTenancyBootstrapper.php +++ b/src/Bootstrappers/RedisTenancyBootstrapper.php @@ -22,18 +22,21 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper $this->config = $config; } - public function bootstrap(Tenant $tenant) + public function bootstrap(Tenant $tenant): void { foreach ($this->prefixedConnections() as $connection) { $prefix = $this->config['tenancy.redis.prefix_base'] . $tenant->getTenantKey(); $client = Redis::connection($connection)->client(); - $this->originalPrefixes[$connection] = $client->getOption($client::OPT_PREFIX); + /** @var string $originalPrefix */ + $originalPrefix = $client->getOption($client::OPT_PREFIX); + + $this->originalPrefixes[$connection] = $originalPrefix; $client->setOption($client::OPT_PREFIX, $prefix); } } - public function revert() + public function revert(): void { foreach ($this->prefixedConnections() as $connection) { $client = Redis::connection($connection)->client(); @@ -44,7 +47,8 @@ class RedisTenancyBootstrapper implements TenancyBootstrapper $this->originalPrefixes = []; } - protected function prefixedConnections() + /** @return string[] */ + protected function prefixedConnections(): array { return $this->config['tenancy.redis.prefixed_connections']; } diff --git a/src/Commands/Down.php b/src/Commands/Down.php new file mode 100644 index 00000000..6b390957 --- /dev/null +++ b/src/Commands/Down.php @@ -0,0 +1,54 @@ +getDownDatabasePayload(); + + // This runs for all tenants if no --tenants are specified + tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($payload) { + $this->line("Tenant: {$tenant['id']}"); + $tenant->putDownForMaintenance($payload); + }); + + $this->comment('Tenants are now in maintenance mode.'); + + return 0; + } + + /** Get the payload to be placed in the "down" file. */ + protected function getDownDatabasePayload(): array + { + return [ + 'except' => $this->excludedPaths(), + 'redirect' => $this->redirectPath(), + 'retry' => $this->getRetryTime(), + 'refresh' => $this->option('refresh'), + 'secret' => $this->option('secret'), + 'status' => (int) ($this->option('status') ?? 503), + ]; + } +} diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 41492b26..12a2c2c9 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -8,24 +8,11 @@ use Illuminate\Console\Command; class Install extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'tenancy:install'; - /** - * The console command description. - * - * @var string - */ protected $description = 'Install stancl/tenancy.'; - /** - * Execute the console command. - */ - public function handle() + public function handle(): void { $this->comment('Installing stancl/tenancy...'); $this->callSilent('vendor:publish', [ diff --git a/src/Commands/Link.php b/src/Commands/Link.php new file mode 100644 index 00000000..53f3cf6f --- /dev/null +++ b/src/Commands/Link.php @@ -0,0 +1,58 @@ +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, + (bool) ($this->option('relative') ?? false), + (bool) ($this->option('force') ?? false), + ); + + $this->info('The links have been created.'); + } +} diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index 52ecd47f..739b56de 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Console\Migrations\MigrateCommand; use Illuminate\Database\Migrations\Migrator; -use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Events\DatabaseMigrated; @@ -15,7 +14,7 @@ use Stancl\Tenancy\Events\MigratingDatabase; class Migrate extends MigrateCommand { - use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; + use HasATenantsOption, ExtendsLaravelCommand; protected $description = 'Run migrations for tenant(s)'; @@ -31,10 +30,7 @@ class Migrate extends MigrateCommand $this->specifyParameters(); } - /** - * Execute the console command. - */ - public function handle() + public function handle(): int { foreach (config('tenancy.migration_parameters') as $parameter => $value) { if (! $this->input->hasParameterOption($parameter)) { @@ -43,10 +39,10 @@ class Migrate extends MigrateCommand } if (! $this->confirmToProceed()) { - return; + return 1; } - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + tenancy()->runForMultiple($this->getTenants(), function ($tenant) { $this->line("Tenant: {$tenant->getTenantKey()}"); event(new MigratingDatabase($tenant)); @@ -56,5 +52,7 @@ class Migrate extends MigrateCommand event(new DatabaseMigrated($tenant)); }); + + return 0; } } diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 63860153..56a6047f 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -5,19 +5,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; -use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\HasATenantsOption; use Symfony\Component\Console\Input\InputOption; final class MigrateFresh extends Command { - use HasATenantsOption, DealsWithMigrations; + use HasATenantsOption; - /** - * The console command description. - * - * @var string - */ protected $description = 'Drop all tables and re-run all migrations for tenant(s)'; public function __construct() @@ -29,12 +23,9 @@ final class MigrateFresh extends Command $this->setName('tenants:migrate-fresh'); } - /** - * Execute the console command. - */ - public function handle() + public function handle(): void { - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + tenancy()->runForMultiple($this->getTenants(), function ($tenant) { $this->info('Dropping tables.'); $this->call('db:wipe', array_filter([ '--database' => 'tenant', diff --git a/src/Commands/Rollback.php b/src/Commands/Rollback.php index 1c434189..d3989cc0 100644 --- a/src/Commands/Rollback.php +++ b/src/Commands/Rollback.php @@ -6,7 +6,6 @@ namespace Stancl\Tenancy\Commands; use Illuminate\Database\Console\Migrations\RollbackCommand; use Illuminate\Database\Migrations\Migrator; -use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Events\DatabaseRolledBack; @@ -14,25 +13,10 @@ use Stancl\Tenancy\Events\RollingBackDatabase; class Rollback extends RollbackCommand { - use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; + use HasATenantsOption, ExtendsLaravelCommand; - protected static function getTenantCommandName(): string - { - return 'tenants:rollback'; - } - - /** - * The console command description. - * - * @var string - */ protected $description = 'Rollback migrations for tenant(s).'; - /** - * Create a new command instance. - * - * @return void - */ public function __construct(Migrator $migrator) { parent::__construct($migrator); @@ -40,10 +24,7 @@ class Rollback extends RollbackCommand $this->specifyTenantSignature(); } - /** - * Execute the console command. - */ - public function handle() + public function handle(): int { foreach (config('tenancy.migration_parameters') as $parameter => $value) { if (! $this->input->hasParameterOption($parameter)) { @@ -52,10 +33,10 @@ class Rollback extends RollbackCommand } if (! $this->confirmToProceed()) { - return; + return 1; } - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + tenancy()->runForMultiple($this->getTenants(), function ($tenant) { $this->line("Tenant: {$tenant->getTenantKey()}"); event(new RollingBackDatabase($tenant)); @@ -65,5 +46,12 @@ class Rollback extends RollbackCommand event(new DatabaseRolledBack($tenant)); }); + + return 0; + } + + protected static function getTenantCommandName(): string + { + return 'tenants:rollback'; } } diff --git a/src/Commands/Run.php b/src/Commands/Run.php index 075f9116..9bb04716 100644 --- a/src/Commands/Run.php +++ b/src/Commands/Run.php @@ -5,36 +5,44 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Artisan; +use Illuminate\Contracts\Console\Kernel; +use Stancl\Tenancy\Concerns\HasATenantsOption; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Output\ConsoleOutput; class Run extends Command { - /** - * The console command description. - * - * @var string - */ + use HasATenantsOption; + protected $description = 'Run a command for tenant(s)'; - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'tenants:run {commandname : The artisan command.} {--tenants=* : The tenant(s) to run the command for. Default: all}'; - /** - * Execute the console command. - */ - public function handle() + public function handle(): void { - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + $argvInput = $this->argvInput(); + + tenancy()->runForMultiple($this->getTenants(), function ($tenant) use ($argvInput) { $this->line("Tenant: {$tenant->getTenantKey()}"); - Artisan::call($this->argument('commandname')); - $this->comment('Command output:'); - $this->info(Artisan::output()); + $this->getLaravel() + ->make(Kernel::class) + ->handle($argvInput, new ConsoleOutput); }); } + + protected function argvInput(): ArgvInput + { + /** @var string $commandname */ + $commandname = $this->argument('commandname'); + + // Convert string command to array + $subcommand = explode(' ', $commandname); + + // Add "artisan" as first parameter because ArgvInput expects "artisan" as first parameter and later removes it + array_unshift($subcommand, 'artisan'); + + return new ArgvInput($subcommand); + } } diff --git a/src/Commands/Seed.php b/src/Commands/Seed.php index 8c525208..496c04e6 100644 --- a/src/Commands/Seed.php +++ b/src/Commands/Seed.php @@ -14,29 +14,16 @@ class Seed extends SeedCommand { use HasATenantsOption; - /** - * The console command description. - * - * @var string - */ protected $description = 'Seed tenant database(s).'; protected $name = 'tenants:seed'; - /** - * Create a new command instance. - * - * @return void - */ public function __construct(ConnectionResolverInterface $resolver) { parent::__construct($resolver); } - /** - * Execute the console command. - */ - public function handle() + public function handle(): int { foreach (config('tenancy.seeder_parameters') as $parameter => $value) { if (! $this->input->hasParameterOption($parameter)) { @@ -45,10 +32,10 @@ class Seed extends SeedCommand } if (! $this->confirmToProceed()) { - return; + return 1; } - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + tenancy()->runForMultiple($this->getTenants(), function ($tenant) { $this->line("Tenant: {$tenant->getTenantKey()}"); event(new SeedingDatabase($tenant)); @@ -58,5 +45,7 @@ class Seed extends SeedCommand event(new DatabaseSeeded($tenant)); }); + + return 0; } } diff --git a/src/Commands/TenantList.php b/src/Commands/TenantList.php index 13775676..9fd3f8bd 100644 --- a/src/Commands/TenantList.php +++ b/src/Commands/TenantList.php @@ -5,39 +5,28 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Contracts\Tenant; class TenantList extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ protected $signature = 'tenants:list'; - /** - * The console command description. - * - * @var string - */ protected $description = 'List tenants.'; - /** - * Execute the console command. - */ - public function handle() + public function handle(): void { $this->info('Listing all tenants.'); - tenancy() - ->query() - ->cursor() - ->each(function (Tenant $tenant) { - if ($tenant->domains) { - $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? [])); - } else { - $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}"); - } - }); + + $tenants = tenancy()->query()->cursor(); + + foreach ($tenants as $tenant) { + /** @var Model&Tenant $tenant */ + if ($tenant->domains) { + $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()} @ " . implode('; ', $tenant->domains->pluck('domain')->toArray() ?? [])); + } else { + $this->line("[Tenant] {$tenant->getTenantKeyName()}: {$tenant->getTenantKey()}"); + } + } } } diff --git a/src/Commands/Up.php b/src/Commands/Up.php new file mode 100644 index 00000000..a3f690c2 --- /dev/null +++ b/src/Commands/Up.php @@ -0,0 +1,27 @@ +runForMultiple($this->getTenants(), function ($tenant) { + $this->line("Tenant: {$tenant['id']}"); + $tenant->bringUpFromMaintenance(); + }); + + $this->comment('Tenants are now out of maintenance mode.'); + } +} diff --git a/src/Concerns/DealsWithMigrations.php b/src/Concerns/DealsWithMigrations.php index 4bb6b44c..3129c68d 100644 --- a/src/Concerns/DealsWithMigrations.php +++ b/src/Concerns/DealsWithMigrations.php @@ -6,12 +6,12 @@ namespace Stancl\Tenancy\Concerns; trait DealsWithMigrations { - protected function getMigrationPaths() + protected function getMigrationPaths(): array { if ($this->input->hasOption('path') && $this->input->getOption('path')) { return parent::getMigrationPaths(); } - return database_path('migrations/tenant'); + return [database_path('migrations/tenant')]; } } diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php new file mode 100644 index 00000000..5f3baf5b --- /dev/null +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -0,0 +1,48 @@ + '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. + * + * @return Collection + */ + 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'); + $tenantKey = $tenant->getTenantKey(); + + /** @var Collection> $symlinks */ + $symlinks = collect([]); + + foreach ($diskUrls as $disk => $publicPath) { + $storagePath = str_replace('%storage_path%', $suffixBase . $tenantKey, $disks[$disk]); + $publicPath = str_replace('%tenant_id%', (string) $tenantKey, $publicPath); + + tenancy()->central(function () use ($symlinks, $publicPath, $storagePath) { + $symlinks->push([public_path($publicPath) => storage_path($storagePath)]); + }); + } + + return $symlinks->mapWithKeys(fn ($item) => $item); // [[a => b], [c => d]] -> [a => b, c => d] + } + + /** 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/Contracts/Domain.php b/src/Contracts/Domain.php index 2c02089e..a9a19a50 100644 --- a/src/Contracts/Domain.php +++ b/src/Contracts/Domain.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + /** * @property-read Tenant $tenant * @@ -15,5 +17,5 @@ namespace Stancl\Tenancy\Contracts; */ interface Domain { - public function tenant(); + public function tenant(): BelongsTo; } diff --git a/src/Contracts/TenancyBootstrapper.php b/src/Contracts/TenancyBootstrapper.php index 8b43755f..6da5c537 100644 --- a/src/Contracts/TenancyBootstrapper.php +++ b/src/Contracts/TenancyBootstrapper.php @@ -9,7 +9,7 @@ namespace Stancl\Tenancy\Contracts; */ interface TenancyBootstrapper { - public function bootstrap(Tenant $tenant); + public function bootstrap(Tenant $tenant): void; - public function revert(); + public function revert(): void; } diff --git a/src/Contracts/TenantCannotBeCreatedException.php b/src/Contracts/TenantCannotBeCreatedException.php index 19eac15b..53d8589f 100644 --- a/src/Contracts/TenantCannotBeCreatedException.php +++ b/src/Contracts/TenantCannotBeCreatedException.php @@ -8,6 +8,7 @@ abstract class TenantCannotBeCreatedException extends \Exception { abstract public function reason(): string; + /** @var string */ protected $message; public function __construct() diff --git a/src/Contracts/TenantCouldNotBeIdentifiedException.php b/src/Contracts/TenantCouldNotBeIdentifiedException.php index 0066291f..011d974b 100644 --- a/src/Contracts/TenantCouldNotBeIdentifiedException.php +++ b/src/Contracts/TenantCouldNotBeIdentifiedException.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; use Exception; -use Facade\IgnitionContracts\BaseSolution; -use Facade\IgnitionContracts\ProvidesSolution; -use Facade\IgnitionContracts\Solution; +use Spatie\Ignition\Contracts\BaseSolution; +use Spatie\Ignition\Contracts\ProvidesSolution; abstract class TenantCouldNotBeIdentifiedException extends Exception implements ProvidesSolution { @@ -42,7 +41,7 @@ abstract class TenantCouldNotBeIdentifiedException extends Exception implements } /** Get the Ignition description. */ - public function getSolution(): Solution + public function getSolution(): BaseSolution { return BaseSolution::create($this->solutionTitle) ->setSolutionDescription($this->solutionDescription) diff --git a/src/Contracts/UniqueIdentifierGenerator.php b/src/Contracts/UniqueIdentifierGenerator.php index b21d6028..14d91ae0 100644 --- a/src/Contracts/UniqueIdentifierGenerator.php +++ b/src/Contracts/UniqueIdentifierGenerator.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; +use Illuminate\Database\Eloquent\Model; + interface UniqueIdentifierGenerator { /** - * Generate a unique identifier. + * Generate a unique identifier for a model. */ - public static function generate($resource): string; + public static function generate(Model $model): string; } diff --git a/src/Controllers/TenantAssetsController.php b/src/Controllers/TenantAssetsController.php index 03d600d0..615f8054 100644 --- a/src/Controllers/TenantAssetsController.php +++ b/src/Controllers/TenantAssetsController.php @@ -6,18 +6,22 @@ namespace Stancl\Tenancy\Controllers; use Closure; use Illuminate\Routing\Controller; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Throwable; -class TenantAssetsController extends Controller +class TenantAssetsController extends Controller // todo rename this to TenantAssetController & update references in docs { - public static string|array|Closure $tenancyMiddleware = Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class; + public static string|array|Closure $tenancyMiddleware = \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class; public function __construct() { $this->middleware(static::$tenancyMiddleware); } - public function asset(string $path = null) + /** + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + */ + public function asset(string $path = null): BinaryFileResponse { abort_if($path === null, 404); diff --git a/src/Database/Concerns/HasDomains.php b/src/Database/Concerns/HasDomains.php index 594e9a81..bd512e23 100644 --- a/src/Database/Concerns/HasDomains.php +++ b/src/Database/Concerns/HasDomains.php @@ -10,6 +10,8 @@ use Stancl\Tenancy\Contracts\Domain; /** * @property-read Domain[]|\Illuminate\Database\Eloquent\Collection $domains + * @mixin \Illuminate\Database\Eloquent\Model + * @mixin \Stancl\Tenancy\Contracts\Tenant */ trait HasDomains { diff --git a/src/Database/Concerns/InitializationHelpers.php b/src/Database/Concerns/InitializationHelpers.php new file mode 100644 index 00000000..46802df1 --- /dev/null +++ b/src/Database/Concerns/InitializationHelpers.php @@ -0,0 +1,21 @@ +initialize($this); + } + + public function leave(): void + { + tenancy()->end(); + } +} diff --git a/src/Database/Concerns/InvalidatesResolverCache.php b/src/Database/Concerns/InvalidatesResolverCache.php index 3b864789..82a85997 100644 --- a/src/Database/Concerns/InvalidatesResolverCache.php +++ b/src/Database/Concerns/InvalidatesResolverCache.php @@ -10,6 +10,7 @@ use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; trait InvalidatesResolverCache { + /** @var array> */ public static $resolvers = [ Resolvers\DomainTenantResolver::class, Resolvers\PathTenantResolver::class, diff --git a/src/Database/Concerns/InvalidatesTenantsResolverCache.php b/src/Database/Concerns/InvalidatesTenantsResolverCache.php index 8d7c2845..aa7fac4b 100644 --- a/src/Database/Concerns/InvalidatesTenantsResolverCache.php +++ b/src/Database/Concerns/InvalidatesTenantsResolverCache.php @@ -13,7 +13,8 @@ use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; */ trait InvalidatesTenantsResolverCache { - public static $resolvers = [ + /** @var array> */ + public static array $resolvers = [ // todo single source of truth for this here and in InvalidatesResolverCache Resolvers\DomainTenantResolver::class, Resolvers\PathTenantResolver::class, Resolvers\RequestDataTenantResolver::class, diff --git a/src/Database/Concerns/MaintenanceMode.php b/src/Database/Concerns/MaintenanceMode.php index 55e0e46d..cc4490f6 100644 --- a/src/Database/Concerns/MaintenanceMode.php +++ b/src/Database/Concerns/MaintenanceMode.php @@ -4,17 +4,27 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; -use Carbon\Carbon; - +/** + * @mixin \Illuminate\Database\Eloquent\Model + */ trait MaintenanceMode { - public function putDownForMaintenance($data = []) + public function putDownForMaintenance($data = []): void { - $this->update(['maintenance_mode' => [ - 'time' => $data['time'] ?? Carbon::now()->getTimestamp(), - 'message' => $data['message'] ?? null, - 'retry' => $data['retry'] ?? null, - 'allowed' => $data['allowed'] ?? [], - ]]); + $this->update([ + 'maintenance_mode' => [ + 'except' => $data['except'] ?? null, + 'redirect' => $data['redirect'] ?? null, + 'retry' => $data['retry'] ?? null, + 'refresh' => $data['refresh'] ?? null, + 'secret' => $data['secret'] ?? null, + 'status' => $data['status'] ?? 503, + ], + ]); + } + + public function bringUpFromMaintenance(): void + { + $this->update(['maintenance_mode' => null]); } } diff --git a/src/Database/Contracts/TenantWithDatabase.php b/src/Database/Contracts/TenantWithDatabase.php index c9247d94..76a73340 100644 --- a/src/Database/Contracts/TenantWithDatabase.php +++ b/src/Database/Contracts/TenantWithDatabase.php @@ -9,5 +9,15 @@ use Stancl\Tenancy\Database\DatabaseConfig; interface TenantWithDatabase extends Tenant { + /** Get the tenant's database config. */ public function database(): DatabaseConfig; + + /** Get the internal prefix. */ + public static function internalPrefix(): string; + + /** Get an internal key. */ + public function getInternal(string $key): mixed; + + /** Set internal key. */ + public function setInternal(string $key, mixed $value): static; } diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index a4c79582..6c68f379 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -26,20 +26,20 @@ class DatabaseConfig public static function __constructStatic(): void { - static::$usernameGenerator = static::$usernameGenerator ?? function (Tenant $tenant) { + static::$usernameGenerator = static::$usernameGenerator ?? function (Model&Tenant $tenant) { return Str::random(16); }; - static::$passwordGenerator = static::$passwordGenerator ?? function (Tenant $tenant) { + static::$passwordGenerator = static::$passwordGenerator ?? function (Model&Tenant $tenant) { return Hash::make(Str::random(32)); }; - static::$databaseNameGenerator = static::$databaseNameGenerator ?? function (Tenant $tenant) { + static::$databaseNameGenerator = static::$databaseNameGenerator ?? function (Model&Tenant $tenant) { return config('tenancy.database.prefix') . $tenant->getTenantKey() . config('tenancy.database.suffix'); }; } - public function __construct(Tenant $tenant) + public function __construct(Model&Tenant $tenant) { static::__constructStatic(); @@ -61,7 +61,7 @@ class DatabaseConfig static::$passwordGenerator = $passwordGenerator; } - public function getName(): ?string + public function getName(): string { return $this->tenant->getInternal('db_name') ?? (static::$databaseNameGenerator)($this->tenant); } @@ -81,7 +81,7 @@ class DatabaseConfig */ public function makeCredentials(): void { - $this->tenant->setInternal('db_name', $this->getName() ?? (static::$databaseNameGenerator)($this->tenant)); + $this->tenant->setInternal('db_name', $this->getName()); if ($this->manager() instanceof Contracts\ManagesDatabaseUsers) { $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant)); diff --git a/src/Database/DatabaseManager.php b/src/Database/DatabaseManager.php index a92ccb7b..ce9219d5 100644 --- a/src/Database/DatabaseManager.php +++ b/src/Database/DatabaseManager.php @@ -15,25 +15,14 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; */ class DatabaseManager { - /** @var Application */ - protected $app; - - /** @var BaseDatabaseManager */ - protected $database; - - /** @var Repository */ - protected $config; - - public function __construct(Application $app, BaseDatabaseManager $database, Repository $config) - { - $this->app = $app; - $this->database = $database; - $this->config = $config; + public function __construct( + protected Application $app, + protected BaseDatabaseManager $database, + protected Repository $config, + ) { } - /** - * Connect to a tenant's database. - */ + /** Connect to a tenant's database. */ public function connectToTenant(TenantWithDatabase $tenant): void { $this->purgeTenantConnection(); @@ -41,35 +30,27 @@ class DatabaseManager $this->setDefaultConnection('tenant'); } - /** - * Reconnect to the default non-tenant connection. - */ + /** Reconnect to the default non-tenant connection. */ public function reconnectToCentral(): void { $this->purgeTenantConnection(); $this->setDefaultConnection($this->config->get('tenancy.database.central_connection')); } - /** - * Change the default database connection config. - */ + /** Change the default database connection config. */ public function setDefaultConnection(string $connection): void { $this->config['database.default'] = $connection; $this->database->setDefaultConnection($connection); } - /** - * Create the tenant database connection. - */ + /** Create the tenant database connection. */ public function createTenantConnection(TenantWithDatabase $tenant): void { $this->config['database.connections.tenant'] = $tenant->database()->connection(); } - /** - * Purge the tenant database connection. - */ + /** Purge the tenant database connection. */ public function purgeTenantConnection(): void { if (array_key_exists('tenant', $this->database->getConnections())) { @@ -83,8 +64,8 @@ class DatabaseManager * Check if a tenant can be created. * * @throws TenantCannotBeCreatedException - * @throws DatabaseManagerNotRegisteredException - * @throws TenantDatabaseAlreadyExistsException + * @throws Exceptions\DatabaseManagerNotRegisteredException + * @throws Exceptions\TenantDatabaseAlreadyExistsException */ public function ensureTenantCanBeCreated(TenantWithDatabase $tenant): void { @@ -94,8 +75,13 @@ class DatabaseManager throw new Exceptions\TenantDatabaseAlreadyExistsException($database); } - if ($manager instanceof Contracts\ManagesDatabaseUsers && $manager->userExists($username = $tenant->database()->getUsername())) { - throw new Exceptions\TenantDatabaseUserAlreadyExistsException($username); + if ($manager instanceof Contracts\ManagesDatabaseUsers) { + /** @var string $username */ + $username = $tenant->database()->getUsername(); + + if ($manager->userExists($username)) { + throw new Exceptions\TenantDatabaseUserAlreadyExistsException($username); + } } } } diff --git a/src/Database/Models/ImpersonationToken.php b/src/Database/Models/ImpersonationToken.php index 8161aca7..05d17ad4 100644 --- a/src/Database/Models/ImpersonationToken.php +++ b/src/Database/Models/ImpersonationToken.php @@ -5,9 +5,12 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Models; use Carbon\Carbon; +use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; use Stancl\Tenancy\Database\Concerns\CentralConnection; +use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException; /** * @property string $token @@ -38,9 +41,15 @@ class ImpersonationToken extends Model public static function booted(): void { static::creating(function ($model) { + $authGuard = $model->auth_guard ?? config('auth.defaults.guard'); + + if (! Auth::guard($authGuard) instanceof StatefulGuard) { + throw new StatefulGuardRequiredException($authGuard); + } + $model->created_at = $model->created_at ?? $model->freshTimestamp(); $model->token = $model->token ?? Str::random(128); - $model->auth_guard = $model->auth_guard ?? config('auth.defaults.guard'); + $model->auth_guard = $authGuard; }); } } diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php index 4518e7b7..88c34146 100644 --- a/src/Database/Models/Tenant.php +++ b/src/Database/Models/Tenant.php @@ -26,6 +26,7 @@ class Tenant extends Model implements Contracts\Tenant Concerns\HasDataColumn, Concerns\HasInternalKeys, Concerns\TenantRun, + Concerns\InitializationHelpers, Concerns\InvalidatesResolverCache; protected $table = 'tenants'; diff --git a/src/Database/ParentModelScope.php b/src/Database/ParentModelScope.php index 78f5de20..cfc003c1 100644 --- a/src/Database/ParentModelScope.php +++ b/src/Database/ParentModelScope.php @@ -19,7 +19,7 @@ class ParentModelScope implements Scope $builder->whereHas($builder->getModel()->getRelationshipToPrimaryModel()); } - public function extend(Builder $builder) + public function extend(Builder $builder): void { $builder->macro('withoutParentModel', function (Builder $builder) { return $builder->withoutGlobalScope($this); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index 337864dc..f7e7440e 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -12,7 +12,8 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl { use CreatesDatabaseUsers; - public static $grants = [ + /** @var string[] */ + public static array $grants = [ 'ALTER', 'ALTER ROUTINE', 'CREATE', 'CREATE ROUTINE', 'CREATE TEMPORARY TABLES', 'CREATE VIEW', 'DELETE', 'DROP', 'EVENT', 'EXECUTE', 'INDEX', 'INSERT', 'LOCK TABLES', 'REFERENCES', 'SELECT', 'SHOW VIEW', 'TRIGGER', 'UPDATE', diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index fa5aa593..a7558e1b 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -25,11 +25,7 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager public function makeConnectionConfig(array $baseConfig, string $databaseName): array { - if (version_compare(app()->version(), '9.0', '>=')) { - $baseConfig['search_path'] = $databaseName; - } else { - $baseConfig['schema'] = $databaseName; - } + $baseConfig['search_path'] = $databaseName; return $baseConfig; } 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/src/Database/TenantScope.php b/src/Database/TenantScope.php index 8592f16c..8b887ac0 100644 --- a/src/Database/TenantScope.php +++ b/src/Database/TenantScope.php @@ -20,7 +20,7 @@ class TenantScope implements Scope $builder->where($model->qualifyColumn(BelongsToTenant::$tenantIdColumn), tenant()->getTenantKey()); } - public function extend(Builder $builder) + public function extend(Builder $builder): void { $builder->macro('withoutTenancy', function (Builder $builder) { return $builder->withoutGlobalScope($this); diff --git a/src/Events/Contracts/TenancyEvent.php b/src/Events/Contracts/TenancyEvent.php index f292049d..85b793f5 100644 --- a/src/Events/Contracts/TenancyEvent.php +++ b/src/Events/Contracts/TenancyEvent.php @@ -8,11 +8,8 @@ use Stancl\Tenancy\Tenancy; abstract class TenancyEvent { - /** @var Tenancy */ - public $tenancy; - - public function __construct(Tenancy $tenancy) - { - $this->tenancy = $tenancy; + public function __construct( + public Tenancy $tenancy, + ) { } } 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 @@ +getTargetUrl(); + + /** + * The original hostname in the redirect response. + * + * @var string $hostname + */ $hostname = parse_url($url, PHP_URL_HOST); - $position = strpos($url, $hostname); - $this->setTargetUrl(substr_replace($url, $domain, $position, strlen($hostname))); + + $this->setTargetUrl((string) str($url)->replace($hostname, $domain)); return $this; }); diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php index 50756b2c..7b82a7cd 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -16,24 +16,25 @@ use Stancl\Tenancy\Tenancy; class TenantConfig implements Feature { - /** @var Repository */ - protected $config; - public array $originalConfig = []; - public static $storageToConfigMap = [ + /** @var array */ + public static array $storageToConfigMap = [ // 'paypal_api_key' => 'services.paypal.api_key', ]; - public function __construct(Repository $config) - { - $this->config = $config; + public function __construct( + protected Repository $config, + ) { } public function bootstrap(Tenancy $tenancy): void { Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) { - $this->setTenantConfig($event->tenancy->tenant); + /** @var Tenant $tenant */ + $tenant = $event->tenancy->tenant; + + $this->setTenantConfig($tenant); }); Event::listen(RevertedToCentralContext::class, function () { @@ -43,8 +44,8 @@ class TenantConfig implements Feature public function setTenantConfig(Tenant $tenant): void { - /** @var Tenant|Model $tenant */ foreach (static::$storageToConfigMap as $storageKey => $configKey) { + /** @var Tenant&Model $tenant */ $override = Arr::get($tenant, $storageKey); if (! is_null($override)) { diff --git a/src/Features/UniversalRoutes.php b/src/Features/UniversalRoutes.php index e327b5d3..ad0433fc 100644 --- a/src/Features/UniversalRoutes.php +++ b/src/Features/UniversalRoutes.php @@ -16,6 +16,7 @@ class UniversalRoutes implements Feature public static string $middlewareGroup = 'universal'; // todo docblock + /** @var array> */ public static array $identificationMiddlewares = [ Middleware\InitializeTenancyByDomain::class, Middleware\InitializeTenancyBySubdomain::class, @@ -42,7 +43,10 @@ class UniversalRoutes implements Feature public static function routeHasMiddleware(Route $route, string $middleware): bool { - if (in_array($middleware, $route->middleware(), true)) { + /** @var array $routeMiddleware */ + $routeMiddleware = $route->middleware(); + + if (in_array($middleware, $routeMiddleware, true)) { return true; } diff --git a/src/Jobs/CreateDatabase.php b/src/Jobs/CreateDatabase.php index f143f399..dbc4b097 100644 --- a/src/Jobs/CreateDatabase.php +++ b/src/Jobs/CreateDatabase.php @@ -24,7 +24,7 @@ class CreateDatabase implements ShouldQueue ) { } - public function handle(DatabaseManager $databaseManager) + public function handle(DatabaseManager $databaseManager): bool { event(new CreatingDatabase($this->tenant)); @@ -38,5 +38,7 @@ class CreateDatabase implements ShouldQueue $this->tenant->database()->manager()->createDatabase($this->tenant); event(new DatabaseCreated($this->tenant)); + + return true; } } diff --git a/src/Jobs/CreateStorageSymlinks.php b/src/Jobs/CreateStorageSymlinks.php new file mode 100644 index 00000000..fb9a3b0d --- /dev/null +++ b/src/Jobs/CreateStorageSymlinks.php @@ -0,0 +1,28 @@ +tenant); + } +} diff --git a/src/Jobs/DeleteDomains.php b/src/Jobs/DeleteDomains.php index 8d89ce9e..15fff779 100644 --- a/src/Jobs/DeleteDomains.php +++ b/src/Jobs/DeleteDomains.php @@ -9,14 +9,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; class DeleteDomains { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - /** @var TenantWithDatabase&Model&HasDomains */ // todo unresolvable type for phpstan protected TenantWithDatabase&Model $tenant; public function __construct(TenantWithDatabase&Model $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/Listeners/BootstrapTenancy.php b/src/Listeners/BootstrapTenancy.php index 205efc5f..50f38208 100644 --- a/src/Listeners/BootstrapTenancy.php +++ b/src/Listeners/BootstrapTenancy.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; +use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\BootstrappingTenancy; use Stancl\Tenancy\Events\TenancyBootstrapped; use Stancl\Tenancy\Events\TenancyInitialized; @@ -15,7 +16,10 @@ class BootstrapTenancy event(new BootstrappingTenancy($event->tenancy)); foreach ($event->tenancy->getBootstrappers() as $bootstrapper) { - $bootstrapper->bootstrap($event->tenancy->tenant); + /** @var Tenant $tenant */ + $tenant = $event->tenancy->tenant; + + $bootstrapper->bootstrap($tenant); } event(new TenancyBootstrapped($event->tenancy)); diff --git a/src/Listeners/CreateTenantConnection.php b/src/Listeners/CreateTenantConnection.php index 01351c08..b4983d32 100644 --- a/src/Listeners/CreateTenantConnection.php +++ b/src/Listeners/CreateTenantConnection.php @@ -4,21 +4,22 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; +use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Events\Contracts\TenantEvent; class CreateTenantConnection { - /** @var DatabaseManager */ - protected $database; - - public function __construct(DatabaseManager $database) - { - $this->database = $database; + public function __construct( + protected DatabaseManager $database, + ) { } public function handle(TenantEvent $event): void { - $this->database->createTenantConnection($event->tenant); + /** @var TenantWithDatabase */ + $tenant = $event->tenant; + + $this->database->createTenantConnection($tenant); } } 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/src/Listeners/QueueableListener.php b/src/Listeners/QueueableListener.php index e10c1e7a..f486873d 100644 --- a/src/Listeners/QueueableListener.php +++ b/src/Listeners/QueueableListener.php @@ -13,7 +13,7 @@ abstract class QueueableListener implements ShouldQueue { public static bool $shouldQueue = false; - public function shouldQueue($event): bool + public function shouldQueue(object $event): bool { if (static::$shouldQueue) { return true; diff --git a/src/Listeners/RevertToCentralContext.php b/src/Listeners/RevertToCentralContext.php index ac746ed4..0a680532 100644 --- a/src/Listeners/RevertToCentralContext.php +++ b/src/Listeners/RevertToCentralContext.php @@ -14,7 +14,7 @@ class RevertToCentralContext { event(new RevertingToCentralContext($event->tenancy)); - foreach ($event->tenancy->getBootstrappers() as $bootstrapper) { + foreach (array_reverse($event->tenancy->getBootstrappers()) as $bootstrapper) { $bootstrapper->revert(); } diff --git a/src/Middleware/CheckTenantForMaintenanceMode.php b/src/Middleware/CheckTenantForMaintenanceMode.php index c1c734f5..58fcd184 100644 --- a/src/Middleware/CheckTenantForMaintenanceMode.php +++ b/src/Middleware/CheckTenantForMaintenanceMode.php @@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Middleware; use Closure; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; -use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Component\HttpKernel\Exception\HttpException; class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode @@ -21,19 +20,38 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode if (tenant('maintenance_mode')) { $data = tenant('maintenance_mode'); - if (isset($data['allowed']) && IpUtils::checkIp($request->ip(), (array) $data['allowed'])) { + if (isset($data['secret']) && $request->path() === $data['secret']) { + return $this->bypassResponse($data['secret']); + } + + if ($this->hasValidBypassCookie($request, $data) || + $this->inExceptArray($request)) { return $next($request); } - if ($this->inExceptArray($request)) { - return $next($request); + if (isset($data['redirect'])) { + $path = $data['redirect'] === '/' + ? $data['redirect'] + : trim($data['redirect'], '/'); + + if ($request->path() !== $path) { + return redirect($path); + } + } + + if (isset($data['template'])) { + return response( + $data['template'], + (int) ($data['status'] ?? 503), + $this->getHeaders($data) + ); } throw new HttpException( - 503, + (int) ($data['status'] ?? 503), 'Service Unavailable', null, - isset($data['retry']) ? ['Retry-After' => $data['retry']] : [] + $this->getHeaders($data) ); } diff --git a/src/Middleware/IdentificationMiddleware.php b/src/Middleware/IdentificationMiddleware.php index 38f4684d..12aa4a16 100644 --- a/src/Middleware/IdentificationMiddleware.php +++ b/src/Middleware/IdentificationMiddleware.php @@ -4,22 +4,22 @@ declare(strict_types=1); namespace Stancl\Tenancy\Middleware; +use Closure; +use Illuminate\Http\Request; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; use Stancl\Tenancy\Contracts\TenantResolver; use Stancl\Tenancy\Tenancy; +/** + * @property Tenancy $tenancy + * @property TenantResolver $resolver + */ abstract class IdentificationMiddleware { - /** @var callable */ - public static $onFail; + public static ?Closure $onFail = null; - /** @var Tenancy */ - protected $tenancy; - - /** @var TenantResolver */ - protected $resolver; - - public function initializeTenancy($request, $next, ...$resolverArguments) + /** @return \Illuminate\Http\Response|mixed */ + public function initializeTenancy(Request $request, Closure $next, mixed ...$resolverArguments): mixed { try { $this->tenancy->initialize( diff --git a/src/Middleware/InitializeTenancyByDomain.php b/src/Middleware/InitializeTenancyByDomain.php index 5a07112d..add5597d 100644 --- a/src/Middleware/InitializeTenancyByDomain.php +++ b/src/Middleware/InitializeTenancyByDomain.php @@ -5,32 +5,22 @@ declare(strict_types=1); namespace Stancl\Tenancy\Middleware; use Closure; +use Illuminate\Http\Request; use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Stancl\Tenancy\Tenancy; class InitializeTenancyByDomain extends IdentificationMiddleware { - /** @var callable|null */ - public static $onFail; + public static ?Closure $onFail = null; - /** @var Tenancy */ - protected $tenancy; - - /** @var DomainTenantResolver */ - protected $resolver; - - public function __construct(Tenancy $tenancy, DomainTenantResolver $resolver) - { - $this->tenancy = $tenancy; - $this->resolver = $resolver; + public function __construct( + protected Tenancy $tenancy, + protected DomainTenantResolver $resolver, + ) { } - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - */ - public function handle($request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { return $this->initializeTenancy( $request, diff --git a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php index 9b153db3..1a30001a 100644 --- a/src/Middleware/InitializeTenancyByDomainOrSubdomain.php +++ b/src/Middleware/InitializeTenancyByDomainOrSubdomain.php @@ -5,16 +5,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Middleware; use Closure; +use Illuminate\Http\Request; use Illuminate\Support\Str; class InitializeTenancyByDomainOrSubdomain { - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - */ - public function handle($request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { if ($this->isSubdomain($request->getHost())) { return app(InitializeTenancyBySubdomain::class)->handle($request, $next); diff --git a/src/Middleware/InitializeTenancyByPath.php b/src/Middleware/InitializeTenancyByPath.php index e66400c5..d4733f62 100644 --- a/src/Middleware/InitializeTenancyByPath.php +++ b/src/Middleware/InitializeTenancyByPath.php @@ -7,28 +7,26 @@ namespace Stancl\Tenancy\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Routing\Route; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\URL; +use Stancl\Tenancy\Contracts\Tenant; +use Stancl\Tenancy\Events\InitializingTenancy; use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException; use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Tenancy; class InitializeTenancyByPath extends IdentificationMiddleware { - /** @var callable|null */ - public static $onFail; + public static ?Closure $onFail = null; - /** @var Tenancy */ - protected $tenancy; - - /** @var PathTenantResolver */ - protected $resolver; - - public function __construct(Tenancy $tenancy, PathTenantResolver $resolver) - { - $this->tenancy = $tenancy; - $this->resolver = $resolver; + public function __construct( + protected Tenancy $tenancy, + protected PathTenantResolver $resolver, + ) { } - public function handle(Request $request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { /** @var Route $route */ $route = $request->route(); @@ -37,6 +35,14 @@ class InitializeTenancyByPath extends IdentificationMiddleware // We don't want to initialize tenancy if the tenant is // simply injected into some route controller action. if ($route->parameterNames()[0] === PathTenantResolver::$tenantParameterName) { + // Set tenant as a default parameter for the URLs in the current request + Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) { + /** @var Tenant $tenant */ + $tenant = $event->tenancy->tenant; + + URL::defaults([PathTenantResolver::$tenantParameterName => $tenant->getTenantKey()]); + }); + return $this->initializeTenancy( $request, $next, diff --git a/src/Middleware/InitializeTenancyByRequestData.php b/src/Middleware/InitializeTenancyByRequestData.php index 4e1d33ff..ba587d9a 100644 --- a/src/Middleware/InitializeTenancyByRequestData.php +++ b/src/Middleware/InitializeTenancyByRequestData.php @@ -11,33 +11,18 @@ use Stancl\Tenancy\Tenancy; class InitializeTenancyByRequestData extends IdentificationMiddleware { - /** @var string|null */ - public static $header = 'X-Tenant'; + public static string $header = 'X-Tenant'; + public static string $queryParameter = 'tenant'; + public static ?Closure $onFail = null; - /** @var string|null */ - public static $queryParameter = 'tenant'; - - /** @var callable|null */ - public static $onFail; - - /** @var Tenancy */ - protected $tenancy; - - /** @var TenantResolver */ - protected $resolver; - - public function __construct(Tenancy $tenancy, RequestDataTenantResolver $resolver) - { - $this->tenancy = $tenancy; - $this->resolver = $resolver; + public function __construct( + protected Tenancy $tenancy, + protected RequestDataTenantResolver $resolver, + ) { } - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - */ - public function handle($request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { if ($request->method() !== 'OPTIONS') { return $this->initializeTenancy($request, $next, $this->getPayload($request)); diff --git a/src/Middleware/InitializeTenancyBySubdomain.php b/src/Middleware/InitializeTenancyBySubdomain.php index 76389df7..1bf083f3 100644 --- a/src/Middleware/InitializeTenancyBySubdomain.php +++ b/src/Middleware/InitializeTenancyBySubdomain.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Middleware; use Closure; use Exception; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Str; use Stancl\Tenancy\Exceptions\NotASubdomainException; @@ -21,15 +22,10 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain */ public static $subdomainIndex = 0; - /** @var callable|null */ - public static $onFail; + public static ?Closure $onFail = null; - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - */ - public function handle($request, Closure $next) + /** @return Response|mixed */ + public function handle(Request $request, Closure $next): mixed { $subdomain = $this->makeSubdomain($request->getHost()); diff --git a/src/Middleware/PreventAccessFromCentralDomains.php b/src/Middleware/PreventAccessFromCentralDomains.php index 14b2306d..40718730 100644 --- a/src/Middleware/PreventAccessFromCentralDomains.php +++ b/src/Middleware/PreventAccessFromCentralDomains.php @@ -11,12 +11,11 @@ class PreventAccessFromCentralDomains { /** * Set this property if you want to customize the on-fail behavior. - * - * @var callable|null */ - public static $abortRequest; + public static ?Closure $abortRequest; - public function handle(Request $request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { if (in_array($request->getHost(), config('tenancy.central_domains'))) { $abortRequest = static::$abortRequest ?? function () { diff --git a/src/Middleware/ScopeSessions.php b/src/Middleware/ScopeSessions.php index 8abfcfe8..dc302ee5 100644 --- a/src/Middleware/ScopeSessions.php +++ b/src/Middleware/ScopeSessions.php @@ -10,9 +10,10 @@ use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; class ScopeSessions { - public static $tenantIdKey = '_tenant_id'; + public static string $tenantIdKey = '_tenant_id'; - public function handle(Request $request, Closure $next) + /** @return \Illuminate\Http\Response|mixed */ + public function handle(Request $request, Closure $next): mixed { if (! tenancy()->initialized) { throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed'); diff --git a/src/Resolvers/Contracts/CachedTenantResolver.php b/src/Resolvers/Contracts/CachedTenantResolver.php index f93d7bb5..d4d5ba6e 100644 --- a/src/Resolvers/Contracts/CachedTenantResolver.php +++ b/src/Resolvers/Contracts/CachedTenantResolver.php @@ -65,7 +65,7 @@ abstract class CachedTenantResolver implements TenantResolver abstract public function resolveWithoutCache(mixed ...$args): Tenant; - public function resolved(Tenant $tenant, ...$args): void + public function resolved(Tenant $tenant, mixed ...$args): void { } diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index 926c02c0..d2970bb5 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -24,7 +24,6 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver { $domain = $args[0]; - /** @var Tenant|null $tenant */ $tenant = config('tenancy.tenant_model')::query() ->whereHas('domains', fn (Builder $query) => $query->where('domain', $domain)) ->with('domains') @@ -39,7 +38,7 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver throw new TenantCouldNotBeIdentifiedOnDomainException($args[0]); } - public function resolved(Tenant $tenant, ...$args): void + public function resolved(Tenant $tenant, mixed ...$args): void { $this->setCurrentDomain($tenant, $args[0]); } diff --git a/src/Resolvers/PathTenantResolver.php b/src/Resolvers/PathTenantResolver.php index 2ac2a59f..c98ac37e 100644 --- a/src/Resolvers/PathTenantResolver.php +++ b/src/Resolvers/PathTenantResolver.php @@ -23,7 +23,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver /** @var Route $route */ $route = $args[0]; - if ($id = $route->parameter(static::$tenantParameterName)) { + if ($id = (string) $route->parameter(static::$tenantParameterName)) { $route->forgetParameter(static::$tenantParameterName); if ($tenant = tenancy()->find($id)) { @@ -37,7 +37,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver public function getArgsForTenant(Tenant $tenant): array { return [ - [$tenant->id], + [$tenant->getTenantKey()], ]; } } diff --git a/src/Resolvers/RequestDataTenantResolver.php b/src/Resolvers/RequestDataTenantResolver.php index 5ed65495..8a5bbc53 100644 --- a/src/Resolvers/RequestDataTenantResolver.php +++ b/src/Resolvers/RequestDataTenantResolver.php @@ -17,7 +17,7 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver public function resolveWithoutCache(mixed ...$args): Tenant { - $payload = $args[0]; + $payload = (string) $args[0]; if ($payload && $tenant = tenancy()->find($payload)) { return $tenant; @@ -29,7 +29,7 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver public function getArgsForTenant(Tenant $tenant): array { return [ - [$tenant->id], + [$tenant->getTenantKey()], ]; } } diff --git a/src/Tenancy.php b/src/Tenancy.php index 012881ae..5fe6bd52 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -99,19 +99,30 @@ class Tenancy { $class = config('tenancy.tenant_model'); - return new $class; + /** @var Tenant&Model $model */ + $model = new $class; + + return $model; } + /** + * Try to find a tenant using an ID. + * + * @return (Tenant&Model)|null + */ public static function find(int|string $id): Tenant|null { - return static::model()->where(static::model()->getTenantKeyName(), $id)->first(); + /** @var (Tenant&Model)|null */ + $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); + + return $tenant; } /** * Run a callback in the central context. * Atomic, safely reverts to previous context. */ - public function central(Closure $callback) + public function central(Closure $callback): mixed { $previousTenant = $this->tenant; @@ -132,7 +143,7 @@ class Tenancy * Run a callback for multiple tenants. * More performant than running $tenant->run() one by one. * - * @param Tenant[]|\Traversable|string[]|null $tenants + * @param array|array|\Traversable|string|int|null $tenants */ public function runForMultiple($tenants, Closure $callback): void { @@ -146,7 +157,7 @@ class Tenancy $tenants = is_string($tenants) ? [$tenants] : $tenants; // Use all tenants if $tenants is falsey - $tenants = $tenants ?: $this->model()->cursor(); + $tenants = $tenants ?: $this->model()->cursor(); // todo0 phpstan thinks this isn't needed, but tests fail without it $originalTenant = $this->tenant; @@ -155,6 +166,7 @@ class Tenancy $tenant = $this->find($tenant); } + /** @var Tenant $tenant */ $this->initialize($tenant); $callback($tenant); } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 3850720c..d9556283 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, @@ -85,6 +86,8 @@ class TenancyServiceProvider extends ServiceProvider Commands\TenantList::class, Commands\TenantDump::class, Commands\MigrateFresh::class, + Commands\Down::class, + Commands\Up::class, ]); $this->publishes([ diff --git a/src/UUIDGenerator.php b/src/UUIDGenerator.php index 736a6924..a0974862 100644 --- a/src/UUIDGenerator.php +++ b/src/UUIDGenerator.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy; +use Illuminate\Database\Eloquent\Model; use Ramsey\Uuid\Uuid; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; @@ -11,7 +12,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; class UUIDGenerator implements UniqueIdentifierGenerator { - public static function generate($resource): string + public static function generate(Model $model): string { return Uuid::uuid4()->toString(); } diff --git a/src/helpers.php b/src/helpers.php index 23b5a627..c50da940 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -35,6 +35,7 @@ if (! function_exists('tenant')) { if (! function_exists('tenant_asset')) { // todo docblock + // todo add an option to generate paths respecting the ASSET_URL function tenant_asset(string|null $asset): string { return route('stancl.tenancy.asset', ['path' => $asset]); @@ -42,27 +43,57 @@ if (! function_exists('tenant_asset')) { } if (! function_exists('global_asset')) { - function global_asset(string $asset) // todo types, also inside the globalUrl implementation + function global_asset(string $asset): string { return app('globalUrl')->asset($asset); } } if (! function_exists('global_cache')) { - function global_cache() + /** + * Get / set the specified cache value in the global cache store. + * + * If an array is passed, we'll assume you want to put to the cache. + * + * @param dynamic key|key,default|data,expiration|null + * @return mixed|\Illuminate\Cache\CacheManager + * + * @throws \InvalidArgumentException + */ + function global_cache(): mixed { - return app('globalCache'); + $arguments = func_get_args(); + + if (empty($arguments)) { + return app('globalCache'); + } + + if (is_string($arguments[0])) { + return app('globalCache')->get(...$arguments); + } + + if (! is_array($arguments[0])) { + throw new InvalidArgumentException( + 'When setting a value in the cache, you must pass an array of key / value pairs.' + ); + } + + return app('globalCache')->put(key($arguments[0]), reset($arguments[0]), $arguments[1] ?? null); } } if (! function_exists('tenant_route')) { function tenant_route(string $domain, string $route, array $parameters = [], bool $absolute = true): string { - // replace the first occurrence of the hostname fragment with $domain $url = route($route, $parameters, $absolute); - $hostname = parse_url($url, PHP_URL_HOST); - $position = strpos($url, $hostname); - return substr_replace($url, $domain, $position, strlen($hostname)); + /** + * The original hostname in the generated route. + * + * @var string $hostname + */ + $hostname = parse_url($url, PHP_URL_HOST); + + return (string) str($url)->replace($hostname, $domain); } } 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/AutomaticModeTest.php b/tests/AutomaticModeTest.php index ab484ccf..fc740fc1 100644 --- a/tests/AutomaticModeTest.php +++ b/tests/AutomaticModeTest.php @@ -107,12 +107,12 @@ function contextIsSwitchedWhenTenancyInitialized() class MyBootstrapper implements TenancyBootstrapper { - public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant) + public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void { app()->instance('tenancy_initialized_for_tenant', $tenant->getTenantKey()); } - public function revert() + public function revert(): void { app()->instance('tenancy_ended', true); } diff --git a/tests/BatchTest.php b/tests/BatchTest.php new file mode 100644 index 00000000..629a4e61 --- /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'); +}); + +function getBatchRepositoryConnectionName() +{ + return app(BatchRepository::class)->getConnection()->getName(); +} diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index 96afbc83..ba4ea41a 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,8 +14,14 @@ 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; @@ -184,24 +190,156 @@ 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, + ], + '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()); + $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 */ $disk = Storage::disk($disk); $adapter = $disk->getAdapter(); - if (! Str::startsWith(app()->version(), '9.')) { - 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 7415b74f..219d87b4 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Illuminate\Database\DatabaseManager; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; @@ -16,6 +18,8 @@ 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 () { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { @@ -26,6 +30,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); }); @@ -40,9 +53,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); @@ -115,8 +128,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(); @@ -175,8 +202,69 @@ test('run command with array of tenants works', function () { Artisan::call('tenants:migrate-fresh'); pest()->artisan("tenants:run --tenants=$tenantId1 --tenants=$tenantId2 'foo foo --b=bar --c=xyz'") - ->expectsOutput('Tenant: ' . $tenantId1) - ->expectsOutput('Tenant: ' . $tenantId2); + ->expectsOutputToContain('Tenant: ' . $tenantId1) + ->expectsOutputToContain('Tenant: ' . $tenantId2) + ->assertExitCode(0); +}); + +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(); + + Artisan::call('tenants:migrate'); + + pest()->artisan("tenants:run --tenants=$id 'user:addwithname Abrar' ") + ->expectsQuestion('What is your email?', 'email@localhost') + ->expectsOutput("Tenant: $id") + ->expectsOutput("User created: Abrar(email@localhost)"); + + // Assert we are in central context + expect(tenancy()->initialized)->toBeFalse(); + + // Assert user was created in tenant context + tenancy()->initialize($tenant); + $user = User::first(); + + // Assert user is same as provided using the command + expect($user->name)->toBe('Abrar'); + expect($user->email)->toBe('email@localhost'); }); // todo@tests diff --git a/tests/DomainTest.php b/tests/DomainTest.php index 594270e1..6995da24 100644 --- a/tests/DomainTest.php +++ b/tests/DomainTest.php @@ -81,7 +81,7 @@ test('tenant can be identified by domain', function () { test('onfail logic can be customized', function () { InitializeTenancyByDomain::$onFail = function () { - return 'foo'; + return response('foo'); }; pest() diff --git a/tests/Etc/AddUserCommand.php b/tests/Etc/Console/AddUserCommand.php similarity index 91% rename from tests/Etc/AddUserCommand.php rename to tests/Etc/Console/AddUserCommand.php index 46e1fcbb..f102bae6 100644 --- a/tests/Etc/AddUserCommand.php +++ b/tests/Etc/Console/AddUserCommand.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Tests\Etc; +namespace Stancl\Tenancy\Tests\Etc\Console; use Illuminate\Console\Command; use Illuminate\Support\Str; use Stancl\Tenancy\Concerns\HasATenantsOption; use Stancl\Tenancy\Concerns\TenantAwareCommand; +use Stancl\Tenancy\Tests\Etc\User; class AddUserCommand extends Command { diff --git a/tests/Etc/ConsoleKernel.php b/tests/Etc/Console/ConsoleKernel.php similarity index 72% rename from tests/Etc/ConsoleKernel.php rename to tests/Etc/Console/ConsoleKernel.php index a548f113..c5e5ee85 100644 --- a/tests/Etc/ConsoleKernel.php +++ b/tests/Etc/Console/ConsoleKernel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Tests\Etc; +namespace Stancl\Tenancy\Tests\Etc\Console; use Orchestra\Testbench\Foundation\Console\Kernel; @@ -10,6 +10,7 @@ class ConsoleKernel extends Kernel { protected $commands = [ ExampleCommand::class, + ExampleQuestionCommand::class, AddUserCommand::class, ]; } diff --git a/tests/Etc/ExampleCommand.php b/tests/Etc/Console/ExampleCommand.php similarity index 94% rename from tests/Etc/ExampleCommand.php rename to tests/Etc/Console/ExampleCommand.php index 49e7189b..72263b37 100644 --- a/tests/Etc/ExampleCommand.php +++ b/tests/Etc/Console/ExampleCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Stancl\Tenancy\Tests\Etc; +namespace Stancl\Tenancy\Tests\Etc\Console; use Illuminate\Console\Command; diff --git a/tests/Etc/Console/ExampleQuestionCommand.php b/tests/Etc/Console/ExampleQuestionCommand.php new file mode 100644 index 00000000..9a967054 --- /dev/null +++ b/tests/Etc/Console/ExampleQuestionCommand.php @@ -0,0 +1,46 @@ +ask('What is your email?'); + + User::create([ + 'name' => $this->argument('name'), + 'email' => $email, + 'email_verified_at' => now(), + 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password + 'remember_token' => Str::random(10), + ]); + + $this->line("User created: ". $this->argument('name') . "($email)"); + + return 0; + } +} diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php index 20a96072..9b59dedb 100644 --- a/tests/Etc/Tenant.php +++ b/tests/Etc/Tenant.php @@ -7,9 +7,13 @@ namespace Stancl\Tenancy\Tests\Etc; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDomains; +use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Stancl\Tenancy\Database\Models; +/** + * @method static static create(array $attributes = []) + */ class Tenant extends Models\Tenant implements TenantWithDatabase { - use HasDatabase, HasDomains; + use HasDatabase, HasDomains, MaintenanceMode; } diff --git a/tests/GlobalCacheTest.php b/tests/GlobalCacheTest.php index 8a13395c..ea38341b 100644 --- a/tests/GlobalCacheTest.php +++ b/tests/GlobalCacheTest.php @@ -50,3 +50,17 @@ test('global cache manager stores data in global cache', function () { expect(cache('def'))->toBe('ghi'); }); +test('the global_cache helper supports the same syntax as the cache helper', function () { + $tenant = Tenant::create(); + $tenant->enter(); + + expect(cache('foo'))->toBe(null); // tenant cache is empty + + global_cache(['foo' => 'bar']); + expect(global_cache('foo'))->toBe('bar'); + + global_cache()->set('foo', 'baz'); + expect(global_cache()->get('foo'))->toBe('baz'); + + expect(cache('foo'))->toBe(null); // tenant cache is not affected +}); diff --git a/tests/MaintenanceModeTest.php b/tests/MaintenanceModeTest.php index 770dc5f2..6e28d1ab 100644 --- a/tests/MaintenanceModeTest.php +++ b/tests/MaintenanceModeTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); +use Illuminate\Support\Facades\Artisan; use Stancl\Tenancy\Database\Concerns\MaintenanceMode; -use Symfony\Component\HttpKernel\Exception\HttpException; use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Tests\Etc\Tenant; -test('tenant can be in maintenance mode', function () { +test('tenants can be in maintenance mode', function () { Route::get('/foo', function () { return 'bar'; })->middleware([InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]); @@ -19,16 +19,40 @@ test('tenant can be in maintenance mode', function () { 'domain' => 'acme.localhost', ]); - pest()->get('http://acme.localhost/foo') - ->assertSuccessful(); - - tenancy()->end(); // flush stored tenant instance + pest()->get('http://acme.localhost/foo')->assertStatus(200); $tenant->putDownForMaintenance(); - pest()->expectException(HttpException::class); - pest()->withoutExceptionHandling() - ->get('http://acme.localhost/foo'); + tenancy()->end(); // End tenancy before making a request + pest()->get('http://acme.localhost/foo')->assertStatus(503); + + $tenant->bringUpFromMaintenance(); + + tenancy()->end(); // End tenancy before making a request + pest()->get('http://acme.localhost/foo')->assertStatus(200); +}); + +test('tenants can be put into maintenance mode using artisan commands', function() { + Route::get('/foo', function () { + return 'bar'; + })->middleware([InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]); + + $tenant = MaintenanceTenant::create(); + $tenant->domains()->create([ + 'domain' => 'acme.localhost', + ]); + + pest()->get('http://acme.localhost/foo')->assertStatus(200); + + Artisan::call('tenants:down'); + + tenancy()->end(); // End tenancy before making a request + pest()->get('http://acme.localhost/foo')->assertStatus(503); + + Artisan::call('tenants:up'); + + tenancy()->end(); // End tenancy before making a request + pest()->get('http://acme.localhost/foo')->assertStatus(200); }); class MaintenanceTenant extends Tenant diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index bda0cfcb..517fa396 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -18,7 +18,11 @@ beforeEach(function () { ], function () { Route::get('/foo/{a}/{b}', function ($a, $b) { return "$a + $b"; - }); + })->name('foo'); + + Route::get('/baz/{a}/{b}', function ($a, $b) { + return "$a - $b"; + })->name('baz'); }); }); @@ -67,7 +71,7 @@ test('exception is thrown when tenant cannot be identified by path', function () test('onfail logic can be customized', function () { InitializeTenancyByPath::$onFail = function () { - return 'foo'; + return response('foo'); }; pest() @@ -123,3 +127,23 @@ test('tenant parameter name can be customized', function () { ->withoutExceptionHandling() ->get('/acme/foo/abc/xyz'); }); + +test('tenant parameter is set for all routes as the default parameter once the tenancy initialized', function () { + Tenant::create([ + 'id' => 'acme', + ]); + + expect(tenancy()->initialized)->toBeFalse(); + + // make a request that will initialize tenancy + pest()->get(route('foo', ['tenant' => 'acme', 'a' => 1, 'b' => 2])); + + expect(tenancy()->initialized)->toBeTrue(); + expect(tenant('id'))->toBe('acme'); + + // assert that the route WITHOUT the tenant parameter matches the route WITH the tenant parameter + expect(route('baz', ['a' => 1, 'b' => 2]))->toBe(route('baz', ['tenant' => 'acme', 'a' => 1, 'b' => 2])); + + expect(route('baz', ['a' => 1, 'b' => 2]))->toBe('http://localhost/acme/baz/1/2'); // assert the full route string + pest()->get(route('baz', ['a' => 1, 'b' => 2]))->assertOk(); // Assert route don't need tenant parameter +}); diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php index 81bdda53..e5a05f65 100644 --- a/tests/RequestDataIdentificationTest.php +++ b/tests/RequestDataIdentificationTest.php @@ -37,7 +37,6 @@ test('header identification works', function () { }); test('query parameter identification works', function () { - InitializeTenancyByRequestData::$header = null; InitializeTenancyByRequestData::$queryParameter = 'tenant'; $tenant = Tenant::create(); diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index 34b12383..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', ]); @@ -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/SubdomainTest.php b/tests/SubdomainTest.php index 00096d8c..0ff52bc0 100644 --- a/tests/SubdomainTest.php +++ b/tests/SubdomainTest.php @@ -44,7 +44,7 @@ test('tenant can be identified by subdomain', function () { test('onfail logic can be customized', function () { InitializeTenancyBySubdomain::$onFail = function () { - return 'foo'; + return response('foo'); }; pest() diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index ab25310c..b16c06b6 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -154,9 +154,7 @@ test('schema manager uses schema to separate tenant dbs', function () { ]); tenancy()->initialize($tenant); - $schemaConfig = version_compare(app()->version(), '9.0', '>=') ? - config('database.connections.' . config('database.default') . '.search_path') : - config('database.connections.' . config('database.default') . '.schema'); + $schemaConfig = config('database.connections.' . config('database.default') . '.search_path'); expect($schemaConfig)->toBe($tenant->database()->getName()); expect(config(['database.connections.pgsql.database']))->toBe($originalDatabaseName); @@ -225,7 +223,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 diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 65aa380d..0fcb9022 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -4,25 +4,27 @@ declare(strict_types=1); use Carbon\Carbon; use Carbon\CarbonInterval; +use Illuminate\Support\Str; +use Illuminate\Auth\TokenGuard; use Illuminate\Auth\SessionGuard; +use Stancl\JobPipeline\JobPipeline; use Illuminate\Support\Facades\Auth; +use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; -use Illuminate\Support\Str; -use Stancl\JobPipeline\JobPipeline; -use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; -use Stancl\Tenancy\Database\Models\ImpersonationToken; use Stancl\Tenancy\Events\TenancyEnded; -use Stancl\Tenancy\Events\TenancyInitialized; -use Stancl\Tenancy\Events\TenantCreated; -use Stancl\Tenancy\Features\UserImpersonation; use Stancl\Tenancy\Jobs\CreateDatabase; +use Stancl\Tenancy\Events\TenantCreated; +use Stancl\Tenancy\Events\TenancyInitialized; +use Stancl\Tenancy\Features\UserImpersonation; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; -use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; -use Stancl\Tenancy\Middleware\InitializeTenancyByPath; -use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Foundation\Auth\User as Authenticable; +use Stancl\Tenancy\Database\Models\ImpersonationToken; +use Stancl\Tenancy\Middleware\InitializeTenancyByPath; +use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; +use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException; beforeEach(function () { pest()->artisan('migrate', [ @@ -223,6 +225,46 @@ test('impersonation works with multiple models and guards', function () { }); }); +test('impersonation tokens can be created only with stateful guards', function () { + config([ + 'auth.guards' => [ + 'nonstateful' => [ + 'driver' => 'nonstateful', + 'provider' => 'provider', + ], + 'stateful' => [ + 'driver' => 'session', + 'provider' => 'provider', + ], + ], + 'auth.providers.provider' => [ + 'driver' => 'eloquent', + 'model' => ImpersonationUser::class, + ], + ]); + + $tenant = Tenant::create(); + migrateTenants(); + + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'Joe', + 'email' => 'joe@local', + 'password' => bcrypt('secret'), + ]); + }); + + Auth::extend('nonstateful', fn($app, $name, array $config) => new TokenGuard(Auth::createUserProvider($config['provider']), request())); + + expect(fn() => tenancy()->impersonate($tenant, $user->id, '/dashboard', 'nonstateful')) + ->toThrow(StatefulGuardRequiredException::class); + + Auth::extend('stateful', fn ($app, $name, array $config) => new SessionGuard($name, Auth::createUserProvider($config['provider']), session())); + + expect(tenancy()->impersonate($tenant, $user->id, '/dashboard', 'stateful')) + ->toBeInstanceOf(ImpersonationToken::class); +}); + function migrateTenants() { pest()->artisan('tenants:migrate')->assertExitCode(0); diff --git a/tests/TestCase.php b/tests/TestCase.php index 1c6c6d8a..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, + '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,12 +144,12 @@ 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) { - $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\ConsoleKernel::class); + $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class); } public function randomString(int $length = 10)