1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-06 01:34:02 +00:00

Merge branch 'master' of github.com:archtechx/tenancy into unware-feature

This commit is contained in:
lukinovec 2023-02-23 15:12:59 +01:00
commit b0560331ed
87 changed files with 2308 additions and 533 deletions

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Broadcasting\Broadcaster;
use Illuminate\Foundation\Application;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\TenancyBroadcastManager;
class BroadcastTenancyBootstrapper implements TenancyBootstrapper
{
/**
* Tenant properties to be mapped to config (similarly to the TenantConfig feature).
*
* For example:
* [
* 'config.key.name' => 'tenant_property',
* ]
*/
public static array $credentialsMap = [];
public static string|null $broadcaster = null;
protected array $originalConfig = [];
protected BroadcastManager|null $originalBroadcastManager = null;
protected Broadcaster|null $originalBroadcaster = null;
public static array $mapPresets = [
'pusher' => [
'broadcasting.connections.pusher.key' => 'pusher_key',
'broadcasting.connections.pusher.secret' => 'pusher_secret',
'broadcasting.connections.pusher.app_id' => 'pusher_app_id',
'broadcasting.connections.pusher.options.cluster' => 'pusher_cluster',
],
'ably' => [
'broadcasting.connections.ably.key' => 'ably_key',
'broadcasting.connections.ably.public' => 'ably_public',
],
];
public function __construct(
protected Repository $config,
protected Application $app
) {
static::$broadcaster ??= $config->get('broadcasting.default');
static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$broadcaster] ?? []);
}
public function bootstrap(Tenant $tenant): void
{
$this->originalBroadcastManager = $this->app->make(BroadcastManager::class);
$this->originalBroadcaster = $this->app->make(Broadcaster::class);
$this->setConfig($tenant);
// Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials
$this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) {
return new TenancyBroadcastManager($this->app);
});
}
public function revert(): void
{
// Change the BroadcastManager and Broadcaster singletons back to what they were before initializing tenancy
$this->app->singleton(BroadcastManager::class, fn (Application $app) => $this->originalBroadcastManager);
$this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster);
$this->unsetConfig();
}
protected function setConfig(Tenant $tenant): void
{
foreach (static::$credentialsMap as $configKey => $storageKey) {
$override = $tenant->$storageKey;
if (array_key_exists($storageKey, $tenant->getAttributes())) {
$this->originalConfig[$configKey] ??= $this->config->get($configKey);
$this->config->set($configKey, $override);
}
}
}
protected function unsetConfig(): void
{
foreach ($this->originalConfig as $key => $value) {
$this->config->set($key, $value);
}
}
}

View file

@ -25,7 +25,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
/** @var TenantWithDatabase $tenant */
// Better debugging, but breaks cached lookup in prod
if (app()->environment('local')) {
if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149
$database = $tenant->database()->getName();
if (! $tenant->database()->manager()->databaseExists($database)) {
throw new TenantDatabaseDoesNotExistException($database);

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Foundation\Application;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class MailTenancyBootstrapper implements TenancyBootstrapper
{
/**
* Tenant properties to be mapped to config (similarly to the TenantConfig feature).
*
* For example:
* [
* 'config.key.name' => 'tenant_property',
* ]
*/
public static array $credentialsMap = [];
public static string|null $mailer = null;
protected array $originalConfig = [];
public static array $mapPresets = [
'smtp' => [
'mail.mailers.smtp.host' => 'smtp_host',
'mail.mailers.smtp.port' => 'smtp_port',
'mail.mailers.smtp.username' => 'smtp_username',
'mail.mailers.smtp.password' => 'smtp_password',
],
];
public function __construct(
protected Repository $config,
protected Application $app
) {
static::$mailer ??= $config->get('mail.default');
static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$mailer] ?? []);
}
public function bootstrap(Tenant $tenant): void
{
// Forget the mail manager instance to clear the cached mailers
$this->app->forgetInstance('mail.manager');
$this->setConfig($tenant);
}
public function revert(): void
{
$this->unsetConfig();
$this->app->forgetInstance('mail.manager');
}
protected function setConfig(Tenant $tenant): void
{
foreach (static::$credentialsMap as $configKey => $storageKey) {
$override = $tenant->$storageKey;
if (array_key_exists($storageKey, $tenant->getAttributes())) {
$this->originalConfig[$configKey] ??= $this->config->get($configKey);
$this->config->set($configKey, $override);
}
}
}
protected function unsetConfig(): void
{
foreach ($this->originalConfig as $key => $value) {
$this->config->set($key, $value);
}
}
}

