conf = $this->loadConfig(); $this->cors(); $this->setInput(); $this->pdo = $this->getPdoTemplates(); $this->resolveAction(); $this->resolveTableMap(); $this->authService = new AuthService($this->conf, $this->pdo); } catch (Throwable $e) { // Im Fehlerfall ruft fail() die respond() Methode auf, die den Header setzt und den Buffer leert. $this->fail('Initialization error', get_class($e) . ': ' . $e->getMessage(), 500); } } // --- Core Responder-Methoden (KORRIGIERT) --- public function respond($data, int $code = 200): void { // 1. Output-Puffer leeren, um jeglichen unbeabsichtigten Output zu verwerfen (z.B. PHP Notices). if (ob_get_level() > 0) { ob_clean(); } // 2. 💡 KRITISCHE KORREKTUR: Content-Type Header setzen. // Dies ist der entscheidende Schritt, der dem Browser sagt: "Dies ist JSON!" if (!headers_sent() && !isset($this->conf['no_content_type'])) { header('Content-Type: application/json; charset=utf-8'); } http_response_code($code); echo is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } public function fail(string $msg, $detail = null, int $code = 400): void { $this->respond(['ok' => false, 'error' => $msg, 'detail' => $detail], $code); } // --- Private Initialisierungs- & Utility-Methoden (Unverändert) --- private function loadConfig(): array { /* ... Logik bleibt unverändert ... */ $paths = [ __DIR__ . '/../config/emailtemplate.conf.php', __DIR__ . '/../inc/config.php', __DIR__ . '/config.php', __DIR__ . '/../config.php', __DIR__ . '/../../config.php', ]; foreach ($paths as $p) { if (is_file($p)) { $conf = @include $p; if (is_array($conf)) return $conf; } } $this->fail('Invalid config', 'config file not found or not returning array', 500); } private function cors(): void { /* ... Logik bleibt unverändert ... */ $cors = $this->conf['cors'] ?? '*'; if ($cors) { header('Access-Control-Allow-Origin: ' . $cors); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization'); header('Access-Control-Allow-Credentials: true'); } if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') $this->respond(['ok' => true]); if (!empty($this->conf['auth']['cookie'])) { $c = $this->conf['auth']['cookie']; $params = session_get_cookie_params(); $params['lifetime'] = $c['lifetime'] ?? $params['lifetime']; $params['path'] = $c['path'] ?? $params['path']; $params['domain'] = $c['domain'] ?? $params['domain']; $params['secure'] = $c['secure'] ?? $params['secure']; $params['httponly'] = $c['httponly'] ?? $params['httponly']; if (isset($c['samesite'])) $params['samesite'] = $c['samesite']; session_set_cookie_params($params); } } private function setInput(): void { /* ... Logik bleibt unverändert ... */ $data = []; $ct = $_SERVER['CONTENT_TYPE'] ?? ''; if (stripos($ct, 'application/json') !== false) { $raw = file_get_contents('php://input'); if ($raw !== false && $raw !== '') { $js = json_decode($raw, true); if (is_array($js)) $data = $js; } } foreach ($_POST as $k => $v) $data[$k] = $v; foreach ($_GET as $k => $v) if (!array_key_exists($k, $data)) $data[$k] = $v; $this->in = $data; } private function getPdoTemplates(): PDO { /* ... Logik bleibt unverändert ... */ if (!isset($this->conf['projectdb']) || !is_array($this->conf['projectdb'])) { $this->fail('Missing project DB config', null, 500); } $c = $this->conf['projectdb']; $host = $c['db_host'] ?? 'localhost'; $db = $c['db_name'] ?? ($c['database'] ?? ''); $user = $c['db_user'] ?? ($c['username'] ?? ''); $pass = $c['db_pass'] ?? ($c['password'] ?? ''); $charset = $c['db_charset'] ?? 'utf8mb4'; $port = $c['db_port'] ?? 3306; $dsn = "mysql:host=$host;port=$port;dbname=$db;charset=$charset"; $opt = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]; return new PDO($dsn, $user, $pass, $opt); } private function resolveAction(): void { /* ... Logik bleibt unverändert ... */ $action = $this->val($this->in, 'action', ''); $resource = $this->val($this->in, 'resource', null); $allowedResources = ['templates', 'sections', 'blocks', 'snippets']; if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) { $verb = strtolower((string)$action); if (in_array($verb, ['list', 'get', 'create', 'update', 'delete'], true)) $action = $resource . '.' . $verb; } $this->action = $action; } private function resolveTableMap(): void { /* ... Logik bleibt unverändert ... */ $tables = $this->conf['tables'] ?? []; $this->tableMap = [ 'templates' => $tables['templates'] ?? 'emailtemplate_templates', 'sections' => $tables['sections'] ?? 'emailtemplate_sections', 'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks', 'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets', ]; } private function val(array $in, $keys, $default = null) { /* ... Logik bleibt unverändert ... */ if (!is_array($keys)) $keys = [$keys]; foreach ($keys as $k) if (array_key_exists($k, $in)) return $in[$k]; return $default; } private function firstExisting(array $columns, array $candidates): ?string { /* ... Logik bleibt unverändert ... */ foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c; return null; } private function tableColumns(string $table): array { /* ... Logik bleibt unverändert ... */ $cols = []; $stmt = $this->pdo->query("SHOW COLUMNS FROM `$table`"); foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field']; return $cols; } private function tableExists(string $table): bool { if ($table === '') return false; if (array_key_exists($table, $this->tableExistsCache)) { return $this->tableExistsCache[$table]; } try { $this->pdo->query("SELECT 1 FROM `$table` LIMIT 1"); $this->tableExistsCache[$table] = true; } catch (Throwable $e) { $this->tableExistsCache[$table] = false; } return $this->tableExistsCache[$table]; } private function primaryKey(string $table): ?string { /* ... Logik bleibt unverändert ... */ $stmt = $this->pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'"); $stmt->execute(); $row = $stmt->fetch(); return $row['Column_name'] ?? null; } private function requireAuth(): array { /* ... Logik bleibt unverändert ... */ return $this->authService->requireAuth(); } private function pullId(array $src) { /* ... Logik bleibt unverändert ... */ $aliases = ['id', 'item_id', 'template_id', 'tpl_id', 'section_id', 'sec_id', 'block_id', 'blk_id', 'snippet_id', 'snip_id']; foreach ($aliases as $a) if (isset($src[$a]) && $src[$a] !== '') return $src[$a]; return null; } private function tenantWhere(array $session): array { /* ... Logik bleibt unverändert ... */ $multi = $this->conf['multi'] ?? []; $tenantCol = $multi['tenant_col'] ?? null; $mapSess = $multi['map_session_to'] ?? 'id'; if (!$tenantCol) return ['', []]; if (!$session) return [' AND 1=0 ', []]; $val = $session[$mapSess] ?? null; if ($val === null || $val === '') return [' AND 1=0 ', []]; return [" AND `$tenantCol` = :__tenant", [':__tenant' => $val]]; } private function tenantAssign(array $session, array $columns): array { /* ... Logik bleibt unverändert ... */ $multi = $this->conf['multi'] ?? []; $tenantCol = $multi['tenant_col'] ?? null; $mapSess = $multi['map_session_to'] ?? 'id'; if (!$tenantCol || !in_array($tenantCol, $columns, true)) return []; $val = $session[$mapSess] ?? null; return ($val === null || $val === '') ? [] : [$tenantCol => $val]; } private function resolveIdCol(string $kind): array { /* ... Logik bleibt unverändert ... */ $t = $this->tableMap[$kind]; $cfg = $this->conf['columns'][$kind] ?? []; $cols = $this->tableColumns($t); $idCol = $cfg['id'] ?? ($this->firstExisting($cols, ['id']) ?: $this->primaryKey($t)); if (!$idCol) $idCol = 'id'; return [$idCol, $cols]; } private function parseHtmlToGjsComponents(string $html): array { /* ... Logik bleibt unverändert ... */ if (trim($html) === '') return []; return [ [ 'type' => 'html', 'content' => $html, 'removable' => true, 'draggable' => true, 'droppable' => true, 'copyable' => true, 'selectable' => true, 'editable' => false, 'traits' => [], ] ]; } // 💡 Bereinigungsmethode private function cleanReferenceComponents(array $node): array { if (isset($node['type']) && $node['type'] === 'library-reference') { $node['content'] = ''; $node['components'] = []; } foreach ($node as $key => $value) { if (is_array($value)) { $node[$key] = $this->cleanReferenceComponents($value); } } return $node; } private function encodeJson($value): string { $options = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; $json = json_encode($value, $options, 2048); if ($json === false) { $json = json_encode( $value, $options | JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE, 2048 ); } return $json === false ? '' : $json; } // ================================================================= // 🚀 CRUD HANDLER METHODEN // ================================================================= /** * Allgemeine Methode zur Handhabung von LIST-Anfragen. */ private function handleList(string $kind): void { $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); $cfg = $this->conf['columns'][$kind] ?? []; $nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol); $descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']); $catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']); $updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']); $q = trim((string)$this->val($this->in, 'q', '')); $limit = max(1, (int)$this->val($this->in, 'limit', 500)); $offset = max(0, (int)$this->val($this->in, 'offset', 0)); $where = ' WHERE 1=1 '; $params = []; // Suchlogik (q) if ($q !== '') { $parts = ["`$nameCol` LIKE :q"]; if ($descCol) $parts[] = "`$descCol` LIKE :q"; if ($catCol) $parts[] = "`$catCol` LIKE :q"; $where .= " AND (" . implode(' OR ', $parts) . ") "; $params[':q'] = '%' . $q . '%'; } // Filterlogik (parentFilters) $parentFilters = [ 'template_id' => $this->val($this->in, ['template_id', 'tpl_id'], null), 'section_id' => $this->val($this->in, ['section_id', 'sec_id'], null), 'block_id' => $this->val($this->in, ['block_id', 'blk_id'], null), ]; foreach ($parentFilters as $col => $v) { if ($v === null || $v === '') continue; if (in_array($col, $allCols, true)) { $where .= " AND `$col` = :$col "; $params[":$col"] = $v; } } // Tenant-Filter [$tw, $tp] = $this->tenantWhere($auth); $where .= $tw; foreach ($tp as $k => $v) $params[$k] = $v; $order = $updCol ? " ORDER BY `$updCol` DESC " : " ORDER BY `$nameCol` ASC "; $sql = "SELECT * FROM `$t` $where $order LIMIT :off,:lim"; $stmt = $this->pdo->prepare($sql); // Bind parameters foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); $stmt->bindValue(':off', $offset, PDO::PARAM_INT); $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $r) { $item = [ 'id' => $r[$idCol] ?? null, 'name' => $r[$nameCol] ?? null, ]; if ($descCol && isset($r[$descCol])) $item['desc'] = $r[$descCol]; if ($catCol && isset($r[$catCol])) $item['category'] = $r[$catCol]; if ($updCol && isset($r[$updCol])) $item['updated_at'] = $r[$updCol]; // Lade HTML und JSON aus den korrekten Spalten $htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']); if ($htmlCol && isset($r[$htmlCol])) $item['html'] = (string)$r[$htmlCol]; $jsonCol = $this->firstExisting($allCols, ['json_content']); if ($jsonCol && isset($r[$jsonCol])) $item['content'] = $r[$jsonCol]; $out[] = $item; } $this->respond(['ok' => true, 'kind' => $kind, 'items' => $out, 'data' => $out, 'count' => count($out), 'offset' => $offset, 'limit' => $limit]); } /** * Allgemeine Methode zur Handhabung von GET-Anfragen. */ private function handleGet(string $kind): void { $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); $id = $this->pullId($this->in); if ($id === null || $id === '') $this->fail('id required', null, 422); [$tw, $tp] = $this->tenantWhere($auth); $sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':id', $id); foreach ($tp as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $row = $stmt->fetch(); if (!$row) $this->fail('Not found', ['kind' => $kind, 'id' => $id], 404); $rowOut = ['id' => $row[$idCol] ?? $id] + $row; // Lade HTML und JSON aus den korrekten Spalten $htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']); $topHtml = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : null; $jsonCol = $this->firstExisting($allCols, ['json_content']); $topContent = ($jsonCol && isset($row[$jsonCol])) ? $row[$jsonCol] : null; $gjsComponents = []; if ($topContent !== null) { $decodedContent = json_decode($topContent, true); if (is_array($decodedContent)) { $gjsComponents = $decodedContent; } } if (empty($gjsComponents) && $topHtml !== null) { $gjsComponents = $this->parseHtmlToGjsComponents($topHtml); } $usage = $this->calculateUsage($kind, (int)$rowOut['id'], $auth); $this->respond([ 'ok' => true, 'kind' => $kind, 'id' => $rowOut['id'], 'item' => $rowOut, 'data' => $rowOut, 'html' => $topHtml, 'content' => $topContent, 'gjs_components' => $gjsComponents, 'usage' => $usage, ]); } /** * Allgemeine Methode zur Handhabung von CREATE-Anfragen (inkl. JSON-Bereinigung). */ private function handleCreate(string $kind): void { $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); $cfg = $this->conf['columns'][$kind] ?? []; $nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol); $descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']); $catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']); $updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']); $name = trim((string)$this->val($this->in, ['name', 'title'], '')); if ($name === '') $this->fail('name required', null, 422); $desc = (string)$this->val($this->in, ['description', 'desc'], null); $cat = (string)$this->val($this->in, ['category', 'cat'], null); $html = (string)$this->val($this->in, ['html', 'body', 'markup'], null); if ($kind === 'snippets' && ($html === null || $html === '')) { $html = (string)$this->val($this->in, ['content'], $html); } $jsonKeys = ($kind === 'snippets') ? ['content_json', 'json', 'structure_json'] : ['content_json', 'json', 'content', 'structure_json']; $json = $this->val($this->in, $jsonKeys, null); $settings = $this->val($this->in, ['settings_json', 'settings'], null); $templateId = $this->val($this->in, ['template_id', 'tpl_id'], null); $sectionId = $this->val($this->in, ['section_id', 'sec_id'], null); $blockId = $this->val($this->in, ['block_id', 'blk_id'], null); $data = [$nameCol => $name]; if ($desc !== null && $descCol) $data[$descCol] = $desc; if ($cat !== null && $catCol) $data[$catCol] = $cat; $htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup'])); $jsonDbCol = $this->firstExisting($allCols, ['json_content']); // --- LOGIK mit ERWEITERTER PRÜFUNG START --- // 1. JSON-Content behandeln if ($json !== null) { if ($jsonDbCol) { $components = is_string($json) ? json_decode($json, true) : $json; if (is_array($components)) { $components = $this->cleanReferenceComponents($components); // BEREINIGUNG $data[$jsonDbCol] = $this->encodeJson($components); } else { $data[$jsonDbCol] = is_string($json) ? $json : ''; } } else { // FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben $this->fail( 'JSON content provided but no `json_content` column found', ['table' => $t, 'available_cols' => $allCols], 422 ); } } // 2. HTML-Content speichern if ($htmlDbCol && $html !== null) { $data[$htmlDbCol] = $html; } // --- LOGIK mit ERWEITERTER PRÜFUNG ENDE --- $c = $this->firstExisting($allCols, ['settings_json', 'settings']); if ($c && $settings !== null) $data[$c] = is_string($settings) ? $settings : $this->encodeJson($settings); if ($templateId !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $templateId; if ($sectionId !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sectionId; if ($blockId !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blockId; $data = $data + $this->tenantAssign($_SESSION['auth'] ?? [], $allCols); $now = date('Y-m-d H:i:s'); $createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']); if ($createdCol) $data[$createdCol] = $now; if ($updCol) $data[$updCol] = $now; $fields = array_keys($data); $place = array_map(fn($c) => ":$c", $fields); $sql = "INSERT INTO `$t` (" . implode(',', array_map(fn($c) => "`$c`", $fields)) . ") VALUES (" . implode(',', $place) . ")"; $stmt = $this->pdo->prepare($sql); foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); $stmt->execute(); $newId = $this->pdo->lastInsertId(); $out = ['id' => $newId, 'name' => $name]; if ($desc !== null) $out['desc'] = $desc; if ($cat !== null) $out['category'] = $cat; $this->respond(['ok' => true, 'kind' => $kind, 'id' => $newId, 'item' => $out, 'data' => $out]); } /** * Allgemeine Methode zur Handhabung von UPDATE-Anfragen (inkl. JSON-Bereinigung). */ private function handleUpdate(string $kind): void { $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); $cfg = $this->conf['columns'][$kind] ?? []; $nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol); $descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']); $catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']); $updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']); $id = $this->pullId($this->in); if ($id === null || $id === '') $this->fail('id required', null, 422); $data = []; $name = $this->val($this->in, ['name', 'title'], null); $desc = $this->val($this->in, ['description', 'desc'], null); $cat = $this->val($this->in, ['category', 'cat'], null); $html = $this->val($this->in, ['html', 'body', 'markup'], null); if ($kind === 'snippets' && $html === null) { $html = $this->val($this->in, ['content'], null); } $jsonKeys = ($kind === 'snippets') ? ['content_json', 'json', 'structure_json'] : ['content_json', 'json', 'content', 'structure_json']; $json = $this->val($this->in, $jsonKeys, null); $settings = $this->val($this->in, ['settings_json', 'settings'], null); if ($name !== null) $data[$nameCol] = (string)$name; if ($desc !== null && $descCol) $data[$descCol] = (string)$desc; if ($cat !== null && $catCol) $data[$catCol] = (string)$cat; $htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup'])); $jsonDbCol = $this->firstExisting($allCols, ['json_content']); // --- LOGIK mit ERWEITERTER PRÜFUNG START --- // 1. JSON-Content behandeln if ($json !== null) { if ($jsonDbCol) { // Wenn JSON-Spalte existiert, JSON verarbeiten und speichern $components = is_string($json) ? json_decode($json, true) : $json; if (is_array($components)) { $components = $this->cleanReferenceComponents($components); // BEREINIGUNG $data[$jsonDbCol] = $this->encodeJson($components); } else { $data[$jsonDbCol] = is_string($json) ? $json : ''; } } else { // FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben $this->fail( 'JSON content provided but no `json_content` column found', ['table' => $t, 'available_cols' => $allCols], 422 ); } // 2. Den zugehörigen HTML-Output speichern (wird vom Editor immer mitgesendet, wenn JSON da ist) if ($html !== null && $htmlDbCol) { $data[$htmlDbCol] = (string)$html; } } elseif ($html !== null && $htmlDbCol) { // Wenn NUR HTML gesendet wird (für minimale Änderungen), speichern wir nur HTML. $data[$htmlDbCol] = (string)$html; } // --- LOGIK mit ERWEITERTER PRÜFUNG ENDE --- $c = $this->firstExisting($allCols, ['settings_json', 'settings']); if ($settings !== null && $c) $data[$c] = is_string($settings) ? $settings : $this->encodeJson($settings); $tpl = $this->val($this->in, ['template_id', 'tpl_id'], null); if ($tpl !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $tpl; $sec = $this->val($this->in, ['section_id', 'sec_id'], null); if ($sec !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sec; $blk = $this->val($this->in, ['block_id', 'blk_id'], null); if ($blk !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blk; if ($updCol) $data[$updCol] = date('Y-m-d H:i:s'); if (!$data) $this->fail('nothing to update', null, 422); [$tw, $tp] = $this->tenantWhere($auth); $set = []; foreach (array_keys($data) as $c) $set[] = "`$c` = :$c"; $sql = "UPDATE `$t` SET " . implode(',', $set) . " WHERE `$idCol` = :__id" . $tw . " LIMIT 1"; $stmt = $this->pdo->prepare($sql); foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); $stmt->bindValue(':__id', $id); foreach ($tp as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'updated' => array_keys($data)]); } /** * Allgemeine Methode zur Handhabung von DELETE-Anfragen. */ private function handleDelete(string $kind): void { $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); $id = $this->pullId($this->in); if ($id === null || $id === '') $this->fail('id required', null, 422); [$tw, $tp] = $this->tenantWhere($auth); $sql = "DELETE FROM `$t` WHERE `$idCol` = :__id" . $tw . " LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':__id', $id); foreach ($tp as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'deleted' => true]); } /** * Sendet einen Testversand für Templates. */ private function handleTemplateTestSend(): void { $auth = $this->requireAuth(); $templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0); if ($templateId <= 0) { $this->fail('template_id required', null, 422); } $recipient = trim((string)$this->val($this->in, ['to', 'email', 'recipient'], '')); if ($recipient === '' || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) { $this->fail('Valid recipient required', null, 422); } $subject = trim((string)$this->val($this->in, ['subject'], 'Testversand')); if ($subject === '') { $subject = 'Testversand'; } $senderId = (int)$this->val($this->in, ['sender_id'], 0); $t = $this->tableMap['templates']; [$idCol, $allCols] = $this->resolveIdCol('templates'); [$tw, $tp] = $this->tenantWhere($auth); $sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':id', $templateId); foreach ($tp as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $row = $stmt->fetch(); if (!$row) { $this->fail('Template not found', ['id' => $templateId], 404); } $htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']); $html = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : ''; if ($html === '' && !empty($row['json_content'])) { $html = '

(Dieses Template enthält noch keine HTML-Inhalte.)

'; } $renderCache = []; $renderStack = []; $html = $this->renderHtmlWithReferences($html, $auth, $renderCache, $renderStack); $html = $this->prepareEmailHtml($html); $sender = null; if ($senderId > 0) { $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId > 0) { $sender = $this->fetchSenderRow($customerId, $senderId); } } if (!$this->dispatchTestMail($recipient, $subject, $html, $sender)) { $this->fail('Send failed', null, 500); } $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId > 0) { $this->incrementTemplateUsage($customerId, $templateId); } $this->respond([ 'ok' => true, 'template_id' => $templateId, 'to' => $recipient, 'subject' => $subject, 'sender_id' => $senderId > 0 ? $senderId : null, ]); } private function prepareEmailHtml(string $html): string { $html = trim($html); if ($html === '') { return '

(Kein Inhalt vorhanden)

'; } if (!class_exists(CssToInlineStyles::class)) { return $html; } try { $inliner = new CssToInlineStyles(); return $inliner->convert($html); } catch (Throwable $e) { return $html; } } private function dispatchTestMail(string $to, string $subject, string $html, ?array $sender = null): bool { if (!function_exists('mail')) { return false; } $smtp = $this->conf['smtp'] ?? []; $fromEmail = $sender['from_email'] ?? ($smtp['from_email'] ?? 'no-reply@example.com'); $fromName = $sender['from_name'] ?? ($sender['label'] ?? ($smtp['from_name'] ?? 'EmailTemplate')); $replyTo = $sender['reply_to'] ?? ''; $headers = [ 'MIME-Version: 1.0', 'Content-Type: text/html; charset=UTF-8', 'From: ' . $this->formatEmailAddress($fromEmail, $fromName), ]; if ($replyTo !== '') { $headers[] = 'Reply-To: ' . $this->formatEmailAddress($replyTo, $fromName ?: $fromEmail); } $encodedSubject = function_exists('mb_encode_mimeheader') ? mb_encode_mimeheader($subject, 'UTF-8') : $subject; return @mail($to, $encodedSubject, $html, implode("\r\n", $headers)); } private function formatEmailAddress(string $email, string $name): string { $email = trim($email); if ($email === '') { return 'no-reply@example.com'; } $name = trim($name); if ($name === '') { return $email; } $encoded = function_exists('mb_encode_mimeheader') ? mb_encode_mimeheader($name, 'UTF-8') : $name; return sprintf('%s <%s>', $encoded, $email); } // ================================================================= // đź’ˇ Ă–ffentliche run()-Methode (KORRIGIERT) // ================================================================= public function run(): void { // đź’ˇ KORREKTUR: Der Content-Type Header wird hier entfernt, da er jetzt in respond() // zentralisiert wurde, um sicherzustellen, dass er auch bei Fehlern im Konstruktor oder // im try-Block korrekt gesetzt wird. // header('Content-Type: application/json; charset=utf-8'); // DIESE ZEILE ENTFERNT try { // Extrahiere den Ressourcen-Typ und die Operation (z.B. 'templates' und 'list') [$kind, $operation] = explode('.', $this->action, 2) + [1 => '']; switch ($this->action) { case 'health': $this->respond(['ok' => true, 'time' => date('c')]); /* ---------- AUTH ---------- */ case 'auth.login': $result = $this->authService->login($this->in); $this->respond(['ok' => true] + $result); break; case 'auth.me': if (empty($_SESSION['auth'])) $this->fail('Not authenticated', null, 401); $this->respond(['ok' => true, 'user' => $_SESSION['auth']]); break; case 'auth.logout': $this->authService->logout(); $this->respond(['ok' => true]); break; case 'account.profile.get': $this->handleAccountProfileGet(); break; case 'account.profile.update': $this->handleAccountProfileUpdate(); break; case 'account.password.update': $this->handleAccountPasswordUpdate(); break; case 'account.settings.get': $this->handleAccountSettingsGet(); break; case 'account.settings.update': $this->handleAccountSettingsUpdate(); break; case 'account.users.list': $this->handleAccountUsersList(); break; case 'account.users.create': $this->handleAccountUsersCreate(); break; case 'account.users.update': $this->handleAccountUsersUpdate(); break; case 'account.users.delete': $this->handleAccountUsersDelete(); break; case 'account.senders.list': $this->handleAccountSendersList(); break; case 'account.senders.save': $this->handleAccountSenderSave(); break; case 'account.senders.delete': $this->handleAccountSenderDelete(); break; case 'dashboard.metrics': $this->handleDashboardMetrics(); break; case 'dashboard.reset_usage': $this->handleDashboardResetUsage(); break; case 'downloads.bridge': $this->handleDownloadFile('bridge'); break; case 'downloads.sender': $this->handleDownloadFile('sender'); break; case 'account.bridge.test': $this->handleAccountBridgeTest(); break; case 'placeholders.status': $this->handlePlaceholderStatus(); break; case 'placeholders.schema': $this->handlePlaceholderSchema(); break; case 'debug.phpinfo': $this->handleDebugPhpInfo(); break; case 'templates.test_send': $this->handleTemplateTestSend(); break; /* ---------- CRUD HANDLER ---------- */ default: if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets'])) { switch ($operation) { case 'list': $this->handleList($kind); break; case 'get': $this->handleGet($kind); break; case 'create': $this->handleCreate($kind); break; case 'update': $this->handleUpdate($kind); break; case 'delete': $this->handleDelete($kind); break; default: $this->fail('Unknown operation for resource: ' . $this->action, null, 404); break; } } else { $this->fail('Unknown action', $this->action ?: 'missing', 404); } break; } } catch (Throwable $e) { $this->fail('Server error', get_class($e) . ': ' . $e->getMessage(), 500); } } private function lookupTableName(string $key, string $default): string { $tables = $this->conf['tables'] ?? []; if (!empty($tables[$key])) return $tables[$key]; $prefix = $this->conf['projectdb']['prefix'] ?? null; if ($prefix && strpos($default, 'emailtemplate_') === 0) { return $prefix . substr($default, strlen('emailtemplate_')); } return $default; } private function countRefsInTable(string $table, string $where, array $params, array $auth): int { try { [$tw, $tp] = $this->tenantWhere($auth); $sql = "SELECT COUNT(*) AS c FROM `$table` WHERE $where" . $tw; $stmt = $this->pdo->prepare($sql); foreach ($params as $k => $v) $stmt->bindValue($k, $v); foreach ($tp as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $row = $stmt->fetch(); return (int)($row['c'] ?? 0); } catch (Throwable $e) { return 0; } } private function fetchResourceCounts(int $customerId): array { $counts = [ 'templates' => 0, 'sections' => 0, 'blocks' => 0, 'snippets' => 0, 'renders_total' => 0, ]; $map = $this->tableMap ?? []; foreach (['templates', 'sections', 'blocks', 'snippets'] as $kind) { $table = $map[$kind] ?? null; if (!$table || !$this->tableExists($table)) continue; $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid"); $stmt->execute([':cid' => $customerId]); $counts[$kind] = (int)($stmt->fetchColumn() ?: 0); } $usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage'); if ($this->tableExists($usageTable)) { $stmt = $this->pdo->prepare("SELECT SUM(`render_count`) FROM `$usageTable` WHERE `customer_id` = :cid"); $stmt->execute([':cid' => $customerId]); $counts['renders_total'] = (int)($stmt->fetchColumn() ?: 0); } return $counts; } private function listTemplateUsage(int $customerId): array { $table = $this->tableMap['templates'] ?? null; if (!$table || !$this->tableExists($table)) { return []; } $usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage'); if ($this->tableExists($usageTable)) { $sql = "SELECT t.id, t.name, t.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at FROM `$table` t LEFT JOIN `$usageTable` u ON u.template_id = t.id WHERE t.customer_id = :cid ORDER BY render_count DESC, t.updated_at DESC"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId]); $rows = $stmt->fetchAll() ?: []; } else { $sql = "SELECT t.id, t.name, t.updated_at FROM `$table` t WHERE t.customer_id = :cid ORDER BY t.updated_at DESC"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId]); $rows = $stmt->fetchAll() ?: []; foreach ($rows as &$row) { $row['render_count'] = 0; $row['last_rendered_at'] = null; } } return array_map(static function ($row) { return [ 'template_id' => (int)($row['id'] ?? 0), 'name' => $row['name'] ?? '', 'render_count' => (int)($row['render_count'] ?? 0), 'last_rendered_at' => $row['last_rendered_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; }, $rows); } private function resetTemplateUsage(int $customerId, array $templateIds): void { $usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage'); if (!$templateIds || !$this->tableExists($usageTable)) { return; } $templateIds = array_values(array_unique(array_filter(array_map('intval', $templateIds), static fn ($v) => $v > 0))); if (!$templateIds) return; $placeholders = implode(',', array_fill(0, count($templateIds), '?')); $sql = "DELETE FROM `$usageTable` WHERE `customer_id` = ? AND `template_id` IN ($placeholders)"; $stmt = $this->pdo->prepare($sql); $stmt->execute(array_merge([$customerId], $templateIds)); } private function extractIdList($raw): array { if ($raw === null) return []; if (is_numeric($raw)) { $raw = [(int)$raw]; } elseif (is_string($raw)) { $raw = preg_split('/[\s,]+/', $raw); } elseif (!is_array($raw)) { return []; } $ids = []; foreach ($raw as $value) { if (is_array($value)) { $ids = array_merge($ids, $this->extractIdList($value)); continue; } if ($value === '' || $value === null) continue; $ids[] = (int)$value; } $ids = array_values(array_unique(array_filter($ids, static fn ($v) => $v > 0))); return $ids; } private function calculateUsage(string $kind, int $id, array $auth): array { if ($id <= 0) return ['total' => 0]; $summary = []; $templateItemsTable = $this->lookupTableName('template_items', 'emailtemplate_template_items'); $sectionItemsTable = $this->lookupTableName('section_items', 'emailtemplate_section_items'); if ($kind === 'sections') { $summary['templates'] = $this->countRefsInTable( $templateItemsTable, "`ref_type` = :rt AND `ref_id` = :rid", [':rt' => 'section', ':rid' => $id], $auth ); } elseif ($kind === 'blocks') { $summary['templates'] = $this->countRefsInTable( $templateItemsTable, "`ref_type` = :rt AND `ref_id` = :rid", [':rt' => 'block', ':rid' => $id], $auth ); $summary['sections'] = $this->countRefsInTable( $sectionItemsTable, "`ref_id` = :rid", [':rid' => $id], $auth ); $summary['snippets'] = $this->countRefsInTable( $this->tableMap['snippets'], "`block_id` = :rid", [':rid' => $id], $auth ); } $summary = array_filter($summary, fn($v) => (int)$v > 0); $summary['total'] = array_sum($summary); return $summary; } private function normalizeResourceKind(string $kind): ?string { $kind = strtolower(trim($kind)); $map = [ 'template' => 'templates', 'templates' => 'templates', 'section' => 'sections', 'sections' => 'sections', 'block' => 'blocks', 'blocks' => 'blocks', 'snippet' => 'snippets', 'snippets' => 'snippets', ]; return $map[$kind] ?? null; } private function resolveHtmlColumn(array $columns, string $kindKey): ?string { $candidates = ($kindKey === 'snippets') ? ['content', 'html', 'body', 'markup'] : ['html', 'body', 'markup', 'content']; return $this->firstExisting($columns, $candidates); } private function fetchResourceHtml(string $kind, int $id, array $auth, array &$cache, array &$stack): ?string { $kindKey = $this->normalizeResourceKind($kind); if (!$kindKey || $id <= 0) return null; $cacheKey = $kindKey . ':' . $id; if (array_key_exists($cacheKey, $cache)) return $cache[$cacheKey]; if (!empty($stack[$cacheKey])) return null; $table = $this->tableMap[$kindKey] ?? null; if (!$table) return null; [$idCol, $allCols] = $this->resolveIdCol($kindKey); [$tw, $tp] = $this->tenantWhere($auth); $sql = "SELECT * FROM `$table` WHERE `$idCol` = :id" . $tw . " LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':id', $id); foreach ($tp as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $row = $stmt->fetch(); if (!$row) { $cache[$cacheKey] = null; return null; } $htmlCol = $this->resolveHtmlColumn($allCols, $kindKey); $html = $htmlCol && isset($row[$htmlCol]) ? (string)$row[$htmlCol] : ''; $stack[$cacheKey] = true; $html = $this->renderHtmlWithReferences($html, $auth, $cache, $stack); unset($stack[$cacheKey]); $cache[$cacheKey] = $html; return $html; } private function renderHtmlWithReferences(string $html, array $auth, array &$cache, array &$stack): string { $trimmed = trim($html); if ($trimmed === '') return $html; if (!class_exists(DOMDocument::class)) return $html; $flags = 0; if (defined('LIBXML_HTML_NOIMPLIED')) { $flags |= LIBXML_HTML_NOIMPLIED; } if (defined('LIBXML_HTML_NODEFDTD')) { $flags |= LIBXML_HTML_NODEFDTD; } $doc = new DOMDocument('1.0', 'UTF-8'); libxml_use_internal_errors(true); $wrapper = '
' . $html . '
'; $loaded = @$doc->loadHTML('' . $wrapper, $flags); libxml_clear_errors(); if (!$loaded) return $html; $xpath = new DOMXPath($doc); $nodes = $xpath->query('//*[@data-lib-kind and @data-lib-id]'); if ($nodes !== false) { foreach ($nodes as $node) { /** @var \DOMElement $node */ $kind = $node->getAttribute('data-lib-kind'); $refId = (int)$node->getAttribute('data-lib-id'); if (!$kind || $refId <= 0) continue; $replacement = $this->fetchResourceHtml($kind, $refId, $auth, $cache, $stack); if ($replacement === null) continue; while ($node->firstChild) { $node->removeChild($node->firstChild); } $this->appendHtmlToNode($doc, $node, $replacement); } } $root = $doc->getElementById('lib-ref-root'); if (!$root) return $html; $output = ''; foreach ($root->childNodes as $child) { $output .= $doc->saveHTML($child); } return $output; } private function appendHtmlToNode(DOMDocument $targetDoc, DOMElement $node, string $html): void { if (trim($html) === '') return; $flags = 0; if (defined('LIBXML_HTML_NOIMPLIED')) { $flags |= LIBXML_HTML_NOIMPLIED; } if (defined('LIBXML_HTML_NODEFDTD')) { $flags |= LIBXML_HTML_NODEFDTD; } $fragmentDoc = new DOMDocument('1.0', 'UTF-8'); libxml_use_internal_errors(true); $wrapped = '
' . $html . '
'; $loaded = @$fragmentDoc->loadHTML('' . $wrapped, $flags); libxml_clear_errors(); if ($loaded) { $wrapper = $fragmentDoc->getElementById('fragment-wrapper'); if ($wrapper) { $children = []; if ($wrapper->hasChildNodes()) { foreach ($wrapper->childNodes as $child) { $children[] = $child; } } foreach ($children as $child) { $imported = $targetDoc->importNode($child, true); if ($imported) { $node->appendChild($imported); } } return; } } $node->appendChild($targetDoc->createTextNode($html)); } private function handlePlaceholderSchema(): void { $user = $this->authService->requireAuth(); $customerId = (int)($user['customer_id'] ?? 0); $bridge = $this->resolveBridgeConfig($customerId); $url = trim((string)($bridge['url'] ?? '')); $token = trim((string)($bridge['token'] ?? '')); if ($url === '' || $token === '') { $this->fail('Bridge not configured', null, 500); } $ttl = (int)($bridge['cache_ttl'] ?? 300); try { $schema = $this->fetchPlaceholderSchema($url, $token, $ttl); } catch (Throwable $e) { $this->fail('Bridge request failed', $e->getMessage(), 502); return; } $settings = $this->getCustomerSettings($customerId); $tables = $schema['tables'] ?? []; if (!empty($settings['bridge_tables'])) { $tables = $this->filterSchemaTables($tables, $settings['bridge_tables']); } $this->respond([ 'ok' => true, 'tables' => $tables, 'fetched' => $schema['fetched'] ?? date(DATE_ATOM), ]); } private function handlePlaceholderStatus(): void { $user = $this->authService->requireAuth(); $customerId = (int)($user['customer_id'] ?? 0); $bridge = $this->resolveBridgeConfig($customerId); $url = trim((string)($bridge['url'] ?? '')); $token = trim((string)($bridge['token'] ?? '')); $available = ($url !== '' && $token !== ''); $this->respond([ 'ok' => true, 'available' => $available, ]); } private function fetchPlaceholderSchema(string $url, string $token, int $ttl): array { $cacheFile = $this->placeholderCachePath($url, $token); if ($ttl > 0 && is_file($cacheFile) && (filemtime($cacheFile) + $ttl) > time()) { $cached = json_decode((string)@file_get_contents($cacheFile), true); if (is_array($cached)) { return $cached; } } $endpoint = $url; if (stripos($endpoint, 'action=') === false) { $endpoint .= (strpos($endpoint, '?') === false ? '?' : '&') . 'action=schema'; } $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => "Authorization: Bearer {$token}\r\nAccept: application/json\r\n", 'timeout' => 10, ], ]); $response = @file_get_contents($endpoint, false, $context); if ($response === false) { throw new RuntimeException('Bridge endpoint unreachable'); } $decoded = json_decode($response, true); if (!is_array($decoded) || empty($decoded['ok'])) { throw new RuntimeException('Bridge did not return a valid schema'); } if ($ttl > 0) { @file_put_contents($cacheFile, json_encode($decoded)); } return $decoded; } private function placeholderCachePath(string $url, string $token): string { $hash = md5($url . '|' . $token); return sys_get_temp_dir() . '/emailtemplate_placeholder_' . $hash . '.json'; } // ----------------------------------------------------------------- // Account & User Management // ----------------------------------------------------------------- private function handleAccountProfileGet(): void { $user = $this->ensureAuthUserHydrated($this->authService->requireAuth()); $customerId = (int)($user['customer_id'] ?? 0); $settings = $customerId ? $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId)) : []; $this->respond([ 'ok' => true, 'user' => $user, 'customer' => $user['customer'] ?? null, 'settings' => $settings, ]); } private function handleAccountProfileUpdate(): void { $user = $this->authService->requireAuth(); $cols = $this->authUserColumns(); $table = $cols['table']; $dbCols = $this->tableColumns($table); $name = trim((string)($this->in['name'] ?? '')); $email = trim(strtolower((string)($this->in['email'] ?? ''))); if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { $this->fail('Valid email required', null, 422); } if ($name === '') $this->fail('Name required', null, 422); $userId = (int)($user['id'] ?? 0); $customerId = (int)($user['customer_id'] ?? 0); if (strtolower($email) !== strtolower((string)$user['email'])) { $this->assertEmailUnique($email, $customerId, $userId); } $set = []; $params = [':id' => $userId]; if ($this->columnExists($dbCols, $cols['col_name'])) { $set[] = sprintf('`%s` = :name', $cols['col_name']); $params[':name'] = $name; } if ($this->columnExists($dbCols, $cols['col_email'])) { $set[] = sprintf('`%s` = :email', $cols['col_email']); $params[':email'] = $email; } if (!$set) { $this->fail('Profile update not supported', null, 500); } $sql = sprintf( 'UPDATE `%s` SET %s WHERE `%s` = :id LIMIT 1', $table, implode(',', $set), $cols['col_id'] ); $stmt = $this->pdo->prepare($sql); foreach ($params as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $_SESSION['auth']['name'] = $name; $_SESSION['auth']['email'] = $email; $this->respond(['ok' => true, 'user' => $_SESSION['auth']]); } private function handleAccountPasswordUpdate(): void { $user = $this->authService->requireAuth(); $current = (string)($this->in['current_password'] ?? ''); $new = (string)($this->in['new_password'] ?? ''); if ($current === '' || $new === '') { $this->fail('Current and new password required', null, 422); } if (strlen($new) < 8) { $this->fail('Password must be at least 8 characters', null, 422); } $cols = $this->authUserColumns(); $table = $cols['table']; $sql = sprintf( 'SELECT `%1$s` FROM `%2$s` WHERE `%3$s` = :id LIMIT 1', $cols['col_pass'], $table, $cols['col_id'] ); $stmt = $this->pdo->prepare($sql); $stmt->execute([':id' => $user['id']]); $row = $stmt->fetch(); if (!$row) $this->fail('User not found', null, 404); $stored = (string)$row[$cols['col_pass']]; if (!$this->verifyUserPasswordValue($current, $stored)) { $this->fail('Current password incorrect', null, 403); } $hash = $this->hashUserPassword($new); $update = $this->pdo->prepare( sprintf('UPDATE `%s` SET `%s` = :pwd WHERE `%s` = :id LIMIT 1', $table, $cols['col_pass'], $cols['col_id']) ); $update->execute([':pwd' => $hash, ':id' => $user['id']]); $this->respond(['ok' => true]); } private function handleAccountSettingsGet(): void { $user = $this->authService->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); $settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId)); $this->respond(['ok' => true, 'settings' => $settings]); } private function handleAccountSettingsUpdate(): void { $user = $this->authService->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $bridgeUrl = trim((string)($this->in['bridge_url'] ?? '')); $bridgeToken = trim((string)($this->in['bridge_token'] ?? '')); $senderToken = trim((string)($this->in['sender_token'] ?? '')); $externalToken = trim((string)($this->in['external_api_token'] ?? '')); $bridgeTablesInput = $this->in['bridge_tables'] ?? null; $bridgeTables = $this->normalizeBridgeTables($bridgeTablesInput); $rotateBridge = !empty($this->in['rotate_bridge_token']); $rotateSender = !empty($this->in['rotate_sender_token']); $rotateExternal = !empty($this->in['rotate_external_token']); if ($bridgeUrl && !filter_var($bridgeUrl, FILTER_VALIDATE_URL)) { $this->fail('Ungültige Bridge-URL', null, 422); } $settings = $this->getCustomerSettings($customerId); if ($rotateBridge || $bridgeToken === '') $bridgeToken = $this->generateToken(); if ($rotateSender || $senderToken === '') $senderToken = $this->generateToken(); if ($rotateExternal || $externalToken === '') $externalToken = $this->generateToken(); $settings = $this->saveCustomerSettings($customerId, [ 'bridge_url' => $bridgeUrl, 'bridge_token' => $bridgeToken, 'sender_token' => $senderToken, 'external_api_token' => $externalToken, 'bridge_tables' => $bridgeTables, ]); $this->respond(['ok' => true, 'settings' => $settings]); } private function handleAccountUsersList(): void { $user = $this->authService->requireAuth(); $this->ensureOwner($user); $customerId = (int)($user['customer_id'] ?? 0); $cols = $this->authUserColumns(); $table = $cols['table']; $dbCols = $this->tableColumns($table); $select = [ sprintf('`%s` AS user_id', $cols['col_id']), sprintf('`%s` AS name', $cols['col_name']), sprintf('`%s` AS email', $cols['col_email']), ]; if ($this->columnExists($dbCols, $cols['col_role'])) { $select[] = sprintf('`%s` AS role', $cols['col_role']); } else { $select[] = "'user' AS role"; } if ($this->columnExists($dbCols, $cols['col_status'])) { $select[] = sprintf('`%s` AS is_active', $cols['col_status']); } else { $select[] = '1 AS is_active'; } if ($this->columnExists($dbCols, 'created_at')) $select[] = '`created_at`'; if ($this->columnExists($dbCols, 'updated_at')) $select[] = '`updated_at`'; $sql = sprintf( 'SELECT %s FROM `%s` WHERE `%s` = :cid ORDER BY `%s` ASC', implode(',', $select), $table, $cols['col_customer'], $cols['col_name'] ); $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId]); $items = []; while ($row = $stmt->fetch()) { $items[] = $this->formatUserOutput($row); } $this->respond(['ok' => true, 'items' => $items]); } private function handleAccountUsersCreate(): void { $owner = $this->authService->requireAuth(); $this->ensureOwner($owner); $customerId = (int)($owner['customer_id'] ?? 0); $name = trim((string)($this->in['name'] ?? '')); $email = trim(strtolower((string)($this->in['email'] ?? ''))); $role = $this->sanitizeRole((string)($this->in['role'] ?? 'user')); if ($name === '' || $email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { $this->fail('Name und gültige E-Mail sind erforderlich', null, 422); } $this->assertEmailUnique($email, $customerId, null); $password = $this->generateReadablePassword(); $hash = $this->hashUserPassword($password); $cols = $this->authUserColumns(); $table = $cols['table']; $dbCols = $this->tableColumns($table); $data = []; $data[$cols['col_name']] = $name; $data[$cols['col_email']] = $email; $data[$cols['col_pass']] = $hash; if ($this->columnExists($dbCols, $cols['col_role'])) $data[$cols['col_role']] = $role; if ($this->columnExists($dbCols, $cols['col_status'])) $data[$cols['col_status']] = 1; if ($this->columnExists($dbCols, $cols['col_customer'])) $data[$cols['col_customer']] = $customerId; if ($this->columnExists($dbCols, 'created_at')) $data['created_at'] = date('Y-m-d H:i:s'); if ($this->columnExists($dbCols, 'updated_at')) $data['updated_at'] = date('Y-m-d H:i:s'); $columns = array_keys($data); $insertCols = implode(',', array_map(fn($c) => "`$c`", $columns)); $placeholders = implode(',', array_map(fn($c) => ":$c", $columns)); $sql = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $insertCols, $placeholders); $stmt = $this->pdo->prepare($sql); foreach ($data as $col => $value) $stmt->bindValue(":$col", $value); $stmt->execute(); $newId = (int)$this->pdo->lastInsertId(); $newUser = $this->fetchUserRow($newId, $customerId); $this->respond(['ok' => true, 'user' => $newUser, 'temp_password' => $password]); } private function handleAccountUsersUpdate(): void { $owner = $this->authService->requireAuth(); $this->ensureOwner($owner); $customerId = (int)($owner['customer_id'] ?? 0); $userId = (int)($this->in['user_id'] ?? 0); if ($userId <= 0) $this->fail('Ungültige Nutzer-ID', null, 422); $target = $this->fetchUserRow($userId, $customerId); if (!$target) $this->fail('Nutzer nicht gefunden', null, 404); $cols = $this->authUserColumns(); $table = $cols['table']; $dbCols = $this->tableColumns($table); $set = []; $params = [':id' => $userId]; $name = trim((string)($this->in['name'] ?? $target['name'])); $email = trim(strtolower((string)($this->in['email'] ?? $target['email']))); $role = $this->sanitizeRole((string)($this->in['role'] ?? $target['role'])); $isActive = isset($this->in['is_active']) ? (int)(bool)$this->in['is_active'] : (int)$target['is_active']; $resetPassword = !empty($this->in['reset_password']); if ($name === '' || $email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { $this->fail('Name und gültige E-Mail sind erforderlich', null, 422); } if (strtolower($email) !== strtolower($target['email'])) { $this->assertEmailUnique($email, $customerId, $userId); } if ($this->columnExists($dbCols, $cols['col_name'])) { $set[] = sprintf('`%s` = :name', $cols['col_name']); $params[':name'] = $name; } if ($this->columnExists($dbCols, $cols['col_email'])) { $set[] = sprintf('`%s` = :email', $cols['col_email']); $params[':email'] = $email; } if ($this->columnExists($dbCols, $cols['col_role'])) { if ($target['role'] === 'owner' && $role !== 'owner' && $this->countOwners($customerId, $userId) < 1) { $this->fail('Mindestens ein Owner muss bestehen bleiben', null, 422); } $set[] = sprintf('`%s` = :role', $cols['col_role']); $params[':role'] = $role; } if ($this->columnExists($dbCols, $cols['col_status'])) { if ($target['role'] === 'owner' && !$isActive && $this->countOwners($customerId, $userId) < 1) { $this->fail('Mindestens ein Owner muss aktiv bleiben', null, 422); } $set[] = sprintf('`%s` = :status', $cols['col_status']); $params[':status'] = $isActive; } $tempPassword = null; if ($resetPassword) { $tempPassword = $this->generateReadablePassword(); $hash = $this->hashUserPassword($tempPassword); $set[] = sprintf('`%s` = :pwd', $cols['col_pass']); $params[':pwd'] = $hash; } if ($this->columnExists($dbCols, 'updated_at')) { $set[] = '`updated_at` = :updated_at'; $params[':updated_at'] = date('Y-m-d H:i:s'); } if (!$set) $this->fail('Keine Änderungen erkannt', null, 422); $sql = sprintf('UPDATE `%s` SET %s WHERE `%s` = :id LIMIT 1', $table, implode(',', $set), $cols['col_id']); $stmt = $this->pdo->prepare($sql); foreach ($params as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $updated = $this->fetchUserRow($userId, $customerId); $resp = ['ok' => true, 'user' => $updated]; if ($tempPassword !== null) $resp['temp_password'] = $tempPassword; $this->respond($resp); } private function handleAccountUsersDelete(): void { $owner = $this->authService->requireAuth(); $this->ensureOwner($owner); $customerId = (int)($owner['customer_id'] ?? 0); $userId = (int)($this->in['user_id'] ?? 0); if ($userId <= 0) $this->fail('Ungültige Nutzer-ID', null, 422); if ($userId === (int)($owner['id'] ?? 0)) $this->fail('Du kannst dich nicht selbst löschen', null, 422); $cols = $this->authUserColumns(); $table = $cols['table']; $dbCols = $this->tableColumns($table); $target = $this->fetchUserRow($userId, $customerId); if (!$target) $this->fail('Nutzer nicht gefunden', null, 404); if ($target['role'] === 'owner' && $this->countOwners($customerId, $userId) < 1) { $this->fail('Mindestens ein Owner muss bestehen bleiben', null, 422); } if ($this->columnExists($dbCols, $cols['col_status'])) { $sql = sprintf('UPDATE `%s` SET `%s` = 0 WHERE `%s` = :id LIMIT 1', $table, $cols['col_status'], $cols['col_id']); $stmt = $this->pdo->prepare($sql); $stmt->execute([':id' => $userId]); } else { $sql = sprintf('DELETE FROM `%s` WHERE `%s` = :id LIMIT 1', $table, $cols['col_id']); $stmt = $this->pdo->prepare($sql); $stmt->execute([':id' => $userId]); } $this->respond(['ok' => true, 'deleted' => true]); } private function handleAccountSendersList(): void { $user = $this->authService->requireAuth(); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $table = $this->senderTable(); $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `label` ASC"); $stmt->execute([':cid' => $customerId]); $items = []; while ($row = $stmt->fetch()) { $items[] = $this->formatSenderRow($row); } $this->respond(['ok' => true, 'items' => $items]); } private function handleAccountSenderSave(): void { $user = $this->authService->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); $senderId = (int)($this->in['sender_id'] ?? 0); $label = trim((string)($this->in['label'] ?? '')); $fromName = trim((string)($this->in['from_name'] ?? '')); $fromEmail = trim((string)($this->in['from_email'] ?? '')); $replyTo = trim((string)($this->in['reply_to'] ?? '')); if ($label === '') $label = $fromName ?: $fromEmail; if ($fromEmail === '' || !filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) { $this->fail('Gültige Absender-Adresse erforderlich', null, 422); } if ($replyTo !== '' && !filter_var($replyTo, FILTER_VALIDATE_EMAIL)) { $this->fail('Ungültige Reply-To-Adresse', null, 422); } $table = $this->senderTable(); if ($senderId > 0) { $stmt = $this->pdo->prepare("UPDATE `$table` SET `label`=:label,`from_name`=:fname,`from_email`=:fmail,`reply_to`=:reply,`updated_at`=NOW() WHERE `id`=:id AND `customer_id`=:cid LIMIT 1"); $stmt->execute([ ':label' => $label, ':fname' => $fromName ?: null, ':fmail' => $fromEmail, ':reply' => $replyTo ?: null, ':id' => $senderId, ':cid' => $customerId, ]); if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404); } else { $stmt = $this->pdo->prepare("INSERT INTO `$table` (`customer_id`,`label`,`from_name`,`from_email`,`reply_to`,`created_at`,`updated_at`) VALUES (:cid,:label,:fname,:fmail,:reply,NOW(),NOW())"); $stmt->execute([ ':cid' => $customerId, ':label' => $label, ':fname' => $fromName ?: null, ':fmail' => $fromEmail, ':reply' => $replyTo ?: null, ]); $senderId = (int)$this->pdo->lastInsertId(); } $sender = $this->fetchSenderRow($customerId, $senderId); $this->respond(['ok' => true, 'sender' => $sender]); } private function handleAccountSenderDelete(): void { $user = $this->authService->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); $senderId = (int)($this->in['sender_id'] ?? 0); if ($senderId <= 0) $this->fail('Ungültige Sender-ID', null, 422); $table = $this->senderTable(); $stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => $senderId, ':cid' => $customerId]); if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404); $this->respond(['ok' => true, 'deleted' => true]); } private function handleDashboardMetrics(): void { $user = $this->authService->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $counts = $this->fetchResourceCounts($customerId); $usage = $this->listTemplateUsage($customerId); $this->respond([ 'ok' => true, 'counts' => $counts, 'usage' => $usage, ]); } private function handleDashboardResetUsage(): void { $user = $this->authService->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $raw = $this->in['template_id'] ?? $this->in['templates'] ?? null; $ids = $this->extractIdList($raw); if (!$ids) { $this->fail('template_id required', null, 422); } $this->resetTemplateUsage($customerId, $ids); $this->respond(['ok' => true]); } private function handleDownloadFile(string $type): void { $user = $this->authService->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId)); $baseDir = dirname(__DIR__); if ($type === 'bridge') { $path = $baseDir . '/download/emailtemplate_bridge.php'; } elseif ($type === 'sender') { $path = $baseDir . '/download/emailtemplate_sender.php'; } else { $this->fail('Unknown download type', $type, 404); } if (!is_file($path)) { $this->fail('Datei nicht gefunden', basename($path), 404); } $content = (string)file_get_contents($path); if ($type === 'bridge') { $content = str_replace('REPLACE_WITH_SHARED_TOKEN', $settings['bridge_token'] ?? '', $content); } else { $apiBase = $this->defaultApiBase(); $content = str_replace('REPLACE_WITH_SHARED_TOKEN', $settings['sender_token'] ?? '', $content); $content = str_replace('REPLACE_WITH_TEMPLATE_API_TOKEN', $settings['external_api_token'] ?? '', $content); if ($apiBase) { $content = str_replace('https://api.emailtemplate.it/external/render', $apiBase, $content); } } $this->respond([ 'ok' => true, 'file_name' => basename($path), 'content' => base64_encode($content), ]); } private function handleAccountBridgeTest(): void { $user = $this->authService->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); $bridgeUrl = trim((string)($this->in['bridge_url'] ?? '')); $bridgeToken = trim((string)($this->in['bridge_token'] ?? '')); if ($bridgeUrl === '' || $bridgeToken === '') { $settings = $this->getCustomerSettings($customerId); if ($bridgeUrl === '') $bridgeUrl = (string)($settings['bridge_url'] ?? ''); if ($bridgeToken === '') $bridgeToken = (string)($settings['bridge_token'] ?? ''); } if ($bridgeUrl === '' || $bridgeToken === '') { $this->fail('Bridge nicht konfiguriert', null, 422); } $settings = $this->getCustomerSettings($customerId); try { $schema = $this->fetchPlaceholderSchema($bridgeUrl, $bridgeToken, 0); } catch (Throwable $e) { $this->fail('Bridge request failed', $e->getMessage(), 502); return; } $tables = $schema['tables'] ?? []; if (!empty($settings['bridge_tables'])) { $tables = $this->filterSchemaTables($tables, $settings['bridge_tables']); } $this->respond([ 'ok' => true, 'tables' => $tables, 'fetched' => $schema['fetched'] ?? date(DATE_ATOM), ]); } private function handleDebugPhpInfo(): void { $user = $this->authService->requireAuth(); $this->ensureDebugUser($user); ob_start(); phpinfo(INFO_GENERAL | INFO_CONFIGURATION | INFO_MODULES | INFO_ENVIRONMENT); $html = ob_get_clean() ?: ''; $this->respond(['ok' => true, 'html' => $html]); } private function resolveBridgeConfig(?int $customerId): array { $fileConf = $this->conf['placeholders']['bridge'] ?? []; $settings = $customerId ? $this->getCustomerSettings($customerId) : []; $url = $settings['bridge_url'] ?? ($fileConf['url'] ?? ''); $token = $settings['bridge_token'] ?? ($fileConf['token'] ?? ''); $ttl = $fileConf['cache_ttl'] ?? 300; return ['url' => $url, 'token' => $token, 'cache_ttl' => $ttl]; } private function getCustomerSettings(int $customerId): array { if ($customerId <= 0) return []; $table = $this->customerSettingsTable(); $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :id LIMIT 1"); $stmt->execute([':id' => $customerId]); $row = $stmt->fetch(); return $row ? $this->formatCustomerSettingsRow($row) : []; } private function saveCustomerSettings(int $customerId, array $data): array { if ($customerId <= 0) return []; $allowed = ['bridge_url', 'bridge_token', 'sender_token', 'external_api_token', 'bridge_tables']; $fields = array_intersect_key($data, array_flip($allowed)); if (!$fields) return $this->getCustomerSettings($customerId); if (array_key_exists('bridge_tables', $fields)) { $normalized = $this->normalizeBridgeTables($fields['bridge_tables']); $fields['bridge_tables'] = $normalized ? $this->encodeBridgeTables($normalized) : null; } $fields['customer_id'] = $customerId; $columns = array_keys($fields); $insertCols = implode(',', array_map(fn($c) => "`$c`", $columns)); $placeholders = implode(',', array_map(fn($c) => ":$c", $columns)); $updates = []; foreach ($columns as $col) { if ($col === 'customer_id') continue; $updates[] = "`$col` = VALUES(`$col`)"; } $table = $this->customerSettingsTable(); $sql = "INSERT INTO `$table` ($insertCols) VALUES ($placeholders) ON DUPLICATE KEY UPDATE " . implode(',', $updates); $stmt = $this->pdo->prepare($sql); foreach ($fields as $col => $value) { $stmt->bindValue(":$col", $value); } $stmt->execute(); return $this->getCustomerSettings($customerId); } private function ensureSettingsTokens(int $customerId, array $settings): array { if ($customerId <= 0) return $settings; $changed = false; foreach (['bridge_token', 'sender_token', 'external_api_token'] as $key) { if (empty($settings[$key])) { $settings[$key] = $this->generateToken(); $changed = true; } } if ($changed) { $settings = $this->saveCustomerSettings($customerId, $settings); } return $settings; } private function formatCustomerSettingsRow(array $row): array { if (array_key_exists('bridge_tables', $row)) { $row['bridge_tables'] = $this->decodeBridgeTables($row['bridge_tables']); } else { $row['bridge_tables'] = []; } return $row; } private function normalizeBridgeTables($input): array { $items = []; if (is_string($input)) { $items = preg_split('/[\s,]+/', $input) ?: []; } elseif (is_array($input)) { $items = $input; } elseif ($input === null) { return []; } else { $items = [$input]; } $items = array_map(static function ($value) { return trim((string)$value); }, $items); $items = array_filter($items, fn($value) => $value !== ''); return array_values(array_unique($items)); } private function encodeBridgeTables(?array $tables): ?string { if (empty($tables)) return null; return json_encode(array_values($tables), JSON_UNESCAPED_SLASHES); } private function decodeBridgeTables($stored): array { if (is_array($stored)) { return $this->normalizeBridgeTables($stored); } $str = (string)$stored; if ($str === '') return []; $decoded = json_decode($str, true); if (is_array($decoded)) { return $this->normalizeBridgeTables($decoded); } return $this->normalizeBridgeTables($str); } private function filterSchemaTables(array $tables, array $allowed): array { if (empty($allowed)) return $tables; $allowedLower = array_map('strtolower', $allowed); $filtered = []; foreach ($tables as $entry) { if (is_array($entry)) { $name = strtolower((string)($entry['name'] ?? $entry['table'] ?? $entry['label'] ?? '')); if ($name !== '' && in_array($name, $allowedLower, true)) { $filtered[] = $entry; } } else { $name = strtolower((string)$entry); if ($name !== '' && in_array($name, $allowedLower, true)) { $filtered[] = $entry; } } } return $filtered; } private function customerSettingsTable(): string { return 'emailtemplate_customer_settings'; } private function generateToken(int $length = 48): string { return rtrim(strtr(base64_encode(random_bytes($length)), '+/', '-_'), '='); } private function generateReadablePassword(int $length = 12): string { $bytes = bin2hex(random_bytes($length)); return substr($bytes, 0, $length); } private function authUserColumns(): array { $db = $this->conf['auth']['db'] ?? []; return [ 'table' => $db['table'] ?? 'customer_users', 'col_id' => $db['col_id'] ?? 'id', 'col_email' => $db['col_user'] ?? 'email', 'col_pass' => $db['col_pass'] ?? 'password_hash', 'col_name' => $db['col_name'] ?? 'name', 'col_role' => $db['col_role'] ?? 'role', 'col_status' => $db['col_status'] ?? 'is_active', 'col_customer' => $db['customer_fk'] ?? 'customer_id', ]; } private function ensureAuthUserHydrated(array $user): array { $role = (string)($user['role'] ?? ''); $hasOwnerFlag = isset($user['permissions']['owner']); if ($role !== '' && $hasOwnerFlag) { return $user; } $userId = (int)($user['id'] ?? 0); if ($userId <= 0 || !$this->pdo) { if ($role === '') $user['role'] = 'user'; if (!$hasOwnerFlag) { $user['permissions']['owner'] = ($user['role'] ?? '') === 'owner'; } return $user; } try { $cols = $this->authUserColumns(); $table = $cols['table']; $dbCols = $this->tableColumns($table); $where = sprintf('`%s` = :id', $cols['col_id']); $params = [':id' => $userId]; $customerId = (int)($user['customer_id'] ?? 0); if ($customerId > 0 && $this->columnExists($dbCols, $cols['col_customer'])) { $where .= sprintf(' AND `%s` = :cid', $cols['col_customer']); $params[':cid'] = $customerId; } $sql = sprintf('SELECT `%s` FROM `%s` WHERE %s LIMIT 1', $cols['col_role'], $table, $where); $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $row = $stmt->fetch(); if ($row && isset($row[$cols['col_role']])) { $roleValue = $this->sanitizeRole((string)$row[$cols['col_role']]); $user['role'] = $roleValue; $user['permissions']['owner'] = ($roleValue === 'owner'); $_SESSION['auth']['role'] = $roleValue; $_SESSION['auth']['permissions']['owner'] = ($roleValue === 'owner'); } else { if ($role === '') $user['role'] = 'user'; if (!$hasOwnerFlag) { $user['permissions']['owner'] = ($user['role'] ?? '') === 'owner'; } } } catch (Throwable $e) { if ($role === '') $user['role'] = 'user'; if (!$hasOwnerFlag) { $user['permissions']['owner'] = ($user['role'] ?? '') === 'owner'; } } return $user; } private function columnExists(array $columns, string $name): bool { if ($name === '') return false; return in_array($name, $columns, true); } private function sanitizeRole(string $role): string { $role = strtolower($role); $valid = ['owner', 'admin', 'editor', 'viewer']; return in_array($role, $valid, true) ? $role : 'user'; } private function assertEmailUnique(string $email, int $customerId, ?int $excludeId): void { $cols = $this->authUserColumns(); $table = $cols['table']; $dbCols = $this->tableColumns($table); $conditions = [sprintf('`%s` = :email', $cols['col_email'])]; if ($this->columnExists($dbCols, $cols['col_customer'])) { $conditions[] = sprintf('`%s` = :cid', $cols['col_customer']); } if ($excludeId) { $conditions[] = sprintf('`%s` != :exclude', $cols['col_id']); } $sql = sprintf('SELECT COUNT(*) FROM `%s` WHERE %s', $table, implode(' AND ', $conditions)); $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':email', $email); if ($this->columnExists($dbCols, $cols['col_customer'])) { $stmt->bindValue(':cid', $customerId); } if ($excludeId) $stmt->bindValue(':exclude', $excludeId, PDO::PARAM_INT); $stmt->execute(); if ((int)$stmt->fetchColumn() > 0) { $this->fail('E-Mail-Adresse ist bereits vergeben', null, 422); } } private function fetchUserRow(int $userId, int $customerId): array { $cols = $this->authUserColumns(); $table = $cols['table']; $sql = sprintf( 'SELECT * FROM `%s` WHERE `%s` = :id AND `%s` = :cid LIMIT 1', $table, $cols['col_id'], $cols['col_customer'] ); $stmt = $this->pdo->prepare($sql); $stmt->execute([':id' => $userId, ':cid' => $customerId]); $row = $stmt->fetch(); if (!$row) $this->fail('Nutzer nicht gefunden', null, 404); return $this->formatUserOutput([ 'user_id' => $row[$cols['col_id']], 'name' => $row[$cols['col_name']] ?? '', 'email' => $row[$cols['col_email']] ?? '', 'role' => $row[$cols['col_role']] ?? 'user', 'is_active' => $row[$cols['col_status']] ?? 1, 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]); } private function senderTable(): string { return 'emailtemplate_sender_identities'; } private function fetchSenderRow(int $customerId, int $senderId): array { if ($customerId <= 0 || $senderId <= 0) { $this->fail('Absender nicht gefunden', null, 404); } $table = $this->senderTable(); $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => $senderId, ':cid' => $customerId]); $row = $stmt->fetch(); if (!$row) $this->fail('Absender nicht gefunden', null, 404); return $this->formatSenderRow($row); } private function formatSenderRow(array $row): array { return [ 'id' => (int)($row['id'] ?? 0), 'label' => $row['label'] ?? '', 'from_name' => $row['from_name'] ?? '', 'from_email' => $row['from_email'] ?? '', 'reply_to' => $row['reply_to'] ?? '', 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } private function formatUserOutput(array $row): array { return [ 'id' => (int)($row['user_id'] ?? $row['id'] ?? 0), 'name' => $row['name'] ?? '', 'email' => $row['email'] ?? '', 'role' => $row['role'] ?? 'user', 'is_active' => (int)($row['is_active'] ?? 1), 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } private function countOwners(int $customerId, ?int $excludeId = null): int { $cols = $this->authUserColumns(); $table = $cols['table']; $dbCols = $this->tableColumns($table); $conditions = [ sprintf('`%s` = :cid', $cols['col_customer']), sprintf('`%s` = :role', $cols['col_role']), ]; if ($this->columnExists($dbCols, $cols['col_status'])) { $conditions[] = sprintf('`%s` = 1', $cols['col_status']); } if ($excludeId) { $conditions[] = sprintf('`%s` != :exclude', $cols['col_id']); } $sql = sprintf('SELECT COUNT(*) FROM `%s` WHERE %s', $table, implode(' AND ', $conditions)); $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':cid', $customerId); $stmt->bindValue(':role', 'owner'); if ($excludeId) $stmt->bindValue(':exclude', $excludeId, PDO::PARAM_INT); $stmt->execute(); return (int)$stmt->fetchColumn(); } private function verifyUserPasswordValue(string $input, string $stored): bool { if (preg_match('~^\$2[aby]\$~', $stored) || strpos($stored, '$argon2') === 0) return password_verify($input, $stored); $legacy = strtolower($this->conf['auth']['db']['legacy'] ?? ''); if ($legacy === 'md5') return hash_equals($stored, md5($input)); if ($legacy === 'sha1') return hash_equals($stored, sha1($input)); if (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored); return hash_equals($stored, $input); } private function hashUserPassword(string $password): string { return password_hash($password, PASSWORD_DEFAULT); } private function ensureOwner(array $user): void { $this->ensureRole($user, ['owner']); } private function ensureRole(array $user, array $roles): void { $role = strtolower((string)($user['role'] ?? '')); $allowed = array_values(array_unique(array_map('strtolower', $roles))); if (!in_array($role, $allowed, true)) { $this->fail('Unzureichende Berechtigungen', null, 403); } } private function ensureDebugUser(array $user): void { $email = strtolower((string)($user['email'] ?? '')); if ($email !== 'madmin@papa-kind-treff.info') { $this->fail('Debug nicht erlaubt', null, 403); } } private function defaultApiBase(): string { $base = $this->conf['base_url'] ?? ''; return $base ? rtrim($base, '/') . '/api.php' : '/api.php'; } }