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 ... */ $corsConfig = $this->conf['cors'] ?? '*'; $originHeader = $_SERVER['HTTP_ORIGIN'] ?? ''; $allowedOrigin = null; if (is_array($corsConfig)) { if ($originHeader && in_array($originHeader, $corsConfig, true)) { $allowedOrigin = $originHeader; } } elseif (is_string($corsConfig)) { if ($corsConfig === '*' && $originHeader !== '') { $allowedOrigin = $originHeader; } else { $allowedOrigin = $corsConfig; } } if ($allowedOrigin) { header('Access-Control-Allow-Origin: ' . $allowedOrigin); header('Vary: Origin'); header('Access-Control-Allow-Credentials: true'); } elseif ($corsConfig === '*') { header('Access-Control-Allow-Origin: *'); } header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization'); 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', 'content', 'sections_config', 'content_versions']; 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', 'content_items' => $tables['content_items'] ?? 'emailtemplate_content_items', 'content_sections' => $tables['content_sections'] ?? 'emailtemplate_content_sections', 'content_versions' => $tables['content_versions'] ?? 'emailtemplate_content_versions', ]; } 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 { return $this->ensureAuthUserHydrated($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; } private function normalizeApiName(string $value): string { $value = trim($value); $value = preg_replace('/\s+/', '-', $value); $value = preg_replace('/[^A-Za-z0-9_-]+/', '-', $value); $value = preg_replace('/-+/', '-', $value); return trim($value, '-'); } private function normalizeSectionSlug(string $value): string { $value = strtolower(trim($value)); $value = preg_replace('/\s+/', '-', $value); $value = preg_replace('/[^a-z0-9_-]+/', '-', $value); $value = preg_replace('/-+/', '-', $value); return trim($value, '-'); } private function contentItemsTable(): string { return $this->tableMap['content_items'] ?? $this->lookupTableName('content_items', 'emailtemplate_content_items'); } private function contentSectionsTable(): string { return $this->tableMap['content_sections'] ?? $this->lookupTableName('content_sections', 'emailtemplate_content_sections'); } private function contentVersionsTable(): string { return $this->tableMap['content_versions'] ?? $this->lookupTableName('content_versions', 'emailtemplate_content_versions'); } private function resolveContentItemColumns(string $table): array { $cols = $this->tableColumns($table); return [ 'category' => $this->firstExisting($cols, ['category', 'cat']), 'html' => $this->firstExisting($cols, ['html', 'html_content', 'body', 'markup', 'content']), 'css' => $this->firstExisting($cols, ['css', 'css_content']), 'json' => $this->firstExisting($cols, ['json_content']), 'editor' => $this->firstExisting($cols, ['editor_type', 'editor']), 'craft' => $this->firstExisting($cols, ['craft_json', 'craft_content', 'craft_data']), 'settings' => $this->firstExisting($cols, ['settings_json', 'settings']), ]; } private function resolveContentVersionColumns(string $table): array { $cols = $this->tableColumns($table); return [ 'json' => $this->firstExisting($cols, ['json_content']), 'html' => $this->firstExisting($cols, ['html', 'html_content']), 'css' => $this->firstExisting($cols, ['css', 'css_content']), 'editor' => $this->firstExisting($cols, ['editor_type', 'editor']), 'craft' => $this->firstExisting($cols, ['craft_json', 'craft_content', 'craft_data']), 'settings' => $this->firstExisting($cols, ['settings_json', 'settings']), 'is_active' => $this->firstExisting($cols, ['is_active']), 'was_active' => $this->firstExisting($cols, ['was_active']), ]; } private function useUnifiedContent(): bool { return $this->tableExists($this->contentItemsTable()) && $this->tableExists($this->contentSectionsTable()); } private function createContentVersion(array $current, array $itemCols, int $customerId, int $sectionId): ?int { $table = $this->contentVersionsTable(); if (!$this->tableExists($table)) return null; $contentId = (int)($current['id'] ?? 0); if ($contentId <= 0) return null; $jsonCol = $itemCols['json'] ?? null; $htmlCol = $itemCols['html'] ?? null; $cssCol = $itemCols['css'] ?? null; $editorCol = $itemCols['editor'] ?? null; $craftCol = $itemCols['craft'] ?? null; $settingsCol = $itemCols['settings'] ?? null; $json = $jsonCol ? ($current[$jsonCol] ?? null) : null; $html = $htmlCol ? ($current[$htmlCol] ?? null) : null; $css = $cssCol ? ($current[$cssCol] ?? null) : null; $editorType = $editorCol ? ($current[$editorCol] ?? null) : null; $craftJson = $craftCol ? ($current[$craftCol] ?? null) : null; $settings = $settingsCol ? ($current[$settingsCol] ?? null) : null; try { $stmt = $this->pdo->prepare("SELECT MAX(`version_no`) FROM `$table` WHERE `content_id` = :cid"); $stmt->execute([':cid' => $contentId]); $nextVersion = (int)($stmt->fetchColumn() ?: 0) + 1; $versionCols = $this->resolveContentVersionColumns($table); $data = [ 'customer_id' => $customerId, 'content_id' => $contentId, 'section_id' => $sectionId, 'version_no' => $nextVersion, ]; if ($versionCols['editor']) $data[$versionCols['editor']] = $editorType; if ($versionCols['json']) $data[$versionCols['json']] = $json; if ($versionCols['html']) $data[$versionCols['html']] = $html; if ($versionCols['css']) $data[$versionCols['css']] = $css; if ($versionCols['craft']) $data[$versionCols['craft']] = $craftJson; if ($versionCols['settings']) $data[$versionCols['settings']] = $settings; $columns = array_keys($data); $insertCols = implode(',', array_map(fn($c) => "`$c`", $columns)); $placeholders = implode(',', array_map(fn($c) => ":$c", $columns)); $stmt = $this->pdo->prepare("INSERT INTO `$table` ($insertCols) VALUES ($placeholders)"); foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); $stmt->execute(); $newId = (int)$this->pdo->lastInsertId(); $this->applyContentVersionRetention($customerId, $contentId); return $newId; } catch (Throwable $e) { // Versioning darf nicht das Speichern blockieren. return null; } } private function applyContentVersionRetention(int $customerId, int $contentId): void { $limit = $this->getContentVersionRetentionLimit($customerId); if ($limit <= 0) return; $table = $this->contentVersionsTable(); if (!$this->tableExists($table)) return; try { $versionCols = $this->resolveContentVersionColumns($table); $isActiveCol = $versionCols['is_active']; $activeFilter = $isActiveCol ? " AND (`$isActiveCol` IS NULL OR `$isActiveCol` = 0)" : ''; $keepSql = "SELECT `id` FROM `$table` WHERE `content_id` = :cid ORDER BY `version_no` DESC, `id` DESC LIMIT :lim"; $deleteSql = "DELETE FROM `$table` WHERE `content_id` = :cid$activeFilter AND `id` NOT IN (SELECT `id` FROM ($keepSql) AS keep_ids)"; $stmt = $this->pdo->prepare($deleteSql); $stmt->bindValue(':cid', $contentId, PDO::PARAM_INT); $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); $stmt->execute(); } catch (Throwable $e) { // Retention darf nicht das Speichern blockieren. } } private function getContentVersionRetentionLimit(int $customerId): int { if ($customerId <= 0) return 0; $settings = $this->getCustomerSettings($customerId); $limit = (int)($settings['versions_retention'] ?? 0); return max(0, $limit); } private function activateContentVersion(int $customerId, int $contentId, int $versionId): bool { $table = $this->contentVersionsTable(); if (!$this->tableExists($table)) return false; $versionCols = $this->resolveContentVersionColumns($table); $isActiveCol = $versionCols['is_active']; $wasActiveCol = $versionCols['was_active']; if (!$isActiveCol) return false; $this->pdo->prepare("UPDATE `$table` SET `$isActiveCol` = 0 WHERE `customer_id` = :cid AND `content_id` = :content") ->execute([':cid' => $customerId, ':content' => $contentId]); $set = "`$isActiveCol` = 1"; if ($wasActiveCol) $set .= ", `$wasActiveCol` = 1"; $stmt = $this->pdo->prepare( "UPDATE `$table` SET $set WHERE `customer_id` = :cid AND `content_id` = :content AND `id` = :id LIMIT 1" ); $stmt->execute([':cid' => $customerId, ':content' => $contentId, ':id' => $versionId]); return $stmt->rowCount() > 0; } private function deactivateContentVersion(int $customerId, int $contentId): bool { $table = $this->contentVersionsTable(); if (!$this->tableExists($table)) return false; $versionCols = $this->resolveContentVersionColumns($table); $isActiveCol = $versionCols['is_active']; if (!$isActiveCol) return false; $stmt = $this->pdo->prepare("UPDATE `$table` SET `$isActiveCol` = 0 WHERE `customer_id` = :cid AND `content_id` = :content"); $stmt->execute([':cid' => $customerId, ':content' => $contentId]); return $stmt->rowCount() > 0; } private function isLegacyContentKind(string $kind): bool { return in_array($kind, ['templates', 'sections', 'blocks', 'snippets'], true); } private function resolveLegacySectionDefaults(string $kind): array { if ($kind === 'templates') { return ['name' => 'Emailtemplate', 'slug' => 'emailtemplate', 'is_template' => true]; } $name = ucfirst($kind); return ['name' => $name, 'slug' => $this->normalizeSectionSlug($kind), 'is_template' => false]; } private function resolveSectionSlugFromKind(string $kind): string { $legacy = $this->normalizeResourceKind($kind); if ($legacy) { return $legacy === 'templates' ? 'emailtemplate' : $legacy; } return $this->normalizeSectionSlug($kind); } private function fetchContentSectionBySlug(int $customerId, string $slug): ?array { $table = $this->contentSectionsTable(); if (!$this->tableExists($table)) return null; $sql = "SELECT * FROM `$table` WHERE `customer_id` = :cid AND `slug` = :slug LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId, ':slug' => $slug]); $row = $stmt->fetch(); return $row ?: null; } private function fetchContentSectionById(int $customerId, int $id): ?array { if ($id <= 0) return null; $table = $this->contentSectionsTable(); if (!$this->tableExists($table)) return null; $sql = "SELECT * FROM `$table` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId, ':id' => $id]); $row = $stmt->fetch(); return $row ?: null; } private function ensureContentSection(int $customerId, string $name, string $slug, bool $isTemplate): array { $slug = $this->normalizeSectionSlug($slug); $existing = $this->fetchContentSectionBySlug($customerId, $slug); if ($existing) return $existing; $table = $this->contentSectionsTable(); if (!$this->tableExists($table)) { $this->fail('Sections table not available', null, 500); } $position = 0; if ($isTemplate) { $stmt = $this->pdo->prepare("UPDATE `$table` SET `position` = `position` + 1 WHERE `customer_id` = :cid"); $stmt->execute([':cid' => $customerId]); $position = 0; } else { $stmt = $this->pdo->prepare("SELECT MAX(`position`) FROM `$table` WHERE `customer_id` = :cid"); $stmt->execute([':cid' => $customerId]); $position = (int)($stmt->fetchColumn() ?: 0) + 1; } $stmt = $this->pdo->prepare( "INSERT INTO `$table` (`customer_id`,`name`,`slug`,`position`,`is_template`) VALUES (:cid,:name,:slug,:pos,:tpl)" ); $stmt->execute([ ':cid' => $customerId, ':name' => $name, ':slug' => $slug, ':pos' => $position, ':tpl' => $isTemplate ? 1 : 0, ]); $id = (int)$this->pdo->lastInsertId(); return $this->fetchContentSectionById($customerId, $id) ?? [ 'id' => $id, 'customer_id' => $customerId, 'name' => $name, 'slug' => $slug, 'position' => $position, 'is_template' => $isTemplate ? 1 : 0, ]; } private function ensureEmailtemplateSection(int $customerId): array { $existing = $this->fetchContentSectionBySlug($customerId, 'emailtemplate'); if ($existing) { $needsUpdate = false; $updates = []; if (empty($existing['is_template'])) { $updates[] = "`is_template` = 1"; $needsUpdate = true; } if (($existing['name'] ?? '') !== 'Emailtemplate') { $updates[] = "`name` = 'Emailtemplate'"; $needsUpdate = true; } if (($existing['slug'] ?? '') !== 'emailtemplate') { $updates[] = "`slug` = 'emailtemplate'"; $needsUpdate = true; } if ((int)($existing['position'] ?? 0) !== 0) { $updates[] = "`position` = 0"; $needsUpdate = true; } if ($needsUpdate) { $table = $this->contentSectionsTable(); if ($this->tableExists($table)) { $sql = "UPDATE `$table` SET " . implode(',', $updates) . " WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':id' => (int)$existing['id'], ':cid' => $customerId]); } $existing = $this->fetchContentSectionById($customerId, (int)$existing['id']) ?? $existing; } return $existing; } return $this->ensureContentSection($customerId, 'Emailtemplate', 'emailtemplate', true); } private function resolveSectionFromInput(int $customerId): ?array { $sectionId = (int)$this->val($this->in, ['section_id', 'section', 'sectionId'], 0); if ($sectionId > 0) { return $this->fetchContentSectionById($customerId, $sectionId); } $sectionSlug = trim((string)$this->val($this->in, ['section_slug', 'sectionSlug', 'section_code'], '')); if ($sectionSlug !== '') { return $this->fetchContentSectionBySlug($customerId, $this->normalizeSectionSlug($sectionSlug)); } return null; } private function assertTemplateApiNameUnique( string $table, string $apiCol, string $idCol, int $customerId, string $apiName, ?int $excludeId ): void { $sql = "SELECT COUNT(*) FROM `$table` WHERE `$apiCol` = :api AND `customer_id` = :cid"; $params = [':api' => $apiName, ':cid' => $customerId]; if ($excludeId !== null && $excludeId > 0) { $sql .= " AND `$idCol` <> :id"; $params[':id'] = $excludeId; } $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $count = (int)$stmt->fetchColumn(); if ($count > 0) { $this->fail('api_name already exists', ['api_name' => $apiName], 409); } } private function assertContentApiNameUnique( string $table, string $apiCol, string $idCol, int $customerId, string $apiName, ?int $excludeId ): void { $sql = "SELECT COUNT(*) FROM `$table` WHERE `$apiCol` = :api AND `customer_id` = :cid"; $params = [':api' => $apiName, ':cid' => $customerId]; if ($excludeId !== null && $excludeId > 0) { $sql .= " AND `$idCol` <> :id"; $params[':id'] = $excludeId; } $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $count = (int)$stmt->fetchColumn(); if ($count > 0) { $this->fail('api_name already exists', ['api_name' => $apiName], 409); } } // ================================================================= // 🚀 CRUD HANDLER METHODEN // ================================================================= private function handleContentList(?array $fixedSection = null): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $itemsTable = $this->contentItemsTable(); $sectionsTable = $this->contentSectionsTable(); $versionsTable = $this->contentVersionsTable(); if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) { $this->fail('Content tables not available', null, 500); } $itemCols = $this->resolveContentItemColumns($itemsTable); $catCol = $itemCols['category']; $htmlCol = $itemCols['html']; $jsonCol = $itemCols['json']; $onlyActive = (int)$this->val($this->in, ['active_only', 'only_active', 'active'], 0) === 1; $versionCols = $onlyActive && $this->tableExists($versionsTable) ? $this->resolveContentVersionColumns($versionsTable) : null; $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); $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 i.`customer_id` = :cid "; $params = [':cid' => $customerId]; if ($section && !empty($section['id'])) { $where .= " AND i.`section_id` = :sid "; $params[':sid'] = (int)$section['id']; } if ($q !== '') { $where .= " AND (i.`name` LIKE :q"; if ($catCol) { $where .= " OR i.`$catCol` LIKE :q"; } $where .= ") "; $params[':q'] = '%' . $q . '%'; } $join = ''; $select = "i.*, s.`name` AS section_name, s.`slug` AS section_slug, s.`position` AS section_position, s.`is_template` AS section_is_template"; if ($onlyActive && $versionCols) { $join = " JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1"; $select .= ", v.`id` AS active_version_id, v.`version_no` AS active_version_no, v.`is_active` AS version_is_active, v.`was_active` AS version_was_active"; if (!empty($versionCols['html'])) $select .= ", v.`{$versionCols['html']}` AS version_html"; if (!empty($versionCols['json'])) $select .= ", v.`{$versionCols['json']}` AS version_json"; if (!empty($versionCols['craft'])) $select .= ", v.`{$versionCols['craft']}` AS version_craft"; if (!empty($versionCols['editor'])) $select .= ", v.`{$versionCols['editor']}` AS version_editor"; } $sql = "SELECT $select FROM `$itemsTable` i JOIN `$sectionsTable` s ON s.`id` = i.`section_id` $join $where ORDER BY i.`updated_at` DESC, i.`id` DESC LIMIT :off,:lim"; $stmt = $this->pdo->prepare($sql); 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['id'] ?? null, 'name' => $r['name'] ?? null, 'api_name' => $r['api_name'] ?? null, 'category' => $catCol ? ($r[$catCol] ?? null) : null, 'section_id' => $r['section_id'] ?? null, 'section_name' => $r['section_name'] ?? null, 'section_slug' => $r['section_slug'] ?? null, 'section_position' => $r['section_position'] ?? null, 'section_is_template' => (int)($r['section_is_template'] ?? 0), 'updated_at' => $r['updated_at'] ?? null, 'created_at' => $r['created_at'] ?? null, ]; if ($onlyActive && $versionCols) { if (!empty($r['active_version_id'])) $item['active_version_id'] = (int)$r['active_version_id']; if (array_key_exists('version_html', $r)) $item['html'] = (string)($r['version_html'] ?? ''); if (array_key_exists('version_json', $r)) $item['content'] = $r['version_json']; if (array_key_exists('version_craft', $r)) $item['craft_json'] = $r['version_craft']; if (array_key_exists('version_editor', $r)) $item['editor_type'] = $r['version_editor']; } else { if ($htmlCol && array_key_exists($htmlCol, $r)) $item['html'] = (string)($r[$htmlCol] ?? ''); if ($jsonCol && array_key_exists($jsonCol, $r)) $item['content'] = $r[$jsonCol]; } $out[] = $item; } $this->respond([ 'ok' => true, 'kind' => 'content', 'items' => $out, 'data' => $out, 'count' => count($out), 'offset' => $offset, 'limit' => $limit, ]); } private function handleContentGet(?array $fixedSection = null): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $id = $this->pullId($this->in); if ($id === null || $id === '') $this->fail('id required', null, 422); $itemsTable = $this->contentItemsTable(); $sectionsTable = $this->contentSectionsTable(); $versionsTable = $this->contentVersionsTable(); if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) { $this->fail('Content tables not available', null, 500); } $itemCols = $this->resolveContentItemColumns($itemsTable); $htmlCol = $itemCols['html']; $jsonCol = $itemCols['json']; $craftCol = $itemCols['craft']; $editorCol = $itemCols['editor']; $onlyActive = (int)$this->val($this->in, ['active_only', 'only_active', 'active'], 0) === 1; $versionCols = $onlyActive && $this->tableExists($versionsTable) ? $this->resolveContentVersionColumns($versionsTable) : null; $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); $params = [':cid' => $customerId, ':id' => $id]; $where = " WHERE i.`customer_id` = :cid AND i.`id` = :id "; if ($section && !empty($section['id'])) { $where .= " AND i.`section_id` = :sid "; $params[':sid'] = (int)$section['id']; } $select = "i.*, s.`name` AS section_name, s.`slug` AS section_slug, s.`position` AS section_position, s.`is_template` AS section_is_template"; $join = ''; if ($onlyActive && $versionCols) { $join = "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1"; if (!empty($versionCols['html'])) $select .= ", v.`{$versionCols['html']}` AS version_html"; if (!empty($versionCols['json'])) $select .= ", v.`{$versionCols['json']}` AS version_json"; if (!empty($versionCols['craft'])) $select .= ", v.`{$versionCols['craft']}` AS version_craft"; if (!empty($versionCols['editor'])) $select .= ", v.`{$versionCols['editor']}` AS version_editor"; $select .= ", v.`id` AS active_version_id, v.`version_no` AS active_version_no, v.`is_active` AS version_is_active, v.`was_active` AS version_was_active"; } $sql = "SELECT $select FROM `$itemsTable` i JOIN `$sectionsTable` s ON s.`id` = i.`section_id` $join $where LIMIT 1"; $stmt = $this->pdo->prepare($sql); foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); $stmt->execute(); $row = $stmt->fetch(); if (!$row) $this->fail('Not found', ['id' => $id], 404); $html = $htmlCol ? (string)($row[$htmlCol] ?? '') : ''; $json = $jsonCol ? ($row[$jsonCol] ?? null) : null; if ($onlyActive && $versionCols) { if (array_key_exists('version_html', $row)) $html = (string)($row['version_html'] ?? $html); if (array_key_exists('version_json', $row)) $json = $row['version_json'] ?? $json; } $gjsComponents = []; if ($json !== null) { $decoded = json_decode((string)$json, true); if (is_array($decoded)) $gjsComponents = $decoded; } if (!$gjsComponents && $html !== '') { $gjsComponents = $this->parseHtmlToGjsComponents($html); } $item = $row; $item['content'] = $json; $item['section_name'] = $row['section_name'] ?? null; $item['section_slug'] = $row['section_slug'] ?? null; $item['section_position'] = $row['section_position'] ?? null; $item['section_is_template'] = (int)($row['section_is_template'] ?? 0); $editorType = $editorCol ? ($row[$editorCol] ?? null) : null; $craftJson = $craftCol ? ($row[$craftCol] ?? null) : null; if ($onlyActive && $versionCols) { if (array_key_exists('version_editor', $row)) $editorType = $row['version_editor'] ?? $editorType; if (array_key_exists('version_craft', $row)) $craftJson = $row['version_craft'] ?? $craftJson; } $this->respond([ 'ok' => true, 'kind' => 'content', 'id' => $row['id'] ?? $id, 'item' => $item, 'data' => $item, 'html' => $html, 'content' => $json, 'gjs_components' => $gjsComponents, 'editor_type' => $editorType, 'craft_json' => $craftJson, ]); } private function handleContentCreate(?array $fixedSection = null): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $itemsTable = $this->contentItemsTable(); if (!$this->tableExists($itemsTable)) { $this->fail('Content table not available', null, 500); } $itemCols = $this->resolveContentItemColumns($itemsTable); $catCol = $itemCols['category']; $htmlCol = $itemCols['html']; $jsonCol = $itemCols['json']; $editorCol = $itemCols['editor']; $craftCol = $itemCols['craft']; $settingsCol = $itemCols['settings']; $name = trim((string)$this->val($this->in, ['name', 'title'], '')); if ($name === '') $this->fail('name required', null, 422); $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); if (!$section) $this->fail('section required', null, 422); $apiRaw = trim((string)$this->val($this->in, ['api_name', 'apiName', 'api'], '')); $apiName = $apiRaw !== '' ? $this->normalizeApiName($apiRaw) : ''; $isTemplate = !empty($section['is_template']); if ($isTemplate && $apiName === '') { $this->fail('api_name required', null, 422); } if ($apiName !== '') { $this->assertContentApiNameUnique($itemsTable, 'api_name', 'id', $customerId, $apiName, null); } else { $apiName = null; } $html = $this->val($this->in, ['html', 'body', 'markup', 'content'], null); $json = $this->val($this->in, ['content_json', 'json', 'structure_json'], null); $category = $this->val($this->in, ['category', 'cat'], null); $editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], ''))); $craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null); $settings = $this->val($this->in, ['settings_json', 'settings'], null); $data = [ 'customer_id' => $customerId, 'section_id' => (int)$section['id'], 'name' => $name, 'api_name' => $apiName, ]; if ($category !== null && $catCol) $data[$catCol] = (string)$category; if ($editorType !== '' && $editorCol) $data[$editorCol] = $editorType; if ($craftJson !== null && $craftCol) $data[$craftCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson); if ($settings !== null && $settingsCol) $data[$settingsCol] = is_string($settings) ? $settings : $this->encodeJson($settings); if ($json !== null) { if (!$jsonCol) $this->fail('json_content column missing', null, 500); $components = is_string($json) ? json_decode($json, true) : $json; if (is_array($components)) { $components = $this->cleanReferenceComponents($components); $data[$jsonCol] = $this->encodeJson($components); } else { $data[$jsonCol] = is_string($json) ? $json : ''; } if ($html !== null) { if (!$htmlCol) $this->fail('html column missing', null, 500); $data[$htmlCol] = (string)$html; } } elseif ($html !== null) { if (!$htmlCol) $this->fail('html column missing', null, 500); $data[$htmlCol] = (string)$html; } $columns = array_keys($data); $insertCols = implode(',', array_map(fn($c) => "`$c`", $columns)); $placeholders = implode(',', array_map(fn($c) => ":$c", $columns)); $stmt = $this->pdo->prepare("INSERT INTO `$itemsTable` ($insertCols) VALUES ($placeholders)"); foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); $stmt->execute(); $newId = (int)$this->pdo->lastInsertId(); $activateVersion = (int)$this->val($this->in, ['activate_version', 'activate', 'set_active'], 0) === 1; try { $stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => $newId, ':cid' => $customerId]); $row = $stmt->fetch(); if ($row) { $vid = $this->createContentVersion($row, $itemCols, $customerId, (int)$section['id']); if ($activateVersion && $vid) { $this->activateContentVersion($customerId, (int)$row['id'], $vid); } } } catch (Throwable $e) { // ignore versioning failures on create } $this->respond(['ok' => true, 'kind' => 'content', 'id' => $newId, 'item' => ['id' => $newId, 'name' => $name]]); } private function handleContentUpdate(?array $fixedSection = null): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $itemsTable = $this->contentItemsTable(); if (!$this->tableExists($itemsTable)) { $this->fail('Content table not available', null, 500); } $itemCols = $this->resolveContentItemColumns($itemsTable); $catCol = $itemCols['category']; $htmlCol = $itemCols['html']; $jsonCol = $itemCols['json']; $editorCol = $itemCols['editor']; $craftCol = $itemCols['craft']; $settingsCol = $itemCols['settings']; $id = $this->pullId($this->in); if ($id === null || $id === '') $this->fail('id required', null, 422); $stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1"); $stmt->execute([':cid' => $customerId, ':id' => $id]); $current = $stmt->fetch(); if (!$current) $this->fail('Not found', ['id' => $id], 404); $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); if (!$section) { $section = $this->fetchContentSectionById($customerId, (int)($current['section_id'] ?? 0)); } if (!$section) $this->fail('section required', null, 422); $data = []; $name = $this->val($this->in, ['name', 'title'], null); if ($name !== null) $data['name'] = (string)$name; $category = $this->val($this->in, ['category', 'cat'], null); if ($category !== null && $catCol) $data[$catCol] = (string)$category; $apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null); $apiName = $apiRaw !== null ? $this->normalizeApiName((string)$apiRaw) : null; $isTemplate = !empty($section['is_template']); if ($isTemplate && $apiRaw === null && empty($current['api_name'])) { $this->fail('api_name required', null, 422); } if ($apiName !== null) { if ($apiName === '' && $isTemplate) { $this->fail('api_name required', null, 422); } if ($apiName !== '') { $this->assertContentApiNameUnique($itemsTable, 'api_name', 'id', $customerId, $apiName, (int)$id); $data['api_name'] = $apiName; } else { $data['api_name'] = null; } } $sectionId = $section['id'] ?? null; if ($sectionId && (int)$sectionId !== (int)($current['section_id'] ?? 0)) { $data['section_id'] = (int)$sectionId; } $html = $this->val($this->in, ['html', 'body', 'markup', 'content'], null); $json = $this->val($this->in, ['content_json', 'json', 'structure_json'], null); if ($json !== null) { if (!$jsonCol) $this->fail('json_content column missing', null, 500); $components = is_string($json) ? json_decode($json, true) : $json; if (is_array($components)) { $components = $this->cleanReferenceComponents($components); $data[$jsonCol] = $this->encodeJson($components); } else { $data[$jsonCol] = is_string($json) ? $json : ''; } if ($html !== null) { if (!$htmlCol) $this->fail('html column missing', null, 500); $data[$htmlCol] = (string)$html; } } elseif ($html !== null) { if (!$htmlCol) $this->fail('html column missing', null, 500); $data[$htmlCol] = (string)$html; } $editorType = $this->val($this->in, ['editor_type', 'editor'], null); if ($editorType !== null && $editorCol) $data[$editorCol] = strtolower(trim((string)$editorType)); $craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null); if ($craftJson !== null && $craftCol) $data[$craftCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson); $settings = $this->val($this->in, ['settings_json', 'settings'], null); if ($settings !== null && $settingsCol) $data[$settingsCol] = is_string($settings) ? $settings : $this->encodeJson($settings); if (!$data) { $this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'updated' => true]); return; } $activateVersion = (int)$this->val($this->in, ['activate_version', 'activate', 'set_active'], 0) === 1; $versionCols = array_filter([$jsonCol, $htmlCol, $craftCol, $settingsCol, $editorCol]); $shouldSnapshot = false; foreach ($versionCols as $col) { if (array_key_exists($col, $data)) { $shouldSnapshot = true; break; } } $requestedVersionId = (int)$this->val($this->in, ['version_id', 'versionId', 'version'], 0); $updatedExistingVersion = false; $set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data))); $data['id'] = $id; $data['customer_id'] = $customerId; $sql = "UPDATE `$itemsTable` SET $set WHERE `id` = :id AND `customer_id` = :customer_id LIMIT 1"; $stmt = $this->pdo->prepare($sql); foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); $stmt->execute(); if ($shouldSnapshot && $requestedVersionId > 0) { try { $versionsTable = $this->contentVersionsTable(); if ($this->tableExists($versionsTable)) { $vCols = $this->resolveContentVersionColumns($versionsTable); $stmt = $this->pdo->prepare( "SELECT `id`,`content_id`,`customer_id`,`section_id`,`is_active`,`was_active` FROM `$versionsTable` WHERE `id` = :id AND `customer_id` = :cid AND `content_id` = :content LIMIT 1" ); $stmt->execute([':id' => $requestedVersionId, ':cid' => $customerId, ':content' => $id]); $versionRow = $stmt->fetch(); if ($versionRow && (int)($versionRow['is_active'] ?? 0) === 0 && (int)($versionRow['was_active'] ?? 0) === 0) { $vdata = []; if ($htmlCol && isset($data[$htmlCol]) && !empty($vCols['html'])) $vdata[$vCols['html']] = $data[$htmlCol]; if ($jsonCol && isset($data[$jsonCol]) && !empty($vCols['json'])) $vdata[$vCols['json']] = $data[$jsonCol]; if ($craftCol && isset($data[$craftCol]) && !empty($vCols['craft'])) $vdata[$vCols['craft']] = $data[$craftCol]; if ($settingsCol && isset($data[$settingsCol]) && !empty($vCols['settings'])) $vdata[$vCols['settings']] = $data[$settingsCol]; if ($editorCol && isset($data[$editorCol]) && !empty($vCols['editor'])) $vdata[$vCols['editor']] = $data[$editorCol]; if ($vdata) { $setVersion = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($vdata))); $vdata['id'] = $requestedVersionId; $vdata['customer_id'] = $customerId; $vdata['content_id'] = $id; $sql = "UPDATE `$versionsTable` SET $setVersion WHERE `id` = :id AND `customer_id` = :customer_id AND `content_id` = :content_id LIMIT 1"; $stmt = $this->pdo->prepare($sql); foreach ($vdata as $k => $v) $stmt->bindValue(":$k", $v); $stmt->execute(); $updatedExistingVersion = true; if ($updatedExistingVersion && $activateVersion) { $this->activateContentVersion($customerId, (int)$id, (int)$requestedVersionId); } } } } } catch (Throwable $e) { $updatedExistingVersion = false; } } if ($shouldSnapshot && !$updatedExistingVersion) { try { $stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1"); $stmt->execute([':cid' => $customerId, ':id' => $id]); $row = $stmt->fetch(); if ($row) { $vid = $this->createContentVersion($row, $itemCols, $customerId, (int)($section['id'] ?? 0)); if ($activateVersion && $vid) { $this->activateContentVersion($customerId, (int)$row['id'], $vid); } } } catch (Throwable $e) { // ignore versioning failures } } $this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'updated' => true]); } private function handleContentDelete(?array $fixedSection = null): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $id = $this->pullId($this->in); if ($id === null || $id === '') $this->fail('id required', null, 422); $itemsTable = $this->contentItemsTable(); if (!$this->tableExists($itemsTable)) { $this->fail('Content table not available', null, 500); } $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); $params = [':cid' => $customerId, ':id' => $id]; $where = "WHERE `customer_id` = :cid AND `id` = :id"; if ($section && !empty($section['id'])) { $where .= " AND `section_id` = :sid"; $params[':sid'] = (int)$section['id']; } $stmt = $this->pdo->prepare("DELETE FROM `$itemsTable` $where LIMIT 1"); foreach ($params as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'deleted' => true]); } private function handleContentVersionsList(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $contentId = (int)$this->val($this->in, ['content_id', 'id'], 0); if ($contentId <= 0) $this->fail('content_id required', null, 422); $table = $this->contentVersionsTable(); if (!$this->tableExists($table)) { $this->respond(['ok' => true, 'items' => [], 'data' => []]); return; } $itemsTable = $this->contentItemsTable(); if ($this->tableExists($itemsTable)) { $stmt = $this->pdo->prepare("SELECT `id` FROM `$itemsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => $contentId, ':cid' => $customerId]); if (!$stmt->fetch()) $this->fail('Not found', ['id' => $contentId], 404); } $cols = $this->resolveContentVersionColumns($table); $select = ['`id`','`content_id`','`section_id`','`version_no`','`editor_type`','`created_at`']; if (!empty($cols['is_active'])) $select[] = "`{$cols['is_active']}` AS is_active"; if (!empty($cols['was_active'])) $select[] = "`{$cols['was_active']}` AS was_active"; $limit = (int)$this->val($this->in, ['limit', 'per_page', 'perPage'], 0); if ($limit <= 0 || $limit > 500) $limit = 200; $stmt = $this->pdo->prepare( "SELECT " . implode(',', $select) . " FROM `$table` WHERE `customer_id` = :cid AND `content_id` = :content ORDER BY `id` DESC LIMIT " . $limit ); $stmt->execute([':cid' => $customerId, ':content' => $contentId]); $rows = $stmt->fetchAll() ?: []; $items = array_map(static function ($row) { return [ 'id' => (int)($row['id'] ?? 0), 'content_id' => (int)($row['content_id'] ?? 0), 'section_id' => (int)($row['section_id'] ?? 0), 'version_no' => (int)($row['version_no'] ?? 0), 'editor_type' => $row['editor_type'] ?? null, 'created_at' => $row['created_at'] ?? null, 'is_active' => (int)($row['is_active'] ?? 0), 'was_active' => (int)($row['was_active'] ?? 0), ]; }, $rows); $this->respond(['ok' => true, 'items' => $items, 'data' => $items]); } private function handleContentVersionsGet(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $id = (int)$this->pullId($this->in); if ($id <= 0) $this->fail('id required', null, 422); $contentId = (int)$this->val($this->in, ['content_id', 'content'], 0); $table = $this->contentVersionsTable(); if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500); $sql = "SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid"; $params = [':id' => $id, ':cid' => $customerId]; if ($contentId > 0) { $sql .= " AND `content_id` = :content"; $params[':content'] = $contentId; } $sql .= " LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $row = $stmt->fetch(); if (!$row) $this->fail('Not found', ['id' => $id], 404); $this->respond(['ok' => true, 'item' => $row, 'data' => $row]); } private function handleContentVersionsRestore(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0); if ($versionId <= 0) $this->fail('version id required', null, 422); $contentId = (int)$this->val($this->in, ['content_id', 'content'], 0); $force = (int)$this->val($this->in, ['force', 'override'], 0) === 1; $versionsTable = $this->contentVersionsTable(); $itemsTable = $this->contentItemsTable(); if (!$this->tableExists($versionsTable) || !$this->tableExists($itemsTable)) { $this->fail('Content tables not available', null, 500); } $sql = "SELECT * FROM `$versionsTable` WHERE `id` = :id AND `customer_id` = :cid"; $params = [':id' => $versionId, ':cid' => $customerId]; if ($contentId > 0) { $sql .= " AND `content_id` = :content"; $params[':content'] = $contentId; } $sql .= " LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $version = $stmt->fetch(); if (!$version) $this->fail('Not found', ['id' => $versionId], 404); $itemCols = $this->resolveContentItemColumns($itemsTable); $versionCols = $this->resolveContentVersionColumns($versionsTable); $data = []; if (!empty($itemCols['json']) && $versionCols['json']) $data[$itemCols['json']] = $version[$versionCols['json']] ?? null; if (!empty($itemCols['html']) && $versionCols['html']) $data[$itemCols['html']] = $version[$versionCols['html']] ?? null; if (!empty($itemCols['css']) && $versionCols['css']) $data[$itemCols['css']] = $version[$versionCols['css']] ?? null; if (!empty($itemCols['craft']) && $versionCols['craft']) $data[$itemCols['craft']] = $version[$versionCols['craft']] ?? null; if (!empty($itemCols['settings']) && $versionCols['settings']) $data[$itemCols['settings']] = $version[$versionCols['settings']] ?? null; if (!empty($itemCols['editor']) && $versionCols['editor']) $data[$itemCols['editor']] = $version[$versionCols['editor']] ?? null; if ($data) { $set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data))); $data['id'] = (int)($version['content_id'] ?? 0); $data['customer_id'] = $customerId; $sql = "UPDATE `$itemsTable` SET $set WHERE `id` = :id AND `customer_id` = :customer_id LIMIT 1"; $stmt = $this->pdo->prepare($sql); foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); $stmt->execute(); } $this->respond(['ok' => true, 'restored' => true, 'content_id' => (int)($version['content_id'] ?? 0)]); } private function handleContentVersionsActivate(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0); if ($versionId <= 0) $this->fail('version id required', null, 422); $table = $this->contentVersionsTable(); if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500); $stmt = $this->pdo->prepare("SELECT `id`,`content_id` FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => $versionId, ':cid' => $customerId]); $row = $stmt->fetch(); if (!$row) $this->fail('Not found', ['id' => $versionId], 404); $ok = $this->activateContentVersion($customerId, (int)$row['content_id'], $versionId); debug_log_write('templates_toggle', [ 'time' => date(DATE_ATOM), 'action' => 'content_versions.activate', 'customer_id' => $customerId, 'content_id' => (int)$row['content_id'], 'version_id' => $versionId, 'ok' => $ok, 'input' => $this->in, ], [ 'append' => true, 'json' => true, 'newline' => true, ]); if (!$ok) $this->fail('Activation failed', ['id' => $versionId], 500); $this->respond(['ok' => true, 'activated' => true, 'id' => $versionId]); } private function handleContentVersionsDeactivate(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $contentId = (int)$this->val($this->in, ['content_id', 'content'], 0); if ($contentId <= 0) $this->fail('content_id required', null, 422); $force = (int)$this->val($this->in, ['force', 'override'], 0) === 1; if (!$force) { $refsDebug = []; $refs = $this->findTemplateReferences($customerId, $contentId, $refsDebug); if (!empty($refsDebug)) { $this->writeDebugLog('templates_references_debug', $refsDebug); } if (!empty($refs)) { debug_log_write('templates_toggle', [ 'time' => date(DATE_ATOM), 'action' => 'content_versions.deactivate.blocked', 'customer_id' => $customerId, 'content_id' => $contentId, 'references' => $refs, 'input' => $this->in, ], [ 'append' => true, 'json' => true, 'newline' => true, ]); $this->respond([ 'ok' => false, 'error' => 'Template wird in anderen Templates verwendet', 'content_id' => $contentId, 'count' => count($refs), 'references' => $refs, ], 409); } } $ok = $this->deactivateContentVersion($customerId, $contentId); debug_log_write('templates_toggle', [ 'time' => date(DATE_ATOM), 'action' => 'content_versions.deactivate', 'customer_id' => $customerId, 'content_id' => $contentId, 'ok' => $ok, 'input' => $this->in, ], [ 'append' => true, 'json' => true, 'newline' => true, ]); if (!$ok) $this->fail('Deactivation failed', ['content_id' => $contentId], 500); $this->respond(['ok' => true, 'deactivated' => true, 'content_id' => $contentId]); } private function handleContentVersionsDelete(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0); if ($versionId <= 0) $this->fail('version id required', null, 422); $contentId = (int)$this->val($this->in, ['content_id', 'content'], 0); $table = $this->contentVersionsTable(); if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500); $sql = "SELECT `id`,`content_id`,`customer_id`,`is_active` FROM `$table` WHERE `id` = :id AND `customer_id` = :cid"; $params = [':id' => $versionId, ':cid' => $customerId]; if ($contentId > 0) { $sql .= " AND `content_id` = :content"; $params[':content'] = $contentId; } $sql .= " LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $row = $stmt->fetch(); if (!$row) $this->fail('Not found', ['id' => $versionId], 404); if ((int)($row['is_active'] ?? 0) === 1) { $this->fail('Active versions cannot be deleted', ['id' => $versionId], 422); } if (!$force && $contentId > 0) { $refsDebug = []; $refs = $this->findTemplateReferences($customerId, $contentId, $refsDebug); if (!empty($refsDebug)) { $this->writeDebugLog('templates_references_debug', $refsDebug); } if (!empty($refs)) { debug_log_write('templates_toggle', [ 'time' => date(DATE_ATOM), 'action' => 'content_versions.delete.blocked', 'customer_id' => $customerId, 'content_id' => $contentId, 'version_id' => $versionId, 'references' => $refs, 'input' => $this->in, ], [ 'append' => true, 'json' => true, 'newline' => true, ]); $this->respond([ 'ok' => false, 'error' => 'Template wird in anderen Templates verwendet', 'content_id' => $contentId, 'version_id' => $versionId, 'count' => count($refs), 'references' => $refs, ], 409); } } $stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => $versionId, ':cid' => $customerId]); $this->respond(['ok' => true, 'deleted' => true, 'id' => $versionId]); } private function handleSectionsConfigList(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $table = $this->contentSectionsTable(); if (!$this->tableExists($table)) { $this->fail('Sections table not available', null, 500); } $this->ensureEmailtemplateSection($customerId); $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `position` ASC, `id` ASC"); $stmt->execute([':cid' => $customerId]); $rows = $stmt->fetchAll() ?: []; $items = array_map(static function ($row) { return [ 'id' => (int)($row['id'] ?? 0), 'name' => $row['name'] ?? '', 'slug' => $row['slug'] ?? '', 'position' => (int)($row['position'] ?? 0), 'is_template' => (int)($row['is_template'] ?? 0), ]; }, $rows); $this->respond(['ok' => true, 'items' => $items, 'data' => $items]); } private function handleSectionsConfigGet(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $id = $this->pullId($this->in); if ($id === null || $id === '') $this->fail('id required', null, 422); $table = $this->contentSectionsTable(); if (!$this->tableExists($table)) { $this->fail('Sections table not available', null, 500); } $row = $this->fetchContentSectionById($customerId, (int)$id); if (!$row) $this->fail('Not found', ['id' => $id], 404); $item = [ 'id' => (int)($row['id'] ?? 0), 'name' => $row['name'] ?? '', 'slug' => $row['slug'] ?? '', 'position' => (int)($row['position'] ?? 0), 'is_template' => (int)($row['is_template'] ?? 0), ]; $this->respond(['ok' => true, 'item' => $item, 'data' => $item]); } private function handleSectionsConfigCreate(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $table = $this->contentSectionsTable(); if (!$this->tableExists($table)) { $this->fail('Sections table not available', null, 500); } $name = trim((string)$this->val($this->in, ['name', 'title'], '')); if ($name === '') $this->fail('name required', null, 422); $slug = $this->normalizeSectionSlug($name); if ($slug === '') $this->fail('slug required', null, 422); $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid AND (LOWER(`name`) = LOWER(:name) OR LOWER(`slug`) = LOWER(:slug))"); $stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug]); if ((int)$stmt->fetchColumn() > 0) { $this->fail('section name already exists', ['name' => $name], 409); } $stmt = $this->pdo->prepare("SELECT MAX(`position`) FROM `$table` WHERE `customer_id` = :cid"); $stmt->execute([':cid' => $customerId]); $position = (int)($stmt->fetchColumn() ?: 0) + 1; $stmt = $this->pdo->prepare( "INSERT INTO `$table` (`customer_id`,`name`,`slug`,`position`,`is_template`) VALUES (:cid,:name,:slug,:pos,0)" ); $stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug, ':pos' => $position]); $id = (int)$this->pdo->lastInsertId(); $this->respond(['ok' => true, 'id' => $id, 'item' => ['id' => $id, 'name' => $name, 'slug' => $slug, 'position' => $position]]); } private function handleSectionsConfigUpdate(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $id = $this->pullId($this->in); if ($id === null || $id === '') $this->fail('id required', null, 422); $table = $this->contentSectionsTable(); if (!$this->tableExists($table)) { $this->fail('Sections table not available', null, 500); } $section = $this->fetchContentSectionById($customerId, (int)$id); if (!$section) $this->fail('Not found', ['id' => $id], 404); if (!empty($section['is_template'])) { $this->fail('Emailtemplate section cannot be changed', null, 422); } $name = $this->val($this->in, ['name', 'title'], null); if ($name === null) { $this->respond(['ok' => true, 'id' => (int)$id, 'updated' => true]); return; } $name = trim((string)$name); if ($name === '') $this->fail('name required', null, 422); $slug = $this->normalizeSectionSlug($name); if ($slug === '') $this->fail('slug required', null, 422); $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid AND (LOWER(`name`) = LOWER(:name) OR LOWER(`slug`) = LOWER(:slug)) AND `id` <> :id" ); $stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug, ':id' => (int)$id]); if ((int)$stmt->fetchColumn() > 0) { $this->fail('section name already exists', ['name' => $name], 409); } $stmt = $this->pdo->prepare("UPDATE `$table` SET `name` = :name, `slug` = :slug WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':name' => $name, ':slug' => $slug, ':id' => (int)$id, ':cid' => $customerId]); $this->respond(['ok' => true, 'id' => (int)$id, 'updated' => true, 'item' => ['id' => (int)$id, 'name' => $name, 'slug' => $slug]]); } private function handleSectionsConfigDelete(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $id = $this->pullId($this->in); if ($id === null || $id === '') $this->fail('id required', null, 422); $moveTo = (int)$this->val($this->in, ['move_to', 'move_to_id', 'target_section'], 0); if ($moveTo <= 0) $this->fail('move_to required', null, 422); $sectionsTable = $this->contentSectionsTable(); $itemsTable = $this->contentItemsTable(); if (!$this->tableExists($sectionsTable) || !$this->tableExists($itemsTable)) { $this->fail('Content tables not available', null, 500); } $section = $this->fetchContentSectionById($customerId, (int)$id); if (!$section) $this->fail('Not found', ['id' => $id], 404); if (!empty($section['is_template'])) { $this->fail('Emailtemplate section cannot be deleted', null, 422); } $target = $this->fetchContentSectionById($customerId, $moveTo); if (!$target) $this->fail('move_to section not found', null, 404); if ((int)$target['id'] === (int)$id) $this->fail('move_to must differ', null, 422); $stmt = $this->pdo->prepare("UPDATE `$itemsTable` SET `section_id` = :target WHERE `customer_id` = :cid AND `section_id` = :sid"); $stmt->execute([':target' => (int)$target['id'], ':cid' => $customerId, ':sid' => (int)$id]); $stmt = $this->pdo->prepare("DELETE FROM `$sectionsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => (int)$id, ':cid' => $customerId]); $this->respond(['ok' => true, 'id' => (int)$id, 'deleted' => true]); } private function handleSectionsConfigReorder(): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $order = $this->val($this->in, ['order', 'items', 'ids'], []); if (!is_array($order)) $this->fail('order must be array', null, 422); $table = $this->contentSectionsTable(); if (!$this->tableExists($table)) { $this->fail('Sections table not available', null, 500); } $this->ensureEmailtemplateSection($customerId); $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `position` ASC, `id` ASC"); $stmt->execute([':cid' => $customerId]); $rows = $stmt->fetchAll() ?: []; $byId = []; $emailtemplateId = null; foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); $byId[$id] = $row; if (!empty($row['is_template'])) $emailtemplateId = $id; } $newOrder = []; if ($emailtemplateId) $newOrder[] = $emailtemplateId; foreach ($order as $rawId) { $id = (int)$rawId; if ($id <= 0 || $id === $emailtemplateId) continue; if (!isset($byId[$id])) continue; $newOrder[] = $id; } foreach ($byId as $id => $_row) { if ($id === $emailtemplateId) continue; if (!in_array($id, $newOrder, true)) $newOrder[] = $id; } $pos = 0; $stmt = $this->pdo->prepare("UPDATE `$table` SET `position` = :pos WHERE `id` = :id AND `customer_id` = :cid"); foreach ($newOrder as $id) { $stmt->execute([':pos' => $pos, ':id' => (int)$id, ':cid' => $customerId]); $pos++; } $this->respond(['ok' => true, 'updated' => true]); } private function handleLegacyContentList(string $kind): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); $defaults = $this->resolveLegacySectionDefaults($kind); if ($defaults['is_template']) { $section = $this->ensureEmailtemplateSection($customerId); } else { $section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']); } if (!$section) { $this->respond([ 'ok' => true, 'kind' => 'content', 'items' => [], 'data' => [], 'count' => 0, 'offset' => 0, 'limit' => 0, ]); return; } $this->handleContentList($section); } private function handleLegacyContentGet(string $kind): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); $defaults = $this->resolveLegacySectionDefaults($kind); if ($defaults['is_template']) { $section = $this->ensureEmailtemplateSection($customerId); } else { $section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']); } if (!$section) $this->fail('section not configured', ['kind' => $kind], 404); $this->handleContentGet($section); } private function handleLegacyContentCreate(string $kind): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); $defaults = $this->resolveLegacySectionDefaults($kind); if ($defaults['is_template']) { $section = $this->ensureEmailtemplateSection($customerId); } else { $section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']); } if (!$section) $this->fail('section not configured', ['kind' => $kind], 404); $this->handleContentCreate($section); } private function handleLegacyContentUpdate(string $kind): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); $defaults = $this->resolveLegacySectionDefaults($kind); if ($defaults['is_template']) { $section = $this->ensureEmailtemplateSection($customerId); } else { $section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']); } if (!$section) $this->fail('section not configured', ['kind' => $kind], 404); $this->handleContentUpdate($section); } private function handleLegacyContentDelete(string $kind): void { $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); $defaults = $this->resolveLegacySectionDefaults($kind); if ($defaults['is_template']) { $section = $this->ensureEmailtemplateSection($customerId); } else { $section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']); } if (!$section) $this->fail('section not configured', ['kind' => $kind], 404); $this->handleContentDelete($section); } /** * Allgemeine Methode zur Handhabung von LIST-Anfragen. */ private function handleList(string $kind): void { if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { $this->handleLegacyContentList($kind); return; } $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']); $apiCol = null; $apiCol = ($kind === 'templates') ? $this->firstExisting($allCols, ['api_name']) : null; $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 ($apiCol && isset($r[$apiCol])) $item['api_name'] = $r[$apiCol]; 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]; $createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']); if ($createdCol && isset($r[$createdCol])) $item['created_at'] = $r[$createdCol]; // 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 { if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { $this->handleLegacyContentGet($kind); return; } $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; $editorCol = $this->firstExisting($allCols, ['editor_type', 'editor']); $craftCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']); $editorType = $editorCol && isset($row[$editorCol]) ? strtolower((string)$row[$editorCol]) : ''; $pendingUpdate = []; $gjsComponents = []; if ($editorCol && $editorType === '') { $settings = $this->getCustomerSettings((int)($auth['customer_id'] ?? 0)); $editorType = strtolower((string)($settings['editor_default'] ?? 'grapesjs')); if (!in_array($editorType, ['grapesjs', 'craftjs'], true)) { $editorType = 'grapesjs'; } $pendingUpdate[$editorCol] = $editorType; $rowOut[$editorCol] = $editorType; } if ($topContent !== null) { $decodedContent = json_decode($topContent, true); if (is_array($decodedContent)) { $gjsComponents = $decodedContent; } } if (empty($gjsComponents) && $topHtml !== null) { $gjsComponents = $this->parseHtmlToGjsComponents($topHtml); } if ($editorType === 'craftjs' && $craftCol) { $craftPayload = isset($row[$craftCol]) ? (string)$row[$craftCol] : ''; if ($craftPayload === '') { $pendingUpdate[$craftCol] = $this->encodeJson(['html' => (string)($topHtml ?? '')]); $rowOut[$craftCol] = $pendingUpdate[$craftCol]; } } if ($pendingUpdate) { $pendingUpdate[$idCol] = $row[$idCol] ?? $id; [$tw, $tp] = $this->tenantWhere($auth); $set = []; foreach (array_keys($pendingUpdate) as $c) { if ($c === $idCol) continue; $set[] = "`$c` = :$c"; } $sql = "UPDATE `$t` SET " . implode(',', $set) . " WHERE `$idCol` = :$idCol" . $tw . " LIMIT 1"; $stmt = $this->pdo->prepare($sql); foreach ($pendingUpdate as $k => $v) $stmt->bindValue(":$k", $v); foreach ($tp as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); } $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, 'editor_type' => $editorType ?: null, 'craft_json' => $craftCol && isset($rowOut[$craftCol]) ? $rowOut[$craftCol] : null, 'usage' => $usage, ]); } /** * Allgemeine Methode zur Handhabung von CREATE-Anfragen (inkl. JSON-Bereinigung). */ private function handleCreate(string $kind): void { if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { $this->handleLegacyContentCreate($kind); return; } $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); $editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], ''))); $craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], 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); $editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], ''))); $craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null); $data = [$nameCol => $name]; if ($kind === 'templates') { $apiCol = $this->firstExisting($allCols, ['api_name']); if ($apiCol) { $apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null); if ($apiRaw === null || trim((string)$apiRaw) === '') { $apiName = $this->normalizeApiName($name); if ($apiName === '') { $this->fail('api_name required', null, 422); } } else { $apiName = trim((string)$apiRaw); if (preg_match('/\s/', $apiName)) { $this->fail('api_name must not contain spaces', null, 422); } } $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $this->assertTemplateApiNameUnique($t, $apiCol, $idCol, $customerId, $apiName, null); $data[$apiCol] = $apiName; } } 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']); $editorDbCol = $this->firstExisting($allCols, ['editor_type', 'editor']); $craftDbCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']); $editorDbCol = $this->firstExisting($allCols, ['editor_type', 'editor']); $craftDbCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']); // --- 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 --- if ($editorDbCol) { if ($editorType === '') { $settings = $this->getCustomerSettings((int)($auth['customer_id'] ?? 0)); $editorType = strtolower((string)($settings['editor_default'] ?? 'grapesjs')); } if ($editorType !== '' && in_array($editorType, ['grapesjs', 'craftjs'], true)) { $data[$editorDbCol] = $editorType; } } if ($craftDbCol && $craftJson !== null) { $data[$craftDbCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson); } $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 (!empty($apiCol) && isset($data[$apiCol])) { $out['api_name'] = $data[$apiCol]; } 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 { if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { $this->handleLegacyContentUpdate($kind); return; } $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']); $apiCol = ($kind === 'templates') ? $this->firstExisting($allCols, ['api_name']) : null; $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; if ($apiCol) { $apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null); if ($apiRaw !== null) { $apiName = trim((string)$apiRaw); if ($apiName === '') $this->fail('api_name required', null, 422); if (preg_match('/\s/', $apiName)) { $this->fail('api_name must not contain spaces', null, 422); } $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $this->assertTemplateApiNameUnique($t, $apiCol, $idCol, $customerId, $apiName, (int)$id); $data[$apiCol] = $apiName; } } $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 --- if ($editorDbCol && $editorType !== '' && in_array($editorType, ['grapesjs', 'craftjs'], true)) { $data[$editorDbCol] = $editorType; } if ($craftDbCol && $craftJson !== null) { $data[$craftDbCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson); } $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'); $this->debugSavePayload($kind, $id, $html, $json); 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 { if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { $this->handleLegacyContentDelete($kind); return; } $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(); $customerId = (int)($auth['customer_id'] ?? 0); $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); $smtpProfileId = (int)$this->val($this->in, ['smtp_profile_id'], 0); $row = null; $html = ''; if ($this->useUnifiedContent()) { if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $section = $this->ensureEmailtemplateSection($customerId); $itemsTable = $this->contentItemsTable(); $sql = "SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `section_id` = :sid AND `id` = :id LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $templateId]); $row = $stmt->fetch(); $html = $row ? (string)($row['html'] ?? '') : ''; } else { $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(); $htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']); $html = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : ''; } if (!$row) { $this->fail('Template not found', ['id' => $templateId], 404); } 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); } } $smtpOverride = null; if ($smtpProfileId > 0 && $customerId > 0) { $profile = $this->fetchSmtpProfileRow($customerId, $smtpProfileId); $smtpOverride = [ 'enabled' => true, 'host' => $profile['smtp_host'] ?? '', 'port' => $profile['smtp_port'] ?? 0, 'user' => $profile['smtp_user'] ?? '', 'pass' => $this->fetchSmtpProfilePassword($customerId, $smtpProfileId), 'secure' => $profile['smtp_secure'] ?? '', 'from_email' => $profile['from_email'] ?? '', 'from_name' => $profile['from_name'] ?? '', 'reply_to' => $profile['reply_to'] ?? '', ]; } if (!$this->dispatchTestMail($recipient, $subject, $html, $sender, $customerId, $smtpOverride)) { $this->writeDebugLog('templates_test_send', [ 'time' => date(DATE_ATOM), 'template_id' => $templateId, 'to' => $recipient, 'subject' => $subject, 'sender_id' => $senderId > 0 ? $senderId : null, 'smtp_profile_id' => $smtpProfileId > 0 ? $smtpProfileId : null, 'from_email' => $sender['from_email'] ?? ($this->conf['smtp']['from_email'] ?? null), 'from_name' => $sender['from_name'] ?? ($this->conf['smtp']['from_name'] ?? null), 'html_length' => strlen($html), 'mail_error' => $this->lastMailError, ]); $this->fail('Send failed', null, 500); } 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 handleTemplateReferences(): void { $traceDir = dirname(__DIR__) . '/debug'; if (!is_dir($traceDir)) { @mkdir($traceDir, 0777, true); } @file_put_contents($traceDir . '/templates_references_trace.log', json_encode([ 'time' => date(DATE_ATOM), 'tag' => 'handleTemplateReferences', 'host' => $_SERVER['HTTP_HOST'] ?? '', 'xfh' => $_SERVER['HTTP_X_FORWARDED_HOST'] ?? '', 'env' => $this->conf['env'] ?? null, 'app_env' => defined('APP_ENV') ? APP_ENV : null, 'user' => ($this->in['user'] ?? null), 'input' => $this->in ?? null, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND); $auth = $this->requireAuth(); $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId <= 0) { $this->fail('Customer context missing', null, 500); } $templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0); if ($templateId <= 0) { $this->fail('template_id required', null, 422); } $debug = []; $references = $this->findTemplateReferences($customerId, $templateId, $debug); if (!empty($debug)) { $this->writeDebugLog('templates_references_debug', $debug); } $this->respond([ 'ok' => true, 'template_id' => $templateId, 'count' => count($references), 'references' => $references, ]); } private function findTemplateReferences(int $customerId, int $templateId, array &$debug = []): array { $out = []; $byId = []; $debug = [ 'time' => date(DATE_ATOM), 'customer_id' => $customerId, 'template_id' => $templateId, 'use_unified' => $this->useUnifiedContent(), 'scanned_rows' => 0, 'matched_rows' => [], 'template_items_matches' => [], ]; $addRef = function (int $id, string $name) use (&$out, &$byId) { if ($id <= 0) return; if (!isset($byId[$id])) { $entry = [ 'id' => $id, 'name' => $name, 'versions' => [], ]; $byId[$id] = count($out); $out[] = $entry; } elseif ($name !== '') { $out[$byId[$id]]['name'] = $name; } }; $addVersion = function (int $id, ?int $ver) use (&$out, &$byId) { if ($id <= 0 || $ver === null || $ver <= 0) return; if (!isset($byId[$id])) { return; } $idx = $byId[$id]; if (!in_array($ver, $out[$idx]['versions'], true)) { $out[$idx]['versions'][] = $ver; sort($out[$idx]['versions']); } }; $matches = function (?string $html, array $libKinds = []) use ($templateId): bool { if (!$html) return false; $id = preg_quote((string)$templateId, '/'); $typePattern = '/data-ref-type\s*=\s*(["\"])template\1/i'; $idPattern = '/data-ref-id\s*=\s*(["\"])' . $id . '\1/i'; $jsonTypePattern = '/"data-ref-type"\s*:\s*"template"/i'; $jsonIdPattern = '/"data-ref-id"\s*:\s*("?)(?:' . $id . ')\1/i'; $hasHtmlAttrs = (bool)(preg_match($typePattern, $html) && preg_match($idPattern, $html)); if ($hasHtmlAttrs) return true; if (preg_match($jsonTypePattern, $html) && preg_match($jsonIdPattern, $html)) return true; $libIdPattern = '/(?:data-)?lib-id\s*=\s*(["\"])' . $id . '\1/i'; if (preg_match($libIdPattern, $html)) { return true; } $jsonLibIdPattern = '/"(?:data-)?lib-id"\s*:\s*("?)(?:' . $id . ')\1/i'; if (preg_match($jsonLibIdPattern, $html)) { return true; } return false; }; if ($this->useUnifiedContent()) { $section = $this->ensureEmailtemplateSection($customerId); if (!$section) return []; $libKinds = [strtolower((string)($section['slug'] ?? 'emailtemplate'))]; $debug['section'] = [ 'id' => (int)($section['id'] ?? 0), 'slug' => (string)($section['slug'] ?? ''), ]; $itemsTable = $this->contentItemsTable(); if (!$this->tableExists($itemsTable)) return []; $itemCols = $this->resolveContentItemColumns($itemsTable); $htmlCol = $itemCols['html']; $jsonCol = $itemCols['json']; $craftCol = $itemCols['craft']; $settingsCol = $itemCols['settings']; $versionsTable = $this->contentVersionsTable(); $versionCols = ($this->tableExists($versionsTable)) ? $this->resolveContentVersionColumns($versionsTable) : null; $versionHtmlCol = $versionCols['html'] ?? null; $versionJsonCol = $versionCols['json'] ?? null; $versionCraftCol = $versionCols['craft'] ?? null; $versionActiveCol = $versionCols['is_active'] ?? null; $versionSettingsCol = $versionCols['settings'] ?? null; $debug['tables'] = [ 'items' => $itemsTable, 'versions' => $versionsTable, ]; $debug['columns'] = [ 'item' => $itemCols, 'version' => $versionCols, ]; $select = "i.`id` AS id, i.`name` AS name"; if ($htmlCol) $select .= ", i.`$htmlCol` AS item_html"; if ($jsonCol) $select .= ", i.`$jsonCol` AS item_json"; if ($craftCol) $select .= ", i.`$craftCol` AS item_craft"; if ($settingsCol) $select .= ", i.`$settingsCol` AS item_settings"; $join = ''; $hasVersionJoin = $versionActiveCol && ($versionHtmlCol || $versionJsonCol || $versionCraftCol); if ($hasVersionJoin) { $join = "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`$versionActiveCol` = 1"; } if ($versionHtmlCol && $versionActiveCol) { $select .= ", v.`$versionHtmlCol` AS version_html"; } if ($versionJsonCol && $versionActiveCol) { $select .= ", v.`$versionJsonCol` AS version_json"; } if ($versionCraftCol && $versionActiveCol) { $select .= ", v.`$versionCraftCol` AS version_craft"; } if ($versionSettingsCol && $versionActiveCol) { $select .= ", v.`$versionSettingsCol` AS version_settings"; } $sql = "SELECT $select FROM `$itemsTable` i $join WHERE i.`customer_id` = :cid AND i.`section_id` = :sid AND i.`id` <> :id"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $templateId]); $rows = $stmt->fetchAll() ?: []; foreach ($rows as $row) { $debug['scanned_rows']++; $blobs = [ (string)($row['version_html'] ?? ''), (string)($row['item_html'] ?? ''), (string)($row['version_json'] ?? ''), (string)($row['item_json'] ?? ''), (string)($row['version_craft'] ?? ''), (string)($row['item_craft'] ?? ''), (string)($row['version_settings'] ?? ''), (string)($row['item_settings'] ?? ''), ]; $found = false; $where = []; $blobKeys = [ 'version_html', 'item_html', 'version_json', 'item_json', 'version_craft', 'item_craft', 'version_settings', 'item_settings', ]; foreach ($blobs as $idx => $blob) { if ($matches($blob, $libKinds)) { $found = true; $where[] = $blobKeys[$idx] ?? (string)$idx; } } if ($found) { $id = (int)($row['id'] ?? 0); if ($id <= 0) continue; $addRef($id, (string)($row['name'] ?? '')); if (count($debug['matched_rows']) < 50) { $debug['matched_rows'][] = [ 'id' => $id, 'name' => (string)($row['name'] ?? ''), 'where' => $where, ]; } } } if ($this->tableExists($versionsTable)) { $vCols = $this->tableColumns($versionsTable); $vHtml = $versionHtmlCol; $vJson = $versionJsonCol; $vCraft = $versionCraftCol; $vSettings = $versionSettingsCol; $vNo = $this->firstExisting($vCols, ['version_no', 'version', 'ver', 'version_nr']); $vContentId = $this->firstExisting($vCols, ['content_id', 'content']); $vCustomerId = $this->firstExisting($vCols, ['customer_id', 'customer']); $vSectionId = $this->firstExisting($vCols, ['section_id', 'section']); if ($vContentId && $vNo) { $select = "v.`$vContentId` AS content_id, v.`$vNo` AS version_no, i.`name` AS name"; if ($vHtml) $select .= ", v.`$vHtml` AS version_html"; if ($vJson) $select .= ", v.`$vJson` AS version_json"; if ($vCraft) $select .= ", v.`$vCraft` AS version_craft"; if ($vSettings) $select .= ", v.`$vSettings` AS version_settings"; $join = "LEFT JOIN `$itemsTable` i ON i.`id` = v.`$vContentId`"; $where = []; $params = []; if ($vCustomerId) { $where[] = "v.`$vCustomerId` = :cid"; $params[':cid'] = $customerId; } if ($vSectionId) { $where[] = "v.`$vSectionId` = :sid"; $params[':sid'] = (int)$section['id']; } $where[] = "v.`$vContentId` <> :id"; $params[':id'] = $templateId; $sql = "SELECT $select FROM `$versionsTable` v $join WHERE " . implode(' AND ', $where); $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $rows = $stmt->fetchAll() ?: []; foreach ($rows as $row) { $blobs = [ (string)($row['version_html'] ?? ''), (string)($row['version_json'] ?? ''), (string)($row['version_craft'] ?? ''), (string)($row['version_settings'] ?? ''), ]; $found = false; foreach ($blobs as $blob) { if ($matches($blob, $libKinds)) { $found = true; break; } } if ($found) { $cid = (int)($row['content_id'] ?? 0); $addRef($cid, (string)($row['name'] ?? '')); $addVersion($cid, (int)($row['version_no'] ?? 0)); } } } } } if (!$this->useUnifiedContent()) { $table = $this->tableMap['templates'] ?? null; if ($table && $this->tableExists($table)) { $libKinds = ['templates', 'template', 'emailtemplate']; [$idCol, $allCols] = $this->resolveIdCol('templates'); $htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']); $jsonCol = $this->firstExisting($allCols, ['json_content']); $craftCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']); $settingsCol = $this->firstExisting($allCols, ['settings_json', 'settings']); if ($htmlCol || $jsonCol || $craftCol || $settingsCol) { $nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol); [$tw, $tp] = $this->tenantWhere(['customer_id' => $customerId]); $select = "`$idCol` AS id, `$nameCol` AS name"; if ($htmlCol) $select .= ", `$htmlCol` AS html"; if ($jsonCol) $select .= ", `$jsonCol` AS json"; if ($craftCol) $select .= ", `$craftCol` AS craft"; if ($settingsCol) $select .= ", `$settingsCol` AS settings"; $sql = "SELECT $select FROM `$table` WHERE `$idCol` <> :id" . $tw; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':id', $templateId, PDO::PARAM_INT); foreach ($tp as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $rows = $stmt->fetchAll() ?: []; foreach ($rows as $row) { $blobs = [ (string)($row['html'] ?? ''), (string)($row['json'] ?? ''), (string)($row['craft'] ?? ''), (string)($row['settings'] ?? ''), ]; $found = false; $where = []; $blobKeys = ['html', 'json', 'craft', 'settings']; foreach ($blobs as $idx => $blob) { if ($matches($blob, $libKinds)) { $found = true; $where[] = $blobKeys[$idx] ?? (string)$idx; } } if ($found) { $id = (int)($row['id'] ?? 0); if ($id <= 0) continue; $addRef($id, (string)($row['name'] ?? '')); if (count($debug['matched_rows']) < 50) { $debug['matched_rows'][] = [ 'id' => $id, 'name' => (string)($row['name'] ?? ''), 'where' => $where, ]; } } } } } } $templateItemsTable = $this->lookupTableName('template_items', 'emailtemplate_template_items'); if ($this->tableExists($templateItemsTable)) { $sql = "SELECT DISTINCT `template_id` FROM `$templateItemsTable` WHERE `customer_id` = :cid AND `ref_type` IN ('section','template') AND `ref_id` = :rid"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId, ':rid' => $templateId]); $ids = array_filter(array_map('intval', array_column($stmt->fetchAll() ?: [], 'template_id'))); if ($ids) { $debug['template_items_matches'] = $ids; if ($this->useUnifiedContent()) { $section = $this->ensureEmailtemplateSection($customerId); if ($section) { $itemsTable = $this->contentItemsTable(); $nameCol = $this->resolveContentItemColumns($itemsTable)['category'] ? 'name' : 'name'; $placeholders = implode(',', array_fill(0, count($ids), '?')); $sql = "SELECT `id`, `name` FROM `$itemsTable` WHERE `customer_id` = ? AND `section_id` = ? AND `id` IN ($placeholders)"; $stmt = $this->pdo->prepare($sql); $stmt->execute(array_merge([$customerId, (int)$section['id']], $ids)); $rows = $stmt->fetchAll() ?: []; foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) continue; $addRef($id, (string)($row['name'] ?? '')); } } } else { $table = $this->tableMap['templates'] ?? null; if ($table && $this->tableExists($table)) { [$idCol, $allCols] = $this->resolveIdCol('templates'); $nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol); $ph = []; $params = []; foreach ($ids as $idx => $val) { $key = ':id' . $idx; $ph[] = $key; $params[$key] = $val; } [$tw, $tp] = $this->tenantWhere(['customer_id' => $customerId]); $sql = "SELECT `$idCol` AS id, `$nameCol` AS name FROM `$table` WHERE `$idCol` IN (" . implode(',', $ph) . ")" . $tw; $stmt = $this->pdo->prepare($sql); foreach ($params as $k => $v) $stmt->bindValue($k, $v, PDO::PARAM_INT); foreach ($tp as $k => $v) $stmt->bindValue($k, $v); $stmt->execute(); $rows = $stmt->fetchAll() ?: []; foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) continue; $addRef($id, (string)($row['name'] ?? '')); } } } } } return $out; } private function handleExternalRender(): void { $token = trim((string)($this->in['token'] ?? '')); if ($token === '') { $this->fail('Token required', null, 401); } $settingsTable = $this->customerSettingsTable(); $stmt = $this->pdo->prepare("SELECT `customer_id` FROM `$settingsTable` WHERE `external_api_token` = :t LIMIT 1"); $stmt->execute([':t' => $token]); $row = $stmt->fetch(); $customerId = (int)($row['customer_id'] ?? 0); if ($customerId <= 0) { $this->fail('Invalid token', null, 403); } $templateKey = $this->val($this->in, ['api_name', 'template', 'template_id', 'id', 'name'], ''); $templateId = is_numeric($templateKey) ? (int)$templateKey : null; $tpl = null; $html = ''; $templateName = null; $apiName = null; if ($this->useUnifiedContent()) { $section = $this->ensureEmailtemplateSection($customerId); $itemsTable = $this->contentItemsTable(); $where = "WHERE `customer_id` = :cid AND `section_id` = :sid "; $params = [':cid' => $customerId, ':sid' => (int)$section['id']]; if ($templateId !== null && $templateId > 0) { $where .= "AND `id` = :id "; $params[':id'] = $templateId; } else { $name = trim((string)$templateKey); if ($name === '') { $this->fail('template required', null, 422); } $where .= "AND `api_name` = :name "; $params[':name'] = $name; } $sql = "SELECT * FROM `$itemsTable` $where LIMIT 1"; $stmt = $this->pdo->prepare($sql); foreach ($params as $k => $v) { $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); } $stmt->execute(); $tpl = $stmt->fetch(); if ($tpl) { $html = (string)($tpl['html'] ?? ''); $templateName = $tpl['name'] ?? null; $apiName = $tpl['api_name'] ?? null; } } else { $templatesTable = $this->tableMap['templates'] ?? null; if (!$templatesTable || !$this->tableExists($templatesTable)) { $this->fail('Templates table not available', null, 500); } [$idCol, $allCols] = $this->resolveIdCol('templates'); $nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol); $apiCol = $this->firstExisting($allCols, ['api_name']); $where = "WHERE `customer_id` = :cid "; $params = [':cid' => $customerId]; if ($templateId !== null && $templateId > 0) { $where .= "AND `$idCol` = :id "; $params[':id'] = $templateId; } else { $name = trim((string)$templateKey); if ($name === '') { $this->fail('template required', null, 422); } if ($apiCol) { $where .= "AND `$apiCol` = :name "; $params[':name'] = $name; } else { $where .= "AND `$nameCol` = :name "; $params[':name'] = $name; } } $sql = "SELECT * FROM `$templatesTable` $where LIMIT 1"; $stmt = $this->pdo->prepare($sql); foreach ($params as $k => $v) { $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); } $stmt->execute(); $tpl = $stmt->fetch(); $htmlCol = $this->resolveHtmlColumn($allCols, 'templates'); $html = ($htmlCol && isset($tpl[$htmlCol])) ? (string)$tpl[$htmlCol] : ''; $templateName = $tpl[$nameCol] ?? null; $apiName = $apiCol ? ($tpl[$apiCol] ?? null) : null; } if (!$tpl) { $this->fail('Template not found', ['template' => $templateKey], 404); } if ($html === '' && !empty($tpl['json_content'])) { $html = '(Dieses Template enthält noch keine HTML-Inhalte.)
'; } $auth = ['id' => $customerId, 'customer_id' => $customerId]; $cache = []; $stack = []; $html = $this->renderHtmlWithReferences($html, $auth, $cache, $stack); $html = $this->replacePlaceholders($html, (array)($this->in['placeholders'] ?? [])); $html = $this->prepareEmailHtml($html); $this->respond([ 'ok' => true, 'template_id' => (int)($tpl['id'] ?? 0), 'name' => $templateName, 'api_name' => $apiName, 'html' => $html, ]); } 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 debugSavePayload(string $kind, $id, $html, $json): void { if (empty($this->in['debug'])) { return; } $payload = [ 'time' => date(DATE_ATOM), 'kind' => $kind, 'id' => $id, 'html_length' => is_string($html) ? strlen($html) : null, 'json_length' => is_string($json) ? strlen($json) : null, 'html_preview' => is_string($html) ? substr($html, 0, 200) : null, 'json_preview' => is_string($json) ? substr($json, 0, 200) : null, 'json_is_empty' => is_string($json) ? trim($json) === '' : ($json === null), ]; $line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; @file_put_contents(sys_get_temp_dir() . '/emailtemplate_debug_save.log', $line, FILE_APPEND); } private function replacePlaceholders(string $html, array $placeholders): string { if ($html === '' || !$placeholders) { return $html; } $map = []; foreach ($placeholders as $key => $value) { if (is_array($value) || is_object($value)) continue; $value = (string)$value; $trimmedKey = trim((string)$key); if ($trimmedKey === '') continue; $map['{{' . $trimmedKey . '}}'] = $value; $map['{{ ' . $trimmedKey . ' }}'] = $value; $map['[[' . $trimmedKey . ']]'] = $value; $map['[[ ' . $trimmedKey . ' ]]'] = $value; } return $map ? strtr($html, $map) : $html; } private function dispatchTestMail(string $to, string $subject, string $html, ?array $sender = null, ?int $customerId = null, ?array $smtpOverride = null): bool { $this->lastMailError = null; $smtpConf = $this->conf['smtp'] ?? []; $settings = ($customerId && $customerId > 0) ? $this->getCustomerSettings($customerId) : []; $override = is_array($smtpOverride) ? $smtpOverride : []; $smtp = array_merge($smtpConf, array_filter([ 'host' => $override['host'] ?? ($settings['smtp_host'] ?? null), 'port' => $override['port'] ?? ($settings['smtp_port'] ?? null), 'user' => $override['user'] ?? ($settings['smtp_user'] ?? null), 'pass' => array_key_exists('pass', $override) ? $override['pass'] : ($settings['smtp_pass'] ?? null), 'secure' => $override['secure'] ?? ($settings['smtp_secure'] ?? null), 'from_email' => $override['from_email'] ?? ($settings['smtp_from_email'] ?? null), 'from_name' => $override['from_name'] ?? ($settings['smtp_from_name'] ?? null), 'reply_to' => $override['reply_to'] ?? ($settings['smtp_reply_to'] ?? null), 'enabled' => $override['enabled'] ?? ($settings['smtp_enabled'] ?? null), ], static fn($v) => $v !== null && $v !== '')); $smtpEnabled = !empty($smtp['enabled']); $smtpHost = trim((string)($smtp['host'] ?? '')); $smtpUser = trim((string)($smtp['user'] ?? '')); $smtpPass = (string)($smtp['pass'] ?? ''); $smtpSecure = strtolower(trim((string)($smtp['secure'] ?? ''))); $smtpPort = (int)($smtp['port'] ?? 0); $fromEmail = $sender['from_email'] ?? ($smtp['from_email'] ?? ($smtpConf['from_email'] ?? 'no-reply@example.com')); $fromName = $sender['from_name'] ?? ($sender['label'] ?? ($smtp['from_name'] ?? ($smtpConf['from_name'] ?? 'EmailTemplate'))); $replyTo = $sender['reply_to'] ?? ($smtp['reply_to'] ?? ''); if ($smtpEnabled && $smtpHost !== '' && class_exists(PHPMailer::class)) { try { $mailer = new PHPMailer(true); $mailer->CharSet = 'UTF-8'; $mailer->isSMTP(); $mailer->Host = $smtpHost; $mailer->SMTPAuth = ($smtpUser !== '' || $smtpPass !== ''); if ($smtpUser !== '') $mailer->Username = $smtpUser; if ($smtpPass !== '') $mailer->Password = $smtpPass; if ($smtpSecure === 'ssl') { $mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; if ($smtpPort <= 0) $smtpPort = 465; } elseif ($smtpSecure === 'tls') { $mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; if ($smtpPort <= 0) $smtpPort = 587; } else { $mailer->SMTPSecure = ''; $mailer->SMTPAutoTLS = false; if ($smtpPort <= 0) $smtpPort = 25; } if ($smtpPort > 0) $mailer->Port = $smtpPort; $mailer->setFrom($fromEmail, $fromName); $mailer->addAddress($to); if ($replyTo !== '') { $mailer->addReplyTo($replyTo, $fromName ?: $fromEmail); } $mailer->isHTML(true); $mailer->Subject = $subject; $mailer->Body = $html; $mailer->AltBody = trim(strip_tags($html)); return $mailer->send(); } catch (PHPMailerException $e) { $this->lastMailError = $e->getMessage(); return false; } catch (Throwable $e) { $this->lastMailError = $e->getMessage(); return false; } } if (!function_exists('mail')) { $this->lastMailError = 'PHP mail() not available'; return false; } $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; $sent = @mail($to, $encodedSubject, $html, implode("\r\n", $headers)); if (!$sent) { $this->lastMailError = 'mail() returned false'; } return $sent; } 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')]); case 'external.render': $this->handleExternalRender(); break; /* ---------- 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 'account.smtp_profiles.list': $this->handleAccountSmtpProfilesList(); break; case 'account.smtp_profiles.save': $this->handleAccountSmtpProfileSave(); break; case 'account.smtp_profiles.delete': $this->handleAccountSmtpProfileDelete(); break; case 'account.smtp_profiles.copy': $this->handleAccountSmtpProfileCopy(); 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.setup.get': $this->handleAccountBridgeSetupGet(); break; case 'account.bridge.setup.save': $this->handleAccountBridgeSetupSave(); break; case 'account.bridge.test': $this->handleAccountBridgeTest(); break; case 'account.smtp.test': $this->handleAccountSmtpTest(); break; case 'account.fonts.list': $this->handleAccountFontsList(); break; case 'blocks_custom.list': $this->handleBlocksCustomList(); break; case 'debug.logs.list': $this->handleDebugLogsList(); break; case 'debug.logs.read': $this->handleDebugLogsRead(); break; case 'debug.log.write': $this->handleDebugLogWrite(); 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; case 'templates.references': $this->handleTemplateReferences(); break; case 'sections_config.reorder': $this->handleSectionsConfigReorder(); break; case 'content_versions.restore': $this->handleContentVersionsRestore(); break; case 'content_versions.activate': $this->handleContentVersionsActivate(); break; case 'content_versions.deactivate': $this->handleContentVersionsDeactivate(); break; case 'content_versions.delete': $this->handleContentVersionsDelete(); break; /* ---------- CRUD HANDLER ---------- */ default: if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config', 'content_versions'])) { switch ($operation) { case 'list': if ($kind === 'content') $this->handleContentList(); elseif ($kind === 'sections_config') $this->handleSectionsConfigList(); elseif ($kind === 'content_versions') $this->handleContentVersionsList(); else $this->handleList($kind); break; case 'get': if ($kind === 'content') $this->handleContentGet(); elseif ($kind === 'sections_config') $this->handleSectionsConfigGet(); elseif ($kind === 'content_versions') $this->handleContentVersionsGet(); else $this->handleGet($kind); break; case 'create': if ($kind === 'content') $this->handleContentCreate(); elseif ($kind === 'sections_config') $this->handleSectionsConfigCreate(); else $this->handleCreate($kind); break; case 'update': if ($kind === 'content') $this->handleContentUpdate(); elseif ($kind === 'sections_config') $this->handleSectionsConfigUpdate(); else $this->handleUpdate($kind); break; case 'delete': if ($kind === 'content') $this->handleContentDelete(); elseif ($kind === 'sections_config') $this->handleSectionsConfigDelete(); else $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 handleBlocksCustomList(): void { $this->requireAuth(); $baseDir = realpath(__DIR__ . '/../public/assets/js/bridge/blocks-custom/elements'); if (!$baseDir || !is_dir($baseDir)) { $this->respond(['ok' => true, 'files' => []]); return; } $files = glob($baseDir . '/*.js') ?: []; $out = []; foreach ($files as $file) { $name = basename($file); if ($name && $name[0] !== '.') $out[] = $name; } sort($out, SORT_NATURAL | SORT_FLAG_CASE); $this->respond(['ok' => true, 'files' => $out]); } 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, ]; if ($this->useUnifiedContent()) { $itemsTable = $this->contentItemsTable(); $sectionsTable = $this->contentSectionsTable(); $stmt = $this->pdo->prepare("SELECT `id`,`slug` FROM `$sectionsTable` WHERE `customer_id` = :cid"); $stmt->execute([':cid' => $customerId]); $sections = $stmt->fetchAll() ?: []; $bySlug = []; foreach ($sections as $row) { $slug = $row['slug'] ?? ''; if ($slug !== '') $bySlug[$slug] = (int)$row['id']; } $slugs = [ 'templates' => 'emailtemplate', 'sections' => 'sections', 'blocks' => 'blocks', 'snippets' => 'snippets', ]; foreach ($slugs as $key => $slug) { $sid = $bySlug[$slug] ?? null; if (!$sid) continue; $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$itemsTable` WHERE `customer_id` = :cid AND `section_id` = :sid"); $stmt->execute([':cid' => $customerId, ':sid' => $sid]); $counts[$key] = (int)($stmt->fetchColumn() ?: 0); } } else { $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 { $usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage'); if ($this->useUnifiedContent()) { $itemsTable = $this->contentItemsTable(); $sectionsTable = $this->contentSectionsTable(); if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) { return []; } $section = $this->ensureEmailtemplateSection($customerId); if ($this->tableExists($usageTable)) { $sql = "SELECT i.id, i.name, i.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at FROM `$itemsTable` i LEFT JOIN `$usageTable` u ON u.template_id = i.id WHERE i.customer_id = :cid AND i.section_id = :sid ORDER BY render_count DESC, i.updated_at DESC"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id']]); $rows = $stmt->fetchAll() ?: []; } else { $sql = "SELECT i.id, i.name, i.updated_at FROM `$itemsTable` i WHERE i.customer_id = :cid AND i.section_id = :sid ORDER BY i.updated_at DESC"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id']]); $rows = $stmt->fetchAll() ?: []; foreach ($rows as &$row) { $row['render_count'] = 0; $row['last_rendered_at'] = null; } } } else { $table = $this->tableMap['templates'] ?? null; if (!$table || !$this->tableExists($table)) { return []; } 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]; if ($this->useUnifiedContent()) { 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 { if ($id <= 0) return null; $kindKey = $this->normalizeResourceKind($kind); $cacheKey = ($kindKey ?: $kind) . ':' . $id; if (array_key_exists($cacheKey, $cache)) return $cache[$cacheKey]; if (!empty($stack[$cacheKey])) return null; if ($this->useUnifiedContent()) { $customerId = (int)($auth['customer_id'] ?? 0); $sectionSlug = $this->resolveSectionSlugFromKind($kind); $section = $this->fetchContentSectionBySlug($customerId, $sectionSlug); if (!$section) { $cache[$cacheKey] = null; return null; } $itemsTable = $this->contentItemsTable(); $itemCols = $this->resolveContentItemColumns($itemsTable); $versionsTable = $this->contentVersionsTable(); $versionCols = $this->tableExists($versionsTable) ? $this->resolveContentVersionColumns($versionsTable) : null; $htmlCol = $itemCols['html']; $jsonCol = $itemCols['json']; if (!$htmlCol && !$jsonCol && (!$versionCols || !$versionCols['html'])) { $cache[$cacheKey] = null; return null; } $selectCols = []; if ($htmlCol) $selectCols[] = "i.`$htmlCol` AS item_html"; if ($jsonCol) $selectCols[] = "i.`$jsonCol` AS item_json"; if ($versionCols && $versionCols['html']) $selectCols[] = "v.`{$versionCols['html']}` AS version_html"; $join = $versionCols ? "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1" : ""; $sql = "SELECT " . implode(',', $selectCols) . " FROM `$itemsTable` i $join WHERE i.`customer_id` = :cid AND i.`section_id` = :sid AND i.`id` = :id LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $id]); $row = $stmt->fetch(); if (!$row) { $cache[$cacheKey] = null; return null; } $html = (string)($row['version_html'] ?? $row['item_html'] ?? ''); } else { if (!$kindKey) 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 = 'SMTP Test erfolgreich.
Zeit: ' . date(DATE_ATOM) . '
'; $ok = $this->dispatchTestMail($recipient, $subject, $html, null, $customerId, $smtpOverride); $this->writeDebugLog('smtp_test', [ 'time' => date(DATE_ATOM), 'customer_id' => $customerId, 'to' => $recipient, 'smtp_profile_id' => $smtpProfileId > 0 ? $smtpProfileId : null, 'smtp_enabled' => $smtpOverride['enabled'] ?? null, 'smtp_host' => $smtpOverride['host'] ?? null, 'smtp_port' => $smtpOverride['port'] ?? null, 'smtp_secure' => $smtpOverride['secure'] ?? null, 'smtp_user' => $smtpOverride['user'] ?? null, 'smtp_pass_set' => isset($smtpOverride['pass']) ? ($smtpOverride['pass'] !== '') : null, 'smtp_pass_len' => isset($smtpOverride['pass']) ? strlen((string)$smtpOverride['pass']) : null, 'smtp_from_email' => $smtpOverride['from_email'] ?? null, 'smtp_from_name' => $smtpOverride['from_name'] ?? null, 'smtp_reply_to' => $smtpOverride['reply_to'] ?? null, 'ok' => $ok, 'error' => $ok ? null : $this->lastMailError, ]); if (!$ok) { $this->fail('SMTP test failed', $this->lastMailError ?: 'Send failed', 500); } $this->respond(['ok' => true, 'to' => $recipient]); } private function handleAccountSettingsUpdate(): void { $user = $this->requireAuth(); $customerId = (int)($user['customer_id'] ?? 0); $hasBridgeUrl = array_key_exists('bridge_url', $this->in); $hasBridgeToken = array_key_exists('bridge_token', $this->in); $hasSenderToken = array_key_exists('sender_token', $this->in); $hasExternalToken = array_key_exists('external_api_token', $this->in); $hasEditorDefault = array_key_exists('editor_default', $this->in); $hasVersionsRetention = array_key_exists('versions_retention', $this->in); $hasListSort = array_key_exists('list_sort', $this->in); $hasBridgeTables = array_key_exists('bridge_tables', $this->in); $hasSmtpEnabled = array_key_exists('smtp_enabled', $this->in); $hasSmtpHost = array_key_exists('smtp_host', $this->in); $hasSmtpPort = array_key_exists('smtp_port', $this->in); $hasSmtpUser = array_key_exists('smtp_user', $this->in); $hasSmtpPass = array_key_exists('smtp_pass', $this->in); $hasSmtpSecure = array_key_exists('smtp_secure', $this->in); $hasSmtpFromEmail = array_key_exists('smtp_from_email', $this->in); $hasSmtpFromName = array_key_exists('smtp_from_name', $this->in); $hasSmtpReplyTo = array_key_exists('smtp_reply_to', $this->in); $hasSmtpPassClear = array_key_exists('smtp_pass_clear', $this->in); $rotateBridge = !empty($this->in['rotate_bridge_token']); $rotateSender = !empty($this->in['rotate_sender_token']); $rotateExternal = !empty($this->in['rotate_external_token']); $onlyListSort = $hasListSort && !$hasBridgeUrl && !$hasBridgeToken && !$hasSenderToken && !$hasExternalToken && !$hasEditorDefault && !$hasBridgeTables && !$hasVersionsRetention && !$rotateBridge && !$rotateSender && !$rotateExternal && !$hasSmtpEnabled && !$hasSmtpHost && !$hasSmtpPort && !$hasSmtpUser && !$hasSmtpPass && !$hasSmtpSecure && !$hasSmtpFromEmail && !$hasSmtpFromName && !$hasSmtpReplyTo && !$hasSmtpPassClear; if (!$onlyListSort) { $this->ensureRole($user, ['owner', 'admin']); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); } $settings = $customerId ? $this->getCustomerSettings($customerId) : []; $bridgeUrl = $hasBridgeUrl ? trim((string)($this->in['bridge_url'] ?? '')) : (string)($settings['bridge_url'] ?? ''); $bridgeToken = $hasBridgeToken ? trim((string)($this->in['bridge_token'] ?? '')) : (string)($settings['bridge_token'] ?? ''); $senderToken = $hasSenderToken ? trim((string)($this->in['sender_token'] ?? '')) : (string)($settings['sender_token'] ?? ''); $externalToken = $hasExternalToken ? trim((string)($this->in['external_api_token'] ?? '')) : (string)($settings['external_api_token'] ?? ''); $editorDefault = $hasEditorDefault ? strtolower(trim((string)($this->in['editor_default'] ?? ''))) : strtolower((string)($settings['editor_default'] ?? '')); $versionsRetention = $hasVersionsRetention ? (int)($this->in['versions_retention'] ?? 0) : (int)($settings['versions_retention'] ?? 0); $listSort = $hasListSort ? strtolower(trim((string)($this->in['list_sort'] ?? ''))) : ''; $bridgeTables = $hasBridgeTables ? $this->normalizeBridgeTables($this->in['bridge_tables'] ?? []) : ($settings['bridge_tables'] ?? []); $smtpEnabled = $hasSmtpEnabled ? (int)($this->in['smtp_enabled'] ?? 0) : (int)($settings['smtp_enabled'] ?? 0); $smtpHost = $hasSmtpHost ? trim((string)($this->in['smtp_host'] ?? '')) : (string)($settings['smtp_host'] ?? ''); $smtpPort = $hasSmtpPort ? (int)($this->in['smtp_port'] ?? 0) : (int)($settings['smtp_port'] ?? 0); $smtpUser = $hasSmtpUser ? trim((string)($this->in['smtp_user'] ?? '')) : (string)($settings['smtp_user'] ?? ''); $smtpPass = $hasSmtpPass ? (string)($this->in['smtp_pass'] ?? '') : (string)($settings['smtp_pass'] ?? ''); $smtpSecure = $hasSmtpSecure ? strtolower(trim((string)($this->in['smtp_secure'] ?? ''))) : strtolower((string)($settings['smtp_secure'] ?? '')); $smtpFromEmail = $hasSmtpFromEmail ? trim((string)($this->in['smtp_from_email'] ?? '')) : (string)($settings['smtp_from_email'] ?? ''); $smtpFromName = $hasSmtpFromName ? trim((string)($this->in['smtp_from_name'] ?? '')) : (string)($settings['smtp_from_name'] ?? ''); $smtpReplyTo = $hasSmtpReplyTo ? trim((string)($this->in['smtp_reply_to'] ?? '')) : (string)($settings['smtp_reply_to'] ?? ''); $smtpPassClear = $hasSmtpPassClear ? !empty($this->in['smtp_pass_clear']) : false; if ($bridgeUrl && !filter_var($bridgeUrl, FILTER_VALIDATE_URL)) { $this->fail('Ungültige Bridge-URL', null, 422); } if ($listSort !== '' && !in_array($listSort, ['created_asc', 'name_asc', 'name_desc', 'updated_desc'], true)) { $this->fail('Ungültige Sortierung', null, 422); } if ($smtpEnabled) { if ($smtpHost === '') { $this->fail('SMTP-Host erforderlich', null, 422); } if ($smtpPort < 0 || $smtpPort > 65535) { $this->fail('Ungültiger SMTP-Port', null, 422); } if ($smtpSecure !== '' && !in_array($smtpSecure, ['tls', 'ssl', 'none'], true)) { $this->fail('Ungültige SMTP-Sicherheit', null, 422); } } if ($smtpFromEmail !== '' && !filter_var($smtpFromEmail, FILTER_VALIDATE_EMAIL)) { $this->fail('Ungültige SMTP-Absenderadresse', null, 422); } if ($smtpReplyTo !== '' && !filter_var($smtpReplyTo, FILTER_VALIDATE_EMAIL)) { $this->fail('Ungültige SMTP-Reply-To-Adresse', null, 422); } if (!$onlyListSort) { if ($rotateBridge || $bridgeToken === '') $bridgeToken = $this->generateToken(); if ($rotateSender || $senderToken === '') $senderToken = $this->generateToken(); if ($rotateExternal || $externalToken === '') $externalToken = $this->generateToken(); if ($editorDefault !== '' && !in_array($editorDefault, ['grapesjs', 'craftjs'], true)) { $this->fail('Ungültiger Editor-Typ', null, 422); } if ($versionsRetention < 0) { $this->fail('Ungültiger Aufbewahrungswert', null, 422); } if ($smtpSecure === 'none') { $smtpSecure = ''; } $save = [ 'bridge_url' => $bridgeUrl, 'bridge_token' => $bridgeToken, 'sender_token' => $senderToken, 'external_api_token' => $externalToken, 'editor_default' => $editorDefault ?: null, 'bridge_tables' => $bridgeTables, 'versions_retention' => $versionsRetention, 'smtp_enabled' => $smtpEnabled ? 1 : 0, 'smtp_host' => $smtpHost ?: null, 'smtp_port' => $smtpPort > 0 ? $smtpPort : null, 'smtp_user' => $smtpUser ?: null, 'smtp_secure' => $smtpSecure ?: null, 'smtp_from_email' => $smtpFromEmail ?: null, 'smtp_from_name' => $smtpFromName ?: null, 'smtp_reply_to' => $smtpReplyTo ?: null, ]; if ($smtpPassClear) { $save['smtp_pass'] = null; } elseif ($hasSmtpPass && $smtpPass !== '') { $save['smtp_pass'] = $smtpPass; } $settings = $this->saveCustomerSettings($customerId, $save); } else { $settings = $customerId ? $this->ensureSettingsTokens($customerId, $settings) : $settings; } if ($hasListSort) { $this->ensureAuthUserListSortColumn(); $this->updateUserListSort($user, $customerId, $listSort ?: null); } $settings['list_sort'] = $this->resolveUserListSort($user, $customerId, $listSort); $this->respond(['ok' => true, 'settings' => $settings]); } private function handleAccountUsersList(): void { $user = $this->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']), ]; $nameSource = $this->columnExists($dbCols, $cols['col_name']) ? $cols['col_name'] : $cols['col_email']; $select[] = sprintf('`%s` AS name', $nameSource); $select[] = 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'], $nameSource ); $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->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 = []; if ($this->columnExists($dbCols, $cols['col_name'])) { $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->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->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->requireAuth(); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $table = $this->senderTable(); try { $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `label` ASC"); } catch (Throwable $e) { $this->fail('Sender-Tabelle existiert nicht', $e->getMessage(), 500); } $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->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->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 handleAccountSmtpProfilesList(): void { $user = $this->requireAuth(); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $this->ensureSmtpProfilesTableExists(); $table = $this->smtpProfilesTable(); $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->formatSmtpProfileRow($row); } $this->respond(['ok' => true, 'items' => $items]); } private function handleAccountSmtpProfileSave(): void { $user = $this->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $profileId = (int)($this->in['profile_id'] ?? 0); $label = trim((string)($this->in['label'] ?? '')); $host = trim((string)($this->in['smtp_host'] ?? '')); $port = (int)($this->in['smtp_port'] ?? 0); $userName = trim((string)($this->in['smtp_user'] ?? '')); $pass = (string)($this->in['smtp_pass'] ?? ''); $secure = strtolower(trim((string)($this->in['smtp_secure'] ?? ''))); $fromEmail = trim((string)($this->in['from_email'] ?? '')); $fromName = trim((string)($this->in['from_name'] ?? '')); $replyTo = trim((string)($this->in['reply_to'] ?? '')); $passClear = !empty($this->in['smtp_pass_clear']); if ($label === '') $label = $fromEmail ?: $host; if ($host === '') $this->fail('SMTP-Host erforderlich', null, 422); if ($port < 0 || $port > 65535) $this->fail('Ungültiger SMTP-Port', null, 422); if ($secure !== '' && !in_array($secure, ['tls', 'ssl', 'none'], true)) { $this->fail('Ungültige SMTP-Sicherheit', null, 422); } if ($fromEmail !== '' && !filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) { $this->fail('Ungültige Absenderadresse', null, 422); } if ($replyTo !== '' && !filter_var($replyTo, FILTER_VALIDATE_EMAIL)) { $this->fail('Ungültige Reply-To-Adresse', null, 422); } if ($secure === 'none') $secure = ''; $this->ensureSmtpProfilesTableExists(); $table = $this->smtpProfilesTable(); if ($profileId > 0) { $fields = [ 'label' => $label, 'smtp_host' => $host, 'smtp_port' => $port > 0 ? $port : null, 'smtp_user' => $userName ?: null, 'smtp_secure' => $secure ?: null, 'from_email' => $fromEmail ?: null, 'from_name' => $fromName ?: null, 'reply_to' => $replyTo ?: null, ]; if ($passClear) { $fields['smtp_pass'] = null; } elseif ($pass !== '') { $fields['smtp_pass'] = $pass; } $set = []; $params = [':id' => $profileId, ':cid' => $customerId]; foreach ($fields as $k => $v) { $set[] = "`$k` = :$k"; $params[":$k"] = $v; } $set[] = "`updated_at` = NOW()"; $sql = "UPDATE `$table` SET " . implode(',', $set) . " WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); if ($stmt->rowCount() === 0) $this->fail('Versandprofil nicht gefunden', null, 404); } else { $stmt = $this->pdo->prepare("INSERT INTO `$table` (`customer_id`,`label`,`smtp_host`,`smtp_port`,`smtp_user`,`smtp_pass`,`smtp_secure`,`from_email`,`from_name`,`reply_to`,`created_at`,`updated_at`) VALUES (:cid,:label,:host,:port,:user,:pass,:secure,:fmail,:fname,:reply,NOW(),NOW())"); $stmt->execute([ ':cid' => $customerId, ':label' => $label, ':host' => $host, ':port' => $port > 0 ? $port : null, ':user' => $userName ?: null, ':pass' => $pass !== '' ? $pass : null, ':secure' => $secure ?: null, ':fmail' => $fromEmail ?: null, ':fname' => $fromName ?: null, ':reply' => $replyTo ?: null, ]); $profileId = (int)$this->pdo->lastInsertId(); } $profile = $this->fetchSmtpProfileRow($customerId, $profileId); $this->respond(['ok' => true, 'profile' => $profile]); } private function handleAccountSmtpProfileDelete(): void { $user = $this->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); $profileId = (int)($this->in['profile_id'] ?? 0); if ($profileId <= 0) $this->fail('Ungültige Profil-ID', null, 422); $this->ensureSmtpProfilesTableExists(); $table = $this->smtpProfilesTable(); $stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => $profileId, ':cid' => $customerId]); if ($stmt->rowCount() === 0) $this->fail('Versandprofil nicht gefunden', null, 404); $this->respond(['ok' => true, 'deleted' => true]); } private function handleAccountSmtpProfileCopy(): void { $user = $this->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); $profileId = (int)($this->in['profile_id'] ?? 0); if ($profileId <= 0) $this->fail('Ungültige Profil-ID', null, 422); $this->ensureSmtpProfilesTableExists(); $table = $this->smtpProfilesTable(); $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => $profileId, ':cid' => $customerId]); $row = $stmt->fetch(); if (!$row) $this->fail('Versandprofil nicht gefunden', null, 404); $newLabel = trim((string)($this->in['label'] ?? '')); if ($newLabel === '') { $newLabel = ($row['label'] ?? 'Profil') . ' (Kopie)'; } $insert = $this->pdo->prepare("INSERT INTO `$table` (`customer_id`,`label`,`smtp_host`,`smtp_port`,`smtp_user`,`smtp_pass`,`smtp_secure`,`from_email`,`from_name`,`reply_to`,`created_at`,`updated_at`) VALUES (:cid,:label,:host,:port,:user,:pass,:secure,:fmail,:fname,:reply,NOW(),NOW())"); $insert->execute([ ':cid' => $customerId, ':label' => $newLabel, ':host' => $row['smtp_host'], ':port' => $row['smtp_port'], ':user' => $row['smtp_user'], ':pass' => $row['smtp_pass'], ':secure' => $row['smtp_secure'], ':fmail' => $row['from_email'], ':fname' => $row['from_name'], ':reply' => $row['reply_to'], ]); $newId = (int)$this->pdo->lastInsertId(); $profile = $this->fetchSmtpProfileRow($customerId, $newId); $this->respond(['ok' => true, 'profile' => $profile]); } private function handleDashboardMetrics(): void { $user = $this->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->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->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)); $bridgeSetup = $this->getBridgeSetupData($customerId); $content = $this->loadDownloadTemplate($type); if ($type === 'bridge') { $content = $this->populateBridgeDownload($content, $settings, $bridgeSetup); } else { $content = $this->populateSenderDownload($content, $settings); } $this->respond([ 'ok' => true, 'file_name' => $type === 'bridge' ? 'emailtemplate_bridge.php' : 'emailtemplate_sender.php', 'content' => base64_encode($content), ]); } private function handleAccountBridgeSetupGet(): void { $user = $this->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $setup = $this->getBridgeSetupData($customerId); $this->respond(['ok' => true, 'setup' => $setup]); } private function handleAccountBridgeSetupSave(): void { $user = $this->requireAuth(); $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); $tables = $this->normalizeBridgeTables($this->in['tables'] ?? $this->in['bridge_tables'] ?? []); $mode = strtolower((string)($this->in['mode'] ?? $this->in['db_mode'] ?? 'direct')); $direct = [ 'host' => trim((string)($this->in['direct_host'] ?? '')), 'port' => (int)($this->in['direct_port'] ?? 3306), 'database' => trim((string)($this->in['direct_database'] ?? $this->in['direct_db'] ?? '')), 'user' => trim((string)($this->in['direct_user'] ?? '')), 'password' => (string)($this->in['direct_password'] ?? ''), 'charset' => trim((string)($this->in['direct_charset'] ?? '')) ?: 'utf8mb4', ]; $config = [ 'file' => trim((string)($this->in['config_file'] ?? '')), 'base' => (string)($this->in['config_base'] ?? ''), 'host_key' => (string)($this->in['config_host_key'] ?? ''), 'port_key' => (string)($this->in['config_port_key'] ?? ''), 'database_key' => (string)($this->in['config_database_key'] ?? ''), 'user_key' => (string)($this->in['config_user_key'] ?? ''), 'password_key' => (string)($this->in['config_password_key'] ?? ''), 'charset_key' => (string)($this->in['config_charset_key'] ?? ''), ]; $fonts = [ 'dir' => (string)($this->in['fonts_dir'] ?? ''), 'url_base' => (string)($this->in['fonts_url_base'] ?? ''), 'urls' => (string)($this->in['fonts_urls'] ?? ''), ]; $setup = $this->sanitizeBridgeSetup([ 'tables' => $tables, 'mode' => $mode, 'direct' => $direct, 'config' => $config, 'fonts' => $fonts, ]); $stored = $this->saveBridgeSetupData($customerId, $setup); $this->respond(['ok' => true, 'setup' => $stored]); } private function handleAccountBridgeTest(): void { $user = $this->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); } try { $schema = $this->fetchPlaceholderSchema($bridgeUrl, $bridgeToken, 0); } catch (Throwable $e) { $this->fail('Bridge request failed', $e->getMessage(), 502); return; } $this->respond([ 'ok' => true, 'tables' => $schema['tables'] ?? [], 'setup_hint' => $schema['setup_hint'] ?? null, 'fetched' => $schema['fetched'] ?? date(DATE_ATOM), ]); } private function handleAccountFontsList(): void { $user = $this->requireAuth(); $customerId = (int)($user['customer_id'] ?? 0); $setup = $this->getBridgeSetupData($customerId); $fonts = $setup['fonts'] ?? []; $payload = $this->buildFontCatalog($fonts); $this->respond([ 'ok' => true, 'fonts' => $payload['fonts'], 'font_face_css' => $payload['font_face_css'], ]); } private function handleDebugPhpInfo(): void { $user = $this->requireAuth(); $this->ensureDebugUser($user); $this->ensureDebugEnv(); ob_start(); phpinfo(INFO_GENERAL | INFO_CONFIGURATION | INFO_MODULES | INFO_ENVIRONMENT); $html = ob_get_clean() ?: ''; $this->respond(['ok' => true, 'html' => $html]); } private function handleDebugLogsList(): void { $user = $this->requireAuth(); $this->ensureDebugUser($user); $this->ensureDebugEnv(); $dir = $this->debugDir(); if (!is_dir($dir)) { $this->respond(['ok' => true, 'items' => []]); } $items = []; foreach (glob($dir . '/*.log') ?: [] as $file) { $items[] = [ 'name' => basename($file), 'size' => filesize($file) ?: 0, 'updated_at' => date(DATE_ATOM, filemtime($file) ?: time()), ]; } $this->respond(['ok' => true, 'items' => $items]); } private function handleDebugLogsRead(): void { $user = $this->requireAuth(); $this->ensureDebugUser($user); $this->ensureDebugEnv(); $name = trim((string)($this->in['name'] ?? '')); if ($name === '') { $this->fail('Log name required', null, 422); } $name = preg_replace('/[^a-zA-Z0-9_\.\-]/', '', $name) ?: ''; if ($name === '' || strpos($name, '..') !== false) { $this->fail('Invalid log name', null, 422); } $file = $this->debugDir() . '/' . $name; if (!is_file($file)) { $this->fail('Log not found', null, 404); } $content = (string)file_get_contents($file); $this->respond(['ok' => true, 'content' => $content]); } private function handleDebugLogWrite(): void { $user = $this->requireAuth(); $this->ensureDebugUser($user); $this->ensureDebugEnv(); $name = trim((string)($this->in['name'] ?? 'ui_editor_dirty.log')); $line = $this->in['line'] ?? ''; $append = (int)($this->in['append'] ?? 1) === 1; if ($name === '') { $this->fail('Log name required', null, 422); } $name = preg_replace('/[^a-zA-Z0-9_\.\-]/', '', $name) ?: 'debug.log'; if (strpos($name, '..') !== false) { $this->fail('Invalid log name', null, 422); } $payload = $line; if (is_array($payload) || is_object($payload)) { $payload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } else { $payload = (string)$payload; } debug_log_write($name, $payload, [ 'append' => $append, 'json' => false, 'newline' => true, ]); $this->respond(['ok' => true, 'name' => $name]); } 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 []; $this->ensureCustomerSettingsTableExists(); $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 getBridgeSetupData(int $customerId): array { $settings = $this->getCustomerSettings($customerId); $setup = $settings['bridge_setup'] ?? $this->defaultBridgeSetup(); if ((!$setup['tables'] || !count($setup['tables'])) && !empty($settings['bridge_tables'])) { $setup['tables'] = $this->normalizeBridgeTables($settings['bridge_tables']); } return $setup; } private function saveCustomerSettings(int $customerId, array $data): array { if ($customerId <= 0) return []; $this->ensureCustomerSettingsTableExists(); $allowed = [ 'bridge_url', 'bridge_token', 'sender_token', 'external_api_token', 'editor_default', 'bridge_tables', 'bridge_setup', 'versions_retention', 'smtp_enabled', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_secure', 'smtp_from_email', 'smtp_from_name', 'smtp_reply_to', ]; $fields = array_intersect_key($data, array_flip($allowed)); if (!$fields) return $this->getCustomerSettings($customerId); if (array_key_exists('versions_retention', $fields)) { try { $columns = $this->tableColumns($this->customerSettingsTable()); if (!in_array('versions_retention', $columns, true)) { $sql = 'ALTER TABLE `' . $this->customerSettingsTable() . '` ADD COLUMN `versions_retention` int(10) unsigned DEFAULT 0'; $this->pdo->exec($sql); } } catch (Throwable $e) { // Falls die Spalte nicht angelegt werden kann, Einstellung ignorieren. unset($fields['versions_retention']); } } if (array_key_exists('bridge_tables', $fields)) { $normalized = $this->normalizeBridgeTables($fields['bridge_tables']); $fields['bridge_tables'] = $normalized ? $this->encodeBridgeTables($normalized) : null; } if (array_key_exists('bridge_setup', $fields)) { $fields['bridge_setup'] = $this->encodeBridgeSetup($fields['bridge_setup']); } $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 saveBridgeSetupData(int $customerId, array $setup): array { $settings = $this->saveCustomerSettings($customerId, ['bridge_setup' => $setup]); return $settings['bridge_setup'] ?? $this->defaultBridgeSetup(); } 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'] = []; } if (array_key_exists('bridge_setup', $row)) { $row['bridge_setup'] = $this->decodeBridgeSetup($row['bridge_setup']); } else { $row['bridge_setup'] = $this->defaultBridgeSetup(); } if (empty($row['editor_default'])) { $row['editor_default'] = 'grapesjs'; } if (!isset($row['versions_retention']) || $row['versions_retention'] === '') { $row['versions_retention'] = 0; } else { $row['versions_retention'] = max(0, (int)$row['versions_retention']); } if (!isset($row['smtp_enabled'])) { $row['smtp_enabled'] = 0; } else { $row['smtp_enabled'] = (int)$row['smtp_enabled'] ? 1 : 0; } if (isset($row['smtp_port'])) { $row['smtp_port'] = (int)$row['smtp_port']; } else { $row['smtp_port'] = 0; } $row['smtp_pass_set'] = !empty($row['smtp_pass']); unset($row['smtp_pass']); 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 encodeBridgeSetup($setup) { if (is_array($setup)) { $setup = $this->sanitizeBridgeSetup($setup); return json_encode($setup, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } if (is_string($setup)) { return $setup; } return null; } private function decodeBridgeSetup($stored): array { if (is_array($stored)) { return $this->sanitizeBridgeSetup($stored); } $str = (string)$stored; if ($str === '') { return $this->defaultBridgeSetup(); } $decoded = json_decode($str, true); if (is_array($decoded)) { return $this->sanitizeBridgeSetup($decoded); } return $this->defaultBridgeSetup(); } private function defaultBridgeSetup(): array { return [ 'tables' => [], 'mode' => 'direct', 'direct' => [ 'host' => '', 'port' => 3306, 'database' => '', 'user' => '', 'password' => '', 'charset' => 'utf8mb4', ], 'config' => [ 'file' => '', 'base' => '', 'host_key' => '', 'port_key' => '', 'database_key' => '', 'user_key' => '', 'password_key' => '', 'charset_key' => '', ], 'fonts' => [ 'dir' => '', 'url_base' => '', 'urls' => '', ], ]; } private function sanitizeBridgeSetup(?array $input): array { $defaults = $this->defaultBridgeSetup(); if (!is_array($input)) { return $defaults; } $mode = strtolower((string)($input['mode'] ?? 'direct')); if (!in_array($mode, ['direct', 'config'], true)) { $mode = 'direct'; } $tables = $this->normalizeBridgeTables($input['tables'] ?? []); $direct = $input['direct'] ?? []; $config = $input['config'] ?? []; $fonts = $input['fonts'] ?? []; $sanitizePath = function ($value) { $value = trim((string)$value); if ($value === '') return ''; return preg_replace('/[^a-zA-Z0-9_\.\-]/', '', $value) ?: ''; }; $sanitizeDir = function ($value) { $value = trim((string)$value); if ($value === '') return ''; return trim(preg_replace('/[^a-zA-Z0-9_\.\-\/\\\\:\s]/', '', $value)) ?: ''; }; $sanitizeUrl = function ($value) { $value = trim((string)$value); if ($value === '') return ''; $value = preg_replace('/[\x00-\x1f]/', '', $value); return $value; }; $sanitizeText = function ($value) { $value = (string)$value; $value = preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f]/', '', $value); return $value; }; $result = [ 'tables' => $tables, 'mode' => $mode, 'direct' => [ 'host' => trim((string)($direct['host'] ?? $defaults['direct']['host'])), 'port' => (int)($direct['port'] ?? $defaults['direct']['port']), 'database' => trim((string)($direct['database'] ?? $defaults['direct']['database'])), 'user' => trim((string)($direct['user'] ?? $defaults['direct']['user'])), 'password' => (string)($direct['password'] ?? $defaults['direct']['password']), 'charset' => trim((string)($direct['charset'] ?? $defaults['direct']['charset'])) ?: 'utf8mb4', ], 'config' => [ 'file' => trim((string)($config['file'] ?? '')), 'base' => $sanitizePath($config['base'] ?? ''), 'host_key' => $sanitizePath($config['host_key'] ?? ''), 'port_key' => $sanitizePath($config['port_key'] ?? ''), 'database_key' => $sanitizePath($config['database_key'] ?? ''), 'user_key' => $sanitizePath($config['user_key'] ?? ''), 'password_key' => $sanitizePath($config['password_key'] ?? ''), 'charset_key' => $sanitizePath($config['charset_key'] ?? ''), ], 'fonts' => [ 'dir' => $sanitizeDir($fonts['dir'] ?? ''), 'url_base' => $sanitizeUrl($fonts['url_base'] ?? ''), 'urls' => $sanitizeText($fonts['urls'] ?? ''), ], ]; if ($result['direct']['port'] <= 0) { $result['direct']['port'] = 3306; } return $result; } private function buildFontCatalog(array $fonts): array { $items = []; $faces = []; $seen = []; $groups = []; $allowed = ['woff2', 'woff', 'ttf', 'otf']; $addGroup = function (string $family, array $sources) use (&$items, &$faces, &$seen, $allowed) { $family = trim($family); if ($family === '') return; if (!empty($seen[strtolower($family)])) return; $srcParts = []; foreach ($allowed as $ext) { if (!empty($sources[$ext])) { $url = $sources[$ext]; $srcParts[] = "url('{$url}') format('{$ext}')"; } } if (!$srcParts) return; $safeFamily = str_replace("'", "\\'", $family); $faces[] = "@font-face{font-family:'{$safeFamily}';font-style:normal;font-weight:400;src:" . implode(',', $srcParts) . ";}"; $items[] = [ 'label' => $family, 'value' => "'" . $safeFamily . "', sans-serif", ]; $seen[strtolower($family)] = true; }; $inferFamily = function (string $name): string { $name = preg_replace('/[-_]+/', ' ', $name); $name = preg_replace('/\s+/', ' ', $name); return trim($name); }; $dir = trim((string)($fonts['dir'] ?? '')); $base = trim((string)($fonts['url_base'] ?? '')); if ($dir !== '' && $base !== '' && is_dir($dir)) { $base = rtrim($base, '/'); $pattern = $dir . '/*.{woff2,woff,ttf,otf,WOFF2,WOFF,TTF,OTF}'; $files = glob($pattern, GLOB_BRACE) ?: []; foreach ($files as $file) { $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); if (!in_array($ext, $allowed, true)) continue; $name = pathinfo($file, PATHINFO_FILENAME); $groups[$name][$ext] = $base . '/' . basename($file); } } $rawUrls = (string)($fonts['urls'] ?? ''); if ($rawUrls !== '') { $lines = preg_split('/\r?\n/', $rawUrls) ?: []; foreach ($lines as $line) { $line = trim($line); if ($line === '') continue; $family = ''; $url = ''; if (strpos($line, '|') !== false) { [$family, $url] = array_map('trim', explode('|', $line, 2)); } elseif (strpos($line, '=') !== false) { [$family, $url] = array_map('trim', explode('=', $line, 2)); } else { $url = $line; } if ($url === '') continue; $path = parse_url($url, PHP_URL_PATH); if (!$path) continue; $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if (!in_array($ext, $allowed, true)) continue; $name = pathinfo($path, PATHINFO_FILENAME); $family = $family !== '' ? $family : $inferFamily($name); if ($family === '') continue; $groups[$family][$ext] = $url; } } foreach ($groups as $family => $sources) { $display = $inferFamily($family); $addGroup($display, $sources); } return [ 'fonts' => $items, 'font_face_css' => implode("\n", $faces), ]; } private function customerSettingsTable(): string { return 'emailtemplate_customer_settings'; } private function ensureCustomerSettingsTableExists(): void { $table = $this->customerSettingsTable(); $justCreated = false; if (!$this->tableExists($table)) { try { $sql = <<