diff --git a/resources/config/sprout.php b/resources/config/sprout.php index 01fbe89..fd8e07e 100644 --- a/resources/config/sprout.php +++ b/resources/config/sprout.php @@ -2,10 +2,46 @@ return [ + /* + |-------------------------------------------------------------------------- + | Should Sprout listen for routing? + |-------------------------------------------------------------------------- + | + | This value decides whether Sprout listens for the RouteMatched event to + | identify tenants. + | + | Setting it to false will disable the pre-middleware identification, + | which in turn will make tenant-aware dependency injection no longer + | functionality. + | + */ + 'listen_for_routing' => true, + /* + |-------------------------------------------------------------------------- + | Service Overrides + |-------------------------------------------------------------------------- + | + | This value sets which core Laravel services Sprout should override. + | + | Setting a service to false will disable its tenant-specific + | configuration/settings, and leave them using the default. + | + */ + 'services' => [ - 'storage' => true, + // This will enable the 'sprout' driver for the filesystem disks, + // allowing for the creation of tenant scoped disks. + 'storage' => true, + + // This will enable the overwriting of the default settings for cookies. + // Each identity resolver may have effect different settings. + 'cookies' => true, + + // This will enable the overwriting of the default settings for sessions. + // Each identity resolver may have effect different settings. + 'sessions' => true, ], ]; diff --git a/src/Contracts/Tenancy.php b/src/Contracts/Tenancy.php index d4a68eb..9d8dfb8 100644 --- a/src/Contracts/Tenancy.php +++ b/src/Contracts/Tenancy.php @@ -32,6 +32,13 @@ public function getName(): string; * tenant. * * @return bool + * + * @phpstan-assert-if-true \Sprout\Contracts\Tenant $this->tenant() + * @phpstan-assert-if-true string $this->identifier() + * @phpstan-assert-if-true string|int $this->key() + * @phpstan-assert-if-false null $this->tenant() + * @phpstan-assert-if-false null $this->identifier() + * @phpstan-assert-if-false null $this->key() */ public function check(): bool; diff --git a/src/Http/Resolvers/PathIdentityResolver.php b/src/Http/Resolvers/PathIdentityResolver.php index e6de4ba..8a5bafd 100644 --- a/src/Http/Resolvers/PathIdentityResolver.php +++ b/src/Http/Resolvers/PathIdentityResolver.php @@ -8,10 +8,14 @@ use Illuminate\Routing\Router; use Illuminate\Routing\RouteRegistrar; use Sprout\Concerns\FindsIdentityInRouteParameter; -use Sprout\Contracts\Tenancy; use Sprout\Contracts\IdentityResolverUsesParameters; +use Sprout\Contracts\Tenancy; +use Sprout\Contracts\Tenant; +use Sprout\Exceptions\TenantMissing; use Sprout\Http\Middleware\TenantRoutes; use Sprout\Support\BaseIdentityResolver; +use Sprout\Support\CookieHelper; +use function Sprout\sprout; final class PathIdentityResolver extends BaseIdentityResolver implements IdentityResolverUsesParameters { @@ -75,9 +79,78 @@ public function routes(Router $router, Closure $groupRoutes, Tenancy $tenancy): { return $this->applyParameterPattern( $router->middleware([TenantRoutes::ALIAS . ':' . $this->getName() . ',' . $tenancy->getName()]) - ->prefix($this->getRouteParameter($tenancy)) + ->prefix($this->getRoutePrefix($tenancy)) ->group($groupRoutes), $tenancy ); } + + /** + * Get the route prefix including the tenant parameter + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Sprout\Contracts\Tenancy $tenancy + * + * @return string + */ + public function getRoutePrefix(Tenancy $tenancy): string + { + return $this->getRouteParameter($tenancy); + } + + /** + * Get the route prefix with the parameter replaced with the tenant identifier + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Sprout\Contracts\Tenancy $tenancy + * + * @return string + * + * @throws \Sprout\Exceptions\TenantMissing + */ + public function getTenantRoutePrefix(Tenancy $tenancy): string + { + if (! $tenancy->check()) { + throw TenantMissing::make($tenancy->getName()); // @codeCoverageIgnore + } + + /** @var string $identifier */ + $identifier = $tenancy->identifier(); + + return str_replace( + '{' . $this->getRouteParameterName($tenancy) . '}', + $identifier, + $this->getRoutePrefix($tenancy) + ); + } + + /** + * Perform setup actions for the tenant + * + * When a tenant is marked as the current tenant within a tenancy, this + * method will be called to perform any necessary setup actions. + * This method is also called if there is no current tenant, as there may + * be actions needed. + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Sprout\Contracts\Tenancy $tenancy + * @param \Sprout\Contracts\Tenant|null $tenant + * + * @phpstan-param TenantClass|null $tenant + * + * @return void + * + * @throws \Sprout\Exceptions\TenantMissing + */ + public function setup(Tenancy $tenancy, ?Tenant $tenant): void + { + parent::setup($tenancy, $tenant); + + if ($tenant !== null && sprout()->config('services.cookies', false) === true) { + CookieHelper::setDefaults(path: $this->getTenantRoutePrefix($tenancy)); + } + } } diff --git a/src/Http/Resolvers/SubdomainIdentityResolver.php b/src/Http/Resolvers/SubdomainIdentityResolver.php index e851481..41590cf 100644 --- a/src/Http/Resolvers/SubdomainIdentityResolver.php +++ b/src/Http/Resolvers/SubdomainIdentityResolver.php @@ -8,10 +8,14 @@ use Illuminate\Routing\Router; use Illuminate\Routing\RouteRegistrar; use Sprout\Concerns\FindsIdentityInRouteParameter; -use Sprout\Contracts\Tenancy; use Sprout\Contracts\IdentityResolverUsesParameters; +use Sprout\Contracts\Tenancy; +use Sprout\Contracts\Tenant; +use Sprout\Exceptions\TenantMissing; use Sprout\Http\Middleware\TenantRoutes; use Sprout\Support\BaseIdentityResolver; +use Sprout\Support\CookieHelper; +use function Sprout\sprout; final class SubdomainIdentityResolver extends BaseIdentityResolver implements IdentityResolverUsesParameters { @@ -58,7 +62,7 @@ public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string * * @return string */ - protected function getDomainParameter(Tenancy $tenancy): string + protected function getRouteDomain(Tenancy $tenancy): string { return $this->getRouteParameter($tenancy) . '.' . $this->domain; } @@ -80,10 +84,65 @@ protected function getDomainParameter(Tenancy $tenancy): string public function routes(Router $router, Closure $groupRoutes, Tenancy $tenancy): RouteRegistrar { return $this->applyParameterPattern( - $router->domain($this->getDomainParameter($tenancy)) + $router->domain($this->getRouteDomain($tenancy)) ->middleware([TenantRoutes::ALIAS . ':' . $this->getName() . ',' . $tenancy->getName()]) ->group($groupRoutes), $tenancy ); } + + /** + * Get the route domain with the parameter replaced with the tenant identifier + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Sprout\Contracts\Tenancy $tenancy + * + * @return string + * + * @throws \Sprout\Exceptions\TenantMissing + */ + public function getTenantRouteDomain(Tenancy $tenancy): string + { + if (! $tenancy->check()) { + throw TenantMissing::make($tenancy->getName()); // @codeCoverageIgnore + } + + /** @var string $identifier */ + $identifier = $tenancy->identifier(); + + return str_replace( + '{' . $this->getRouteParameterName($tenancy) . '}', + $identifier, + $this->getRouteDomain($tenancy) + ); + } + + /** + * Perform setup actions for the tenant + * + * When a tenant is marked as the current tenant within a tenancy, this + * method will be called to perform any necessary setup actions. + * This method is also called if there is no current tenant, as there may + * be actions needed. + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Sprout\Contracts\Tenancy $tenancy + * @param \Sprout\Contracts\Tenant|null $tenant + * + * @phpstan-param TenantClass|null $tenant + * + * @return void + * + * @throws \Sprout\Exceptions\TenantMissing + */ + public function setup(Tenancy $tenancy, ?Tenant $tenant): void + { + parent::setup($tenancy, $tenant); + + if ($tenant !== null && sprout()->config('services.cookies', false) === true) { + CookieHelper::setDefaults(domain: $this->getTenantRouteDomain($tenancy)); + } + } } diff --git a/src/Support/CookieHelper.php b/src/Support/CookieHelper.php new file mode 100644 index 0000000..95aee86 --- /dev/null +++ b/src/Support/CookieHelper.php @@ -0,0 +1,36 @@ +setDefaultPathAndDomain($path, $domain, $secure, $sameSite); + } + + public static function collectDefaults(?string &$path = null, ?string &$domain = null, ?bool &$secure = null, ?string &$sameSite = null): void + { + // Collect the defaults for the values + $path ??= config('session.path'); + $domain ??= config('session.domain'); + $secure ??= config('session.secure', false); + $sameSite ??= config('session.same_site'); + } +} diff --git a/src/helpers.php b/src/helpers.php index a43bb00..a382ad2 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,3 +1,13 @@ make(Sprout::class); +} diff --git a/tests/Services/CookieTest.php b/tests/Services/CookieTest.php new file mode 100644 index 0000000..b740d1e --- /dev/null +++ b/tests/Services/CookieTest.php @@ -0,0 +1,152 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + $config->set('multitenancy.resolvers.subdomain.domain', 'localhost'); + }); + } + + protected function defineRoutes($router): void + { + $router->get('/', function () { + return response('No tenancy')->cookie(Cookie::make('no_tenancy_cookie', 'foo')); + })->middleware('web')->name('home'); + + $router->tenanted(function (Router $router) { + $router->get('/subdomain-route', function (#[CurrentTenant] Tenant $tenant) { + return response($tenant->getTenantIdentifier())->cookie( + Cookie::make('yes_tenancy_cookie', $tenant->getTenantKey()) + ); + })->name('subdomain.route')->middleware('web'); + }, 'subdomain', 'tenants'); + + $router->tenanted(function (Router $router) { + $router->get('/path-route', function (#[CurrentTenant] Tenant $tenant) { + return response($tenant->getTenantIdentifier())->cookie( + Cookie::make('yes_tenancy_cookie', $tenant->getTenantKey()) + ); + })->name('path.route')->middleware('web'); + }, 'path', 'tenants'); + } + + #[Test] + public function doesNotAffectNonTenantedCookies(): void + { + $result = $this->get(route('home')); + + $result->assertOk()->assertCookie('no_tenancy_cookie'); + + /** @var \Symfony\Component\HttpFoundation\Cookie $cookie */ + $cookie = $result->getCookie('no_tenancy_cookie'); + + $this->assertSame(config('session.domain'), $cookie->getDomain()); + $this->assertSame(config('session.path'), $cookie->getPath()); + $this->assertSame((bool)config('session.secure'), $cookie->isSecure()); + $this->assertSame(config('session.same_site'), $cookie->getSameSite()); + $this->assertSame('foo', $cookie->getValue()); + } + + #[Test] + public function setsTheCookieDomainWhenUsingTheSubdomainIdentityResolver(): void + { + $tenant = TenantModel::factory()->createOne(); + + $result = $this->get(route('subdomain.route', [$tenant->getTenantIdentifier()])); + + $result->assertOk()->assertCookie('yes_tenancy_cookie'); + + /** @var \Symfony\Component\HttpFoundation\Cookie $cookie */ + $cookie = $result->getCookie('yes_tenancy_cookie'); + + $this->assertSame($tenant->getTenantIdentifier() . '.localhost', $cookie->getDomain()); + $this->assertSame(config('session.path'), $cookie->getPath()); + $this->assertSame((bool)config('session.secure'), $cookie->isSecure()); + $this->assertSame(config('session.same_site'), $cookie->getSameSite()); + $this->assertSame((string)$tenant->getTenantKey(), $cookie->getValue()); + } + + #[Test] + public function setsTheCookiePathWhenUsingThePathIdentityResolver(): void + { + $tenant = TenantModel::factory()->createOne(); + + $result = $this->get(route('path.route', [$tenant->getTenantIdentifier()])); + + $result->assertOk()->assertCookie('yes_tenancy_cookie'); + + /** @var \Symfony\Component\HttpFoundation\Cookie $cookie */ + $cookie = $result->getCookie('yes_tenancy_cookie'); + + $this->assertSame(config('session.domain'), $cookie->getDomain()); + $this->assertSame($tenant->getTenantIdentifier(), $cookie->getPath()); + $this->assertSame((bool)config('session.secure'), $cookie->isSecure()); + $this->assertSame(config('session.same_site'), $cookie->getSameSite()); + $this->assertSame((string)$tenant->getTenantKey(), $cookie->getValue()); + } + + #[Test] + public function doesNotSetTheCookieDomainWhenUsingTheSubdomainIdentityResolverIfDisabled(): void + { + config()->set('sprout.services.cookies', false); + + $tenant = TenantModel::factory()->createOne(); + + $result = $this->get(route('subdomain.route', [$tenant->getTenantIdentifier()])); + + $result->assertOk()->assertCookie('yes_tenancy_cookie'); + + /** @var \Symfony\Component\HttpFoundation\Cookie $cookie */ + $cookie = $result->getCookie('yes_tenancy_cookie'); + + $this->assertSame(config('session.domain'), $cookie->getDomain()); + $this->assertSame(config('session.path'), $cookie->getPath()); + $this->assertSame((bool)config('session.secure'), $cookie->isSecure()); + $this->assertSame(config('session.same_site'), $cookie->getSameSite()); + $this->assertSame((string)$tenant->getTenantKey(), $cookie->getValue()); + } + + #[Test] + public function doesNotSetTheCookiePathWhenUsingThePathIdentityResolverIfDisabled(): void + { + config()->set('sprout.services.cookies', false); + + $tenant = TenantModel::factory()->createOne(); + + $result = $this->get(route('path.route', [$tenant->getTenantIdentifier()])); + + $result->assertOk()->assertCookie('yes_tenancy_cookie'); + + /** @var \Symfony\Component\HttpFoundation\Cookie $cookie */ + $cookie = $result->getCookie('yes_tenancy_cookie'); + + $this->assertSame(config('session.domain'), $cookie->getDomain()); + $this->assertSame(config('session.path'), $cookie->getPath()); + $this->assertSame((bool)config('session.secure'), $cookie->isSecure()); + $this->assertSame(config('session.same_site'), $cookie->getSameSite()); + $this->assertSame((string)$tenant->getTenantKey(), $cookie->getValue()); + } +}