0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']); session_start(); } require_once __DIR__ . '/db.php'; if (!is_installed()) { header('Location: /install.php'); exit; } $config = get_config(); $db = get_db(); $error = null; $earlyRoute = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH); if (str_starts_with($earlyRoute, '/admin/accept-invite')) { handle_accept_invite($db); exit; } if (str_starts_with($earlyRoute, '/admin/forgot-password')) { handle_forgot_password($db); exit; } if (str_starts_with($earlyRoute, '/admin/reset-password')) { handle_reset_password($db); exit; } if (str_starts_with($earlyRoute, '/admin/sso')) { handle_sso_login($config); exit; } if (isset($_POST['action']) && $_POST['action'] === 'login') { $clientIp = get_client_ip(); $emailForRateLimit = trim(strtolower($_POST['email'] ?? '')); $acctIdentifier = $emailForRateLimit !== '' ? 'acct:' . $emailForRateLimit : null; $waitSeconds = max( login_rate_limit_wait($db, $clientIp), $acctIdentifier !== null ? login_rate_limit_wait($db, $acctIdentifier) : 0 ); if ($waitSeconds > 0) { $error = t('admin.login.rate_limited', ['minutes' => (int)ceil($waitSeconds / 60)]); } else { $email = $emailForRateLimit; $pass = $_POST['password'] ?? ''; $loggedIn = false; $totpPending = null; if ($email !== '') { $stmt = $db->prepare("SELECT * FROM users WHERE email = ? AND status = 'active'"); $stmt->execute([$email]); $user = $stmt->fetch(); if ($user && !empty($user['password_hash']) && password_verify($pass, $user['password_hash'])) { if (!empty($user['totp_enabled_at'])) { // Eine auf diesem Seitenaufruf gewaehlte Sprache muss ueber den TOTP-Zwischenschritt // hinweg mitgegeben werden -- der Redirect nach /admin (unten) traegt kein ?locale= // mehr, und beim Abschluss in handle_totp_verify() waere $_GET['locale'] dann leer. $pendingLocaleKnown = array_keys(ui_locales()); $pendingLocale = in_array($_GET['locale'] ?? '', $pendingLocaleKnown, true) ? $_GET['locale'] : null; $totpPending = ['type' => 'user', 'user_id' => (int)$user['id'], 'started_at' => time(), 'locale' => $pendingLocale]; } else { finalize_user_session($user); $loggedIn = true; } } } elseif (empty($config['admin_password_login_disabled']) && password_verify($pass, $config['admin_password_hash'] ?? '')) { if (admin_totp_available() && admin_totp_enabled()) { $totpPending = ['type' => 'admin', 'started_at' => time()]; } else { finalize_admin_session(); $loggedIn = true; } } if ($loggedIn || $totpPending !== null) { // Passwort war in beiden Faellen korrekt -- ein evtl. anschliessend // falscher TOTP-Code zaehlt separat unter dem totp:-Praefix (siehe // handle_totp_verify()), nicht hier gegen den Passwort-Zaehler. clear_login_failures($db, $clientIp); if ($acctIdentifier !== null) { clear_login_failures($db, $acctIdentifier); } if ($totpPending !== null) { $_SESSION['totp_pending'] = $totpPending; } header('Location: /admin'); exit; } record_login_failure($db, $clientIp); if ($acctIdentifier !== null) { record_login_failure($db, $acctIdentifier); } $error = ($email === '' && !empty($config['admin_password_login_disabled'])) ? t('admin.login.admin_password_login_disabled') : t('admin.login.invalid_credentials'); } } if (isset($_POST['action']) && $_POST['action'] === 'totp_verify') { $error = handle_totp_verify($db); } if (isset($_GET['totp_cancel'])) { unset($_SESSION['totp_pending']); header('Location: /admin'); exit; } if (isset($_GET['logout'])) { unset($_SESSION['paragrafy_admin'], $_SESSION['paragrafy_user_id'], $_SESSION['paragrafy_user_name'], $_SESSION['paragrafy_user_email'], $_SESSION['paragrafy_user_locale'], $_SESSION['totp_pending']); header('Location: /admin'); exit; } if (empty($_SESSION['paragrafy_admin'])) { $totpPending = $_SESSION['totp_pending'] ?? null; if (is_array($totpPending) && ($totpPending['started_at'] ?? 0) > time() - 300) { render_totp_view($error); } else { unset($_SESSION['totp_pending']); render_login_view($error); } exit; } require_csrf(); $allProjects = $db->query("SELECT * FROM projects ORDER BY name ASC")->fetchAll(); $accessibleProjectIds = current_user_accessible_project_ids($db); $projects = array_values(array_filter($allProjects, fn($p) => in_array((int)$p['id'], $accessibleProjectIds, true))); $requestedProjectId = isset($_GET['project_id']) ? (int)$_GET['project_id'] : null; $projectId = (int)($requestedProjectId ?? $_SESSION['admin_project_id'] ?? ($projects[0]['id'] ?? 0)); if ($projectId > 0 && !in_array($projectId, $accessibleProjectIds, true)) { if ($requestedProjectId !== null) { http_response_code(403); echo htmlspecialchars(t('admin.common.access_denied')); exit; } $projectId = (int)($projects[0]['id'] ?? 0); } $_SESSION['admin_project_id'] = $projectId; $stmt = $db->prepare("SELECT * FROM projects WHERE id = ?"); $stmt->execute([$projectId]); $project = $stmt->fetch(); if (!$project) { render_no_access_view(); exit; } // Diese Aktionen wirken auf die gesamte Instanz (alle Projekte/Mandanten), nicht nur auf das // aktuell gewaehlte Projekt -- nur der primaere Admin-Login darf sie ausloesen, sonst koennte ein // auf ein einzelnes Projekt beschraenkter Multi-User Daten anderer Mandanten abziehen/ueberschreiben. $instanceWideAction = ($_GET['action'] ?? $_POST['action'] ?? ''); if (in_array($instanceWideAction, ['download_backup', 'download_backup_file', 'run_backup_now', 'restore_backup_upload', 'run_webhook_queue_now', 'regenerate_cron_secret'], true) && !current_user_is_primary_admin()) { http_response_code(403); echo htmlspecialchars(t('admin.common.access_denied')); exit; } // 1. SQLite Datenbank-Backup herunterladen if (isset($_GET['action']) && $_GET['action'] === 'download_backup') { if (file_exists(DB_FILE)) { header('Content-Type: application/x-sqlite3'); header('Content-Disposition: attachment; filename="paragrafy_backup_' . date('Y-m-d_His') . '.sqlite"'); header('Content-Length: ' . filesize(DB_FILE)); readfile(DB_FILE); exit; } } // 1b. Rollierendes Backup herunterladen (7-Tage-Verlauf) if (isset($_GET['action']) && $_GET['action'] === 'download_backup_file') { $requested = basename((string)($_GET['file'] ?? '')); $path = BACKUP_DIR . '/' . $requested; if (preg_match('/^paragrafy_backup_[0-9_-]+\.sqlite$/', $requested) && file_exists($path)) { header('Content-Type: application/x-sqlite3'); header('Content-Disposition: attachment; filename="' . $requested . '"'); header('Content-Length: ' . filesize($path)); readfile($path); exit; } http_response_code(404); echo htmlspecialchars(t('admin.common.backup_not_found')); exit; } // 1b2. Einzelnes Projekt exportieren (nur Rechtsinhalte, kein Voll-Instanz-Export) if (isset($_GET['action']) && $_GET['action'] === 'export_project') { $exportResult = export_project_backup($db, $projectId); if ($exportResult['success']) { header('Content-Type: application/x-sqlite3'); header('Content-Disposition: attachment; filename="' . $exportResult['filename'] . '"'); header('Content-Length: ' . filesize($exportResult['path'])); readfile($exportResult['path']); @unlink($exportResult['path']); exit; } http_response_code(500); echo htmlspecialchars($exportResult['error'] ?? ''); exit; } // 1c. Rollierendes Backup manuell anstoßen if (isset($_POST['action']) && $_POST['action'] === 'run_backup_now') { $result = run_scheduled_backup(); log_audit(null, '', $result['success'] ? t('admin.common.audit.backup_triggered') : t('admin.common.audit.backup_failed', ['error' => $result['error'] ?? ''])); header("Location: /admin/settings?project_id=$projectId&msg=" . ($result['success'] ? 'backup_created' : 'backup_failed')); exit; } // 1e. Backup hochladen & Datenbank wiederherstellen if (isset($_POST['action']) && $_POST['action'] === 'restore_backup_upload') { if (empty($_FILES['backup_file']['tmp_name'])) { header("Location: /admin/settings?project_id=$projectId&msg=restore_failed&restore_error=" . urlencode(t('db.restore.upload_failed'))); exit; } $result = restore_database_from_upload($_FILES['backup_file']['tmp_name']); if ($result['success']) { log_audit(null, '', t('admin.common.audit.restore_triggered', ['count' => $result['project_count'] ?? 0])); $redirect = "/admin/settings?msg=restore_success&restore_count=" . (int)($result['project_count'] ?? 0); if (!empty($result['over_limit'])) { $redirect .= "&restore_over_limit=1&restore_limit=" . (int)($result['project_limit'] ?? 0); } header("Location: $redirect"); } else { log_audit(null, '', t('admin.common.audit.restore_failed', ['error' => $result['error'] ?? ''])); header("Location: /admin/settings?project_id=$projectId&msg=restore_failed&restore_error=" . urlencode($result['error'] ?? '')); } exit; } // 1f. Einzelnes Projekt in ein explizit gewähltes, bestehendes Projekt importieren (Merge, // andere Projekte dieser Instanz bleiben unberührt) if (isset($_POST['action']) && $_POST['action'] === 'import_project_upload') { $targetProjectId = (int)($_POST['target_project_id'] ?? 0); if (empty($_FILES['project_file']['tmp_name']) || $targetProjectId <= 0 || !in_array($targetProjectId, $accessibleProjectIds, true)) { header("Location: /admin/settings?project_id=$projectId&msg=project_import_failed&import_error=" . urlencode(t('db.restore.upload_failed'))); exit; } $result = import_project_backup($db, $_FILES['project_file']['tmp_name'], $targetProjectId); if ($result['success']) { log_audit($result['target_project_id'], '', t('admin.common.audit.project_import_merged', ['docs' => $result['documents_merged'], 'translations' => $result['translations_merged']])); header("Location: /admin/settings?project_id=" . (int)$result['target_project_id'] . "&msg=project_import_success&import_docs=" . (int)$result['documents_merged']); } else { log_audit(null, '', t('admin.common.audit.project_import_failed', ['error' => $result['error'] ?? ''])); header("Location: /admin/settings?project_id=$projectId&msg=project_import_failed&import_error=" . urlencode($result['error'] ?? '')); } exit; } // 1d. Webhook-Warteschlange manuell abarbeiten if (isset($_POST['action']) && $_POST['action'] === 'run_webhook_queue_now') { process_webhook_queue(); header("Location: /admin/settings?project_id=$projectId&msg=queue_processed"); exit; } // 1e. Cron-Secret neu generieren (macht bestehende Cron-URLs ungültig) if (isset($_POST['action']) && $_POST['action'] === 'regenerate_cron_secret') { if (!empty(get_config()['managed_cloud']) || !empty(get_config()['is_demo'])) { header('Location: /admin/settings?project_id=' . $projectId . '&msg=managed_cloud_blocked'); exit; } regenerate_cron_secret(); log_audit(null, '', t('admin.common.audit.cron_secret_regenerated')); header("Location: /admin/settings?project_id=$projectId&msg=cron_secret_regenerated"); exit; } // 2. Markdown Export herunterladen if (isset($_GET['action']) && $_GET['action'] === 'export_markdown') { $stmt = $db->prepare(" SELECT t.title, t.slug, t.lang, t.content, t.updated_at, dt.title as type_title FROM translations t JOIN documents d ON t.document_id = d.id JOIN doc_types dt ON d.doc_type_id = dt.id WHERE d.project_id = ? AND t.status = 'published' "); $stmt->execute([$projectId]); $docs = $stmt->fetchAll(); if (class_exists('ZipArchive')) { $zipFile = tempnam(sys_get_temp_dir(), 'pg_md_'); $zip = new ZipArchive(); $zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE); foreach ($docs as $d) { $filename = $d['lang'] . '/' . $d['slug'] . '.md'; $md = "# " . $d['title'] . "\n\n" . t('admin.matrix.export.md_stand', ['date' => $d['updated_at']]) . "\n\n" . strip_tags($d['content']); $zip->addFromString($filename, $md); } $zip->close(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . preg_replace('/[^a-zA-Z0-9]+/', '_', $project['name']) . '_legal_markdown.zip"'); header('Content-Length: ' . filesize($zipFile)); readfile($zipFile); @unlink($zipFile); exit; } else { header('Content-Type: text/plain; charset=utf-8'); header('Content-Disposition: attachment; filename="' . preg_replace('/[^a-zA-Z0-9]+/', '_', $project['name']) . '_legal_export.txt"'); foreach ($docs as $d) { echo "========================================\n"; echo t('admin.matrix.export.txt_document', ['title' => $d['title'], 'lang' => strtoupper($d['lang']), 'slug' => $d['slug']]) . "\n"; echo t('admin.matrix.export.txt_stand', ['date' => $d['updated_at']]) . "\n"; echo "========================================\n\n"; echo strip_tags($d['content']) . "\n\n\n"; } exit; } } // 2b. Änderungsprotokoll als CSV herunterladen if (isset($_GET['action']) && $_GET['action'] === 'export_audit_csv') { if (current_user_is_primary_admin()) { $stmt = $db->prepare("SELECT * FROM audit_log WHERE project_id = ? OR project_id IS NULL ORDER BY created_at DESC LIMIT 1000"); } else { $stmt = $db->prepare("SELECT * FROM audit_log WHERE project_id = ? ORDER BY created_at DESC LIMIT 1000"); } $stmt->execute([$projectId]); $entries = $stmt->fetchAll(); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . preg_replace('/[^a-zA-Z0-9]+/', '_', $project['name']) . '_protokoll.csv"'); $out = fopen('php://output', 'w'); fputs($out, "\xEF\xBB\xBF"); fputcsv($out, [t('admin.audit.col_time'), t('admin.audit.col_user'), t('admin.audit.col_action'), t('admin.audit.col_project')]); foreach ($entries as $e) { fputcsv($out, [csv_safe($e['created_at']), csv_safe($e['user_name']), csv_safe($e['action']), csv_safe($e['project_name'])]); } fclose($out); exit; } // 2c. Consent-Nachweise als CSV herunterladen (DSGVO-Prüfbericht) if (isset($_GET['action']) && $_GET['action'] === 'export_consent_log_csv') { $stmt = $db->prepare("SELECT * FROM consent_logs WHERE project_id = ? ORDER BY created_at DESC LIMIT 5000"); $stmt->execute([$projectId]); $entries = $stmt->fetchAll(); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . preg_replace('/[^a-zA-Z0-9]+/', '_', $project['name']) . '_consent_nachweise.csv"'); $out = fopen('php://output', 'w'); fputs($out, "\xEF\xBB\xBF"); fputcsv($out, [ t('admin.consent_log.col_time'), t('admin.consent_log.col_consent_id'), t('admin.consent_log.col_action'), t('admin.consent_log.col_lang'), t('admin.consent_log.col_ip'), t('admin.consent_log.col_text_hash'), t('admin.consent_log.col_useragent') ]); foreach ($entries as $e) { $actionLabel = $e['action'] === 'accepted' ? t('admin.consent_log.action_accepted') : t('admin.consent_log.action_declined'); fputcsv($out, [csv_safe($e['created_at']), csv_safe($e['consent_id']), $actionLabel, csv_safe($e['lang']), csv_safe($e['ip_anonymized']), csv_safe($e['banner_text_hash']), csv_safe($e['user_agent'])]); } fclose($out); exit; } // 3. Webhook Test Trigger mit detailliertem Protokoll if (isset($_POST['action']) && $_POST['action'] === 'test_webhook') { header('Content-Type: application/json'); $result = dispatch_webhook($project, [ 'test' => true, 'message' => t('admin.settings.webhook_test_message'), 'triggered_at' => date('c') ]); echo json_encode($result); exit; } // 4. SMTP Test-Mail Trigger if (isset($_POST['action']) && $_POST['action'] === 'test_smtp') { header('Content-Type: application/json'); $recipient = trim($project['audit_email_recipient'] ?? '') ?: ($project['email'] ?? ''); if (empty($recipient)) { echo json_encode(['success' => false, 'error' => t('admin.settings.smtp_test_missing_recipient')]); exit; } $html = "

