mirror of
https://github.com/archtechx/tenancy.git
synced 2026-08-06 12:04:04 +00:00
This PR adds the LogChannelBootstrapper to provide tenant-specific logging configuration. The bootstrapper automatically configures storage path channels to use tenant-specific directories (NOTE: for this to work correctly, the bootstrapper has to run AFTER FilesystemTenancyBootstrapper, otherwise, the logs still won't be separated, unless you use overrides) and supports custom channel overrides for custom logging scenarios -- mapping tenant properties to the channel config, or using custom closures an array with the logging config, e.g. for making the slack channel (that's not handled by the bootstrapper by default) tenant-specific. The bootstrapper first modifies the channel config, then forgets the channel from LogManager so that on the next logging attempt, the channel is re-resolved with the modified config. Otherwise, the channel would just use the initial config **if the channel was resolved before**. If the channel wasn't resolved before, it'll always be resolved with the updated (tenant) config, unless the configuration fails. In that case, the config will be reverted (the central config will be restored) and the error will be logged using the original channel. When using a channel stack, the `stack` channel itself also has to be forgotten, since the LogManager could retain e.g. the original `stack` channel's webhook URL, while the underlying `slack` channel would use the updated one, and while logging, the app would actually use the initial webhook URL instead of the updated one (encountered this issue while testing). Note that **all** channels in `$storagePathChannels` and `$channelOverrides` are affected. Also, adding `'attachment' => 'false'` to the slack channel's config makes the slack channel work with Discord webhooks (just a cool thing we figured out whlle testing the bootstrapper). --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Samuel Štancl <samuel@archte.ch>
522 lines
20 KiB
PHP
522 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Illuminate\Support\Facades\Event;
|
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
|
use Stancl\Tenancy\Events\TenancyEnded;
|
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
|
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
|
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
|
use Stancl\Tenancy\Bootstrappers\LogChannelBootstrapper;
|
|
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
afterEach($cleanup = function () {
|
|
LogChannelBootstrapper::$channelOverrides = [];
|
|
LogChannelBootstrapper::$storagePathChannels = ['single', 'daily'];
|
|
|
|
$logFiles = array_merge(
|
|
glob(storage_path('logs/*.log')) ?: [],
|
|
glob(storage_path('logs/*/*.log')) ?: [],
|
|
glob(storage_path('tenant*/logs/*.log')) ?: [],
|
|
glob(storage_path('tenant*/logs/*/*.log')) ?: []
|
|
);
|
|
|
|
foreach ($logFiles as $path) {
|
|
@unlink($path);
|
|
}
|
|
});
|
|
|
|
beforeEach(function () use ($cleanup) {
|
|
config([
|
|
'tenancy.bootstrappers' => [
|
|
LogChannelBootstrapper::class,
|
|
],
|
|
]);
|
|
|
|
$cleanup();
|
|
|
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
|
});
|
|
|
|
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();
|
|
$tenant = Tenant::create();
|
|
|
|
// Storage path channels are 'single' and 'daily' by default.
|
|
// This can be customized via LogChannelBootstrapper::$storagePathChannels.
|
|
foreach (LogChannelBootstrapper::$storagePathChannels as $channel) {
|
|
$originalPath = config("logging.channels.{$channel}.path");
|
|
|
|
tenancy()->initialize($tenant);
|
|
|
|
// Path should now point to the log in the tenant's storage directory
|
|
$tenantLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log";
|
|
expect(config("logging.channels.{$channel}.path"))
|
|
->not()->toBe($originalPath)
|
|
->toBe($tenantLogPath);
|
|
|
|
tenancy()->end();
|
|
|
|
// Path should be reverted
|
|
expect(config("logging.channels.{$channel}.path"))->toBe($originalPath);
|
|
}
|
|
});
|
|
|
|
test('all channels included in a stack get processed correctly', function () {
|
|
config([
|
|
'tenancy.bootstrappers' => [
|
|
FilesystemTenancyBootstrapper::class,
|
|
LogChannelBootstrapper::class,
|
|
],
|
|
'logging.channels.stack' => [
|
|
'driver' => 'stack',
|
|
'channels' => ['single', 'daily'],
|
|
],
|
|
]);
|
|
|
|
$centralStoragePath = storage_path();
|
|
$centralLogPath = $centralStoragePath . '/logs/laravel.log';
|
|
$originalSinglePath = config('logging.channels.single.path');
|
|
$originalDailyPath = config('logging.channels.daily.path');
|
|
|
|
// By default, both paths are the same in the config.
|
|
// Note that in actual usage, the daily log file name is parsed differently from the path in the config,
|
|
// e.g. if daily channel has 'path' => storage_path('logs/laravel.log') in config, the log will be
|
|
// located at storage_path('logs/laravel-2026-01-01.log'). But the paths *in the config* are the same.
|
|
expect($centralLogPath)
|
|
->toBe($centralStoragePath . '/logs/laravel.log')
|
|
->toBe($originalSinglePath)
|
|
->toBe($originalDailyPath);
|
|
|
|
$tenant = Tenant::create();
|
|
|
|
tenancy()->initialize($tenant);
|
|
|
|
// Both channels in the stack are updated correctly
|
|
expect("{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log")
|
|
->not()->toBe($originalSinglePath)
|
|
->not()->toBe($originalDailyPath)
|
|
->toBe(config('logging.channels.single.path'))
|
|
->toBe(config('logging.channels.daily.path'));
|
|
|
|
tenancy()->end();
|
|
|
|
expect(config('logging.channels.single.path'))->toBe($originalSinglePath);
|
|
expect(config('logging.channels.daily.path'))->toBe($originalDailyPath);
|
|
});
|
|
|
|
test('channel overrides work correctly with both arrays and closures', function () {
|
|
config([
|
|
'logging.channels.stack.channels' => ['slack', 'single'],
|
|
'logging.channels.slack' => [
|
|
'url' => $originalSlackUrl = 'default-webhook',
|
|
'username' => 'Default',
|
|
],
|
|
]);
|
|
|
|
$centralStoragePath = storage_path();
|
|
$originalSinglePath = config('logging.channels.single.path');
|
|
|
|
$tenant = Tenant::create(['webhookUrl' => 'tenant-webhook']);
|
|
|
|
// Channel override closures must return an array, otherwise an exception is thrown
|
|
LogChannelBootstrapper::$channelOverrides['slack'] = fn (Tenant $tenant, array $channel) => 'invalid override';
|
|
|
|
expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class);
|
|
|
|
// Test both array mapping and closure-based overrides
|
|
LogChannelBootstrapper::$channelOverrides = [
|
|
'slack' => ['url' => 'webhookUrl'], // slack.url will be mapped to $tenant->webhookUrl
|
|
'single' => function (Tenant $tenant, array $channel) use ($centralStoragePath) {
|
|
return array_merge($channel, ['path' => $centralStoragePath . "/logs/override-{$tenant->id}.log"]);
|
|
},
|
|
];
|
|
|
|
// Reinitialize tenancy to apply the new overrides
|
|
tenancy()->reinitialize();
|
|
|
|
// Array mapping overrides work
|
|
expect(config('logging.channels.slack.url'))->toBe($tenant->webhookUrl);
|
|
expect(config('logging.channels.slack.username'))->toBe('Default'); // Default username, remains default unless overridden
|
|
|
|
// Closure overrides work
|
|
expect(config('logging.channels.single.path'))->toBe("{$centralStoragePath}/logs/override-{$tenant->id}.log");
|
|
|
|
tenancy()->end();
|
|
|
|
// After tenancy ends, the original config should be restored
|
|
expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl);
|
|
expect(config('logging.channels.single.path'))->toBe($originalSinglePath);
|
|
expect(config('logging.channels.slack.username'))->toBe('Default'); // Unchanged
|
|
});
|
|
|
|
test('channel config keys remain unchanged if the specified tenant override attribute is null', function() {
|
|
config(['logging.channels.slack.username' => 'Default username']);
|
|
|
|
LogChannelBootstrapper::$channelOverrides = [
|
|
'slack' => ['username' => 'nonExistentAttribute'], // $tenant->nonExistentAttribute
|
|
];
|
|
|
|
tenancy()->initialize(Tenant::create());
|
|
|
|
// The username should remain unchanged since the tenant attribute is null
|
|
expect(config('logging.channels.slack.username'))->toBe('Default username');
|
|
});
|
|
|
|
test('channel overrides take precedence over the default storage path channel updating logic', function () {
|
|
$tenant = Tenant::create(['id' => 'tenant1']);
|
|
|
|
LogChannelBootstrapper::$storagePathChannels = ['single'];
|
|
|
|
LogChannelBootstrapper::$channelOverrides = [
|
|
'single' => function (Tenant $tenant, array $channel) {
|
|
return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]);
|
|
},
|
|
];
|
|
|
|
tenancy()->initialize($tenant);
|
|
|
|
// Should use channel override, not the storage path updating behavior
|
|
expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log');
|
|
});
|
|
|
|
test('channels are forgotten and re-resolved during bootstrap and revert', function () {
|
|
config([
|
|
'tenancy.bootstrappers' => [
|
|
FilesystemTenancyBootstrapper::class,
|
|
LogChannelBootstrapper::class,
|
|
],
|
|
]);
|
|
|
|
$logManager = app('log');
|
|
$originalChannel = $logManager->channel('single');
|
|
$originalSinglePath = config('logging.channels.single.path');
|
|
|
|
$tenant = Tenant::create();
|
|
|
|
tenancy()->initialize($tenant);
|
|
|
|
// After bootstrap, the channel should be a new instance with the updated config
|
|
$tenantChannel = $logManager->channel('single');
|
|
$tenantSingleChannelPath = $tenantChannel->getLogger()->getHandlers()[0]->getUrl();
|
|
|
|
expect($tenantChannel)->not()->toBe($originalChannel);
|
|
expect($tenantSingleChannelPath)
|
|
->not()->toBe($originalSinglePath)
|
|
->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log");
|
|
|
|
tenancy()->end();
|
|
|
|
// After revert, the channel should get re-resolved with the original config
|
|
$currentChannel = $logManager->channel('single');
|
|
$currentChannelPath = $currentChannel->getLogger()->getHandlers()[0]->getUrl();
|
|
|
|
expect($currentChannel)->not()->toBe($tenantChannel);
|
|
expect($currentChannelPath)->toBe($originalSinglePath);
|
|
});
|
|
|
|
// Test real usage
|
|
test('logs are written to tenant-specific files and do not leak between contexts', function () {
|
|
config([
|
|
'tenancy.bootstrappers' => [
|
|
FilesystemTenancyBootstrapper::class,
|
|
LogChannelBootstrapper::class,
|
|
],
|
|
]);
|
|
|
|
$centralLogPath = storage_path('logs/laravel.log');
|
|
|
|
Log::channel('single')->info('central');
|
|
|
|
expect(file_get_contents($centralLogPath))->toContain('central');
|
|
|
|
[$tenant1, $tenant2] = [Tenant::create(['id' => 'tenant1']), Tenant::create(['id' => 'tenant2'])];
|
|
|
|
tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralLogPath) {
|
|
Log::channel('single')->info($tenant->id);
|
|
|
|
$tenantLogPath = storage_path('logs/laravel.log');
|
|
|
|
// The log gets saved to the tenant's storage directory (default behavior)
|
|
expect($tenantLogPath)
|
|
->not()->toBe($centralLogPath)
|
|
->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log");
|
|
|
|
expect(file_get_contents($tenantLogPath))
|
|
->toContain($tenant->id)
|
|
->not()->toContain('central');
|
|
});
|
|
|
|
// Tenant log messages didn't leak into central log
|
|
expect(file_get_contents($centralLogPath))
|
|
->toContain('central')
|
|
->not()->toContain('tenant1')
|
|
->not()->toContain('tenant2');
|
|
|
|
// Tenant log messages didn't leak to logs of other tenants
|
|
tenancy()->initialize($tenant1);
|
|
|
|
expect(file_get_contents(storage_path('logs/laravel.log')))
|
|
->toContain('tenant1')
|
|
->not()->toContain('central')
|
|
->not()->toContain('tenant2');
|
|
|
|
tenancy()->initialize($tenant2);
|
|
|
|
expect(file_get_contents(storage_path('logs/laravel.log')))
|
|
->toContain('tenant2')
|
|
->not()->toContain('central')
|
|
->not()->toContain('tenant1');
|
|
|
|
// Overriding the channels also works
|
|
// Channel overrides also override the default behavior for the storage path-based channels
|
|
$tenant = Tenant::create(['id' => 'override-tenant']);
|
|
|
|
LogChannelBootstrapper::$channelOverrides = [
|
|
'single' => function (Tenant $tenant, array $channel) {
|
|
// The tenant log path will be set to storage/tenantoverride-tenant/logs/custom-override-tenant.log
|
|
return array_merge($channel, ['path' => storage_path("logs/custom-{$tenant->id}.log")]);
|
|
},
|
|
];
|
|
|
|
// Tenant context log (should use custom path due to override)
|
|
tenancy()->initialize($tenant);
|
|
|
|
Log::channel('single')->info('tenant-override');
|
|
|
|
expect(file_get_contents(storage_path('logs/custom-override-tenant.log')))->toContain('tenant-override');
|
|
});
|
|
|
|
test('stack logs are written to all configured channels with tenant-specific paths', function () {
|
|
config([
|
|
'tenancy.bootstrappers' => [
|
|
FilesystemTenancyBootstrapper::class,
|
|
LogChannelBootstrapper::class,
|
|
],
|
|
'logging.channels.stack' => [
|
|
'driver' => 'stack',
|
|
'channels' => ['single', 'daily'],
|
|
],
|
|
]);
|
|
|
|
$tenant = Tenant::create(['id' => 'stack-tenant']);
|
|
$today = now()->format('Y-m-d');
|
|
|
|
// Central context stack log
|
|
Log::channel('stack')->info('central');
|
|
$centralSingleLogPath = storage_path('logs/laravel.log');
|
|
|
|
// The single and daily channels have the same path in the config, but the daily driver parses the file name so that the date is included in the file name
|
|
$centralDailyLogPath = storage_path("logs/laravel-{$today}.log");
|
|
|
|
expect(file_get_contents($centralSingleLogPath))->toContain('central');
|
|
expect(file_get_contents($centralDailyLogPath))->toContain('central');
|
|
|
|
// Tenant context stack log
|
|
tenancy()->initialize($tenant);
|
|
Log::channel('stack')->info('tenant');
|
|
$tenantSingleLogPath = storage_path('logs/laravel.log');
|
|
$tenantDailyLogPath = storage_path("logs/laravel-{$today}.log");
|
|
|
|
expect(file_get_contents($tenantSingleLogPath))->toContain('tenant');
|
|
expect(file_get_contents($tenantDailyLogPath))->toContain('tenant');
|
|
|
|
// Verify tenant logs don't contain central messages
|
|
expect(file_get_contents($tenantSingleLogPath))->not()->toContain('central');
|
|
expect(file_get_contents($tenantDailyLogPath))->not()->toContain('central');
|
|
|
|
tenancy()->end();
|
|
|
|
// Verify central logs still only contain the central messages
|
|
expect(file_get_contents($centralSingleLogPath))
|
|
->toContain('central')
|
|
->not()->toContain('tenant');
|
|
|
|
expect(file_get_contents($centralDailyLogPath))
|
|
->toContain('central')
|
|
->not()->toContain('tenant');
|
|
});
|
|
|
|
test('stack channels that include any configured channel are re-resolved', function () {
|
|
config([
|
|
'tenancy.bootstrappers' => [
|
|
FilesystemTenancyBootstrapper::class,
|
|
LogChannelBootstrapper::class,
|
|
],
|
|
'logging.channels.custom_stack' => [
|
|
'driver' => 'stack',
|
|
'channels' => ['single'],
|
|
],
|
|
]);
|
|
|
|
$tenant = Tenant::create(['id' => 'stack-tenant']);
|
|
$centralLogPath = storage_path('logs/laravel.log');
|
|
|
|
$logManager = app('log');
|
|
|
|
// Resolve the stack channel in the central context first
|
|
// (this caches the stack with its members still pointing at the central logs).
|
|
$originalStackChannel = $logManager->channel('custom_stack');
|
|
$originalStackChannel->info('central log message');
|
|
expect(file_get_contents($centralLogPath))->toContain('central log message');
|
|
|
|
tenancy()->initialize($tenant);
|
|
|
|
// The stack channel should have been re-resolved with the
|
|
// updated (tenant) config for its member channels
|
|
$tenantStackChannel = $logManager->channel('custom_stack');
|
|
expect($tenantStackChannel)->not()->toBe($originalStackChannel);
|
|
|
|
$tenantStackChannel->info('tenant log message');
|
|
|
|
// so 'tenant log message' should be logged to the tenant log,
|
|
// not the central log.
|
|
expect(file_get_contents($centralLogPath))
|
|
->toContain('central log message')
|
|
->not()->toContain('tenant log message');
|
|
|
|
$tenantLogPath = storage_path('logs/laravel.log');
|
|
expect(file_exists($tenantLogPath))->toBeTrue();
|
|
expect(file_get_contents($tenantLogPath))
|
|
->toContain('tenant log message');
|
|
|
|
tenancy()->end();
|
|
|
|
// After revert, the stack channel should get re-resolved again with the original config
|
|
$currentStackChannel = $logManager->channel('custom_stack');
|
|
expect($currentStackChannel)->not()->toBe($tenantStackChannel);
|
|
|
|
$currentStackChannel->info('central after revert');
|
|
|
|
expect(file_get_contents($centralLogPath))
|
|
->toContain('central log message')
|
|
->toContain('central after revert')
|
|
->not()->toContain('tenant log message');
|
|
|
|
expect(file_get_contents($tenantLogPath))
|
|
->toContain('tenant log message')
|
|
->not()->toContain('central after revert');
|
|
});
|
|
|
|
test('slack channel uses correct webhook urls', function () {
|
|
config([
|
|
'logging.channels.slack.url' => 'central-webhook',
|
|
'logging.channels.slack.level' => 'debug', // Set level to debug to keep the tests simple, since the default level here is 'critical'
|
|
]);
|
|
|
|
$assertWebhook = function (string $expectedWebhook, string $message): void {
|
|
$thrown = false;
|
|
|
|
// Because the Slack channel uses cURL to send messages, we cannot use Http::fake() here.
|
|
// Instead, we catch the exception and check the error message which contains the actual webhook URL
|
|
// (logging always throws "Curl error (code 6): Could not resolve host: {webhook_url}").
|
|
try {
|
|
Log::channel('slack')->info($message);
|
|
} catch (Exception $e) {
|
|
$thrown = true;
|
|
expect($e->getMessage())->toContain($expectedWebhook);
|
|
}
|
|
|
|
expect($thrown)->toBeTrue();
|
|
};
|
|
|
|
$tenant1 = Tenant::create(['id' => 'tenant1', 'logging' => ['slackUrl' => 'tenant1-webhook']]);
|
|
$tenant2 = Tenant::create(['id' => 'tenant2', 'logging' => ['slackUrl' => 'tenant2-webhook']]);
|
|
|
|
// Attribute mapping using nested attributes (dot notation) works
|
|
LogChannelBootstrapper::$channelOverrides = [
|
|
'slack' => ['url' => 'logging.slackUrl'],
|
|
];
|
|
|
|
// Test central context - should attempt to use central webhook
|
|
$assertWebhook('central-webhook', 'central');
|
|
|
|
// Slack channel should attempt to use the tenant-specific webhooks
|
|
tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($assertWebhook) {
|
|
$assertWebhook($tenant->logging['slackUrl'], $tenant->id);
|
|
});
|
|
|
|
// Central context, central webhook should be used again
|
|
$assertWebhook('central-webhook', 'central');
|
|
});
|
|
|
|
test('tenant logs inherit the path from the central log path config', function () {
|
|
config([
|
|
'tenancy.bootstrappers' => [
|
|
FilesystemTenancyBootstrapper::class,
|
|
LogChannelBootstrapper::class,
|
|
],
|
|
'logging.channels.stack' => [
|
|
'driver' => 'stack',
|
|
'channels' => ['single', 'daily'],
|
|
],
|
|
'logging.channels.single.path' => storage_path('logs/single/custom-name.log'),
|
|
'logging.channels.daily.path' => storage_path('logs/daily/custom-name.log'),
|
|
]);
|
|
|
|
$tenant = Tenant::create();
|
|
$today = now()->format('Y-m-d');
|
|
|
|
Log::channel('stack')->info('central');
|
|
|
|
expect(file_get_contents(storage_path('logs/single/custom-name.log')))->toContain('central');
|
|
expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log")))->toContain('central');
|
|
|
|
tenancy()->initialize($tenant);
|
|
|
|
// Tenant log is located at storage/tenantX/logs/custom-name.log
|
|
Log::channel('stack')->info($tenant->id);
|
|
|
|
// 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.daily.path'))->toEndWith('logs/daily/custom-name.log');
|
|
|
|
expect(file_get_contents(storage_path('logs/single/custom-name.log')))
|
|
->toContain($tenant->id)
|
|
->not()->toContain('central');
|
|
|
|
expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log")))
|
|
->toContain($tenant->id)
|
|
->not()->toContain('central');
|
|
});
|
|
|
|
test('logging config is reverted to the original state if configuration fails', function() {
|
|
config([
|
|
'logging.channels.slack.url' => $originalSlackUrl = 'default-webhook',
|
|
'logging.channels.single.path' => $originalSinglePath = storage_path('logs/default-single-path.log'),
|
|
]);
|
|
|
|
$tenant = Tenant::create(['loggingPath' => storage_path('logs/tenant-single-path.log')]);
|
|
|
|
// Valid override first, the config will be updated properly,
|
|
// then an invalid override that will cause the configuration to fail and throw an exception.
|
|
LogChannelBootstrapper::$channelOverrides = [
|
|
'single' => ['path' => 'loggingPath'], // Valid override
|
|
'slack' => fn () => 'invalid override',
|
|
];
|
|
|
|
expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class);
|
|
|
|
// Single channel config reverted to original state after the exception was thrown
|
|
expect(config('logging.channels.single.path'))->toBe($originalSinglePath);
|
|
|
|
// Exception thrown before slack config got changed
|
|
expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl);
|
|
|
|
// The single channel uses the original path for logging
|
|
Log::channel('single')->info('bootstrap failed');
|
|
expect(file_exists($originalSinglePath))->toBeTrue();
|
|
expect(file_get_contents($originalSinglePath))->toContain('bootstrap failed');
|
|
});
|