1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-06-21 02:44:03 +00:00
tenancy/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php
lukinovec 34d19e94e2 Make hardening work with all db/schema managers
Previously, hardening only worked with databases, not with schemas. Also test that hardening works with all relevant db managers.
2026-06-10 13:21:41 +02:00

195 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
use Closure;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
use PDO;
use Stancl\Tenancy\Database\Concerns\ValidatesDatabaseParameters;
use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Throwable;
class SQLiteDatabaseManager implements TenantDatabaseManager
{
use ValidatesDatabaseParameters;
/**
* SQLite database directory path.
*
* Defaults to database_path().
*/
public static string|null $path = null;
/*
* If this isn't null, a connection to the tenant DB will be created
* and passed to the provided closure, for the purpose of keeping the
* connection alive for the desired lifetime. This means it's the
* closure's job to store the connection in a place that lives as
* long as the connection should live.
*
* The closure is called in makeConnectionConfig() -- a method normally
* called shortly before a connection is established.
*
* NOTE: The closure is called EVERY time makeConnectionConfig()
* is called, therefore it's up to the closure to discard
* the connection if a connection to the same database is already persisted.
*
* The closure also receives the DSN used to create the PDO connection,
* since the PDO connection driver makes it a bit hard to recover DB names
* from PDO instances. That should make it easier to match these with
* tenant instances passed to $closeInMemoryConnectionUsing closures,
* if you're setting that property as well.
*
* @var Closure(PDO, string)|null
*/
public static Closure|null $persistInMemoryConnectionUsing = null;
/*
* The opposite of $persistInMemoryConnectionUsing. This closure
* is called when the tenant is deleted, to clear the database
* in case a tenant with the same ID should be created within
* the lifetime of the $persistInMemoryConnectionUsing logic.
*
* NOTE: The parameter provided to the closure is the Tenant
* instance, not a PDO connection.
*
* @var Closure(Tenant)|null
*/
public static Closure|null $closeInMemoryConnectionUsing = null;
/**
* Characters allowed in database names.
*
* Includes dots to support file extensions (e.g. '.sqlite').
*/
public static string $allowedDatabaseNameCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.';
public function createDatabase(TenantWithDatabase $tenant): bool
{
/** @var TenantWithDatabase&Model $tenant */
$name = $tenant->database()->getName();
if ($this->isInMemory($name)) {
// If :memory: is used, we update the tenant with a *named* in-memory SQLite connection.
//
// This makes use of the package feasible with in-memory SQLite. Pure :memory: isn't
// sufficient since the tenant creation process involves constant creation and destruction
// of the tenant connection, always clearing the memory (like migrations). Additionally,
// tenancy()->central() calls would close the database since at the moment we close the
// tenant connection (to prevent accidental references to it in the central context) when
// tenancy is ended.
//
// Note that named in-memory databases DO NOT have process lifetime. You need an open
// PDO connection to keep the memory from being cleaned up. It's up to the user how they
// handle this, common solutions may involve storing the connection in the service container
// or creating a closure holding a reference to it and passing that to register_shutdown_function().
$name = '_tenancy_inmemory_' . $tenant->getTenantKey();
$tenant->setInternal('db_name', "file:$name?mode=memory&cache=shared");
$tenant->save();
return true;
}
return file_put_contents($this->getPath($name), '') !== false;
}
public function deleteDatabase(TenantWithDatabase $tenant): bool
{
$name = $tenant->database()->getName();
if ($this->isInMemory($name)) {
if (static::$closeInMemoryConnectionUsing) {
(static::$closeInMemoryConnectionUsing)($tenant);
}
return true;
}
$path = $this->getPath($name);
try {
unlink($path . '-journal');
unlink($path . '-wal');
unlink($path . '-shm');
} catch (Throwable) {}
try {
return unlink($path);
} catch (Throwable) {
return false;
}
}
public function databaseExists(string $name): bool
{
return $this->isInMemory($name) || file_exists($this->getPath($name));
}
public function makeConnectionConfig(array $baseConfig, string $databaseName): array
{
if ($this->isInMemory($databaseName)) {
// Named in-memory DBs are formatted like 'file:_tenancy_inmemory_tenant123?mode=memory&cache=shared'
$this->validateDatabaseName($databaseName, extraAllowedCharacters: ':?=&');
$baseConfig['database'] = $databaseName;
if (static::$persistInMemoryConnectionUsing !== null) {
$dsn = "sqlite:$databaseName";
(static::$persistInMemoryConnectionUsing)(new PDO($dsn), $dsn);
}
} else {
$baseConfig['database'] = $this->getPath($databaseName);
}
return $baseConfig;
}
public function getCurrentDatabaseName(Connection $connection): string
{
return $connection->getDatabaseName();
}
public function getPath(string $name): string
{
$this->validateDatabaseName($name);
if (static::$path) {
return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
}
return database_path($name);
}
public static function isInMemory(string $name): bool
{
$isNamed = str_starts_with($name, 'file:_tenancy_inmemory_') &&
str_ends_with($name, '?mode=memory&cache=shared');
return $name === ':memory:' || $isNamed;
}
/**
* Ensure database name only contains allowed characters
* (allowedDatabaseNameCharacters() + $extraAllowedCharacters) and is not a directory name.
*
* @throws InvalidArgumentException
*/
protected function validateDatabaseName(string $name, string $extraAllowedCharacters = ''): void
{
$this->validateParameter($name, static::$allowedDatabaseNameCharacters . $extraAllowedCharacters);
if ($name === '') {
throw new InvalidArgumentException('Database name cannot be empty.');
}
if (is_dir($name)) {
throw new InvalidArgumentException('Database name cannot be a directory.');
}
}
}