" . htmlspecialchars(t('admin.settings.smtp_test_heading')) . "

" . t('admin.settings.smtp_test_body', ['project' => htmlspecialchars($project['name'])]) . "

"; $res = send_smtp_mail($project, $recipient, t('admin.settings.smtp_test_subject'), $html); echo json_encode($res); exit; } // 5. Webhook Logs leeren if (isset($_POST['action']) && $_POST['action'] === 'clear_webhook_logs') { $del = $db->prepare("DELETE FROM webhook_logs WHERE project_id = ?"); $del->execute([$projectId]); header("Location: /admin/settings?project_id=$projectId&msg=logs_cleared"); exit; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? ''; // Admin-UI-Sprache des eingeloggten Benutzers speichern if ($action === 'save_locale') { $newLocale = $_POST['locale'] ?? ''; if (isset(ui_locales()[$newLocale]) && !empty($_SESSION['paragrafy_user_id'])) { $upd = $db->prepare("UPDATE users SET locale = ? WHERE id = ?"); $upd->execute([$newLocale, (int)$_SESSION['paragrafy_user_id']]); $_SESSION['paragrafy_user_locale'] = $newLocale; } header("Location: /admin/settings?project_id=$projectId&msg=locale_saved"); exit; } // Neues Projekt anlegen if ($action === 'create_project') { if (!empty(get_config()['managed_cloud'])) { header('Location: /admin?msg=managed_cloud_blocked'); exit; } $newName = trim($_POST['new_project_name'] ?? ''); $newDomain = trim($_POST['new_project_domain'] ?? ''); $newLang = trim($_POST['new_primary_lang'] ?? 'de'); $newColor = trim($_POST['new_brand_color'] ?? '#F0A63C'); if (!str_starts_with($newColor, '#')) $newColor = '#' . $newColor; $projectLimit = get_project_limit(); if ($projectLimit !== null && count($projects) >= $projectLimit) { header("Location: /admin?msg=project_limit_reached"); exit; } if (!empty($newName) && !empty($newDomain)) { $stmt = $db->prepare("INSERT INTO projects (name, domain, brand_color, primary_lang, active_languages) VALUES (?, ?, ?, ?, 'de,en')"); $stmt->execute([$newName, $newDomain, $newColor, $newLang]); $newProjectId = (int)$db->lastInsertId(); $docTypes = $db->query("SELECT id FROM doc_types")->fetchAll(); foreach ($docTypes as $dt) { $insDoc = $db->prepare("INSERT INTO documents (project_id, doc_type_id) VALUES (?, ?)"); $insDoc->execute([$newProjectId, $dt['id']]); } log_audit($newProjectId, $newName, t('admin.common.audit.project_created')); header("Location: /admin?project_id=$newProjectId&msg=project_created"); exit; } } // Projekt löschen if ($action === 'delete_project') { $delId = (int)$_POST['delete_project_id']; if (count($projects) > 1 && in_array($delId, $accessibleProjectIds, true)) { $delName = ''; foreach ($projects as $p) { if ((int)$p['id'] === $delId) { $delName = $p['name']; break; } } $stmt = $db->prepare("DELETE FROM projects WHERE id = ?"); $stmt->execute([$delId]); log_audit(null, $delName, t('admin.common.audit.project_deleted')); header("Location: /admin?msg=project_deleted"); exit; } } // Dokumenttypen sind instanzweit (gelten fuer alle Projekte/Mandanten) -- nur der primaere // Admin darf sie anlegen/aendern/loeschen, sonst koennte ein auf ein Projekt beschraenkter // Multi-User die Dokumentstruktur aller anderen Mandanten manipulieren. if (in_array($action, ['toggle_required', 'create_doc_type', 'delete_doc_type'], true) && !current_user_is_primary_admin()) { http_response_code(403); echo htmlspecialchars(t('admin.common.access_denied')); exit; } // AJAX / Post Toggle für Pflichtstatus if ($action === 'toggle_required') { $typeId = (int)$_POST['doc_type_id']; $stmt = $db->prepare("UPDATE doc_types SET is_required = CASE WHEN is_required = 1 THEN 0 ELSE 1 END WHERE id = ?"); $stmt->execute([$typeId]); if (!empty($_SERVER['HTTP_X_REQUESTED_WITH'])) { header('Content-Type: application/json'); $stmtNew = $db->prepare("SELECT is_required FROM doc_types WHERE id = ?"); $stmtNew->execute([$typeId]); echo json_encode(['success' => true, 'is_required' => (int)$stmtNew->fetchColumn()]); exit; } header("Location: /admin?project_id=$projectId&msg=toggled"); exit; } // Die Projekt-Einstellungsseite besteht aus mehreren unabhaengigen Formularen (Allgemein, // Cookie-Banner, Consent-Log, E-Mail/SMTP, Webhook/API-Keys, Firma). Jedes Formular hat einen // eigenen action-Wert und aktualisiert NUR seine eigenen Spalten -- frueher teilten sich alle // Formulare einen gemeinsamen 'save_project'-Handler mit einem Riesen-UPDATE ueber alle Spalten, // wofuer jedes Formular smtp_host, brand_color usw. der jeweils anderen Tabs als hidden-Input // mitschicken musste. Diese hidden-Werte wurden beim Seiten-Rendern eingefroren, sodass das // Speichern eines Tabs (z. B. Webhook) unabsichtlich Aenderungen aus einem anderen, gerade erst // gespeicherten Tab (z. B. brand_color) wieder auf den alten Stand zuruecksetzte. if (in_array($action, ['save_general', 'save_cookie_banner', 'save_consent_log', 'save_email', 'save_webhook', 'save_company'], true)) { // settings_updated_at wird bewusst nicht per CURRENT_TIMESTAMP gesetzt, sondern strikt // hochgezaehlt (+1s statt Gleichstand), damit Last-Modified/If-Modified-Since bei zwei // Aenderungen innerhalb derselben Sekunde nicht auf denselben Wert kollabiert -- siehe // cache.php (build_public_last_modified) und update_project_company_fields() in db.php, // die dasselbe Muster fahren. $touchSql = "settings_updated_at=CASE WHEN datetime('now') > settings_updated_at THEN datetime('now') ELSE datetime(settings_updated_at, '+1 second') END, settings_version=settings_version + 1"; $auditName = $project['name']; if ($action === 'save_general') { $activeLangs = implode(',', array_filter(array_map('trim', explode(',', $_POST['active_languages'] ?? 'de,en')))); $brandColor = trim($_POST['brand_color'] ?? '#F0A63C'); if (!str_starts_with($brandColor, '#')) { $brandColor = '#' . $brandColor; } $logoUrl = trim($_POST['logo_url'] ?? ''); $auditMonths = max(1, (int)($_POST['audit_interval_months'] ?? 12)); $isLockedInstance = !empty(get_config()['managed_cloud']) || !empty(get_config()['is_demo']); $domainToSave = $isLockedInstance ? $project['domain'] : $_POST['domain']; $stmt = $db->prepare(" UPDATE projects SET name=?, domain=?, brand_color=?, primary_lang=?, active_languages=?, logo_url=?, audit_interval_months=?, $touchSql WHERE id=? "); $stmt->execute([ $_POST['name'], $domainToSave, $brandColor, $_POST['primary_lang'], $activeLangs, $logoUrl, $auditMonths, $projectId ]); $auditName = $_POST['name']; } elseif ($action === 'save_cookie_banner') { $cookieBanner = !empty($_POST['cookie_banner_enabled']) ? 1 : 0; $bannerMap = []; foreach (ui_locales() as $code => $meta) { $text = trim($_POST['cookie_banner_text'][$code] ?? ''); if ($text !== '') { $bannerMap[$code] = $text; } } $cookieBannerText = $bannerMap ? json_encode($bannerMap, JSON_UNESCAPED_UNICODE) : ''; $stmt = $db->prepare("UPDATE projects SET cookie_banner_enabled=?, cookie_banner_text=?, $touchSql WHERE id=?"); $stmt->execute([$cookieBanner, $cookieBannerText, $projectId]); } elseif ($action === 'save_consent_log') { $consentLogging = !empty($_POST['consent_logging_enabled']) ? 1 : 0; $consentLogRetentionDays = max(0, (int)($_POST['consent_log_retention_days'] ?? 1095)); $stmt = $db->prepare("UPDATE projects SET consent_logging_enabled=?, consent_log_retention_days=?, $touchSql WHERE id=?"); $stmt->execute([$consentLogging, $consentLogRetentionDays, $projectId]); } elseif ($action === 'save_email') { $smtpHost = trim($_POST['smtp_host'] ?? ''); $smtpPort = (int)($_POST['smtp_port'] ?? 587); $smtpUser = trim($_POST['smtp_user'] ?? ''); // Secret-Felder werden im Formular nie mit ihrem echten Wert vorbefuellt (siehe Rendering // weiter unten) -- ein leer abgeschicktes Feld heisst also "unveraendert lassen", nicht // "loeschen", sonst wuerde jedes Speichern ohne Neueingabe die Secrets wegloeschen. $smtpPass = trim($_POST['smtp_pass'] ?? '') ?: (string)($project['smtp_pass'] ?? ''); $smtpSecure = trim($_POST['smtp_secure'] ?? 'tls'); $smtpFrom = trim($_POST['smtp_from'] ?? ''); $auditRecipient = trim($_POST['audit_email_recipient'] ?? ''); $stmt = $db->prepare(" UPDATE projects SET smtp_host=?, smtp_port=?, smtp_user=?, smtp_pass=?, smtp_secure=?, smtp_from=?, audit_email_recipient=?, $touchSql WHERE id=? "); $stmt->execute([$smtpHost, $smtpPort, $smtpUser, $smtpPass, $smtpSecure, $smtpFrom, $auditRecipient, $projectId]); } elseif ($action === 'save_webhook') { $webhookUrl = trim($_POST['webhook_url'] ?? ''); $webhookSecret = trim($_POST['webhook_secret'] ?? '') ?: (string)($project['webhook_secret'] ?? ''); $deeplKey = trim($_POST['deepl_api_key'] ?? '') ?: (string)($project['deepl_api_key'] ?? ''); $aiProvider = in_array($_POST['ai_provider'] ?? '', ['claude', 'openai'], true) ? $_POST['ai_provider'] : ''; $aiApiKey = trim($_POST['ai_api_key'] ?? '') ?: (string)($project['ai_api_key'] ?? ''); $stmt = $db->prepare(" UPDATE projects SET webhook_url=?, webhook_secret=?, deepl_api_key=?, ai_provider=?, ai_api_key=?, $touchSql WHERE id=? "); $stmt->execute([$webhookUrl, $webhookSecret, $deeplKey, $aiProvider, $aiApiKey, $projectId]); } elseif ($action === 'save_company') { $stmt = $db->prepare(" UPDATE projects SET company_name=?, address=?, email=?, phone=?, representative=?, register_info=?, $touchSql WHERE id=? "); $stmt->execute([ $_POST['company_name'], $_POST['address'], $_POST['email'], $_POST['phone'], $_POST['representative'], $_POST['register_info'], $projectId ]); } log_audit($projectId, $auditName, t('admin.common.audit.settings_updated')); header("Location: /admin/settings?project_id=$projectId&msg=saved"); exit; } if ($action === 'create_doc_type') { $title = trim($_POST['doc_title'] ?? ''); $slug = trim($_POST['doc_slug'] ?? ''); $isReq = !empty($_POST['is_required']) ? 1 : 0; if ($slug === '' && $title !== '') { $slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $title)); } if ($title !== '' && $slug !== '') { $stmt = $db->prepare("INSERT INTO doc_types (title, slug, is_required) VALUES (?, ?, ?)"); $stmt->execute([$title, $slug, $isReq]); $newTypeId = (int)$db->lastInsertId(); foreach ($projects as $p) { $docStmt = $db->prepare("INSERT INTO documents (project_id, doc_type_id) VALUES (?, ?)"); $docStmt->execute([$p['id'], $newTypeId]); } log_audit(null, '', t('admin.matrix.audit.doctype_created', ['title' => $title])); } header("Location: /admin?project_id=$projectId&msg=type_created"); exit; } if ($action === 'delete_doc_type') { $typeId = (int)$_POST['doc_type_id']; $typeTitleStmt = $db->prepare("SELECT title FROM doc_types WHERE id = ?"); $typeTitleStmt->execute([$typeId]); $typeTitle = (string)($typeTitleStmt->fetchColumn() ?: ''); $stmt = $db->prepare("DELETE FROM doc_types WHERE id = ?"); $stmt->execute([$typeId]); log_audit(null, '', t('admin.matrix.audit.doctype_deleted', ['title' => $typeTitle])); header("Location: /admin?project_id=$projectId&msg=type_deleted"); exit; } // Nutzerverwaltung ist ausschliesslich dem primaeren Admin-Login vorbehalten -- ein // ueber die users-Tabelle eingeloggter Benutzer darf nie selbst Benutzer verwalten. if (in_array($action, ['invite_user', 'resend_invite', 'delete_user', 'update_user_projects'], true) && !current_user_is_primary_admin()) { http_response_code(403); echo htmlspecialchars(t('admin.common.access_denied')); exit; } // Benutzer einladen if ($action === 'invite_user') { if (!empty(get_config()['managed_cloud'])) { header('Location: /admin/users?msg=managed_cloud_blocked'); exit; } $name = trim($_POST['invite_name'] ?? ''); $email = trim(strtolower($_POST['invite_email'] ?? '')); if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { header("Location: /admin/users?msg=invalid"); exit; } $chk = $db->prepare("SELECT id FROM users WHERE email = ?"); $chk->execute([$email]); if ($chk->fetch()) { header("Location: /admin/users?msg=email_exists"); exit; } $token = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', strtotime('+7 days')); $ins = $db->prepare("INSERT INTO users (name, email, status, invite_token, invite_token_expires_at) VALUES (?, ?, 'invited', ?, ?)"); $ins->execute([$name, $email, $token, $expiresAt]); log_audit(null, '', t('admin.users.audit.invited', ['name' => $name, 'email' => $email])); $res = send_invite_mail($project, $name, $email, $token); header("Location: /admin/users?msg=" . ($res['success'] ? 'invited' : 'invite_mail_failed')); exit; } // Einladung erneut senden if ($action === 'resend_invite') { $uid = (int)($_POST['user_id'] ?? 0); $stmt = $db->prepare("SELECT * FROM users WHERE id = ? AND status = 'invited'"); $stmt->execute([$uid]); $u = $stmt->fetch(); if ($u) { $token = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', strtotime('+7 days')); $upd = $db->prepare("UPDATE users SET invite_token = ?, invite_token_expires_at = ? WHERE id = ?"); $upd->execute([$token, $expiresAt, $uid]); $res = send_invite_mail($project, $u['name'], $u['email'], $token); header("Location: /admin/users?msg=" . ($res['success'] ? 'invited' : 'invite_mail_failed')); exit; } header("Location: /admin/users"); exit; } // Benutzer entfernen if ($action === 'delete_user') { $uid = (int)($_POST['user_id'] ?? 0); if ($uid !== (int)($_SESSION['paragrafy_user_id'] ?? 0)) { $delUserNameStmt = $db->prepare("SELECT name FROM users WHERE id = ?"); $delUserNameStmt->execute([$uid]); $delUserName = (string)($delUserNameStmt->fetchColumn() ?: ''); $del = $db->prepare("DELETE FROM users WHERE id = ?"); $del->execute([$uid]); log_audit(null, '', t('admin.users.audit.deleted', ['name' => $delUserName])); } header("Location: /admin/users?msg=user_deleted"); exit; } // Projekt-Zuordnung eines Benutzers speichern (Berechtigungsmatrix) if ($action === 'update_user_projects') { $uid = (int)($_POST['user_id'] ?? 0); $projectIds = array_map('intval', $_POST['project_ids'] ?? []); $userRow = $db->prepare("SELECT name FROM users WHERE id = ?"); $userRow->execute([$uid]); $uName = (string)($userRow->fetchColumn() ?: ''); if ($uid > 0 && $uName !== '') { $db->prepare("DELETE FROM user_projects WHERE user_id = ?")->execute([$uid]); if (!empty($projectIds)) { $insUp = $db->prepare("INSERT INTO user_projects (user_id, project_id) VALUES (?, ?)"); foreach (array_unique($projectIds) as $pid) { $insUp->execute([$uid, $pid]); } log_audit(null, '', t('admin.users.audit.access_restricted', ['name' => $uName, 'count' => count(array_unique($projectIds))])); } else { log_audit(null, '', t('admin.users.audit.access_unrestricted', ['name' => $uName])); } } header("Location: /admin/users?msg=access_updated"); exit; } } function send_invite_mail(array $project, string $name, string $email, string $token): array { $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? $project['domain']; $inviteLink = $scheme . '://' . $host . '/admin/accept-invite?token=' . $token; $html = "

