diff --git a/aurora-livecam/.gitignore b/aurora-livecam/.gitignore new file mode 100644 index 0000000..28d83e0 --- /dev/null +++ b/aurora-livecam/.gitignore @@ -0,0 +1,26 @@ +# Configuration (contains secrets) +config.php + +# Cache files +weather_cache.json +active_viewers.json + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Vendor (if using composer) +# vendor/ + +# Uploads (user content) +uploads/ diff --git a/aurora-livecam/SettingsManager.php b/aurora-livecam/SettingsManager.php index 3dc0415..bb3336d 100644 --- a/aurora-livecam/SettingsManager.php +++ b/aurora-livecam/SettingsManager.php @@ -83,6 +83,38 @@ class SettingsManager { 'update_interval' => 5, // Minuten 'units' => 'metric' // metric (Celsius) oder imperial (Fahrenheit) ], + // SaaS Features - alle aktivierbar/deaktivierbar + 'saas_features' => [ + // Multi-Tenant + 'multi_tenant_enabled' => false, // Aktiviert DB-basierte Tenant-Verwaltung + 'customer_management_enabled' => false, + + // Onboarding + 'self_registration_enabled' => false, + 'email_verification_required' => true, + 'trial_enabled' => true, + 'trial_days' => 14, + + // Billing + 'billing_enabled' => false, + 'stripe_enabled' => false, + 'free_plan_available' => true, + + // Dashboard + 'tenant_dashboard_enabled' => false, + 'analytics_enabled' => false, + 'custom_domain_enabled' => false, + 'custom_branding_enabled' => false, + + // Landing + 'landing_page_enabled' => false, + 'demo_mode_enabled' => false, + + // Limits (Default für Free-Plan) + 'default_max_viewers' => 50, + 'default_storage_mb' => 500, + 'default_retention_days' => 7 + ], 'last_updated' => null, 'updated_by' => null ]; @@ -277,4 +309,49 @@ class SettingsManager { public function getWeatherUnits() { return $this->get('weather.units') ?? 'metric'; } + + // SaaS Feature Helper + public function isMultiTenantEnabled() { + return $this->get('saas_features.multi_tenant_enabled') === true; + } + + public function isSelfRegistrationEnabled() { + return $this->get('saas_features.self_registration_enabled') === true; + } + + public function isBillingEnabled() { + return $this->get('saas_features.billing_enabled') === true; + } + + public function isStripeEnabled() { + return $this->get('saas_features.stripe_enabled') === true; + } + + public function isTenantDashboardEnabled() { + return $this->get('saas_features.tenant_dashboard_enabled') === true; + } + + public function isAnalyticsEnabled() { + return $this->get('saas_features.analytics_enabled') === true; + } + + public function isCustomDomainEnabled() { + return $this->get('saas_features.custom_domain_enabled') === true; + } + + public function isCustomBrandingEnabled() { + return $this->get('saas_features.custom_branding_enabled') === true; + } + + public function isLandingPageEnabled() { + return $this->get('saas_features.landing_page_enabled') === true; + } + + public function getTrialDays() { + return $this->get('saas_features.trial_days') ?? 14; + } + + public function getDefaultMaxViewers() { + return $this->get('saas_features.default_max_viewers') ?? 50; + } } diff --git a/aurora-livecam/config.example.php b/aurora-livecam/config.example.php new file mode 100644 index 0000000..e7e8bb8 --- /dev/null +++ b/aurora-livecam/config.example.php @@ -0,0 +1,59 @@ + [ + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'aurora_livecam', + 'username' => 'root', + 'password' => '', + 'charset' => 'utf8mb4', + ], + + // Anwendungs-Einstellungen + 'app' => [ + 'name' => 'Aurora Livecam', + 'url' => 'https://aurora-weather-livecam.com', + 'debug' => false, + 'timezone' => 'Europe/Zurich', + ], + + // Multi-Tenant Einstellungen + 'tenant' => [ + 'default_subdomain_suffix' => '.aurora-livecam.com', + 'allow_custom_domains' => true, + 'trial_days' => 14, + ], + + // Stripe (für Billing) + 'stripe' => [ + 'public_key' => '', + 'secret_key' => '', + 'webhook_secret' => '', + 'currency' => 'chf', + ], + + // E-Mail Einstellungen (für Onboarding) + 'mail' => [ + 'host' => 'smtp.example.com', + 'port' => 587, + 'username' => '', + 'password' => '', + 'from_address' => 'noreply@aurora-livecam.com', + 'from_name' => 'Aurora Livecam', + ], + + // Sicherheit + 'security' => [ + 'session_lifetime' => 7200, // 2 Stunden + 'remember_me_days' => 30, + 'password_min_length' => 8, + ], +]; diff --git a/aurora-livecam/database/schema.sql b/aurora-livecam/database/schema.sql new file mode 100644 index 0000000..a5a8c8e --- /dev/null +++ b/aurora-livecam/database/schema.sql @@ -0,0 +1,205 @@ +-- Aurora Livecam - Multi-Tenant SaaS Schema +-- Version: 1.0.0 + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- -------------------------------------------------------- +-- Subscription Plans +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `plans` ( + `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `slug` VARCHAR(50) UNIQUE NOT NULL, + `stripe_price_id` VARCHAR(100) NULL, + `price_monthly` DECIMAL(10,2) DEFAULT 0.00, + `price_yearly` DECIMAL(10,2) DEFAULT 0.00, + `features` JSON NULL COMMENT '{"max_viewers": 100, "storage_gb": 5, "custom_domain": true}', + `is_active` TINYINT(1) DEFAULT 1, + `sort_order` INT DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Default Plans +INSERT INTO `plans` (`name`, `slug`, `price_monthly`, `price_yearly`, `features`, `sort_order`) VALUES +('Free', 'free', 0.00, 0.00, '{"max_viewers": 10, "storage_gb": 0.5, "custom_domain": false, "weather_widget": true, "timelapse": false, "analytics": false, "branding": false}', 1), +('Basic', 'basic', 19.00, 190.00, '{"max_viewers": 50, "storage_gb": 5, "custom_domain": false, "weather_widget": true, "timelapse": true, "analytics": true, "branding": false}', 2), +('Professional', 'professional', 49.00, 490.00, '{"max_viewers": 200, "storage_gb": 20, "custom_domain": true, "weather_widget": true, "timelapse": true, "analytics": true, "branding": true}', 3), +('Enterprise', 'enterprise', 149.00, 1490.00, '{"max_viewers": -1, "storage_gb": 100, "custom_domain": true, "weather_widget": true, "timelapse": true, "analytics": true, "branding": true, "priority_support": true}', 4); + +-- -------------------------------------------------------- +-- Tenants (Customers) +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `tenants` ( + `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `uuid` VARCHAR(36) UNIQUE NOT NULL, + `name` VARCHAR(255) NOT NULL, + `slug` VARCHAR(100) UNIQUE NOT NULL COMMENT 'URL-safe identifier, e.g. aurora, seecam', + `email` VARCHAR(255) NOT NULL, + `status` ENUM('trial', 'active', 'suspended', 'cancelled') DEFAULT 'trial', + `plan_id` INT UNSIGNED NULL, + `trial_ends_at` TIMESTAMP NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`plan_id`) REFERENCES `plans`(`id`) ON DELETE SET NULL, + INDEX `idx_status` (`status`), + INDEX `idx_slug` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Tenant Domains +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `tenant_domains` ( + `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `domain` VARCHAR(255) UNIQUE NOT NULL, + `is_primary` TINYINT(1) DEFAULT 0, + `ssl_status` ENUM('pending', 'active', 'failed') DEFAULT 'pending', + `verified_at` TIMESTAMP NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE, + INDEX `idx_domain` (`domain`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Tenant Settings (replaces settings.json per tenant) +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `tenant_settings` ( + `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `setting_key` VARCHAR(255) NOT NULL, + `setting_value` TEXT NULL, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `uk_tenant_key` (`tenant_id`, `setting_key`), + FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Tenant Branding +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `tenant_branding` ( + `tenant_id` INT UNSIGNED PRIMARY KEY, + `site_name` VARCHAR(255) NULL, + `site_name_full` VARCHAR(255) NULL, + `tagline` VARCHAR(255) NULL, + `logo_path` VARCHAR(500) NULL, + `favicon_path` VARCHAR(500) NULL, + `primary_color` VARCHAR(7) DEFAULT '#667eea', + `secondary_color` VARCHAR(7) DEFAULT '#764ba2', + `accent_color` VARCHAR(7) DEFAULT '#f093fb', + `welcome_text_de` TEXT NULL, + `welcome_text_en` TEXT NULL, + `footer_text` TEXT NULL, + `custom_css` TEXT NULL, + `custom_js` TEXT NULL, + `social_facebook` VARCHAR(255) NULL, + `social_instagram` VARCHAR(255) NULL, + `social_youtube` VARCHAR(255) NULL, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Tenant Streams +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `tenant_streams` ( + `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(255) DEFAULT 'Main Stream', + `stream_url` VARCHAR(500) NOT NULL, + `stream_type` ENUM('hls', 'rtmp', 'webrtc', 'iframe') DEFAULT 'hls', + `is_active` TINYINT(1) DEFAULT 1, + `is_primary` TINYINT(1) DEFAULT 1, + `last_check_at` TIMESTAMP NULL, + `last_status` ENUM('online', 'offline', 'error') NULL, + `error_message` VARCHAR(500) NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Users +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NULL COMMENT 'NULL = Super Admin', + `email` VARCHAR(255) UNIQUE NOT NULL, + `password_hash` VARCHAR(255) NOT NULL, + `name` VARCHAR(255) NULL, + `role` ENUM('super_admin', 'tenant_admin', 'tenant_user') NOT NULL DEFAULT 'tenant_user', + `email_verified_at` TIMESTAMP NULL, + `last_login_at` TIMESTAMP NULL, + `remember_token` VARCHAR(100) NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE, + INDEX `idx_email` (`email`), + INDEX `idx_tenant` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Subscriptions +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `subscriptions` ( + `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `plan_id` INT UNSIGNED NOT NULL, + `stripe_subscription_id` VARCHAR(100) NULL, + `stripe_customer_id` VARCHAR(100) NULL, + `status` ENUM('trialing', 'active', 'past_due', 'canceled', 'unpaid', 'incomplete') DEFAULT 'trialing', + `current_period_start` TIMESTAMP NULL, + `current_period_end` TIMESTAMP NULL, + `canceled_at` TIMESTAMP NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`plan_id`) REFERENCES `plans`(`id`), + INDEX `idx_tenant` (`tenant_id`), + INDEX `idx_stripe_sub` (`stripe_subscription_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Invoices (Stripe cache) +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `invoices` ( + `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `stripe_invoice_id` VARCHAR(100) UNIQUE NULL, + `amount` DECIMAL(10,2) NOT NULL, + `currency` VARCHAR(3) DEFAULT 'CHF', + `status` VARCHAR(50) NULL, + `paid_at` TIMESTAMP NULL, + `invoice_pdf_url` VARCHAR(500) NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Viewer Statistics +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `viewer_stats` ( + `id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `recorded_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `viewer_count` INT DEFAULT 0, + `unique_sessions` INT DEFAULT 0, + FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE, + INDEX `idx_tenant_time` (`tenant_id`, `recorded_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- Onboarding Progress +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `tenant_onboarding` ( + `tenant_id` INT UNSIGNED PRIMARY KEY, + `current_step` INT DEFAULT 1, + `stream_verified` TINYINT(1) DEFAULT 0, + `branding_configured` TINYINT(1) DEFAULT 0, + `payment_configured` TINYINT(1) DEFAULT 0, + `completed_at` TIMESTAMP NULL, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/aurora-livecam/index.php b/aurora-livecam/index.php index bdde27c..9b0c44f 100644 --- a/aurora-livecam/index.php +++ b/aurora-livecam/index.php @@ -6,6 +6,11 @@ require __DIR__ . '/vendor/autoload.php'; require_once 'SettingsManager.php'; require_once 'WeatherManager.php'; +// Multi-Tenant Bootstrap laden (falls vorhanden) +if (file_exists(__DIR__ . '/src/bootstrap.php')) { + require_once __DIR__ . '/src/bootstrap.php'; +} + // SettingsManager initialisieren $settingsManager = new SettingsManager(); @@ -60,70 +65,104 @@ function safeRedirect($url) { exit(); } -// Hauptlogik +// Hauptlogik - Domain Redirects werden jetzt in bootstrap.php behandelt +// (Legacy-Redirect bleibt als Fallback falls Bootstrap nicht geladen) $oldDomains = [ 'www.aurora-wetter-lifecam.ch', 'www.aurora-wetter-livecam.ch' ]; $newDomain = 'www.aurora-weather-livecam.com'; -if (in_array($_SERVER['HTTP_HOST'], $oldDomains)) { +if (in_array($_SERVER['HTTP_HOST'] ?? '', $oldDomains)) { $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; $newUrl = $protocol . '://' . $newDomain . $_SERVER['REQUEST_URI']; - - // Logging für Debugging - error_log("Umleitung von {$_SERVER['HTTP_HOST']} nach $newUrl"); - if (!headers_sent()) { header("HTTP/1.1 301 Moved Permanently"); header("Location: " . $newUrl); - } else { - echo ''; + exit(); } - exit(); } -// Site-Konfiguration basierend auf Domain -$isSeecam = ($_SERVER['HTTP_HOST'] === 'www.seecam.ch' || $_SERVER['HTTP_HOST'] === 'seecam.ch'); +// Site-Konfiguration: Nutze Multi-Tenant System falls verfügbar, sonst Legacy +if (function_exists('getSiteConfig')) { + // Multi-Tenant Modus (aus bootstrap.php) + $tenantConfig = getSiteConfig(); + $isSeecam = ($tenantConfig['tenant_slug'] === 'seecam'); -if ($isSeecam) { $siteConfig = [ - 'domain' => 'www.seecam.ch', - 'domainUrl' => 'https://www.seecam.ch', - 'logo' => 'seecam.jpg', - 'siteName' => 'Seecam', - 'siteNameFull' => 'Seecam Wetter Livecam', - 'siteNameFullEn' => 'Seecam Weather Livecam', - 'siteTitle' => 'Zürich Oberland Webcam Live - Zürichsee & Patrouille Suisse | Seecam 24/7', - 'author' => 'Seecam Wetter Livecam', - 'alternateName' => 'Seecam Webcam Schweiz', - 'welcomeDe' => 'Willkommen bei Seecam Wetter Livecam', - 'welcomeEn' => 'Welcome to Seecam Weather Livecam', - 'aboutDe' => 'Seecam Wetter Livecam ist ein Herzensprojekt von Wetterbegeisterten. Wir möchten Ihnen die Schönheit der Natur und Faszination des Wetters näher bringen.', - 'aboutEn' => 'Seecam Weather Livecam is a passion project...', - 'blogTitle' => 'Seecam Wetter Blog', - 'footerName' => 'Seecam Wetter Livecam', - 'copyright' => '© 2024 Seecam Wetter Livecam - Webcam Zürich Oberland' + 'domain' => $_SERVER['HTTP_HOST'] ?? 'localhost', + 'domainUrl' => (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost'), + 'logo' => $tenantConfig['logo_path'] ?? ($isSeecam ? 'seecam.jpg' : 'logo.png'), + 'siteName' => $tenantConfig['site_name'], + 'siteNameFull' => $tenantConfig['site_name_full'], + 'siteNameFullEn' => $tenantConfig['site_name_full'], + 'siteTitle' => $tenantConfig['site_name_full'] . ' - Live Webcam', + 'author' => $tenantConfig['site_name_full'], + 'alternateName' => $tenantConfig['site_name'] . ' Webcam Schweiz', + 'welcomeDe' => $tenantConfig['welcome_de'] ?: ('Willkommen bei ' . $tenantConfig['site_name_full']), + 'welcomeEn' => $tenantConfig['welcome_en'] ?: ('Welcome to ' . $tenantConfig['site_name_full']), + 'aboutDe' => $tenantConfig['site_name_full'] . ' ist ein Herzensprojekt von Wetterbegeisterten.', + 'aboutEn' => $tenantConfig['site_name_full'] . ' is a passion project by weather enthusiasts.', + 'blogTitle' => $tenantConfig['site_name'] . ' Wetter Blog', + 'footerName' => $tenantConfig['site_name_full'], + 'copyright' => '© ' . date('Y') . ' ' . $tenantConfig['site_name_full'], + // Zusätzliche Multi-Tenant Felder + 'tenant_id' => $tenantConfig['tenant_id'] ?? 0, + 'primary_color' => $tenantConfig['primary_color'] ?? '#667eea', + 'secondary_color' => $tenantConfig['secondary_color'] ?? '#764ba2', + 'custom_css' => $tenantConfig['custom_css'] ?? '', ]; } else { - $siteConfig = [ - 'domain' => 'www.aurora-weather-livecam.com', - 'domainUrl' => 'https://www.aurora-weather-livecam.com', - 'logo' => 'logo.png', - 'siteName' => 'Aurora', - 'siteNameFull' => 'Aurora Wetter Livecam', - 'siteNameFullEn' => 'Aurora Weather Livecam', - 'siteTitle' => 'Zürich Oberland Webcam Live - Zürichsee & Patrouille Suisse | Aurora Livecam 24/7', - 'author' => 'Aurora Wetter Livecam', - 'alternateName' => 'Aurora Webcam Schweiz', - 'welcomeDe' => 'Willkommen bei Aurora Wetter Livecam', - 'welcomeEn' => 'Welcome to Aurora Weather Livecam', - 'aboutDe' => 'Aurora Wetter Livecam ist ein Herzensprojekt von Wetterbegeisterten. Wir möchten Ihnen die Schönheit der Natur und Faszination des Wetters näher bringen.', - 'aboutEn' => 'Aurora Weather Livecam is a passion project...', - 'blogTitle' => 'Aurora Wetter Blog', - 'footerName' => 'Aurora Wetter Livecam', - 'copyright' => '© 2024 Aurora Wetter Lifecam - Webcam Zürich Oberland' - ]; + // Legacy-Modus (hardcoded) + $isSeecam = ($_SERVER['HTTP_HOST'] === 'www.seecam.ch' || $_SERVER['HTTP_HOST'] === 'seecam.ch'); + + if ($isSeecam) { + $siteConfig = [ + 'domain' => 'www.seecam.ch', + 'domainUrl' => 'https://www.seecam.ch', + 'logo' => 'seecam.jpg', + 'siteName' => 'Seecam', + 'siteNameFull' => 'Seecam Wetter Livecam', + 'siteNameFullEn' => 'Seecam Weather Livecam', + 'siteTitle' => 'Zürich Oberland Webcam Live - Zürichsee & Patrouille Suisse | Seecam 24/7', + 'author' => 'Seecam Wetter Livecam', + 'alternateName' => 'Seecam Webcam Schweiz', + 'welcomeDe' => 'Willkommen bei Seecam Wetter Livecam', + 'welcomeEn' => 'Welcome to Seecam Weather Livecam', + 'aboutDe' => 'Seecam Wetter Livecam ist ein Herzensprojekt von Wetterbegeisterten.', + 'aboutEn' => 'Seecam Weather Livecam is a passion project.', + 'blogTitle' => 'Seecam Wetter Blog', + 'footerName' => 'Seecam Wetter Livecam', + 'copyright' => '© 2024 Seecam Wetter Livecam - Webcam Zürich Oberland', + 'tenant_id' => 0, + 'primary_color' => '#667eea', + 'secondary_color' => '#764ba2', + 'custom_css' => '', + ]; + } else { + $siteConfig = [ + 'domain' => 'www.aurora-weather-livecam.com', + 'domainUrl' => 'https://www.aurora-weather-livecam.com', + 'logo' => 'logo.png', + 'siteName' => 'Aurora', + 'siteNameFull' => 'Aurora Wetter Livecam', + 'siteNameFullEn' => 'Aurora Weather Livecam', + 'siteTitle' => 'Zürich Oberland Webcam Live - Zürichsee & Patrouille Suisse | Aurora Livecam 24/7', + 'author' => 'Aurora Wetter Livecam', + 'alternateName' => 'Aurora Webcam Schweiz', + 'welcomeDe' => 'Willkommen bei Aurora Wetter Livecam', + 'welcomeEn' => 'Welcome to Aurora Weather Livecam', + 'aboutDe' => 'Aurora Wetter Livecam ist ein Herzensprojekt von Wetterbegeisterten.', + 'aboutEn' => 'Aurora Weather Livecam is a passion project.', + 'blogTitle' => 'Aurora Wetter Blog', + 'footerName' => 'Aurora Wetter Livecam', + 'copyright' => '© 2024 Aurora Wetter Lifecam - Webcam Zürich Oberland', + 'tenant_id' => 0, + 'primary_color' => '#667eea', + 'secondary_color' => '#764ba2', + 'custom_css' => '', + ]; + } } diff --git a/aurora-livecam/src/Core/Database.php b/aurora-livecam/src/Core/Database.php new file mode 100644 index 0000000..6f35d22 --- /dev/null +++ b/aurora-livecam/src/Core/Database.php @@ -0,0 +1,215 @@ +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"); + } +} diff --git a/aurora-livecam/src/Core/TenantResolver.php b/aurora-livecam/src/Core/TenantResolver.php new file mode 100644 index 0000000..e0f8659 --- /dev/null +++ b/aurora-livecam/src/Core/TenantResolver.php @@ -0,0 +1,316 @@ +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 = []; + } +} diff --git a/aurora-livecam/src/Tenant/TenantManager.php b/aurora-livecam/src/Tenant/TenantManager.php new file mode 100644 index 0000000..c73a93f --- /dev/null +++ b/aurora-livecam/src/Tenant/TenantManager.php @@ -0,0 +1,404 @@ +db = $db ?? Database::getInstance(); + } + + /** + * Erstellt einen neuen Tenant + */ + public function create(array $data): int + { + $this->db->beginTransaction(); + + try { + // UUID generieren + $uuid = $this->generateUuid(); + + // Slug generieren falls nicht vorhanden + $slug = $data['slug'] ?? $this->generateSlug($data['name']); + + // Tenant erstellen + $tenantId = $this->db->insert('tenants', [ + 'uuid' => $uuid, + 'name' => $data['name'], + 'slug' => $slug, + 'email' => $data['email'], + 'status' => $data['status'] ?? 'trial', + 'plan_id' => $data['plan_id'] ?? $this->getDefaultPlanId(), + 'trial_ends_at' => $data['trial_ends_at'] ?? $this->calculateTrialEnd(), + ]); + + // Domain hinzufügen + if (!empty($data['domain'])) { + $this->addDomain($tenantId, $data['domain'], true); + } + + // Default-Subdomain erstellen + if (!empty($data['subdomain'])) { + $subdomain = $data['subdomain'] . '.aurora-livecam.com'; + $this->addDomain($tenantId, $subdomain, empty($data['domain'])); + } + + // Branding mit Defaults initialisieren + $this->db->insert('tenant_branding', [ + 'tenant_id' => $tenantId, + 'site_name' => $data['name'], + 'site_name_full' => $data['name'], + ]); + + // Onboarding initialisieren + $this->db->insert('tenant_onboarding', [ + 'tenant_id' => $tenantId, + 'current_step' => 1, + ]); + + // Stream hinzufügen falls vorhanden + if (!empty($data['stream_url'])) { + $this->db->insert('tenant_streams', [ + 'tenant_id' => $tenantId, + 'name' => 'Main Stream', + 'stream_url' => $data['stream_url'], + 'stream_type' => $data['stream_type'] ?? 'hls', + 'is_primary' => 1, + ]); + } + + $this->db->commit(); + + return $tenantId; + + } catch (Exception $e) { + $this->db->rollback(); + throw $e; + } + } + + /** + * Aktualisiert einen Tenant + */ + public function update(int $tenantId, array $data): bool + { + $allowedFields = ['name', 'email', 'status', 'plan_id']; + $updateData = array_intersect_key($data, array_flip($allowedFields)); + + if (empty($updateData)) { + return false; + } + + return $this->db->update('tenants', $updateData, 'id = ?', [$tenantId]) > 0; + } + + /** + * Löscht einen Tenant (Soft-Delete durch Status-Änderung) + */ + public function delete(int $tenantId): bool + { + return $this->db->update('tenants', ['status' => 'cancelled'], 'id = ?', [$tenantId]) > 0; + } + + /** + * Hard-Delete (wirklich löschen - Vorsicht!) + */ + public function hardDelete(int $tenantId): bool + { + return $this->db->delete('tenants', 'id = ?', [$tenantId]) > 0; + } + + /** + * Gibt einen Tenant anhand der ID zurück + */ + public function getById(int $id): ?array + { + return $this->db->fetchOne( + "SELECT t.*, p.name as plan_name, p.features as plan_features + FROM tenants t + LEFT JOIN plans p ON t.plan_id = p.id + WHERE t.id = ?", + [$id] + ); + } + + /** + * Gibt einen Tenant anhand des Slugs zurück + */ + public function getBySlug(string $slug): ?array + { + return $this->db->fetchOne( + "SELECT t.*, p.name as plan_name, p.features as plan_features + FROM tenants t + LEFT JOIN plans p ON t.plan_id = p.id + WHERE t.slug = ?", + [$slug] + ); + } + + /** + * Gibt einen Tenant anhand der UUID zurück + */ + public function getByUuid(string $uuid): ?array + { + return $this->db->fetchOne( + "SELECT t.*, p.name as plan_name, p.features as plan_features + FROM tenants t + LEFT JOIN plans p ON t.plan_id = p.id + WHERE t.uuid = ?", + [$uuid] + ); + } + + /** + * Listet alle Tenants auf + */ + public function getAll(array $filters = []): array + { + $sql = "SELECT t.*, p.name as plan_name, p.features as plan_features + FROM tenants t + LEFT JOIN plans p ON t.plan_id = p.id + WHERE 1=1"; + $params = []; + + if (!empty($filters['status'])) { + $sql .= " AND t.status = ?"; + $params[] = $filters['status']; + } + + if (!empty($filters['search'])) { + $sql .= " AND (t.name LIKE ? OR t.email LIKE ?)"; + $params[] = '%' . $filters['search'] . '%'; + $params[] = '%' . $filters['search'] . '%'; + } + + $sql .= " ORDER BY t.created_at DESC"; + + if (!empty($filters['limit'])) { + $sql .= " LIMIT " . (int)$filters['limit']; + if (!empty($filters['offset'])) { + $sql .= " OFFSET " . (int)$filters['offset']; + } + } + + return $this->db->fetchAll($sql, $params); + } + + /** + * Zählt Tenants + */ + public function count(array $filters = []): int + { + $sql = "SELECT COUNT(*) FROM tenants WHERE 1=1"; + $params = []; + + if (!empty($filters['status'])) { + $sql .= " AND status = ?"; + $params[] = $filters['status']; + } + + return (int) $this->db->fetchColumn($sql, $params); + } + + /** + * Fügt eine Domain zu einem Tenant hinzu + */ + public function addDomain(int $tenantId, string $domain, bool $isPrimary = false): int + { + // Normalisiere Domain + $domain = strtolower(trim($domain)); + + // Prüfe ob Domain bereits existiert + $existing = $this->db->fetchOne( + "SELECT id FROM tenant_domains WHERE domain = ?", + [$domain] + ); + + if ($existing) { + throw new Exception("Domain '$domain' is already in use"); + } + + // Wenn primary, setze alle anderen auf non-primary + if ($isPrimary) { + $this->db->execute( + "UPDATE tenant_domains SET is_primary = 0 WHERE tenant_id = ?", + [$tenantId] + ); + } + + return $this->db->insert('tenant_domains', [ + 'tenant_id' => $tenantId, + 'domain' => $domain, + 'is_primary' => $isPrimary ? 1 : 0, + ]); + } + + /** + * Entfernt eine Domain von einem Tenant + */ + public function removeDomain(int $tenantId, string $domain): bool + { + return $this->db->delete('tenant_domains', 'tenant_id = ? AND domain = ?', [$tenantId, $domain]) > 0; + } + + /** + * Gibt alle Domains eines Tenants zurück + */ + public function getDomains(int $tenantId): array + { + return $this->db->fetchAll( + "SELECT * FROM tenant_domains WHERE tenant_id = ? ORDER BY is_primary DESC", + [$tenantId] + ); + } + + /** + * Aktualisiert das Branding eines Tenants + */ + public function updateBranding(int $tenantId, array $data): bool + { + $allowedFields = [ + 'site_name', 'site_name_full', 'tagline', 'logo_path', 'favicon_path', + 'primary_color', 'secondary_color', 'accent_color', + 'welcome_text_de', 'welcome_text_en', 'footer_text', + 'custom_css', 'custom_js', + 'social_facebook', 'social_instagram', 'social_youtube' + ]; + + $updateData = array_intersect_key($data, array_flip($allowedFields)); + + if (empty($updateData)) { + return false; + } + + // Prüfe ob Branding existiert + $exists = $this->db->fetchColumn( + "SELECT tenant_id FROM tenant_branding WHERE tenant_id = ?", + [$tenantId] + ); + + if ($exists) { + return $this->db->update('tenant_branding', $updateData, 'tenant_id = ?', [$tenantId]) > 0; + } else { + $updateData['tenant_id'] = $tenantId; + return $this->db->insert('tenant_branding', $updateData) > 0; + } + } + + /** + * Gibt das Branding eines Tenants zurück + */ + public function getBranding(int $tenantId): ?array + { + return $this->db->fetchOne( + "SELECT * FROM tenant_branding WHERE tenant_id = ?", + [$tenantId] + ); + } + + /** + * Prüft ob ein Slug verfügbar ist + */ + public function isSlugAvailable(string $slug, ?int $excludeTenantId = null): bool + { + $sql = "SELECT id FROM tenants WHERE slug = ?"; + $params = [$slug]; + + if ($excludeTenantId) { + $sql .= " AND id != ?"; + $params[] = $excludeTenantId; + } + + return $this->db->fetchOne($sql, $params) === null; + } + + /** + * Prüft ob eine Domain verfügbar ist + */ + public function isDomainAvailable(string $domain, ?int $excludeTenantId = null): bool + { + $sql = "SELECT td.id FROM tenant_domains td WHERE td.domain = ?"; + $params = [$domain]; + + if ($excludeTenantId) { + $sql .= " AND td.tenant_id != ?"; + $params[] = $excludeTenantId; + } + + return $this->db->fetchOne($sql, $params) === null; + } + + /** + * Generiert einen URL-sicheren Slug aus einem Namen + */ + private function generateSlug(string $name): string + { + $slug = strtolower($name); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug); + $slug = trim($slug, '-'); + + // Sicherstellen dass Slug einzigartig ist + $baseSlug = $slug; + $counter = 1; + while (!$this->isSlugAvailable($slug)) { + $slug = $baseSlug . '-' . $counter; + $counter++; + } + + return $slug; + } + + /** + * Generiert eine UUID v4 + */ + private function generateUuid(): string + { + $data = random_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + + /** + * Berechnet das Trial-Ende (14 Tage) + */ + private function calculateTrialEnd(): string + { + return date('Y-m-d H:i:s', strtotime('+14 days')); + } + + /** + * Gibt die ID des Default-Plans (Free) zurück + */ + private function getDefaultPlanId(): int + { + $plan = $this->db->fetchOne("SELECT id FROM plans WHERE slug = 'free' LIMIT 1"); + return $plan ? (int)$plan['id'] : 1; + } + + /** + * Aktiviert einen Tenant (z.B. nach Zahlung) + */ + public function activate(int $tenantId): bool + { + return $this->db->update('tenants', ['status' => 'active'], 'id = ?', [$tenantId]) > 0; + } + + /** + * Suspendiert einen Tenant (z.B. bei Zahlungsausfall) + */ + public function suspend(int $tenantId): bool + { + return $this->db->update('tenants', ['status' => 'suspended'], 'id = ?', [$tenantId]) > 0; + } +} diff --git a/aurora-livecam/src/Tenant/TenantSettingsManager.php b/aurora-livecam/src/Tenant/TenantSettingsManager.php new file mode 100644 index 0000000..07e7e0d --- /dev/null +++ b/aurora-livecam/src/Tenant/TenantSettingsManager.php @@ -0,0 +1,427 @@ +db = $db ?? Database::getInstance(); + $this->resolver = $resolver ?? TenantResolver::getInstance(); + $this->tenantId = $tenantId ?? $this->resolver->getTenantId(); + + $this->checkDbAvailability(); + } + + /** + * Prüft ob die DB verfügbar ist + */ + private function checkDbAvailability(): void + { + try { + $this->db->fetchOne("SELECT 1 FROM tenant_settings LIMIT 1"); + $this->dbAvailable = true; + } catch (\Exception $e) { + $this->dbAvailable = false; + } + } + + /** + * Lädt alle Settings für den Tenant + */ + private function load(): void + { + if ($this->loaded) { + return; + } + + // Wenn keine DB, nutze Legacy + if (!$this->dbAvailable || $this->tenantId === 0) { + $this->loadFromLegacy(); + return; + } + + $rows = $this->db->fetchAll( + "SELECT setting_key, setting_value FROM tenant_settings WHERE tenant_id = ?", + [$this->tenantId] + ); + + foreach ($rows as $row) { + $value = $row['setting_value']; + // JSON-Werte parsen + if ($value !== null && ($value[0] === '{' || $value[0] === '[')) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $value = $decoded; + } + } + // Booleans und Zahlen konvertieren + elseif ($value === 'true') $value = true; + elseif ($value === 'false') $value = false; + elseif (is_numeric($value)) $value = strpos($value, '.') !== false ? (float)$value : (int)$value; + + $this->settings[$row['setting_key']] = $value; + } + + // Defaults für fehlende Keys + $this->settings = array_merge($this->getDefaults(), $this->settings); + $this->loaded = true; + } + + /** + * Fallback auf Legacy SettingsManager + */ + private function loadFromLegacy(): void + { + if ($this->legacyManager === null) { + // Legacy-Manager einbinden + $legacyFile = dirname(__DIR__, 2) . '/SettingsManager.php'; + if (file_exists($legacyFile) && !class_exists('\SettingsManager')) { + require_once $legacyFile; + } + + if (class_exists('\SettingsManager')) { + $this->legacyManager = new \SettingsManager(); + } + } + + if ($this->legacyManager) { + // Konvertiere Legacy-Settings in unser Format + $this->settings = $this->convertLegacySettings($this->legacyManager); + } else { + $this->settings = $this->getDefaults(); + } + + $this->loaded = true; + } + + /** + * Konvertiert Legacy-Settings + */ + private function convertLegacySettings(\SettingsManager $legacy): array + { + $settings = $this->getDefaults(); + + // Mappe Legacy-Werte + $mappings = [ + 'viewer_display.enabled' => 'viewer_display.enabled', + 'viewer_display.min_viewers' => 'viewer_display.min_viewers', + 'video_mode.play_in_player' => 'video_mode.play_in_player', + 'video_mode.allow_download' => 'video_mode.allow_download', + 'timelapse.default_speed' => 'timelapse.default_speed', + 'ui_display.show_recommendation_banner' => 'ui_display.show_recommendation_banner', + 'ui_display.show_qr_code' => 'ui_display.show_qr_code', + 'ui_display.show_social_media' => 'ui_display.show_social_media', + 'content.guestbook_enabled' => 'content.guestbook_enabled', + 'content.gallery_enabled' => 'content.gallery_enabled', + 'weather.enabled' => 'weather.enabled', + 'weather.location' => 'weather.location', + 'weather.lat' => 'weather.lat', + 'weather.lon' => 'weather.lon', + 'seo.custom_title' => 'seo.custom_title', + 'seo.meta_description' => 'seo.meta_description', + ]; + + foreach ($mappings as $legacyKey => $newKey) { + $value = $legacy->get($legacyKey); + if ($value !== null) { + $settings[$newKey] = $value; + } + } + + return $settings; + } + + /** + * Gibt einen Setting-Wert zurück (mit Dot-Notation) + */ + public function get(string $key, mixed $default = null): mixed + { + $this->load(); + + // Direkte Keys + if (isset($this->settings[$key])) { + return $this->settings[$key]; + } + + // Dot-Notation auflösen + $keys = explode('.', $key); + $value = $this->settings; + + foreach ($keys as $k) { + if (!is_array($value) || !isset($value[$k])) { + return $default; + } + $value = $value[$k]; + } + + return $value; + } + + /** + * Setzt einen Setting-Wert + */ + public function set(string $key, mixed $value): bool + { + $this->load(); + + // Wenn keine DB, nutze Legacy + if (!$this->dbAvailable || $this->tenantId === 0) { + return $this->setLegacy($key, $value); + } + + // Wert für DB vorbereiten + $dbValue = $this->prepareValueForDb($value); + + // UPSERT + $sql = "INSERT INTO tenant_settings (tenant_id, setting_key, setting_value) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"; + + $result = $this->db->execute($sql, [$this->tenantId, $key, $dbValue]) > 0; + + if ($result) { + $this->settings[$key] = $value; + } + + return $result; + } + + /** + * Setzt Legacy-Setting + */ + private function setLegacy(string $key, mixed $value): bool + { + if ($this->legacyManager) { + return $this->legacyManager->set($key, $value); + } + return false; + } + + /** + * Bereitet einen Wert für die DB vor + */ + private function prepareValueForDb(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_array($value) || is_object($value)) { + return json_encode($value); + } + return (string)$value; + } + + /** + * Löscht ein Setting + */ + public function delete(string $key): bool + { + if (!$this->dbAvailable || $this->tenantId === 0) { + return false; + } + + $result = $this->db->delete('tenant_settings', 'tenant_id = ? AND setting_key = ?', [$this->tenantId, $key]) > 0; + + if ($result) { + unset($this->settings[$key]); + } + + return $result; + } + + /** + * Gibt alle Settings zurück + */ + public function all(): array + { + $this->load(); + return $this->settings; + } + + /** + * Setzt mehrere Settings auf einmal + */ + public function setMany(array $settings): bool + { + foreach ($settings as $key => $value) { + $this->set($key, $value); + } + return true; + } + + /** + * Default-Settings + */ + private function getDefaults(): array + { + return [ + // Viewer Display + 'viewer_display.enabled' => true, + 'viewer_display.min_viewers' => 1, + 'viewer_display.update_interval' => 5, + + // Video Mode + 'video_mode.play_in_player' => true, + 'video_mode.allow_download' => true, + + // Timelapse + 'timelapse.default_speed' => 1, + 'timelapse.available_speeds' => [1, 10, 100], + 'timelapse.reverse_enabled' => true, + + // UI Display + 'ui_display.show_recommendation_banner' => true, + 'ui_display.show_qr_code' => true, + 'ui_display.show_social_media' => true, + + // Zoom + 'zoom.show_controls' => true, + 'zoom.max_level' => 4.0, + + // Content + 'content.guestbook_enabled' => true, + 'content.gallery_enabled' => true, + 'content.ai_events_enabled' => true, + + // Weather + 'weather.enabled' => true, + 'weather.location' => 'Zürich,CH', + 'weather.lat' => '47.3769', + 'weather.lon' => '8.5417', + 'weather.update_interval' => 5, + 'weather.units' => 'metric', + + // SEO + 'seo.custom_title' => '', + 'seo.meta_description' => '', + 'seo.meta_keywords' => '', + + // Theme + 'theme.default' => 'theme-legacy', + 'theme.show_switcher' => false, + ]; + } + + // === Helper-Methoden (kompatibel mit altem SettingsManager) === + + public function isWeatherEnabled(): bool + { + return $this->get('weather.enabled', true) === true; + } + + public function getWeatherLocation(): string + { + return $this->get('weather.location', 'Zürich,CH'); + } + + public function getWeatherCoords(): array + { + return [ + 'lat' => $this->get('weather.lat', '47.3769'), + 'lon' => $this->get('weather.lon', '8.5417'), + ]; + } + + public function getWeatherUpdateInterval(): int + { + return (int)$this->get('weather.update_interval', 5); + } + + public function shouldShowViewers(): bool + { + return $this->get('viewer_display.enabled', true) === true; + } + + public function getMinViewers(): int + { + return (int)$this->get('viewer_display.min_viewers', 1); + } + + public function isGuestbookEnabled(): bool + { + return $this->get('content.guestbook_enabled', true) === true; + } + + public function isGalleryEnabled(): bool + { + return $this->get('content.gallery_enabled', true) === true; + } + + /** + * AJAX-Handler (kompatibel mit altem SettingsManager) + */ + public function handleAjax(): void + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') return; + if (!isset($_POST['settings_action'])) return; + + header('Content-Type: application/json'); + + // Auth prüfen + if (!$this->isAdmin()) { + echo json_encode(['success' => false, 'error' => 'Unauthorized']); + exit; + } + + $action = $_POST['settings_action']; + + if ($action === 'update' && isset($_POST['key'], $_POST['value'])) { + $key = $_POST['key']; + $value = $_POST['value']; + + // Booleans konvertieren + if ($value === 'true') $value = true; + elseif ($value === 'false') $value = false; + + $success = $this->set($key, $value); + echo json_encode(['success' => $success]); + exit; + } + + if ($action === 'get') { + echo json_encode(['success' => true, 'data' => $this->all()]); + exit; + } + + echo json_encode(['success' => false, 'error' => 'Unknown action']); + exit; + } + + /** + * Prüft ob der User Admin ist + */ + private function isAdmin(): bool + { + return isset($_SESSION['admin']) && $_SESSION['admin'] === true; + } + + /** + * Lädt Settings neu aus der DB + */ + public function reload(): void + { + $this->loaded = false; + $this->settings = []; + $this->load(); + } +} diff --git a/aurora-livecam/src/bootstrap.php b/aurora-livecam/src/bootstrap.php new file mode 100644 index 0000000..6a089e7 --- /dev/null +++ b/aurora-livecam/src/bootstrap.php @@ -0,0 +1,179 @@ +isMultiTenantEnabled()) { + return getLegacySiteConfig(); + } + + // Multi-Tenant Modus + try { + $resolver = TenantResolver::getInstance(); + $tenant = $resolver->resolve(); + $branding = $resolver->getBranding(); + + if (!$tenant) { + return getLegacySiteConfig(); + } + + return [ + 'tenant_id' => $tenant['id'], + 'tenant_slug' => $tenant['slug'], + 'is_multi_tenant' => true, + 'site_name' => $branding['site_name'] ?? $tenant['name'], + 'site_name_full' => $branding['site_name_full'] ?? $tenant['name'], + 'tagline' => $branding['tagline'] ?? '', + 'logo_path' => $branding['logo_path'] ?? null, + 'favicon_path' => $branding['favicon_path'] ?? null, + 'primary_color' => $branding['primary_color'] ?? '#667eea', + 'secondary_color' => $branding['secondary_color'] ?? '#764ba2', + 'accent_color' => $branding['accent_color'] ?? '#f093fb', + 'welcome_de' => $branding['welcome_text_de'] ?? '', + 'welcome_en' => $branding['welcome_text_en'] ?? '', + 'footer_text' => $branding['footer_text'] ?? '', + 'custom_css' => $branding['custom_css'] ?? '', + 'social' => [ + 'facebook' => $branding['social_facebook'] ?? '', + 'instagram' => $branding['social_instagram'] ?? '', + 'youtube' => $branding['social_youtube'] ?? '', + ], + ]; + + } catch (\Exception $e) { + // Fallback auf Legacy bei Fehlern + return getLegacySiteConfig(); + } +} + +/** + * Legacy Site-Konfiguration (hardcoded Domains) + * Kompatibilität mit bestehendem Code + */ +function getLegacySiteConfig(): array +{ + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $isSeecam = (stripos($host, 'seecam.ch') !== false); + + if ($isSeecam) { + return [ + 'tenant_id' => 0, + 'tenant_slug' => 'seecam', + 'is_multi_tenant' => false, + 'site_name' => 'Seecam', + 'site_name_full' => 'Seecam.ch - Live Webcam am See', + 'tagline' => 'Ihre Live-Webcam am See', + 'logo_path' => null, + 'favicon_path' => null, + 'primary_color' => '#667eea', + 'secondary_color' => '#764ba2', + 'accent_color' => '#f093fb', + 'welcome_de' => 'Willkommen bei Seecam - Ihrer Live-Webcam am See!', + 'welcome_en' => 'Welcome to Seecam - Your Live Webcam at the Lake!', + 'footer_text' => '', + 'custom_css' => '', + 'social' => [ + 'facebook' => '', + 'instagram' => '', + 'youtube' => '', + ], + ]; + } + + // Default: Aurora + return [ + 'tenant_id' => 0, + 'tenant_slug' => 'aurora', + 'is_multi_tenant' => false, + 'site_name' => 'Aurora', + 'site_name_full' => 'Aurora Weather Livecam - Zürich Oberland', + 'tagline' => 'Wetter Webcam Schweiz - Zürich Oberland', + 'logo_path' => null, + 'favicon_path' => null, + 'primary_color' => '#667eea', + 'secondary_color' => '#764ba2', + 'accent_color' => '#f093fb', + 'welcome_de' => 'Willkommen bei Aurora Weather Livecam - Ihre Wetter-Webcam im Zürcher Oberland mit AI-Erkennung für Aurora, Starlink und mehr!', + 'welcome_en' => 'Welcome to Aurora Weather Livecam - Your weather webcam in the Zurich Oberland with AI detection for Aurora, Starlink and more!', + 'footer_text' => '', + 'custom_css' => '', + 'social' => [ + 'facebook' => '', + 'instagram' => '', + 'youtube' => '', + ], + ]; +} + +/** + * Redirect Handler für alte Domains + */ +function handleDomainRedirects(): void +{ + $host = $_SERVER['HTTP_HOST'] ?? ''; + + // Alte Aurora-Domains auf neue Domain umleiten + $oldDomains = [ + 'www.aurora-wetter-lifecam.ch', + 'aurora-wetter-lifecam.ch', + 'www.aurora-wetter-livecam.ch', + 'aurora-wetter-livecam.ch' + ]; + + $newDomain = 'www.aurora-weather-livecam.com'; + + if (in_array($host, $oldDomains)) { + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $requestUri = $_SERVER['REQUEST_URI'] ?? '/'; + header("HTTP/1.1 301 Moved Permanently"); + header("Location: {$protocol}://{$newDomain}{$requestUri}"); + exit; + } +} + +// Domain-Redirects automatisch ausführen +handleDomainRedirects();