1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 21:54:03 +00:00

Fix origin id w/ empty header & using full-hostname subdomain records

This makes it possible to have Domain records in both `foo` and
`foo.{centralDomain}` format when using the combined domain/subdomain
identification middleware, or the origin header id mw which extends it.

This commit also refactors some related logic.
This commit is contained in:
Samuel Štancl 2024-11-09 20:48:45 +01:00
parent c199a6e0c8
commit 56dd4117ab
7 changed files with 131 additions and 63 deletions

View file

@ -71,6 +71,7 @@
"docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml",
"testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor",
"testbench-link": "ln -s vendor ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-link": "ln -s vendor ./vendor/orchestra/testbench-core/laravel/vendor",
"testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache",
"coverage": "open coverage/phpunit/html/index.html", "coverage": "open coverage/phpunit/html/index.html",
"phpstan": "vendor/bin/phpstan", "phpstan": "vendor/bin/phpstan",
"phpstan-pro": "vendor/bin/phpstan --pro", "phpstan-pro": "vendor/bin/phpstan --pro",

View file

@ -44,7 +44,12 @@ class InitializeTenancyByDomain extends IdentificationMiddleware
*/ */
public function requestHasTenant(Request $request): bool public function requestHasTenant(Request $request): bool
{ {
return ! in_array($this->getDomain($request), config('tenancy.identification.central_domains')); $domain = $this->getDomain($request);
// Mainly used with origin identification if the header isn't specified and e.g. universal routes are used
if (! $domain) return false;
return ! in_array($domain, config('tenancy.identification.central_domains'));
} }
public function getDomain(Request $request): string public function getDomain(Request $request): string

View file

@ -8,8 +8,9 @@ use Closure;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain
{ {
@ -23,34 +24,46 @@ class InitializeTenancyByDomainOrSubdomain extends InitializeTenancyBySubdomain
} }
$domain = $this->getDomain($request); $domain = $this->getDomain($request);
$subdomain = null;
if ($this->isSubdomain($domain)) { if (DomainTenantResolver::isSubdomain($domain)) {
$domain = $this->makeSubdomain($domain); $subdomain = $this->makeSubdomain($domain);
if ($domain instanceof Exception) { if ($subdomain instanceof Exception) {
$onFail = static::$onFail ?? function ($e) { $onFail = static::$onFail ?? function ($e) {
throw $e; throw $e;
}; };
return $onFail($domain, $request, $next); return $onFail($subdomain, $request, $next);
}
// If a Response instance was returned, we return it immediately.
// todo@samuel when does this execute?
if ($domain instanceof Response) {
return $domain;
} }
} }
return $this->initializeTenancy( try {
$request, $this->tenancy->initialize(
$next, $this->resolver->resolve($subdomain ?? $domain)
$domain );
); } catch (TenantCouldNotBeIdentifiedException $e) {
} if ($subdomain) {
try {
$this->tenancy->initialize(
$this->resolver->resolve($domain)
);
} catch (TenantCouldNotBeIdentifiedException $e) {
$onFail = static::$onFail ?? function ($e) {
throw $e;
};
protected function isSubdomain(string $hostname): bool return $onFail($e, $request, $next);
{ }
return Str::endsWith($hostname, config('tenancy.identification.central_domains')); } else {
$onFail = static::$onFail ?? function ($e) {
throw $e;
};
return $onFail($e, $request, $next);
}
}
return $next($request);
} }
} }

View file