" . htmlspecialchars(t('admin.login.invite_mail_heading')) . "

" . "

" . htmlspecialchars(t('admin.login.invite_mail_greeting', ['name' => $name])) . "

" . "

" . htmlspecialchars(t('admin.login.invite_mail_body')) . "

" . "

" . htmlspecialchars(t('admin.login.invite_mail_button')) . "

" . "

" . htmlspecialchars(t('admin.login.invite_mail_link_hint')) . "
" . htmlspecialchars($inviteLink) . "

"; return send_smtp_mail($project, $email, t('admin.login.invite_mail_subject'), $html); } function handle_accept_invite(PDO $db): void { $token = trim($_GET['token'] ?? $_POST['token'] ?? ''); $stmt = $db->prepare("SELECT * FROM users WHERE invite_token = ? AND invite_token != '' AND status = 'invited' AND (invite_token_expires_at IS NULL OR invite_token_expires_at > CURRENT_TIMESTAMP)"); $stmt->execute([$token]); $user = $stmt->fetch(); $error = null; if ($user && isset($_POST['action']) && $_POST['action'] === 'set_password') { $pass = $_POST['password'] ?? ''; $confirm = $_POST['password_confirm'] ?? ''; if (strlen($pass) < 8) { $error = t('admin.login.password_too_short'); } elseif ($pass !== $confirm) { $error = t('admin.login.passwords_mismatch'); } else { $upd = $db->prepare("UPDATE users SET password_hash = ?, status = 'active', invite_token = '', activated_at = CURRENT_TIMESTAMP WHERE id = ?"); $upd->execute([password_hash($pass, PASSWORD_DEFAULT), $user['id']]); session_regenerate_id(true); $_SESSION['paragrafy_admin'] = true; $_SESSION['paragrafy_user_id'] = (int)$user['id']; $_SESSION['paragrafy_user_name'] = $user['name']; $_SESSION['paragrafy_user_email'] = $user['email']; header('Location: /admin'); exit; } } render_accept_invite_view($user, $error); } function render_accept_invite_view(?array $user, ?string $error): void { ?> <?= htmlspecialchars(t('admin.login.accept_invite.page_title')) ?>
Paragrafy

