db.php
Rama main · réplica de lectura del repositorio original.
<?php
/**
* Paragrafy - Database, Helper, Scheduled Publishing, SMTP & Full-Spec Webhook Logger Core
*/
declare(strict_types=1);
// CalVer: JAHR.MONAT.BUILD - BUILD zaehlt Releases innerhalb des Monats hoch (startet bei 1).
// Siehe CHANGELOG.md fuer die Aenderungen je Version.
define('PARAGRAFY_VERSION', '2026.10.1');
define('PARAGRAFY_DIR', __DIR__);
// Where persistent data (DB, config, backups, .env) lives. Defaults to the
// code directory (bare-metal installs); set PARAGRAFY_DATA_DIR to point this
// at a mounted volume in Docker so rebuilds don't wipe your data.
define('PARAGRAFY_DATA_DIR', rtrim((string)(getenv('PARAGRAFY_DATA_DIR') ?: PARAGRAFY_DIR), '/'));
define('DB_FILE', PARAGRAFY_DATA_DIR . '/paragrafy_data.sqlite');
define('CONFIG_FILE', PARAGRAFY_DATA_DIR . '/config.php');
define('BACKUP_DIR', PARAGRAFY_DATA_DIR . '/backups');
define('BACKUP_RETENTION_DAYS', 7);
if (!is_dir(PARAGRAFY_DATA_DIR)) {
@mkdir(PARAGRAFY_DATA_DIR, 0755, true);
}
require_once __DIR__ . '/totp.php';
function load_env_file(): array {
$env = [];
$candidates = [PARAGRAFY_DATA_DIR . '/.env.local', PARAGRAFY_DATA_DIR . '/.env'];
foreach ($candidates as $file) {
if (file_exists($file)) {
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) continue;
if (str_contains($line, '=')) {
[$k, $v] = explode('=', $line, 2);
$k = trim($k);
$v = trim($v, " \t\n\r\0\x0B\"'");
$env[$k] = $v;
}
}
}
break;
}
}
return $env;
}
function is_installed(): bool {
return file_exists(CONFIG_FILE) && file_exists(DB_FILE);
}
/** Shared in-process cache backing get_config()/write_config() (see write_config() for why). */
function &config_cache_ref(): ?array {
static $cache = null;
return $cache;
}
function get_config(): array {
$cache = &config_cache_ref();
if ($cache !== null) {
return $cache;
}
if (!file_exists(CONFIG_FILE)) {
return [];
}
$cache = require CONFIG_FILE;
return $cache;
}
/**
* Writes config.php and keeps the in-process cache in sync so a get_config()
* call later in the *same* request immediately sees this write. Without this,
* two write_config() calls in one request (e.g. TOTP setup persisting the
* secret, then the recovery codes, in two separate get_config()+write_config()
* round-trips) can clobber each other: PHP's `require` re-parses the file on
* every call, but with opcache enabled the bytecode isn't guaranteed to be
* revalidated against disk within the same request/revalidate_freq window --
* the second write_config() could start from a get_config() that still
* reflects the pre-first-write state and overwrite it.
*/
/**
* Atomic read-modify-write for config.php: holds an exclusive file lock for
* the entire cycle, so two concurrent requests mutating config.php (e.g. two
* logins racing to consume the same admin TOTP step/recovery code, or two
* first-time TOTP setups racing to generate totp_encryption_key) can't
* interleave their get_config()+write_config() calls and clobber each
* other's write -- unlike two independent get_config()/write_config() calls,
* which are each individually consistent but not atomic as a pair.
*
* $mutator receives the current config (freshly read from disk under the
* lock, ignoring the in-process cache to guarantee it reflects any write
* another process made) and returns the new config to persist, or null to
* abort without writing (e.g. "the code was already consumed by someone
* else, don't touch the file"). Returns the written config, or null if the
* mutator aborted.
*/
function update_config(callable $mutator): ?array {
if (!is_dir(PARAGRAFY_DATA_DIR)) {
mkdir(PARAGRAFY_DATA_DIR, 0755, true);
}
$lockHandle = fopen(PARAGRAFY_DATA_DIR . '/config.lock', 'c');
if ($lockHandle === false || !flock($lockHandle, LOCK_EX)) {
// Fail closed, not open: falling back to an unlocked read-modify-
// write here would silently reintroduce the exact race this
// function exists to prevent (e.g. two logins consuming the same
// admin TOTP step/recovery code) for every caller that trusts a
// non-null return as proof of atomic, durable consumption. A lock
// acquisition failure is an environment problem (unwritable data
// dir, a filesystem without flock support) the operator needs to
// know about -- not something to paper over with weaker semantics.
throw new \RuntimeException('Could not acquire config.lock for an atomic config update.');
}
try {
$config = file_exists(CONFIG_FILE) ? (require CONFIG_FILE) : [];
$config = $mutator($config);
// A caller like totp_consume_for_admin_login() only trusts the
// one-time factor as consumed once update_config() returns non-null
// -- if the write itself fails (disk full, permissions), treat the
// whole mutation as if it never happened rather than letting the
// caller finalize a session/state change that never made it to disk
// (which would make the same code replayable on the next request).
if ($config === null || !write_config($config)) {
return null;
}
return $config;
} finally {
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
}
}
/** Returns false (without throwing) if the write itself failed, so callers -- especially update_config() -- can refuse to treat an unpersisted mutation as having happened. */
function write_config(array $config): bool {
if (!is_dir(PARAGRAFY_DATA_DIR)) {
mkdir(PARAGRAFY_DATA_DIR, 0755, true);
}
$content = "<?php\nreturn " . var_export($config, true) . ";\n";
if (file_put_contents(CONFIG_FILE, $content) === false) {
return false;
}
$cache = &config_cache_ref();
$cache = $config;
return true;
}
/** Returns the shared secret for the /api/cron/* endpoints, generating one on first use (self-healing for installs from before this existed). */
function ensure_cron_secret(): string {
$config = get_config();
if (!empty($config['cron_secret'])) {
return $config['cron_secret'];
}
return regenerate_cron_secret();
}
function regenerate_cron_secret(): string {
$secret = bin2hex(random_bytes(32));
// Goes through update_config() (not a bare get_config()+write_config())
// so this can't race with a concurrent TOTP config mutation (e.g. an
// admin login consuming a TOTP step) and silently clobber it or get
// clobbered by it -- both now serialize on the same config.php lock.
update_config(function (?array $config) use ($secret): array {
$config = $config ?? [];
$config['cron_secret'] = $secret;
return $config;
});
return $secret;
}
function verify_cron_secret(): bool {
$expected = ensure_cron_secret();
$given = (string)($_GET['secret'] ?? '');
return $given !== '' && hash_equals($expected, $given);
}
/**
* Optional cap on how many rows the `projects` table may hold in this
* instance, read from config.php's `project_limit` (int|null). Absent by
* default (existing installs never gained this key) -- returning null means
* unlimited, so bare self-hosted installs are unaffected. A SaaS layer that
* provisions one Paragrafy instance per account can set this via
* write_config() at provisioning time (e.g. 1 for a single-project plan, a
* configurable ceiling for an agency plan) to prevent an account from
* bypassing its plan's project limit through this instance's own /admin.
*/
function get_project_limit(): ?int {
$config = get_config();
if (!isset($config['project_limit']) || $config['project_limit'] === null || $config['project_limit'] === '') {
return null;
}
return (int)$config['project_limit'];
}
/**
* Call at the top of a /api/cron/* handler; exits with 403 JSON unless either
* ?secret= matches or the request comes from an already-logged-in admin
* session (so in-app buttons like "Prüfbericht jetzt senden" keep working
* without exposing the secret in front-end JS).
*/
function require_cron_secret(): void {
if (!empty($_SESSION['paragrafy_admin'])) {
return;
}
if (!verify_cron_secret()) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 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' => '<path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>',
'warning' => '<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4m0 4h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'clock' => '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 6v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
'calendar' => '<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
'lightning' => '<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'eye' => '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/>',
'disk' => '<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2zM17 21v-8H7v8M7 3v5h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'folder' => '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'link' => '<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
'copy' => '<rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'external' => '<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6m4-3h6v6m-11 5L21 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'mail' => '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" stroke="currentColor" stroke-width="2"/><path d="M22 6l-10 7L2 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
'print' => '<path d="M6 9V2h12v7M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2M6 14h12v8H6z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'edit' => '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'search' => '<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/><path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
'shield' => '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'sync' => '<path d="M23 4v6h-6M1 20v-6h6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'terminal' => '<polyline points="4 17 10 11 4 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="19" x2="20" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>'
];
$path = $icons[$name] ?? '';
return '<svg viewBox="0 0 24 24" width="' . $size . '" height="' . $size . '" fill="none" class="' . $extraClass . '" style="vertical-align:middle; display:inline-block;">' . $path . '</svg>';
}
function help_icon(string $text): string {
return '<span class="pg-help" tabindex="0" title="' . htmlspecialchars($text, ENT_QUOTES) . '">?</span>';
}
/**
* Allowlist HTML sanitizer for legal-text content coming from the WYSIWYG
* editor, the AI import (BETA) and DeepL translation results. Content saved
* here is later embedded verbatim into customer sites (index.php) and shown
* raw in editor.php's preview panes, so it must never carry executable
* markup (script/style/iframe/on*-handlers/javascript: URLs) -- but it does
* need to stay real HTML (headings, lists, links, tables), so htmlspecialchars()
* would be wrong here. Disallowed-but-harmless wrapper tags are unwrapped
* (their content survives as plain text/inline markup); disallowed tags that
* only make sense as executable/embedded content are dropped entirely.
*/
function sanitize_legal_html(string $html): string {
if (trim($html) === '') {
return '';
}
$allowedTags = [
'p' => [], 'br' => [], 'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [],
'h1' => [], 'h2' => [], 'h3' => [], 'h4' => [], 'h5' => [], 'h6' => [],
'ul' => [], 'ol' => [], 'li' => [],
'a' => ['href', 'target', 'rel'],
'table' => [], 'thead' => [], 'tbody' => [], 'tr' => [], 'td' => [], 'th' => [],
'span' => ['class'], 'div' => ['class'],
];
$dropEntirely = ['script', 'style', 'iframe', 'object', 'embed', 'noscript', 'svg', 'math', 'link', 'meta', 'base', 'form', 'input', 'button', 'textarea', 'select', 'option'];
$prevErrors = libxml_use_internal_errors(true);
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->loadHTML('<?xml encoding="UTF-8"?><html><body>' . $html . '</body></html>', LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($prevErrors);
$body = $doc->getElementsByTagName('body')->item(0);
if (!$body) {
return '';
}
sanitize_html_node($body, $allowedTags, $dropEntirely);
$out = '';
foreach (iterator_to_array($body->childNodes) as $child) {
$out .= $doc->saveHTML($child);
}
return $out;
}
function sanitize_html_node(DOMNode $node, array $allowedTags, array $dropEntirely): void {
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof DOMText) {
continue;
}
if (!($child instanceof DOMElement)) {
// Comments, processing instructions, doctypes etc. -- never legitimate in saved content.
$node->removeChild($child);
continue;
}
$tag = strtolower($child->nodeName);
if (in_array($tag, $dropEntirely, true)) {
$node->removeChild($child);
continue;
}
if (!isset($allowedTags[$tag])) {
// Unwrap: keep the (sanitized) children, drop just this wrapper tag.
sanitize_html_node($child, $allowedTags, $dropEntirely);
while ($child->firstChild) {
$node->insertBefore($child->firstChild, $child);
}
$node->removeChild($child);
continue;
}
$allowedAttrs = $allowedTags[$tag];
foreach (iterator_to_array($child->attributes ?? []) as $attr) {
$attrName = strtolower($attr->nodeName);
if (!in_array($attrName, $allowedAttrs, true)) {
$child->removeAttribute($attr->nodeName);
continue;
}
if (in_array($attrName, ['href', 'src'], true) && preg_match('/^\s*(javascript|data|vbscript):/i', $attr->nodeValue)) {
$child->removeAttribute($attr->nodeName);
}
}
if ($tag === 'a') {
$child->setAttribute('rel', 'noopener noreferrer nofollow');
}
sanitize_html_node($child, $allowedTags, $dropEntirely);
}
}
/**
* Waehlt aus einer Liste veroeffentlichter Uebersetzungen desselben Dokuments die beste
* Sprache: zuerst $preferredLang, dann Englisch, dann die Projekt-primary_lang, sonst die
* erste vorhandene (deckt den Fall "nur eine Sprache existiert" automatisch ab).
* $primaryLang ist dabei kein verlaesslicher Garant fuer Existenz (frei editierbares Feld),
* daher nur als Versuch in der Kette, nicht als sicherer Treffer.
*/
function pick_fallback_translation(array $candidates, string $preferredLang, string $primaryLang): ?array {
if (empty($candidates)) {
return null;
}
foreach ([$preferredLang, 'en', $primaryLang] as $lang) {
foreach ($candidates as $c) {
if ($c['lang'] === $lang) {
return $c;
}
}
}
return $candidates[0];
}
/**
* Fuehrt $sql aus und liefert die einzige Ergebniszeile zurueck -- aber nur, wenn GENAU EIN
* Treffer existiert. Bei 0 oder >=2 Treffern wird bewusst null geliefert statt zu raten (per
* LIMIT 2 reicht ein zweiter Treffer schon, um "mehrdeutig" zu erkennen, ohne alle Zeilen zu
* laden). Bei oeffentlichen Rechtstexten ist das falsche Dokument auszuliefern schlimmer als
* ein 404 -- diese Funktion setzt "nicht eindeutig = kein Treffer" konsequent durch, statt es
* an jeder Aufrufstelle einzeln (und ggf. inkonsistent) zu pruefen.
*/
function find_unambiguous_match(PDO $db, string $sql, array $params): ?array {
$stmt = $db->prepare($sql . ' LIMIT 2');
$stmt->execute($params);
$matches = $stmt->fetchAll();
return count($matches) === 1 ? $matches[0] : null;
}
/**
* Findet die oeffentlich auslieferbare Uebersetzung fuer genau die angefragte ($lang, $slug)-
* Kombination -- der Normalfall, BEVOR ueberhaupt ein Sprach-Fallback in Betracht gezogen wird.
* Historisch war das eine einzelne Query mit `t.slug = ? OR dt.slug = ?`, die (genau wie der
* urspruengliche Fallback-Entwurf) bei zwei Dokumenten mit kollidierendem Slug in derselben
* Sprache per ungeordnetem LIMIT 1 haette das falsche Dokument waehlen koennen.
*
* Aufloesung in ZWEI PRIORITAETSSTUFEN (nicht als eine kombinierte Menge -- siehe
* find_public_translation_with_fallback() fuer die Begruendung, warum dt.slug bewusst Vorrang
* vor t.slug hat, auch wenn beide vorkommen):
* 1. dt.slug (kanonisch) -- eindeutigkeitsgeprueft ueber find_unambiguous_match(), gewinnt wenn
* eindeutig, UNABHAENGIG davon, ob $slug zufaellig auch als jemandes Custom-Slug auftaucht.
* 2. Nur falls Stufe 1 nichts liefert: t.slug (benutzerdefiniert), ebenfalls eindeutigkeitsgeprueft.
*/
function find_public_translation(PDO $db, int $projectId, string $lang, string $slug): ?array {
$doc = find_unambiguous_match(
$db,
"SELECT DISTINCT d.id FROM documents d
JOIN doc_types dt ON d.doc_type_id = dt.id
JOIN translations t ON t.document_id = d.id AND t.lang = ? AND t.status = 'published'
WHERE d.project_id = ? AND dt.slug = ?",
[$lang, $projectId, $slug]
);
if (!$doc) {
$doc = find_unambiguous_match(
$db,
"SELECT DISTINCT d.id FROM translations t
JOIN documents d ON t.document_id = d.id
WHERE d.project_id = ? AND t.lang = ? AND t.slug = ? AND t.status = 'published'",
[$projectId, $lang, $slug]
);
}
if (!$doc) {
return null;
}
$stmt = $db->prepare("
SELECT t.*, d.doc_type_id, dt.title AS default_title, dt.slug AS default_slug
FROM translations t
JOIN documents d ON t.document_id = d.id
JOIN doc_types dt ON d.doc_type_id = dt.id
WHERE t.document_id = ? AND t.lang = ? AND t.status = 'published'
LIMIT 1
");
$stmt->execute([$doc['id'], $lang]);
return $stmt->fetch() ?: null;
}
/**
* Findet eine oeffentlich auslieferbare Uebersetzung fuer $slug, wenn die exakt angefragte
* $lang keine veroeffentlichte Version hat. Das Dokument wird ueber JEDE Sprache anhand des
* Slugs identifiziert, in zwei PRIORITAETSSTUFEN statt einer kombinierten Menge:
*
* 1. dt.slug (kanonisch): doc_types.slug hat ein UNIQUE-Constraint (der Doc-Type selbst ist
* projektweit eindeutig), aber documents(project_id, doc_type_id) ist NICHT UNIQUE --
* trotzdem ueber find_unambiguous_match() geprueft statt blindem LIMIT 1. Findet Stufe 1
* einen eindeutigen Treffer, WIRD ER VERWENDET, auch wenn derselbe Slug zufaellig auch als
* benutzerdefinierter Slug eines VOELLIG ANDEREN Dokuments existiert (Stufe 2 wird dann gar
* nicht erst abgefragt). Grund: der kanonische Slug ist ein strukturell garantiertes,
* admin-vergebenes Merkmal des Doc-Types -- ein zufaellig gleichlautender Custom-Slug eines
* fremden Dokuments ist so gut wie immer ein Tippfehler/Versehen der jeweiligen Redaktion,
* nicht ein ernsthafter Anspruch auf denselben Slug. Diese Prioritaet ist zugleich wichtig
* fuer render_public_overview(): deren Links zeigen IMMER auf dt.slug (siehe dort) und
* duerfen sich nicht durch eine fremde Custom-Slug-Kollision in ein 404 verwandeln lassen,
* obwohl die Uebersicht das verlinkte Dokument eindeutig kannte.
* 2. Nur falls Stufe 1 nichts liefert: t.slug (benutzerdefiniert). Der ist NICHT global eindeutig
* (nur UNIQUE(document_id, lang)) -- zwei verschiedene Dokumente KOENNEN denselben eigenen
* Slug tragen, daher ebenfalls ueber find_unambiguous_match() statt zu raten.
*/
function find_public_translation_with_fallback(PDO $db, int $projectId, string $lang, string $slug, string $primaryLang): ?array {
$doc = find_unambiguous_match(
$db,
"SELECT DISTINCT d.id FROM documents d JOIN doc_types dt ON d.doc_type_id = dt.id WHERE d.project_id = ? AND dt.slug = ?",
[$projectId, $slug]
);
if (!$doc) {
$doc = find_unambiguous_match(
$db,
"SELECT DISTINCT d.id FROM translations t JOIN documents d ON t.document_id = d.id WHERE d.project_id = ? AND t.slug = ? AND t.status = 'published'",
[$projectId, $slug]
);
}
if (!$doc) {
return null;
}
$stmt = $db->prepare("
SELECT t.*, d.doc_type_id, dt.title AS default_title, dt.slug AS default_slug
FROM translations t
JOIN documents d ON t.document_id = d.id
JOIN doc_types dt ON d.doc_type_id = dt.id
WHERE t.document_id = ? AND t.status = 'published'
");
$stmt->execute([$doc['id']]);
return pick_fallback_translation($stmt->fetchAll(), $lang, $primaryLang);
}
function replace_placeholders(string $content, array $project): string {
$map = [
'{{company_name}}' => htmlspecialchars($project['company_name'] ?? ''),
'{{address}}' => nl2br(htmlspecialchars($project['address'] ?? '')),
'{{email}}' => htmlspecialchars($project['email'] ?? ''),
'{{phone}}' => htmlspecialchars($project['phone'] ?? ''),
'{{representative}}' => htmlspecialchars($project['representative'] ?? ''),
'{{register_info}}' => htmlspecialchars($project['register_info'] ?? ''),
'{{year}}' => date('Y'),
];
return str_replace(array_keys($map), array_values($map), $content);
}
function check_unfilled_placeholders(string $content, array $project): array {
$tokens = ['{{company_name}}', '{{address}}', '{{email}}', '{{phone}}', '{{representative}}', '{{register_info}}'];
$keys = [
'{{company_name}}' => 'company_name',
'{{address}}' => 'address',
'{{email}}' => 'email',
'{{phone}}' => 'phone',
'{{representative}}' => 'representative',
'{{register_info}}' => 'register_info',
];
$unfilled = [];
foreach ($tokens as $t) {
if (str_contains($content, $t)) {
$prop = $keys[$t];
if (empty(trim($project[$prop] ?? ''))) {
$unfilled[] = $t;
}
}
}
return array_unique($unfilled);
}
/**
* Referenzstruktur der 6 Standard-Rechtstexte. Wird sowohl vom Install-Wizard
* als auch vom KI-Einlesemodus (BETA) als Ziel-Vorlage genutzt.
*/
function get_standard_legal_templates(): array {
return [
'impressum' => [
'title' => 'Impressum',
'slug' => 'impressum',
'is_required' => 1,
'checked' => true,
'content' => "<h2>Angaben gemäß § 5 DDG</h2>\n<p><strong>{{company_name}}</strong><br>{{address}}</p>\n<h3>Vertreten durch:</h3>\n<p>{{representative}}</p>\n<h3>Kontakt:</h3>\n<p>E-Mail: {{email}}<br>Telefon: {{phone}}</p>\n<h3>Registereintrag:</h3>\n<p>{{register_info}}</p>"
],
'privacy' => [
'title' => 'Datenschutzerklärung',
'slug' => 'datenschutz',
'is_required' => 1,
'checked' => true,
'content' => "<h2>1. Datenschutz auf einen Blick</h2>\n<h3>Allgemeine Hinweise</h3>\n<p>Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen.</p>\n<h3>Verantwortliche Stelle:</h3>\n<p><strong>{{company_name}}</strong><br>{{address}}<br>E-Mail: {{email}}</p>\n<h2>2. Erfassung von Daten auf dieser Website</h2>\n<p>Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber.</p>"
],
'terms_b2c' => [
'title' => 'AGB (Endkunden / B2C)',
'slug' => 'agb-b2c',
'is_required' => 0,
'checked' => false,
'content' => "<h2>1. Geltungsbereich für Verbraucher (B2C)</h2>\n<p>Für alle Verträge mit Verbrauchern über die Angebote von {{company_name}} gelten nachfolgende Bedingungen.</p>"
],
'terms_b2b' => [
'title' => 'AGB (Geschäftskunden / B2B)',
'slug' => 'agb-b2b',
'is_required' => 0,
'checked' => false,
'content' => "<h2>1. Geltungsbereich für Unternehmer (B2B)</h2>\n<p>Diese Geschäftsbedingungen gelten ausschließlich für Geschäftsbeziehungen mit Unternehmern, juristischen Personen des öffentlichen Rechts oder öffentlich-rechtlichen Sondervermögen.</p>"
],
'cookies' => [
'title' => 'Cookie-Richtlinie',
'slug' => 'cookie-richtlinie',
'is_required' => 0,
'checked' => false,
'content' => "<h2>Verwendung von Cookies</h2>\n<p>Unsere Website verwendet technisch notwendige Cookies, um grundlegende Funktionen zu gewährleisten.</p>"
],
'revocation' => [
'title' => 'Widerrufsbelehrung',
'slug' => 'widerruf',
'is_required' => 0,
'checked' => false,
'content' => "<h2>Widerrufsrecht</h2>\n<p>Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.</p>"
]
];
}
/**
* 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": "<html-string>", "fields": {"company_name": "", "address": "", "email": "", "phone": "", "representative": "", "register_info": ""}}' . "\n\n"
. "REFERENZSTRUKTUR (" . $reference['title'] . "):\n" . $reference['content'] . "\n\n"
. "ROHTEXT:\n" . $rawText;
if ($provider === 'openai') {
$result = call_openai_chat($prompt, $apiKey);
} else {
$result = call_claude_messages($prompt, $apiKey);
}
if (!$result['success']) {
return $result;
}
$jsonText = trim($result['text']);
$jsonText = preg_replace('/^```(?:json)?/i', '', $jsonText);
$jsonText = preg_replace('/```$/', '', trim($jsonText));
$decoded = json_decode(trim($jsonText), true);
if (!is_array($decoded) || !isset($decoded['content'])) {
return ['success' => false, 'error' => t('db.ai_import.invalid_ai_response')];
}
$fields = [];
foreach (['company_name', 'address', 'email', 'phone', 'representative', 'register_info'] as $f) {
$fields[$f] = trim((string)($decoded['fields'][$f] ?? ''));
}
return ['success' => true, 'content' => (string)$decoded['content'], 'fields' => $fields];
}
function call_claude_messages(string $prompt, string $apiKey): array {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api.anthropic.com/v1/messages',
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 45,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . trim($apiKey),
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'claude-sonnet-5',
'max_tokens' => 4096,
'messages' => [['role' => 'user', 'content' => $prompt]],
]),
]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false || $err) {
return ['success' => false, 'error' => t('db.ai_import.request_failed', ['error' => $err])];
}
$data = json_decode((string)$body, true);
if ($httpCode < 200 || $httpCode >= 300 || !is_array($data)) {
$apiErr = $data['error']['message'] ?? (string)$body;
return ['success' => false, 'error' => t('db.ai_import.provider_error', ['error' => $apiErr])];
}
$text = $data['content'][0]['text'] ?? '';
if ($text === '') {
return ['success' => false, 'error' => t('db.ai_import.invalid_ai_response')];
}
return ['success' => true, 'text' => $text];
}
function call_openai_chat(string $prompt, string $apiKey): array {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api.openai.com/v1/chat/completions',
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 45,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . trim($apiKey),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'gpt-4o',
'messages' => [['role' => 'user', 'content' => $prompt]],
'response_format' => ['type' => 'json_object'],
]),
]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false || $err) {
return ['success' => false, 'error' => t('db.ai_import.request_failed', ['error' => $err])];
}
$data = json_decode((string)$body, true);
if ($httpCode < 200 || $httpCode >= 300 || !is_array($data)) {
$apiErr = $data['error']['message'] ?? (string)$body;
return ['success' => false, 'error' => t('db.ai_import.provider_error', ['error' => $apiErr])];
}
$text = $data['choices'][0]['message']['content'] ?? '';
if ($text === '') {
return ['success' => false, 'error' => t('db.ai_import.invalid_ai_response')];
}
return ['success' => true, 'text' => $text];
}
/**
* Übernimmt bestätigte Firmendaten aus dem Einlesemodus (BETA) in die projects-Tabelle.
* Nur nicht-leere Felder werden geschrieben, bestehende Werte bleiben sonst erhalten.
*/
function update_project_company_fields(PDO $db, int $projectId, array $fields): void {
$allowed = ['company_name', 'address', 'email', 'phone', 'representative', 'register_info'];
$sets = [];
$params = [];
foreach ($allowed as $f) {
if (isset($fields[$f]) && trim((string)$fields[$f]) !== '') {
$sets[] = "$f = ?";
$params[] = trim((string)$fields[$f]);
}
}
if (empty($sets)) {
return;
}
// Strikt hochzaehlen statt CURRENT_TIMESTAMP (s. admin.php-Settings-Save fuer die
// ausfuehrliche Begruendung): verhindert, dass zwei Projekt-Updates in derselben Sekunde
// (z. B. Einlesemodus direkt gefolgt von einem manuellen Admin-Save) denselben
// Last-Modified-Wert erzeugen.
$sets[] = "settings_updated_at = CASE WHEN datetime('now') > settings_updated_at THEN datetime('now') ELSE datetime(settings_updated_at, '+1 second') END";
$sets[] = "settings_version = settings_version + 1";
$params[] = $projectId;
$stmt = $db->prepare("UPDATE projects SET " . implode(', ', $sets) . " WHERE id = ?");
$stmt->execute($params);
}
function send_smtp_mail(array $project, string $to, string $subject, string $bodyHtml): array {
$to = strip_header_injection($to);
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
return ['success' => false, 'error' => t('db.smtp.recipient_rejected', ['response' => 'invalid recipient'])];
}
$subject = strip_header_injection($subject);
$projectName = strip_header_injection((string)($project['name'] ?? ''));
$host = trim($project['smtp_host'] ?? '');
$port = (int)($project['smtp_port'] ?? 587);
$user = trim($project['smtp_user'] ?? '');
$pass = trim($project['smtp_pass'] ?? '');
$secure = strtolower(trim($project['smtp_secure'] ?? 'tls'));
$from = strip_header_injection(trim($project['smtp_from'] ?? '') ?: ($project['email'] ?? 'noreply@' . $project['domain']));
if (empty($host)) {
$headers = "MIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\nFrom: " . $from;
$sent = @mail($to, $subject, $bodyHtml, $headers);
return $sent ? ['success' => true] : ['success' => false, 'error' => t('db.smtp.mail_function_failed')];
}
$socketHost = ($secure === 'ssl') ? 'ssl://' . $host : $host;
$socket = @fsockopen($socketHost, $port, $errno, $errstr, 15);
if (!$socket) {
return ['success' => false, 'error' => t('db.smtp.connection_failed', ['host' => $host, 'port' => $port, 'error' => $errstr, 'errno' => $errno])];
}
$read = function() use ($socket) {
$res = '';
while ($str = fgets($socket, 515)) {
$res .= $str;
if (substr($str, 3, 1) === ' ') break;
}
return $res;
};
$write = function(string $cmd) use ($socket) {
fputs($socket, $cmd . "\r\n");
};
$read();
$write("EHLO " . gethostname());
$read();
if ($secure === 'tls') {
$write("STARTTLS");
$tlsRes = $read();
if (!str_starts_with($tlsRes, '220')) {
fclose($socket);
return ['success' => false, 'error' => t('db.smtp.starttls_failed', ['response' => $tlsRes])];
}
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
$write("EHLO " . gethostname());
$read();
}
if (!empty($user) && !empty($pass)) {
$write("AUTH LOGIN");
$read();
$write(base64_encode($user));
$read();
$write(base64_encode($pass));
$authRes = $read();
if (!str_starts_with($authRes, '235')) {
fclose($socket);
return ['success' => false, 'error' => t('db.smtp.auth_failed', ['response' => $authRes])];
}
}
$write("MAIL FROM: <$from>");
$read();
$write("RCPT TO: <$to>");
$rcptRes = $read();
if (!str_starts_with($rcptRes, '250')) {
fclose($socket);
return ['success' => false, 'error' => t('db.smtp.recipient_rejected', ['response' => $rcptRes])];
}
$write("DATA");
$read();
$headers = [
"From: " . $projectName . " <$from>",
"To: <$to>",
"Subject: =?UTF-8?B?" . base64_encode($subject) . "?=",
"MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8",
"Date: " . date('r')
];
$emailData = implode("\r\n", $headers) . "\r\n\r\n" . $bodyHtml . "\r\n.";
$write($emailData);
$dataRes = $read();
$write("QUIT");
fclose($socket);
if (str_starts_with($dataRes, '250')) {
return ['success' => true];
}
return ['success' => false, 'error' => t('db.smtp.send_failed', ['response' => $dataRes])];
}
/**
* Builds the final webhook envelope (event name + ready-to-send JSON payload)
* from a project and raw event data, applying the same field auto-completion
* (url/api_url/status/effective_date/was_scheduled) used by the spec.
*/
function build_webhook_payload(array $project, array $eventData): array {
$url = trim($project['webhook_url'] ?? '');
// Callers pass joined rows (documents/translations JOIN projects) where `id` is the
// document/translation id and `project_id` the real project id -- so project_id wins.
$projectId = (int)($project['project_id'] ?? ($project['id'] ?? 1));
$projectName = $project['name'] ?? ($project['project_name'] ?? 'Paragrafy');
$projectDomain = $project['domain'] ?? '';
$eventName = $eventData['event_type'] ?? 'legal_text.updated';
$lang = $eventData['lang'] ?? ($project['primary_lang'] ?? 'de');
$slug = $eventData['slug'] ?? '';
if (!isset($eventData['url']) && !empty($slug) && !empty($projectDomain)) {
$eventData['url'] = 'https://' . $projectDomain . '/' . $lang . '/' . $slug;
}
if (!isset($eventData['api_url']) && !empty($slug) && !empty($projectDomain)) {
$eventData['api_url'] = 'https://' . $projectDomain . '/api/' . $lang . '/' . $slug;
}
if (!isset($eventData['status'])) {
$eventData['status'] = ($eventName === 'legal_text.scheduled') ? 'scheduled' : 'published';
}
if (!isset($eventData['effective_date'])) {
$eventData['effective_date'] = $eventData['scheduled_at'] ?? ($eventData['updated_at'] ?? date('c'));
}
if (!isset($eventData['was_scheduled'])) {
$eventData['was_scheduled'] = false;
}
unset($eventData['event_type']);
$payload = json_encode([
'event' => $eventName,
'timestamp' => date('c'),
'project' => [
'id' => $projectId,
'name' => $projectName,
'domain' => $projectDomain
],
'data' => $eventData
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return [
'project_id' => $projectId,
'event_name' => $eventName,
'url' => $url,
'payload' => (string)$payload
];
}
/** Performs the actual outbound HTTP POST for one webhook payload. */
function send_webhook_http(string $url, string $payload, string $eventName, string $secret, int $timeoutSeconds = 6): array {
$secret = trim($secret);
$signature = $secret !== '' ? hash_hmac('sha256', $payload, $secret) : '';
$headers = [
'Content-Type: application/json',
'User-Agent: Paragrafy-Webhook/' . PARAGRAFY_VERSION,
'X-Paragrafy-Event: ' . $eventName
];
if ($signature !== '') {
$headers[] = 'X-Paragrafy-Signature: ' . $signature;
}
$startTime = microtime(true);
$statusCode = 0;
$responseBody = '';
$errorMessage = '';
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => $timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => min(3, $timeoutSeconds)
]);
$responseBody = (string)curl_exec($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$errorMessage = curl_error($ch);
curl_close($ch);
} else {
$errorMessage = t('db.webhook.curl_unavailable');
}
$durationMs = (int)round((microtime(true) - $startTime) * 1000);
$success = ($statusCode >= 200 && $statusCode < 300);
return [
'success' => $success,
'status_code' => $statusCode,
'duration_ms' => $durationMs,
'response' => substr($responseBody, 0, 500),
'error' => $errorMessage ?: ($success ? '' : t('db.webhook.http_status_received', ['status' => $statusCode]))
];
}
/**
* Sends a webhook immediately and logs the result. Used only for the manual
* "Test-Webhook senden" button, which needs an instant result to show the
* admin. Real content-change events should use enqueue_webhook() instead so
* a slow/unreachable customer server can never block saving a legal text.
*/
function dispatch_webhook(array $project, array $eventData): array {
$built = build_webhook_payload($project, $eventData);
if (empty($built['url']) || !is_public_http_url($built['url'])) {
return ['success' => false, 'error' => t('db.webhook.no_url_configured')];
}
$result = send_webhook_http($built['url'], $built['payload'], $built['event_name'], $project['webhook_secret'] ?? '', 6);
try {
$db = get_db();
$ins = $db->prepare("
INSERT INTO webhook_logs (project_id, event_name, url, status_code, request_payload, response_body, error_message, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
$ins->execute([
$built['project_id'],
$built['event_name'],
$built['url'],
$result['status_code'],
$built['payload'],
$result['response'],
$result['error'] ?: '',
$result['duration_ms']
]);
} catch (Throwable $e) {
}
return $result;
}
/**
* Queues a webhook instead of sending it inline -- this is what every real
* content-change trigger (publish, schedule, restore, auto-publish) should
* call. A background worker (process_webhook_queue(), run via cron) does
* the actual, potentially-slow HTTP delivery later.
*/
function enqueue_webhook(array $project, array $eventData): void {
try {
$built = build_webhook_payload($project, $eventData);
if (empty($built['url']) || !is_public_http_url($built['url'])) {
// No/invalid URL is a normal "webhook not configured" state; only log when a URL
// is set but rejected, since that is the silent-failure case.
if (!empty($built['url'])) {
error_log('Paragrafy webhook not queued: URL rejected by is_public_http_url() (project ' . $built['project_id'] . ', event ' . $built['event_name'] . ')');
}
return;
}
$db = get_db();
$stmt = $db->prepare("INSERT INTO webhook_queue (project_id, event_name, payload, status, attempts, next_attempt_at) VALUES (?, ?, ?, 'pending', 0, CURRENT_TIMESTAMP)");
$stmt->execute([$built['project_id'], $built['event_name'], $built['payload']]);
} catch (Throwable $e) {
error_log('Paragrafy enqueue_webhook failed: ' . $e->getMessage());
}
}
const WEBHOOK_MAX_ATTEMPTS = 5;
const WEBHOOK_TIMEOUT_SECONDS = 5;
/** Backoff schedule per attempt number: 1min, 5min, 15min, 60min, then 3h. */
function webhook_backoff_seconds(int $attempt): int {
$steps = [60, 300, 900, 3600, 10800];
return $steps[$attempt - 1] ?? end($steps);
}
/**
* Works through pending webhook_queue rows that are due, sending each with a
* short timeout so one unreachable customer server can't stall the batch.
* Meant to be triggered every few minutes via an external cron hitting
* /api/cron/webhooks (same pattern as the audit-mail and backup crons).
*/
function process_webhook_queue(int $limit = 20): array {
$db = get_db();
$processed = 0;
$sent = 0;
$failed = 0;
$retried = 0;
try {
$stmt = $db->prepare("SELECT * FROM webhook_queue WHERE status = 'pending' AND next_attempt_at <= datetime('now') ORDER BY created_at ASC LIMIT " . (int)$limit);
$stmt->execute();
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
$processed++;
$projStmt = $db->prepare("SELECT * FROM projects WHERE id = ?");
$projStmt->execute([$row['project_id']]);
$project = $projStmt->fetch();
$url = trim($project['webhook_url'] ?? '');
if (!$project || empty($url) || !is_public_http_url($url)) {
$upd = $db->prepare("UPDATE webhook_queue SET status = 'failed', last_error = ? WHERE id = ?");
$upd->execute([t('db.webhook.no_url_configured_queue'), $row['id']]);
$failed++;
continue;
}
$result = send_webhook_http($url, $row['payload'], $row['event_name'], $project['webhook_secret'] ?? '', WEBHOOK_TIMEOUT_SECONDS);
try {
$logStmt = $db->prepare("
INSERT INTO webhook_logs (project_id, event_name, url, status_code, request_payload, response_body, error_message, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
$logStmt->execute([
$row['project_id'], $row['event_name'], $url, $result['status_code'],
$row['payload'], $result['response'], $result['error'] ?: '', $result['duration_ms']
]);
} catch (Throwable $e) {
}
$attempts = (int)$row['attempts'] + 1;
if ($result['success']) {
$upd = $db->prepare("UPDATE webhook_queue SET status = 'sent', attempts = ?, last_status_code = ?, last_error = '', sent_at = CURRENT_TIMESTAMP WHERE id = ?");
$upd->execute([$attempts, $result['status_code'], $row['id']]);
$sent++;
} elseif ($attempts >= WEBHOOK_MAX_ATTEMPTS) {
$upd = $db->prepare("UPDATE webhook_queue SET status = 'failed', attempts = ?, last_status_code = ?, last_error = ? WHERE id = ?");
$upd->execute([$attempts, $result['status_code'], $result['error'], $row['id']]);
$failed++;
} else {
$waitSeconds = webhook_backoff_seconds($attempts);
$upd = $db->prepare("UPDATE webhook_queue SET attempts = ?, last_status_code = ?, last_error = ?, next_attempt_at = datetime('now', ?) WHERE id = ?");
$upd->execute([$attempts, $result['status_code'], $result['error'], '+' . $waitSeconds . ' seconds', $row['id']]);
$retried++;
}
}
} catch (Throwable $e) {
}
return ['processed' => $processed, 'sent' => $sent, 'failed' => $failed, 'retried' => $retried];
}
/** Pending/failed queue counts for the current project, shown in Settings. */
function webhook_queue_summary(PDO $db, int $projectId): array {
try {
$stmt = $db->prepare("SELECT status, COUNT(*) as c FROM webhook_queue WHERE project_id = ? GROUP BY status");
$stmt->execute([$projectId]);
$counts = ['pending' => 0, 'sent' => 0, 'failed' => 0];
foreach ($stmt->fetchAll() as $row) {
$counts[$row['status']] = (int)$row['c'];
}
return $counts;
} catch (Throwable $e) {
return ['pending' => 0, 'sent' => 0, 'failed' => 0];
}
}
function translate_with_deepl(string $text, string $sourceLang, string $targetLang, string $apiKey): array {
if (empty(trim($apiKey))) {
return ['success' => false, 'error' => t('db.deepl.no_api_key')];
}
if (!function_exists('curl_init')) {
return ['success' => false, 'error' => t('db.deepl.curl_missing')];
}
$isFreeTier = str_ends_with(trim($apiKey), ':fx');
$endpoint = $isFreeTier ? 'https://api-free.deepl.com/v2/translate' : 'https://api.deepl.com/v2/translate';
$targetCode = strtoupper($targetLang);
if ($targetCode === 'EN') $targetCode = 'EN-US';
if ($targetCode === 'PT') $targetCode = 'PT-PT';
$tokens = ['{{company_name}}', '{{address}}', '{{email}}', '{{phone}}', '{{representative}}', '{{register_info}}', '{{year}}'];
$protectedText = $text;
foreach ($tokens as $t) {
$protectedText = str_replace($t, '<span translate="no">' . $t . '</span>', $protectedText);
}
$postData = http_build_query([
'text' => $protectedText,
'source_lang' => strtoupper($sourceLang),
'target_lang' => $targetCode,
'tag_handling' => 'html'
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $endpoint,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 25,
CURLOPT_HTTPHEADER => [
'Authorization: DeepL-Auth-Key ' . trim($apiKey),
'Content-Type: application/x-www-form-urlencoded'
]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
return ['success' => false, 'error' => t('db.deepl.curl_error', ['error' => $curlError])];
}
$data = json_decode((string)$response, true);
if ($httpCode !== 200 || !isset($data['translations'][0]['text'])) {
$msg = $data['message'] ?? ("HTTP " . $httpCode . ": " . substr((string)$response, 0, 150));
return ['success' => false, 'error' => t('db.deepl.api_error', ['message' => $msg])];
}
$translated = $data['translations'][0]['text'];
$translated = preg_replace('/<span translate="no">({{.*?}})<\/span>/i', '$1', $translated);
return ['success' => true, 'text' => $translated];
}
function get_current_host(): string {
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return explode(':', $host)[0];
}
function lang_meta(string $code): array {
$map = [
'de' => ['flag' => '🇩🇪', 'label' => 'Deutsch'],
'en' => ['flag' => '🇬🇧', 'label' => 'English'],
'es' => ['flag' => '🇪🇸', 'label' => 'Español'],
'fr' => ['flag' => '🇫🇷', 'label' => 'Français'],
'it' => ['flag' => '🇮🇹', 'label' => 'Italiano'],
'nl' => ['flag' => '🇳🇱', 'label' => 'Nederlands'],
];
return $map[$code] ?? ['flag' => '', 'label' => strtoupper($code)];
}
/**
* UI languages Paragrafy's own admin/editor/public chrome is translated into
* (lang/<code>.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) { ?><div class="pg-locale-switch-floating"><?php }
?>
<?php
// 'msg' ist ein einmaliger Flash-Banner-Parameter (z.B. ?msg=saved) -- beim Sprachwechsel
// mitkopiert, wuerde er den letzten Erfolgs-/Fehler-Hinweis erneut anzeigen, obwohl gerade
// gar keine Aktion stattgefunden hat.
$baseQuery = $_GET;
unset($baseQuery['msg']);
?>
<div class="pg-locale-switch">
<?php foreach (ui_locales() as $code => $meta): ?>
<?php $query = $baseQuery; $query['locale'] = $code; ?>
<a href="<?= htmlspecialchars($path . '?' . http_build_query($query)) ?>" class="<?= $code === $curLocale ? 'active' : '' ?>" title="<?= htmlspecialchars($meta['label'] ?? strtoupper($code)) ?>"><?= htmlspecialchars(strtoupper($code)) ?></a>
<?php endforeach; ?>
</div>
<?php
if ($floating) { ?></div><?php }
return ob_get_clean();
}
/**
* Resolves the UI language for the current request: explicit ?locale= (public
* pages, also persisted to a cookie) or the logged-in user's saved
* preference, falling back to the browser's Accept-Language header, then 'de'.
*/
function current_locale(): string {
static $resolved = null;
if ($resolved !== null) {
return $resolved;
}
$known = array_keys(ui_locales());
if (!empty($_GET['locale']) && in_array($_GET['locale'], $known, true)) {
$resolved = $_GET['locale'];
if (!headers_sent()) {
setcookie('paragrafy_locale', $resolved, time() + 60 * 60 * 24 * 30, '/');
}
return $resolved;
}
if (!empty($_SESSION['paragrafy_user_locale']) && in_array($_SESSION['paragrafy_user_locale'], $known, true)) {
$resolved = $_SESSION['paragrafy_user_locale'];
return $resolved;
}
if (!empty($_COOKIE['paragrafy_locale']) && in_array($_COOKIE['paragrafy_locale'], $known, true)) {
$resolved = $_COOKIE['paragrafy_locale'];
return $resolved;
}
$accept = (string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '');
if ($accept !== '') {
foreach (explode(',', $accept) as $part) {
$code = strtolower(substr(trim(explode(';', $part)[0]), 0, 2));
if (in_array($code, $known, true)) {
$resolved = $code;
return $resolved;
}
}
}
if (is_installed()) {
$configLocale = get_config()['ui_locale'] ?? null;
if ($configLocale && in_array($configLocale, $known, true)) {
$resolved = $configLocale;
return $resolved;
}
}
$resolved = 'de';
return $resolved;
}
/**
* Negotiates the document content language for an unprefixed public URL
* (e.g. /impressum) from the browser's Accept-Language header, restricted to
* the project's active_languages. Returns null (no redirect) if the header
* is missing, matches no active language, or only matches $primaryLang
* (which is already served at the unprefixed URL).
*/
function negotiate_document_lang(array $activeLangs, string $primaryLang): ?string {
$accept = (string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '');
if ($accept === '') {
return null;
}
foreach (explode(',', $accept) as $part) {
$code = strtolower(substr(trim(explode(';', $part)[0]), 0, 2));
if ($code !== $primaryLang && in_array($code, $activeLangs, true)) {
return $code;
}
}
return null;
}
/**
* Liest den projektspezifischen Cookie-Banner-Text als Locale => 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/<locale>.php when available. :placeholder params are
* interpolated via strtr; pass $count to select a '<key>.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 '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(csrf_token(), ENT_QUOTES) . '">';
}
/**
* 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 = "<h2>" . htmlspecialchars(t('db.audit.mail_heading')) . "</h2>";
$html .= "<p>" . t('db.audit.mail_intro', ['project' => htmlspecialchars($project['name']), 'domain' => htmlspecialchars($project['domain']), 'months' => $auditMonths]) . "</p>";
$html .= "<ul>" . implode('', $overdue) . "</ul>";
$html .= "<p><a href='https://" . htmlspecialchars($project['domain']) . "/admin' style='background:#F0A63C;color:#fff;padding:0.6rem 1.2rem;border-radius:6px;text-decoration:none;display:inline-block;font-weight:bold;'>Zum Admin-Dashboard</a></p>";
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 '<link rel="preconnect" href="https://fonts.googleapis.com">'
. '<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">'
. '<script>(function(){try{var t=localStorage.getItem("paragrafy_theme");if(t==="light"||t==="dark"){document.documentElement.setAttribute("data-theme",t);}}catch(e){}})();</script>';
}
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 = <<<VARS
--accent: {$lightAccent};
--accent-bg: color-mix(in srgb, {$accent} 12%, white);
--bg: #FAF9F6; --card: #ffffff; --border: #E8E4DC; --border-strong: #D8D2C6; --border-soft: #F1EEE7;
--text: #201C24; --text-muted: #746E78; --text-faint: #9C96A0; --text-faintest: #B8B2AA;
--green: #16814f; --green-bg: #E8F6EE; --amber: #92601a; --amber-bg: #FBF0DC; --red: #b3223a;
color-scheme: light;
VARS;
$darkVars = <<<VARS
--accent: {$accent};
--accent-bg: color-mix(in srgb, {$accent} 22%, black);
--bg: #18151d; --card: #221f27; --border: #34303b; --border-strong: #45404d; --border-soft: #2b2830;
--text: #F0EDE9; --text-muted: #ACA6B2; --text-faint: #837d8a; --text-faintest: #5c5763;
--green: #3ecf8e; --green-bg: #1c3229; --amber: #e0a950; --amber-bg: #3a2e1a; --red: #ea7b8d;
color-scheme: dark;
VARS;
if (!$enableDarkMode) {
$rootVars = $lightVars;
$themeBlock = '';
} else {
$rootVars = $darkVars;
$themeBlock = <<<CSS
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
{$lightVars}
}
}
:root[data-theme="light"] {
{$lightVars}
}
CSS;
}
return <<<CSS
<style>
: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; }
</style>
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 '<style>'
. '@font-face { font-family: "Fraunces"; src: url("/assets/fonts/fraunces-variable.woff2") format("woff2-variations"); font-weight: 300 900; font-display: swap; }'
. '@font-face { font-family: "Inter"; src: url("/assets/fonts/inter-variable.woff2") format("woff2-variations"); font-weight: 300 900; font-display: swap; }'
. '@font-face { font-family: "JetBrains Mono"; src: url("/assets/fonts/jetbrainsmono-variable.woff2") format("woff2-variations"); font-weight: 300 800; font-display: swap; }'
. '</style>'
. '<script>(function(){try{var t=localStorage.getItem("paragrafy_theme");if(t==="light"||t==="dark"){document.documentElement.setAttribute("data-theme",t);}}catch(e){}})();</script>';
}
/**
* "§ — 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 = <<<VARS
--accent: {$lightAccent};
--accent-bg: color-mix(in srgb, {$accent} 10%, white);
--btn-ink: {$btnInk};
--bg: #f7f3ea; --card: #fffdf8; --input-bg: #fbf8f1;
--border: rgba(28,23,18,.12); --border-strong: rgba(28,23,18,.24); --border-soft: rgba(28,23,18,.06);
--text: #1c1712; --text-muted: rgba(28,23,18,.65); --text-faint: rgba(28,23,18,.46); --text-faintest: rgba(28,23,18,.32);
--green: #1a8f63; --green-bg: rgba(26,143,99,.1); --amber: #a86a1c; --amber-bg: rgba(168,106,28,.1); --red: #b3382e; --red-bg: rgba(179,56,46,.1);
color-scheme: light;
VARS;
$darkVars = <<<VARS
--accent: {$accent};
--accent-bg: color-mix(in srgb, {$accent} 20%, black);
--btn-ink: {$btnInk};
--bg: #0b0a08; --card: #16130f; --input-bg: #0e0c09;
--border: rgba(245,241,232,.14); --border-strong: rgba(245,241,232,.28); --border-soft: rgba(245,241,232,.08);
--text: #f5f1e8; --text-muted: rgba(245,241,232,.68); --text-faint: rgba(245,241,232,.5); --text-faintest: rgba(245,241,232,.35);
--green: #34d399; --green-bg: rgba(52,211,153,.12); --amber: #f0a63c; --amber-bg: rgba(240,166,60,.12); --red: #e04b3f; --red-bg: rgba(224,75,63,.12);
color-scheme: dark;
VARS;
if (!$enableDarkMode) {
$rootVars = $lightVars;
$themeBlock = '';
} else {
$rootVars = $darkVars;
$themeBlock = <<<CSS
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
{$lightVars}
}
}
:root[data-theme="light"] {
{$lightVars}
}
CSS;
}
return <<<CSS
<style>
: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; }
</style>
CSS;
}
function render_demo_countdown_banner(): string {
if (empty(get_config()['is_demo'])) {
return '';
}
ob_start();
?>
<div class="pg-alert pg-alert-amber" style="margin-bottom:14px;font-size:12px;padding:10px 12px;" data-demo-server-time-ms="<?= (int) round(microtime(true) * 1000) ?>">
<span>
<?= t('admin.common.demo_banner.text', ['time' => '<span class="pg-demo-countdown-value">10:00</span>']) ?>
</span>
</div>
<script>
(function () {
var banners = document.querySelectorAll('[data-demo-server-time-ms]');
banners.forEach(function (banner) {
var serverTimeMs = parseInt(banner.getAttribute('data-demo-server-time-ms'), 10);
var clockOffsetMs = serverTimeMs - Date.now();
function render() {
var msPerReset = 10 * 60 * 1000;
var now = Date.now() + clockOffsetMs;
var remaining = msPerReset - (now % msPerReset);
var totalSeconds = Math.ceil(remaining / 1000);
var mm = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
var ss = String(totalSeconds % 60).padStart(2, '0');
banner.querySelectorAll('.pg-demo-countdown-value').forEach(function (el) {
el.textContent = mm + ':' + ss;
});
}
render();
setInterval(render, 1000);
});
})();
</script>
<?php
return (string) ob_get_clean();
}
function render_sidebar(string $active, array $project, array $projects): string {
$items = [
'dashboard' => ['/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();
?>
<aside class="pg-sidebar">
<div class="pg-logo-row">
<div class="pg-logo-badge"><img src="/paragrafy.svg" alt="Paragrafy"></div>
<span class="pg-logo-text">Paragrafy</span>
</div>
<?= render_demo_countdown_banner() ?>
<select class="pg-proj-select" onchange="location.href='/admin?project_id=' + this.value">
<?php foreach ($projects as $p): ?>
<option value="<?= $p['id'] ?>" <?= (int)$p['id'] === (int)$project['id'] ? 'selected' : '' ?>><?= htmlspecialchars($p['name']) ?></option>
<?php endforeach; ?>
</select>
<nav class="pg-nav">
<?php foreach ($items as $key => [$href, $label, $icon]): ?>
<a href="<?= htmlspecialchars($href) ?>" class="<?= $key === $active ? 'active' : '' ?>">
<?php if ($icon === 'grid'): ?>
<span class="pg-nav-grid"><span></span><span></span><span></span><span></span></span>
<?php elseif ($icon === 'users'): ?>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:.85"><circle cx="6" cy="5.2" r="2.2"/><path d="M1.6 13c.5-2.6 2.4-4 4.4-4s3.9 1.4 4.4 4"/><circle cx="11.3" cy="5.8" r="1.7"/><path d="M10.5 9.3c1.7.2 3 1.4 3.4 3.7"/></svg>
<?php elseif ($icon === 'clock'): ?>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:.85"><circle cx="8" cy="8" r="6.3"/><path d="M8 4.6V8l2.6 1.6"/></svg>
<?php elseif ($icon === 'gear'): ?>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:.85"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<?php elseif ($icon === 'shield'): ?>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:.85"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<?php else: ?>
<span class="pg-nav-dot"></span>
<?php endif; ?>
<?= $label ?>
</a>
<?php endforeach; ?>
</nav>
<div class="pg-sidebar-spacer"></div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:13px;margin-bottom:10px">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
<span style="width:6px;height:6px;border-radius:50%;background:var(--text-faint);display:inline-block"></span>
<span style="font-size:11px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.04em"><?= $isManagedCloud ? t('admin.common.sidebar.managed_cloud', ['version' => PARAGRAFY_VERSION]) : t('admin.common.sidebar.self_hosted', ['version' => PARAGRAFY_VERSION]) ?></span>
</div>
<p style="font-size:12px;color:var(--text-faint);margin:0;line-height:1.5"><?= $isManagedCloud ? t('admin.common.sidebar.hosted_by_cloud') : t('admin.common.sidebar.open_source') ?></p>
<a href="/CHANGELOG.md" target="_blank" style="display:inline-block;margin-top:6px;font-size:11px;font-weight:600;color:var(--text-faint);text-decoration:none"><?= t('admin.common.sidebar.whats_new') ?></a>
</div>
<?php if ($isManagedCloud): ?>
<a href="https://app.paragrafy.cloud/dashboard" class="pg-viewer-link"><?= t('admin.common.sidebar.back_to_portal') ?></a>
<?php endif; ?>
<a href="https://<?= htmlspecialchars($project['domain']) ?>" target="_blank" class="pg-viewer-link"><?= htmlspecialchars(t('admin.common.sidebar.view_public_site')) ?><span>↗</span></a>
<a href="/admin/settings?project_id=<?= $project['id'] ?>" class="pg-viewer-link pg-settings-link <?= $active === 'settings' ? 'active' : '' ?>">
<span style="display:flex;align-items:center;gap:10px">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<?= htmlspecialchars(t('admin.common.nav.settings')) ?>
</span>
</a>
<div style="padding:0 2px 10px">
<?= render_locale_switch() ?>
</div>
<div class="pg-user-row">
<div class="pg-user-avatar"><?= htmlspecialchars($initials) ?></div>
<div style="flex:1;min-width:0">
<div class="pg-user-name"><?= htmlspecialchars($currentUserName) ?></div>
</div>
<a href="/admin?logout=1" class="pg-logout" title="<?= htmlspecialchars(t('admin.common.sidebar.logout_title')) ?>">⏻</a>
</div>
</aside>
<?php
return ob_get_clean();
}