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 = "
" . 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_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) . "
= htmlspecialchars(t('admin.login.accept_invite.invalid_token')) ?>
= t('admin.login.accept_invite.welcome', ['name' => '' . htmlspecialchars($user['name']) . '', 'email' => '' . htmlspecialchars($user['email']) . '']) ?>
" . 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.sent_notice')) ?>
= htmlspecialchars(t('admin.login.forgot.intro')) ?>
= htmlspecialchars(t('admin.login.reset.invalid_link')) ?>
= t('admin.login.reset.intro', ['email' => '' . htmlspecialchars($user['email']) . '']) ?>
' . 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.totp.hint')) ?>
= htmlspecialchars(t('admin.no_access.body')) ?>
= htmlspecialchars(t('admin.common.sidebar.logout_title')) ?>= htmlspecialchars(t('admin.matrix.table.subtitle')) ?>
| = htmlspecialchars(t('admin.matrix.table.col_doctype')) ?> | = htmlspecialchars(t('admin.matrix.table.col_required')) ?> | = strtoupper(htmlspecialchars($lang)) ?> | = htmlspecialchars(t('admin.matrix.table.col_actions')) ?> |
|---|---|---|---|
|
= htmlspecialchars($type['title']) ?>
|
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; } } ?> | = htmlspecialchars(t('admin.matrix.table.outdated_label')) ?> = htmlspecialchars(t('admin.matrix.table.live_label')) ?> = htmlspecialchars(t('admin.matrix.table.draft_label')) ?> = htmlspecialchars(t('admin.matrix.table.create_label')) ?> |
= htmlspecialchars(t('admin.matrix.add.subtitle')) ?>
= htmlspecialchars(t('admin.matrix.new_project_modal.desc')) ?>
= htmlspecialchars(t('admin.settings.appearance.subtitle')) ?>
= htmlspecialchars(t('admin.settings.locale.subtitle')) ?>
= htmlspecialchars(t('admin.settings.locale.saved_msg')) ?>
= htmlspecialchars(t('admin.settings.automation.cloud_subtitle')) ?>
= htmlspecialchars(t('admin.settings.automation.demo_subtitle')) ?>
= t('admin.settings.automation.cron_subtitle') ?>
= htmlspecialchars(t('admin.settings.automation.secret_regenerated')) ?>
= t('admin.settings.automation.crontab_hint') ?>
= t('admin.settings.cookie.subtitle') ?>
= t('admin.settings.consent_log.subtitle') ?>
= htmlspecialchars(t('admin.settings.email.subtitle')) ?>
= htmlspecialchars(t('admin.settings.webhooks.subtitle')) ?>
= t('admin.settings.company.subtitle') ?>
1): ?>= htmlspecialchars(t('admin.settings.backup.subtitle')) ?>
= htmlspecialchars(t('admin.settings.backup.created_msg')) ?>
= htmlspecialchars(t('admin.settings.backup.failed_msg')) ?>
= htmlspecialchars(t('admin.settings.backup.cloud_hint')) ?>
= htmlspecialchars(t('admin.settings.backup.manual_hint')) ?>
= htmlspecialchars(t('admin.settings.restore.success_msg', ['count' => (int)($_GET['restore_count'] ?? 0)])) ?>
= htmlspecialchars(t('admin.settings.restore.error_msg', ['error' => $_GET['restore_error'] ?? ''])) ?>
= htmlspecialchars(t('admin.settings.project_transfer.subtitle')) ?>
= htmlspecialchars(t('admin.settings.project_transfer.import_merged_msg', ['count' => (int)($_GET['import_docs'] ?? 0)])) ?>
= htmlspecialchars(t('admin.settings.project_transfer.import_error_msg', ['error' => $_GET['import_error'] ?? ''])) ?>
= htmlspecialchars(t('admin.settings.queue.processed_msg')) ?>
= htmlspecialchars(t('admin.settings.log.subtitle')) ?>
| = htmlspecialchars(t('admin.settings.log.col_time')) ?> | = htmlspecialchars(t('admin.settings.log.col_event')) ?> | = htmlspecialchars(t('admin.settings.log.col_status')) ?> | = htmlspecialchars(t('admin.settings.log.col_latency')) ?> | = htmlspecialchars(t('admin.settings.log.col_response')) ?> |
|---|---|---|---|---|
| = date('d.m. H:i:s', strtotime($l['created_at'])) ?> | = htmlspecialchars($l['event_name']) ?> | = $l['status_code'] ?: 'ERR' ?> | = $l['duration_ms'] ?> ms |
= htmlspecialchars($l['error_message']) ?>
= htmlspecialchars($l['response_body']) ?>
= htmlspecialchars(t('admin.settings.log.empty_response')) ?>
|
= htmlspecialchars(t('admin.security.subtitle')) ?>
= htmlspecialchars(t('admin.security.unavailable_managed_cloud')) ?>
= htmlspecialchars(t('admin.security.vendor_missing')) ?>
= htmlspecialchars(t('admin.security.recovery.intro')) ?>
= htmlspecialchars(t('admin.security.setup.scan_hint')) ?>
= htmlspecialchars(t('admin.security.setup.manual_entry_hint')) ?>
= htmlspecialchars($pendingSecret) ?>
= htmlspecialchars(t('admin.users.subtitle')) ?>
= htmlspecialchars(t('admin.users.access.hint')) ?>
= htmlspecialchars(t('admin.users.modal.desc')) ?>
= htmlspecialchars(t('admin.consent_log.subtitle')) ?>
= htmlspecialchars(t('admin.audit.subtitle')) ?>