'' . htmlspecialchars($user['name']) . '', 'email' => '' . htmlspecialchars($user['email']) . '']) ?>

prepare("SELECT * FROM users WHERE email = ? AND status = 'active'"); $stmt->execute([$email]); $user = $stmt->fetch(); if ($user) { $token = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour')); $upd = $db->prepare("UPDATE users SET invite_token = ?, invite_token_expires_at = ? WHERE id = ?"); $upd->execute([$token, $expiresAt, $user['id']]); $project = $db->query("SELECT * FROM projects ORDER BY id ASC LIMIT 1")->fetch(); if ($project) { $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? $project['domain']; $resetLink = $scheme . '://' . $host . '/admin/reset-password?token=' . $token; $html = "

" . htmlspecialchars(t('admin.login.forgot.mail_heading')) . "

" . "

" . htmlspecialchars(t('admin.login.forgot.mail_greeting', ['name' => $user['name']])) . "

" . "

" . htmlspecialchars(t('admin.login.forgot.mail_body')) . "

" . "

" . htmlspecialchars(t('admin.login.forgot.mail_button')) . "

" . "

" . htmlspecialchars(t('admin.login.forgot.mail_ignore_hint')) . "

"; send_smtp_mail($project, $user['email'], t('admin.login.forgot.mail_subject'), $html); } } } $sent = true; } render_forgot_password_view($sent); } function render_forgot_password_view(bool $sent): void { ?> <?= htmlspecialchars(t('admin.login.forgot.page_title')) ?>
Paragrafy

prepare("SELECT * FROM users WHERE invite_token = ? AND invite_token != '' AND status = 'active' AND (invite_token_expires_at IS NULL OR invite_token_expires_at > CURRENT_TIMESTAMP)"); $stmt->execute([$token]); $user = $stmt->fetch(); $error = null; if ($user && isset($_POST['action']) && $_POST['action'] === 'set_new_password') { $pass = $_POST['password'] ?? ''; $confirm = $_POST['password_confirm'] ?? ''; if (strlen($pass) < 8) { $error = t('admin.login.password_too_short'); } elseif ($pass !== $confirm) { $error = t('admin.login.passwords_mismatch'); } else { $upd = $db->prepare("UPDATE users SET password_hash = ?, invite_token = '' WHERE id = ?"); $upd->execute([password_hash($pass, PASSWORD_DEFAULT), $user['id']]); log_audit(null, '', t('admin.login.reset.audit_note', ['name' => $user['name']])); // Ein neues Passwort ersetzt nicht den zweiten Faktor: TOTP-Nutzer muessen nach dem // Reset genauso den Code eingeben wie beim normalen Login (siehe totp_pending-Handling // oben in der action=login-Verzweigung), sonst waere ein abgefangener Reset-Token // allein ausreichend, um TOTP komplett zu umgehen. if (!empty($user['totp_enabled_at'])) { $_SESSION['totp_pending'] = ['type' => 'user', 'user_id' => (int)$user['id'], 'started_at' => time()]; } else { finalize_user_session($user); } header('Location: /admin'); exit; } } render_reset_password_view($user, $error); } function render_reset_password_view(?array $user, ?string $error): void { ?> <?= htmlspecialchars(t('admin.login.reset.page_title')) ?>
Paragrafy

'' . htmlspecialchars($user['email']) . '']) ?>

prepare('INSERT INTO sso_nonces (nonce) VALUES (?)')->execute([$payload['n']]); } catch (\PDOException $e) { // UNIQUE-Constraint-Verletzung (SQLite Code 19) = Nonce wurde bereits // eingeloest -- Replay ablehnen. Das INSERT ist selbst der atomare // "pruefen + als benutzt markieren"-Schritt, SQLite serialisiert // Writes ohnehin, kein zusaetzliches Locking noetig. http_response_code(403); exit(t('admin.sso.token_already_used')); } // Gelegentliches, beilaeufiges Aufraeumen abgelaufener Nonces -- diese // sind laengst nicht mehr einloesbar, die Zeile dient nur noch der // Replay-Erkennung waehrend der TTL. Retention ist unkritisch (keine // personenbezogenen Daten, nur Zufallsstrings + Zeitstempel). if (random_int(1, 20) === 1) { try { $db->exec("DELETE FROM sso_nonces WHERE used_at < datetime('now', '-1 hour')"); } catch (\PDOException $e) { } } session_regenerate_id(true); $_SESSION['paragrafy_admin'] = true; $_SESSION['paragrafy_user_name'] = 'Admin'; unset($_SESSION['paragrafy_user_id'], $_SESSION['paragrafy_user_email']); $projectId = (int)($_GET['project_id'] ?? 0); header('Location: /admin' . ($projectId > 0 ? '?project_id=' . $projectId : '')); exit; } function finalize_user_session(array $user, ?string $explicitLocale = null): void { session_regenerate_id(true); $_SESSION['paragrafy_admin'] = true; $_SESSION['paragrafy_user_id'] = (int)$user['id']; $_SESSION['paragrafy_user_name'] = $user['name']; $_SESSION['paragrafy_user_email'] = $user['email']; // Eine Sprache, die man gerade erst auf dem Login-Bildschirm gewaehlt hat, soll den Login // ueberleben statt sofort von der gespeicherten Account-Praeferenz ueberschrieben zu werden -- // current_locale() gibt der Session-Praeferenz sonst immer Vorrang. Bewusst $_GET['locale'] aus // DIESEM Request (als Default, falls $explicitLocale nicht mitgegeben wird) statt des // paragrafy_locale-Cookies: das Cookie lebt 30 Tage und wuerde sonst auch Monate spaeter noch // jede frisch in den Einstellungen gespeicherte Account-Praeferenz beim naechsten Login // stillschweigend zurueckdrehen. $explicitLocale existiert fuer den TOTP-Zwischenschritt: dort // greift der finale POST erst nach dem Redirect zur TOTP-Seite, der kein ?locale= mehr traegt, // daher wird die Wahl dort ueber $_SESSION['totp_pending']['locale'] durchgereicht. $known = array_keys(ui_locales()); $justChosen = $explicitLocale ?? (string)($_GET['locale'] ?? ''); $_SESSION['paragrafy_user_locale'] = in_array($justChosen, $known, true) ? $justChosen : ($user['locale'] ?? 'de'); } function finalize_admin_session(): void { session_regenerate_id(true); $_SESSION['paragrafy_admin'] = true; $_SESSION['paragrafy_user_name'] = 'Admin'; unset($_SESSION['paragrafy_user_id'], $_SESSION['paragrafy_user_email'], $_SESSION['paragrafy_user_locale']); } /** * Second step of the login flow once a user's or the admin's password has * already been verified and totp_pending was stashed in the session. Tries * the 6-digit TOTP code first, falls back to a recovery code. Uses the same * rate_limit_wait()/login_attempts machinery as the password step, under a * "totp:"-prefixed identifier so a flood of bad codes can't be used to * bypass the password throttle (and vice versa). */ function handle_totp_verify(PDO $db): ?string { $pending = $_SESSION['totp_pending'] ?? null; if (!is_array($pending) || ($pending['started_at'] ?? 0) < time() - 300) { unset($_SESSION['totp_pending']); header('Location: /admin'); exit; } $clientIp = get_client_ip(); $type = $pending['type'] ?? ''; $acctIdentifier = $type === 'user' ? ('totp:acct:user:' . (int)($pending['user_id'] ?? 0)) : 'totp:acct:admin'; $waitSeconds = max( rate_limit_wait($db, 'totp:' . $clientIp, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MINUTES), rate_limit_wait($db, $acctIdentifier, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MINUTES) ); if ($waitSeconds > 0) { return t('admin.login.rate_limited', ['minutes' => (int)ceil($waitSeconds / 60)]); } $input = trim($_POST['totp_code'] ?? ''); $success = false; if ($type === 'user') { $stmt = $db->prepare("SELECT * FROM users WHERE id = ? AND status = 'active'"); $stmt->execute([(int)($pending['user_id'] ?? 0)]); $user = $stmt->fetch(); if ($user) { $success = totp_finish_user_login($db, $user, $input, $pending['locale'] ?? null); } } elseif ($type === 'admin' && admin_totp_available()) { $success = totp_finish_admin_login($input); } if ($success) { clear_login_failures($db, $clientIp); clear_login_failures($db, $acctIdentifier); unset($_SESSION['totp_pending']); header('Location: /admin'); exit; } record_login_failure($db, $clientIp); record_login_failure($db, $acctIdentifier); return t('admin.login.totp_invalid'); } function totp_finish_user_login(PDO $db, array $user, string $input, ?string $pendingLocale = null): bool { if (totp_consume_for_user_login($db, $user, $input)) { finalize_user_session($user, $pendingLocale); return true; } return false; } function totp_finish_admin_login(string $input): bool { if (totp_consume_for_admin_login($input)) { finalize_admin_session(); return true; } return false; } // TOTP Self-Service (/admin/security) -- gilt fuer den eingeloggten Account // (regulaerer User oder, nur self-hosted, der primaere Admin). totp_current_identity() // liefert null, wenn TOTP hier gar nicht angeboten wird (Admin auf Managed Cloud). if (isset($_POST['action']) && $_POST['action'] === 'totp_setup_start') { $identity = totp_current_identity($db); if ($identity !== null && !totp_identity_enabled($identity) && totp_vendor_available()) { $_SESSION['totp_setup'] = ['secret' => totp_generate_secret()]; } header("Location: /admin/security?project_id=$projectId"); exit; } if (isset($_POST['action']) && $_POST['action'] === 'totp_setup_cancel') { unset($_SESSION['totp_setup']); header("Location: /admin/security?project_id=$projectId"); exit; } if (isset($_POST['action']) && $_POST['action'] === 'totp_setup_confirm') { $identity = totp_current_identity($db); $pendingSecret = $_SESSION['totp_setup']['secret'] ?? null; $code = trim($_POST['totp_code'] ?? ''); if ($identity === null || totp_identity_enabled($identity) || !is_string($pendingSecret)) { header("Location: /admin/security?project_id=$projectId"); exit; } if (totp_verify_and_consume($pendingSecret, $code, null) === null) { header("Location: /admin/security?project_id=$projectId&msg=totp_setup_invalid_code"); exit; } $recoveryCodes = totp_generate_recovery_codes(); try { // totp_encrypt_secret() can itself throw if the totp_encryption_key // couldn't be persisted on its first-ever use (see // ensure_totp_encryption_key()) -- treat that exactly like // totp_identity_complete_setup() returning false below: do NOT show // recovery codes or log "enabled", nothing was actually saved, and // telling the user otherwise would make them believe the account is // protected when it isn't. Leave $_SESSION['totp_setup']['secret'] // in place so they can simply retry the confirmation. $persisted = totp_identity_complete_setup($db, $identity, totp_encrypt_secret($pendingSecret), totp_hash_recovery_codes($recoveryCodes)); } catch (\RuntimeException $e) { $persisted = false; } if (!$persisted) { header("Location: /admin/security?project_id=$projectId&msg=totp_setup_persist_failed"); exit; } $_SESSION['totp_setup'] = ['recovery_codes' => $recoveryCodes]; log_audit(null, '', t('admin.security.audit.totp_enabled', ['who' => $identity['type'] === 'user' ? $identity['row']['email'] : 'Admin'])); header("Location: /admin/security?project_id=$projectId"); exit; } if (isset($_POST['action']) && $_POST['action'] === 'totp_setup_ack') { unset($_SESSION['totp_setup']); header("Location: /admin/security?project_id=$projectId&msg=totp_enabled"); exit; } if (isset($_POST['action']) && $_POST['action'] === 'totp_regenerate_codes') { $identity = totp_current_identity($db); if ($identity === null || !totp_identity_enabled($identity)) { header("Location: /admin/security?project_id=$projectId"); exit; } $recoveryCodes = totp_generate_recovery_codes(); if (!totp_identity_replace_recovery_codes($db, $identity, totp_hash_recovery_codes($recoveryCodes))) { header("Location: /admin/security?project_id=$projectId&msg=totp_setup_persist_failed"); exit; } $_SESSION['totp_setup'] = ['recovery_codes' => $recoveryCodes]; log_audit(null, '', t('admin.security.audit.totp_codes_regenerated', ['who' => $identity['type'] === 'user' ? $identity['row']['email'] : 'Admin'])); header("Location: /admin/security?project_id=$projectId"); exit; } if (isset($_POST['action']) && $_POST['action'] === 'totp_disable') { $identity = totp_current_identity($db); $pass = $_POST['confirm_password'] ?? ''; if ($identity !== null && totp_identity_enabled($identity) && $pass !== '' && password_verify($pass, totp_identity_password_hash($identity, $config))) { if (!totp_identity_disable($db, $identity)) { header("Location: /admin/security?project_id=$projectId&msg=totp_setup_persist_failed"); exit; } unset($_SESSION['totp_setup']); log_audit(null, '', t('admin.security.audit.totp_disabled', ['who' => $identity['type'] === 'user' ? $identity['row']['email'] : 'Admin'])); header("Location: /admin/security?project_id=$projectId&msg=totp_disabled"); exit; } header("Location: /admin/security?project_id=$projectId&msg=totp_disable_wrong_password"); exit; } // Admin-seitiger TOTP-Reset fuer einen regulaeren User-Account (Geraet/Recovery-Codes // verloren). Nur der primaere Admin darf das; der betroffene User bekommt eine // Benachrichtigungsmail, ausserdem ein Audit-Log-Eintrag. if (isset($_POST['action']) && $_POST['action'] === 'reset_user_totp') { if (!current_user_is_primary_admin()) { http_response_code(403); echo htmlspecialchars(t('admin.common.access_denied')); exit; } $targetUserId = (int)($_POST['user_id'] ?? 0); $stmt = $db->prepare("SELECT * FROM users WHERE id = ?"); $stmt->execute([$targetUserId]); $targetUser = $stmt->fetch(); if ($targetUser) { $db->prepare("UPDATE users SET totp_secret = NULL, totp_enabled_at = NULL, totp_recovery_codes = NULL, totp_last_used_step = NULL WHERE id = ?") ->execute([$targetUserId]); send_smtp_mail( $project, $targetUser['email'], t('admin.security.reset_mail.subject'), '

' . htmlspecialchars(t('admin.security.reset_mail.body', ['name' => $targetUser['name']])) . '

' ); log_audit(null, '', t('admin.users.audit.totp_reset', ['name' => $targetUser['name'], 'email' => $targetUser['email']])); } header('Location: /admin/users?msg=totp_reset'); exit; } $subRoute = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH); if (str_starts_with($subRoute, '/admin/edit')) { require_once __DIR__ . '/editor.php'; exit; } if (str_starts_with($subRoute, '/admin/settings')) { render_settings_view($db, $project, $projects); exit; } if (str_starts_with($subRoute, '/admin/security')) { render_security_view($db, $project, $projects); exit; } if (str_starts_with($subRoute, '/admin/users')) { if (!current_user_is_primary_admin()) { http_response_code(403); echo htmlspecialchars(t('admin.common.access_denied')); exit; } render_users_view($db, $project, $projects); exit; } if (str_starts_with($subRoute, '/admin/audit')) { render_audit_view($db, $project, $projects); exit; } if (str_starts_with($subRoute, '/admin/consent-log')) { render_consent_log_view($db, $project, $projects); exit; } render_matrix_view($db, $project, $projects); function render_login_view(?string $error): void { ?> <?= htmlspecialchars(t('admin.login.page_title')) ?>
Paragrafy

<?= htmlspecialchars(t('admin.login.totp.page_title')) ?>
Paragrafy

<?= htmlspecialchars(t('admin.no_access.page_title')) ?>

query("SELECT * FROM doc_types ORDER BY is_required DESC, title ASC")->fetchAll(); $activeLangs = array_filter(array_map('trim', explode(',', $project['active_languages'] ?? 'de,en'))); $primaryLang = $project['primary_lang'] ?: 'de'; $auditIntervalMonths = (int)($project['audit_interval_months'] ?? 12); $unfilledWarnings = []; $auditWarnings = []; $totalRequired = 0; $publishedRequired = 0; $stmt = $db->prepare(" SELECT t.content, dt.title, t.lang, t.updated_at, t.source_hash, dt.is_required, t.document_id FROM translations t JOIN documents d ON t.document_id = d.id JOIN doc_types dt ON d.doc_type_id = dt.id WHERE d.project_id = ? AND t.status = 'published' "); $stmt->execute([$project['id']]); $allPublished = $stmt->fetchAll(); $now = new DateTime(); foreach ($allPublished as $row) { $unfilled = check_unfilled_placeholders($row['content'], $project); if (!empty($unfilled)) { $unfilledWarnings[] = t('admin.matrix.unfilled_warning_line', ['title' => $row['title'], 'lang' => strtoupper($row['lang']), 'placeholders' => implode(', ', $unfilled)]); } if (!empty($row['updated_at'])) { $updated = new DateTime($row['updated_at']); $diffMonths = (($now->format('Y') - $updated->format('Y')) * 12) + ($now->format('m') - $updated->format('m')); if ($diffMonths >= $auditIntervalMonths) { $days = $now->diff($updated)->days; $auditWarnings[] = t('admin.matrix.audit_warning_line', ['title' => $row['title'], 'lang' => strtoupper($row['lang']), 'days' => $days]); } } } foreach ($docTypes as $t) { if ($t['is_required']) { $totalRequired++; $chk = $db->prepare("SELECT COUNT(*) FROM translations t JOIN documents d ON t.document_id = d.id WHERE d.project_id = ? AND d.doc_type_id = ? AND t.lang = ? AND t.status = 'published'"); $chk->execute([$project['id'], $t['id'], $primaryLang]); if ((int)$chk->fetchColumn() > 0) { $publishedRequired++; } } } $complianceScore = $totalRequired > 0 ? (int)round(($publishedRequired / $totalRequired) * 100) : 100; $isManagedCloud = !empty(get_config()['managed_cloud']); ?> <?= htmlspecialchars(t('admin.matrix.page_title', ['project' => $project['name']])) ?>
/

· /api/:lang/:slug
= get_project_limit(); ?>
(int)get_project_limit(), 'suffix' => get_project_limit() === 1 ? '' : 'e']) ?>
%
$publishedRequired, 'total' => $totalRequired])) ?>
count($auditWarnings)])) ?>
$auditIntervalMonths])) ?>
$auditIntervalMonths]) ?>