@ -8,9 +8,9 @@ use Closure;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
use Stancl\Tenancy\Exceptions\NotASubdomainException; use Stancl\Tenancy\Exceptions\NotASubdomainException;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
class InitializeTenancyBySubdomain extends InitializeTenancyByDomain class InitializeTenancyBySubdomain extends InitializeTenancyByDomain
{ {
@ -57,20 +57,16 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain
); );
} }
/** @return string|Response|Exception|mixed */ /** @return string|Exception */
protected function makeSubdomain(string $hostname) protected function makeSubdomain(string $hostname)
{ {
$parts = explode('.', $hostname); $parts = explode('.', $hostname);
$isLocalhost = count($parts) === 1;
$isIpAddress = count(array_filter($parts, 'is_numeric')) === count($parts); $isIpAddress = count(array_filter($parts, 'is_numeric')) === count($parts);
// If we're on localhost or an IP address, then we're not visiting a subdomain.
$isACentralDomain = in_array($hostname, config('tenancy.identification.central_domains'), true); $isACentralDomain = in_array($hostname, config('tenancy.identification.central_domains'), true);
$notADomain = $isLocalhost || $isIpAddress; $thirdPartyDomain = ! DomainTenantResolver::isSubdomain($hostname);
$thirdPartyDomain = ! Str::endsWith($hostname, config('tenancy.identification.central_domains'));
if ($isACentralDomain || $notADomain || $thirdPartyDomain) { if ($isACentralDomain || $isIpAddress || $thirdPartyDomain) {
return new NotASubdomainException($hostname); return new NotASubdomainException($hostname);
} }

View file

@ -12,6 +12,7 @@ use Stancl\Tenancy\Contracts\SingleDomainTenant;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
use Illuminate\Support\Str;
class DomainTenantResolver extends Contracts\CachedTenantResolver class DomainTenantResolver extends Contracts\CachedTenantResolver
{ {
@ -55,6 +56,11 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
return $tenant; return $tenant;
} }
public static function isSubdomain(string $domain): bool
{
return Str::endsWith($domain, config('tenancy.identification.central_domains'));
}
public function resolved(Tenant $tenant, mixed ...$args): void public function resolved(Tenant $tenant, mixed ...$args): void
{ {
$this->setCurrentDomain($tenant, $args[0]); $this->setCurrentDomain($tenant, $args[0]);

View file

@ -6,62 +6,56 @@ use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Database\Concerns\HasDomains; use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
use Stancl\Tenancy\Database\Models; use Stancl\Tenancy\Database\Models;
use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () { beforeEach(function () {
Route::group([ Route::group([
'middleware' => InitializeTenancyByDomainOrSubdomain::class, 'middleware' => InitializeTenancyByDomainOrSubdomain::class,
], function () { ], function () {
Route::get('/foo/{a}/{b}', function ($a, $b) { Route::get('/test', function () {
return "$a + $b"; return tenant('id');
}); });
}); });
config(['tenancy.models.tenant' => CombinedTenant::class]);
}); });
test('tenant can be identified by subdomain', function () { test('tenant can be identified by subdomain', function () {
config(['tenancy.identification.central_domains' => ['localhost']]); config(['tenancy.identification.central_domains' => ['localhost']]);
$tenant = CombinedTenant::create([ $tenant = Tenant::create(['id' => 'acme']);
'id' => 'acme', $tenant->domains()->create(['domain' => 'foo']);
]);
$tenant->domains()->create([
'domain' => 'foo',
]);
expect(tenancy()->initialized)->toBeFalse(); expect(tenancy()->initialized)->toBeFalse();
pest() pest()->get('http://foo.localhost/test')->assertSee('acme');
->get('http://foo.localhost/foo/abc/xyz')
->assertSee('abc + xyz');
expect(tenancy()->initialized)->toBeTrue();
expect(tenant('id'))->toBe('acme');
}); });
test('tenant can be identified by domain', function () { test('tenant can be identified by domain', function () {
config(['tenancy.identification.central_domains' => []]); config(['tenancy.identification.central_domains' => []]);
$tenant = CombinedTenant::create([ $tenant = Tenant::create(['id' => 'acme']);
'id' => 'acme', $tenant->domains()->create(['domain' => 'foobar.localhost']);
]);
$tenant->domains()->create([
'domain' => 'foobar.localhost',
]);
expect(tenancy()->initialized)->toBeFalse(); expect(tenancy()->initialized)->toBeFalse();
pest() pest()->get('http://foobar.localhost/test')->assertSee('acme');
->get('http://foobar.localhost/foo/abc/xyz')
->assertSee('abc + xyz');
expect(tenancy()->initialized)->toBeTrue();
expect(tenant('id'))->toBe('acme');
}); });
class CombinedTenant extends Models\Tenant test('domain records can be either in domain syntax or subdomain syntax', function () {
{ config(['tenancy.identification.central_domains' => ['localhost']]);
use HasDomains;
} $foo = Tenant::create(['id' => 'foo']);
$foo->domains()->create(['domain' => 'foo']);
$bar = Tenant::create(['id' => 'bar']);
$bar->domains()->create(['domain' => 'bar.localhost']);
expect(tenancy()->initialized)->toBeFalse();
// Subdomain format
pest()->get('http://foo.localhost/test')->assertSee('foo');
tenancy()->end();
// Domain format
pest()->get('http://bar.localhost/test')->assertSee('bar');
});

View file

@ -38,6 +38,12 @@ test('origin identification works', function () {
}); });
test('tenant routes are not accessible on central domains while using origin identification', function () { test('tenant routes are not accessible on central domains while using origin identification', function () {
$tenant = Tenant::create();
$tenant->domains()->create([
'domain' => 'foo',
]);
pest() pest()
->withHeader('Origin', 'localhost') ->withHeader('Origin', 'localhost')
->post('home') ->post('home')
@ -54,3 +60,50 @@ test('onfail logic can be customized', function() {
->post('home') ->post('home')
->assertSee('onFail message'); ->assertSee('onFail message');
}); });
test('origin identification can be used with universal routes', function () {
$tenant = Tenant::create();
$tenant->domains()->create([
'domain' => 'foo',
]);
Route::post('/universal', function () {
return response(tenant('id') ?? 'central');
})->middleware([InitializeTenancyByOriginHeader::class, 'universal'])->name('universal');
pest()
->withHeader('Origin', 'foo.localhost')
->post('universal')
->assertSee($tenant->id);
tenancy()->end();
pest()
->withHeader('Origin', 'localhost')
->post('universal')
->assertSee('central');
pest()
// no header
->post('universal')
->assertSee('central');
});
test('origin identification can be used with both domains and subdomains', function () {
$foo = Tenant::create();
$foo->domains()->create(['domain' => 'foo']);
$bar = Tenant::create();
$bar->domains()->create(['domain' => 'bar.localhost']);
pest()
->withHeader('Origin', 'foo.localhost')
->post('home')
->assertSee($foo->id);
pest()
->withHeader('Origin', 'bar.localhost')
->post('home')
->assertSee($bar->id);
});