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(); $cleanup = $this->pdo->prepare( "DELETE FROM `$table` WHERE `id` IN ( SELECT `id` FROM ( SELECT `id` FROM `$table` WHERE `content_id` = :cid ORDER BY `id` DESC LIMIT 10, 1000000 ) t )" ); $cleanup->execute([':cid' => $contentId]); return $newId; } catch (Throwable $e) { // Versioning darf nicht das Speichern blockieren. return null; } } 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 = $stmt->rowCount() > 0; 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"; $stmt = $this->pdo->prepare( "SELECT " . implode(',', $select) . " FROM `$table` WHERE `customer_id` = :cid AND `content_id` = :content ORDER BY `id` DESC LIMIT 10" ); $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); $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); 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); $ok = $this->deactivateContentVersion($customerId, $contentId); 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); } $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); $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); } } if (!$this->dispatchTestMail($recipient, $subject, $html, $sender)) { $this->writeDebugLog('templates_test_send', [ 'time' => date(DATE_ATOM), 'template_id' => $templateId, 'to' => $recipient, 'subject' => $subject, 'sender_id' => $senderId > 0 ? $senderId : 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), ]); $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 { $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); } $references = $this->findTemplateReferences($customerId, $templateId); $this->respond([ 'ok' => true, 'template_id' => $templateId, 'count' => count($references), 'references' => $references, ]); } private function findTemplateReferences(int $customerId, int $templateId): array { $out = []; $seen = []; $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'))]; $itemsTable = $this->contentItemsTable(); if (!$this->tableExists($itemsTable)) return []; $itemCols = $this->resolveContentItemColumns($itemsTable); $htmlCol = $itemCols['html']; $jsonCol = $itemCols['json']; $craftCol = $itemCols['craft']; $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; $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"; $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"; } $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) { $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'] ?? ''), ]; $found = false; foreach ($blobs as $blob) { if ($matches($blob, $libKinds)) { $found = true; break; } } if ($found) { $id = (int)($row['id'] ?? 0); if ($id <= 0 || isset($seen[$id])) continue; $seen[$id] = true; $out[] = [ 'id' => $id, 'name' => (string)($row['name'] ?? ''), ]; } } } 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']); if ($htmlCol || $jsonCol || $craftCol) { $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"; $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'] ?? ''), ]; $found = false; foreach ($blobs as $blob) { if ($matches($blob, $libKinds)) { $found = true; break; } } if ($found) { $id = (int)($row['id'] ?? 0); if ($id <= 0 || isset($seen[$id])) continue; $seen[$id] = true; $out[] = [ 'id' => $id, 'name' => (string)($row['name'] ?? ''), ]; } } } } } $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) { 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 || isset($seen[$id])) continue; $seen[$id] = true; $out[] = [ 'id' => $id, 'name' => (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 || isset($seen[$id])) continue; $seen[$id] = true; $out[] = [ 'id' => $id, 'name' => (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): bool { if (!function_exists('mail')) { return false; } $smtp = $this->conf['smtp'] ?? []; $fromEmail = $sender['from_email'] ?? ($smtp['from_email'] ?? 'no-reply@example.com'); $fromName = $sender['from_name'] ?? ($sender['label'] ?? ($smtp['from_name'] ?? 'EmailTemplate')); $replyTo = $sender['reply_to'] ?? ''; $headers = [ 'MIME-Version: 1.0', 'Content-Type: text/html; charset=UTF-8', 'From: ' . $this->formatEmailAddress($fromEmail, $fromName), ]; if ($replyTo !== '') { $headers[] = 'Reply-To: ' . $this->formatEmailAddress($replyTo, $fromName ?: $fromEmail); } $encodedSubject = function_exists('mb_encode_mimeheader') ? mb_encode_mimeheader($subject, 'UTF-8') : $subject; return @mail($to, $encodedSubject, $html, implode("\r\n", $headers)); } private function formatEmailAddress(string $email, string $name): string { $email = trim($email); if ($email === '') { return 'no-reply@example.com'; } $name = trim($name); if ($name === '') { return $email; } $encoded = function_exists('mb_encode_mimeheader') ? mb_encode_mimeheader($name, 'UTF-8') : $name; return sprintf('%s <%s>', $encoded, $email); } // ================================================================= // đź’ˇ Ă–ffentliche run()-Methode (KORRIGIERT) // ================================================================= public function run(): void { // đź’ˇ KORREKTUR: Der Content-Type Header wird hier entfernt, da er jetzt in respond() // zentralisiert wurde, um sicherzustellen, dass er auch bei Fehlern im Konstruktor oder // im try-Block korrekt gesetzt wird. // header('Content-Type: application/json; charset=utf-8'); // DIESE ZEILE ENTFERNT try { // Extrahiere den Ressourcen-Typ und die Operation (z.B. 'templates' und 'list') [$kind, $operation] = explode('.', $this->action, 2) + [1 => '']; switch ($this->action) { case 'health': $this->respond(['ok' => true, 'time' => date('c')]); 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 '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.fonts.list': $this->handleAccountFontsList(); break; case 'debug.logs.list': $this->handleDebugLogsList(); break; case 'debug.logs.read': $this->handleDebugLogsRead(); 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 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 = '