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/WeatherManager.php b/aurora-livecam/WeatherManager.php
index bd96cec..2a05ba7 100644
--- a/aurora-livecam/WeatherManager.php
+++ b/aurora-livecam/WeatherManager.php
@@ -160,6 +160,12 @@ class WeatherManager {
return null;
}
+ // Fehler nicht aus Cache zurückgeben (z.B. alter "API Key fehlt" Error)
+ if (isset($data['error'])) {
+ @unlink($this->cacheFile); // Cache mit Fehler löschen
+ return null;
+ }
+
// Update-Intervall aus Settings holen (in Minuten)
$updateInterval = $this->settingsManager->getWeatherUpdateInterval() * 60; // Minuten -> Sekunden
@@ -172,9 +178,13 @@ class WeatherManager {
}
/**
- * Speichert Daten im Cache
+ * Speichert Daten im Cache (nur wenn kein Fehler)
*/
private function saveCache($data) {
+ // Fehler nicht cachen
+ if (isset($data['error'])) {
+ return;
+ }
$json = json_encode($data, JSON_PRETTY_PRINT);
file_put_contents($this->cacheFile, $json, LOCK_EX);
}
diff --git a/aurora-livecam/api/stripe-webhook.php b/aurora-livecam/api/stripe-webhook.php
new file mode 100644
index 0000000..98070bf
--- /dev/null
+++ b/aurora-livecam/api/stripe-webhook.php
@@ -0,0 +1,56 @@
+ 'Method not allowed']);
+ exit;
+}
+
+// Payload lesen
+$payload = file_get_contents('php://input');
+$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
+
+if (empty($payload)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Empty payload']);
+ exit;
+}
+
+// Webhook verarbeiten
+try {
+ $handler = new WebhookHandler();
+ $result = $handler->handle($payload, $signature);
+
+ if ($result['success']) {
+ http_response_code(200);
+ } else {
+ http_response_code(400);
+ }
+
+ header('Content-Type: application/json');
+ echo json_encode($result);
+
+} catch (\Exception $e) {
+ error_log('Stripe Webhook Error: ' . $e->getMessage());
+ http_response_code(500);
+ echo json_encode(['error' => 'Internal server error']);
+}
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/dashboard/api/stats.php b/aurora-livecam/dashboard/api/stats.php
new file mode 100644
index 0000000..a8c4952
--- /dev/null
+++ b/aurora-livecam/dashboard/api/stats.php
@@ -0,0 +1,75 @@
+isLoggedIn()) {
+ http_response_code(401);
+ echo json_encode(['success' => false, 'error' => 'Unauthorized']);
+ exit;
+}
+
+$user = $auth->getUser();
+$tenantId = $user['tenant_id'] ?? 0;
+
+$stats = [
+ 'viewers_current' => 0,
+ 'viewers_today' => 0,
+ 'viewers_peak' => 0,
+ 'stream_status' => 'unknown',
+];
+
+// Aktuelle Zuschauer aus Datei
+$viewerFile = dirname(__DIR__, 2) . '/active_viewers.json';
+if (file_exists($viewerFile)) {
+ $viewers = json_decode(file_get_contents($viewerFile), true);
+ $stats['viewers_current'] = count($viewers ?? []);
+}
+
+// DB Stats falls verfügbar
+try {
+ $db = Database::getInstance();
+
+ if ($tenantId > 0) {
+ $todayStats = $db->fetchOne(
+ "SELECT SUM(viewer_count) as total, MAX(viewer_count) as peak
+ FROM viewer_stats
+ WHERE tenant_id = ? AND DATE(recorded_at) = CURDATE()",
+ [$tenantId]
+ );
+
+ if ($todayStats) {
+ $stats['viewers_today'] = (int)($todayStats['total'] ?? 0);
+ $stats['viewers_peak'] = (int)($todayStats['peak'] ?? 0);
+ }
+
+ $stream = $db->fetchOne(
+ "SELECT last_status FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
+ [$tenantId]
+ );
+ $stats['stream_status'] = $stream['last_status'] ?? 'unknown';
+ }
+} catch (\Exception $e) {
+ // DB nicht verfügbar - Stats bleiben auf Defaults
+}
+
+echo json_encode([
+ 'success' => true,
+ 'stats' => $stats,
+ 'timestamp' => time(),
+]);
diff --git a/aurora-livecam/dashboard/assets/dashboard.css b/aurora-livecam/dashboard/assets/dashboard.css
new file mode 100644
index 0000000..f0bcb1e
--- /dev/null
+++ b/aurora-livecam/dashboard/assets/dashboard.css
@@ -0,0 +1,536 @@
+/* Dashboard CSS */
+:root {
+ --primary: #667eea;
+ --primary-dark: #5a67d8;
+ --secondary: #764ba2;
+ --accent: #f093fb;
+ --success: #48bb78;
+ --warning: #ed8936;
+ --danger: #f56565;
+ --dark: #1a202c;
+ --gray-900: #1a202c;
+ --gray-800: #2d3748;
+ --gray-700: #4a5568;
+ --gray-600: #718096;
+ --gray-500: #a0aec0;
+ --gray-400: #cbd5e0;
+ --gray-300: #e2e8f0;
+ --gray-200: #edf2f7;
+ --gray-100: #f7fafc;
+ --white: #ffffff;
+ --sidebar-width: 260px;
+ --header-height: 60px;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background: var(--gray-100);
+ color: var(--gray-800);
+ line-height: 1.6;
+}
+
+/* Dashboard Container */
+.dashboard-container {
+ display: flex;
+ min-height: 100vh;
+}
+
+/* Sidebar */
+.sidebar {
+ width: var(--sidebar-width);
+ background: linear-gradient(180deg, var(--gray-900) 0%, var(--gray-800) 100%);
+ color: var(--white);
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ height: 100vh;
+ z-index: 100;
+}
+
+.sidebar-header {
+ padding: 1.5rem;
+ border-bottom: 1px solid var(--gray-700);
+}
+
+.sidebar-header h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 0.25rem;
+}
+
+.role-badge {
+ font-size: 0.75rem;
+ background: var(--primary);
+ padding: 0.125rem 0.5rem;
+ border-radius: 9999px;
+ text-transform: capitalize;
+}
+
+/* Navigation */
+.sidebar-nav {
+ flex: 1;
+ padding: 1rem 0;
+ overflow-y: auto;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 1.5rem;
+ color: var(--gray-400);
+ text-decoration: none;
+ transition: all 0.2s;
+}
+
+.nav-item:hover {
+ background: var(--gray-700);
+ color: var(--white);
+}
+
+.nav-item.active {
+ background: linear-gradient(90deg, var(--primary) 0%, var(--secondary) 100%);
+ color: var(--white);
+}
+
+.nav-icon {
+ font-size: 1.25rem;
+ width: 1.5rem;
+ text-align: center;
+}
+
+.nav-divider {
+ height: 1px;
+ background: var(--gray-700);
+ margin: 1rem 0;
+}
+
+.nav-label {
+ display: block;
+ padding: 0.5rem 1.5rem;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ color: var(--gray-500);
+ letter-spacing: 0.05em;
+}
+
+.sidebar-footer {
+ border-top: 1px solid var(--gray-700);
+ padding: 0.5rem 0;
+}
+
+.nav-item.logout:hover {
+ background: var(--danger);
+}
+
+/* Main Content */
+.main-content {
+ flex: 1;
+ margin-left: var(--sidebar-width);
+ min-height: 100vh;
+}
+
+.main-header {
+ height: var(--header-height);
+ background: var(--white);
+ border-bottom: 1px solid var(--gray-300);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 2rem;
+ position: sticky;
+ top: 0;
+ z-index: 50;
+}
+
+.main-header h1 {
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+.user-info {
+ color: var(--gray-600);
+ font-size: 0.875rem;
+}
+
+.content-wrapper {
+ padding: 2rem;
+}
+
+/* Cards */
+.card {
+ background: var(--white);
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ margin-bottom: 1.5rem;
+}
+
+.card-header {
+ padding: 1rem 1.5rem;
+ border-bottom: 1px solid var(--gray-200);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.card-title {
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.card-body {
+ padding: 1.5rem;
+}
+
+/* Stats Grid */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: var(--white);
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+
+.stat-icon {
+ font-size: 2rem;
+ margin-bottom: 0.5rem;
+}
+
+.stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--gray-900);
+}
+
+.stat-label {
+ color: var(--gray-600);
+ font-size: 0.875rem;
+}
+
+.stat-change {
+ font-size: 0.875rem;
+ margin-top: 0.25rem;
+}
+
+.stat-change.positive { color: var(--success); }
+.stat-change.negative { color: var(--danger); }
+
+/* Forms */
+.form-group {
+ margin-bottom: 1.5rem;
+}
+
+.form-label {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 0.5rem;
+ color: var(--gray-700);
+}
+
+.form-input,
+.form-select,
+.form-textarea {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--gray-300);
+ border-radius: 0.375rem;
+ font-size: 1rem;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.form-input:focus,
+.form-select:focus,
+.form-textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
+}
+
+.form-help {
+ font-size: 0.875rem;
+ color: var(--gray-500);
+ margin-top: 0.25rem;
+}
+
+.form-textarea {
+ min-height: 100px;
+ resize: vertical;
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ text-decoration: none;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
+ color: var(--white);
+}
+
+.btn-primary:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+}
+
+.btn-secondary {
+ background: var(--gray-200);
+ color: var(--gray-700);
+}
+
+.btn-secondary:hover {
+ background: var(--gray-300);
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: var(--white);
+}
+
+.btn-success {
+ background: var(--success);
+ color: var(--white);
+}
+
+.btn-sm {
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+}
+
+/* Alerts */
+.alert {
+ padding: 1rem 1.5rem;
+ border-radius: 0.375rem;
+ margin-bottom: 1.5rem;
+}
+
+.alert-success {
+ background: #c6f6d5;
+ color: #22543d;
+ border: 1px solid #9ae6b4;
+}
+
+.alert-error {
+ background: #fed7d7;
+ color: #742a2a;
+ border: 1px solid #feb2b2;
+}
+
+.alert-warning {
+ background: #feebc8;
+ color: #744210;
+ border: 1px solid #fbd38d;
+}
+
+.alert-info {
+ background: #bee3f8;
+ color: #2a4365;
+ border: 1px solid #90cdf4;
+}
+
+/* Tables */
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table th,
+.table td {
+ padding: 1rem;
+ text-align: left;
+ border-bottom: 1px solid var(--gray-200);
+}
+
+.table th {
+ font-weight: 600;
+ color: var(--gray-600);
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.table tbody tr:hover {
+ background: var(--gray-50);
+}
+
+/* Status Badges */
+.badge {
+ display: inline-block;
+ padding: 0.25rem 0.75rem;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.badge-success { background: #c6f6d5; color: #22543d; }
+.badge-warning { background: #feebc8; color: #744210; }
+.badge-danger { background: #fed7d7; color: #742a2a; }
+.badge-info { background: #bee3f8; color: #2a4365; }
+
+/* Grid */
+.grid {
+ display: grid;
+ gap: 1.5rem;
+}
+
+.grid-2 { grid-template-columns: repeat(2, 1fr); }
+.grid-3 { grid-template-columns: repeat(3, 1fr); }
+
+/* Color Picker */
+.color-picker-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.color-picker {
+ width: 50px;
+ height: 40px;
+ border: none;
+ border-radius: 0.375rem;
+ cursor: pointer;
+}
+
+.color-value {
+ font-family: monospace;
+ color: var(--gray-600);
+}
+
+/* Preview Box */
+.preview-box {
+ border: 2px dashed var(--gray-300);
+ border-radius: 0.5rem;
+ padding: 2rem;
+ text-align: center;
+ background: var(--gray-50);
+}
+
+/* Toggle Switch */
+.toggle-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.toggle {
+ position: relative;
+ width: 48px;
+ height: 24px;
+}
+
+.toggle input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--gray-300);
+ border-radius: 24px;
+ transition: 0.3s;
+}
+
+.toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 18px;
+ width: 18px;
+ left: 3px;
+ bottom: 3px;
+ background: white;
+ border-radius: 50%;
+ transition: 0.3s;
+}
+
+.toggle input:checked + .toggle-slider {
+ background: var(--primary);
+}
+
+.toggle input:checked + .toggle-slider:before {
+ transform: translateX(24px);
+}
+
+/* Login Page */
+.login-container {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
+}
+
+.login-box {
+ background: var(--white);
+ padding: 2.5rem;
+ border-radius: 1rem;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ width: 100%;
+ max-width: 400px;
+}
+
+.login-title {
+ text-align: center;
+ margin-bottom: 2rem;
+}
+
+.login-title h1 {
+ font-size: 1.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.login-title p {
+ color: var(--gray-500);
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .sidebar {
+ transform: translateX(-100%);
+ transition: transform 0.3s;
+ }
+
+ .sidebar.open {
+ transform: translateX(0);
+ }
+
+ .main-content {
+ margin-left: 0;
+ }
+
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .grid-2,
+ .grid-3 {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/aurora-livecam/dashboard/assets/dashboard.js b/aurora-livecam/dashboard/assets/dashboard.js
new file mode 100644
index 0000000..2c8a2a3
--- /dev/null
+++ b/aurora-livecam/dashboard/assets/dashboard.js
@@ -0,0 +1,131 @@
+/**
+ * Dashboard JavaScript
+ */
+
+document.addEventListener('DOMContentLoaded', function() {
+ // Auto-dismiss alerts after 5 seconds
+ const alerts = document.querySelectorAll('.alert');
+ alerts.forEach(alert => {
+ setTimeout(() => {
+ alert.style.transition = 'opacity 0.3s';
+ alert.style.opacity = '0';
+ setTimeout(() => alert.remove(), 300);
+ }, 5000);
+ });
+
+ // Mobile sidebar toggle
+ const sidebar = document.querySelector('.sidebar');
+ const mainContent = document.querySelector('.main-content');
+
+ if (window.innerWidth <= 768) {
+ // Add menu button
+ const menuBtn = document.createElement('button');
+ menuBtn.className = 'btn btn-secondary';
+ menuBtn.style.cssText = 'position: fixed; top: 10px; left: 10px; z-index: 200; padding: 0.5rem;';
+ menuBtn.innerHTML = '☰';
+ menuBtn.onclick = () => sidebar.classList.toggle('open');
+ document.body.appendChild(menuBtn);
+
+ // Close sidebar on content click
+ mainContent.addEventListener('click', () => {
+ sidebar.classList.remove('open');
+ });
+ }
+
+ // Color picker live preview
+ document.querySelectorAll('.color-picker').forEach(picker => {
+ picker.addEventListener('input', function() {
+ const wrapper = this.closest('.color-picker-wrapper');
+ if (wrapper) {
+ const valueDisplay = wrapper.querySelector('.color-value');
+ if (valueDisplay) {
+ valueDisplay.textContent = this.value;
+ }
+ }
+ });
+ });
+
+ // Form unsaved changes warning
+ const forms = document.querySelectorAll('form');
+ let formChanged = false;
+
+ forms.forEach(form => {
+ form.addEventListener('change', () => {
+ formChanged = true;
+ });
+
+ form.addEventListener('submit', () => {
+ formChanged = false;
+ });
+ });
+
+ window.addEventListener('beforeunload', (e) => {
+ if (formChanged) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ });
+
+ // Stats refresh (every 30 seconds on overview page)
+ if (document.querySelector('.stats-grid')) {
+ setInterval(refreshStats, 30000);
+ }
+});
+
+/**
+ * Refresh stats via AJAX
+ */
+function refreshStats() {
+ fetch('/dashboard/api/stats.php')
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ updateStatCard('viewers_current', data.stats.viewers_current);
+ updateStatCard('viewers_today', data.stats.viewers_today);
+ updateStatCard('viewers_peak', data.stats.viewers_peak);
+ }
+ })
+ .catch(err => console.log('Stats refresh failed:', err));
+}
+
+/**
+ * Update a stat card value
+ */
+function updateStatCard(id, value) {
+ const cards = document.querySelectorAll('.stat-card');
+ cards.forEach(card => {
+ const label = card.querySelector('.stat-label');
+ if (label) {
+ // Match by label text (simplified)
+ const valueEl = card.querySelector('.stat-value');
+ if (valueEl && typeof value !== 'undefined') {
+ valueEl.textContent = value;
+ }
+ }
+ });
+}
+
+/**
+ * Show notification toast
+ */
+function showNotification(message, type = 'info') {
+ const toast = document.createElement('div');
+ toast.className = `alert alert-${type}`;
+ toast.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 1000; min-width: 300px;';
+ toast.textContent = message;
+
+ document.body.appendChild(toast);
+
+ setTimeout(() => {
+ toast.style.transition = 'opacity 0.3s';
+ toast.style.opacity = '0';
+ setTimeout(() => toast.remove(), 300);
+ }, 3000);
+}
+
+/**
+ * Confirm dangerous actions
+ */
+function confirmAction(message) {
+ return confirm(message || 'Sind Sie sicher?');
+}
diff --git a/aurora-livecam/dashboard/billing.php b/aurora-livecam/dashboard/billing.php
new file mode 100644
index 0000000..a6157a0
--- /dev/null
+++ b/aurora-livecam/dashboard/billing.php
@@ -0,0 +1,282 @@
+requireLogin();
+
+// Prüfe ob Billing aktiviert
+if (!$settingsManager->isBillingEnabled()) {
+ header('Location: /dashboard/');
+ exit;
+}
+
+$user = $auth->getUser();
+$tenantId = $user['tenant_id'] ?? 0;
+
+$flashMessage = null;
+$flashType = 'info';
+
+$stripe = new StripeService();
+$subscriptions = new SubscriptionManager();
+
+// Aktuelle Subscription
+$currentSub = null;
+$plans = [];
+$invoices = [];
+$trialDays = 0;
+
+try {
+ $currentSub = $subscriptions->getSubscription($tenantId);
+ $plans = $subscriptions->getPlans();
+ $invoices = $subscriptions->getInvoices($tenantId, 5);
+ $trialDays = $subscriptions->getTrialDaysRemaining($tenantId);
+} catch (\Exception $e) {
+ $flashMessage = 'Fehler beim Laden der Abrechnungsdaten';
+ $flashType = 'error';
+}
+
+// Checkout starten
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['plan_id'])) {
+ $planId = (int)$_POST['plan_id'];
+ $plan = $subscriptions->getPlan($planId);
+
+ if ($plan && !empty($plan['stripe_price_id'])) {
+ $baseUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
+ $session = $stripe->createCheckoutSession(
+ $tenantId,
+ $plan['stripe_price_id'],
+ $baseUrl . '/dashboard/billing.php?success=1',
+ $baseUrl . '/dashboard/billing.php?canceled=1'
+ );
+
+ if ($session && isset($session['url'])) {
+ header('Location: ' . $session['url']);
+ exit;
+ } else {
+ $flashMessage = 'Fehler beim Erstellen der Checkout-Session';
+ $flashType = 'error';
+ }
+ }
+}
+
+// Billing Portal öffnen
+if (isset($_GET['portal'])) {
+ $baseUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
+ $session = $stripe->createPortalSession($tenantId, $baseUrl . '/dashboard/billing.php');
+
+ if ($session && isset($session['url'])) {
+ header('Location: ' . $session['url']);
+ exit;
+ }
+}
+
+// Success/Cancel Messages
+if (isset($_GET['success'])) {
+ $flashMessage = 'Zahlung erfolgreich! Ihr Abo ist jetzt aktiv.';
+ $flashType = 'success';
+}
+if (isset($_GET['canceled'])) {
+ $flashMessage = 'Checkout abgebrochen.';
+ $flashType = 'warning';
+}
+
+$pageTitle = 'Abrechnung';
+$currentPage = 'billing';
+
+ob_start();
+?>
+
+
+
+
+
+
+
+
+
+ 0): ?>
+
+ Trial endet in Tag
+
+
+
+ Nächste Abrechnung:
+
+
+
+ isConfigured() && !empty($currentSub['stripe_customer_id'])): ?>
+
+ Abo verwalten
+
+
+
+
+
+
+
Enthaltene Features:
+
+ $value): ?>
+
+
+ 'Max. Zuschauer: ' . ($value === -1 ? '∞' : $value),
+ 'storage_gb' => 'Speicher: ' . $value . ' GB',
+ 'custom_domain' => 'Custom Domain',
+ 'weather_widget' => 'Wetter-Widget',
+ 'timelapse' => 'Timelapse',
+ 'analytics' => 'Analytics',
+ 'branding' => 'Custom Branding',
+ 'priority_support' => 'Priority Support',
+ ];
+ echo $labels[$feature] ?? ucfirst(str_replace('_', ' ', $feature));
+ ?>
+
+
+
+
+
+
+
+
Kein aktives Abo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0): ?>
+ CHF
+ /Monat
+
+ Kostenlos
+
+
+
+
+
+ $value): ?>
+
+
+ ✓ 'Bis ' . ($value === -1 ? 'unbegrenzt' : $value) . ' Zuschauer',
+ 'storage_gb' => $value . ' GB Speicher',
+ 'custom_domain' => 'Eigene Domain',
+ 'weather_widget' => 'Wetter-Widget',
+ 'timelapse' => 'Timelapse',
+ 'analytics' => 'Analytics',
+ 'branding' => 'Custom Branding',
+ 'priority_support' => 'Priority Support',
+ ];
+ echo $labels[$feature] ?? ucfirst(str_replace('_', ' ', $feature));
+ ?>
+
+
+
+
+
+
+
+
Aktueller Plan
+ 0 && $stripe->isConfigured()): ?>
+
+
+
Free Plan
+
+
Stripe nicht konfiguriert
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Datum
+ Betrag
+ Status
+ PDF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Download
+
+
+
+
+
+
+
+
+
+
+
+isConfigured()): ?>
+
+ Hinweis: Stripe ist noch nicht konfiguriert. Bitte fügen Sie Ihre Stripe API-Keys in config.php hinzu.
+
+
+
+requireLogin();
+
+$user = $auth->getUser();
+$tenantId = $user['tenant_id'] ?? 0;
+
+$flashMessage = null;
+$flashType = 'info';
+
+// Branding-Daten laden
+$branding = [
+ 'site_name' => '',
+ 'site_name_full' => '',
+ 'tagline' => '',
+ 'primary_color' => '#667eea',
+ 'secondary_color' => '#764ba2',
+ 'accent_color' => '#f093fb',
+ 'welcome_text_de' => '',
+ 'welcome_text_en' => '',
+ 'footer_text' => '',
+ 'custom_css' => '',
+];
+
+try {
+ $db = Database::getInstance();
+ if ($tenantId > 0) {
+ $tenantManager = new TenantManager($db);
+ $dbBranding = $tenantManager->getBranding($tenantId);
+ if ($dbBranding) {
+ $branding = array_merge($branding, $dbBranding);
+ }
+ }
+} catch (\Exception $e) {
+ // DB nicht verfügbar
+}
+
+// Formular verarbeiten
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $newBranding = [
+ 'site_name' => trim($_POST['site_name'] ?? ''),
+ 'site_name_full' => trim($_POST['site_name_full'] ?? ''),
+ 'tagline' => trim($_POST['tagline'] ?? ''),
+ 'primary_color' => $_POST['primary_color'] ?? '#667eea',
+ 'secondary_color' => $_POST['secondary_color'] ?? '#764ba2',
+ 'accent_color' => $_POST['accent_color'] ?? '#f093fb',
+ 'welcome_text_de' => trim($_POST['welcome_text_de'] ?? ''),
+ 'welcome_text_en' => trim($_POST['welcome_text_en'] ?? ''),
+ 'footer_text' => trim($_POST['footer_text'] ?? ''),
+ 'custom_css' => trim($_POST['custom_css'] ?? ''),
+ ];
+
+ try {
+ $db = Database::getInstance();
+ if ($tenantId > 0) {
+ $tenantManager = new TenantManager($db);
+ $tenantManager->updateBranding($tenantId, $newBranding);
+
+ $flashMessage = 'Branding gespeichert!';
+ $flashType = 'success';
+ $branding = array_merge($branding, $newBranding);
+ } else {
+ $flashMessage = 'Branding kann im Legacy-Modus nicht gespeichert werden.';
+ $flashType = 'warning';
+ }
+ } catch (\Exception $e) {
+ $flashMessage = 'Fehler beim Speichern: ' . $e->getMessage();
+ $flashType = 'error';
+ }
+}
+
+$pageTitle = 'Branding';
+$currentPage = 'branding';
+
+ob_start();
+?>
+
+
+
+
+
+requireLogin();
+
+$user = $auth->getUser();
+$tenantId = $user['tenant_id'] ?? 0;
+
+// Stats laden
+$stats = [
+ 'viewers_current' => 0,
+ 'viewers_today' => 0,
+ 'viewers_peak' => 0,
+ 'stream_status' => 'unknown',
+];
+
+// Versuche Stats aus DB zu laden
+try {
+ $db = Database::getInstance();
+
+ if ($tenantId > 0) {
+ // Aktuelle Zuschauer (vereinfacht)
+ $viewerFile = dirname(__DIR__) . '/active_viewers.json';
+ if (file_exists($viewerFile)) {
+ $viewers = json_decode(file_get_contents($viewerFile), true);
+ $stats['viewers_current'] = count($viewers ?? []);
+ }
+
+ // Heute Stats
+ $todayStats = $db->fetchOne(
+ "SELECT SUM(viewer_count) as total, MAX(viewer_count) as peak
+ FROM viewer_stats
+ WHERE tenant_id = ? AND DATE(recorded_at) = CURDATE()",
+ [$tenantId]
+ );
+
+ if ($todayStats) {
+ $stats['viewers_today'] = $todayStats['total'] ?? 0;
+ $stats['viewers_peak'] = $todayStats['peak'] ?? 0;
+ }
+
+ // Stream Status
+ $stream = $db->fetchOne(
+ "SELECT last_status FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
+ [$tenantId]
+ );
+ $stats['stream_status'] = $stream['last_status'] ?? 'unknown';
+ }
+} catch (\Exception $e) {
+ // DB nicht verfügbar - Legacy-Modus
+ $viewerFile = dirname(__DIR__) . '/active_viewers.json';
+ if (file_exists($viewerFile)) {
+ $viewers = json_decode(file_get_contents($viewerFile), true);
+ $stats['viewers_current'] = count($viewers ?? []);
+ }
+}
+
+// Page Setup
+$pageTitle = 'Übersicht';
+$currentPage = 'overview';
+
+ob_start();
+?>
+
+
+
+
+
👥
+
+
Aktuelle Zuschauer
+
+
+
+
📊
+
+
Zuschauer heute
+
+
+
+
+
+
+
+
+
+
+
+
Stream Status
+
+
+
+
+
+
+
+
+
+
+
+ Aktivitäten werden hier angezeigt, sobald Analytics aktiviert ist.
+
+
+
+
+isTenantDashboardEnabled() && !$settingsManager->isMultiTenantEnabled()) {
+ // Fallback auf Legacy-Admin
+ header('Location: /?admin=1');
+ exit;
+}
+
+$auth = new AuthManager();
+
+// Bereits eingeloggt?
+if ($auth->isLoggedIn()) {
+ header('Location: /dashboard/');
+ exit;
+}
+
+$error = '';
+
+// Login verarbeiten
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $email = $_POST['email'] ?? '';
+ $password = $_POST['password'] ?? '';
+ $remember = isset($_POST['remember']);
+
+ if ($auth->login($email, $password, $remember)) {
+ header('Location: /dashboard/');
+ exit;
+ } else {
+ $error = 'Ungültige Anmeldedaten';
+ }
+}
+?>
+
+
+
+
+
+ Login - Dashboard
+
+
+
+
+
+
+
Dashboard Login
+
Melden Sie sich an, um fortzufahren
+
+
+
+
+
+
+
+
+ E-Mail / Benutzername
+
+
+
+
+ Passwort
+
+
+
+
+
+
+
+
+
+ Angemeldet bleiben
+
+
+
+
+ Anmelden
+
+
+
+
+ Zurück zur Livecam
+
+
+
+
+
diff --git a/aurora-livecam/dashboard/logout.php b/aurora-livecam/dashboard/logout.php
new file mode 100644
index 0000000..2174e90
--- /dev/null
+++ b/aurora-livecam/dashboard/logout.php
@@ -0,0 +1,18 @@
+logout();
+
+header('Location: /dashboard/login.php');
+exit;
diff --git a/aurora-livecam/dashboard/settings.php b/aurora-livecam/dashboard/settings.php
new file mode 100644
index 0000000..305ed2e
--- /dev/null
+++ b/aurora-livecam/dashboard/settings.php
@@ -0,0 +1,271 @@
+requireLogin();
+
+$user = $auth->getUser();
+$tenantId = $user['tenant_id'] ?? 0;
+
+$flashMessage = null;
+$flashType = 'info';
+
+// Tenant-Settings laden
+try {
+ $tenantSettings = new TenantSettingsManager($tenantId);
+} catch (\Exception $e) {
+ $tenantSettings = null;
+}
+
+// Einstellungen für das Template
+$settings = [
+ 'viewer_display_enabled' => $settingsManager->get('viewer_display.enabled') ?? true,
+ 'viewer_min' => $settingsManager->get('viewer_display.min_viewers') ?? 1,
+ 'weather_enabled' => $settingsManager->get('weather.enabled') ?? true,
+ 'weather_location' => $settingsManager->get('weather.location') ?? 'Zürich,CH',
+ 'weather_lat' => $settingsManager->get('weather.lat') ?? '47.3769',
+ 'weather_lon' => $settingsManager->get('weather.lon') ?? '8.5417',
+ 'guestbook_enabled' => $settingsManager->get('content.guestbook_enabled') ?? true,
+ 'gallery_enabled' => $settingsManager->get('content.gallery_enabled') ?? true,
+ 'ai_events_enabled' => $settingsManager->get('content.ai_events_enabled') ?? true,
+ 'show_qr_code' => $settingsManager->get('ui_display.show_qr_code') ?? true,
+ 'show_social_media' => $settingsManager->get('ui_display.show_social_media') ?? true,
+ 'timelapse_reverse' => $settingsManager->get('zoom_timelapse.timelapse_reverse_enabled') ?? true,
+ 'max_zoom' => $settingsManager->get('zoom_timelapse.max_zoom_level') ?? 4.0,
+];
+
+// Formular verarbeiten
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $updates = [
+ 'viewer_display.enabled' => isset($_POST['viewer_display_enabled']),
+ 'viewer_display.min_viewers' => (int)($_POST['viewer_min'] ?? 1),
+ 'weather.enabled' => isset($_POST['weather_enabled']),
+ 'weather.location' => trim($_POST['weather_location'] ?? ''),
+ 'weather.lat' => trim($_POST['weather_lat'] ?? ''),
+ 'weather.lon' => trim($_POST['weather_lon'] ?? ''),
+ 'content.guestbook_enabled' => isset($_POST['guestbook_enabled']),
+ 'content.gallery_enabled' => isset($_POST['gallery_enabled']),
+ 'content.ai_events_enabled' => isset($_POST['ai_events_enabled']),
+ 'ui_display.show_qr_code' => isset($_POST['show_qr_code']),
+ 'ui_display.show_social_media' => isset($_POST['show_social_media']),
+ 'zoom_timelapse.timelapse_reverse_enabled' => isset($_POST['timelapse_reverse']),
+ 'zoom_timelapse.max_zoom_level' => (float)($_POST['max_zoom'] ?? 4.0),
+ ];
+
+ $success = true;
+ foreach ($updates as $key => $value) {
+ if (!$settingsManager->set($key, $value)) {
+ $success = false;
+ }
+ }
+
+ if ($success) {
+ $flashMessage = 'Einstellungen gespeichert!';
+ $flashType = 'success';
+
+ // Reload settings
+ $settings = [
+ 'viewer_display_enabled' => $updates['viewer_display.enabled'],
+ 'viewer_min' => $updates['viewer_display.min_viewers'],
+ 'weather_enabled' => $updates['weather.enabled'],
+ 'weather_location' => $updates['weather.location'],
+ 'weather_lat' => $updates['weather.lat'],
+ 'weather_lon' => $updates['weather.lon'],
+ 'guestbook_enabled' => $updates['content.guestbook_enabled'],
+ 'gallery_enabled' => $updates['content.gallery_enabled'],
+ 'ai_events_enabled' => $updates['content.ai_events_enabled'],
+ 'show_qr_code' => $updates['ui_display.show_qr_code'],
+ 'show_social_media' => $updates['ui_display.show_social_media'],
+ 'timelapse_reverse' => $updates['zoom_timelapse.timelapse_reverse_enabled'],
+ 'max_zoom' => $updates['zoom_timelapse.max_zoom_level'],
+ ];
+ } else {
+ $flashMessage = 'Fehler beim Speichern einiger Einstellungen.';
+ $flashType = 'error';
+ }
+}
+
+$pageTitle = 'Einstellungen';
+$currentPage = 'settings';
+
+ob_start();
+?>
+
+
+
+
+
+
+
+
+
+
+ >
+
+
+ Zuschauer-Anzahl anzeigen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+
+
+ Wetter-Widget aktivieren
+
+
+
+
+ Standort-Name
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Einstellungen speichern
+
+
+
+
+requireLogin();
+
+$user = $auth->getUser();
+$tenantId = $user['tenant_id'] ?? 0;
+
+$flashMessage = null;
+$flashType = 'info';
+
+// Stream-Daten laden
+$stream = [
+ 'stream_url' => '',
+ 'stream_type' => 'hls',
+ 'is_active' => true,
+ 'last_status' => 'unknown',
+];
+
+try {
+ $db = Database::getInstance();
+ if ($tenantId > 0) {
+ $dbStream = $db->fetchOne(
+ "SELECT * FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
+ [$tenantId]
+ );
+ if ($dbStream) {
+ $stream = $dbStream;
+ }
+ }
+} catch (\Exception $e) {
+ // DB nicht verfügbar
+}
+
+// Formular verarbeiten
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $streamUrl = trim($_POST['stream_url'] ?? '');
+ $streamType = $_POST['stream_type'] ?? 'hls';
+
+ if (empty($streamUrl)) {
+ $flashMessage = 'Bitte geben Sie eine Stream-URL ein.';
+ $flashType = 'error';
+ } else {
+ try {
+ $db = Database::getInstance();
+
+ if ($tenantId > 0) {
+ // Prüfe ob Stream existiert
+ $existing = $db->fetchOne(
+ "SELECT id FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
+ [$tenantId]
+ );
+
+ if ($existing) {
+ $db->update('tenant_streams', [
+ 'stream_url' => $streamUrl,
+ 'stream_type' => $streamType,
+ ], 'id = ?', [$existing['id']]);
+ } else {
+ $db->insert('tenant_streams', [
+ 'tenant_id' => $tenantId,
+ 'stream_url' => $streamUrl,
+ 'stream_type' => $streamType,
+ 'is_primary' => 1,
+ ]);
+ }
+
+ $flashMessage = 'Stream-Einstellungen gespeichert!';
+ $flashType = 'success';
+
+ // Reload stream data
+ $stream['stream_url'] = $streamUrl;
+ $stream['stream_type'] = $streamType;
+ } else {
+ $flashMessage = 'Stream-Einstellungen können im Legacy-Modus nicht gespeichert werden.';
+ $flashType = 'warning';
+ }
+ } catch (\Exception $e) {
+ $flashMessage = 'Fehler beim Speichern: ' . $e->getMessage();
+ $flashType = 'error';
+ }
+ }
+}
+
+$pageTitle = 'Stream Einstellungen';
+$currentPage = 'stream';
+
+ob_start();
+?>
+
+
+
+
+
+
+
+
+ Stream Typ
+
+ >
+ HLS (.m3u8)
+
+ >
+ RTMP
+
+ >
+ WebRTC
+
+ >
+ iFrame Embed
+
+
+
+
+
+ Speichern
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hinweis: Die Vorschau funktioniert nur mit HLS-Streams und wenn Ihr Browser HLS unterstützt.
+
+
+
+
Keine Stream-URL konfiguriert
+
+
+
+
+
+
+
+
+
+ Stream-Monitoring zeigt automatische Verfügbarkeitsprüfungen an.
+ Diese Funktion wird demnächst verfügbar sein.
+
+
+
+
+getUser();
+$tenantName = $user['tenant_name'] ?? 'Dashboard';
+?>
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
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/landing/index.php b/aurora-livecam/landing/index.php
new file mode 100644
index 0000000..2967dd7
--- /dev/null
+++ b/aurora-livecam/landing/index.php
@@ -0,0 +1,422 @@
+isLandingPageEnabled()) {
+ header('Location: /');
+ exit;
+}
+
+$trialDays = $settingsManager->getTrialDays();
+?>
+
+
+
+
+
+ Aurora Livecam - Ihre Webcam als Service
+
+
+
+
+
+
+
+
+
+
+ Ihre Webcam als Service - in 5 Minuten online
+ Erstellen Sie Ihre eigene Live-Webcam-Website mit Wetter-Widget, Timelapse, Analytics und mehr. Keine Programmierkenntnisse erforderlich.
+
+
+ Tage kostenlos testen - Keine Kreditkarte erforderlich
+
+
+
+
+
+
+
Alles was Sie brauchen
+
Professionelle Features für Ihre Live-Webcam
+
+
+
+
📹
+
Live-Streaming
+
HLS, RTMP oder WebRTC - verbinden Sie jeden Stream in Sekunden. Automatische Qualitätsanpassung inklusive.
+
+
+
🌤️
+
Wetter-Widget
+
Zeigen Sie Temperatur, Wind, Luftdruck und mehr an. Kostenlose Open-Meteo Integration ohne API-Key.
+
+
+
⏱️
+
Timelapse
+
Automatische Zeitraffer-Erstellung. Scrubben Sie durch den ganzen Tag mit variabler Geschwindigkeit.
+
+
+
🔍
+
Zoom & Pan
+
Lassen Sie Besucher in Ihren Stream hineinzoomen. Unterstützt Touch-Gesten und Maus-Steuerung.
+
+
+
📊
+
Analytics
+
Sehen Sie wer Ihre Webcam besucht. Echtzeit-Zuschauerzähler und detaillierte Statistiken.
+
+
+
🎨
+
Custom Branding
+
Ihr Logo, Ihre Farben, Ihre Domain. Machen Sie die Webcam zu Ihrer eigenen.
+
+
+
+
+
+
+
+
So einfach geht's
+
In 3 Schritten zur eigenen Livecam
+
+
+
+
1
+
Registrieren
+
Erstellen Sie in 30 Sekunden Ihr kostenloses Konto.
+
+
+
2
+
Stream verbinden
+
Fügen Sie Ihre Stream-URL ein. Wir unterstützen alle gängigen Formate.
+
+
+
3
+
Anpassen & Teilen
+
Personalisieren Sie Ihre Seite und teilen Sie den Link.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/aurora-livecam/landing/pricing.php b/aurora-livecam/landing/pricing.php
new file mode 100644
index 0000000..227b9d0
--- /dev/null
+++ b/aurora-livecam/landing/pricing.php
@@ -0,0 +1,497 @@
+getPlans();
+} catch (\Exception $e) {
+ // Fallback-Pläne
+ $plans = [
+ ['name' => 'Free', 'slug' => 'free', 'price_monthly' => 0, 'features' => ['max_viewers' => 10, 'weather_widget' => true]],
+ ['name' => 'Basic', 'slug' => 'basic', 'price_monthly' => 19, 'features' => ['max_viewers' => 50, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true]],
+ ['name' => 'Professional', 'slug' => 'professional', 'price_monthly' => 49, 'features' => ['max_viewers' => 200, 'custom_domain' => true, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true, 'branding' => true]],
+ ['name' => 'Enterprise', 'slug' => 'enterprise', 'price_monthly' => 149, 'features' => ['max_viewers' => -1, 'custom_domain' => true, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true, 'branding' => true, 'priority_support' => true]],
+ ];
+}
+
+$trialDays = $settingsManager->getTrialDays();
+
+// Feature-Labels
+$featureLabels = [
+ 'max_viewers' => 'Gleichzeitige Zuschauer',
+ 'storage_gb' => 'Speicherplatz',
+ 'custom_domain' => 'Eigene Domain',
+ 'weather_widget' => 'Wetter-Widget',
+ 'timelapse' => 'Timelapse',
+ 'analytics' => 'Analytics & Statistiken',
+ 'branding' => 'Custom Branding',
+ 'priority_support' => 'Priority Support',
+];
+?>
+
+
+
+
+
+ Preise - Aurora Livecam
+
+
+
+
+
+
+
+
+
+
+
+
+ Häufige Fragen
+
+
+
+ Kann ich jederzeit wechseln oder kündigen?
+ +
+
+
+ Ja! Sie können Ihren Plan jederzeit upgraden oder downgraden. Bei einer Kündigung bleibt Ihr Zugang bis zum Ende der Abrechnungsperiode aktiv.
+
+
+
+
+
+ Was passiert nach dem Trial?
+ +
+
+
+ Nach Ablauf der Tage werden Sie automatisch auf den kostenlosen Plan umgestellt, sofern Sie kein Abo abschliessen. Keine Sorge, Ihre Daten bleiben erhalten.
+
+
+
+
+
+ Welche Zahlungsmethoden werden akzeptiert?
+ +
+
+
+ Wir akzeptieren alle gängigen Kreditkarten (Visa, Mastercard, American Express) sowie TWINT und Banküberweisung bei Jahresabos.
+
+
+
+
+
+ Brauche ich technisches Wissen?
+ +
+
+
+ Nein! Unser Onboarding-Wizard führt Sie Schritt für Schritt durch die Einrichtung. Sie benötigen lediglich eine Stream-URL (HLS/m3u8) von Ihrem Kamera-Anbieter.
+
+
+
+
+
+
+
+
+
diff --git a/aurora-livecam/onboarding/branding.php b/aurora-livecam/onboarding/branding.php
new file mode 100644
index 0000000..830d9ab
--- /dev/null
+++ b/aurora-livecam/onboarding/branding.php
@@ -0,0 +1,253 @@
+isLoggedIn()) {
+ header('Location: /onboarding/register.php');
+ exit;
+}
+
+$user = $auth->getUser();
+$tenantId = $user['tenant_id'] ?? 0;
+
+$error = '';
+$branding = [
+ 'site_name' => $user['tenant_name'] ?? '',
+ 'tagline' => '',
+ 'primary_color' => '#667eea',
+ 'secondary_color' => '#764ba2',
+];
+
+// Formular verarbeiten
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $branding = [
+ 'site_name' => trim($_POST['site_name'] ?? ''),
+ 'site_name_full' => trim($_POST['site_name'] ?? ''),
+ 'tagline' => trim($_POST['tagline'] ?? ''),
+ 'primary_color' => $_POST['primary_color'] ?? '#667eea',
+ 'secondary_color' => $_POST['secondary_color'] ?? '#764ba2',
+ ];
+
+ try {
+ $onboarding = new OnboardingManager();
+ $result = $onboarding->saveBranding($tenantId, $branding);
+
+ if ($result['success']) {
+ header('Location: /onboarding/complete.php');
+ exit;
+ } else {
+ $error = $result['error'] ?? 'Fehler beim Speichern';
+ }
+ } catch (\Exception $e) {
+ $error = 'Fehler: ' . $e->getMessage();
+ }
+}
+
+// Skip
+if (isset($_GET['skip'])) {
+ header('Location: /onboarding/complete.php');
+ exit;
+}
+?>
+
+
+
+
+
+ Branding - Aurora Livecam
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name Ihrer Livecam
+
+
+
+
+ Slogan / Beschreibung
+
+
+
+
+
+
+
+
+
+ Speichern & abschliessen
+
+
+
+
+ Später anpassen →
+
+
+
+
+
+
+
diff --git a/aurora-livecam/onboarding/complete.php b/aurora-livecam/onboarding/complete.php
new file mode 100644
index 0000000..3696d4d
--- /dev/null
+++ b/aurora-livecam/onboarding/complete.php
@@ -0,0 +1,237 @@
+isLoggedIn()) {
+ header('Location: /onboarding/register.php');
+ exit;
+}
+
+$user = $auth->getUser();
+$tenantId = $user['tenant_id'] ?? 0;
+
+// Onboarding abschliessen
+try {
+ $onboarding = new OnboardingManager();
+ $onboarding->complete($tenantId);
+} catch (\Exception $e) {
+ // Ignorieren wenn DB nicht verfügbar
+}
+
+// Tenant-Info laden
+$tenantSlug = 'demo';
+$subdomain = '';
+
+try {
+ $db = Database::getInstance();
+ $tenant = $db->fetchOne("SELECT slug FROM tenants WHERE id = ?", [$tenantId]);
+ if ($tenant) {
+ $tenantSlug = $tenant['slug'];
+ $subdomain = $tenantSlug . '.aurora-livecam.com';
+ }
+} catch (\Exception $e) {
+ // Fallback
+}
+?>
+
+
+
+
+
+ Fertig! - Aurora Livecam
+
+
+
+
+
+
+
+
+
🎉
+
Herzlichen Glückwunsch!
+
Ihre Livecam ist jetzt eingerichtet und bereit.
+
+
+
+
Ihre Livecam-Adresse:
+
https://
+
+
+
+
+
+
+
Nächste Schritte
+
+ Stream-URL im Dashboard anpassen (falls noch nicht geschehen)
+ Logo und Farben im Branding-Bereich hochladen
+ Wetter-Widget konfigurieren
+ Eigene Domain verbinden (optional)
+ isBillingEnabled()): ?>
+ Abo auswählen für mehr Funktionen
+
+
+
+
+
+
+
+
+
diff --git a/aurora-livecam/onboarding/register.php b/aurora-livecam/onboarding/register.php
new file mode 100644
index 0000000..3649dae
--- /dev/null
+++ b/aurora-livecam/onboarding/register.php
@@ -0,0 +1,265 @@
+isSelfRegistrationEnabled()) {
+ header('Location: /');
+ exit;
+}
+
+$auth = new AuthManager();
+
+// Bereits eingeloggt?
+if ($auth->isLoggedIn()) {
+ header('Location: /dashboard/');
+ exit;
+}
+
+$errors = [];
+$formData = [];
+$success = false;
+
+// Formular verarbeiten
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $formData = [
+ 'name' => trim($_POST['name'] ?? ''),
+ 'company_name' => trim($_POST['company_name'] ?? ''),
+ 'email' => trim($_POST['email'] ?? ''),
+ 'password' => $_POST['password'] ?? '',
+ 'password_confirm' => $_POST['password_confirm'] ?? '',
+ 'stream_url' => trim($_POST['stream_url'] ?? ''),
+ 'accept_terms' => isset($_POST['accept_terms']),
+ ];
+
+ try {
+ $onboarding = new OnboardingManager();
+ $result = $onboarding->register($formData);
+
+ if ($result['success']) {
+ // Session starten und User einloggen
+ $auth->login($formData['email'], $formData['password']);
+
+ // Zur nächsten Seite weiterleiten
+ if ($onboarding->requiresEmailVerification()) {
+ // Token für Demo-Zwecke in Session speichern
+ $_SESSION['verification_token'] = $result['verification_token'];
+ header('Location: /onboarding/verify.php');
+ } else {
+ header('Location: /onboarding/stream.php');
+ }
+ exit;
+ } else {
+ $errors = $result['errors'];
+ }
+ } catch (\Exception $e) {
+ $errors['general'] = 'Registrierung fehlgeschlagen: ' . $e->getMessage();
+ }
+}
+
+$trialDays = $settingsManager->getTrialDays();
+?>
+
+
+
+
+
+ Registrierung - Aurora Livecam
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Optional
+
+
+
+
+
+
+ Kostenlos registrieren
+
+
+
+
+ Bereits registriert?
+ Anmelden
+
+
+
+
+
diff --git a/aurora-livecam/onboarding/stream.php b/aurora-livecam/onboarding/stream.php
new file mode 100644
index 0000000..0a4899a
--- /dev/null
+++ b/aurora-livecam/onboarding/stream.php
@@ -0,0 +1,265 @@
+isLoggedIn()) {
+ header('Location: /onboarding/register.php');
+ exit;
+}
+
+$user = $auth->getUser();
+$tenantId = $user['tenant_id'] ?? 0;
+
+$error = '';
+$streamUrl = '';
+$streamType = 'hls';
+$validationResult = null;
+
+// Formular verarbeiten
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $streamUrl = trim($_POST['stream_url'] ?? '');
+ $streamType = $_POST['stream_type'] ?? 'hls';
+
+ if (empty($streamUrl)) {
+ $error = 'Bitte geben Sie eine Stream-URL ein';
+ } else {
+ try {
+ // Stream validieren
+ $validator = new StreamValidator();
+ $validationResult = $validator->validate($streamUrl);
+
+ if ($validationResult['valid']) {
+ // Speichern
+ $onboarding = new OnboardingManager();
+ $result = $onboarding->saveStream($tenantId, $streamUrl, $streamType);
+
+ if ($result['success']) {
+ header('Location: /onboarding/branding.php');
+ exit;
+ } else {
+ $error = $result['error'];
+ }
+ } else {
+ $error = $validationResult['error'] ?? 'Stream-URL konnte nicht validiert werden';
+ }
+ } catch (\Exception $e) {
+ $error = 'Fehler: ' . $e->getMessage();
+ }
+ }
+}
+
+// Skip erlauben
+if (isset($_GET['skip'])) {
+ header('Location: /onboarding/branding.php');
+ exit;
+}
+?>
+
+
+
+
+
+ Stream einrichten - Aurora Livecam
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Erkannter Typ:
+
+
+
+
+
+
+
+ Stream testen & weiter
+
+
+
+
+ Später einrichten →
+
+
+
+
+
+
+
diff --git a/aurora-livecam/onboarding/verify.php b/aurora-livecam/onboarding/verify.php
new file mode 100644
index 0000000..080be0e
--- /dev/null
+++ b/aurora-livecam/onboarding/verify.php
@@ -0,0 +1,214 @@
+isLoggedIn()) {
+ header('Location: /onboarding/register.php');
+ exit;
+}
+
+$user = $auth->getUser();
+$message = '';
+$error = '';
+$verified = false;
+
+// Token aus URL verarbeiten
+if (isset($_GET['token'])) {
+ try {
+ $onboarding = new OnboardingManager();
+ $result = $onboarding->verifyEmail($_GET['token']);
+
+ if ($result['success']) {
+ $verified = true;
+ $message = 'E-Mail erfolgreich verifiziert!';
+ } else {
+ $error = $result['error'];
+ }
+ } catch (\Exception $e) {
+ $error = 'Verifikation fehlgeschlagen';
+ }
+}
+
+// E-Mail erneut senden
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['resend'])) {
+ try {
+ $onboarding = new OnboardingManager();
+ $result = $onboarding->resendVerification($user['id']);
+
+ if ($result['success']) {
+ $_SESSION['verification_token'] = $result['token'];
+ $message = 'Verifikations-E-Mail wurde erneut gesendet!';
+ } else {
+ $error = $result['error'];
+ }
+ } catch (\Exception $e) {
+ $error = 'Fehler beim Senden';
+ }
+}
+
+// Demo: Token anzeigen (in Produktion würde eine E-Mail gesendet)
+$demoToken = $_SESSION['verification_token'] ?? null;
+?>
+
+
+
+
+
+ E-Mail verifizieren - Aurora Livecam
+
+
+
+
+
+
+
+
+
+
✅
+
E-Mail verifiziert!
+
Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
+
+ Weiter zur Stream-Konfiguration
+
+
+
📧
+
E-Mail bestätigen
+
+ Wir haben eine Bestätigungs-E-Mail an
+
+ gesendet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine E-Mail erhalten?
+
+
+
+
+ Erneut senden
+
+
+
+
+
+
+ Abmelden
+
+
+
+
+
+
diff --git a/aurora-livecam/src/Auth/AuthManager.php b/aurora-livecam/src/Auth/AuthManager.php
new file mode 100644
index 0000000..efc3099
--- /dev/null
+++ b/aurora-livecam/src/Auth/AuthManager.php
@@ -0,0 +1,355 @@
+db = $db ?? Database::getInstance();
+ $this->checkDbAvailability();
+
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+ }
+
+ private function checkDbAvailability(): void
+ {
+ try {
+ $this->db->fetchOne("SELECT 1 FROM users LIMIT 1");
+ $this->dbAvailable = true;
+ } catch (\Exception $e) {
+ $this->dbAvailable = false;
+ }
+ }
+
+ /**
+ * Registriert einen neuen Benutzer
+ */
+ public function register(array $data): int
+ {
+ if (!$this->dbAvailable) {
+ throw new \Exception('Database not available');
+ }
+
+ // Validierung
+ if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
+ throw new \Exception('Invalid email address');
+ }
+
+ if (empty($data['password']) || strlen($data['password']) < 8) {
+ throw new \Exception('Password must be at least 8 characters');
+ }
+
+ // Prüfe ob Email bereits existiert
+ $existing = $this->db->fetchOne("SELECT id FROM users WHERE email = ?", [$data['email']]);
+ if ($existing) {
+ throw new \Exception('Email already registered');
+ }
+
+ // Benutzer erstellen
+ return $this->db->insert('users', [
+ 'tenant_id' => $data['tenant_id'] ?? null,
+ 'email' => strtolower($data['email']),
+ 'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
+ 'name' => $data['name'] ?? null,
+ 'role' => $data['role'] ?? 'tenant_user',
+ ]);
+ }
+
+ /**
+ * Login mit Email und Passwort
+ */
+ public function login(string $email, string $password, bool $remember = false): bool
+ {
+ // Legacy-Modus (hardcoded admin)
+ if (!$this->dbAvailable) {
+ return $this->legacyLogin($email, $password);
+ }
+
+ $user = $this->db->fetchOne(
+ "SELECT u.*, t.name as tenant_name, t.slug as tenant_slug
+ FROM users u
+ LEFT JOIN tenants t ON u.tenant_id = t.id
+ WHERE u.email = ?",
+ [strtolower($email)]
+ );
+
+ if (!$user || !password_verify($password, $user['password_hash'])) {
+ return false;
+ }
+
+ // Session setzen
+ $this->setSession($user);
+
+ // Last login aktualisieren
+ $this->db->update('users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [$user['id']]);
+
+ // Remember-Me Cookie
+ if ($remember) {
+ $this->setRememberToken($user['id']);
+ }
+
+ return true;
+ }
+
+ /**
+ * Legacy Login (kompatibel mit altem AdminManager)
+ */
+ private function legacyLogin(string $email, string $password): bool
+ {
+ // Alte hardcoded Credentials als Fallback
+ if ($email === 'admin' && $password === 'sonne4000$$$$Q') {
+ $_SESSION['admin'] = true;
+ $_SESSION['user'] = [
+ 'id' => 0,
+ 'email' => 'admin',
+ 'name' => 'Administrator',
+ 'role' => 'super_admin',
+ 'tenant_id' => null,
+ ];
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Setzt die Session-Daten
+ */
+ private function setSession(array $user): void
+ {
+ $_SESSION['admin'] = true; // Kompatibilität mit Legacy
+ $_SESSION['user'] = [
+ 'id' => $user['id'],
+ 'email' => $user['email'],
+ 'name' => $user['name'],
+ 'role' => $user['role'],
+ 'tenant_id' => $user['tenant_id'],
+ 'tenant_name' => $user['tenant_name'] ?? null,
+ 'tenant_slug' => $user['tenant_slug'] ?? null,
+ ];
+ }
+
+ /**
+ * Setzt Remember-Me Token
+ */
+ private function setRememberToken(int $userId): void
+ {
+ $token = bin2hex(random_bytes(32));
+ $hashedToken = hash('sha256', $token);
+
+ $this->db->update('users', ['remember_token' => $hashedToken], 'id = ?', [$userId]);
+
+ setcookie('remember_token', $token, [
+ 'expires' => time() + (86400 * 30), // 30 Tage
+ 'path' => '/',
+ 'secure' => true,
+ 'httponly' => true,
+ 'samesite' => 'Lax'
+ ]);
+ }
+
+ /**
+ * Prüft Remember-Me Cookie
+ */
+ public function checkRememberToken(): bool
+ {
+ if (!isset($_COOKIE['remember_token']) || !$this->dbAvailable) {
+ return false;
+ }
+
+ $hashedToken = hash('sha256', $_COOKIE['remember_token']);
+
+ $user = $this->db->fetchOne(
+ "SELECT u.*, t.name as tenant_name, t.slug as tenant_slug
+ FROM users u
+ LEFT JOIN tenants t ON u.tenant_id = t.id
+ WHERE u.remember_token = ?",
+ [$hashedToken]
+ );
+
+ if ($user) {
+ $this->setSession($user);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Logout
+ */
+ public function logout(): void
+ {
+ // Remember-Token löschen
+ if ($this->isLoggedIn() && $this->dbAvailable) {
+ $userId = $_SESSION['user']['id'] ?? 0;
+ if ($userId > 0) {
+ $this->db->update('users', ['remember_token' => null], 'id = ?', [$userId]);
+ }
+ }
+
+ // Cookie löschen
+ setcookie('remember_token', '', [
+ 'expires' => time() - 3600,
+ 'path' => '/',
+ 'secure' => true,
+ 'httponly' => true,
+ ]);
+
+ // Session zerstören
+ $_SESSION = [];
+ if (ini_get("session.use_cookies")) {
+ $params = session_get_cookie_params();
+ setcookie(session_name(), '', time() - 42000,
+ $params["path"], $params["domain"],
+ $params["secure"], $params["httponly"]
+ );
+ }
+ session_destroy();
+ }
+
+ /**
+ * Prüft ob User eingeloggt ist
+ */
+ public function isLoggedIn(): bool
+ {
+ return isset($_SESSION['admin']) && $_SESSION['admin'] === true;
+ }
+
+ /**
+ * Gibt den aktuellen User zurück
+ */
+ public function getUser(): ?array
+ {
+ return $_SESSION['user'] ?? null;
+ }
+
+ /**
+ * Prüft ob User eine bestimmte Rolle hat
+ */
+ public function hasRole(string $role): bool
+ {
+ $user = $this->getUser();
+ return $user && $user['role'] === $role;
+ }
+
+ /**
+ * Prüft ob User Super-Admin ist
+ */
+ public function isSuperAdmin(): bool
+ {
+ return $this->hasRole('super_admin');
+ }
+
+ /**
+ * Prüft ob User Tenant-Admin ist
+ */
+ public function isTenantAdmin(): bool
+ {
+ return $this->hasRole('tenant_admin') || $this->hasRole('super_admin');
+ }
+
+ /**
+ * Gibt die Tenant-ID des aktuellen Users zurück
+ */
+ public function getTenantId(): ?int
+ {
+ $user = $this->getUser();
+ return $user ? ($user['tenant_id'] ?? null) : null;
+ }
+
+ /**
+ * Prüft ob User Zugriff auf einen bestimmten Tenant hat
+ */
+ public function canAccessTenant(int $tenantId): bool
+ {
+ if ($this->isSuperAdmin()) {
+ return true;
+ }
+
+ return $this->getTenantId() === $tenantId;
+ }
+
+ /**
+ * Ändert das Passwort
+ */
+ public function changePassword(int $userId, string $currentPassword, string $newPassword): bool
+ {
+ if (!$this->dbAvailable) {
+ return false;
+ }
+
+ $user = $this->db->fetchOne("SELECT password_hash FROM users WHERE id = ?", [$userId]);
+
+ if (!$user || !password_verify($currentPassword, $user['password_hash'])) {
+ return false;
+ }
+
+ if (strlen($newPassword) < 8) {
+ throw new \Exception('Password must be at least 8 characters');
+ }
+
+ return $this->db->update('users', [
+ 'password_hash' => password_hash($newPassword, PASSWORD_ARGON2ID)
+ ], 'id = ?', [$userId]) > 0;
+ }
+
+ /**
+ * Generiert ein Passwort-Reset-Token
+ */
+ public function generateResetToken(string $email): ?string
+ {
+ if (!$this->dbAvailable) {
+ return null;
+ }
+
+ $user = $this->db->fetchOne("SELECT id FROM users WHERE email = ?", [strtolower($email)]);
+
+ if (!$user) {
+ return null; // Keine Info leaken ob Email existiert
+ }
+
+ $token = bin2hex(random_bytes(32));
+ // Token würde normalerweise in separater Tabelle mit Ablaufzeit gespeichert
+ // Für jetzt: vereinfachte Version
+
+ return $token;
+ }
+
+ /**
+ * Middleware: Erfordert Login
+ */
+ public function requireLogin(): void
+ {
+ if (!$this->isLoggedIn()) {
+ if (!$this->checkRememberToken()) {
+ header('Location: /dashboard/login.php');
+ exit;
+ }
+ }
+ }
+
+ /**
+ * Middleware: Erfordert bestimmte Rolle
+ */
+ public function requireRole(string $role): void
+ {
+ $this->requireLogin();
+
+ if (!$this->hasRole($role) && !$this->isSuperAdmin()) {
+ http_response_code(403);
+ echo "Access denied";
+ exit;
+ }
+ }
+}
diff --git a/aurora-livecam/src/Billing/StripeService.php b/aurora-livecam/src/Billing/StripeService.php
new file mode 100644
index 0000000..4f5e986
--- /dev/null
+++ b/aurora-livecam/src/Billing/StripeService.php
@@ -0,0 +1,290 @@
+db = $db ?? Database::getInstance();
+ $this->loadConfig();
+ }
+
+ /**
+ * Lädt Stripe-Konfiguration
+ */
+ private function loadConfig(): void
+ {
+ $configFile = dirname(__DIR__, 2) . '/config.php';
+
+ if (file_exists($configFile)) {
+ $config = require $configFile;
+ $this->secretKey = $config['stripe']['secret_key'] ?? '';
+ $this->publicKey = $config['stripe']['public_key'] ?? '';
+ $this->webhookSecret = $config['stripe']['webhook_secret'] ?? '';
+ $this->currency = $config['stripe']['currency'] ?? 'chf';
+ } else {
+ $this->secretKey = getenv('STRIPE_SECRET_KEY') ?: '';
+ $this->publicKey = getenv('STRIPE_PUBLIC_KEY') ?: '';
+ $this->webhookSecret = getenv('STRIPE_WEBHOOK_SECRET') ?: '';
+ $this->currency = 'chf';
+ }
+ }
+
+ /**
+ * Prüft ob Stripe konfiguriert ist
+ */
+ public function isConfigured(): bool
+ {
+ return !empty($this->secretKey) && !empty($this->publicKey);
+ }
+
+ /**
+ * Gibt den Public Key zurück
+ */
+ public function getPublicKey(): string
+ {
+ return $this->publicKey ?? '';
+ }
+
+ /**
+ * Erstellt einen Stripe Customer
+ */
+ public function createCustomer(int $tenantId, string $email, string $name): ?string
+ {
+ $response = $this->request('POST', '/v1/customers', [
+ 'email' => $email,
+ 'name' => $name,
+ 'metadata' => [
+ 'tenant_id' => $tenantId,
+ ],
+ ]);
+
+ if ($response && isset($response['id'])) {
+ // In DB speichern
+ $this->db->execute(
+ "UPDATE subscriptions SET stripe_customer_id = ? WHERE tenant_id = ?",
+ [$response['id'], $tenantId]
+ );
+ return $response['id'];
+ }
+
+ return null;
+ }
+
+ /**
+ * Erstellt eine Checkout Session
+ */
+ public function createCheckoutSession(int $tenantId, string $priceId, string $successUrl, string $cancelUrl): ?array
+ {
+ // Customer ID holen oder erstellen
+ $customerId = $this->getOrCreateCustomer($tenantId);
+
+ $params = [
+ 'customer' => $customerId,
+ 'payment_method_types' => ['card'],
+ 'line_items' => [[
+ 'price' => $priceId,
+ 'quantity' => 1,
+ ]],
+ 'mode' => 'subscription',
+ 'success_url' => $successUrl,
+ 'cancel_url' => $cancelUrl,
+ 'metadata' => [
+ 'tenant_id' => $tenantId,
+ ],
+ ];
+
+ return $this->request('POST', '/v1/checkout/sessions', $params);
+ }
+
+ /**
+ * Erstellt ein Billing Portal Session
+ */
+ public function createPortalSession(int $tenantId, string $returnUrl): ?array
+ {
+ $customerId = $this->getCustomerId($tenantId);
+
+ if (!$customerId) {
+ return null;
+ }
+
+ return $this->request('POST', '/v1/billing_portal/sessions', [
+ 'customer' => $customerId,
+ 'return_url' => $returnUrl,
+ ]);
+ }
+
+ /**
+ * Holt oder erstellt Customer
+ */
+ private function getOrCreateCustomer(int $tenantId): ?string
+ {
+ $customerId = $this->getCustomerId($tenantId);
+
+ if ($customerId) {
+ return $customerId;
+ }
+
+ // Tenant-Daten laden
+ $tenant = $this->db->fetchOne(
+ "SELECT t.*, u.email, u.name FROM tenants t
+ LEFT JOIN users u ON u.tenant_id = t.id AND u.role = 'tenant_admin'
+ WHERE t.id = ? LIMIT 1",
+ [$tenantId]
+ );
+
+ if (!$tenant) {
+ return null;
+ }
+
+ return $this->createCustomer($tenantId, $tenant['email'], $tenant['name'] ?? $tenant['name']);
+ }
+
+ /**
+ * Holt Customer ID aus DB
+ */
+ private function getCustomerId(int $tenantId): ?string
+ {
+ $sub = $this->db->fetchOne(
+ "SELECT stripe_customer_id FROM subscriptions WHERE tenant_id = ?",
+ [$tenantId]
+ );
+
+ return $sub['stripe_customer_id'] ?? null;
+ }
+
+ /**
+ * Holt Subscription von Stripe
+ */
+ public function getSubscription(string $subscriptionId): ?array
+ {
+ return $this->request('GET', '/v1/subscriptions/' . $subscriptionId);
+ }
+
+ /**
+ * Kündigt Subscription
+ */
+ public function cancelSubscription(string $subscriptionId, bool $immediately = false): ?array
+ {
+ if ($immediately) {
+ return $this->request('DELETE', '/v1/subscriptions/' . $subscriptionId);
+ }
+
+ return $this->request('POST', '/v1/subscriptions/' . $subscriptionId, [
+ 'cancel_at_period_end' => true,
+ ]);
+ }
+
+ /**
+ * Holt Rechnungen
+ */
+ public function getInvoices(string $customerId, int $limit = 10): array
+ {
+ $response = $this->request('GET', '/v1/invoices', [
+ 'customer' => $customerId,
+ 'limit' => $limit,
+ ]);
+
+ return $response['data'] ?? [];
+ }
+
+ /**
+ * Verifiziert Webhook-Signatur
+ */
+ public function verifyWebhook(string $payload, string $signature): ?array
+ {
+ if (empty($this->webhookSecret)) {
+ return json_decode($payload, true);
+ }
+
+ $elements = explode(',', $signature);
+ $timestamp = null;
+ $signatures = [];
+
+ foreach ($elements as $element) {
+ $parts = explode('=', $element, 2);
+ if ($parts[0] === 't') {
+ $timestamp = $parts[1];
+ } elseif ($parts[0] === 'v1') {
+ $signatures[] = $parts[1];
+ }
+ }
+
+ if (!$timestamp || empty($signatures)) {
+ return null;
+ }
+
+ // Toleranz: 5 Minuten
+ if (abs(time() - $timestamp) > 300) {
+ return null;
+ }
+
+ $signedPayload = $timestamp . '.' . $payload;
+ $expectedSignature = hash_hmac('sha256', $signedPayload, $this->webhookSecret);
+
+ foreach ($signatures as $sig) {
+ if (hash_equals($expectedSignature, $sig)) {
+ return json_decode($payload, true);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Stripe API Request
+ */
+ private function request(string $method, string $endpoint, array $data = []): ?array
+ {
+ if (!$this->isConfigured()) {
+ return null;
+ }
+
+ $url = 'https://api.stripe.com' . $endpoint;
+
+ $ch = curl_init();
+
+ $headers = [
+ 'Authorization: Bearer ' . $this->secretKey,
+ 'Content-Type: application/x-www-form-urlencoded',
+ ];
+
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url . ($method === 'GET' && $data ? '?' . http_build_query($data) : ''),
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_TIMEOUT => 30,
+ ]);
+
+ if ($method === 'POST') {
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
+ } elseif ($method === 'DELETE') {
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
+ }
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode >= 200 && $httpCode < 300) {
+ return json_decode($response, true);
+ }
+
+ // Log error
+ error_log("Stripe API Error ($httpCode): $response");
+ return null;
+ }
+}
diff --git a/aurora-livecam/src/Billing/SubscriptionManager.php b/aurora-livecam/src/Billing/SubscriptionManager.php
new file mode 100644
index 0000000..062588e
--- /dev/null
+++ b/aurora-livecam/src/Billing/SubscriptionManager.php
@@ -0,0 +1,285 @@
+db = $db ?? Database::getInstance();
+ $this->stripe = new StripeService($this->db);
+ }
+
+ /**
+ * Gibt alle Pläne zurück
+ */
+ public function getPlans(bool $activeOnly = true): array
+ {
+ $sql = "SELECT * FROM plans";
+ if ($activeOnly) {
+ $sql .= " WHERE is_active = 1";
+ }
+ $sql .= " ORDER BY sort_order ASC";
+
+ $plans = $this->db->fetchAll($sql);
+
+ // Features JSON decodieren
+ foreach ($plans as &$plan) {
+ if (isset($plan['features'])) {
+ $plan['features'] = json_decode($plan['features'], true) ?? [];
+ }
+ }
+
+ return $plans;
+ }
+
+ /**
+ * Gibt einen Plan zurück
+ */
+ public function getPlan(int $planId): ?array
+ {
+ $plan = $this->db->fetchOne("SELECT * FROM plans WHERE id = ?", [$planId]);
+
+ if ($plan && isset($plan['features'])) {
+ $plan['features'] = json_decode($plan['features'], true) ?? [];
+ }
+
+ return $plan;
+ }
+
+ /**
+ * Gibt Plan by Slug zurück
+ */
+ public function getPlanBySlug(string $slug): ?array
+ {
+ $plan = $this->db->fetchOne("SELECT * FROM plans WHERE slug = ?", [$slug]);
+
+ if ($plan && isset($plan['features'])) {
+ $plan['features'] = json_decode($plan['features'], true) ?? [];
+ }
+
+ return $plan;
+ }
+
+ /**
+ * Gibt die aktuelle Subscription eines Tenants zurück
+ */
+ public function getSubscription(int $tenantId): ?array
+ {
+ $sub = $this->db->fetchOne(
+ "SELECT s.*, p.name as plan_name, p.slug as plan_slug, p.features as plan_features
+ FROM subscriptions s
+ JOIN plans p ON s.plan_id = p.id
+ WHERE s.tenant_id = ?
+ ORDER BY s.created_at DESC LIMIT 1",
+ [$tenantId]
+ );
+
+ if ($sub && isset($sub['plan_features'])) {
+ $sub['plan_features'] = json_decode($sub['plan_features'], true) ?? [];
+ }
+
+ return $sub;
+ }
+
+ /**
+ * Erstellt oder aktualisiert eine Subscription
+ */
+ public function createOrUpdate(int $tenantId, array $data): int
+ {
+ $existing = $this->db->fetchOne(
+ "SELECT id FROM subscriptions WHERE tenant_id = ?",
+ [$tenantId]
+ );
+
+ if ($existing) {
+ $this->db->update('subscriptions', $data, 'id = ?', [$existing['id']]);
+ return $existing['id'];
+ }
+
+ $data['tenant_id'] = $tenantId;
+ return $this->db->insert('subscriptions', $data);
+ }
+
+ /**
+ * Startet Trial für einen Tenant
+ */
+ public function startTrial(int $tenantId, int $planId = null, int $days = 14): void
+ {
+ if (!$planId) {
+ $freePlan = $this->getPlanBySlug('basic');
+ $planId = $freePlan['id'] ?? 1;
+ }
+
+ $this->createOrUpdate($tenantId, [
+ 'plan_id' => $planId,
+ 'status' => 'trialing',
+ 'current_period_start' => date('Y-m-d H:i:s'),
+ 'current_period_end' => date('Y-m-d H:i:s', strtotime("+$days days")),
+ ]);
+
+ // Tenant Status
+ $this->db->update('tenants', [
+ 'status' => 'trial',
+ 'trial_ends_at' => date('Y-m-d H:i:s', strtotime("+$days days")),
+ ], 'id = ?', [$tenantId]);
+ }
+
+ /**
+ * Aktiviert Subscription nach Zahlung
+ */
+ public function activate(int $tenantId, string $stripeSubscriptionId, int $planId): void
+ {
+ $this->createOrUpdate($tenantId, [
+ 'plan_id' => $planId,
+ 'stripe_subscription_id' => $stripeSubscriptionId,
+ 'status' => 'active',
+ 'current_period_start' => date('Y-m-d H:i:s'),
+ ]);
+
+ $this->db->update('tenants', ['status' => 'active', 'plan_id' => $planId], 'id = ?', [$tenantId]);
+ }
+
+ /**
+ * Kündigt Subscription
+ */
+ public function cancel(int $tenantId, bool $immediately = false): bool
+ {
+ $sub = $this->getSubscription($tenantId);
+
+ if (!$sub) {
+ return false;
+ }
+
+ // Bei Stripe kündigen
+ if (!empty($sub['stripe_subscription_id'])) {
+ $this->stripe->cancelSubscription($sub['stripe_subscription_id'], $immediately);
+ }
+
+ $status = $immediately ? 'canceled' : 'active'; // Bleibt aktiv bis Periodenende
+
+ $this->db->update('subscriptions', [
+ 'status' => $status,
+ 'canceled_at' => date('Y-m-d H:i:s'),
+ ], 'tenant_id = ?', [$tenantId]);
+
+ if ($immediately) {
+ $this->db->update('tenants', ['status' => 'cancelled'], 'id = ?', [$tenantId]);
+ }
+
+ return true;
+ }
+
+ /**
+ * Prüft ob Tenant aktiv ist (Trial oder bezahlt)
+ */
+ public function isActive(int $tenantId): bool
+ {
+ $sub = $this->getSubscription($tenantId);
+
+ if (!$sub) {
+ return false;
+ }
+
+ if ($sub['status'] === 'active') {
+ return true;
+ }
+
+ if ($sub['status'] === 'trialing') {
+ $endDate = strtotime($sub['current_period_end']);
+ return $endDate > time();
+ }
+
+ return false;
+ }
+
+ /**
+ * Gibt verbleibende Trial-Tage zurück
+ */
+ public function getTrialDaysRemaining(int $tenantId): int
+ {
+ $tenant = $this->db->fetchOne(
+ "SELECT trial_ends_at FROM tenants WHERE id = ?",
+ [$tenantId]
+ );
+
+ if (!$tenant || !$tenant['trial_ends_at']) {
+ return 0;
+ }
+
+ $remaining = strtotime($tenant['trial_ends_at']) - time();
+ return max(0, (int)ceil($remaining / 86400));
+ }
+
+ /**
+ * Prüft Feature-Zugriff
+ */
+ public function hasFeature(int $tenantId, string $feature): bool
+ {
+ $sub = $this->getSubscription($tenantId);
+
+ if (!$sub || !isset($sub['plan_features'])) {
+ return false;
+ }
+
+ return !empty($sub['plan_features'][$feature]);
+ }
+
+ /**
+ * Gibt Feature-Limit zurück
+ */
+ public function getFeatureLimit(int $tenantId, string $feature): int
+ {
+ $sub = $this->getSubscription($tenantId);
+
+ if (!$sub || !isset($sub['plan_features'][$feature])) {
+ return 0;
+ }
+
+ $value = $sub['plan_features'][$feature];
+
+ // -1 = unlimited
+ if ($value === -1 || $value === true) {
+ return PHP_INT_MAX;
+ }
+
+ return (int)$value;
+ }
+
+ /**
+ * Speichert Rechnung
+ */
+ public function saveInvoice(int $tenantId, array $invoiceData): void
+ {
+ $this->db->insert('invoices', [
+ 'tenant_id' => $tenantId,
+ 'stripe_invoice_id' => $invoiceData['id'] ?? null,
+ 'amount' => ($invoiceData['amount_paid'] ?? 0) / 100,
+ 'currency' => strtoupper($invoiceData['currency'] ?? 'CHF'),
+ 'status' => $invoiceData['status'] ?? 'unknown',
+ 'paid_at' => isset($invoiceData['status_transitions']['paid_at'])
+ ? date('Y-m-d H:i:s', $invoiceData['status_transitions']['paid_at'])
+ : null,
+ 'invoice_pdf_url' => $invoiceData['invoice_pdf'] ?? null,
+ ]);
+ }
+
+ /**
+ * Gibt Rechnungen eines Tenants zurück
+ */
+ public function getInvoices(int $tenantId, int $limit = 10): array
+ {
+ return $this->db->fetchAll(
+ "SELECT * FROM invoices WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ?",
+ [$tenantId, $limit]
+ );
+ }
+}
diff --git a/aurora-livecam/src/Billing/WebhookHandler.php b/aurora-livecam/src/Billing/WebhookHandler.php
new file mode 100644
index 0000000..63d893d
--- /dev/null
+++ b/aurora-livecam/src/Billing/WebhookHandler.php
@@ -0,0 +1,250 @@
+db = $db ?? Database::getInstance();
+ $this->stripe = new StripeService($this->db);
+ $this->subscriptions = new SubscriptionManager($this->db);
+ }
+
+ /**
+ * Verarbeitet einen Webhook
+ */
+ public function handle(string $payload, string $signature): array
+ {
+ // Signatur verifizieren
+ $event = $this->stripe->verifyWebhook($payload, $signature);
+
+ if (!$event) {
+ return ['success' => false, 'error' => 'Invalid signature'];
+ }
+
+ $type = $event['type'] ?? '';
+ $data = $event['data']['object'] ?? [];
+
+ try {
+ switch ($type) {
+ case 'checkout.session.completed':
+ return $this->handleCheckoutComplete($data);
+
+ case 'customer.subscription.created':
+ case 'customer.subscription.updated':
+ return $this->handleSubscriptionUpdate($data);
+
+ case 'customer.subscription.deleted':
+ return $this->handleSubscriptionDeleted($data);
+
+ case 'invoice.paid':
+ return $this->handleInvoicePaid($data);
+
+ case 'invoice.payment_failed':
+ return $this->handlePaymentFailed($data);
+
+ default:
+ return ['success' => true, 'message' => 'Event ignored: ' . $type];
+ }
+ } catch (\Exception $e) {
+ error_log("Webhook error: " . $e->getMessage());
+ return ['success' => false, 'error' => $e->getMessage()];
+ }
+ }
+
+ /**
+ * Checkout abgeschlossen
+ */
+ private function handleCheckoutComplete(array $session): array
+ {
+ $tenantId = $session['metadata']['tenant_id'] ?? null;
+ $subscriptionId = $session['subscription'] ?? null;
+
+ if (!$tenantId || !$subscriptionId) {
+ return ['success' => false, 'error' => 'Missing tenant_id or subscription'];
+ }
+
+ // Subscription-Details von Stripe holen
+ $subscription = $this->stripe->getSubscription($subscriptionId);
+
+ if (!$subscription) {
+ return ['success' => false, 'error' => 'Could not fetch subscription'];
+ }
+
+ // Plan aus Stripe Price ID ermitteln
+ $priceId = $subscription['items']['data'][0]['price']['id'] ?? null;
+ $plan = $this->db->fetchOne(
+ "SELECT id FROM plans WHERE stripe_price_id = ?",
+ [$priceId]
+ );
+
+ $planId = $plan['id'] ?? 1;
+
+ // Subscription aktivieren
+ $this->subscriptions->activate($tenantId, $subscriptionId, $planId);
+
+ // Customer ID speichern
+ $this->db->update('subscriptions', [
+ 'stripe_customer_id' => $session['customer'],
+ ], 'tenant_id = ?', [$tenantId]);
+
+ return ['success' => true, 'message' => 'Subscription activated'];
+ }
+
+ /**
+ * Subscription erstellt/aktualisiert
+ */
+ private function handleSubscriptionUpdate(array $subscription): array
+ {
+ $customerId = $subscription['customer'] ?? null;
+
+ if (!$customerId) {
+ return ['success' => false, 'error' => 'No customer ID'];
+ }
+
+ // Tenant über Customer ID finden
+ $sub = $this->db->fetchOne(
+ "SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?",
+ [$customerId]
+ );
+
+ if (!$sub) {
+ return ['success' => true, 'message' => 'Customer not found in DB'];
+ }
+
+ $tenantId = $sub['tenant_id'];
+ $status = $this->mapStripeStatus($subscription['status']);
+
+ $this->db->update('subscriptions', [
+ 'stripe_subscription_id' => $subscription['id'],
+ 'status' => $status,
+ 'current_period_start' => date('Y-m-d H:i:s', $subscription['current_period_start']),
+ 'current_period_end' => date('Y-m-d H:i:s', $subscription['current_period_end']),
+ ], 'tenant_id = ?', [$tenantId]);
+
+ // Tenant-Status aktualisieren
+ $tenantStatus = in_array($status, ['active', 'trialing']) ? 'active' : 'suspended';
+ $this->db->update('tenants', ['status' => $tenantStatus], 'id = ?', [$tenantId]);
+
+ return ['success' => true, 'message' => 'Subscription updated'];
+ }
+
+ /**
+ * Subscription gelöscht/gekündigt
+ */
+ private function handleSubscriptionDeleted(array $subscription): array
+ {
+ $customerId = $subscription['customer'] ?? null;
+
+ if (!$customerId) {
+ return ['success' => false, 'error' => 'No customer ID'];
+ }
+
+ $sub = $this->db->fetchOne(
+ "SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?",
+ [$customerId]
+ );
+
+ if (!$sub) {
+ return ['success' => true, 'message' => 'Customer not found'];
+ }
+
+ $this->db->update('subscriptions', [
+ 'status' => 'canceled',
+ 'canceled_at' => date('Y-m-d H:i:s'),
+ ], 'tenant_id = ?', [$sub['tenant_id']]);
+
+ // Downgrade zu Free-Plan
+ $freePlan = $this->db->fetchOne("SELECT id FROM plans WHERE slug = 'free'");
+ if ($freePlan) {
+ $this->db->update('tenants', [
+ 'status' => 'active',
+ 'plan_id' => $freePlan['id'],
+ ], 'id = ?', [$sub['tenant_id']]);
+ }
+
+ return ['success' => true, 'message' => 'Subscription canceled'];
+ }
+
+ /**
+ * Rechnung bezahlt
+ */
+ private function handleInvoicePaid(array $invoice): array
+ {
+ $customerId = $invoice['customer'] ?? null;
+
+ if (!$customerId) {
+ return ['success' => false, 'error' => 'No customer ID'];
+ }
+
+ $sub = $this->db->fetchOne(
+ "SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?",
+ [$customerId]
+ );
+
+ if (!$sub) {
+ return ['success' => true, 'message' => 'Customer not found'];
+ }
+
+ // Rechnung speichern
+ $this->subscriptions->saveInvoice($sub['tenant_id'], $invoice);
+
+ return ['success' => true, 'message' => 'Invoice saved'];
+ }
+
+ /**
+ * Zahlung fehlgeschlagen
+ */
+ private function handlePaymentFailed(array $invoice): array
+ {
+ $customerId = $invoice['customer'] ?? null;
+
+ if (!$customerId) {
+ return ['success' => false, 'error' => 'No customer ID'];
+ }
+
+ $sub = $this->db->fetchOne(
+ "SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?",
+ [$customerId]
+ );
+
+ if (!$sub) {
+ return ['success' => true, 'message' => 'Customer not found'];
+ }
+
+ // Status auf past_due setzen
+ $this->db->update('subscriptions', ['status' => 'past_due'], 'tenant_id = ?', [$sub['tenant_id']]);
+
+ // TODO: E-Mail an Tenant senden
+
+ return ['success' => true, 'message' => 'Payment failure recorded'];
+ }
+
+ /**
+ * Mappt Stripe-Status auf DB-Status
+ */
+ private function mapStripeStatus(string $stripeStatus): string
+ {
+ $map = [
+ 'active' => 'active',
+ 'trialing' => 'trialing',
+ 'past_due' => 'past_due',
+ 'canceled' => 'canceled',
+ 'unpaid' => 'unpaid',
+ 'incomplete' => 'incomplete',
+ 'incomplete_expired' => 'canceled',
+ ];
+
+ return $map[$stripeStatus] ?? 'unknown';
+ }
+}
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/Onboarding/OnboardingManager.php b/aurora-livecam/src/Onboarding/OnboardingManager.php
new file mode 100644
index 0000000..98bd38c
--- /dev/null
+++ b/aurora-livecam/src/Onboarding/OnboardingManager.php
@@ -0,0 +1,366 @@
+db = $db ?? Database::getInstance();
+ $this->tenantManager = new TenantManager($this->db);
+ $this->streamValidator = new StreamValidator();
+ }
+
+ /**
+ * Startet den Onboarding-Prozess (Registrierung)
+ */
+ public function register(array $data): array
+ {
+ $errors = $this->validateRegistration($data);
+
+ if (!empty($errors)) {
+ return ['success' => false, 'errors' => $errors];
+ }
+
+ try {
+ $this->db->beginTransaction();
+
+ // Tenant erstellen
+ $tenantId = $this->tenantManager->create([
+ 'name' => $data['company_name'] ?? $data['name'],
+ 'email' => $data['email'],
+ 'subdomain' => $this->generateSubdomain($data['company_name'] ?? $data['name']),
+ 'stream_url' => $data['stream_url'] ?? '',
+ 'stream_type' => $data['stream_type'] ?? 'hls',
+ ]);
+
+ // Admin-User für den Tenant erstellen
+ $auth = new AuthManager($this->db);
+ $userId = $auth->register([
+ 'tenant_id' => $tenantId,
+ 'email' => $data['email'],
+ 'password' => $data['password'],
+ 'name' => $data['name'],
+ 'role' => 'tenant_admin',
+ ]);
+
+ // Verification-Token generieren
+ $verificationToken = $this->generateVerificationToken($userId);
+
+ $this->db->commit();
+
+ return [
+ 'success' => true,
+ 'tenant_id' => $tenantId,
+ 'user_id' => $userId,
+ 'verification_token' => $verificationToken,
+ 'next_step' => self::STEP_VERIFY_EMAIL,
+ ];
+
+ } catch (\Exception $e) {
+ $this->db->rollback();
+ return ['success' => false, 'errors' => ['general' => $e->getMessage()]];
+ }
+ }
+
+ /**
+ * Validiert Registrierungsdaten
+ */
+ private function validateRegistration(array $data): array
+ {
+ $errors = [];
+
+ // Name
+ if (empty($data['name'])) {
+ $errors['name'] = 'Name ist erforderlich';
+ }
+
+ // Company/Site Name
+ if (empty($data['company_name'])) {
+ $errors['company_name'] = 'Firmen-/Site-Name ist erforderlich';
+ }
+
+ // Email
+ if (empty($data['email'])) {
+ $errors['email'] = 'E-Mail ist erforderlich';
+ } elseif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
+ $errors['email'] = 'Ungültige E-Mail-Adresse';
+ } else {
+ // Prüfe ob Email bereits existiert
+ $existing = $this->db->fetchOne("SELECT id FROM users WHERE email = ?", [strtolower($data['email'])]);
+ if ($existing) {
+ $errors['email'] = 'Diese E-Mail-Adresse ist bereits registriert';
+ }
+ }
+
+ // Password
+ if (empty($data['password'])) {
+ $errors['password'] = 'Passwort ist erforderlich';
+ } elseif (strlen($data['password']) < 8) {
+ $errors['password'] = 'Passwort muss mindestens 8 Zeichen lang sein';
+ }
+
+ // Password Confirmation
+ if (($data['password'] ?? '') !== ($data['password_confirm'] ?? '')) {
+ $errors['password_confirm'] = 'Passwörter stimmen nicht überein';
+ }
+
+ // Stream URL (optional, aber wenn angegeben, validieren)
+ if (!empty($data['stream_url'])) {
+ $validation = $this->streamValidator->validate($data['stream_url']);
+ if (!$validation['valid']) {
+ $errors['stream_url'] = $validation['error'] ?? 'Stream-URL ungültig';
+ }
+ }
+
+ // Terms
+ if (empty($data['accept_terms'])) {
+ $errors['accept_terms'] = 'Sie müssen die AGB akzeptieren';
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Generiert eine Subdomain aus dem Firmennamen
+ */
+ private function generateSubdomain(string $name): string
+ {
+ // Umlaute ersetzen
+ $replacements = ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'];
+ $slug = str_replace(array_keys($replacements), array_values($replacements), strtolower($name));
+
+ // Nur alphanumerische Zeichen und Bindestriche
+ $slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
+ $slug = trim($slug, '-');
+
+ // Max 30 Zeichen
+ $slug = substr($slug, 0, 30);
+
+ // Eindeutigkeit prüfen
+ $baseSlug = $slug;
+ $counter = 1;
+ while (!$this->tenantManager->isDomainAvailable($slug . '.aurora-livecam.com')) {
+ $slug = $baseSlug . '-' . $counter;
+ $counter++;
+ }
+
+ return $slug;
+ }
+
+ /**
+ * Generiert einen E-Mail-Verification-Token
+ */
+ private function generateVerificationToken(int $userId): string
+ {
+ $token = bin2hex(random_bytes(32));
+
+ // Token in einer separaten Tabelle speichern (oder im User)
+ // Vereinfacht: Wir nutzen remember_token temporär
+ $this->db->update('users', ['remember_token' => hash('sha256', $token)], 'id = ?', [$userId]);
+
+ return $token;
+ }
+
+ /**
+ * Verifiziert E-Mail-Adresse
+ */
+ public function verifyEmail(string $token): array
+ {
+ $hashedToken = hash('sha256', $token);
+
+ $user = $this->db->fetchOne(
+ "SELECT id, tenant_id FROM users WHERE remember_token = ? AND email_verified_at IS NULL",
+ [$hashedToken]
+ );
+
+ if (!$user) {
+ return ['success' => false, 'error' => 'Ungültiger oder abgelaufener Token'];
+ }
+
+ $this->db->update('users', [
+ 'email_verified_at' => date('Y-m-d H:i:s'),
+ 'remember_token' => null,
+ ], 'id = ?', [$user['id']]);
+
+ // Onboarding-Status aktualisieren
+ $this->updateOnboardingStep($user['tenant_id'], self::STEP_STREAM);
+
+ return [
+ 'success' => true,
+ 'user_id' => $user['id'],
+ 'tenant_id' => $user['tenant_id'],
+ 'next_step' => self::STEP_STREAM,
+ ];
+ }
+
+ /**
+ * Speichert Stream-Konfiguration
+ */
+ public function saveStream(int $tenantId, string $url, string $type = 'hls'): array
+ {
+ // Validieren
+ $validation = $this->streamValidator->validate($url);
+
+ if (!$validation['valid']) {
+ return ['success' => false, 'error' => $validation['error']];
+ }
+
+ // Speichern
+ $existing = $this->db->fetchOne(
+ "SELECT id FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
+ [$tenantId]
+ );
+
+ if ($existing) {
+ $this->db->update('tenant_streams', [
+ 'stream_url' => $url,
+ 'stream_type' => $validation['type'] ?? $type,
+ 'last_status' => 'online',
+ 'last_check_at' => date('Y-m-d H:i:s'),
+ ], 'id = ?', [$existing['id']]);
+ } else {
+ $this->db->insert('tenant_streams', [
+ 'tenant_id' => $tenantId,
+ 'stream_url' => $url,
+ 'stream_type' => $validation['type'] ?? $type,
+ 'is_primary' => 1,
+ 'last_status' => 'online',
+ 'last_check_at' => date('Y-m-d H:i:s'),
+ ]);
+ }
+
+ // Onboarding-Schritt aktualisieren
+ $this->updateOnboardingStep($tenantId, self::STEP_BRANDING, ['stream_verified' => 1]);
+
+ return [
+ 'success' => true,
+ 'stream_type' => $validation['type'],
+ 'next_step' => self::STEP_BRANDING,
+ ];
+ }
+
+ /**
+ * Speichert Basis-Branding
+ */
+ public function saveBranding(int $tenantId, array $branding): array
+ {
+ $this->tenantManager->updateBranding($tenantId, $branding);
+
+ // Onboarding-Schritt aktualisieren
+ $this->updateOnboardingStep($tenantId, self::STEP_COMPLETE, ['branding_configured' => 1]);
+
+ return [
+ 'success' => true,
+ 'next_step' => self::STEP_COMPLETE,
+ ];
+ }
+
+ /**
+ * Schliesst das Onboarding ab
+ */
+ public function complete(int $tenantId): array
+ {
+ $this->db->update('tenant_onboarding', [
+ 'current_step' => self::STEP_COMPLETE,
+ 'completed_at' => date('Y-m-d H:i:s'),
+ ], 'tenant_id = ?', [$tenantId]);
+
+ // Tenant aktivieren
+ $this->tenantManager->activate($tenantId);
+
+ return ['success' => true, 'completed' => true];
+ }
+
+ /**
+ * Aktualisiert den Onboarding-Schritt
+ */
+ private function updateOnboardingStep(int $tenantId, int $step, array $extra = []): void
+ {
+ $data = array_merge(['current_step' => $step], $extra);
+ $this->db->update('tenant_onboarding', $data, 'tenant_id = ?', [$tenantId]);
+ }
+
+ /**
+ * Gibt den aktuellen Onboarding-Status zurück
+ */
+ public function getStatus(int $tenantId): array
+ {
+ $onboarding = $this->db->fetchOne(
+ "SELECT * FROM tenant_onboarding WHERE tenant_id = ?",
+ [$tenantId]
+ );
+
+ if (!$onboarding) {
+ return [
+ 'current_step' => self::STEP_REGISTER,
+ 'completed' => false,
+ ];
+ }
+
+ return [
+ 'current_step' => (int)$onboarding['current_step'],
+ 'stream_verified' => (bool)$onboarding['stream_verified'],
+ 'branding_configured' => (bool)$onboarding['branding_configured'],
+ 'payment_configured' => (bool)$onboarding['payment_configured'],
+ 'completed' => $onboarding['completed_at'] !== null,
+ 'completed_at' => $onboarding['completed_at'],
+ ];
+ }
+
+ /**
+ * Prüft ob E-Mail-Verification erforderlich ist
+ */
+ public function requiresEmailVerification(): bool
+ {
+ // Aus Settings laden
+ $settingsFile = dirname(__DIR__, 2) . '/SettingsManager.php';
+ if (file_exists($settingsFile)) {
+ require_once $settingsFile;
+ $settings = new \SettingsManager();
+ return $settings->get('saas_features.email_verification_required') ?? true;
+ }
+ return true;
+ }
+
+ /**
+ * Sendet Verification-E-Mail erneut
+ */
+ public function resendVerification(int $userId): array
+ {
+ $user = $this->db->fetchOne("SELECT email, email_verified_at FROM users WHERE id = ?", [$userId]);
+
+ if (!$user) {
+ return ['success' => false, 'error' => 'Benutzer nicht gefunden'];
+ }
+
+ if ($user['email_verified_at']) {
+ return ['success' => false, 'error' => 'E-Mail bereits verifiziert'];
+ }
+
+ $token = $this->generateVerificationToken($userId);
+
+ return [
+ 'success' => true,
+ 'token' => $token,
+ 'email' => $user['email'],
+ ];
+ }
+}
diff --git a/aurora-livecam/src/Onboarding/StreamValidator.php b/aurora-livecam/src/Onboarding/StreamValidator.php
new file mode 100644
index 0000000..786de30
--- /dev/null
+++ b/aurora-livecam/src/Onboarding/StreamValidator.php
@@ -0,0 +1,263 @@
+ false,
+ 'type' => null,
+ 'error' => null,
+ 'details' => [],
+ ];
+
+ // URL-Format prüfen
+ if (!filter_var($url, FILTER_VALIDATE_URL)) {
+ $result['error'] = 'Ungültiges URL-Format';
+ return $result;
+ }
+
+ // Stream-Typ erkennen
+ $type = $this->detectStreamType($url);
+ $result['type'] = $type;
+ $result['details']['detected_type'] = $type;
+
+ // Je nach Typ validieren
+ switch ($type) {
+ case 'hls':
+ return $this->validateHls($url, $result);
+ case 'rtmp':
+ return $this->validateRtmp($url, $result);
+ case 'iframe':
+ return $this->validateIframe($url, $result);
+ default:
+ // Generische HTTP-Prüfung
+ return $this->validateHttp($url, $result);
+ }
+ }
+
+ /**
+ * Erkennt den Stream-Typ anhand der URL
+ */
+ public function detectStreamType(string $url): string
+ {
+ $url = strtolower($url);
+
+ if (str_contains($url, '.m3u8')) {
+ return 'hls';
+ }
+
+ if (str_starts_with($url, 'rtmp://') || str_starts_with($url, 'rtmps://')) {
+ return 'rtmp';
+ }
+
+ if (str_contains($url, 'youtube.com') || str_contains($url, 'youtu.be') ||
+ str_contains($url, 'vimeo.com') || str_contains($url, 'twitch.tv')) {
+ return 'iframe';
+ }
+
+ if (str_contains($url, '.mp4') || str_contains($url, '.webm')) {
+ return 'video';
+ }
+
+ return 'unknown';
+ }
+
+ /**
+ * Validiert HLS-Stream
+ */
+ private function validateHls(string $url, array $result): array
+ {
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => $this->timeout,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_HTTPHEADER => [
+ 'User-Agent: Mozilla/5.0 (compatible; StreamValidator/1.0)'
+ ],
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ $result['details']['http_code'] = $httpCode;
+ $result['details']['content_type'] = $contentType;
+
+ if ($error) {
+ $result['error'] = 'Verbindungsfehler: ' . $error;
+ return $result;
+ }
+
+ if ($httpCode !== 200) {
+ $result['error'] = "HTTP-Fehler: $httpCode";
+ return $result;
+ }
+
+ // Prüfe ob es ein gültiges M3U8 ist
+ if (!str_contains($response, '#EXTM3U')) {
+ $result['error'] = 'Keine gültige HLS-Playlist gefunden';
+ return $result;
+ }
+
+ $result['valid'] = true;
+ $result['details']['is_master'] = str_contains($response, '#EXT-X-STREAM-INF');
+ $result['details']['segments'] = substr_count($response, '#EXTINF');
+
+ return $result;
+ }
+
+ /**
+ * Validiert RTMP-Stream (nur Format-Check)
+ */
+ private function validateRtmp(string $url, array $result): array
+ {
+ // RTMP kann nicht einfach per HTTP geprüft werden
+ // Wir prüfen nur das Format
+
+ $parsed = parse_url($url);
+
+ if (!isset($parsed['host']) || empty($parsed['host'])) {
+ $result['error'] = 'RTMP-URL enthält keinen gültigen Host';
+ return $result;
+ }
+
+ // DNS-Check
+ $ip = gethostbyname($parsed['host']);
+ if ($ip === $parsed['host']) {
+ $result['error'] = 'RTMP-Host nicht erreichbar (DNS-Fehler)';
+ return $result;
+ }
+
+ $result['valid'] = true;
+ $result['details']['host'] = $parsed['host'];
+ $result['details']['note'] = 'RTMP-Streams können erst zur Laufzeit vollständig validiert werden';
+
+ return $result;
+ }
+
+ /**
+ * Validiert iFrame-Embed URL
+ */
+ private function validateIframe(string $url, array $result): array
+ {
+ // Bekannte Embed-Plattformen
+ $embedPatterns = [
+ 'youtube' => '/(?:youtube\.com\/(?:embed|watch)|youtu\.be)/i',
+ 'vimeo' => '/vimeo\.com/i',
+ 'twitch' => '/(?:twitch\.tv|player\.twitch\.tv)/i',
+ 'dailymotion' => '/dailymotion\.com/i',
+ ];
+
+ $platform = 'unknown';
+ foreach ($embedPatterns as $name => $pattern) {
+ if (preg_match($pattern, $url)) {
+ $platform = $name;
+ break;
+ }
+ }
+
+ $result['details']['platform'] = $platform;
+
+ // HTTP-Check
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => $this->timeout,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_NOBODY => true, // HEAD request
+ CURLOPT_SSL_VERIFYPEER => false,
+ ]);
+
+ curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ $result['details']['http_code'] = $httpCode;
+
+ if ($httpCode >= 200 && $httpCode < 400) {
+ $result['valid'] = true;
+ } else {
+ $result['error'] = "URL nicht erreichbar (HTTP $httpCode)";
+ }
+
+ return $result;
+ }
+
+ /**
+ * Generische HTTP-Validierung
+ */
+ private function validateHttp(string $url, array $result): array
+ {
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => $this->timeout,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_NOBODY => true,
+ CURLOPT_SSL_VERIFYPEER => false,
+ ]);
+
+ curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ $result['details']['http_code'] = $httpCode;
+ $result['details']['content_type'] = $contentType;
+
+ if ($error) {
+ $result['error'] = 'Verbindungsfehler: ' . $error;
+ return $result;
+ }
+
+ if ($httpCode >= 200 && $httpCode < 400) {
+ $result['valid'] = true;
+ } else {
+ $result['error'] = "URL nicht erreichbar (HTTP $httpCode)";
+ }
+
+ return $result;
+ }
+
+ /**
+ * Schnelle Erreichbarkeitsprüfung
+ */
+ public function isReachable(string $url): bool
+ {
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_NOBODY => true,
+ CURLOPT_SSL_VERIFYPEER => false,
+ ]);
+
+ curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ return $httpCode >= 200 && $httpCode < 400;
+ }
+}
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();