1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-09-20 14:14:03 +00:00
This commit is contained in:
lukinovec 2026-09-18 10:58:17 +02:00 committed by GitHub
commit 125825af06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 791 additions and 193 deletions

View file

@ -321,11 +321,12 @@ return [
/** /**
* Filesystem tenancy config. Used by FilesystemTenancyBootstrapper. * Filesystem tenancy config. Used by FilesystemTenancyBootstrapper.
* https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper. * https://v4.tenancyforlaravel.com/bootstrappers/filesystem
*/ */
'filesystem' => [ 'filesystem' => [
/** /**
* Each disk listed in the 'disks' array will be suffixed by the suffix_base, followed by the tenant_id. * Each disk listed in the 'disks' array will have its root
* suffixed by the suffix_base, followed by the tenant_id.
*/ */
'suffix_base' => 'tenant', 'suffix_base' => 'tenant',
'disks' => [ 'disks' => [
@ -337,10 +338,18 @@ return [
/** /**
* Use this for local disks. * Use this for local disks.
* *
* See https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper * Customizes how the disk's root is scoped, instead of the default behavior
* which simply appends the tenant suffix to the original root.
*
* The overrides can reference the following placeholders:
* - %storage_path% -- the tenant's storage directory
* - %original_storage_path% -- the central storage directory.
* - %tenant% -- the tenant key.
*
* See https://v4.tenancyforlaravel.com/bootstrappers/filesystem
*/ */
'root_override' => [ 'root_override' => [
// Disks whose roots should be overridden after storage_path() is suffixed. // Disks whose roots should be overridden in tenant context.
'local' => '%storage_path%/app/', 'local' => '%storage_path%/app/',
'public' => '%storage_path%/app/public/', 'public' => '%storage_path%/app/public/',
], ],
@ -357,7 +366,8 @@ return [
* Use `php artisan tenants:link` to create a symbolic link from the tenant's storage to its public directory. * Use `php artisan tenants:link` to create a symbolic link from the tenant's storage to its public directory.
*/ */
'url_override' => [ 'url_override' => [
// Note that the local disk you add must exist in the tenancy.filesystem.root_override config // Note that the local disk you add must exist in the tenancy.filesystem.disks config,
// and it must have a non-falsy root (not null nor an empty string).
'public' => 'public-%tenant%', 'public' => 'public-%tenant%',
], ],
@ -378,11 +388,7 @@ return [
/** /**
* Should storage_path() be suffixed. * Should storage_path() be suffixed.
* *
* Note: Disabling this will likely break local disk tenancy. Only disable this if you're using an external file storage service like S3. * Only affects the storage_path() helper, other features use the tenant storage directory regardless.
*
* For the vast majority of applications, this feature should be enabled. But in some
* edge cases, it can cause issues (like using Passport with Vapor - see #196), so
* you may want to disable this if you are experiencing these edge case issues.
*/ */
'suffix_storage_path' => true, 'suffix_storage_path' => true,

View file

@ -47,6 +47,12 @@ class CreateStorageSymlinksAction
mkdir($storagePath, 0777, true); mkdir($storagePath, 0777, true);
} }
// The public path of a prefixed disk includes the prefix,
// and its parent directories may not exist yet.
if (! is_dir($publicParent = dirname($publicPath))) {
mkdir($publicParent, 0777, true);
}
if ($relativeLink) { if ($relativeLink) {
app()->make('files')->relativeLink($storagePath, $publicPath); app()->make('files')->relativeLink($storagePath, $publicPath);
} else { } else {

View file

@ -15,6 +15,16 @@ class RemoveStorageSymlinksAction
{ {
use DealsWithTenantSymlinks; use DealsWithTenantSymlinks;
/**
* Should the directories created for nested symlinks be removed along with the symlink.
*
* Before enabling this, make sure you understand the removeLink() method
* and the high stakes of recursively removing parent directories (even if the logic should be sound).
*
* @see CreateStorageSymlinksAction
*/
public static bool $removeNestedDirectories = false;
/** /**
* @param Tenant|Collection<covariant int|string, Tenant&\Illuminate\Database\Eloquent\Model>|LazyCollection<covariant int|string, Tenant&\Illuminate\Database\Eloquent\Model> $tenants * @param Tenant|Collection<covariant int|string, Tenant&\Illuminate\Database\Eloquent\Model>|LazyCollection<covariant int|string, Tenant&\Illuminate\Database\Eloquent\Model> $tenants
*/ */
@ -32,12 +42,38 @@ class RemoveStorageSymlinksAction
protected function removeLink(string $publicPath, Tenant $tenant): void protected function removeLink(string $publicPath, Tenant $tenant): void
{ {
if ($this->symlinkExists($publicPath)) { if (! $this->symlinkExists($publicPath)) {
event(new RemovingStorageSymlink($tenant)); return;
}
app()->make('files')->delete($publicPath); $files = app()->make('files');
event(new StorageSymlinkRemoved($tenant)); event(new RemovingStorageSymlink($tenant));
$files->delete($publicPath);
event(new StorageSymlinkRemoved($tenant));
if (! static::$removeNestedDirectories) {
return;
}
$publicRoot = realpath(public_path());
$directory = realpath(dirname($publicPath));
if ($publicRoot === false || $directory === false) {
return;
}
// Remove the directories CreateStorageSymlinksAction created for the symlink
// until a non-empty one is reached.
while (str_starts_with($directory, rtrim($publicRoot, '/\\') . DIRECTORY_SEPARATOR) && $files->isEmptyDirectory($directory)) {
if (! @rmdir($directory)) {
// Stop the loop if the directory couldn't be removed
break;
}
$directory = dirname($directory);
} }
} }
} }

View file

@ -128,10 +128,14 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
$scopedDisks = []; $scopedDisks = [];
foreach ($this->app['config']['filesystems.disks'] as $name => $disk) { foreach ($this->app['config']['filesystems.disks'] as $name => $disk) {
if (isset($disk['driver'], $disk['disk']) if (($disk['driver'] ?? null) !== 'scoped') {
&& $disk['driver'] === 'scoped' continue;
&& in_array($disk['disk'], $tenantDisks, true)) { }
if (in_array(static::baseDiskName($name), $tenantDisks, true)) {
$scopedDisks[] = $name; $scopedDisks[] = $name;
} elseif (in_array($name, $tenantDisks, true)) {
throw new Exception("Disk [$name] uses the 'scoped' driver, so it has no root to make tenant-aware. List its base disk in tenancy.filesystem.disks instead.");
} }
} }
@ -140,6 +144,12 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
protected function diskRoot(string $disk, Tenant|false $tenant): void protected function diskRoot(string $disk, Tenant|false $tenant): void
{ {
if ($this->app['config']["filesystems.disks.$disk.driver"] === 'scoped') {
// Skip scoped disks since they have no root to override
// (reachable when a scoped disk is listed in tenancy.filesystem.disks alongside its base disk).
return;
}
if ($tenant === false) { if ($tenant === false) {
$this->app['config']["filesystems.disks.$disk.root"] = $this->originalDisks[$disk]['root']; $this->app['config']["filesystems.disks.$disk.root"] = $this->originalDisks[$disk]['root'];
@ -176,7 +186,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
{ {
$diskConfig = $this->app['config']["filesystems.disks.{$disk}"]; $diskConfig = $this->app['config']["filesystems.disks.{$disk}"];
if ($diskConfig['driver'] !== 'local' || $this->app['config']["tenancy.filesystem.url_override.{$disk}"] === null) { if ($diskConfig['driver'] !== 'local' || ! $this->app['config']["tenancy.filesystem.url_override.{$disk}"]) {
return; return;
} }
@ -327,4 +337,39 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper
{ {
return app(static::class)->originalStoragePath; 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 getTenantStoragePath(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 those 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 base disk has no name, i.e. when the disk is configured inline as an array.
*/
public static function baseDiskName(string $disk): string|null
{
while (config("filesystems.disks.$disk.driver") === 'scoped') {
if (! is_string($parent = config("filesystems.disks.$disk.disk"))) {
// Laravel allows configuring the parent disk inline as an array, and such a disk has no name
return null;
}
$disk = $parent;
}
return $disk;
}
} }

View file

@ -21,11 +21,8 @@ use Stancl\Tenancy\Contracts\Tenant;
* Laravel's 'single' and 'daily' channels by default. To customize it, * Laravel's 'single' and 'daily' channels by default. To customize it,
* see the property's docblock. * see the property's docblock.
* *
* For the storage path channels to be scoped correctly: * Note that since the tenant's storage path is resolved using FilesystemTenancyBootstrapper::getTenantStoragePath(),
* - this bootstrapper must run *after* FilesystemTenancyBootstrapper, * which is a public static method, FilesystemTenancyBootstrapper does not have to be enabled.
* since FilesystemTenancyBootstrapper adjusts storage_path() for the tenant
* - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path')
* must be true), since the storage path suffix is what separates filesystem-based logs
* *
* For logging channels that are not filesystem-based, see the $channelOverrides logic. * For logging channels that are not filesystem-based, see the $channelOverrides logic.
* *
@ -40,14 +37,10 @@ class LogChannelBootstrapper implements TenancyBootstrapper
/** /**
* Logging channels whose path is built using storage_path() (e.g. Laravel's 'single' and 'daily'). * Logging channels whose path is built using storage_path() (e.g. Laravel's 'single' and 'daily').
* *
* Channels included here will be configured to use tenant-specific storage paths * Channels included here will be configured to use tenant-specific storage paths.
* created using storage_path() in the tenant context. Overrides in the $channelOverrides
* property take precedence over $storagePathChannels when a channel is included in both.
* *
* Requires FilesystemTenancyBootstrapper to run before this bootstrapper, * Overrides in the $channelOverrides property take precedence over
* and storage path suffixing to be enabled. * $storagePathChannels when a channel is included in both.
*
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
*/ */
public static array $storagePathChannels = ['single', 'daily']; public static array $storagePathChannels = ['single', 'daily'];
@ -158,11 +151,12 @@ class LogChannelBootstrapper implements TenancyBootstrapper
// The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log". // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log".
$originalChannelPath = $this->config->get("logging.channels.{$channel}.path"); $originalChannelPath = $this->config->get("logging.channels.{$channel}.path");
$centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath(); $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath();
$tenantStoragePath = FilesystemTenancyBootstrapper::getTenantStoragePath($tenant);
// The tenant log will inherit the segment that follows the storage path from the central channel path config. // The tenant log will inherit the segment that follows the storage path from the central channel path config.
// For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log), // For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log),
// the 'logs/foo.log' segment will be passed to storage_path() in the tenant context (storage/tenant123/logs/foo.log). // the '/logs/foo.log' segment will be placed within the tenant storage path (so the log will be located at storage/tenant123/logs/foo.log).
$this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath))); $this->config->set("logging.channels.{$channel}.path", $tenantStoragePath . Str::after($originalChannelPath, rtrim($centralStoragePath, '/\\')));
} }
} }
} }

View file

@ -7,15 +7,23 @@ namespace Stancl\Tenancy\Concerns;
use Exception; use Exception;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
/**
* Requires FilesystemTenancyBootstrapper to be enabled, since the tenant symlinks
* point to the disk roots scoped by the bootstrapper.
*
* @see \Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
*/
trait DealsWithTenantSymlinks trait DealsWithTenantSymlinks
{ {
/** /**
* Get all possible tenant symlinks, existing or not (array of ['public path' => 'storage path']). * Get all possible tenant symlinks, existing or not (array of ['public path' => 'disk root with the disk's prefix appended']).
*
* Tenants can have a symlink for each local disk that is listed
* in both tenancy.filesystem.disks and tenancy.filesystem.url_override.
* *
* 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. * This is used for creating all possible tenant symlinks and removing all existing tenant symlinks.
* The same storage path can be symlinked to multiple public paths, which is why the public path * The same disk root can be symlinked to multiple public paths, which is why the public path
* is the Collection key. * is the array key.
* *
* @return array<string, string> * @return array<string, string>
*/ */
@ -23,20 +31,19 @@ trait DealsWithTenantSymlinks
{ {
$disks = config('filesystems.disks'); $disks = config('filesystems.disks');
$urlOverrides = config('tenancy.filesystem.url_override'); $urlOverrides = config('tenancy.filesystem.url_override');
$rootOverrides = config('tenancy.filesystem.root_override');
$tenantKey = $tenant->getTenantKey(); $tenantKey = $tenant->getTenantKey();
$tenantStoragePath = tenancy()->run($tenant, fn () => storage_path()); $tenantDisks = tenancy()->run($tenant, fn () => config('filesystems.disks'));
/** @var array<string, string> $symlinks */ /** @var array<string, string> $symlinks */
$symlinks = []; $symlinks = [];
foreach ($urlOverrides as $disk => $publicPath) { foreach ($urlOverrides as $disk => $publicPath) {
if (! isset($disks[$disk])) { if (! $publicPath) {
continue; continue;
} }
if (! isset($rootOverrides[$disk])) { if (! isset($disks[$disk])) {
continue; continue;
} }
@ -44,10 +51,25 @@ trait DealsWithTenantSymlinks
throw new Exception("Disk $disk is not a local disk. Only local disks can be symlinked."); throw new Exception("Disk $disk is not a local disk. Only local disks can be symlinked.");
} }
$publicPath = str_replace('%tenant%', (string) $tenantKey, $publicPath); if (! in_array($disk, config('tenancy.filesystem.disks'), true)) {
$storagePath = str_replace('%storage_path%', $tenantStoragePath, $rootOverrides[$disk]); // The bootstrapper only scopes disks listed in tenancy.filesystem.disks.
// Without that, the root stays central and the symlink of every tenant would point to it.
throw new Exception("Disk $disk is not tenant-aware. Add it to the tenancy.filesystem.disks config to make its root tenant-specific.");
}
$symlinks[public_path($publicPath)] = $storagePath; $publicPath = str_replace('%tenant%', (string) $tenantKey, $publicPath);
$diskRoot = $tenantDisks[$disk]['root'];
if ($prefix = trim($disks[$disk]['prefix'] ?? '', '/\\')) {
// Append the disk's prefix to the disk root
$diskRoot = rtrim($diskRoot, '/\\') . DIRECTORY_SEPARATOR . $prefix;
// Append the same prefix to the public path.
// Storage::url() appends the disk's prefix to the url, so the prefix has to be in the public path as well.
$publicPath .= DIRECTORY_SEPARATOR . $prefix;
}
$symlinks[public_path($publicPath)] = $diskRoot;
} }
return $symlinks; return $symlinks;

View file

@ -6,12 +6,25 @@ namespace Stancl\Tenancy\Controllers;
use Closure; use Closure;
use Exception; use Exception;
use Illuminate\Filesystem\LocalFilesystemAdapter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware; use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware; use Illuminate\Routing\Controllers\Middleware;
use Illuminate\Support\Facades\Storage;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Throwable; use Throwable;
/**
* Serves files from app/public inside the tenant's storage directory, or from the root
* of the $publicDisk when the property is set.
*
* Requires FilesystemTenancyBootstrapper to be enabled, so that writes to the default
* public disk end up in the app/public directory within the *tenant's* storage, or so that the
* public disk set in the static property is similarly scoped.
*
* @see FilesystemTenancyBootstrapper
*/
class TenantAssetController implements HasMiddleware class TenantAssetController implements HasMiddleware
{ {
/** /**
@ -28,6 +41,19 @@ class TenantAssetController implements HasMiddleware
*/ */
public static array $middleware = []; public static array $middleware = [];
/**
* Disk the assets are served from.
*
* When null, the assets are served from app/public inside the tenant's storage directory.
*
* The disk has to be local, since the assets are read from the filesystem. Disks using the
* 'scoped' driver are supported as long as the disk they're based on uses the 'local' driver.
*
* The disk also has to be listed in tenancy.filesystem.disks -- for scoped disks, it's the
* disk they're based on that has to be listed there (since a scoped disk inherits its root).
*/
public static string|null $publicDisk = null;
public static function middleware() public static function middleware()
{ {
return array_map( return array_map(
@ -51,12 +77,53 @@ class TenantAssetController implements HasMiddleware
? (static::$headers)($request) ? (static::$headers)($request)
: static::$headers; : static::$headers;
return response()->file(storage_path("app/public/$path"), $headers); return response()->file($this->assetRoot() . "/$path", $headers);
} catch (Throwable) { } catch (Throwable) {
abort(404); abort(404);
} }
} }
/**
* Directory the assets are served from -- the root of the $publicDisk, or app/public
* inside the tenant's storage directory when no disk is configured.
*
* The tenant's storage directory is resolved using the FilesystemTenancyBootstrapper::getTenantStoragePath().
*/
protected function assetRoot(): string
{
if (! tenant()) {
throw new Exception('Tenant assets can only be served in tenant context.');
}
if (static::$publicDisk) {
$disk = Storage::disk(static::$publicDisk);
if (! $disk instanceof LocalFilesystemAdapter) {
throw new Exception('Disk [' . static::$publicDisk . '] is not a local disk. Only local disks can be used for serving assets.');
}
$baseDiskName = FilesystemTenancyBootstrapper::baseDiskName(static::$publicDisk);
if ($baseDiskName === null) {
throw new Exception('Disk [' . static::$publicDisk . '] has an unnamed parent disk. Use a named parent disk listed in tenancy.filesystem.disks.');
}
if (! in_array($baseDiskName, config('tenancy.filesystem.disks'), true)) {
// FilesystemTenancyBootstrapper only scopes the roots of disks listed in tenancy.filesystem.disks.
// Without that, the root stays central and every tenant would be served the same directory.
throw new Exception("Disk [$baseDiskName] is not tenant-aware. Add it to the tenancy.filesystem.disks config to make its root tenant-specific.");
}
// The full path is read from the resolved disk rather than from the disk's configured 'root',
// since the 'root' doesn't have to be the full path of every local disk:
// - disks using the 'scoped' driver have no 'root' in their config -- they inherit it from the parent disk
// - a disk's configured 'prefix' is a part of the full path as well.
return rtrim($disk->path(''), DIRECTORY_SEPARATOR);
}
return FilesystemTenancyBootstrapper::getTenantStoragePath(tenant()) . '/app/public';
}
/** /**
* Prevent path traversal attacks. This is generally a non-issue on modern * Prevent path traversal attacks. This is generally a non-issue on modern
* webservers but it's still worth handling on the application level as well. * webservers but it's still worth handling on the application level as well.
@ -67,18 +134,21 @@ class TenantAssetController implements HasMiddleware
{ {
$this->abortIf($path === null, 'Empty path'); $this->abortIf($path === null, 'Empty path');
$allowedRoot = realpath(storage_path('app/public')); $allowedRoot = realpath($this->assetRoot());
// `storage_path('app/public')` doesn't exist, so it cannot contain files // The asset root doesn't exist, so it cannot contain files
$this->abortIf($allowedRoot === false, "Storage root doesn't exist"); $this->abortIf($allowedRoot === false, "Storage root doesn't exist");
// realpath() ensures the directory exists and converts / to \ on Windows
$attemptedPath = realpath("{$allowedRoot}/{$path}"); $attemptedPath = realpath("{$allowedRoot}/{$path}");
// User is attempting to access a nonexistent file // User is attempting to access a nonexistent file
$this->abortIf($attemptedPath === false, 'Accessing a nonexistent file'); $this->abortIf($attemptedPath === false, 'Accessing a nonexistent file');
// User is attempting to access a file outside the $allowedRoot folder // User is attempting to access a file outside the $allowedRoot folder.
$this->abortIf(! str($attemptedPath)->startsWith($allowedRoot), 'Accessing a file outside the storage root'); // The trailing separator is needed so that sibling directories that
// start with the same name (e.g. app/public-private) aren't accepted.
$this->abortIf(! str($attemptedPath)->startsWith(rtrim($allowedRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR), 'Accessing a file outside the storage root');
} }
/** @return void|never */ /** @return void|never */

View file

@ -10,8 +10,20 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
/**
* Delete the tenant's storage directory.
*
* The directory is used by the FilesystemTenancyBootstrapper for:
* - scoped storage_path() when suffix_storage_path is enabled
* - scoped cache when enabled
* - scoped sessions when enabled
* - scoped disks when enabled
*
* @see FilesystemTenancyBootstrapper
*/
class DeleteTenantStorage implements ShouldQueue class DeleteTenantStorage implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@ -22,17 +34,11 @@ class DeleteTenantStorage implements ShouldQueue
public function handle(): void public function handle(): void
{ {
if (config('tenancy.filesystem.suffix_storage_path') === false) { $tenantStoragePath = FilesystemTenancyBootstrapper::getTenantStoragePath($this->tenant);
// Skip storage deletion if path suffixing is disabled $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath();
return;
}
$centralStoragePath = tenancy()->central(fn () => storage_path()); if (realpath($tenantStoragePath) === realpath($centralStoragePath)) {
$tenantStoragePath = tenancy()->run($this->tenant, fn () => storage_path()); // Never delete the central storage directory
if ($tenantStoragePath === $centralStoragePath) {
// Check again to ensure the tenant storage path is distinct from the central storage path
// to avoid any accidental central storage path deletion
return; return;
} }

View file

@ -4,6 +4,10 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\StorageSymlinkCreated;
use Stancl\Tenancy\Events\StorageSymlinkRemoved;
use Stancl\Tenancy\Events\CreatingStorageSymlink;
use Stancl\Tenancy\Events\RemovingStorageSymlink;
use Stancl\Tenancy\Database\Models\Tenant; use Stancl\Tenancy\Database\Models\Tenant;
use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
@ -12,20 +16,33 @@ use Stancl\Tenancy\Actions\CreateStorageSymlinksAction;
use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction; use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
beforeEach(function () { beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class); Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
RemoveStorageSymlinksAction::$removeNestedDirectories = false;
}); });
test('create storage symlinks action works', function() { afterEach(function () {
RemoveStorageSymlinksAction::$removeNestedDirectories = false;
});
test('create storage symlinks action works', function (string|null $rootOverride, bool $suffixStoragePath) {
config([ config([
'tenancy.bootstrappers' => [ 'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class, FilesystemTenancyBootstrapper::class,
], ],
'tenancy.filesystem.suffix_base' => 'tenant-', 'tenancy.filesystem.suffix_base' => 'tenant-',
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/', // The disk root is suffixed regardless of the suffix_storage_path config
'tenancy.filesystem.url_override.public' => 'public-%tenant%' 'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath,
'tenancy.filesystem.root_override.public' => $rootOverride,
'tenancy.filesystem.url_override' => [
'public' => 'public-%tenant%',
// Disks with a falsy url_override are skipped
'local' => '',
],
]); ]);
/** @var Tenant $tenant */ /** @var Tenant $tenant */
@ -38,12 +55,50 @@ test('create storage symlinks action works', function() {
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeFalse(); expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeFalse();
expect(file_exists($publicPath))->toBeFalse(); expect(file_exists($publicPath))->toBeFalse();
Storage::disk('public')->put('foo.txt', 'tenant file');
Event::fake([CreatingStorageSymlink::class, StorageSymlinkCreated::class]);
(new CreateStorageSymlinksAction)($tenant); (new CreateStorageSymlinksAction)($tenant);
// The symlink exists and is valid Event::assertDispatched(CreatingStorageSymlink::class, fn (CreatingStorageSymlink $event) => $event->tenant->is($tenant));
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue(); Event::assertDispatched(StorageSymlinkCreated::class, fn (StorageSymlinkCreated $event) => $event->tenant->is($tenant));
expect(file_exists($publicPath))->toBeTrue();
$this->assertEquals(storage_path("app/public/"), readlink($publicPath)); // The symlink exists and points to the directory the tenant's disk writes to
expect(is_link($publicPath))->toBeTrue();
expect(readlink($publicPath))->toBe(config('filesystems.disks.public.root'));
expect(file_get_contents($publicPath . '/foo.txt'))->toBe('tenant file');
// The local disk is skipped because its url_override is '' -- no symlink is created at public_path('')
expect(is_link(public_path('')))->toBeFalse();
})->with([
'default root_override' => ['%storage_path%/app/public/', true],
'suffix_storage_path disabled' => ['%storage_path%/app/public/', false],
'custom root_override' => ['%original_storage_path%/app/public/%tenant%/', true],
// Without a root_override, the bootstrapper suffixes the disk's central root
'no root_override' => [null, true],
]);
test('create storage symlinks action fails for disks that are not tenant-aware', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
// The public disk has both overrides configured, but it is not in
// tenancy.filesystem.disks, so the bootstrapper never scopes its root.
// Symlinking it would point every tenant's public path to the central disk root.
'tenancy.filesystem.disks' => ['local'],
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant%',
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
expect(fn () => (new CreateStorageSymlinksAction)($tenant))
->toThrow(Exception::class, 'Disk public is not tenant-aware.');
expect(is_link(public_path('public-' . $tenant->getTenantKey())))->toBeFalse();
}); });
test('remove storage symlinks action works', function() { test('remove storage symlinks action works', function() {
@ -68,11 +123,25 @@ test('remove storage symlinks action works', function() {
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue(); expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue();
expect(file_exists($publicPath))->toBeTrue(); expect(file_exists($publicPath))->toBeTrue();
Event::fake([RemovingStorageSymlink::class, StorageSymlinkRemoved::class]);
(new RemoveStorageSymlinksAction)($tenant); (new RemoveStorageSymlinksAction)($tenant);
Event::assertDispatched(RemovingStorageSymlink::class, fn (RemovingStorageSymlink $event) => $event->tenant->is($tenant));
Event::assertDispatched(StorageSymlinkRemoved::class, fn (StorageSymlinkRemoved $event) => $event->tenant->is($tenant));
// The symlink doesn't exist // The symlink doesn't exist
expect(is_link($publicPath))->toBeFalse(); expect(is_link($publicPath))->toBeFalse();
expect(file_exists($publicPath))->toBeFalse(); expect(file_exists($publicPath))->toBeFalse();
// Flush
Event::fake([RemovingStorageSymlink::class, StorageSymlinkRemoved::class]);
// Nothing happens when there are no symlinks
(new RemoveStorageSymlinksAction)($tenant);
Event::assertNotDispatched(RemovingStorageSymlink::class);
Event::assertNotDispatched(StorageSymlinkRemoved::class);
}); });
test('removing tenant symlinks works even if the symlinks are invalid', function() { test('removing tenant symlinks works even if the symlinks are invalid', function() {
@ -109,3 +178,123 @@ test('removing tenant symlinks works even if the symlinks are invalid', function
expect(is_link($publicPath))->toBeFalse(); expect(is_link($publicPath))->toBeFalse();
}); });
test('disks with a prefix are symlinked correctly', function (string $prefix) {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_base' => 'tenant-',
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant%',
'filesystems.disks.public.prefix' => $prefix,
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
// possibleTenantSymlinks() trims the prefix, do the same here for the assertions to be accurate
$prefix = trim($prefix, '/');
tenancy()->initialize($tenant);
Storage::disk('public')->put('foo.txt', 'tenant file');
(new CreateStorageSymlinksAction)($tenant);
expect(Storage::disk('public')->url('foo.txt'))->toBe("http://localhost/public-{$tenantKey}/{$prefix}/foo.txt");
expect(readlink(public_path("public-{$tenantKey}/{$prefix}")))->toBe(storage_path("app/public/{$prefix}"));
expect(file_get_contents(public_path("public-{$tenantKey}/{$prefix}/foo.txt")))->toBe('tenant file');
})->with(['abc', 'abc/def', '/abc/def/']);
test('symlinks of prefixed disks only expose the prefixed directory', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant%',
'filesystems.disks.public.prefix' => 'abc',
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
tenancy()->initialize($tenant);
Storage::disk('public')->put('foo.txt', 'tenant file');
// The disk cannot reach this file, neither should the symlink
File::put(storage_path('app/public/sibling.txt'), 'file next to the prefixed directory');
(new CreateStorageSymlinksAction)($tenant);
expect(file_exists(public_path("public-{$tenantKey}/sibling.txt")))->toBeFalse();
expect(file_get_contents(public_path("public-{$tenantKey}/abc/foo.txt")))->toBe('tenant file');
});
test('removing a prefixed disk symlink removes the directories created for it', function () {
RemoveStorageSymlinksAction::$removeNestedDirectories = true;
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant%',
'filesystems.disks.public.prefix' => 'abc/def',
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
tenancy()->initialize($tenant);
Storage::disk('public')->put('foo.txt', 'tenant file');
(new CreateStorageSymlinksAction)($tenant);
$symlink = public_path("public-{$tenantKey}/abc/def");
$diskRoot = readlink($symlink);
(new RemoveStorageSymlinksAction)($tenant);
// The symlink and every directory created for it are deleted
expect(is_link($symlink))->toBeFalse();
expect(file_exists(public_path("public-{$tenantKey}")))->toBeFalse();
// public_path() itself is not deleted
expect(is_dir(public_path()))->toBeTrue();
// The directory that the symlink points to is untouched
expect(file_get_contents($diskRoot . '/foo.txt'))->toBe('tenant file');
});
test('non-empty directories are not removed with the symlink', function () {
RemoveStorageSymlinksAction::$removeNestedDirectories = true;
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant%',
'filesystems.disks.public.prefix' => 'abc/def',
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
tenancy()->initialize($tenant);
(new CreateStorageSymlinksAction)($tenant);
File::put(public_path("public-{$tenantKey}/abc/actual-file.txt"), 'foo');
(new RemoveStorageSymlinksAction)($tenant);
expect(is_link(public_path("public-{$tenantKey}/abc/def")))->toBeFalse();
expect(file_get_contents(public_path("public-{$tenantKey}/abc/actual-file.txt")))->toBe('foo');
});

View file

@ -146,6 +146,26 @@ test('links to storage disks with a configured root are suffixed if not overridd
expect(storage_path())->toEqual($expectedStoragePath); expect(storage_path())->toEqual($expectedStoragePath);
}); });
test('disks with a falsy url_override do not get their url overridden', function ($urlOverride) {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.url_override.public' => $urlOverride,
]);
$tenant = Tenant::create();
$centralUrl = config('filesystems.disks.public.url');
tenancy()->initialize($tenant);
expect(config('filesystems.disks.public.url'))->toBe($centralUrl);
})->with([
'empty string' => [''],
'null' => [null],
]);
test('create and delete storage symlinks jobs work', function() { test('create and delete storage symlinks jobs work', function() {
Event::listen( Event::listen(
TenantCreated::class, TenantCreated::class,
@ -185,63 +205,55 @@ test('create and delete storage symlinks jobs work', function() {
$this->assertDirectoryDoesNotExist(public_path("public-$tenantKey")); $this->assertDirectoryDoesNotExist(public_path("public-$tenantKey"));
}); });
test('tenant storage gets deleted during tenant deletion when the DeletingTenant pipeline contains DeleteTenantStorage', function() { test('tenant storage gets deleted during tenant deletion when the DeletingTenant pipeline contains DeleteTenantStorage', function (bool $bootstrapperEnabled) {
Event::listen(DeletingTenant::class, Event::listen(DeletingTenant::class,
JobPipeline::make([DeleteTenantStorage::class])->send(function (DeletingTenant $event) { JobPipeline::make([DeleteTenantStorage::class])->send(function (DeletingTenant $event) {
return $event->tenant; return $event->tenant;
})->shouldBeQueued(false)->toListener() })->shouldBeQueued(false)->toListener()
); );
config([
'tenancy.bootstrappers' => $bootstrapperEnabled ? [FilesystemTenancyBootstrapper::class] : [],
]);
$centralStoragePath = storage_path(); $centralStoragePath = storage_path();
tenancy()->initialize(Tenant::create()); $tenantStoragePath = fn (Tenant $tenant) => $centralStoragePath . "/tenant{$tenant->getTenantKey()}";
// FilesystemTenancyBootstrapper not enabled, $tenant = Tenant::create();
// tenant and central storage path is the same,
// the storage deletion will be skipped. File::ensureDirectoryExists($tenantStoragePath($tenant));
$tenantStoragePath = storage_path();
expect($tenantStoragePath)->toBe($centralStoragePath);
expect(File::isDirectory($centralStoragePath))->toBeTrue();
tenant()->delete();
expect(File::isDirectory($centralStoragePath))->toBeTrue(); expect(File::isDirectory($centralStoragePath))->toBeTrue();
expect(File::isDirectory($tenantStoragePath($tenant)))->toBeTrue();
config([ $tenant->delete();
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
'tenancy.filesystem.suffix_storage_path' => false,
]);
tenancy()->initialize(Tenant::create());
$tenantStoragePath = storage_path();
// FilesystemTenancyBootstrapper enabled,
// but tenant and central storage path is still the same
// because suffix_storage_path is false.
// The storage deletion will be skipped.
expect($tenantStoragePath)->toBe($centralStoragePath);
expect(File::isDirectory($centralStoragePath))->toBeTrue();
tenant()->delete();
expect(File::isDirectory($centralStoragePath))->toBeTrue(); expect(File::isDirectory($centralStoragePath))->toBeTrue();
expect(File::isDirectory($tenantStoragePath($tenant)))->toBeFalse();
})->with([
'filesystem bootstrapper enabled' => true,
'filesystem bootstrapper disabled' => false,
]);
config([ test('DeleteTenantStorage never deletes the central storage directory', function () {
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], $tenant = Tenant::create();
'tenancy.filesystem.suffix_storage_path' => true,
]);
tenancy()->initialize(Tenant::create()); $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath();
$tenantStoragePath = storage_path(); $tenantStoragePath = FilesystemTenancyBootstrapper::getTenantStoragePath($tenant);
// FilesystemTenancyBootstrapper enabled, File::ensureDirectoryExists($centralStoragePath . '/app');
// suffix_storage_path enabled, so the two paths are distinct.
// Tenant storage will be deleted.
expect($tenantStoragePath)->not()->toBe($centralStoragePath);
expect(File::isDirectory($tenantStoragePath))->toBeTrue();
tenant()->delete(); // Make the tenant storage path a symlink to the central storage directory
File::deleteDirectory($tenantStoragePath);
symlink($centralStoragePath, $tenantStoragePath);
expect(realpath($tenantStoragePath))->toBe(realpath($centralStoragePath));
(new DeleteTenantStorage($tenant))->handle();
expect(File::isDirectory($tenantStoragePath))->toBeFalse();
expect(File::isDirectory($centralStoragePath))->toBeTrue(); expect(File::isDirectory($centralStoragePath))->toBeTrue();
expect(File::isDirectory($centralStoragePath . '/app'))->toBeTrue();
expect(is_link($tenantStoragePath))->toBeTrue();
}); });
test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) { test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) {
@ -275,29 +287,106 @@ test('scoped disks are scoped per tenant', function () {
'disk' => 'public', 'disk' => 'public',
'prefix' => 'scoped_disk_prefix', 'prefix' => 'scoped_disk_prefix',
], ],
'filesystems.disks.nested_disk' => [
'driver' => 'scoped',
'disk' => 'scoped_disk',
'prefix' => 'nested_disk_prefix',
],
]); ]);
foreach (['scoped_disk' => '', 'nested_disk' => '/nested_disk_prefix'] as $disk => $nested_prefix) {
$path = "app/public/scoped_disk_prefix{$nested_prefix}/foo.txt";
$tenant = Tenant::create();
$centralFile = storage_path($path);
$tenantFile = storage_path("tenant{$tenant->id}/$path");
Storage::disk($disk)->put('foo.txt', 'central');
expect(file_get_contents($centralFile))->toBe('central');
tenancy()->initialize($tenant);
expect(Storage::disk($disk)->get('foo.txt'))->toBeNull();
Storage::disk($disk)->put('foo.txt', 'tenant');
expect(file_get_contents($tenantFile))->toBe('tenant');
tenancy()->end();
expect(Storage::disk($disk)->get('foo.txt'))->toBe('central');
expect(file_get_contents($centralFile))->toBe('central');
expect(file_get_contents($tenantFile))->toBe('tenant');
}
});
test('scoped disks based on a non-local disk are scoped per tenant', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'filesystems.disks.scoped_s3' => [
'driver' => 'scoped',
'disk' => 's3',
'prefix' => 'scoped_s3_prefix',
],
'tenancy.filesystem.disks' => ['s3'], // As long as the base disk (s3) is listed here, the scoped disk will be scoped
]);
expect(Storage::disk('s3')->path('foo.txt'))->toBe('foo.txt');
expect(Storage::disk('scoped_s3')->path('foo.txt'))->toBe('scoped_s3_prefix/foo.txt');
$tenant = Tenant::create(); $tenant = Tenant::create();
Storage::disk('scoped_disk')->put('foo.txt', 'central');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central');
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe(null); expect(Storage::disk('s3')->path('foo.txt'))->toBe("tenant{$tenant->id}/foo.txt");
Storage::disk('scoped_disk')->put('foo.txt', 'tenant'); expect(Storage::disk('scoped_s3')->path('foo.txt'))->toBe("tenant{$tenant->id}/scoped_s3_prefix/foo.txt");
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant');
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('tenant');
tenancy()->end(); tenancy()->end();
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central'); expect(Storage::disk('scoped_s3')->path('foo.txt'))->toBe('scoped_s3_prefix/foo.txt');
Storage::disk('scoped_disk')->put('foo.txt', 'central2'); });
expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central2');
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central2'); test('adding a scoped disk to tenancy.filesystem.disks throws an exception if its base disk is not listed', function () {
expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant'); config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'filesystems.disks.foo' => [
'driver' => 'scoped',
'disk' => 'public',
'prefix' => 'foo',
],
'filesystems.disks.bar' => [
'driver' => 'scoped',
'disk' => 'foo',
'prefix' => 'bar',
],
// There's no way for a scoped disk with an array parent to have the parent listed in tenancy.filesystem.disks
'filesystems.disks.inline_base' => [
'driver' => 'scoped',
'disk' => [
'driver' => 'local',
'root' => storage_path('app/inline'),
],
'prefix' => 'inline_base',
],
]);
$initializeTenancy = fn () => tenancy()->initialize(Tenant::create());
config(['tenancy.filesystem.disks' => ['foo']]);
expect($initializeTenancy)->toThrow(Exception::class, "Disk [foo] uses the 'scoped' driver, so it has no root to make tenant-aware.");
config(['tenancy.filesystem.disks' => ['bar']]);
expect($initializeTenancy)->toThrow(Exception::class, "Disk [bar] uses the 'scoped' driver, so it has no root to make tenant-aware.");
config(['tenancy.filesystem.disks' => ['inline_base']]);
expect($initializeTenancy)->toThrow(Exception::class, "Disk [inline_base] uses the 'scoped' driver, so it has no root to make tenant-aware.");
config(['tenancy.filesystem.disks' => ['public', 'foo', 'bar']]);
expect($initializeTenancy)->not()->toThrow(Throwable::class);
// No way to make the 'inline_base' disk work
}); });
test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () { test('file cache stores get their paths scoped on bootstrap and restored back on revert', function () {

View file

@ -9,7 +9,6 @@ use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\LogChannelBootstrapper; use Stancl\Tenancy\Bootstrappers\LogChannelBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
afterEach($cleanup = function () { afterEach($cleanup = function () {
@ -42,15 +41,6 @@ beforeEach(function () use ($cleanup) {
}); });
test('storage path channels get tenant-specific paths by default', function () { test('storage path channels get tenant-specific paths by default', function () {
// Note that for LogChannelBootstrapper to change the paths correctly by default,
// the bootstrapper MUST run after FilesystemTenancyBootstrapper.
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
]);
$centralStoragePath = storage_path(); $centralStoragePath = storage_path();
$tenant = Tenant::create(); $tenant = Tenant::create();
@ -76,10 +66,6 @@ test('storage path channels get tenant-specific paths by default', function () {
test('all channels included in a stack get processed correctly', function () { test('all channels included in a stack get processed correctly', function () {
config([ config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
'logging.channels.stack' => [ 'logging.channels.stack' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => ['single', 'daily'], 'channels' => ['single', 'daily'],
@ -176,30 +162,24 @@ test('channel config keys remain unchanged if the specified tenant override attr
}); });
test('channel overrides take precedence over the default storage path channel updating logic', function () { test('channel overrides take precedence over the default storage path channel updating logic', function () {
$centralStoragePath = storage_path();
$tenant = Tenant::create(['id' => 'tenant1']); $tenant = Tenant::create(['id' => 'tenant1']);
LogChannelBootstrapper::$storagePathChannels = ['single']; LogChannelBootstrapper::$storagePathChannels = ['single'];
LogChannelBootstrapper::$channelOverrides = [ LogChannelBootstrapper::$channelOverrides = [
'single' => function (Tenant $tenant, array $channel) { 'single' => function (Tenant $tenant, array $channel) use ($centralStoragePath) {
return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]); return array_merge($channel, ['path' => "{$centralStoragePath}/logs/override-{$tenant->id}.log"]);
}, },
]; ];
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
// Should use channel override, not the storage path updating behavior // Should use channel override, not the storage path updating behavior
expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); expect(config('logging.channels.single.path'))->toBe("{$centralStoragePath}/logs/override-tenant1.log");
}); });
test('channels are forgotten and re-resolved during bootstrap and revert', function () { test('channels are forgotten and re-resolved during bootstrap and revert', function () {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
]);
$logManager = app('log'); $logManager = app('log');
$originalChannel = $logManager->channel('single'); $originalChannel = $logManager->channel('single');
$originalSinglePath = config('logging.channels.single.path'); $originalSinglePath = config('logging.channels.single.path');
@ -229,14 +209,8 @@ test('channels are forgotten and re-resolved during bootstrap and revert', funct
// Test real usage // Test real usage
test('logs are written to tenant-specific files and do not leak between contexts', function () { test('logs are written to tenant-specific files and do not leak between contexts', function () {
config([ $centralStoragePath = storage_path();
'tenancy.bootstrappers' => [ $centralLogPath = "{$centralStoragePath}/logs/laravel.log";
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
]);
$centralLogPath = storage_path('logs/laravel.log');
Log::channel('single')->info('central'); Log::channel('single')->info('central');
@ -244,15 +218,11 @@ test('logs are written to tenant-specific files and do not leak between contexts
[$tenant1, $tenant2] = [Tenant::create(['id' => 'tenant1']), Tenant::create(['id' => 'tenant2'])]; [$tenant1, $tenant2] = [Tenant::create(['id' => 'tenant1']), Tenant::create(['id' => 'tenant2'])];
tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralLogPath) { tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralStoragePath) {
Log::channel('single')->info($tenant->id); Log::channel('single')->info($tenant->id);
$tenantLogPath = storage_path('logs/laravel.log');
// The log gets saved to the tenant's storage directory (default behavior) // The log gets saved to the tenant's storage directory (default behavior)
expect($tenantLogPath) $tenantLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log";
->not()->toBe($centralLogPath)
->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log");
expect(file_get_contents($tenantLogPath)) expect(file_get_contents($tenantLogPath))
->toContain($tenant->id) ->toContain($tenant->id)
@ -268,14 +238,14 @@ test('logs are written to tenant-specific files and do not leak between contexts
// Tenant log messages didn't leak to logs of other tenants // Tenant log messages didn't leak to logs of other tenants
tenancy()->initialize($tenant1); tenancy()->initialize($tenant1);
expect(file_get_contents(storage_path('logs/laravel.log'))) expect(file_get_contents("{$centralStoragePath}/tenant{$tenant1->id}/logs/laravel.log"))
->toContain('tenant1') ->toContain('tenant1')
->not()->toContain('central') ->not()->toContain('central')
->not()->toContain('tenant2'); ->not()->toContain('tenant2');
tenancy()->initialize($tenant2); tenancy()->initialize($tenant2);
expect(file_get_contents(storage_path('logs/laravel.log'))) expect(file_get_contents("{$centralStoragePath}/tenant{$tenant2->id}/logs/laravel.log"))
->toContain('tenant2') ->toContain('tenant2')
->not()->toContain('central') ->not()->toContain('central')
->not()->toContain('tenant1'); ->not()->toContain('tenant1');
@ -285,9 +255,8 @@ test('logs are written to tenant-specific files and do not leak between contexts
$tenant = Tenant::create(['id' => 'override-tenant']); $tenant = Tenant::create(['id' => 'override-tenant']);
LogChannelBootstrapper::$channelOverrides = [ LogChannelBootstrapper::$channelOverrides = [
'single' => function (Tenant $tenant, array $channel) { 'single' => function (Tenant $tenant, array $channel) use ($centralStoragePath) {
// The tenant log path will be set to storage/tenantoverride-tenant/logs/custom-override-tenant.log return array_merge($channel, ['path' => "{$centralStoragePath}/tenant{$tenant->id}/logs/custom-{$tenant->id}.log"]);
return array_merge($channel, ['path' => storage_path("logs/custom-{$tenant->id}.log")]);
}, },
]; ];
@ -296,21 +265,18 @@ test('logs are written to tenant-specific files and do not leak between contexts
Log::channel('single')->info('tenant-override'); Log::channel('single')->info('tenant-override');
expect(file_get_contents(storage_path('logs/custom-override-tenant.log')))->toContain('tenant-override'); expect(file_get_contents("{$centralStoragePath}/tenantoverride-tenant/logs/custom-override-tenant.log"))->toContain('tenant-override');
}); });
test('stack logs are written to all configured channels with tenant-specific paths', function () { test('stack logs are written to all configured channels with tenant-specific paths', function () {
config([ config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
'logging.channels.stack' => [ 'logging.channels.stack' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => ['single', 'daily'], 'channels' => ['single', 'daily'],
], ],
]); ]);
$centralStoragePath = storage_path();
$tenant = Tenant::create(['id' => 'stack-tenant']); $tenant = Tenant::create(['id' => 'stack-tenant']);
$today = now()->format('Y-m-d'); $today = now()->format('Y-m-d');
@ -327,8 +293,8 @@ test('stack logs are written to all configured channels with tenant-specific pat
// Tenant context stack log // Tenant context stack log
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
Log::channel('stack')->info('tenant'); Log::channel('stack')->info('tenant');
$tenantSingleLogPath = storage_path('logs/laravel.log'); $tenantSingleLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log";
$tenantDailyLogPath = storage_path("logs/laravel-{$today}.log"); $tenantDailyLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel-{$today}.log";
expect(file_get_contents($tenantSingleLogPath))->toContain('tenant'); expect(file_get_contents($tenantSingleLogPath))->toContain('tenant');
expect(file_get_contents($tenantDailyLogPath))->toContain('tenant'); expect(file_get_contents($tenantDailyLogPath))->toContain('tenant');
@ -351,18 +317,15 @@ test('stack logs are written to all configured channels with tenant-specific pat
test('stack channels that include any configured channel are re-resolved', function () { test('stack channels that include any configured channel are re-resolved', function () {
config([ config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
'logging.channels.custom_stack' => [ 'logging.channels.custom_stack' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => ['single'], 'channels' => ['single'],
], ],
]); ]);
$centralStoragePath = storage_path();
$tenant = Tenant::create(['id' => 'stack-tenant']); $tenant = Tenant::create(['id' => 'stack-tenant']);
$centralLogPath = storage_path('logs/laravel.log'); $centralLogPath = "{$centralStoragePath}/logs/laravel.log";
$logManager = app('log'); $logManager = app('log');
@ -387,7 +350,7 @@ test('stack channels that include any configured channel are re-resolved', funct
->toContain('central log message') ->toContain('central log message')
->not()->toContain('tenant log message'); ->not()->toContain('tenant log message');
$tenantLogPath = storage_path('logs/laravel.log'); $tenantLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log";
expect(file_exists($tenantLogPath))->toBeTrue(); expect(file_exists($tenantLogPath))->toBeTrue();
expect(file_get_contents($tenantLogPath)) expect(file_get_contents($tenantLogPath))
->toContain('tenant log message'); ->toContain('tenant log message');
@ -454,10 +417,6 @@ test('slack channel uses correct webhook urls', function () {
test('tenant logs inherit the path from the central log path config', function () { test('tenant logs inherit the path from the central log path config', function () {
config([ config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
LogChannelBootstrapper::class,
],
'logging.channels.stack' => [ 'logging.channels.stack' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => ['single', 'daily'], 'channels' => ['single', 'daily'],
@ -466,6 +425,7 @@ test('tenant logs inherit the path from the central log path config', function (
'logging.channels.daily.path' => storage_path('logs/daily/custom-name.log'), 'logging.channels.daily.path' => storage_path('logs/daily/custom-name.log'),
]); ]);
$centralStoragePath = storage_path();
$tenant = Tenant::create(); $tenant = Tenant::create();
$today = now()->format('Y-m-d'); $today = now()->format('Y-m-d');
@ -476,18 +436,17 @@ test('tenant logs inherit the path from the central log path config', function (
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
// Tenant log is located at storage/tenantX/logs/custom-name.log
Log::channel('stack')->info($tenant->id); Log::channel('stack')->info($tenant->id);
// The filename from the central config is preserved in tenant context // The filename from the central config is preserved in tenant context
expect(config('logging.channels.single.path'))->toEndWith('logs/single/custom-name.log'); expect(config('logging.channels.single.path'))->toEndWith('logs/single/custom-name.log');
expect(config('logging.channels.daily.path'))->toEndWith('logs/daily/custom-name.log'); expect(config('logging.channels.daily.path'))->toEndWith('logs/daily/custom-name.log');
expect(file_get_contents(storage_path('logs/single/custom-name.log'))) expect(file_get_contents("{$centralStoragePath}/tenant{$tenant->id}/logs/single/custom-name.log"))
->toContain($tenant->id) ->toContain($tenant->id)
->not()->toContain('central'); ->not()->toContain('central');
expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log"))) expect(file_get_contents("{$centralStoragePath}/tenant{$tenant->id}/logs/daily/custom-name-{$today}.log"))
->toContain($tenant->id) ->toContain($tenant->id)
->not()->toContain('central'); ->not()->toContain('central');
}); });

View file

@ -17,6 +17,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper; use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Controllers\TenantAssetController; use Stancl\Tenancy\Controllers\TenantAssetController;
use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator; use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
@ -30,6 +31,7 @@ beforeEach(function () {
TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = true; TenancyUrlGenerator::$passTenantParameterToRoutes = true;
TenantAssetController::$headers = []; TenantAssetController::$headers = [];
TenantAssetController::$publicDisk = null;
/** @var CloneRoutesAsTenant $cloneAction */ /** @var CloneRoutesAsTenant $cloneAction */
$cloneAction = app(CloneRoutesAsTenant::class); $cloneAction = app(CloneRoutesAsTenant::class);
@ -39,6 +41,11 @@ beforeEach(function () {
Event::listen(TenancyEnded::class, RevertToCentralContext::class); Event::listen(TenancyEnded::class, RevertToCentralContext::class);
}); });
afterEach(function () {
TenantAssetController::$headers = [];
TenantAssetController::$publicDisk = null;
});
test('asset can be accessed using the url returned by the tenant asset helper', function () { test('asset can be accessed using the url returned by the tenant asset helper', function () {
config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
@ -65,6 +72,174 @@ test('asset can be accessed using the url returned by the tenant asset helper',
expect($content)->toBe('bar'); expect($content)->toBe('bar');
}); });
test('tenant assets are served even when the suffix_storage_path config is set to false', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
'tenancy.filesystem.suffix_storage_path' => false,
]);
// With suffix_storage_path disabled, storage_path() stays central in tenant context
$centralStoragePath = storage_path();
$tenant = Tenant::create();
tenancy()->initialize($tenant);
expect(storage_path())->toBe($centralStoragePath);
$filename = 'testfile' . Str::random(8);
Storage::disk('public')->put($filename, 'bar');
$response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]);
// The asset is served from the tenant's storage directory, not from the central storage path
$response->assertSuccessful();
expect($response->getFile()->getPathname())
->toBe("$centralStoragePath/tenant{$tenant->id}/app/public/$filename");
});
test('the disk used for serving tenant assets is configurable', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
// This is tenancy's default override for the local disk's root, set it here for clarity
'tenancy.filesystem.root_override.local' => '%storage_path%/app/',
]);
// The local disk's root is overridden to '%storage_path%/app/' (so it does not use 'app/public')
TenantAssetController::$publicDisk = 'local';
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$filename = 'testfile' . Str::random(8);
Storage::disk('local')->put($filename, 'bar');
$path = Storage::disk('local')->path($filename);
$response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]);
// The asset is served from the disk's root instead of 'app/public'
$response->assertSuccessful();
expect($response->getFile()->getPathname())->toBe($path);
});
test('tenant asset controller throws when the configured disk is not local or not tenant-aware', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
// Add a disk that uses the s3 driver (= non-local disk).
// Use dummy credentials so that the s3 disk can be resolved without throwing an AWS exception.
'filesystems.disks.remote' => [
'driver' => 's3',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'bucket',
],
'filesystems.disks.scoped_remote' => [
'driver' => 'scoped',
'disk' => 'remote',
'prefix' => 'assets',
],
// 'media' isn't tenant-aware (i.e. not included in tenancy.filesystem.disks)
'filesystems.disks.media' => [
'driver' => 'local',
'root' => storage_path('app/media'),
],
'filesystems.disks.scoped_media' => [
'driver' => 'scoped',
'disk' => 'media',
'prefix' => 'assets',
],
'filesystems.disks.inline_scoped_media' => [
'driver' => 'scoped',
// Laravel allows configuring the parent disk inline, but such a disk
// has no name, so it cannot be listed in tenancy.filesystem.disks (i.e. made tenant-aware)
'disk' => [
'driver' => 'local',
'root' => storage_path('app/media'),
],
'prefix' => 'assets',
],
]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$this->withoutExceptionHandling();
$expectedExceptions = [
'remote' => 'Disk [remote] is not a local disk.',
'scoped_remote' => 'Disk [scoped_remote] is not a local disk.',
'media' => 'Disk [media] is not tenant-aware.',
'scoped_media' => 'Disk [media] is not tenant-aware.',
'inline_scoped_media' => 'Disk [inline_scoped_media] has an unnamed parent disk.',
];
foreach ($expectedExceptions as $publicDisk => $exceptionMessage) {
TenantAssetController::$publicDisk = $publicDisk;
expect(fn () => pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id]))
->toThrow(Exception::class, $exceptionMessage);
}
});
test('tenant assets cannot be served in central context', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
// Make the asset route skip tenant identification
'tenancy.default_route_mode' => RouteMode::UNIVERSAL,
]);
$this->withoutExceptionHandling();
foreach ([null, 'local'] as $publicDisk) {
TenantAssetController::$publicDisk = $publicDisk;
expect(fn () => pest()->get(tenant_asset('foo.txt')))
->toThrow(Exception::class, 'Tenant assets can only be served in tenant context.');
}
});
test('tenant assets are served from the resolved root of the configured disk', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
// A scoped disk has no configured root -- it inherits the root of its parent disk
// and appends its prefix to it, both of which happens when the disk is resolved.
'filesystems.disks.scoped_disk' => [
'driver' => 'scoped',
'disk' => 'public',
'prefix' => 'scoped_disk_prefix',
],
// A prefix is part of the disk's full path, so it has to be included in the asset root
'filesystems.disks.prefixed' => [
'driver' => 'local',
'root' => storage_path('app/media'),
'prefix' => 'foo_prefix',
],
'tenancy.filesystem.disks' => ['local', 'public', 'prefixed'],
'tenancy.filesystem.root_override.prefixed' => '%storage_path%/app/media/',
]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
foreach ([
'scoped_disk' => 'app/public/scoped_disk_prefix',
'prefixed' => 'app/media/foo_prefix',
] as $publicDisk => $expectedRoot) {
TenantAssetController::$publicDisk = $publicDisk;
$filename = 'testfile' . Str::random(8);
Storage::disk($publicDisk)->put($filename, 'bar');
$path = Storage::disk($publicDisk)->path($filename);
expect($path)->toBe(storage_path("{$expectedRoot}/$filename"));
$response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]);
$response->assertSuccessful();
expect($response->getFile()->getPathname())->toBe($path);
}
});
test('asset helper returns a link to tenant asset controller when asset url is null', function () { test('asset helper returns a link to tenant asset controller when asset url is null', function () {
config(['app.asset_url' => null]); config(['app.asset_url' => null]);
config(['tenancy.filesystem.asset_helper_override' => true]); config(['tenancy.filesystem.asset_helper_override' => true]);
@ -148,8 +323,6 @@ test('TenantAssetController headers are configurable', function () {
$response->assertSuccessful(); $response->assertSuccessful();
$response->assertHeader('X-Foo', 'Bar'); $response->assertHeader('X-Foo', 'Bar');
TenantAssetController::$headers = []; // reset static property
}); });
test('global asset helper returns the same url regardless of tenancy initialization', function () { test('global asset helper returns the same url regardless of tenancy initialization', function () {
@ -233,24 +406,27 @@ test('tenant asset controller returns a 404 when accessing a nonexistent file',
]); ]);
}); });
test('test asset controller returns a 404 when accessing a file outside the storage root', function () { test('tenant asset controller only serves files inside the asset root', function () {
config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]); config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
$tenant = Tenant::create(); $tenant = Tenant::create();
tenancy()->initialize($tenant); tenancy()->initialize($tenant);
$storageRoot = storage_path("app/public"); Storage::disk('public')->put('photo.jpg', 'public file');
if (! is_dir($storageRoot)) { pest()->get(tenant_asset('photo.jpg'), ['X-Tenant' => $tenant->id])->assertSuccessful();
mkdir(storage_path("app/public"), recursive: true);
file_put_contents(storage_path('app/foo.txt'), 'bar'); // Files outside the asset root
} file_put_contents(storage_path('app/photo.jpg'), 'private file');
// This path starts with the asset root but is a sibling dir, not a child. Regression assertion
mkdir($siblingDirectory = storage_path('app/public-originals'), recursive: true);
file_put_contents($siblingDirectory . '/photo.jpg', 'private file');
$this->withoutExceptionHandling(); $this->withoutExceptionHandling();
pest()->expectExceptionMessage('Accessing a file outside the storage root'); // outside tests this is a 404
pest()->get(tenant_asset('../foo.txt'), [ foreach (['../photo.jpg', '../public-originals/photo.jpg'] as $path) {
'X-Tenant' => $tenant->id, expect(fn () => pest()->get(tenant_asset($path), ['X-Tenant' => $tenant->id]))
]); ->toThrow(Exception::class, 'Accessing a file outside the storage root'); // outside tests this is a 404
}
}); });