Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions phpmyfaq/content/core/config/database.php.original
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ $DB['password'] = '';
$DB['db'] = '';
$DB['prefix'] = '';
$DB['type'] = '';
$DB['schema'] = '';
51 changes: 50 additions & 1 deletion phpmyfaq/src/phpMyFAQ/Bootstrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use phpMyFAQ\Core\Exception;
use phpMyFAQ\Core\Exception\DatabaseConnectionException;
use phpMyFAQ\Database\DatabaseDriver;
use RuntimeException;
use Symfony\Component\HttpFoundation\Request;

class Bootstrapper
Expand Down Expand Up @@ -146,7 +147,9 @@ private function connectDatabase(string $databaseFile): void
$dbConfig->getDatabase(),
$dbConfig->getPort(),
);
} catch (Exception $exception) {

$this->switchToTenantSchema($dbConfig);
} catch (Exception|RuntimeException $exception) {
throw new DatabaseConnectionException(
message: 'Database connection failed: ' . $exception->getMessage(),
code: 500,
Expand All @@ -166,6 +169,52 @@ private function connectDatabase(string $databaseFile): void
}
}

/**
* Switches to the tenant's schema or database after connection, if configured.
*
* @throws RuntimeException
*/
private function switchToTenantSchema(DatabaseConfiguration $dbConfig): void
{
$schema = $dbConfig->getSchema();
if ($schema === null || $schema === '') {
return;
}

$schema = trim($schema);
if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) {
throw new RuntimeException('Invalid tenant schema identifier.');
}

$dbType = $dbConfig->getType();

try {
if (str_contains($dbType, 'mysql')) {
$quotedSchema = sprintf('`%s`', str_replace('`', '``', $schema));
$result = $this->db->query(sprintf('USE %s', $quotedSchema));
if ($result === false) {
throw new RuntimeException('Failed to switch to tenant schema for MySQL.');
}
return;
}

if (str_contains($dbType, 'pgsql')) {
$quotedSchema = sprintf('"%s"', str_replace('"', '""', $schema));
$result = $this->db->query(sprintf('SET search_path TO %s', $quotedSchema));
if ($result === false) {
throw new RuntimeException('Failed to switch to tenant schema for PostgreSQL.');
}
}
} catch (\Throwable $exception) {
throw new RuntimeException(
'Failed to switch to tenant schema: ' . $exception->getMessage(),
previous: $exception,
);
}

// SQL Server uses a schema prefix in queries; no global switch needed.
}

private function configureLdap(): void
{
if ($this->faqConfig->isLdapActive() && file_exists(PMF_CONFIG_DIR . '/ldap.php') && extension_loaded('ldap')) {
Expand Down
9 changes: 9 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Configuration/DatabaseConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@

private string $type;

private ?string $schema;

public function __construct(string $filename)
{
$DB = [
Expand All @@ -45,6 +47,7 @@ public function __construct(string $filename)
'db' => '',
'prefix' => '',
'type' => '',
'schema' => '',
];

include $filename;
Expand All @@ -56,6 +59,7 @@ public function __construct(string $filename)
$this->database = $DB['db'];
$this->prefix = $DB['prefix'];
$this->type = $DB['type'];
$this->schema = $DB['schema'] === '' ? null : $DB['schema'];
}

public function getServer(): string
Expand Down Expand Up @@ -92,4 +96,9 @@ public function getType(): string
{
return $this->type;
}

public function getSchema(): ?string
{
return $this->schema;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,56 @@ public static function locateConfigurationDirectory(Request $request, string $co
$parsed = parse_url($protocol . '://' . $host . $scriptName);

if (isset($parsed['host']) && $parsed['host'] !== '') {
// 1. Try an exact hostname match (existing behavior)
$configDir = rtrim($configurationDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $parsed['host'];

if (is_dir($configDir)) {
return $configDir;
}

// 2. Try subdomain-based tenant matching
$tenantName = self::extractTenantFromSubdomain($parsed['host']);
if ($tenantName !== null) {
$configDir = rtrim($configurationDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $tenantName;

if (is_dir($configDir)) {
return $configDir;
}
}
}

return null;
}

/**
* Extracts the tenant identifier from a subdomain pattern.
*
* Checks the PMF_MULTISITE_BASE_DOMAIN environment variable. If set,
* extracts the subdomain part from hostnames matching {tenant}.{baseDomain}.
*
* Example: With PMF_MULTISITE_BASE_DOMAIN=faq.example.com,
* the host "acme.faq.example.com" returns "acme".
*/
public static function extractTenantFromSubdomain(string $host): ?string
{
$host = strtolower($host);
$baseDomain = getenv('PMF_MULTISITE_BASE_DOMAIN');
if ($baseDomain === false || $baseDomain === '') {
return null;
}

$baseDomain = strtolower(ltrim($baseDomain, characters: '.'));
$suffix = '.' . $baseDomain;

if (!str_ends_with($host, $suffix)) {
return null;
}

$tenant = strtolower(substr($host, offset: 0, length: -strlen($suffix)));
if ($tenant === '' || str_contains($tenant, '.')) {
return null;
}

return $tenant;
}
}
34 changes: 34 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Enums/TenantIsolationMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/**
* Tenant isolation mode enum
*
* Defines the isolation strategy for multi-tenant instances:
* - prefix: Table prefix isolation in a shared database (default)
* - schema: Schema-per-tenant isolation in a shared database
* - database: Separate database per tenant
*
* Configurable via the PMF_TENANT_ISOLATION_MODE environment variable.
*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at https://mozilla.org/MPL/2.0/.
*
* @package phpMyFAQ
* @author Thorsten Rinne <[email protected]>
* @copyright 2026 phpMyFAQ Team
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
* @link https://www.phpmyfaq.de
* @since 2026-02-08
*/

declare(strict_types=1);

namespace phpMyFAQ\Enums;

enum TenantIsolationMode: string
{
case PREFIX = 'prefix';
case SCHEMA = 'schema';
case DATABASE = 'database';
}
Loading