prepare("SELECT id FROM documents WHERE project_id = ? AND doc_type_id = ?"); $stmt->execute([$project['id'], $type['id']]); $doc = $stmt->fetch(); if (!$doc) { $ins = $db->prepare("INSERT INTO documents (project_id, doc_type_id) VALUES (?, ?)"); $ins->execute([$project['id'], $type['id']]); $docId = (int)$db->lastInsertId(); } else { $docId = (int)$doc['id']; } $stmt = $db->prepare("SELECT source_hash, content FROM translations WHERE document_id = ? AND lang = ?"); $stmt->execute([$docId, $primaryLang]); $sourceRow = $stmt->fetch(); $currentSourceHash = $sourceRow ? md5($sourceRow['content']) : ''; $primaryUrl = "https://" . $project['domain'] . "/" . $type['slug']; ?> prepare("SELECT * FROM translations WHERE document_id = ? AND lang = ?"); $stmt->execute([$docId, $lang]); $trans = $stmt->fetch(); $isOutdated = false; if ($trans && $lang !== $primaryLang && !empty($trans['source_hash']) && !empty($currentSourceHash)) { if ($trans['source_hash'] !== $currentSourceHash) { $isOutdated = true; } } ?>
/

prepare("SELECT * FROM webhook_logs WHERE project_id = ? ORDER BY created_at DESC LIMIT 10"); $stmtLogs->execute([$project['id']]); $logs = $stmtLogs->fetchAll(); $backups = list_backups(); $backupMsg = $_GET['msg'] ?? ''; $cronSecret = ensure_cron_secret(); $cronScheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $cronHost = $_SERVER['HTTP_HOST'] ?? $project['domain']; $cronBase = $cronScheme . '://' . $cronHost; $queueSummary = webhook_queue_summary($db, $project['id']); $isManagedCloud = !empty(get_config()['managed_cloud']); $isDemo = !empty(get_config()['is_demo']); $isLockedInstance = $isManagedCloud || $isDemo; ?> <?= htmlspecialchars(t('admin.settings.page_title', ['project' => $project['name']])) ?>
/

