t('db.cron.invalid_secret')]);
exit;
}
}
function get_db(): PDO {
static $pdo = null;
if ($pdo === null) {
$pdo = new PDO('sqlite:' . DB_FILE);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec("PRAGMA foreign_keys = ON;");
ensure_schema_migrations($pdo);
}
return $pdo;
}
function ensure_schema_migrations(PDO $pdo): void {
try {
$stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'");
if ($stmt && $stmt->fetch()) {
$cols = $pdo->query("PRAGMA table_info(projects)")->fetchAll();
$colNames = array_column($cols, 'name');
$newCols = [
'deepl_api_key' => "TEXT DEFAULT ''",
'logo_url' => "TEXT DEFAULT ''",
'cookie_banner_enabled' => "INTEGER DEFAULT 0",
'cookie_banner_text' => "TEXT DEFAULT ''",
'webhook_url' => "TEXT DEFAULT ''",
'webhook_secret' => "TEXT DEFAULT ''",
'audit_interval_months' => "INTEGER DEFAULT 12",
'smtp_host' => "TEXT DEFAULT ''",
'smtp_port' => "INTEGER DEFAULT 587",
'smtp_user' => "TEXT DEFAULT ''",
'smtp_pass' => "TEXT DEFAULT ''",
'smtp_secure' => "TEXT DEFAULT 'tls'",
'smtp_from' => "TEXT DEFAULT ''",
'audit_email_recipient' => "TEXT DEFAULT ''",
'consent_logging_enabled' => "INTEGER DEFAULT 0",
'consent_log_retention_days' => "INTEGER DEFAULT 1095",
'ai_provider' => "TEXT DEFAULT ''",
'ai_api_key' => "TEXT DEFAULT ''",
// SQLite lehnt bei ALTER TABLE ... ADD COLUMN einen nicht-konstanten Default
// (CURRENT_TIMESTAMP) auf Tabellen mit UNIQUE-Constraint bzw. eingehenden
// Foreign-Key-Referenzen ab ("Cannot add a column with non-constant default")
// -- genau das ist projects (UNIQUE(domain), von documents/... referenziert).
// Deshalb hier ein konstanter Platzhalter-Default, Backfill per UPDATE direkt
// im Anschluss (UPDATE darf CURRENT_TIMESTAMP sehr wohl verwenden).
'settings_updated_at' => "DATETIME DEFAULT '1970-01-01 00:00:00'",
'settings_version' => "INTEGER NOT NULL DEFAULT 1"
];
foreach ($newCols as $c => $type) {
if (!in_array($c, $colNames)) {
$pdo->exec("ALTER TABLE projects ADD COLUMN " . $c . " " . $type);
}
}
// Unbedingt (nicht nur direkt nach dem ADD COLUMN) ausfuehren: bricht ein Lauf
// zwischen ALTER TABLE und diesem UPDATE ab, erkennt der naechste Aufruf die Spalte
// bereits als vorhanden und wuerde den Backfill sonst fuer immer ueberspringen.
$pdo->exec("UPDATE projects SET settings_updated_at = CURRENT_TIMESTAMP WHERE settings_updated_at = '1970-01-01 00:00:00'");
}
$stmtUsers = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='users'");
if ($stmtUsers && $stmtUsers->fetch()) {
$colsUsers = $pdo->query("PRAGMA table_info(users)")->fetchAll();
$userColNames = array_column($colsUsers, 'name');
if (!in_array('locale', $userColNames)) {
$pdo->exec("ALTER TABLE users ADD COLUMN locale TEXT DEFAULT 'de'");
}
if (!in_array('notes', $userColNames)) {
$pdo->exec("ALTER TABLE users ADD COLUMN notes TEXT DEFAULT ''");
}
if (!in_array('invite_token_expires_at', $userColNames)) {
$pdo->exec("ALTER TABLE users ADD COLUMN invite_token_expires_at DATETIME DEFAULT NULL");
}
if (!in_array('totp_secret', $userColNames)) {
$pdo->exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NULL");
}
if (!in_array('totp_enabled_at', $userColNames)) {
$pdo->exec("ALTER TABLE users ADD COLUMN totp_enabled_at DATETIME NULL");
}
if (!in_array('totp_recovery_codes', $userColNames)) {
$pdo->exec("ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT NULL");
}
if (!in_array('totp_last_used_step', $userColNames)) {
$pdo->exec("ALTER TABLE users ADD COLUMN totp_last_used_step INTEGER NULL");
}
}
$stmtTrans = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='translations'");
if ($stmtTrans && $stmtTrans->fetch()) {
$colsTrans = $pdo->query("PRAGMA table_info(translations)")->fetchAll();
$transColNames = array_column($colsTrans, 'name');
$transNewCols = [
'change_note' => "TEXT DEFAULT ''",
'source_hash' => "TEXT DEFAULT ''",
'previous_content' => "TEXT DEFAULT ''",
'scheduled_at' => "DATETIME DEFAULT NULL",
'scheduled_title' => "TEXT DEFAULT ''",
'scheduled_slug' => "TEXT DEFAULT ''",
'scheduled_content' => "TEXT DEFAULT ''",
'scheduled_note' => "TEXT DEFAULT ''"
];
foreach ($transNewCols as $tc => $ttype) {
if (!in_array($tc, $transColNames)) {
$pdo->exec("ALTER TABLE translations ADD COLUMN " . $tc . " " . $ttype);
}
}
}
$pdo->exec("
CREATE TABLE IF NOT EXISTS sso_nonces (
nonce TEXT PRIMARY KEY,
used_at TEXT NOT NULL DEFAULT (datetime('now'))
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS webhook_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
event_name TEXT NOT NULL,
url TEXT NOT NULL,
status_code INTEGER DEFAULT 0,
request_payload TEXT DEFAULT '',
response_body TEXT DEFAULT '',
error_message TEXT DEFAULT '',
duration_ms INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT DEFAULT '',
status TEXT DEFAULT 'invited',
invite_token TEXT DEFAULT '',
invited_at DATETIME DEFAULT CURRENT_TIMESTAMP,
activated_at DATETIME DEFAULT NULL,
locale TEXT DEFAULT 'de',
notes TEXT DEFAULT '',
invite_token_expires_at DATETIME DEFAULT NULL,
totp_secret TEXT NULL,
totp_enabled_at DATETIME NULL,
totp_recovery_codes TEXT NULL,
totp_last_used_step INTEGER NULL
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER DEFAULT NULL,
project_name TEXT DEFAULT '',
user_name TEXT DEFAULT 'Admin',
action TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS translation_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL,
lang TEXT NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
content TEXT NOT NULL,
change_note TEXT DEFAULT '',
status TEXT DEFAULT 'published',
user_name TEXT DEFAULT 'Admin',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identifier TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS webhook_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
event_name TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
next_attempt_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_error TEXT DEFAULT '',
last_status_code INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME DEFAULT NULL
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS consent_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
consent_id TEXT NOT NULL,
action TEXT NOT NULL,
lang TEXT DEFAULT '',
banner_text_hash TEXT DEFAULT '',
ip_anonymized TEXT DEFAULT '',
user_agent TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS user_projects (
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
PRIMARY KEY (user_id, project_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
");
} catch (Throwable $e) {
}
}
function init_database_schema(PDO $pdo): void {
$pdo->exec("
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
primary_lang TEXT DEFAULT 'de',
active_languages TEXT DEFAULT 'de,en',
brand_color TEXT DEFAULT '#F0A63C',
logo_url TEXT DEFAULT '',
deepl_api_key TEXT DEFAULT '',
webhook_url TEXT DEFAULT '',
webhook_secret TEXT DEFAULT '',
audit_interval_months INTEGER DEFAULT 12,
smtp_host TEXT DEFAULT '',
smtp_port INTEGER DEFAULT 587,
smtp_user TEXT DEFAULT '',
smtp_pass TEXT DEFAULT '',
smtp_secure TEXT DEFAULT 'tls',
smtp_from TEXT DEFAULT '',
audit_email_recipient TEXT DEFAULT '',
cookie_banner_enabled INTEGER DEFAULT 0,
cookie_banner_text TEXT DEFAULT '',
consent_logging_enabled INTEGER DEFAULT 0,
consent_log_retention_days INTEGER DEFAULT 1095,
ai_provider TEXT DEFAULT '',
ai_api_key TEXT DEFAULT '',
company_name TEXT DEFAULT '',
address TEXT DEFAULT '',
email TEXT DEFAULT '',
phone TEXT DEFAULT '',
representative TEXT DEFAULT '',
register_info TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS doc_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT DEFAULT '',
is_required INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
doc_type_id INTEGER NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (doc_type_id) REFERENCES doc_types(id) ON DELETE CASCADE,
UNIQUE(project_id, doc_type_id)
);
CREATE TABLE IF NOT EXISTS translations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL,
lang TEXT NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
content TEXT NOT NULL,
previous_content TEXT DEFAULT '',
status TEXT DEFAULT 'draft',
change_note TEXT DEFAULT '',
source_hash TEXT DEFAULT '',
scheduled_at DATETIME DEFAULT NULL,
scheduled_title TEXT DEFAULT '',
scheduled_slug TEXT DEFAULT '',
scheduled_content TEXT DEFAULT '',
scheduled_note TEXT DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
UNIQUE(document_id, lang)
);
CREATE TABLE IF NOT EXISTS webhook_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
event_name TEXT NOT NULL,
url TEXT NOT NULL,
status_code INTEGER DEFAULT 0,
request_payload TEXT DEFAULT '',
response_body TEXT DEFAULT '',
error_message TEXT DEFAULT '',
duration_ms INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT DEFAULT '',
status TEXT DEFAULT 'invited',
invite_token TEXT DEFAULT '',
invited_at DATETIME DEFAULT CURRENT_TIMESTAMP,
activated_at DATETIME DEFAULT NULL,
locale TEXT DEFAULT 'de',
notes TEXT DEFAULT '',
invite_token_expires_at DATETIME DEFAULT NULL,
totp_secret TEXT NULL,
totp_enabled_at DATETIME NULL,
totp_recovery_codes TEXT NULL,
totp_last_used_step INTEGER NULL
);
CREATE TABLE IF NOT EXISTS sso_nonces (
nonce TEXT PRIMARY KEY,
used_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_projects (
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
PRIMARY KEY (user_id, project_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER DEFAULT NULL,
project_name TEXT DEFAULT '',
user_name TEXT DEFAULT 'Admin',
action TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS translation_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL,
lang TEXT NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
content TEXT NOT NULL,
change_note TEXT DEFAULT '',
status TEXT DEFAULT 'published',
user_name TEXT DEFAULT 'Admin',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identifier TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS webhook_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
event_name TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
next_attempt_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_error TEXT DEFAULT '',
last_status_code INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS consent_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
consent_id TEXT NOT NULL,
action TEXT NOT NULL,
lang TEXT DEFAULT '',
banner_text_hash TEXT DEFAULT '',
ip_anonymized TEXT DEFAULT '',
user_agent TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
");
}
function check_and_publish_scheduled(PDO $pdo, ?array $project = null): array {
$publishedCount = 0;
try {
$nowStr = date('Y-m-d H:i:s');
$sql = "
SELECT t.id, t.document_id, t.lang, t.title, t.slug, t.content, t.scheduled_title, t.scheduled_slug, t.scheduled_content, t.scheduled_note, t.scheduled_at,
d.project_id, p.name as project_name, p.domain, p.webhook_url, p.webhook_secret
FROM translations t
JOIN documents d ON t.document_id = d.id
JOIN projects p ON d.project_id = p.id
WHERE t.scheduled_at IS NOT NULL AND t.scheduled_at <= ?
";
$params = [$nowStr];
if ($project !== null) {
$sql .= " AND p.id = ?";
$params[] = $project['id'];
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$due = $stmt->fetchAll();
foreach ($due as $row) {
$finalTitle = $row['scheduled_title'] !== '' ? $row['scheduled_title'] : $row['title'];
$finalSlug = $row['scheduled_slug'] !== '' ? $row['scheduled_slug'] : $row['slug'];
$upd = $pdo->prepare("
UPDATE translations SET
title = CASE WHEN scheduled_title != '' THEN scheduled_title ELSE title END,
slug = CASE WHEN scheduled_slug != '' THEN scheduled_slug ELSE slug END,
previous_content = content,
content = CASE WHEN scheduled_content != '' THEN scheduled_content ELSE content END,
change_note = scheduled_note,
status = 'published',
scheduled_at = NULL,
scheduled_title = '',
scheduled_slug = '',
scheduled_content = '',
scheduled_note = '',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
$upd->execute([$row['id']]);
$publishedCount++;
record_translation_version(
$pdo,
(int)$row['document_id'],
$row['lang'],
$finalTitle,
$finalSlug,
$row['scheduled_content'] !== '' ? $row['scheduled_content'] : $row['content'],
$row['scheduled_note'],
'published',
t('db.scheduled_publish_note')
);
enqueue_webhook($row, [
'event_type' => 'legal_text.updated',
'document_id' => (int)$row['document_id'],
'slug' => $finalSlug,
'lang' => $row['lang'],
'title' => $finalTitle,
'status' => 'published',
'change_note' => $row['scheduled_note'],
'was_scheduled' => true,
'effective_date' => date('c'),
'updated_at' => date('c')
]);
}
} catch (Throwable $e) {
}
return ['published_count' => $publishedCount];
}
function svg_icon(string $name, string $extraClass = '', int $size = 16): string {
$icons = [
'check' => '
{{company_name}}
{{address}}
{{representative}}
\nE-Mail: {{email}}
Telefon: {{phone}}
{{register_info}}
" ], 'privacy' => [ 'title' => 'Datenschutzerklärung', 'slug' => 'datenschutz', 'is_required' => 1, 'checked' => true, 'content' => "Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen.
\n{{company_name}}
{{address}}
E-Mail: {{email}}
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber.
" ], 'terms_b2c' => [ 'title' => 'AGB (Endkunden / B2C)', 'slug' => 'agb-b2c', 'is_required' => 0, 'checked' => false, 'content' => "Für alle Verträge mit Verbrauchern über die Angebote von {{company_name}} gelten nachfolgende Bedingungen.
" ], 'terms_b2b' => [ 'title' => 'AGB (Geschäftskunden / B2B)', 'slug' => 'agb-b2b', 'is_required' => 0, 'checked' => false, 'content' => "Diese Geschäftsbedingungen gelten ausschließlich für Geschäftsbeziehungen mit Unternehmern, juristischen Personen des öffentlichen Rechts oder öffentlich-rechtlichen Sondervermögen.
" ], 'cookies' => [ 'title' => 'Cookie-Richtlinie', 'slug' => 'cookie-richtlinie', 'is_required' => 0, 'checked' => false, 'content' => "Unsere Website verwendet technisch notwendige Cookies, um grundlegende Funktionen zu gewährleisten.
" ], 'revocation' => [ 'title' => 'Widerrufsbelehrung', 'slug' => 'widerruf', 'is_required' => 0, 'checked' => false, 'content' => "Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.
" ] ]; } /** * Findet die passendste Standard-Referenzvorlage für einen gegebenen doc_type-Slug * (z.B. 'impressum', 'datenschutz', 'agb-b2c', ...). Fällt auf null zurück, * wenn kein Standardtyp passt (Einlesemodus BETA ist nur für Standardtypen gedacht). */ function find_reference_template_for_slug(string $docTypeSlug): ?array { foreach (get_standard_legal_templates() as $tpl) { if ($tpl['slug'] === $docTypeSlug) { return $tpl; } } return null; } /** Grenzwert für Rohtext-Import (BETA), gegen versehentliche Riesig-Uploads/Fetches. */ const AI_IMPORT_MAX_RAW_BYTES = 200000; /** * Prüft, ob eine per URL angegebene Adresse öffentlich (nicht privat/lokal) auflöst, * als SSRF-Schutz für den serverseitigen Fetch im Einlesemodus (BETA). */ function is_public_http_url(string $url): bool { $parts = parse_url($url); if (!$parts || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) || empty($parts['host'])) { return false; } $host = $parts['host']; $ips = []; if (filter_var($host, FILTER_VALIDATE_IP)) { $ips[] = $host; } else { $resolved = @gethostbynamel($host); if ($resolved === false || empty($resolved)) { return false; } $ips = $resolved; } foreach ($ips as $ip) { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return false; } } return true; } /** * Loest eine Location-Header-Angabe (absolut oder relativ) gegen die zuletzt * angefragte URL auf, damit ein Redirect-Ziel vor dem naechsten Fetch erneut * durch is_public_http_url() geprueft werden kann. */ function resolve_redirect_url(string $baseUrl, string $location): ?string { $location = trim($location); if ($location === '') { return null; } $locParts = parse_url($location); if ($locParts === false) { return null; } if (!empty($locParts['scheme'])) { return $location; } $base = parse_url($baseUrl); if (!$base || empty($base['host'])) { return null; } $scheme = $base['scheme'] ?? 'https'; if (str_starts_with($location, '//')) { return $scheme . ':' . $location; } $host = $base['host'] . (isset($base['port']) ? ':' . $base['port'] : ''); $locPath = $locParts['path'] ?? ''; if ($locPath === '') { // Query-only ("?x=1") oder Fragment-only ("#abc") Redirect: der Pfad der zuletzt // angefragten URL bleibt unveraendert. Fehlt dem Location-Wert selbst eine Query // (reines Fragment), gilt weiterhin die Query der Basis-URL -- sonst wuerde ein // Fragment-only-Redirect die bisherige Query stillschweigend verwerfen (RFC 3986 5.3). $path = $base['path'] ?? '/'; $query = $locParts['query'] ?? ($base['query'] ?? null); } elseif (str_starts_with($locPath, '/')) { $path = $locPath; $query = $locParts['query'] ?? null; } else { $basePath = $base['path'] ?? '/'; $dir = str_ends_with($basePath, '/') ? $basePath : (dirname($basePath) . '/'); $path = $dir . $locPath; $query = $locParts['query'] ?? null; } $result = $scheme . '://' . $host . $path; if ($query !== null) { $result .= '?' . $query; } return $result; } /** * Holt den Rohtext für den KI-Einlesemodus (BETA) entweder von einer URL oder aus * einer hochgeladenen Datei (.html/.htm/.txt direkt, .pdf via pdftotext falls vorhanden). */ function fetch_raw_legal_text(string $source, string $type): array { if ($type === 'url') { $url = trim($source); if (!function_exists('curl_init')) { return ['success' => false, 'error' => t('db.deepl.curl_missing')]; } // CURLOPT_FOLLOWLOCATION wuerde is_public_http_url() nur auf die urspruengliche URL // anwenden und dann jedem Redirect blind folgen (auch auf private/loopback-Adressen) // -- deshalb Redirects hier manuell verfolgen und JEDES Ziel erneut validieren, statt // der SSRF-Pruefung nur einmalig am Anfang zu vertrauen. $maxRedirects = 3; $body = null; $httpCode = null; $err = ''; for ($hop = 0; $hop <= $maxRedirects; $hop++) { if (!is_public_http_url($url)) { return ['success' => false, 'error' => t('db.ai_import.invalid_or_private_url')]; } $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 20, CURLOPT_USERAGENT => 'Paragrafy-Import-Bot/1.0', CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); $err = curl_error($ch); curl_close($ch); if ($response === false || $err) { return ['success' => false, 'error' => t('db.ai_import.fetch_failed', ['error' => $err])]; } if (in_array($httpCode, [301, 302, 303, 307, 308], true)) { $headers = substr($response, 0, $headerSize); if (!preg_match('/^Location:\s*(.+?)\s*$/mi', $headers, $m)) { return ['success' => false, 'error' => t('db.ai_import.fetch_http_error', ['code' => $httpCode])]; } $nextUrl = resolve_redirect_url($url, $m[1]); if ($nextUrl === null) { return ['success' => false, 'error' => t('db.ai_import.fetch_http_error', ['code' => $httpCode])]; } $url = $nextUrl; continue; } $body = substr($response, $headerSize); break; } if ($body === null) { return ['success' => false, 'error' => t('db.ai_import.fetch_http_error', ['code' => $httpCode ?? 0])]; } if ($httpCode < 200 || $httpCode >= 300) { return ['success' => false, 'error' => t('db.ai_import.fetch_http_error', ['code' => $httpCode])]; } $text = html_entity_decode(strip_tags(preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', ' ', $body)), ENT_QUOTES, 'UTF-8'); $text = trim(preg_replace('/[ \t]+/', ' ', preg_replace('/\n{3,}/', "\n\n", $text))); if ($text === '') { return ['success' => false, 'error' => t('db.ai_import.empty_content')]; } return ['success' => true, 'text' => substr($text, 0, AI_IMPORT_MAX_RAW_BYTES)]; } if ($type === 'file') { $tmpPath = $source; if (!is_uploaded_file($tmpPath) && !is_readable($tmpPath)) { return ['success' => false, 'error' => t('db.ai_import.upload_failed')]; } if (filesize($tmpPath) > AI_IMPORT_MAX_RAW_BYTES * 4) { return ['success' => false, 'error' => t('db.ai_import.file_too_large')]; } $ext = strtolower(pathinfo($_FILES['import_file']['name'] ?? '', PATHINFO_EXTENSION)); $raw = ''; if (in_array($ext, ['html', 'htm'], true)) { $raw = html_entity_decode(strip_tags(preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', ' ', (string)file_get_contents($tmpPath))), ENT_QUOTES, 'UTF-8'); } elseif ($ext === 'txt') { $raw = (string)file_get_contents($tmpPath); } elseif ($ext === 'pdf') { if (function_exists('shell_exec') && trim((string)@shell_exec('which pdftotext 2>/dev/null')) !== '') { $raw = (string)@shell_exec('pdftotext ' . escapeshellarg($tmpPath) . ' - 2>/dev/null'); } if (trim($raw) === '') { return ['success' => false, 'error' => t('db.ai_import.pdf_extraction_unavailable')]; } } else { return ['success' => false, 'error' => t('db.ai_import.unsupported_file_type')]; } $raw = trim(preg_replace('/[ \t]+/', ' ', preg_replace('/\n{3,}/', "\n\n", $raw))); if ($raw === '') { return ['success' => false, 'error' => t('db.ai_import.empty_content')]; } return ['success' => true, 'text' => substr($raw, 0, AI_IMPORT_MAX_RAW_BYTES)]; } return ['success' => false, 'error' => t('db.ai_import.unsupported_file_type')]; } /** * KI-gestützter Einlesemodus (BETA): überführt einen alten Rechtstext in die * Paragrafy-Zielstruktur inkl. {{platzhalter}} und liefert die im Text erkannten * Firmendaten als Vorschlag zurück (werden NICHT automatisch übernommen). */ function import_legal_text_with_ai(string $rawText, string $docTypeSlug, string $provider, string $apiKey): array { if (empty(trim($apiKey))) { return ['success' => false, 'error' => t('db.ai_import.no_api_key')]; } if (!function_exists('curl_init')) { return ['success' => false, 'error' => t('db.deepl.curl_missing')]; } $reference = find_reference_template_for_slug($docTypeSlug); if (!$reference) { return ['success' => false, 'error' => t('db.ai_import.unsupported_doc_type')]; } $allowedTokens = ['{{company_name}}', '{{address}}', '{{email}}', '{{phone}}', '{{representative}}', '{{register_info}}']; $prompt = "Du bekommst den rohen Text einer bestehenden Rechtstext-Seite (z.B. aus einem alten Impressum) sowie eine Ziel-Referenzstruktur im HTML-Format.\n\n" . "AUFGABE:\n" . "1. Erzeuge daraus HTML im Stil der Referenzstruktur (gleiche Überschriften-Ebenen und Abschnitte), aber mit den inhaltlichen Angaben aus dem Rohtext.\n" . "2. Ersetze konkrete Firmendaten im HTML-Fließtext IMMER durch genau diese Platzhalter-Tokens (keine anderen erfinden): " . implode(', ', $allowedTokens) . "\n" . "3. Extrahiere zusätzlich die erkannten Rohwerte für diese Felder aus dem Text: company_name, address, email, phone, representative, register_info. Wenn ein Feld nicht im Text vorkommt, liefere einen leeren String.\n" . "4. Gib AUSSCHLIESSLICH ein valides JSON-Objekt zurück, keinen weiteren Text, kein Markdown, in exakt dieser Form:\n" . '{"content": ".php). Separate from lang_meta(), which lists the languages a
* *document's content* can be translated into.
*/
function ui_locales(): array {
return [
'de' => ['flag' => '🇩🇪', 'label' => 'Deutsch'],
'en' => ['flag' => '🇬🇧', 'label' => 'English'],
];
}
/**
* Kleiner DE/EN-Sprachumschalter fuer Sidebar und Login-Bildschirme. Zeigt
* bewusst den Sprachcode als Text statt der Flaggen-Emoji aus ui_locales() --
* Regional-Indicator-Flaggen werden auf vielen Systemen (u.a. Windows ohne
* aktuelle Emoji-Schriftart) nur als Laendercode-Buchstaben gerendert, was bei
* "en" als "GB" statt "EN" erscheint und die Sprachwahl eher verwirrt als
* hilft. Der Link haengt ?locale= an die aktuelle URL (Pfad + bestehende
* Query-Parameter) an, damit ein Sprachwechsel nicht von der aktuellen Seite
* wegspringt.
*/
function render_locale_switch(bool $floating = false): string {
$curLocale = current_locale();
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/admin', PHP_URL_PATH) ?: '/admin';
ob_start();
if ($floating) { ?>
$meta): ?>
= htmlspecialchars(strtoupper($code)) ?>
Text Map.
* Neues Format ist ein JSON-Objekt (z.B. {"de": "...", "en": "..."}); ein
* alter Plain-String wird der Projekt-Primärsprache zugeordnet, damit
* bestehende Projekte ihren Text nicht verlieren.
*/
function cookie_banner_text_map(array $project): array {
$raw = trim($project['cookie_banner_text'] ?? '');
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
return [($project['primary_lang'] ?? 'de') => $raw];
}
/**
* Translates a UI string key using lang/de.php as the exhaustive baseline,
* overlaid with lang/.php when available. :placeholder params are
* interpolated via strtr; pass $count to select a '.plural' variant.
*/
function t(string $key, array $params = [], ?int $count = null): string {
static $strings = [];
$locale = current_locale();
if (!isset($strings[$locale])) {
$baseline = require PARAGRAFY_DIR . '/lang/de.php';
if ($locale !== 'de') {
$override = @include PARAGRAFY_DIR . "/lang/{$locale}.php";
if (is_array($override)) {
$baseline = array_merge($baseline, $override);
}
}
$strings[$locale] = $baseline;
}
$lookupKey = ($count !== null && $count !== 1) ? $key . '.plural' : $key;
$raw = $strings[$locale][$lookupKey] ?? $strings[$locale][$key] ?? $key;
if (!$params) {
return $raw;
}
$replacements = [];
foreach ($params as $name => $value) {
$replacements[':' . $name] = (string)$value;
}
return strtr($raw, $replacements);
}
/**
* Der primaere Admin-Login (Legacy-Master-Passwort bzw. SSO) hat keine
* paragrafy_user_id in der Session -- nur er darf Benutzer verwalten.
*/
function current_user_is_primary_admin(): bool {
return empty($_SESSION['paragrafy_user_id']);
}
/**
* Ohne paragrafy_user_id (primaerer Admin) oder ohne jede Zeile in
* user_projects (Bestandsnutzer vor Einfuehrung der Zuordnung) gilt
* unbeschraenkter Zugriff -- erst eine explizite Zuordnung schraenkt ein.
*/
function current_user_accessible_project_ids(PDO $db): array {
$allIds = array_column($db->query("SELECT id FROM projects")->fetchAll(), 'id');
$userId = (int)($_SESSION['paragrafy_user_id'] ?? 0);
if ($userId <= 0) {
return array_map('intval', $allIds);
}
$stmt = $db->prepare("SELECT project_id FROM user_projects WHERE user_id = ?");
$stmt->execute([$userId]);
$assigned = array_column($stmt->fetchAll(), 'project_id');
if (empty($assigned)) {
return array_map('intval', $allIds);
}
return array_map('intval', $assigned);
}
function user_can_access_project(PDO $db, int $projectId): bool {
return in_array($projectId, current_user_accessible_project_ids($db), true);
}
/** Returns (and lazily creates) this session's CSRF token. */
function csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/** Hidden form field carrying the current session's CSRF token. */
function csrf_field(): string {
return '';
}
/**
* Call at the top of any POST-handling section (after the auth/session gate)
* to reject state-changing requests without a valid CSRF token. Accepts the
* token either as a POST field (regular forms, FormData-based AJAX) or as the
* X-CSRF-Token header (fetch calls that don't submit FormData).
*/
function require_csrf(): void {
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
return;
}
$given = (string)($_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if ($given === '' || !hash_equals(csrf_token(), $given)) {
http_response_code(403);
echo htmlspecialchars(t('admin.common.access_denied'));
exit;
}
}
/** Strips CR/LF from a value before it is embedded in a mail/SMTP header, preventing header/command injection. */
function strip_header_injection(string $v): string {
return trim(preg_replace('/[\r\n]+/', ' ', $v));
}
/** Escapes a CSV cell against formula/CSV injection (Excel etc. treat a leading =+-@ as a formula). */
function csv_safe(?string $v): string {
$v = (string)$v;
return preg_match('/^[=+\-@]/', $v) ? "'" . $v : $v;
}
function log_audit(?int $projectId, string $projectName, string $action): void {
try {
$db = get_db();
$userName = $_SESSION['paragrafy_user_name'] ?? 'Admin';
$stmt = $db->prepare("INSERT INTO audit_log (project_id, project_name, user_name, action) VALUES (?, ?, ?, ?)");
$stmt->execute([$projectId, $projectName, $userName, $action]);
} catch (Throwable $e) {
}
}
function record_translation_version(PDO $db, int $documentId, string $lang, string $title, string $slug, string $content, string $changeNote, string $status, ?string $userName = null): void {
try {
$stmt = $db->prepare("INSERT INTO translation_versions (document_id, lang, title, slug, content, change_note, status, user_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$documentId, $lang, $title, $slug, $content, $changeNote, $status, $userName ?? ($_SESSION['paragrafy_user_name'] ?? 'Admin')]);
} catch (Throwable $e) {
}
}
/**
* Truncates an IP address to network level so it can never be traced back to
* an individual visitor -- IPv4 loses its last octet, IPv6 keeps only the
* first 48 bits. This is the same "IP anonymization" approach used by
* Google Analytics/most consent-management tools: enough to remain a
* meaningful audit artifact (proves a request came from a given network at a
* given time) without storing personal data. Unparseable input returns ''.
*/
function anonymize_ip(string $ip): string {
// IPv4-mapped IPv6 (e.g. "::ffff:203.0.113.5", seen behind some proxies) would otherwise
// fall through to the IPv6 branch below and get zeroed past its embedded IPv4 bytes --
// unmask it first so it's anonymized the same way a plain IPv4 address would be.
if (str_starts_with($ip, '::ffff:') && filter_var(substr($ip, 7), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ip = substr($ip, 7);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$parts = explode('.', $ip);
if (count($parts) === 4) {
$parts[3] = '0';
return implode('.', $parts);
}
return '';
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$bin = @inet_pton($ip);
if ($bin !== false && strlen($bin) === 16) {
$bin = substr($bin, 0, 6) . str_repeat("\0", 10);
$anon = inet_ntop($bin);
return $anon !== false ? $anon : '';
}
return '';
}
return '';
}
/**
* Inserts one DSGVO consent-proof record (see /consent.js + /api/consent-log
* in index.php). Never stores the raw IP -- only anonymize_ip()'s output.
*/
function log_consent(PDO $db, int $projectId, string $consentId, string $action, string $lang, string $textHash, string $ip, string $userAgent): void {
try {
$stmt = $db->prepare("INSERT INTO consent_logs (project_id, consent_id, action, lang, banner_text_hash, ip_anonymized, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$projectId, $consentId, $action, $lang, $textHash, anonymize_ip($ip), substr($userAgent, 0, 255)]);
} catch (Throwable $e) {
}
}
/**
* Deletes consent_logs rows past each project's own consent_log_retention_days
* (0 = keep forever). Called from run_scheduled_backup() so it rides the
* existing daily /api/cron/backup cadence instead of needing its own cron.
*/
function cleanup_expired_consent_logs(PDO $db): int {
try {
$stmt = $db->prepare("
DELETE FROM consent_logs WHERE id IN (
SELECT cl.id FROM consent_logs cl
JOIN projects p ON cl.project_id = p.id
WHERE p.consent_log_retention_days > 0
AND cl.created_at < datetime('now', '-' || p.consent_log_retention_days || ' days')
)
");
$stmt->execute();
return $stmt->rowCount();
} catch (Throwable $e) {
return 0;
}
}
const LOGIN_MAX_ATTEMPTS = 5;
const LOGIN_WINDOW_MINUTES = 15;
function get_client_ip(): string {
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
/** Seconds to wait, or 0 if login attempts are currently allowed. */
function login_rate_limit_wait(PDO $db, string $identifier): int {
return rate_limit_wait($db, $identifier, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MINUTES);
}
/**
* Generic reusable rate limiter built on the same login_attempts table as the
* admin login throttle (identifier is a free-form string, so callers just
* pick a distinct prefix, e.g. "consent:{project_id}:{ip}" or "api:{ip}").
* Seconds to wait, or 0 if a request is currently allowed.
*/
function rate_limit_wait(PDO $db, string $identifier, int $maxAttempts, int $windowMinutes): int {
try {
$stmt = $db->prepare("SELECT created_at FROM login_attempts WHERE identifier = ? AND created_at >= datetime('now', ?) ORDER BY created_at ASC");
$stmt->execute([$identifier, '-' . $windowMinutes . ' minutes']);
$rows = $stmt->fetchAll();
if (count($rows) < $maxAttempts) {
return 0;
}
$oldest = strtotime($rows[0]['created_at']);
$waitUntil = $oldest + ($windowMinutes * 60);
$remaining = $waitUntil - time();
return max(0, $remaining);
} catch (Throwable $e) {
return 0;
}
}
function record_login_failure(PDO $db, string $identifier): void {
try {
$stmt = $db->prepare("INSERT INTO login_attempts (identifier) VALUES (?)");
$stmt->execute([$identifier]);
$db->prepare("DELETE FROM login_attempts WHERE created_at < datetime('now', '-1 day')")->execute();
} catch (Throwable $e) {
}
}
function clear_login_failures(PDO $db, string $identifier): void {
try {
$stmt = $db->prepare("DELETE FROM login_attempts WHERE identifier = ?");
$stmt->execute([$identifier]);
} catch (Throwable $e) {
}
}
/**
* Copies the SQLite database into /backups and prunes anything older than
* BACKUP_RETENTION_DAYS. Meant to be triggered daily via an external cron
* hitting /api/cron/backup (the same pattern as the audit-mail cron).
*/
function run_scheduled_backup(): array {
try {
cleanup_expired_consent_logs(get_db());
if (!file_exists(DB_FILE)) {
return ['success' => false, 'error' => t('db.backup.no_database')];
}
if (!is_dir(BACKUP_DIR) && !mkdir(BACKUP_DIR, 0755, true) && !is_dir(BACKUP_DIR)) {
return ['success' => false, 'error' => t('db.backup.dir_creation_failed')];
}
$filename = 'paragrafy_backup_' . date('Y-m-d_His') . '.sqlite';
if (!copy(DB_FILE, BACKUP_DIR . '/' . $filename)) {
return ['success' => false, 'error' => t('db.backup.copy_failed')];
}
$cutoff = time() - (BACKUP_RETENTION_DAYS * 24 * 60 * 60);
$deleted = 0;
foreach (glob(BACKUP_DIR . '/paragrafy_backup_*.sqlite') ?: [] as $file) {
if (filemtime($file) < $cutoff) {
@unlink($file);
$deleted++;
}
}
return ['success' => true, 'file' => $filename, 'deleted_old' => $deleted];
} catch (Throwable $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
function run_audit_check(array $project, PDO $db): array {
$recipient = trim($project['audit_email_recipient'] ?? '') ?: ($project['email'] ?? '');
if (empty($recipient)) {
return ['success' => false, 'error' => t('db.audit.no_recipient')];
}
$auditMonths = (int)($project['audit_interval_months'] ?? 12);
$stmt = $db->prepare("
SELECT t.title, t.lang, 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([$project['id']]);
$docs = $stmt->fetchAll();
$now = new DateTime();
$overdue = [];
foreach ($docs as $d) {
if (!empty($d['updated_at'])) {
$updated = new DateTime($d['updated_at']);
$diffMonths = (($now->format('Y') - $updated->format('Y')) * 12) + ($now->format('m') - $updated->format('m'));
if ($diffMonths >= $auditMonths) {
$days = $now->diff($updated)->days;
$overdue[] = t('db.audit.overdue_item', ['title' => htmlspecialchars($d['title']), 'lang' => strtoupper($d['lang']), 'days' => $days, 'date' => date('d.m.Y', strtotime($d['updated_at']))]);
}
}
}
if (empty($overdue)) {
return ['success' => true, 'message' => t('db.audit.all_current')];
}
$html = "" . htmlspecialchars(t('db.audit.mail_heading')) . "
";
$html .= "" . t('db.audit.mail_intro', ['project' => htmlspecialchars($project['name']), 'domain' => htmlspecialchars($project['domain']), 'months' => $auditMonths]) . "
";
$html .= "" . implode('', $overdue) . "
";
$html .= "";
return send_smtp_mail($project, $recipient, t('db.audit.mail_subject', ['count' => count($overdue)]), $html);
}
/** Returns rolling backups newest-first as [filename, size_bytes, created_at]. */
function list_backups(): array {
$files = glob(BACKUP_DIR . '/paragrafy_backup_*.sqlite') ?: [];
$backups = [];
foreach ($files as $file) {
$backups[] = [
'filename' => basename($file),
'size' => filesize($file),
'created_at' => filemtime($file)
];
}
usort($backups, fn($a, $b) => $b['created_at'] <=> $a['created_at']);
return $backups;
}
/** Grenzwert für hochgeladene Backup-Dateien beim Restore. */
const BACKUP_UPLOAD_MAX_BYTES = 200 * 1024 * 1024;
/**
* Prüft, ob eine hochgeladene Datei eine valide Paragrafy-SQLite-Datenbank ist, bevor sie beim
* Restore die aktuelle Datenbank ersetzt. Öffnet dazu eine separate PDO-Instanz, um die
* Haupt-Connection (get_db()) nicht zu berühren.
*/
function is_valid_sqlite_signature(string $path): bool {
$handle = @fopen($path, 'rb');
$header = $handle ? fread($handle, 16) : '';
if ($handle) fclose($handle);
return $header === "SQLite format 3\000";
}
function validate_backup_upload(string $tmpPath): array {
if (!is_uploaded_file($tmpPath) && !is_readable($tmpPath)) {
return ['success' => false, 'error' => t('db.restore.upload_failed')];
}
if (filesize($tmpPath) > BACKUP_UPLOAD_MAX_BYTES) {
return ['success' => false, 'error' => t('db.restore.file_too_large')];
}
if (!is_valid_sqlite_signature($tmpPath)) {
return ['success' => false, 'error' => t('db.restore.not_sqlite')];
}
try {
$testPdo = new PDO('sqlite:' . $tmpPath);
$testPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$tables = $testPdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN);
$required = ['projects', 'doc_types', 'documents', 'translations'];
$missing = array_diff($required, $tables);
if (!empty($missing)) {
return ['success' => false, 'error' => t('db.restore.missing_tables', ['tables' => implode(', ', $missing)])];
}
$projectCount = (int)$testPdo->query("SELECT COUNT(*) FROM projects")->fetchColumn();
$testPdo = null;
return ['success' => true, 'project_count' => $projectCount];
} catch (Throwable $e) {
return ['success' => false, 'error' => t('db.restore.invalid_database')];
}
}
/**
* Ersetzt die aktuelle Datenbank durch eine hochgeladene Backup-Datei. Legt vorher über
* run_scheduled_backup() eine Sicherheitskopie der bisherigen Datenbank an und bringt die neue
* Datenbank direkt im selben Request per ensure_schema_migrations() auf den aktuellen Schema-Stand
* — auch wenn das Backup aus einer älteren Paragrafy-Version stammt und ihm neuere Spalten/Tabellen
* fehlen.
*/
function restore_database_from_upload(string $tmpPath): array {
$validation = validate_backup_upload($tmpPath);
if (!$validation['success']) {
return $validation;
}
$safetyBackup = run_scheduled_backup();
if (!copy($tmpPath, DB_FILE)) {
return ['success' => false, 'error' => t('db.restore.copy_failed')];
}
@unlink($tmpPath);
try {
$newPdo = new PDO('sqlite:' . DB_FILE);
$newPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$newPdo->exec("PRAGMA foreign_keys = ON;");
ensure_schema_migrations($newPdo);
$newPdo = null;
} catch (Throwable $e) {
return ['success' => false, 'error' => t('db.restore.migration_failed', ['error' => $e->getMessage()])];
}
$projectLimit = get_project_limit();
$overLimit = $projectLimit !== null && $validation['project_count'] > $projectLimit;
return [
'success' => true,
'project_count' => $validation['project_count'],
'safety_backup' => $safetyBackup['success'] ?? false,
'over_limit' => $overLimit,
'project_limit' => $projectLimit,
];
}
/**
* Exportiert nur die Rechtsinhalte eines einzelnen Projekts (projects-Zeile, doc_types,
* documents, translations, translation_versions) als eigenständige SQLite-Datei — im Gegensatz
* zu restore_database_from_upload() betrifft ein späterer Import damit gezielt nur dieses eine
* Projekt, nicht die ganze Zielinstanz.
*/
function export_project_backup(PDO $db, int $projectId): array {
$tmpFile = tempnam(sys_get_temp_dir(), 'pgexp_');
if ($tmpFile === false) {
return ['success' => false, 'error' => t('db.project_export.tmp_failed')];
}
@unlink($tmpFile);
try {
$stmt = $db->prepare("SELECT * FROM projects WHERE id = ?");
$stmt->execute([$projectId]);
$projectRow = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$projectRow) {
return ['success' => false, 'error' => t('db.project_export.project_not_found')];
}
$docTypeStmt = $db->prepare("SELECT DISTINCT dt.* FROM doc_types dt JOIN documents d ON d.doc_type_id = dt.id WHERE d.project_id = ?");
$docTypeStmt->execute([$projectId]);
$docTypeRows = $docTypeStmt->fetchAll(PDO::FETCH_ASSOC);
$docStmt = $db->prepare("SELECT * FROM documents WHERE project_id = ?");
$docStmt->execute([$projectId]);
$documentRows = $docStmt->fetchAll(PDO::FETCH_ASSOC);
$documentIds = array_column($documentRows, 'id');
$translationRows = [];
$versionRows = [];
if (!empty($documentIds)) {
$inPlaceholders = implode(',', array_fill(0, count($documentIds), '?'));
$trStmt = $db->prepare("SELECT * FROM translations WHERE document_id IN ($inPlaceholders)");
$trStmt->execute($documentIds);
$translationRows = $trStmt->fetchAll(PDO::FETCH_ASSOC);
$vStmt = $db->prepare("SELECT * FROM translation_versions WHERE document_id IN ($inPlaceholders)");
$vStmt->execute($documentIds);
$versionRows = $vStmt->fetchAll(PDO::FETCH_ASSOC);
}
// Ab hier eine komplett unabhängige Connection auf die neue, leere Export-Datei —
// bewusst KEIN ATTACH DATABASE auf DB_FILE, das kollidiert mit der bereits offenen
// Haupt-Connection ($db) und führt zu "database is locked".
$exportPdo = new PDO('sqlite:' . $tmpFile);
$exportPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$exportPdo->exec("
CREATE TABLE projects (
id INTEGER PRIMARY KEY, domain TEXT UNIQUE NOT NULL, name TEXT NOT NULL,
primary_lang TEXT DEFAULT 'de', active_languages TEXT DEFAULT 'de,en',
brand_color TEXT DEFAULT '#F0A63C', logo_url TEXT DEFAULT '',
deepl_api_key TEXT DEFAULT '', webhook_url TEXT DEFAULT '', webhook_secret TEXT DEFAULT '',
audit_interval_months INTEGER DEFAULT 12, smtp_host TEXT DEFAULT '', smtp_port INTEGER DEFAULT 587,
smtp_user TEXT DEFAULT '', smtp_pass TEXT DEFAULT '', smtp_secure TEXT DEFAULT 'tls',
smtp_from TEXT DEFAULT '', audit_email_recipient TEXT DEFAULT '',
cookie_banner_enabled INTEGER DEFAULT 0, cookie_banner_text TEXT DEFAULT '',
consent_logging_enabled INTEGER DEFAULT 0, consent_log_retention_days INTEGER DEFAULT 1095,
ai_provider TEXT DEFAULT '', ai_api_key TEXT DEFAULT '',
company_name TEXT DEFAULT '', address TEXT DEFAULT '', email TEXT DEFAULT '',
phone TEXT DEFAULT '', representative TEXT DEFAULT '', register_info TEXT DEFAULT '',
settings_updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
settings_version INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE doc_types (
id INTEGER PRIMARY KEY, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL,
description TEXT DEFAULT '', is_required INTEGER DEFAULT 1
);
CREATE TABLE documents (
id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL, doc_type_id INTEGER NOT NULL
);
CREATE TABLE translations (
id INTEGER PRIMARY KEY, document_id INTEGER NOT NULL, lang TEXT NOT NULL,
title TEXT NOT NULL, slug TEXT NOT NULL, content TEXT NOT NULL,
previous_content TEXT DEFAULT '', status TEXT DEFAULT 'draft', change_note TEXT DEFAULT '',
source_hash TEXT DEFAULT '', scheduled_at DATETIME DEFAULT NULL, scheduled_title TEXT DEFAULT '',
scheduled_slug TEXT DEFAULT '', scheduled_content TEXT DEFAULT '', scheduled_note TEXT DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE translation_versions (
id INTEGER PRIMARY KEY, document_id INTEGER NOT NULL, lang TEXT NOT NULL,
title TEXT NOT NULL, slug TEXT NOT NULL, content TEXT NOT NULL,
change_note TEXT DEFAULT '', status TEXT DEFAULT 'published', user_name TEXT DEFAULT 'Admin',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE paragrafy_export_meta (key TEXT PRIMARY KEY, value TEXT);
");
$insertRow = function (PDO $pdo, string $table, array $row) {
$cols = array_keys($row);
$placeholders = implode(',', array_fill(0, count($cols), '?'));
$pdo->prepare("INSERT INTO $table (" . implode(',', $cols) . ") VALUES ($placeholders)")->execute(array_values($row));
};
// Secrets nie in eine herunterladbare Export-Datei schreiben -- der Export kann per Mail/
// Cloud-Speicher/USB-Stick geteilt werden, weit ausserhalb des durch Admin-Login
// geschuetzten Kontexts. Muessen nach einem Re-Import in den Projekteinstellungen neu
// gesetzt werden.
foreach (['smtp_pass', 'webhook_secret', 'ai_api_key', 'deepl_api_key'] as $secretCol) {
if (array_key_exists($secretCol, $projectRow)) {
$projectRow[$secretCol] = '';
}
}
$insertRow($exportPdo, 'projects', $projectRow);
foreach ($docTypeRows as $row) $insertRow($exportPdo, 'doc_types', $row);
foreach ($documentRows as $row) $insertRow($exportPdo, 'documents', $row);
foreach ($translationRows as $row) $insertRow($exportPdo, 'translations', $row);
foreach ($versionRows as $row) $insertRow($exportPdo, 'translation_versions', $row);
$meta = [
'export_type' => 'project',
'source_project_id' => (string)$projectId,
'exported_at' => date('c'),
'paragrafy_version' => PARAGRAFY_VERSION,
];
foreach ($meta as $k => $v) {
$insertRow($exportPdo, 'paragrafy_export_meta', ['key' => $k, 'value' => $v]);
}
$exportPdo = null;
$domainSlug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($projectRow['domain'])), '-');
$filename = 'paragrafy_project_' . $domainSlug . '_' . date('Y-m-d_His') . '.sqlite';
return ['success' => true, 'path' => $tmpFile, 'filename' => $filename];
} catch (Throwable $e) {
@unlink($tmpFile);
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Prüft eine hochgeladene Datei für den Projekt-Import: valides SQLite-Format, vorhandene
* Kerntabellen, und GENAU EIN Projekt darin (mehr wäre ein Voll-Instanz-Export und gehört zu
* restore_database_from_upload(), nicht hierher).
*/
function validate_project_upload(string $tmpPath): array {
if (!is_uploaded_file($tmpPath) && !is_readable($tmpPath)) {
return ['success' => false, 'error' => t('db.restore.upload_failed')];
}
if (filesize($tmpPath) > BACKUP_UPLOAD_MAX_BYTES) {
return ['success' => false, 'error' => t('db.restore.file_too_large')];
}
if (!is_valid_sqlite_signature($tmpPath)) {
return ['success' => false, 'error' => t('db.restore.not_sqlite')];
}
try {
$testPdo = new PDO('sqlite:' . $tmpPath);
$testPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$tables = $testPdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN);
$required = ['projects', 'doc_types', 'documents', 'translations'];
$missing = array_diff($required, $tables);
if (!empty($missing)) {
return ['success' => false, 'error' => t('db.restore.missing_tables', ['tables' => implode(', ', $missing)])];
}
$projectCount = (int)$testPdo->query("SELECT COUNT(*) FROM projects")->fetchColumn();
if ($projectCount !== 1) {
return ['success' => false, 'error' => t('db.project_import.expects_single_project', ['count' => $projectCount])];
}
$testPdo = null;
return ['success' => true];
} catch (Throwable $e) {
return ['success' => false, 'error' => t('db.restore.invalid_database')];
}
}
/**
* Führt ein hochgeladenes Projekt-Export in ein explizit ausgewähltes, bereits bestehendes
* Zielprojekt zusammen (Merge) — kein automatisches Domain-Raten und kein Anlegen neuer
* Projekte. Nur Dokumente/Übersetzungen des Zielprojekts werden aktualisiert; dessen
* Firmendaten/Settings sowie alle anderen Projekte der Instanz bleiben unverändert. doc_types
* werden per slug gegen bestehende Einträge abgeglichen (instanzweit geteilt, keine Duplikate).
*/
function import_project_backup(PDO $db, string $tmpPath, int $targetProjectId): array {
$validation = validate_project_upload($tmpPath);
if (!$validation['success']) {
return $validation;
}
$stmtCheck = $db->prepare("SELECT id FROM projects WHERE id = ?");
$stmtCheck->execute([$targetProjectId]);
if (!$stmtCheck->fetchColumn()) {
return ['success' => false, 'error' => t('db.project_import.target_not_found')];
}
run_scheduled_backup();
// Unabhängige Read-Only-Connection auf die hochgeladene Datei — bewusst KEIN
// ATTACH DATABASE auf $db, das kollidiert mit dessen bereits offener Connection.
$importPdo = new PDO('sqlite:' . $tmpPath);
$importPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
$db->beginTransaction();
$docTypeIdMap = [];
$importDocTypes = $importPdo->query("SELECT * FROM doc_types")->fetchAll(PDO::FETCH_ASSOC);
$findDocType = $db->prepare("SELECT id FROM doc_types WHERE slug = ?");
$insertDocType = $db->prepare("INSERT INTO doc_types (slug, title, description, is_required) VALUES (?, ?, ?, ?)");
foreach ($importDocTypes as $dt) {
$findDocType->execute([$dt['slug']]);
$existingDtId = $findDocType->fetchColumn();
if ($existingDtId) {
$docTypeIdMap[$dt['id']] = (int)$existingDtId;
} else {
$insertDocType->execute([$dt['slug'], $dt['title'], $dt['description'], $dt['is_required']]);
$docTypeIdMap[$dt['id']] = (int)$db->lastInsertId();
}
}
$documentIdMap = [];
$importDocuments = $importPdo->query("SELECT * FROM documents")->fetchAll(PDO::FETCH_ASSOC);
$findDoc = $db->prepare("SELECT id FROM documents WHERE project_id = ? AND doc_type_id = ?");
$insertDoc = $db->prepare("INSERT INTO documents (project_id, doc_type_id) VALUES (?, ?)");
foreach ($importDocuments as $doc) {
$mappedDocTypeId = $docTypeIdMap[$doc['doc_type_id']] ?? null;
if ($mappedDocTypeId === null) continue;
$findDoc->execute([$targetProjectId, $mappedDocTypeId]);
$existingDocId = $findDoc->fetchColumn();
if ($existingDocId) {
$documentIdMap[$doc['id']] = (int)$existingDocId;
} else {
$insertDoc->execute([$targetProjectId, $mappedDocTypeId]);
$documentIdMap[$doc['id']] = (int)$db->lastInsertId();
}
}
$importTranslations = $importPdo->query("SELECT * FROM translations")->fetchAll(PDO::FETCH_ASSOC);
$upsertTranslation = $db->prepare("
INSERT INTO translations (document_id, lang, title, slug, content, previous_content, status, change_note, source_hash, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(document_id, lang) DO UPDATE SET
title=excluded.title, slug=excluded.slug, content=excluded.content,
previous_content=excluded.previous_content, status=excluded.status,
change_note=excluded.change_note, source_hash=excluded.source_hash,
updated_at=excluded.updated_at
");
$translationsMerged = 0;
foreach ($importTranslations as $tr) {
$mappedDocId = $documentIdMap[$tr['document_id']] ?? null;
if ($mappedDocId === null) continue;
$upsertTranslation->execute([
$mappedDocId, $tr['lang'], $tr['title'], $tr['slug'], $tr['content'],
$tr['previous_content'], $tr['status'], $tr['change_note'], $tr['source_hash'],
]);
$translationsMerged++;
}
$importVersions = $importPdo->query("SELECT * FROM translation_versions")->fetchAll(PDO::FETCH_ASSOC);
$insertVersion = $db->prepare("
INSERT INTO translation_versions (document_id, lang, title, slug, content, change_note, status, user_name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
");
foreach ($importVersions as $v) {
$mappedDocId = $documentIdMap[$v['document_id']] ?? null;
if ($mappedDocId === null) continue;
$insertVersion->execute([
$mappedDocId, $v['lang'], $v['title'], $v['slug'], $v['content'],
$v['change_note'], $v['status'], $v['user_name'], $v['created_at'],
]);
}
$db->commit();
$importPdo = null;
@unlink($tmpPath);
return [
'success' => true,
'target_project_id' => $targetProjectId,
'documents_merged' => count($documentIdMap),
'translations_merged' => $translationsMerged,
];
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
return ['success' => false, 'error' => t('db.project_import.merge_failed', ['error' => $e->getMessage()])];
}
}
/**
* Shared visual theme (fonts, base tokens, admin shell) for the redesigned UI.
* $accent is the project's brand_color (hex) and drives primary buttons/links.
*/
function theme_head_tags(): string {
return ''
. ''
. '';
}
function theme_base_css(string $accent = '#F0A63C', bool $enableDarkMode = true): string {
$accent = htmlspecialchars($accent, ENT_QUOTES);
$lightAccent = $enableDarkMode ? "color-mix(in srgb, {$accent} 82%, black)" : $accent;
$lightVars = <<
:root {
{$rootVars}
}
{$themeBlock}
* { box-sizing: border-box; }
body { font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); margin: 0; }
h1, h2, h3, .heading-font { font-family: 'Plus Jakarta Sans', sans-serif; letter-spacing: -0.01em; }
a { color: var(--accent); text-decoration: none; }
a:hover { opacity: 0.85; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
.pg-topbar { height: 56px; background: var(--card); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 28px; flex-shrink: 0; }
.pg-crumb { font-size: 13.5px; color: var(--text-faint); }
.pg-crumb strong { color: var(--text); font-weight: 600; }
.pg-shell { display: flex; min-height: 100vh; }
.pg-sidebar { width: 236px; flex-shrink: 0; background: var(--card); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 20px 14px; box-sizing: border-box; position: sticky; top: 0; height: 100vh; }
.pg-logo-row { display: flex; align-items: center; gap: 9px; padding: 4px 8px 18px; }
.pg-logo-badge { width: 28px; height: 28px; border-radius: 7px; overflow: hidden; flex-shrink: 0; }
.pg-logo-badge img { width: 100%; height: 100%; display: block; }
.pg-logo-text { font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 800; font-size: 16.5px; letter-spacing: -0.01em; color: var(--text); }
.pg-proj-select { width: 100%; box-sizing: border-box; border: 1px solid var(--border); background: var(--bg); border-radius: 8px; padding: 8px 10px; font-size: 13px; font-weight: 600; color: var(--text); font-family: 'IBM Plex Sans', sans-serif; cursor: pointer; margin-bottom: 16px; }
.pg-nav { display: flex; flex-direction: column; gap: 2px; padding: 0 4px; }
.pg-nav a { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 8px; cursor: pointer; font-size: 13.5px; font-weight: 600; color: var(--text-muted); }
.pg-nav a:hover { background: var(--bg); opacity: 1; }
.pg-nav a.active { background: var(--accent-bg); color: var(--accent); }
.pg-nav-dot { width: 13px; height: 13px; border-radius: 50%; border: 2px solid currentColor; box-sizing: border-box; flex-shrink: 0; opacity: .85; }
.pg-nav-grid { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 2px; width: 13px; height: 13px; flex-shrink: 0; }
.pg-nav-grid span { background: currentColor; border-radius: 2px; opacity: .85; }
.pg-sidebar-spacer { flex: 1; }
.pg-viewer-link { display: flex; align-items: center; justify-content: space-between; padding: 9px 10px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 10px; text-decoration: none; }
.pg-viewer-link:hover { background: var(--bg); opacity: 1; }
.pg-settings-link { margin-bottom: 0; }
.pg-settings-link.active { background: var(--accent-bg); color: var(--accent); }
.pg-user-row { display: flex; align-items: center; gap: 9px; padding: 6px 8px; }
.pg-user-avatar { width: 26px; height: 26px; border-radius: 50%; background: #2b2732; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
.pg-user-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pg-logout { color: var(--text-faint); font-size: 12px; }
.pg-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.pg-content { padding: 32px 32px 80px; }
.pg-footer-note { padding: 14px 32px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-faintest); line-height: 1.5; }
.pg-card { background: var(--card); border: 1px solid var(--border); border-radius: 14px; margin-bottom: 18px; }
.pg-card-pad { padding: 22px; }
.pg-card h2 { font-size: 16px; font-weight: 700; margin: 0 0 5px; }
.pg-card .pg-card-sub { font-size: 12.5px; color: var(--text-muted); margin: 0; }
.pg-kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 24px; }
@media (max-width: 980px) { .pg-kpi-grid { grid-template-columns: 1fr 1fr; } }
.pg-kpi { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; }
.pg-kpi-label { font-size: 11px; font-weight: 700; letter-spacing: .06em; color: var(--text-faint); text-transform: uppercase; margin-bottom: 10px; }
.pg-kpi-val { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 26px; font-weight: 800; }
.pg-kpi-sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
table.pg-table { width: 100%; border-collapse: collapse; }
.pg-table th { text-align: left; padding: 9px 22px; font-size: 10.5px; font-weight: 700; letter-spacing: .05em; color: var(--text-faint); text-transform: uppercase; background: var(--bg); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.pg-table td { padding: 13px 22px; border-bottom: 1px solid var(--border-soft); font-size: 13.5px; vertical-align: middle; }
.pg-table tr:hover td { background: var(--bg); }
.pg-pill { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 20px; }
.pg-pill-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.pg-pill-green { color: var(--green); background: var(--green-bg); }
.pg-pill-green .pg-pill-dot { background: #2fa06a; }
.pg-pill-amber { color: var(--amber); background: var(--amber-bg); }
.pg-pill-amber .pg-pill-dot { background: #d9a441; }
.pg-pill-red { color: var(--red); background: #FBE7EA; }
.pg-pill-red .pg-pill-dot { background: #d9536b; }
.pg-pill-muted { color: var(--text-faint); background: var(--border-soft); font-weight: 500; }
.pg-req-label { font-size: 12px; font-weight: 700; color: var(--accent); }
.pg-opt-label { font-size: 12px; font-weight: 500; color: var(--text-faint); }
.pg-icon-btn { border: 1px solid var(--border); background: var(--card); border-radius: 7px; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-muted); }
.pg-icon-btn:hover { border-color: var(--border-strong); color: var(--text); }
.pg-icon-btn.danger:hover { border-color: #d99; color: var(--red); }
.pg-copy-btn { border: none; background: transparent; padding: 2px; cursor: pointer; color: var(--text-faint); display: inline-flex; align-items: center; }
.pg-copy-btn:hover { color: var(--text-muted); }
input[type=text], input[type=password], input[type=email], input[type=number], input[type=datetime-local], textarea, select {
font-family: 'IBM Plex Sans', sans-serif; border: 1px solid var(--border-strong); border-radius: 9px; padding: 9px 12px; font-size: 13px; background: var(--card); color: var(--text);
}
label.pg-label { display: block; font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 6px; margin-top: 14px; }
.pg-hint { font-size: 11.5px; color: var(--text-faint); margin-top: 6px; }
.pg-help { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; border-radius: 50%; background: var(--border-soft); color: var(--text-faint); font-size: 10px; font-weight: 700; font-style: normal; cursor: help; margin-left: 5px; flex-shrink: 0; vertical-align: middle; }
.pg-help:hover { background: var(--border); color: var(--text-muted); }
.pg-btn { border: none; border-radius: 9px; padding: 10px 18px; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 65%, white)); box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 35%, transparent); color: #fff; font-size: 13.5px; font-weight: 700; cursor: pointer; font-family: 'IBM Plex Sans', sans-serif; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; }
.pg-btn:hover { filter: brightness(1.08); }
.pg-btn-secondary { border: 1px solid var(--border-strong); background: var(--card); border-radius: 9px; padding: 9px 15px; font-size: 13px; font-weight: 600; cursor: pointer; color: var(--text); display: inline-flex; align-items: center; gap: 6px; }
.pg-btn-secondary:hover { border-color: var(--accent); }
.pg-btn-dark { border: none; background: #17141b; color: #fff; border-radius: 7px; padding: 6px 10px; font-size: 11.5px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.pg-btn-dark:hover { background: #2b2732; }
.pg-btn-danger { border: none; background: #7f1d1d; color: #fecaca; border-radius: 8px; padding: 9px 15px; font-size: 13px; font-weight: 700; cursor: pointer; }
.pg-btn-danger:hover { background: #991b1b; }
.pg-alert { border-radius: 10px; padding: 13px 16px; margin-bottom: 24px; display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
.pg-alert-amber { background: #FCF3E1; border: 1px solid #EAD2A0; color: #6b4a13; }
.pg-alert-red { background: #FBE9EB; border: 1px solid #E9B7BE; color: #7a2431; }
.pg-alert ul { margin: 6px 0 0 18px; padding: 0; }
.pg-modal-backdrop { position: fixed; inset: 0; background: rgba(20,16,22,.5); display: none; align-items: center; justify-content: center; z-index: 300; padding: 1rem; }
.pg-modal-backdrop.show { display: flex; }
.pg-modal { background: var(--card); border-radius: 16px; padding: 32px; width: 480px; max-width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,.25); box-sizing: border-box; }
.pg-toast { position: fixed; bottom: 2rem; right: 2rem; background: #17141b; color: #fff; padding: 0.85rem 1.5rem; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.3); z-index: 99999; font-size: 0.875rem; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; transform: translateY(100px); opacity: 0; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.pg-toast.show { transform: translateY(0); opacity: 1; }
CSS;
}
/**
* Relative luminance (WCAG) of a hex color, used to pick readable button text
* against an arbitrary customer-chosen brand_color (unlike the fixed marketing
* amber, this accent can be any hue/lightness, so text color can't be hardcoded).
*/
function theme_contrast_text(string $hex): string {
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (strlen($hex) !== 6 || !ctype_xdigit($hex)) {
return '#17130d';
}
$lin = function (int $c): float {
$c /= 255;
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
};
$l = 0.2126 * $lin((int)hexdec(substr($hex, 0, 2)))
+ 0.7152 * $lin((int)hexdec(substr($hex, 2, 2)))
+ 0.0722 * $lin((int)hexdec(substr($hex, 4, 2)));
return $l > 0.45 ? '#17130d' : '#f5f1e8';
}
/**
* "§ — Ink & Paper, quiet" head tags for admin.php/editor.php ONLY.
* Self-hosted webfonts (no external font CDN in a consent-management product).
* index.php (the embedded public banner) keeps using theme_head_tags() unchanged.
*/
function theme_head_tags_admin(): string {
return ''
. '';
}
/**
* "§ — Ink & Paper, quiet" base CSS for admin.php/editor.php ONLY.
* $accent stays the project's own brand_color (customer-controlled) and drives
* --accent/--accent-bg exactly as before -- only the surrounding palette,
* radii, typography and component styling move to the new design system.
* index.php keeps calling theme_base_css() unchanged, so the public banner
* widget's look is completely unaffected by this function.
*/
function theme_base_css_admin(string $accent = '#F0A63C', bool $enableDarkMode = true): string {
$accent = htmlspecialchars($accent, ENT_QUOTES);
$btnInk = theme_contrast_text($accent);
$lightAccent = $enableDarkMode ? "color-mix(in srgb, {$accent} 82%, black)" : $accent;
$lightVars = <<
:root {
--radius: 3px; --radius-sm: 2px;
{$rootVars}
}
{$themeBlock}
* { box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); margin: 0; }
h2, h3, .heading-font { font-family: 'Inter', sans-serif; font-weight: 700; letter-spacing: -0.01em; }
h1, .pg-display { font-family: 'Fraunces', serif; font-weight: 600; letter-spacing: -0.01em; }
a { color: var(--accent); text-decoration: none; }
a:hover { opacity: 0.85; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
.pg-topbar { height: 56px; background: var(--card); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 28px; flex-shrink: 0; }
.pg-crumb { font-size: 13.5px; color: var(--text-faint); }
.pg-crumb strong { color: var(--text); font-weight: 600; }
.pg-shell { display: flex; min-height: 100vh; }
.pg-sidebar { width: 236px; flex-shrink: 0; background: var(--card); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 20px 14px; box-sizing: border-box; position: sticky; top: 0; height: 100vh; }
.pg-logo-row { display: flex; align-items: center; gap: 9px; padding: 4px 8px 18px; }
.pg-logo-badge { width: 28px; height: 28px; border-radius: var(--radius); overflow: hidden; flex-shrink: 0; }
.pg-logo-badge img { width: 100%; height: 100%; display: block; }
.pg-logo-text { font-family: 'Fraunces', serif; font-weight: 600; font-size: 16.5px; letter-spacing: -0.01em; color: var(--text); }
.pg-proj-select { width: 100%; box-sizing: border-box; border: 1px solid var(--border); background: var(--bg); border-radius: var(--radius); padding: 8px 10px; font-size: 13px; font-weight: 600; color: var(--text); font-family: 'Inter', sans-serif; cursor: pointer; margin-bottom: 16px; }
.pg-nav { display: flex; flex-direction: column; gap: 2px; padding: 0 4px; }
.pg-nav a { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: var(--radius); cursor: pointer; font-size: 13.5px; font-weight: 600; color: var(--text-muted); background-image: linear-gradient(currentColor, currentColor); background-size: 0 1px; background-position: 32px 100%; background-repeat: no-repeat; transition: background-size .15s ease; }
.pg-nav a:hover { background-color: var(--bg); opacity: 1; background-size: calc(100% - 42px) 1px; }
.pg-nav a.active { background-color: var(--accent-bg); color: var(--accent); background-size: 0 1px; }
.pg-nav-dot { width: 13px; height: 13px; border-radius: 50%; border: 2px solid currentColor; box-sizing: border-box; flex-shrink: 0; opacity: .85; }
.pg-nav-grid { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 2px; width: 13px; height: 13px; flex-shrink: 0; }
.pg-nav-grid span { background: currentColor; border-radius: 2px; opacity: .85; }
.pg-sidebar-spacer { flex: 1; }
.pg-viewer-link { display: flex; align-items: center; justify-content: space-between; padding: 9px 10px; border-radius: var(--radius); font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 10px; text-decoration: none; }
.pg-viewer-link:hover { background: var(--bg); opacity: 1; }
.pg-settings-link { margin-bottom: 0; }
.pg-settings-link.active { background: var(--accent-bg); color: var(--accent); }
.pg-user-row { display: flex; align-items: center; gap: 9px; padding: 6px 8px; }
.pg-user-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--text); color: var(--bg); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
.pg-user-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pg-logout { color: var(--text-faint); font-size: 12px; }
.pg-locale-switch { display: flex; gap: 4px; }
.pg-locale-switch a { padding: 5px 10px; border-radius: var(--radius-sm); font-size: 11.5px; font-weight: 700; letter-spacing: .03em; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); text-decoration: none; border: 1px solid var(--border); }
.pg-locale-switch a:hover { color: var(--text); background: var(--bg); opacity: 1; }
.pg-locale-switch a.active { color: var(--accent); background: var(--accent-bg); border-color: var(--accent); }
.pg-locale-switch-floating { position: fixed; top: 16px; right: 16px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 4px; z-index: 20; }
.pg-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.pg-content { padding: 32px 32px 80px; }
.pg-footer-note { padding: 14px 32px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-faintest); line-height: 1.5; }
.pg-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 18px; }
.pg-card-pad { padding: 22px; }
.pg-card h2 { font-size: 16px; font-weight: 700; margin: 0 0 5px; }
.pg-card .pg-card-sub { font-size: 12.5px; color: var(--text-muted); margin: 0; }
.pg-kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 24px; }
@media (max-width: 980px) { .pg-kpi-grid { grid-template-columns: 1fr 1fr; } }
.pg-kpi { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; }
.pg-kpi-label { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; font-weight: 600; letter-spacing: .05em; color: var(--text-faint); text-transform: uppercase; margin-bottom: 10px; }
.pg-kpi-val { font-family: 'JetBrains Mono', monospace; font-size: 24px; font-weight: 700; }
.pg-kpi-sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
table.pg-table { width: 100%; border-collapse: collapse; }
.pg-table th { text-align: left; padding: 9px 22px; font-family: 'JetBrains Mono', monospace; font-size: 10.5px; font-weight: 600; letter-spacing: .05em; color: var(--text-faint); text-transform: uppercase; background: var(--bg); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.pg-table td { padding: 13px 22px; border-bottom: 1px solid var(--border-soft); font-size: 13.5px; vertical-align: middle; }
.pg-table tr:hover td { background: var(--bg); }
.pg-pill { display: inline-flex; align-items: center; gap: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid currentColor; background: transparent; }
.pg-pill-dot { display: none; }
.pg-pill-green { color: var(--green); }
.pg-pill-amber { color: var(--amber); }
.pg-pill-red { color: var(--red); }
.pg-pill-muted { color: var(--text-faint); border-color: var(--border-strong); font-weight: 500; text-transform: none; letter-spacing: normal; font-family: 'Inter', sans-serif; }
.pg-req-label { font-size: 12px; font-weight: 700; color: var(--accent); }
.pg-opt-label { font-size: 12px; font-weight: 500; color: var(--text-faint); }
.pg-icon-btn { border: 1px solid var(--border); background: var(--card); border-radius: var(--radius); width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-muted); }
.pg-icon-btn:hover { border-color: var(--border-strong); color: var(--text); }
.pg-icon-btn.danger:hover { border-color: var(--red); color: var(--red); }
.pg-copy-btn { border: none; background: transparent; padding: 2px; cursor: pointer; color: var(--text-faint); display: inline-flex; align-items: center; }
.pg-copy-btn:hover { color: var(--text-muted); }
input[type=text], input[type=password], input[type=email], input[type=number], input[type=datetime-local], textarea, select {
font-family: 'Inter', sans-serif; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 9px 12px; font-size: 13px; color: var(--text);
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
label.pg-label { display: block; font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; color: var(--text-faint); margin-bottom: 6px; margin-top: 14px; }
.pg-hint { font-size: 11.5px; color: var(--text-faint); margin-top: 6px; }
.pg-help { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; border-radius: 50%; background: var(--border-soft); color: var(--text-faint); font-size: 10px; font-weight: 700; font-style: normal; cursor: help; margin-left: 5px; flex-shrink: 0; vertical-align: middle; }
.pg-help:hover { background: var(--border); color: var(--text-muted); }
.pg-btn { border: none; border-radius: var(--radius); padding: 10px 18px; background: var(--accent); color: var(--btn-ink); font-size: 13.5px; font-weight: 700; cursor: pointer; font-family: 'Inter', sans-serif; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; transition: transform .1s ease; }
.pg-btn:hover { background: color-mix(in srgb, var(--accent) 85%, white); }
.pg-btn:active { transform: scale(.98); }
.pg-btn-secondary { border: 1px solid var(--border-strong); background: transparent; border-radius: var(--radius); padding: 9px 15px; font-size: 13px; font-weight: 600; cursor: pointer; color: var(--text); display: inline-flex; align-items: center; gap: 6px; }
.pg-btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
.pg-btn-dark { border: 1px solid var(--border-strong); background: transparent; color: var(--text); border-radius: var(--radius-sm); padding: 6px 10px; font-size: 11.5px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.pg-btn-dark:hover { border-color: var(--accent); color: var(--accent); }
.pg-btn-danger { border: 1px solid var(--red); background: transparent; color: var(--red); border-radius: var(--radius); padding: 9px 15px; font-size: 13px; font-weight: 700; cursor: pointer; }
.pg-btn-danger:hover { background: var(--red-bg); }
.pg-alert { border-radius: var(--radius); padding: 13px 16px; margin-bottom: 24px; display: flex; align-items: flex-start; gap: 10px; font-size: 13px; border: 1px solid; }
.pg-alert-amber { background: var(--amber-bg); border-color: var(--amber); color: var(--amber); }
.pg-alert-red { background: var(--red-bg); border-color: var(--red); color: var(--red); }
.pg-alert ul { margin: 6px 0 0 18px; padding: 0; }
.pg-modal-backdrop { position: fixed; inset: 0; background: rgba(20,16,22,.5); display: none; align-items: center; justify-content: center; z-index: 300; padding: 1rem; }
.pg-modal-backdrop.show { display: flex; }
.pg-modal { background: var(--card); border-radius: var(--radius); padding: 32px; width: 480px; max-width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,.25); box-sizing: border-box; }
.pg-toast { position: fixed; bottom: 2rem; right: 2rem; background: var(--text); color: var(--bg); padding: 0.85rem 1.5rem; border-radius: var(--radius); box-shadow: 0 10px 30px rgba(0,0,0,0.3); z-index: 99999; font-size: 0.875rem; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; transform: translateY(100px); opacity: 0; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.pg-toast.show { transform: translateY(0); opacity: 1; }
CSS;
}
function render_demo_countdown_banner(): string {
if (empty(get_config()['is_demo'])) {
return '';
}
ob_start();
?>
= t('admin.common.demo_banner.text', ['time' => '10:00']) ?>
['/admin', t('admin.common.nav.dashboard'), 'grid'],
];
if (current_user_is_primary_admin()) {
$items['users'] = ['/admin/users', t('admin.common.nav.users'), 'users'];
}
if (!current_user_is_primary_admin() || admin_totp_available()) {
$items['security'] = ['/admin/security?project_id=' . $project['id'], t('admin.common.nav.security'), 'shield'];
}
$items['audit'] = ['/admin/audit?project_id=' . $project['id'], t('admin.common.nav.audit'), 'clock'];
$items['consent_log'] = ['/admin/consent-log?project_id=' . $project['id'], t('admin.common.nav.consent_log'), 'shield'];
$currentUserName = $_SESSION['paragrafy_user_name'] ?? 'Admin';
$initials = '';
foreach (preg_split('/\s+/', trim($currentUserName)) as $part) {
if ($part !== '') { $initials .= mb_strtoupper(mb_substr($part, 0, 1)); }
if (mb_strlen($initials) >= 2) break;
}
if ($initials === '') { $initials = 'A'; }
$isManagedCloud = !empty(get_config()['managed_cloud']);
ob_start();
?>