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/composer.json b/composer.json index cc213add..0dc9df09 100644 --- a/composer.json +++ b/composer.json @@ -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/src/Actions/CreateStorageSymlinksAction.php b/src/Actions/CreateStorageSymlinksAction.php index 779a42af..eac5d933 100644 --- a/src/Actions/CreateStorageSymlinksAction.php +++ b/src/Actions/CreateStorageSymlinksAction.php @@ -8,7 +8,7 @@ use Exception; use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; use Stancl\Tenancy\Concerns\DealsWithTenantSymlinks; -use Stancl\Tenancy\Database\Models\Tenant; +use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\CreatingStorageSymlink; use Stancl\Tenancy\Events\StorageSymlinkCreated; diff --git a/src/Actions/RemoveStorageSymlinksAction.php b/src/Actions/RemoveStorageSymlinksAction.php index bfbcfa0a..a3660e7a 100644 --- a/src/Actions/RemoveStorageSymlinksAction.php +++ b/src/Actions/RemoveStorageSymlinksAction.php @@ -7,7 +7,7 @@ namespace Stancl\Tenancy\Actions; use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; use Stancl\Tenancy\Concerns\DealsWithTenantSymlinks; -use Stancl\Tenancy\Database\Models\Tenant; +use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\RemovingStorageSymlink; use Stancl\Tenancy\Events\StorageSymlinkRemoved; diff --git a/src/Commands/Down.php b/src/Commands/Down.php new file mode 100644 index 00000000..96ed5335 --- /dev/null +++ b/src/Commands/Down.php @@ -0,0 +1,52 @@ +getDownDatabasePayload(); + + // This runs for all tenants if no --tenants are specified + tenancy()->runForMultiple($this->option('tenants'), function ($tenant) use ($payload) { + $this->line("Tenant: {$tenant['id']}"); + $tenant->putDownForMaintenance($payload); + }); + + $this->comment('Tenants are now in maintenance mode.'); + } + + /** Get the payload to be placed in the "down" file. */ + protected function getDownDatabasePayload() + { + 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/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/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index a4c972bb..d6d6f5f2 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -15,6 +15,8 @@ trait DealsWithTenantSymlinks * 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 { @@ -33,7 +35,7 @@ trait DealsWithTenantSymlinks }); } - return $symlinks->mapWithKeys(fn ($item) => $item); + return $symlinks->mapWithKeys(fn ($item) => $item); // [[a => b], [c => d]] -> [a => b, c => d] } /** Determine if the provided path is an existing symlink. */ 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/Exceptions/DomainOccupiedByOtherTenantException.php b/src/Exceptions/DomainOccupiedByOtherTenantException.php index 00d42f3e..c0860ca8 100644 --- a/src/Exceptions/DomainOccupiedByOtherTenantException.php +++ b/src/Exceptions/DomainOccupiedByOtherTenantException.php @@ -8,7 +8,7 @@ use Exception; class DomainOccupiedByOtherTenantException extends Exception { - public function __construct($domain) + public function __construct(string $domain) { parent::__construct("The $domain domain is occupied by another tenant."); } diff --git a/src/Exceptions/TenancyNotInitializedException.php b/src/Exceptions/TenancyNotInitializedException.php index d2744499..be936747 100644 --- a/src/Exceptions/TenancyNotInitializedException.php +++ b/src/Exceptions/TenancyNotInitializedException.php @@ -8,7 +8,7 @@ use Exception; class TenancyNotInitializedException extends Exception { - public function __construct($message = '') + public function __construct(string $message = '') { parent::__construct($message ?: 'Tenancy is not initialized.'); } 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 index 4f18bb03..fb9a3b0d 100644 --- a/src/Jobs/CreateStorageSymlinks.php +++ b/src/Jobs/CreateStorageSymlinks.php @@ -16,24 +16,12 @@ class CreateStorageSymlinks implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public Tenant $tenant; - - /** - * Create a new job instance. - * - * @return void - */ - public function __construct(Tenant $tenant) - { - $this->tenant = $tenant; + public function __construct( + public Tenant $tenant, + ) { } - /** - * Execute the job. - * - * @return void - */ - public function handle() + public function handle(): void { CreateStorageSymlinksAction::handle($this->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/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 ed582c93..12aa4a16 100644 --- a/src/Middleware/IdentificationMiddleware.php +++ b/src/Middleware/IdentificationMiddleware.php @@ -5,6 +5,7 @@ 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; @@ -17,7 +18,8 @@ abstract class IdentificationMiddleware { public static ?Closure $onFail = null; - 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/ScopeSessions.php b/src/Middleware/ScopeSessions.php index a72146d7..dc302ee5 100644 --- a/src/Middleware/ScopeSessions.php +++ b/src/Middleware/ScopeSessions.php @@ -10,7 +10,7 @@ use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; class ScopeSessions { - public static $tenantIdKey = '_tenant_id'; + public static string $tenantIdKey = '_tenant_id'; /** @return \Illuminate\Http\Response|mixed */ public function handle(Request $request, Closure $next): mixed diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 06fe107f..7b940300 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -77,7 +77,9 @@ class TenancyServiceProvider extends ServiceProvider public function boot(): void { $this->commands([ + Commands\Up::class, Commands\Run::class, + Commands\Down::class, Commands\Link::class, Commands\Seed::class, Commands\Install::class, @@ -86,8 +88,8 @@ class TenancyServiceProvider extends ServiceProvider Commands\TenantList::class, Commands\TenantDump::class, Commands\MigrateFresh::class, - Commands\CreatePendingTenants::class, Commands\ClearPendingTenants::class, + Commands\CreatePendingTenants::class, ]); $this->publishes([ diff --git a/tests/Etc/Tenant.php b/tests/Etc/Tenant.php index 8c4340b5..f9a11d95 100644 --- a/tests/Etc/Tenant.php +++ b/tests/Etc/Tenant.php @@ -8,6 +8,7 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Database\Concerns\HasPending; +use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Stancl\Tenancy\Database\Models; /** @@ -15,5 +16,5 @@ use Stancl\Tenancy\Database\Models; */ class Tenant extends Models\Tenant implements TenantWithDatabase { - use HasDatabase, HasDomains, HasPending; + use HasDatabase, HasDomains, HasPending, MaintenanceMode; } 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