/dev/null'; ?>

$meta): ?>

1): ?>
1): ?>

MB

(int)($_GET['restore_count'] ?? 0)])) ?>

(int)($_GET['restore_count'] ?? 0), 'limit' => (int)($_GET['restore_limit'] ?? 0)])) ?>

$_GET['restore_error'] ?? ''])) ?>

(int)($_GET['import_docs'] ?? 0)])) ?>

$_GET['import_error'] ?? ''])) ?>

$queueSummary['pending']])) ?> $queueSummary['sent']])) ?> 0): ?> $queueSummary['failed']])) ?>

= 200 && $l['status_code'] < 300); ?>
ms
['err', t('admin.security.msg.invalid_code')], 'totp_enabled' => ['ok', t('admin.security.msg.enabled')], 'totp_disabled' => ['ok', t('admin.security.msg.disabled')], 'totp_disable_wrong_password' => ['err', t('admin.security.msg.wrong_password')], 'totp_setup_persist_failed' => ['err', t('admin.security.msg.persist_failed')], ]; $qrDataUri = null; $provisioningUri = null; if ($pendingSecret !== null && $identity !== null) { $provisioningUri = totp_provisioning_uri($pendingSecret, totp_identity_label($identity, $project), totp_identity_issuer($identity, $project)); $qrDataUri = totp_qr_data_uri($provisioningUri); } ?> <?= htmlspecialchars(t('admin.security.page_title', ['project' => $project['name']])) ?>
/

