Add Multi-Tenant SaaS foundation for customer management

Phase 1 implementation includes:

Database:
- schema.sql with tables for tenants, domains, settings, branding,
  streams, users, subscriptions, plans, invoices, viewer_stats

Core Classes (src/Core/):
- Database.php: PDO wrapper with singleton pattern
- TenantResolver.php: Domain-to-tenant resolution with fallback

Tenant Classes (src/Tenant/):
- TenantManager.php: CRUD operations for tenants
- TenantSettingsManager.php: DB-based settings per tenant

Configuration:
- config.example.php: Template for database/stripe/mail config
- bootstrap.php: Initializes multi-tenant environment
- .gitignore: Excludes config.php and cache files

Integration:
- SettingsManager.php: Added saas_features toggles (all off by default)
- index.php: Uses getSiteConfig() from bootstrap when multi-tenant enabled,
  falls back to legacy hardcoded domains when disabled

All SaaS features are disabled by default (saas_features.multi_tenant_enabled = false),
ensuring zero breaking changes to existing installations.
This commit is contained in:
Claude
2026-01-23 16:40:42 +00:00
parent 328b5b5b15
commit 402604b4cc
10 changed files with 1993 additions and 46 deletions
+215
View File
@@ -0,0 +1,215 @@
<?php
/**
* Database - PDO Wrapper mit Singleton Pattern
*
* Verwendung:
* $db = Database::getInstance();
* $users = $db->fetchAll("SELECT * FROM users WHERE tenant_id = ?", [$tenantId]);
*/
namespace AuroraLivecam\Core;
use PDO;
use PDOException;
use Exception;
class Database
{
private static ?Database $instance = null;
private ?PDO $pdo = null;
private array $config;
private function __construct()
{
$this->config = $this->loadConfig();
}
/**
* Singleton: Gibt die einzige Instanz zurück
*/
public static function getInstance(): Database
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Lädt die Datenbank-Konfiguration
*/
private function loadConfig(): array
{
// Versuche .env oder config.php zu laden
$configFile = dirname(__DIR__, 2) . '/config.php';
if (file_exists($configFile)) {
$config = require $configFile;
return $config['database'] ?? [];
}
// Fallback auf Umgebungsvariablen
return [
'host' => getenv('DB_HOST') ?: 'localhost',
'port' => getenv('DB_PORT') ?: 3306,
'database' => getenv('DB_DATABASE') ?: 'aurora_livecam',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: '',
'charset' => 'utf8mb4',
];
}
/**
* Stellt die Datenbankverbindung her (Lazy Loading)
*/
public function connect(): PDO
{
if ($this->pdo !== null) {
return $this->pdo;
}
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->config['host'],
$this->config['port'],
$this->config['database'],
$this->config['charset']
);
try {
$this->pdo = new PDO($dsn, $this->config['username'], $this->config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci"
]);
} catch (PDOException $e) {
throw new Exception('Database connection failed: ' . $e->getMessage());
}
return $this->pdo;
}
/**
* Führt eine Query aus und gibt alle Ergebnisse zurück
*/
public function fetchAll(string $sql, array $params = []): array
{
$stmt = $this->connect()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Führt eine Query aus und gibt eine Zeile zurück
*/
public function fetchOne(string $sql, array $params = []): ?array
{
$stmt = $this->connect()->prepare($sql);
$stmt->execute($params);
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Führt eine Query aus und gibt einen einzelnen Wert zurück
*/
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
$stmt = $this->connect()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchColumn($column);
}
/**
* Führt INSERT/UPDATE/DELETE aus und gibt die Anzahl betroffener Zeilen zurück
*/
public function execute(string $sql, array $params = []): int
{
$stmt = $this->connect()->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
/**
* INSERT und gibt die neue ID zurück
*/
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_map(fn($col) => "`$col`", array_keys($data)));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `$table` ($columns) VALUES ($placeholders)";
$this->execute($sql, array_values($data));
return (int) $this->connect()->lastInsertId();
}
/**
* UPDATE mit WHERE-Bedingung
*/
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$set = implode(', ', array_map(fn($col) => "`$col` = ?", array_keys($data)));
$sql = "UPDATE `$table` SET $set WHERE $where";
return $this->execute($sql, [...array_values($data), ...$whereParams]);
}
/**
* DELETE mit WHERE-Bedingung
*/
public function delete(string $table, string $where, array $params = []): int
{
return $this->execute("DELETE FROM `$table` WHERE $where", $params);
}
/**
* Startet eine Transaktion
*/
public function beginTransaction(): bool
{
return $this->connect()->beginTransaction();
}
/**
* Bestätigt eine Transaktion
*/
public function commit(): bool
{
return $this->connect()->commit();
}
/**
* Macht eine Transaktion rückgängig
*/
public function rollback(): bool
{
return $this->connect()->rollBack();
}
/**
* Prüft ob eine Datenbankverbindung besteht
*/
public function isConnected(): bool
{
return $this->pdo !== null;
}
/**
* Gibt die PDO-Instanz direkt zurück (für komplexe Queries)
*/
public function getPdo(): PDO
{
return $this->connect();
}
// Prevent cloning
private function __clone() {}
// Prevent unserialization
public function __wakeup()
{
throw new Exception("Cannot unserialize singleton");
}
}
+316
View File
@@ -0,0 +1,316 @@
<?php
/**
* TenantResolver - Ermittelt den aktuellen Tenant basierend auf Domain
*
* Ersetzt den hardcoded Domain-Switch in index.php
*/
namespace AuroraLivecam\Core;
class TenantResolver
{
private Database $db;
private ?array $currentTenant = null;
private ?array $currentBranding = null;
private static ?TenantResolver $instance = null;
// Cache für Domain-Lookups (vermeidet DB-Anfragen bei jedem Request)
private static array $domainCache = [];
public function __construct(?Database $db = null)
{
$this->db = $db ?? Database::getInstance();
}
/**
* Singleton für globalen Zugriff
*/
public static function getInstance(): TenantResolver
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Löst die aktuelle Domain auf und gibt den Tenant zurück
*/
public function resolve(?string $domain = null): ?array
{
$domain = $domain ?? $this->getCurrentDomain();
if ($this->currentTenant !== null && ($this->currentTenant['domain'] ?? '') === $domain) {
return $this->currentTenant;
}
// Cache prüfen
if (isset(self::$domainCache[$domain])) {
$this->currentTenant = self::$domainCache[$domain];
return $this->currentTenant;
}
// Aus DB laden
$this->currentTenant = $this->loadTenantByDomain($domain);
// In Cache speichern
self::$domainCache[$domain] = $this->currentTenant;
return $this->currentTenant;
}
/**
* Lädt einen Tenant anhand der Domain aus der Datenbank
*/
private function loadTenantByDomain(string $domain): ?array
{
// Normalisiere Domain (ohne www.)
$normalizedDomain = $this->normalizeDomain($domain);
try {
$sql = "
SELECT
t.*,
td.domain,
td.is_primary,
p.name as plan_name,
p.slug as plan_slug,
p.features as plan_features
FROM tenant_domains td
JOIN tenants t ON td.tenant_id = t.id
LEFT JOIN plans p ON t.plan_id = p.id
WHERE td.domain = ? OR td.domain = ?
LIMIT 1
";
$tenant = $this->db->fetchOne($sql, [$domain, $normalizedDomain]);
if ($tenant && isset($tenant['plan_features'])) {
$tenant['plan_features'] = json_decode($tenant['plan_features'], true);
}
return $tenant;
} catch (\Exception $e) {
// Fallback: Keine DB-Verbindung oder Tabelle existiert nicht
return $this->getFallbackTenant($domain);
}
}
/**
* Fallback für Legacy-Modus (ohne Datenbank)
* Unterstützt die alten hardcoded Domains
*/
private function getFallbackTenant(string $domain): ?array
{
$normalizedDomain = $this->normalizeDomain($domain);
// Alte seecam.ch Konfiguration
if (str_contains($normalizedDomain, 'seecam.ch')) {
return [
'id' => 0,
'uuid' => 'legacy-seecam',
'name' => 'Seecam',
'slug' => 'seecam',
'status' => 'active',
'domain' => $domain,
'is_legacy' => true,
'branding' => [
'site_name' => 'Seecam',
'site_name_full' => 'Seecam.ch - Live Webcam',
'tagline' => 'Ihre Live-Webcam',
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
],
];
}
// Default: Aurora
if (str_contains($normalizedDomain, 'aurora') ||
str_contains($normalizedDomain, 'localhost') ||
$normalizedDomain === '127.0.0.1') {
return [
'id' => 0,
'uuid' => 'legacy-aurora',
'name' => 'Aurora Weather Livecam',
'slug' => 'aurora',
'status' => 'active',
'domain' => $domain,
'is_legacy' => true,
'branding' => [
'site_name' => 'Aurora',
'site_name_full' => 'Aurora Weather Livecam - Zürich Oberland',
'tagline' => 'Wetter Webcam Schweiz',
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
],
];
}
// Unbekannte Domain - Default Tenant
return [
'id' => 0,
'uuid' => 'default',
'name' => 'Livecam',
'slug' => 'default',
'status' => 'active',
'domain' => $domain,
'is_legacy' => true,
'branding' => [
'site_name' => 'Livecam',
'site_name_full' => 'Livecam',
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
],
];
}
/**
* Gibt das Branding des aktuellen Tenants zurück
*/
public function getBranding(): array
{
if ($this->currentBranding !== null) {
return $this->currentBranding;
}
$tenant = $this->resolve();
if (!$tenant) {
return $this->getDefaultBranding();
}
// Legacy-Tenant hat Branding inline
if (isset($tenant['is_legacy']) && $tenant['is_legacy']) {
$this->currentBranding = $tenant['branding'] ?? $this->getDefaultBranding();
return $this->currentBranding;
}
// Aus DB laden
try {
$branding = $this->db->fetchOne(
"SELECT * FROM tenant_branding WHERE tenant_id = ?",
[$tenant['id']]
);
$this->currentBranding = $branding ?: $this->getDefaultBranding();
} catch (\Exception $e) {
$this->currentBranding = $this->getDefaultBranding();
}
return $this->currentBranding;
}
/**
* Default Branding
*/
private function getDefaultBranding(): array
{
return [
'site_name' => 'Livecam',
'site_name_full' => 'Live Webcam',
'tagline' => '',
'logo_path' => null,
'favicon_path' => null,
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
'accent_color' => '#f093fb',
'welcome_text_de' => '',
'welcome_text_en' => '',
'footer_text' => '',
'custom_css' => '',
];
}
/**
* Gibt die aktuelle Domain zurück
*/
public function getCurrentDomain(): string
{
return $_SERVER['HTTP_HOST'] ?? 'localhost';
}
/**
* Normalisiert eine Domain (entfernt www.)
*/
private function normalizeDomain(string $domain): string
{
return preg_replace('/^www\./i', '', strtolower($domain));
}
/**
* Prüft ob der aktuelle Tenant aktiv ist
*/
public function isActive(): bool
{
$tenant = $this->resolve();
return $tenant && in_array($tenant['status'], ['active', 'trial']);
}
/**
* Prüft ob der Tenant im Trial ist
*/
public function isTrial(): bool
{
$tenant = $this->resolve();
return $tenant && $tenant['status'] === 'trial';
}
/**
* Gibt die Tenant-ID zurück (oder 0 für Legacy)
*/
public function getTenantId(): int
{
$tenant = $this->resolve();
return $tenant['id'] ?? 0;
}
/**
* Gibt den Tenant-Slug zurück
*/
public function getTenantSlug(): string
{
$tenant = $this->resolve();
return $tenant['slug'] ?? 'default';
}
/**
* Prüft ob Multi-Tenant-Modus aktiv ist (DB vorhanden)
*/
public function isMultiTenantEnabled(): bool
{
$tenant = $this->resolve();
return $tenant && !isset($tenant['is_legacy']);
}
/**
* Gibt alle Domains eines Tenants zurück
*/
public function getTenantDomains(int $tenantId): array
{
try {
return $this->db->fetchAll(
"SELECT * FROM tenant_domains WHERE tenant_id = ? ORDER BY is_primary DESC",
[$tenantId]
);
} catch (\Exception $e) {
return [];
}
}
/**
* Setzt den aktuellen Tenant manuell (für Tests oder CLI)
*/
public function setTenant(array $tenant): void
{
$this->currentTenant = $tenant;
$this->currentBranding = null;
}
/**
* Leert den Cache
*/
public static function clearCache(): void
{
self::$domainCache = [];
}
}