1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-09-20 12:34:03 +00:00
tenancy/src/Bootstrappers/FilesystemTenancyBootstrapper.php
lukinovec 9cda4cb3e4 Forget scoped disk's parent no matter how nested it is
This includes moving the TenantAssetController baseDiskName() method to FSBootstrapper and making it public static, since the same logic is used in two places now. Also cover the edge case where a scoped disk A has a scoped disk B as its parent, and B has A as its parent -- in that case, the method would be stuck in an infinite loop (also added separate test for this, commenting out the $visited-related code in baseDiskName will make the test fail).

Also updated the assetRoot's unnamed disk exception message.
2026-09-07 18:32:15 -07:00

373 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Exception;
use Illuminate\Foundation\Application;
use Illuminate\Session\FileSessionHandler;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class FilesystemTenancyBootstrapper implements TenancyBootstrapper
{
public array $originalDisks = [];
protected array $originalCachePaths = [];
protected array $originalCacheLockPaths = [];
protected string|null $originalSessionPath = null;
public string|null $originalAssetUrl;
public string $originalStoragePath;
public function __construct(
protected Application $app,
) {
$this->originalAssetUrl = $this->app['config']['app.asset_url'];
$this->originalStoragePath = $app->storagePath();
}
public function bootstrap(Tenant $tenant): void
{
$suffix = $this->suffix($tenant);
$this->storagePath($suffix);
$this->assetHelper($suffix);
$this->forgetDisks();
$this->scopeCache($suffix);
$this->scopeSessions($suffix);
foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) {
$this->diskRoot($disk, $tenant);
$this->diskUrl(
$disk,
str($this->app['config']["tenancy.filesystem.url_override.{$disk}"])
->replace('%tenant%', (string) $tenant->getTenantKey())
->toString(),
);
}
}
public function revert(): void
{
$this->storagePath(false);
$this->assetHelper(false);
$this->forgetDisks();
$this->scopeCache(false);
$this->scopeSessions(false);
foreach ($this->app['config']['tenancy.filesystem.disks'] as $disk) {
$this->diskRoot($disk, false);
$this->diskUrl($disk, false);
}
}
protected function suffix(Tenant $tenant): string
{
return $this->app['config']['tenancy.filesystem.suffix_base'] . $tenant->getTenantKey();
}
protected function storagePath(string|false $suffix): void
{
if ($this->app['config']['tenancy.filesystem.suffix_storage_path'] === false) {
return;
}
$path = $suffix
? $this->tenantStoragePath($suffix) . '/framework/cache'
: $this->originalStoragePath . '/framework/cache';
if (! is_dir($path)) {
// Create tenant framework/cache directory if it does not exist.
// We ignore errors due to TOCTOU race conditions, instead we check for success below.
@mkdir($path, 0750, true);
if (! is_dir($path)) {
throw new Exception("Unable to create tenant storage directory [{$path}].");
}
}
if ($suffix === false) {
$this->app->useStoragePath($this->originalStoragePath);
} else {
$this->app->useStoragePath($this->tenantStoragePath($suffix));
}
}
protected function tenantStoragePath(string $suffix): string
{
return rtrim($this->originalStoragePath, '/\\') . DIRECTORY_SEPARATOR . $suffix;
}
protected function assetHelper(string|false $suffix): void
{
if (! $this->app['config']['tenancy.filesystem.asset_helper_override']) {
return;
}
if ($suffix === false) {
$this->app['config']['app.asset_url'] = $this->originalAssetUrl;
$this->app['url']->useAssetOrigin($this->originalAssetUrl);
return;
}
if ($this->originalAssetUrl) {
$this->app['config']['app.asset_url'] = $this->originalAssetUrl . "/$suffix";
$this->app['url']->useAssetOrigin($this->app['config']['app.asset_url']);
} else {
$this->app['url']->useAssetOrigin($this->app['url']->route('stancl.tenancy.asset', ['path' => '']));
}
}
protected function forgetDisks(): void
{
$tenantDisks = $this->app['config']['tenancy.filesystem.disks'];
$scopedDisks = [];
foreach ($this->app['config']['filesystems.disks'] as $name => $disk) {
if (isset($disk['driver'])
&& $disk['driver'] === 'scoped'
&& in_array(static::baseDiskName($name), $tenantDisks, true)) {
$scopedDisks[] = $name;
}
}
Storage::forgetDisk(array_merge($tenantDisks, $scopedDisks));
}
protected function diskRoot(string $disk, Tenant|false $tenant): void
{
if ($tenant === false) {
$this->app['config']["filesystems.disks.$disk.root"] = $this->originalDisks[$disk]['root'];
return;
}
$suffix = $this->suffix($tenant);
$diskConfig = $this->app['config']["filesystems.disks.{$disk}"];
$originalRoot = $diskConfig['root'] ?? null;
$this->originalDisks[$disk]['root'] = $originalRoot;
if ($override = $this->app['config']["tenancy.filesystem.root_override.{$disk}"]) {
// This is executed if the disk is in tenancy.filesystem.disks AND has a root_override
// This behavior is used for local disks.
$newRoot = str($override)
->replace('%storage_path%', $this->tenantStoragePath($suffix))
->replace('%original_storage_path%', $this->originalStoragePath)
->replace('%tenant%', (string) $tenant->getTenantKey())
->toString();
} else {
// This is executed if the disk is in tenancy.filesystem.disks but does NOT have a root_override
// This behavior is used for disks like S3.
$newRoot = $originalRoot
? rtrim($originalRoot, '/\\') . '/' . $suffix
: $suffix;
}
$this->app['config']["filesystems.disks.{$disk}.root"] = $newRoot;
}
protected function diskUrl(string $disk, string|false $override): void
{
$diskConfig = $this->app['config']["filesystems.disks.{$disk}"];
if ($diskConfig['driver'] !== 'local' || ! $this->app['config']["tenancy.filesystem.url_override.{$disk}"]) {
return;
}
if ($override === false) {
$url = data_get($this->originalDisks, "$disk.url");
$this->app['config']["filesystems.disks.$disk.url"] = $url;
} else {
$this->originalDisks[$disk]['url'] ??= $diskConfig['url'] ?? null;
$this->app['config']["filesystems.disks.{$disk}.url"] = url($override);
}
}
public function scopeCache(string|false $suffix): void
{
if (! $this->app['config']['tenancy.filesystem.scope_cache']) {
return;
}
$stores = $suffix !== false
? $this->app['config']['tenancy.cache.stores']
: array_keys($this->originalCachePaths);
foreach ($stores as $name) {
$store = $this->app['config']["cache.stores.{$name}"];
// Only file stores have a path to scope. Skip stores that don't exist (null) or use another driver.
if ($store === null || $store['driver'] !== 'file') {
continue;
}
if ($suffix !== false && ! isset($this->originalCachePaths[$name])) {
$this->originalCachePaths[$name] = $store['path'];
$this->originalCacheLockPaths[$name] = $store['lock_path'] ?? null;
}
$path = $suffix ? $this->tenantScopedPath($this->originalCachePaths[$name], $suffix) : $this->originalCachePaths[$name];
// Unlike path, lock_path is optional -- if it's not set, FileStore::lock() falls back to path
// itself (see `$this->lockDirectory ?? $this->directory` in FileStore). Leave it null here rather
// than hardcoding it to $path ourselves, so a store that didn't configure a separate lock_path
// doesn't end up with one.
$lockPath = $this->originalCacheLockPaths[$name];
if ($suffix && $lockPath !== null) {
$lockPath = $this->tenantScopedPath($lockPath, $suffix);
}
$this->app['config']["cache.stores.{$name}.path"] = $path;
$this->app['config']["cache.stores.{$name}.lock_path"] = $lockPath;
/** @var \Illuminate\Cache\FileStore $store */
$store = $this->app['cache']->store($name)->getStore();
$store->setDirectory($path);
$store->setLockDirectory($lockPath);
}
}
/**
* Scope a configured path (a cache store's path or lock_path, or the session path)
* to the tenant identified by $suffix.
*/
protected function tenantScopedPath(string $configuredPath, string $suffix): string
{
$configuredPath = $this->normalizePath($configuredPath);
$storagePath = $this->normalizePath($this->originalStoragePath);
if (str_starts_with($configuredPath, $storagePath . DIRECTORY_SEPARATOR)) {
// Swap the central storage path prefix for the tenant's.
// For example, storage_path('framework/cache/data') becomes storage_path('tenant1/framework/cache/data').
return str($configuredPath)
->replaceFirst($storagePath, $this->tenantStoragePath($suffix))
->toString();
}
// Otherwise $configuredPath isn't necessarily storage_path()-based, so just append the
// suffix as a subdirectory, e.g. '/var/cache/foo' becomes '/var/cache/foo/tenant1'.
return $configuredPath . DIRECTORY_SEPARATOR . $suffix;
}
/**
* Normalize the path to use the separator of the current OS.
*
* The separators are also deduplicated, with two exceptions:
* - if the path begins with \\ on Windows (i.e. a UNC path), the *leading* separators won't be deduplicated
* - if the path contains non-UTF-8 characters, the separators won't be deduplicated since Str::deduplicate() only supports UTF-8 strings)
*/
protected function normalizePath(string $path): string
{
$path = str_replace('/', DIRECTORY_SEPARATOR, $path);
$uncPrefix = DIRECTORY_SEPARATOR === '\\' && str_starts_with($path, '\\\\') ? DIRECTORY_SEPARATOR : '';
if ($deduplicated = Str::deduplicate($path, DIRECTORY_SEPARATOR)) {
// On Windows, a path starting with two separators is a UNC path (e.g. '\\server\share'),
// so the leading separator that got collapsed by deduplicate() should be added back
// (only one \ will be kept, so we use one for the prefix).
return $uncPrefix . rtrim($deduplicated, DIRECTORY_SEPARATOR);
} else {
// Because deduplicate() only supports UTF-8 paths, paths with non-UTF-8 characters will not
// be deduplicated since deduplicate() returns an empty result with unsupported strings
return rtrim($path, DIRECTORY_SEPARATOR);
}
}
public function scopeSessions(string|false $suffix): void
{
if (! $this->app['config']['tenancy.filesystem.scope_sessions']) {
return;
}
$originalPath = $this->originalSessionPath ?? $this->app['config']['session.files'];
$this->originalSessionPath = $originalPath;
$path = $suffix
? $this->tenantScopedPath($originalPath, $suffix)
: $originalPath;
if (! is_dir($path)) {
// Create tenant session directory if it does not exist.
// We ignore errors due to TOCTOU race conditions, instead we check for success below.
@mkdir($path, 0750, true);
if (! is_dir($path)) {
throw new Exception("Unable to create tenant session directory [{$path}].");
}
}
$this->app['config']['session.files'] = $path;
/** @var \Illuminate\Session\SessionManager $sessionManager */
$sessionManager = $this->app['session'];
// Since this bootstrapper runs much earlier than the StartSession middleware, this doesn't execute
// on the average tenant request. It only executes when the context is switched *after* original
// middleware initialization.
if (isset($sessionManager->getDrivers()['file'])) {
$handler = new FileSessionHandler(
$this->app->make('files'),
$path,
$this->app['config']->get('session.lifetime'),
);
$sessionManager->getDrivers()['file']->setHandler($handler);
}
}
/** Get the central storage path from the bound singleton instance of this class. */
public static function getBoundCentralStoragePath(): string
{
return app(static::class)->originalStoragePath;
}
/**
* Get the storage path of the passed tenant (independent of the current context).
*
* Note that the returned path doesn't depend on suffix_storage_path.
* That config option only affects the storage_path() helper.
*/
public static function getBoundTenantStoragePath(Tenant $tenant): string
{
$bootstrapper = app(static::class);
return $bootstrapper->tenantStoragePath($bootstrapper->suffix($tenant));
}
/**
* Name of the disk whose root the passed disk uses.
*
* Disks using the 'scoped' driver have no root or url of their own -- they inherit these from their parent disk,
* which can be scoped as well, so only the final/base parent has to be tenant-aware.
*
* Returns null if the chain doesn't end with a named disk, i.e. when a parent disk is
* configured inline or when the disks reference each other.
*/
public static function baseDiskName(string $disk): string|null
{
// Keep track of visited disks to avoid infinite loops in case of disks referencing each other
$visited = [];
while (config("filesystems.disks.$disk.driver") === 'scoped') {
if (in_array($disk, $visited, true)) {
return null;
}
$visited[] = $disk;
if (! is_string($disk = config("filesystems.disks.$disk.disk"))) {
// Laravel allows configuring the parent disk inline as an array, and such a disk has no name
return null;
}
}
return $disk;
}
}