QR


query("SELECT * FROM users ORDER BY (status = 'active') DESC, name ASC")->fetchAll(); $accessMap = []; foreach ($db->query("SELECT user_id, project_id FROM user_projects") as $row) { $accessMap[(int)$row['user_id']][] = (int)$row['project_id']; } $msg = $_GET['msg'] ?? ''; $msgMap = [ 'invited' => ['ok', t('admin.users.msg.invited')], 'invite_mail_failed' => ['err', t('admin.users.msg.invite_mail_failed')], 'email_exists' => ['err', t('admin.users.msg.email_exists')], 'invalid' => ['err', t('admin.users.msg.invalid')], 'user_deleted' => ['ok', t('admin.users.msg.user_deleted')], 'managed_cloud_blocked' => ['err', t('admin.users.msg.managed_cloud_blocked')], 'access_updated' => ['ok', t('admin.users.msg.access_updated')], 'totp_reset' => ['ok', t('admin.users.msg.totp_reset')], ]; ?> <?= htmlspecialchars(t('admin.users.page_title', ['project' => $project['name']])) ?>
/

' . htmlspecialchars(t('admin.users.you_suffix')) . '' : '' ?>
—
>
—

prepare("SELECT * FROM consent_logs WHERE project_id = ? ORDER BY created_at DESC LIMIT 500"); $stmt->execute([$project['id']]); $entries = $stmt->fetchAll(); } ?> <?= htmlspecialchars(t('admin.consent_log.page_title', ['project' => $project['name']])) ?>
/
prepare("SELECT * FROM audit_log WHERE project_id = ? OR project_id IS NULL ORDER BY created_at DESC LIMIT 200"); } else { $stmt = $db->prepare("SELECT * FROM audit_log WHERE project_id = ? ORDER BY created_at DESC LIMIT 200"); } $stmt->execute([$project['id']]); $entries = $stmt->fetchAll(); ?> <?= htmlspecialchars(t('admin.audit.page_title', ['project' => $project['name']])) ?>
/

·