View file

@ -79,9 +79,9 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
$dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails
}
protected static function initializeTenancyForQueue(string|int $tenantId): void
protected static function initializeTenancyForQueue(string|int|null $tenantId): void
{
if (! $tenantId) {
if ($tenantId === null) {
// The job is not tenant-aware
if (tenancy()->initialized) {
// Tenancy was initialized, so we revert back to the central context

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Container\Container;
use Illuminate\Session\DatabaseSessionHandler;
use Illuminate\Session\SessionManager;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
/**
* This resets the database connection used by the database session driver.
*
* It runs each time tenancy is initialized or ended.
* That way the session driver always uses the current DB connection.
*/
class SessionTenancyBootstrapper implements TenancyBootstrapper
{
public function __construct(
protected Repository $config,
protected Container $container,
protected SessionManager $session,
) {
}
public function bootstrap(Tenant $tenant): void
{
$this->resetDatabaseHandler();
}
public function revert(): void
{
// When ending tenancy, this runs *before* the DatabaseTenancyBootstrapper, so DB tenancy
// is still bootstrapped. For that reason, we have to explicitly use the central connection
$this->resetDatabaseHandler(config('tenancy.database.central_connection'));
}
protected function resetDatabaseHandler(string $defaultConnection = null): void
{
$sessionDrivers = $this->session->getDrivers();
if (isset($sessionDrivers['database'])) {
/** @var \Illuminate\Session\Store $databaseDriver */
$databaseDriver = $sessionDrivers['database'];
$databaseDriver->setHandler($this->createDatabaseHandler($defaultConnection));
}
}
protected function createDatabaseHandler(string $defaultConnection = null): DatabaseSessionHandler
{
// Typically returns null, so this falls back to the default DB connection
$connection = $this->config->get('session.connection') ?? $defaultConnection;
// Based on SessionManager::createDatabaseDriver
return new DatabaseSessionHandler(
$this->container->make('db')->connection($connection),
$this->config->get('session.table'),
$this->config->get('session.lifetime'),
$this->container,
);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Closure;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Routing\UrlGenerator;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class UrlTenancyBootstrapper implements TenancyBootstrapper
{
public static Closure|null $rootUrlOverride = null;
protected string|null $originalRootUrl = null;
public function __construct(
protected UrlGenerator $urlGenerator,
protected Repository $config,
) {
}
public function bootstrap(Tenant $tenant): void
{
$this->originalRootUrl = $this->urlGenerator->to('/');
if (static::$rootUrlOverride) {
$newRootUrl = (static::$rootUrlOverride)($tenant);
$this->urlGenerator->forceRootUrl($newRootUrl);
$this->config->set('app.url', $newRootUrl);
}
}
public function revert(): void
{
$this->urlGenerator->forceRootUrl($this->originalRootUrl);
$this->config->set('app.url', $this->originalRootUrl);
}
}

View file

@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Builder;
class ClearPendingTenants extends Command
{
protected $signature = 'tenants:pending-clear
{--all : Override the default settings and deletes all pending tenants}
{--older-than-days= : Deletes all pending tenants older than the amount of days}
{--older-than-hours= : Deletes all pending tenants older than the amount of hours}';
@ -18,38 +17,30 @@ class ClearPendingTenants extends Command
public function handle(): int
{
$this->info('Removing pending tenants.');
$this->components->info('Removing pending tenants.');
$expirationDate = now();
// We compare the original expiration date to the new one to check if the new one is different later
$originalExpirationDate = $expirationDate->copy()->toImmutable();
// Skip the time constraints if the 'all' option is given
if (! $this->option('all')) {
/** @var ?int $olderThanDays */
$olderThanDays = $this->option('older-than-days');
$olderThanDays = (int) $this->option('older-than-days');
$olderThanHours = (int) $this->option('older-than-hours');
/** @var ?int $olderThanHours */
$olderThanHours = $this->option('older-than-hours');
if ($olderThanDays && $olderThanHours) {
$this->components->error("Cannot use '--older-than-days' and '--older-than-hours' together. Please, choose only one of these options.");
if ($olderThanDays && $olderThanHours) {
$this->line("<options=bold,reverse;fg=red> Cannot use '--older-than-days' and '--older-than-hours' together \n"); // todo@cli refactor all of these styled command outputs to use $this->components
$this->line('Please, choose only one of these options.');
return 1; // Exit code for failure
}
if ($olderThanDays) {
$expirationDate->subDays($olderThanDays);
}
if ($olderThanHours) {
$expirationDate->subHours($olderThanHours);
}
return 1; // Exit code for failure
}
$deletedTenantCount = tenancy()
->query()
if ($olderThanDays) {
$expirationDate->subDays($olderThanDays);
}
if ($olderThanHours) {
$expirationDate->subHours($olderThanHours);
}
$deletedTenantCount = tenancy()->query()
->onlyPending()
->when($originalExpirationDate->notEqualTo($expirationDate), function (Builder $query) use ($expirationDate) {
$query->where($query->getModel()->getColumnForQuery('pending_since'), '<', $expirationDate->timestamp);
@ -59,7 +50,7 @@ class ClearPendingTenants extends Command
->delete()
->count();
$this->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.');
$this->components->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.');
return 0;
}

View file

@ -14,7 +14,7 @@ class CreatePendingTenants extends Command
public function handle(): int
{
$this->info('Creating pending tenants.');
$this->components->info('Creating pending tenants.');
$maxPendingTenantCount = (int) ($this->option('count') ?? config('tenancy.pending.count'));
$pendingTenantCount = $this->getPendingTenantCount();
@ -30,8 +30,8 @@ class CreatePendingTenants extends Command
$createdCount++;
}
$this->info($createdCount . ' ' . str('tenant')->plural($createdCount) . ' created.');
$this->info($maxPendingTenantCount . ' ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.');
$this->components->info($createdCount . ' pending ' . str('tenant')->plural($createdCount) . ' created.');
$this->components->info($maxPendingTenantCount . ' pending ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.');
return 0;
}
@ -39,8 +39,7 @@ class CreatePendingTenants extends Command
/** Calculate the number of currently available pending tenants. */
protected function getPendingTenantCount(): int
{
return tenancy()
->query()
return tenancy()->query()
->onlyPending()
->count();
}

View file

@ -34,7 +34,7 @@ class Link extends Command
$this->createLinks($tenants);
}
} catch (Exception $exception) {
$this->error($exception->getMessage());
$this->components->error($exception->getMessage());
return 1;
}

View file

@ -7,9 +7,11 @@ namespace Stancl\Tenancy\Commands;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Console\Migrations\MigrateCommand;
use Illuminate\Database\Migrations\Migrator;
use Illuminate\Database\QueryException;
use Stancl\Tenancy\Concerns\DealsWithMigrations;
use Stancl\Tenancy\Concerns\ExtendsLaravelCommand;
use Stancl\Tenancy\Concerns\HasTenantOptions;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use Stancl\Tenancy\Events\DatabaseMigrated;
use Stancl\Tenancy\Events\MigratingDatabase;
@ -28,6 +30,8 @@ class Migrate extends MigrateCommand
{
parent::__construct($migrator, $dispatcher);
$this->addOption('skip-failing');
$this->specifyParameters();
}
@ -43,16 +47,23 @@ class Migrate extends MigrateCommand
return 1;
}
tenancy()->runForMultiple($this->getTenants(), function ($tenant) {
$this->components->info("Tenant: {$tenant->getTenantKey()}");
foreach ($this->getTenants() as $tenant) {
try {
$tenant->run(function ($tenant) {
$this->line("Tenant: {$tenant->getTenantKey()}");
event(new MigratingDatabase($tenant));
event(new MigratingDatabase($tenant));
// Migrate
parent::handle();
// Migrate
parent::handle();
event(new DatabaseMigrated($tenant));
});
event(new DatabaseMigrated($tenant));
});
} catch (TenantDatabaseDoesNotExistException|QueryException $th) {
if (! $this->option('skip-failing')) {
throw $th;
}
}
}
return 0;
}

View file

@ -23,7 +23,7 @@ class TenantDump extends DumpCommand
public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int
{
if (is_null($this->option('path'))) {
$this->input->setOption('path', database_path('schema/tenant-schema.dump'));
$this->input->setOption('path', config('tenancy.migration_parameters.--schema-path') ?? database_path('schema/tenant-schema.dump'));
}
$tenant = $this->option('tenant')
@ -41,7 +41,7 @@ class TenantDump extends DumpCommand
return 1;
}
parent::handle($connections, $dispatcher);
$tenant->run(fn () => parent::handle($connections, $dispatcher));
return 0;
}

View file

@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Concerns;
use Closure;
use Stancl\Tenancy\Enums\LogMode;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
use Stancl\Tenancy\Tenancy;
// todo finish this feature
/**
* @mixin Tenancy
*/
trait Debuggable
{
protected LogMode $logMode = LogMode::NONE;
protected array $eventLog = [];
public function log(LogMode $mode = LogMode::SILENT): static
{
$this->eventLog = [];
$this->logMode = $mode;
return $this;
}
public function logMode(): LogMode
{
return $this->logMode;
}
public function getLog(): array
{
return $this->eventLog;
}
public function logEvent(TenancyEvent $event): static
{
$this->eventLog[] = ['time' => now(), 'event' => $event::class, 'tenant' => $this->tenant];
return $this;
}
public function dump(Closure $dump = null): static
{
$dump ??= dd(...);
// Dump the log if we were already logging in silent mode
// Otherwise start logging in instant mode
match ($this->logMode) {
LogMode::NONE => $this->log(LogMode::INSTANT),
LogMode::SILENT => $dump($this->eventLog),
LogMode::INSTANT => null,
};
return $this;
}
public function dd(Closure $dump = null): void
{
$dump ??= dd(...);
if ($this->logMode === LogMode::SILENT) {
$dump($this->eventLog);
} else {
$dump($this);
}
}
}

View file

@ -23,8 +23,7 @@ trait HasTenantOptions
protected function getTenants(): LazyCollection
{
return tenancy()
->query()
return tenancy()->query()
->when($this->option('tenants'), function ($query) {
$query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants'));
})

View file

@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
*
* @see \Stancl\Tenancy\Database\Models\Domain
*
* @method __call(string $method, array $parameters) IDE support. This will be a model.
* @method __call(string $method, array $parameters) IDE support. This will be a model. // todo check if we can remove these now
* @method static __callStatic(string $method, array $parameters) IDE support. This will be a model.
* @mixin \Illuminate\Database\Eloquent\Model
*/

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\TenantScope;
use Stancl\Tenancy\Tenancy;
@ -13,7 +14,7 @@ use Stancl\Tenancy\Tenancy;
*/
trait BelongsToTenant
{
public function tenant()
public function tenant(): BelongsTo
{
return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
}

View file

@ -4,8 +4,10 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Stancl\Tenancy\Contracts\Syncable;
use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator;
use Stancl\Tenancy\Database\Models\TenantMorphPivot;
use Stancl\Tenancy\Events\SyncedResourceSaved;
trait ResourceSyncing
@ -43,4 +45,10 @@ trait ResourceSyncing
{
return true;
}
public function tenants(): MorphToMany
{
return $this->morphToMany(config('tenancy.models.tenant'), 'tenant_resources', 'tenant_resources', 'resource_global_id', 'tenant_id', 'global_id')
->using(TenantMorphPivot::class);
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Contracts\Syncable;
trait TriggerSyncEvent
{
public static function booted(): void
{
static::saved(function (self $pivot) {
$parent = $pivot->pivotParent;
if ($parent instanceof Syncable && $parent->shouldSync()) {
$parent->triggerSyncEvent();
}
});
}
}

View file

@ -87,7 +87,7 @@ class DatabaseConfig
{
$this->tenant->setInternal('db_name', $this->getName());
if ($this->connectionDriverManager($this->getTemplateConnectionName()) instanceof Contracts\ManagesDatabaseUsers) {
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
$this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant));
$this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant));
}
@ -97,11 +97,29 @@ class DatabaseConfig
}
}
public function getTemplateConnectionName(): string
public function getTemplateConnectionDriver(): string
{
return $this->tenant->getInternal('db_connection')
?? config('tenancy.database.template_tenant_connection')
?? config('tenancy.database.central_connection');
return $this->getTemplateConnection()['driver'];
}
public function getTemplateConnection(): array
{
if ($template = $this->tenant->getInternal('db_connection')) {
return config("database.connections.{$template}");
}
if ($template = config('tenancy.database.template_tenant_connection')) {
return is_array($template) ? array_merge($this->getCentralConnection(), $template) : config("database.connections.{$template}");
}
return $this->getCentralConnection();
}
protected function getCentralConnection(): array
{
$centralConnectionName = config('tenancy.database.central_connection');
return config("database.connections.{$centralConnectionName}");
}
public function getTenantHostConnectionName(): string
@ -114,8 +132,7 @@ class DatabaseConfig
*/
public function connection(): array
{
$template = $this->getTemplateConnectionName();
$templateConnection = config("database.connections.{$template}");
$templateConnection = $this->getTemplateConnection();
return $this->manager()->makeConnectionConfig(
array_merge($templateConnection, $this->tenantConfig()),
@ -129,10 +146,9 @@ class DatabaseConfig
public function hostConnection(): array
{
$config = $this->tenantConfig();
$template = $this->getTemplateConnectionName();
$templateConnection = config("database.connections.{$template}");
$templateConnection = $this->getTemplateConnection();
if ($this->connectionDriverManager($template) instanceof Contracts\ManagesDatabaseUsers) {
if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) {
// We're removing the username and password because user with these credentials is not created yet
// If you need to provide username and password when using PermissionControlledMySQLDatabaseManager,
// consider creating a new connection and use it as `tenancy_db_connection` tenant config key
@ -196,7 +212,7 @@ class DatabaseConfig
$tenantHostConnectionName = $this->getTenantHostConnectionName();
config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]);
$manager = $this->connectionDriverManager($tenantHostConnectionName);
$manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver"));
if ($manager instanceof Contracts\StatefulTenantDatabaseManager) {
$manager->setConnection($tenantHostConnectionName);
@ -211,10 +227,8 @@ class DatabaseConfig
*
* @throws DatabaseManagerNotRegisteredException
*/
protected function connectionDriverManager(string $connectionName): Contracts\TenantDatabaseManager
protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager
{
$driver = config("database.connections.{$connectionName}.driver");
$databaseManagers = config('tenancy.database.managers');
if (! array_key_exists($driver, $databaseManagers)) {

View file

@ -10,6 +10,7 @@ use Stancl\Tenancy\Contracts;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Events;
use Stancl\Tenancy\Tenancy;
/**
* @property string $domain
@ -28,7 +29,7 @@ class Domain extends Model implements Contracts\Domain
public function tenant(): BelongsTo
{
return $this->belongsTo(config('tenancy.models.tenant'));
return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
}
protected $dispatchesEvents = [

View file

@ -33,9 +33,8 @@ class ImpersonationToken extends Model
public $incrementing = false;
protected $table = 'tenant_user_impersonation_tokens';
protected $dates = [
'created_at',
protected $casts = [
'created_at' => 'datetime',
];
public static function booted(): void

View file

@ -32,6 +32,8 @@ class Tenant extends Model implements Contracts\Tenant
Concerns\InitializationHelpers,
Concerns\InvalidatesResolverCache;
protected static $modelsShouldPreventAccessingMissingAttributes = false;
protected $table = 'tenants';
protected $primaryKey = 'id';
protected $guarded = [];

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Models;
use Illuminate\Database\Eloquent\Relations\MorphPivot;
use Stancl\Tenancy\Database\Concerns\TriggerSyncEvent;
class TenantMorphPivot extends MorphPivot
{
use TriggerSyncEvent;
}

View file

@ -5,18 +5,9 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Stancl\Tenancy\Contracts\Syncable;
use Stancl\Tenancy\Database\Concerns\TriggerSyncEvent;
class TenantPivot extends Pivot
{
public static function booted(): void
{
static::saved(function (self $pivot) {
$parent = $pivot->pivotParent;
if ($parent instanceof Syncable && $parent->shouldSync()) {
$parent->triggerSyncEvent();
}
});
}
use TriggerSyncEvent;
}

View file

@ -41,7 +41,8 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
protected function isVersion8(): bool
{
$version = $this->database()->select($this->database()->raw('select version()'))[0]->{'version()'};
$versionSelect = (string) $this->database()->raw('select version()')->getValue($this->database()->getQueryGrammar());
$version = $this->database()->select($versionSelect)[0]->{'version()'};
return version_compare($version, '8.0.0') >= 0;
}

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Enums;
enum LogMode
{
case NONE;
case SILENT;
case INSTANT;
}

View file

@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Closure;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as Router;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Middleware;
class UniversalRoutes implements Feature
{
public static string $middlewareGroup = 'universal';
// todo docblock
/** @var array<class-string<\Stancl\Tenancy\Middleware\IdentificationMiddleware>> */
public static array $identificationMiddlewares = [
Middleware\InitializeTenancyByDomain::class,
Middleware\InitializeTenancyBySubdomain::class,
];
public function bootstrap(): void
{
foreach (static::$identificationMiddlewares as $middleware) {
$originalOnFail = $middleware::$onFail;
$middleware::$onFail = function ($exception, $request, $next) use ($originalOnFail) {
if (static::routeHasMiddleware($request->route(), static::$middlewareGroup)) {
return $next($request);
}
if ($originalOnFail) {
return $originalOnFail($exception, $request, $next);
}
throw $exception;
};
}
}
public static function routeHasMiddleware(Route $route, string $middleware): bool
{
/** @var array $routeMiddleware */
$routeMiddleware = $route->middleware();
if (in_array($middleware, $routeMiddleware, true)) {
return true;
}
// Loop one level deep and check if the route's middleware
// groups have the searched middleware group inside them
$middlewareGroups = Router::getMiddlewareGroups();
foreach ($route->gatherMiddleware() as $inner) {
if (! $inner instanceof Closure && isset($middlewareGroups[$inner]) && in_array($middleware, $middlewareGroups[$inner], true)) {
return true;
}
}
return false;
}
public static function alwaysBootstrap(): bool
{
return false;
}
}

View file

@ -48,11 +48,26 @@ class UserImpersonation implements Feature
$token->delete();
session()->put('tenancy_impersonating', true);
return redirect($token->redirect_url);
}
public static function alwaysBootstrap(): bool
{
return false;
public static function isImpersonating(): bool
{
return session()->has('tenancy_impersonating');
}
/**
* Logout from the current domain and forget impersonation session.
*/
public static function leave(): void // todo possibly rename
{
auth()->logout();
session()->forget('tenancy_impersonating');
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Illuminate\Foundation\Application;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
use Stancl\Tenancy\Vite;
class ViteBundler implements Feature
{
/** @var Application */
protected $app;
public function __construct(Application $app)
{
$this->app = $app;
}
public function bootstrap(Tenancy $tenancy): void
{
$this->app->singleton(\Illuminate\Foundation\Vite::class, Vite::class);
}
}

View file

@ -6,7 +6,7 @@ namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenantEvent;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
class CreateTenantConnection
{
@ -15,11 +15,12 @@ class CreateTenantConnection
) {
}
public function handle(TenantEvent $event): void
public function handle(TenancyEvent $event): void
{
/** @var TenantWithDatabase */
$tenant = $event->tenant;
/** @var TenantWithDatabase $tenant */
$tenant = $event->tenancy->tenant;
$this->database->purgeTenantConnection();
$this->database->createTenantConnection($tenant);
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Events\TenantCreated;
class CreateTenantStorage
{
public function handle(TenantCreated $event): void
{
$storage_path = $event->tenant->run(fn () => storage_path());
mkdir("$storage_path", 0777, true); // Create the tenant's folder inside storage/
mkdir("$storage_path/framework/cache", 0777, true); // Create /framework/cache inside the tenant's storage (used for e.g. real-time facades)
}
}

View file

@ -11,6 +11,9 @@ class DeleteTenantStorage
{
public function handle(DeletingTenant $event): void
{
// todo@lukas since this is using the 'File' facade instead of low-level PHP functions, Tenancy might affect this?
// Therefore, when Tenancy is initialized, this might look INSIDE the tenant's storage, instead of the main storage dir?
// The DeletingTenant event will be fired in the central context in 99% of cases, but sometimes it might run in the tenant context (from another tenant) so we want to make sure this works well in all contexts.
File::deleteDirectory($event->tenant->run(fn () => storage_path()));
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
class UseCentralConnection
{
public function __construct(
protected DatabaseManager $database,
) {
}
public function handle(TenancyEvent $event): void
{
$this->database->reconnectToCentral();
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
class UseTenantConnection
{
public function __construct(
protected DatabaseManager $database,
) {
}
public function handle(TenancyEvent $event): void
{
$this->database->setDefaultConnection('tenant');
}
}

View file

@ -22,6 +22,11 @@ class InitializeTenancyByDomain extends IdentificationMiddleware
/** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed
{
if (in_array($request->getHost(), config('tenancy.central_domains', []), true)) {
// Always bypass tenancy initialization when host is in central domains
return $next($request);
}
return $this->initializeTenancy(
$request,
$next,

View file

@ -28,14 +28,13 @@ class InitializeTenancyByPath extends IdentificationMiddleware
/** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed
{
/** @var Route $route */
$route = $request->route();
$route = $this->route($request);
// Only initialize tenancy if tenant is the first parameter
// We don't want to initialize tenancy if the tenant is
// simply injected into some route controller action.
if ($route->parameterNames()[0] === PathTenantResolver::tenantParameterName()) {
$this->setDefaultTenantForRouteParametersWhenTenancyIsInitialized();
$this->setDefaultTenantForRouteParametersWhenInitializingTenancy();
return $this->initializeTenancy(
$request,
@ -47,7 +46,26 @@ class InitializeTenancyByPath extends IdentificationMiddleware
}
}
protected function setDefaultTenantForRouteParametersWhenTenancyIsInitialized(): void
protected function route(Request $request): Route
{
/** @var ?Route $route */
$route = $request->route();
if (! $route) {
// Create a fake $route instance that has enough information for this middleware's needs
$route = new Route($request->method(), $request->getUri(), []);
/**
* getPathInfo() returns the path except the root domain.
* We fetch the first parameter because tenant parameter is *always* first.
*/
$route->parameters[PathTenantResolver::tenantParameterName()] = explode('/', ltrim($request->getPathInfo(), '/'))[0];
$route->parameterNames[] = PathTenantResolver::tenantParameterName();
}
return $route;
}
protected function setDefaultTenantForRouteParametersWhenInitializingTenancy(): void
{
Event::listen(InitializingTenancy::class, function (InitializingTenancy $event) {
/** @var Tenant $tenant */

View file

@ -27,6 +27,11 @@ class InitializeTenancyBySubdomain extends InitializeTenancyByDomain
/** @return Response|mixed */
public function handle(Request $request, Closure $next): mixed
{
if (in_array($request->getHost(), config('tenancy.central_domains', []), true)) {
// Always bypass tenancy initialization when host is in central domains
return $next($request);
}
$subdomain = $this->makeSubdomain($request->getHost());
if (is_object($subdomain) && $subdomain instanceof Exception) {

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Http\Request;
class PreventAccessFromCentralDomains
{
/**
* Set this property if you want to customize the on-fail behavior.
*/
public static ?Closure $abortRequest;
/** @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 () {
abort(404);
};
return $abortRequest($request, $next);
}
return $next($request);
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as Router;
// todo come up with a better name
class PreventAccessFromUnwantedDomains
{
/**
* Set this property if you want to customize the on-fail behavior.
*/
public static ?Closure $abortRequest;
/** @return \Illuminate\Http\Response|mixed */
public function handle(Request $request, Closure $next): mixed
{
/** @var Route $route */
$route = $request->route();
if ($this->routeHasMiddleware($route, 'universal')) {
return $next($request);
}
if (in_array($request->getHost(), config('tenancy.central_domains'), true)) {
$abortRequest = static::$abortRequest ?? function () {
abort(404);
};
return $abortRequest($request, $next);
}
return $next($request);
}
protected function routeHasMiddleware(Route $route, string $middleware): bool
{
/** @var array $routeMiddleware */
$routeMiddleware = $route->middleware();
if (in_array($middleware, $routeMiddleware, true)) {
return true;
}
// Loop one level deep and check if the route's middleware
// groups have the searched middleware group inside them
$middlewareGroups = Router::getMiddlewareGroups();
foreach ($route->gatherMiddleware() as $inner) {
if (! $inner instanceof Closure && isset($middlewareGroups[$inner]) && in_array($middleware, $middlewareGroups[$inner], true)) {
return true;
}
}
return false;
}
}

View file

@ -27,9 +27,7 @@ abstract class CachedTenantResolver implements TenantResolver
$key = $this->getCacheKey(...$args);
if ($this->cache->has($key)) {
$tenant = $this->cache->get($key);
if ($tenant = $this->cache->get($key)) {
$this->resolved($tenant, ...$args);
return $tenant;

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Resolvers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Domain;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
@ -39,14 +40,16 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
protected function setCurrentDomain(Tenant $tenant, string $domain): void
{
/** @var Tenant&Model $tenant */
static::$currentDomain = $tenant->domains->where('domain', $domain)->first();
}
public function getArgsForTenant(Tenant $tenant): array
{
/** @var Tenant&Model $tenant */
$tenant->unsetRelation('domains');
return $tenant->domains->map(function (Domain $domain) {
return $tenant->domains->map(function (Domain&Model $domain) {
return [$domain->domain];
})->toArray();
}

View file

@ -8,21 +8,18 @@ use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Traits\Macroable;
use Stancl\Tenancy\Concerns\Debuggable;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
class Tenancy
{
use Macroable, Debuggable;
use Macroable;
/**
* The current tenant.
*
* @var (Tenant&Model)|null
*/
public ?Tenant $tenant = null;
public Tenant|null $tenant = null;
// todo docblock
public ?Closure $getBootstrappersUsing = null;
@ -97,9 +94,9 @@ class Tenancy
public static function model(): Tenant&Model
{
/** @var class-string<Tenant&Model> $class */
$class = config('tenancy.models.tenant');
/** @var Tenant&Model $model */
$model = new $class;
return $model;
@ -113,8 +110,6 @@ class Tenancy
/**
* Try to find a tenant using an ID.
*
* @return (Tenant&Model)|null
*/
public static function find(int|string $id): Tenant|null
{

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy; // todo new Overrides namespace?
use Illuminate\Broadcasting\Broadcasters\Broadcaster;
use Illuminate\Broadcasting\BroadcastManager;
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use Illuminate\Contracts\Foundation\Application;
class TenancyBroadcastManager extends BroadcastManager
{
/**
* Names of broadcasters to always recreate using $this->resolve() (even when they're
* cached and available in the $broadcasters property).
*
* The reason for recreating the broadcasters is
* to make your app use the correct broadcaster credentials when tenancy is initialized.
*/
public static array $tenantBroadcasters = ['pusher', 'ably'];
/**
* Override the get method so that the broadcasters in $tenantBroadcasters
* always get freshly resolved even when they're cached and available in the $broadcasters property,
* and that the resolved broadcaster will override the BroadcasterContract::class singleton.
*
* If there's a cached broadcaster with the same name as $name,
* give its channels to the newly resolved bootstrapper.
*/
protected function get($name)
{
if (in_array($name, static::$tenantBroadcasters)) {
/** @var Broadcaster|null $originalBroadcaster */
$originalBroadcaster = $this->app->make(BroadcasterContract::class);
$newBroadcaster = $this->resolve($name);
// If there is a current broadcaster, give its channels to the newly resolved one
// Broadcasters only have to implement the Illuminate\Contracts\Broadcasting\Broadcaster contract
// Which doesn't require the channels property
// So passing the channels is only needed for Illuminate\Broadcasting\Broadcasters\Broadcaster instances
if ($originalBroadcaster instanceof Broadcaster && $newBroadcaster instanceof Broadcaster) {
$this->passChannelsFromOriginalBroadcaster($originalBroadcaster, $newBroadcaster);
}
$this->app->singleton(BroadcasterContract::class, fn (Application $app) => $newBroadcaster);
return $newBroadcaster;
}
return parent::get($name);
}
// Because, unlike the original broadcaster, the newly resolved broadcaster won't have the channels registered using routes/channels.php
// Using it for broadcasting won't work, unless we make it have the original broadcaster's channels
protected function passChannelsFromOriginalBroadcaster(Broadcaster $originalBroadcaster, Broadcaster $newBroadcaster): void
{
// invade() because channels can't be retrieved through any of the broadcaster's public methods
$originalBroadcaster = invade($originalBroadcaster);
foreach ($originalBroadcaster->channels as $channel => $callback) {
$newBroadcaster->channel($channel, $callback, $originalBroadcaster->retrieveChannelOptions($channel));
}
}
}

View file

@ -6,13 +6,10 @@ namespace Stancl\Tenancy;
use Illuminate\Cache\CacheManager;
use Illuminate\Database\Console\Migrations\FreshCommand;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Contracts\Domain;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Enums\LogMode;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
class TenancyServiceProvider extends ServiceProvider
@ -62,6 +59,7 @@ class TenancyServiceProvider extends ServiceProvider
$this->app->singleton(Commands\Rollback::class, function ($app) {
return new Commands\Rollback($app['migrator']);
});
$this->app->singleton(Commands\Seed::class, function ($app) {
return new Commands\Seed($app['db']);
});
@ -106,6 +104,10 @@ class TenancyServiceProvider extends ServiceProvider
__DIR__ . '/../assets/impersonation-migrations/' => database_path('migrations'),
], 'impersonation-migrations');
$this->publishes([
__DIR__ . '/../assets/resource-syncing-migrations/' => database_path('migrations'),
], 'resource-syncing-migrations');
$this->publishes([
__DIR__ . '/../assets/tenant_routes.stub.php' => base_path('routes/tenant.php'),
], 'routes');
@ -118,18 +120,6 @@ class TenancyServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/../assets/routes.php');
}
Event::listen('Stancl\\Tenancy\\Events\\*', function (string $name, array $data) {
$event = $data[0];
if ($event instanceof TenancyEvent) {
match (tenancy()->logMode()) {
LogMode::SILENT => tenancy()->logEvent($event),
LogMode::INSTANT => dump($event), // todo1 perhaps still log
default => null,
};
}
});
$this->app->singleton('globalUrl', function ($app) {
if ($app->bound(FilesystemTenancyBootstrapper::class)) {
$instance = clone $app['url'];

22
src/Vite.php Normal file
View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy;
use Illuminate\Foundation\Vite as BaseVite;
class Vite extends BaseVite // todo move to a different directory in v4
{
/**
* Generate an asset path for the application.
*
* @param string $path
* @param bool|null $secure
* @return string
*/
protected function assetPath($path, $secure = null)
{
return global_asset($path);
}
}