Skip to content

Commit a19d3b5

Browse files
authored
Merge branch 'master' into broadcasting-fixes
2 parents 937c8c8 + 76e5f96 commit a19d3b5

11 files changed

Lines changed: 799 additions & 24 deletions

assets/config.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@
185185
// Bootstrappers\RootUrlBootstrapper::class,
186186
// Bootstrappers\UrlGeneratorBootstrapper::class,
187187
// Bootstrappers\MailConfigBootstrapper::class,
188+
// Bootstrappers\LogChannelBootstrapper::class,
188189
// Bootstrappers\BroadcastingConfigBootstrapper::class,
189190
// Bootstrappers\BroadcastChannelPrefixBootstrapper::class,
190191

src/Bootstrappers/DatabaseTenancyBootstrapper.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ public function bootstrap(Tenant $tenant): void
5353
{
5454
/** @var TenantWithDatabase $tenant */
5555
if (data_get($tenant->database()->getTemplateConnection(), 'url')) {
56-
// The package works with individual parts of the database connection config, so DATABASE_URL is not supported.
57-
// When DATABASE_URL is set, this bootstrapper can silently fail i.e. keep using the template connection's database URL
56+
// The package works with individual parts of the database connection config, so DB_URL is not supported.
57+
// When DB_URL is set, this bootstrapper can silently fail i.e. keep using the template connection's database URL
5858
// which takes precedence over individual segments of the connection config. This issue can be hard to debug as it can be
5959
// production-specific. Therefore, we throw an exception (that effectively blocks all tenant pages) to prevent incorrect DB use.
6060
throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.');

src/Bootstrappers/FilesystemTenancyBootstrapper.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,4 +255,10 @@ public function scopeSessions(string|false $suffix): void
255255
$sessionManager->getDrivers()['file']->setHandler($handler);
256256
}
257257
}
258+
259+
/** Get the central storage path from the bound singleton instance of this class. */
260+
public static function getBoundCentralStoragePath(): string
261+
{
262+
return app(static::class)->originalStoragePath;
263+
}
258264
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Stancl\Tenancy\Bootstrappers;
6+
7+
use Closure;
8+
use Illuminate\Contracts\Config\Repository;
9+
use Illuminate\Database\Eloquent\Model;
10+
use Illuminate\Log\LogManager;
11+
use Illuminate\Support\Str;
12+
use InvalidArgumentException;
13+
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
14+
use Stancl\Tenancy\Contracts\Tenant;
15+
16+
/**
17+
* Use tenant-specific logging channels.
18+
*
19+
* Channels included in the $storagePathChannels property will be configured
20+
* to write logs into the tenant's storage directory. The list includes
21+
* Laravel's 'single' and 'daily' channels by default. To customize it,
22+
* see the property's docblock.
23+
*
24+
* For the storage path channels to be scoped correctly:
25+
* - this bootstrapper must run *after* FilesystemTenancyBootstrapper,
26+
* since FilesystemTenancyBootstrapper adjusts storage_path() for the tenant
27+
* - storage path suffixing has to be enabled (= config('tenancy.filesystem.suffix_storage_path')
28+
* must be true), since the storage path suffix is what separates filesystem-based logs
29+
*
30+
* For logging channels that are not filesystem-based, see the $channelOverrides logic.
31+
*
32+
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
33+
*/
34+
class LogChannelBootstrapper implements TenancyBootstrapper
35+
{
36+
protected array $defaultConfig = [];
37+
38+
protected array $configuredChannels = [];
39+
40+
/**
41+
* Logging channels whose path is built using storage_path() (e.g. Laravel's 'single' and 'daily').
42+
*
43+
* Channels included here will be configured to use tenant-specific storage paths
44+
* created using storage_path() in the tenant context. Overrides in the $channelOverrides
45+
* property take precedence over $storagePathChannels when a channel is included in both.
46+
*
47+
* Requires FilesystemTenancyBootstrapper to run before this bootstrapper,
48+
* and storage path suffixing to be enabled.
49+
*
50+
* @see Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper
51+
*/
52+
public static array $storagePathChannels = ['single', 'daily'];
53+
54+
/**
55+
* Custom channel configuration overrides.
56+
*
57+
* Channels included here will be configured using the provided override.
58+
* The overrides take precedence over the $storagePathChannels behavior
59+
* when both approaches are used for the same channel.
60+
*
61+
* You can either map tenant attributes to channel config keys using an array,
62+
* or provide a closure that returns the full channel config array.
63+
*
64+
* Examples:
65+
* - Array mapping: ['slack' => ['url' => 'webhookUrl']]
66+
* - this maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is null, the override is ignored)
67+
* - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])]
68+
* - this manually merges ['url' => $tenant->slackUrl] into the channel's config
69+
* - null is not ignored, the closure controls the override fully
70+
*
71+
* So the channel overrides can be arrays and closures that return arrays.
72+
*/
73+
public static array $channelOverrides = [];
74+
75+
public function __construct(
76+
protected Repository $config,
77+
protected LogManager $logManager,
78+
) {}
79+
80+
public function bootstrap(Tenant $tenant): void
81+
{
82+
$this->defaultConfig = $this->config->get('logging.channels');
83+
$this->configuredChannels = $this->getChannels();
84+
85+
try {
86+
$this->configureChannels($this->configuredChannels, $tenant);
87+
$this->forgetChannels($this->configuredChannels);
88+
} catch (\Throwable $exception) {
89+
// If an exception is thrown while updating the logging config, the logging config
90+
// could be left in a corrupt state, so we revert to the original config to
91+
// to avoid logging the exception in a tenant channel or a broken channel.
92+
$this->revert();
93+
94+
// We re-throw the exception after having reverted the logging config to central.
95+
throw $exception;
96+
}
97+
}
98+
99+
public function revert(): void
100+
{
101+
$this->config->set('logging.channels', $this->defaultConfig);
102+
103+
$this->forgetChannels($this->configuredChannels);
104+
}
105+
106+
/**
107+
* Channels to configure and forget from the log manager so they can be
108+
* re-resolved with the new, tenant-specific config on the next use.
109+
*
110+
* Includes:
111+
* - all channels in the $storagePathChannels array
112+
* - all channels that have custom overrides in the $channelOverrides property
113+
* - any 'stack' channel that includes one of the above as a member
114+
*
115+
* Stack channels are included because once a stack has been used, it keeps logging
116+
* to wherever its members pointed to at that moment. So a stack used in the central
117+
* context would keep writing to the central logs, even after tenancy is initialized
118+
* and its member channels are configured for the tenant.
119+
* Forgetting the stack forces it to be re-resolved with its members' updated (tenant)
120+
* config.
121+
*
122+
* Importantly, stacks are only inspected one level deep - they are not traversed recursively.
123+
*/
124+
protected function getChannels(): array
125+
{
126+
$configuredChannels = array_unique([
127+
...static::$storagePathChannels,
128+
...array_keys(static::$channelOverrides),
129+
]);
130+
131+
$stackChannels = [];
132+
133+
foreach ($this->config->get('logging.channels') as $channel => $config) {
134+
// Include stack channels that have at least one configured channel as a member
135+
if (($config['driver'] ?? null) === 'stack' && array_intersect($config['channels'] ?? [], $configuredChannels)) {
136+
$stackChannels[] = $channel;
137+
}
138+
}
139+
140+
return array_filter(
141+
array_unique([...$configuredChannels, ...$stackChannels]),
142+
fn (string $channel): bool => $this->config->has("logging.channels.{$channel}")
143+
);
144+
}
145+
146+
/**
147+
* Configure channels for the tenant context.
148+
*
149+
* This handles both $storagePathChannels and $channelOverrides.
150+
*/
151+
protected function configureChannels(array $channels, Tenant $tenant): void
152+
{
153+
foreach ($channels as $channel) {
154+
if (isset(static::$channelOverrides[$channel])) {
155+
$this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant);
156+
} elseif (in_array($channel, static::$storagePathChannels)) {
157+
// Set storage path channels to use a tenant-specific directory.
158+
// The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log".
159+
$originalChannelPath = $this->config->get("logging.channels.{$channel}.path");
160+
$centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath();
161+
162+
// The tenant log will inherit the segment that follows the storage path from the central channel path config.
163+
// For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log),
164+
// the 'logs/foo.log' segment will be passed to storage_path() in the tenant context (storage/tenant123/logs/foo.log).
165+
$this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath)));
166+
}
167+
}
168+
}
169+
170+
/**
171+
* Update channel configurations per $channelOverrides.
172+
*
173+
* For overrides set in array format, update individual keys of the channel.
174+
* - This ignores cases where the value of the respective tenant attribute is null.
175+
* For overrides set as closures, replace the entire channel with the returned config override.
176+
* - This does not ignore cases where parts of the config may be null - the closure fully controls the override.
177+
*/
178+
protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void
179+
{
180+
if (is_array($override)) {
181+
// Map tenant attributes to channel config keys.
182+
foreach ($override as $configKey => $tenantAttributeName) {
183+
/** @var Tenant&Model $tenant */
184+
$tenantAttribute = data_get($tenant, $tenantAttributeName);
185+
186+
// If the tenant attribute is null, the override is ignored
187+
// and the channel config key's value remains unchanged.
188+
if ($tenantAttribute !== null) {
189+
$this->config->set("logging.channels.{$channel}.{$configKey}", $tenantAttribute);
190+
}
191+
}
192+
} elseif ($override instanceof Closure) {
193+
$channelConfigKey = "logging.channels.{$channel}";
194+
195+
$result = $override($tenant, $this->config->get($channelConfigKey));
196+
197+
if (! is_array($result)) {
198+
throw new InvalidArgumentException("Channel override closure for '{$channel}' must return an array.");
199+
}
200+
201+
$this->config->set($channelConfigKey, $result);
202+
}
203+
}
204+
205+
/**
206+
* Forget all passed channels from the log manager so that they can be
207+
* re-resolved with the updated config on the next logging attempt.
208+
*/
209+
protected function forgetChannels(array $channels): void
210+
{
211+
foreach ($channels as $channel) {
212+
$this->logManager->forgetChannel($channel);
213+
}
214+
}
215+
}

