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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user