src/Features/DisallowSqliteAttach.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ protected function setNativeAuthorizer(PDO $pdo): void
7474
// @phpstan-ignore method.notFound
7575
$pdo->setAuthorizer(static function (int $action): int {
7676
return $action === 24 // SQLITE_ATTACH
77-
? PDO\Sqlite::DENY
78-
: PDO\Sqlite::OK;
77+
? PDO\Sqlite::DENY // @phpstan-ignore classConstant.notFound
78+
: PDO\Sqlite::OK; // @phpstan-ignore classConstant.notFound
7979
});
8080
}
8181
}

src/ResourceSyncing/TriggerSyncingEvents.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@ trait TriggerSyncingEvents
2222
public static function bootTriggerSyncingEvents(): void
2323
{
2424
static::saving(static function (self $pivot) {
25-
// Try getting the central resource to see if it is available
26-
// If it is not available, throw an exception to interrupt the saving process
27-
// And prevent creating a pivot record without a central resource
25+
// Try getting the central resource to see if it is available.
26+
// If it is not, getCentralResourceAndTenant() throws (indirectly, via findCentralResource() -> getResourceClass()),
27+
// interrupting the save, preventing the creation of a pivot record without a central resource.
2828
$pivot->getCentralResourceAndTenant();
2929
});
3030

31-
static::saved(static function (self $pivot) {
31+
// Only attach when the pivot is created
32+
static::created(static function (self $pivot) {
3233
/**
3334
* @var static&Pivot $pivot
3435
* @var SyncMaster|null $centralResource
@@ -55,6 +56,10 @@ public static function bootTriggerSyncingEvents(): void
5556
});
5657
}
5758

59+
/**
60+
* @throws CentralResourceNotAvailableInPivotException Throws when the tenant is the pivot parent
61+
* but the central resource class cannot be resolved (thrown indirectly via findCentralResource() -> getResourceClass())
62+
*/
5863
public function getCentralResourceAndTenant(): array
5964
{
6065
/** @var $this&Pivot $this */

src/helpers.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ function tenant(?string $key = null): mixed
3030
return app(Tenant::class);
3131
}
3232

33-
// @phpstan-ignore-next-line nullsafe.neverNull
3433
return app(Tenant::class)?->getAttribute($key);
3534
}
3635
}

tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
use Stancl\Tenancy\Tests\Etc\Tenant;
1313
use Illuminate\Support\Str;
1414
use Illuminate\Support\Facades\DB;
15-
use Illuminate\Database\QueryException;
1615
use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager;
1716
use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager;
1817
use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager;
@@ -116,7 +115,9 @@
116115
expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class);
117116

118117
// Connection should be reverted back to central
119-
expect(DB::connection()->getName())->toBe('central');
118+
$centralConnection = config('tenancy.database.central_connection');
119+
120+
expect(DB::connection()->getName())->toBe($centralConnection);
120121
} else {
121122
expect(fn() => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class);
122123

@@ -128,25 +129,22 @@
128129
'hardening disabled' => false,
129130
])->with('db_managers');
130131

131-
test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) {
132-
config(['database.connections.central.url' => $databaseUrl]);
133-
132+
test('database tenancy bootstrapper throws an exception if DB_URL is set', function (string|null $databaseUrl) {
134133
config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]);
135134

136135
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
137136
return $event->tenant;
138137
})->toListener());
139138

140-
if ($databaseUrl) {
141-
expect(fn() => Tenant::create())->toThrow(QueryException::class);
142-
} else {
143-
expect(function() {
144-
$tenant1 = Tenant::create();
139+
$tenant = Tenant::create();
145140

146-
pest()->artisan('tenants:migrate');
141+
config(['database.connections.central.url' => $databaseUrl]);
147142

148-
tenancy()->initialize($tenant1);
149-
})->not()->toThrow(Throwable::class);
143+
if ($databaseUrl) {
144+
expect(fn() => tenancy()->initialize($tenant))
145+
->toThrow(Exception::class, 'The template connection must NOT have URL defined.');
146+
} else {
147+
expect(fn() => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class);
150148
}
151149
})->with(['abc.us-east-1.rds.amazonaws.com', null]);
152150

0 commit comments

Comments
 (0)