Files
emailtemplate.it/src/ApiKernel.php
2026-02-24 01:01:42 +01:00

6140 lines
261 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
// 💡 NEUE KORREKTUR: Starte Output Buffering so früh wie möglich, um Whitespace/Errors
// von inkludierten Dateien (AuthService.php, config.php) abzufangen.
ob_start();
// Lade den AuthService
require_once __DIR__ . '/AuthService.php';
require_once __DIR__ . '/../inc/helpers.php';
// -----------------------------------------------------------------
// ApiKernel.php (OPTIMIERT & KORRIGIERT)
// -----------------------------------------------------------------
class ApiKernel
{
// Klassen-Eigenschaften
private array $conf;
private ?PDO $pdo = null;
private array $in;
private string $action;
private array $tableMap;
private AuthService $authService;
private array $tableExistsCache = [];
private ?string $lastMailError = null;
// --- Initialisierung & Konstruktor (Optimiert) ---
public function __construct()
{
// ob_start() wurde an den Anfang der Datei verschoben und wird hier entfernt.
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
try {
$this->conf = $this->loadConfig();
$this->cors();
$this->setInput();
$this->pdo = $this->getPdoTemplates();
$this->resolveAction();
$this->resolveTableMap();
$this->authService = new AuthService($this->conf, $this->pdo);
} catch (Throwable $e) {
// Im Fehlerfall ruft fail() die respond() Methode auf, die den Header setzt und den Buffer leert.
$this->fail('Initialization error', get_class($e) . ': ' . $e->getMessage(), 500);
}
}
// --- Core Responder-Methoden (KORRIGIERT) ---
public function respond($data, int $code = 200): void
{
// 1. Output-Puffer leeren, um jeglichen unbeabsichtigten Output zu verwerfen (z.B. PHP Notices).
if (ob_get_level() > 0) {
ob_clean();
}
// 2. 💡 KRITISCHE KORREKTUR: Content-Type Header setzen.
// Dies ist der entscheidende Schritt, der dem Browser sagt: "Dies ist JSON!"
if (!headers_sent() && !isset($this->conf['no_content_type'])) {
header('Content-Type: application/json; charset=utf-8');
}
http_response_code($code);
echo is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public function fail(string $msg, $detail = null, int $code = 400): void
{
$this->respond(['ok' => false, 'error' => $msg, 'detail' => $detail], $code);
}
// --- Private Initialisierungs- & Utility-Methoden (Unverändert) ---
private function loadConfig(): array { /* ... Logik bleibt unverändert ... */
$paths = [
__DIR__ . '/../config/emailtemplate.conf.php',
__DIR__ . '/../inc/config.php',
__DIR__ . '/config.php',
__DIR__ . '/../config.php',
__DIR__ . '/../../config.php',
];
foreach ($paths as $p) {
if (is_file($p)) {
$conf = @include $p;
if (is_array($conf)) return $conf;
}
}
$this->fail('Invalid config', 'config file not found or not returning array', 500);
}
private function cors(): void { /* ... Logik bleibt unverändert ... */
$corsConfig = $this->conf['cors'] ?? '*';
$originHeader = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowedOrigin = null;
if (is_array($corsConfig)) {
if ($originHeader && in_array($originHeader, $corsConfig, true)) {
$allowedOrigin = $originHeader;
}
} elseif (is_string($corsConfig)) {
if ($corsConfig === '*' && $originHeader !== '') {
$allowedOrigin = $originHeader;
} else {
$allowedOrigin = $corsConfig;
}
}
if ($allowedOrigin) {
header('Access-Control-Allow-Origin: ' . $allowedOrigin);
header('Vary: Origin');
header('Access-Control-Allow-Credentials: true');
} elseif ($corsConfig === '*') {
header('Access-Control-Allow-Origin: *');
}
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
$this->respond(['ok' => true]);
}
if (!empty($this->conf['auth']['cookie'])) {
$c = $this->conf['auth']['cookie'];
$params = session_get_cookie_params();
$params['lifetime'] = $c['lifetime'] ?? $params['lifetime'];
$params['path'] = $c['path'] ?? $params['path'];
$params['domain'] = $c['domain'] ?? $params['domain'];
$params['secure'] = $c['secure'] ?? $params['secure'];
$params['httponly'] = $c['httponly'] ?? $params['httponly'];
if (isset($c['samesite'])) $params['samesite'] = $c['samesite'];
session_set_cookie_params($params);
}
}
private function setInput(): void { /* ... Logik bleibt unverändert ... */
$data = [];
$ct = $_SERVER['CONTENT_TYPE'] ?? '';
if (stripos($ct, 'application/json') !== false) {
$raw = file_get_contents('php://input');
if ($raw !== false && $raw !== '') {
$js = json_decode($raw, true);
if (is_array($js)) $data = $js;
}
}
foreach ($_POST as $k => $v) $data[$k] = $v;
foreach ($_GET as $k => $v) if (!array_key_exists($k, $data)) $data[$k] = $v;
$this->in = $data;
}
private function getPdoTemplates(): PDO { /* ... Logik bleibt unverändert ... */
if (!isset($this->conf['projectdb']) || !is_array($this->conf['projectdb'])) {
$this->fail('Missing project DB config', null, 500);
}
$c = $this->conf['projectdb'];
$host = $c['db_host'] ?? 'localhost';
$db = $c['db_name'] ?? ($c['database'] ?? '');
$user = $c['db_user'] ?? ($c['username'] ?? '');
$pass = $c['db_pass'] ?? ($c['password'] ?? '');
$charset = $c['db_charset'] ?? 'utf8mb4';
$port = $c['db_port'] ?? 3306;
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=$charset";
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];
return new PDO($dsn, $user, $pass, $opt);
}
private function resolveAction(): void { /* ... Logik bleibt unverändert ... */
$action = $this->val($this->in, 'action', '');
$resource = $this->val($this->in, 'resource', null);
$allowedResources = ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config', 'content_versions'];
if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) {
$verb = strtolower((string)$action);
if (in_array($verb, ['list', 'get', 'create', 'update', 'delete'], true)) $action = $resource . '.' . $verb;
}
$this->action = $action;
}
private function resolveTableMap(): void { /* ... Logik bleibt unverändert ... */
$tables = $this->conf['tables'] ?? [];
$this->tableMap = [
'templates' => $tables['templates'] ?? 'emailtemplate_templates',
'sections' => $tables['sections'] ?? 'emailtemplate_sections',
'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks',
'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets',
'content_items' => $tables['content_items'] ?? 'emailtemplate_content_items',
'content_sections' => $tables['content_sections'] ?? 'emailtemplate_content_sections',
'content_versions' => $tables['content_versions'] ?? 'emailtemplate_content_versions',
];
}
private function val(array $in, $keys, $default = null) { /* ... Logik bleibt unverändert ... */
if (!is_array($keys)) $keys = [$keys];
foreach ($keys as $k) if (array_key_exists($k, $in)) return $in[$k];
return $default;
}
private function firstExisting(array $columns, array $candidates): ?string { /* ... Logik bleibt unverändert ... */
foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c;
return null;
}
private function tableColumns(string $table): array { /* ... Logik bleibt unverändert ... */
$cols = [];
$stmt = $this->pdo->query("SHOW COLUMNS FROM `$table`");
foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field'];
return $cols;
}
private function tableExists(string $table): bool
{
if ($table === '') return false;
if (array_key_exists($table, $this->tableExistsCache)) {
return $this->tableExistsCache[$table];
}
try {
$this->pdo->query("SELECT 1 FROM `$table` LIMIT 1");
$this->tableExistsCache[$table] = true;
} catch (Throwable $e) {
$this->tableExistsCache[$table] = false;
}
return $this->tableExistsCache[$table];
}
private function primaryKey(string $table): ?string { /* ... Logik bleibt unverändert ... */
$stmt = $this->pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'");
$stmt->execute();
$row = $stmt->fetch();
return $row['Column_name'] ?? null;
}
private function requireAuth(): array
{
return $this->ensureAuthUserHydrated($this->authService->requireAuth());
}
private function pullId(array $src) { /* ... Logik bleibt unverändert ... */
$aliases = ['id', 'item_id', 'template_id', 'tpl_id', 'section_id', 'sec_id', 'block_id', 'blk_id', 'snippet_id', 'snip_id'];
foreach ($aliases as $a) if (isset($src[$a]) && $src[$a] !== '') return $src[$a];
return null;
}
private function tenantWhere(array $session): array { /* ... Logik bleibt unverändert ... */
$multi = $this->conf['multi'] ?? [];
$tenantCol = $multi['tenant_col'] ?? null;
$mapSess = $multi['map_session_to'] ?? 'id';
if (!$tenantCol) return ['', []];
if (!$session) return [' AND 1=0 ', []];
$val = $session[$mapSess] ?? null;
if ($val === null || $val === '') return [' AND 1=0 ', []];
return [" AND `$tenantCol` = :__tenant", [':__tenant' => $val]];
}
private function tenantAssign(array $session, array $columns): array { /* ... Logik bleibt unverändert ... */
$multi = $this->conf['multi'] ?? [];
$tenantCol = $multi['tenant_col'] ?? null;
$mapSess = $multi['map_session_to'] ?? 'id';
if (!$tenantCol || !in_array($tenantCol, $columns, true)) return [];
$val = $session[$mapSess] ?? null;
return ($val === null || $val === '') ? [] : [$tenantCol => $val];
}
private function resolveIdCol(string $kind): array { /* ... Logik bleibt unverändert ... */
$t = $this->tableMap[$kind];
$cfg = $this->conf['columns'][$kind] ?? [];
$cols = $this->tableColumns($t);
$idCol = $cfg['id'] ?? ($this->firstExisting($cols, ['id']) ?: $this->primaryKey($t));
if (!$idCol) $idCol = 'id';
return [$idCol, $cols];
}
private function parseHtmlToGjsComponents(string $html): array { /* ... Logik bleibt unverändert ... */
if (trim($html) === '') return [];
return [
[
'type' => 'html',
'content' => $html,
'removable' => true,
'draggable' => true,
'droppable' => true,
'copyable' => true,
'selectable' => true,
'editable' => false,
'traits' => [],
]
];
}
// 💡 Bereinigungsmethode
private function cleanReferenceComponents(array $node): array
{
if (isset($node['type']) && $node['type'] === 'library-reference') {
$node['content'] = '';
$node['components'] = [];
}
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->cleanReferenceComponents($value);
}
}
return $node;
}
private function encodeJson($value): string
{
$options = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
$json = json_encode($value, $options, 2048);
if ($json === false) {
$json = json_encode(
$value,
$options | JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE,
2048
);
}
return $json === false ? '' : $json;
}
private function normalizeApiName(string $value): string
{
$value = trim($value);
$value = preg_replace('/\s+/', '-', $value);
$value = preg_replace('/[^A-Za-z0-9_-]+/', '-', $value);
$value = preg_replace('/-+/', '-', $value);
return trim($value, '-');
}
private function normalizeSectionSlug(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/\s+/', '-', $value);
$value = preg_replace('/[^a-z0-9_-]+/', '-', $value);
$value = preg_replace('/-+/', '-', $value);
return trim($value, '-');
}
private function contentItemsTable(): string
{
return $this->tableMap['content_items'] ?? $this->lookupTableName('content_items', 'emailtemplate_content_items');
}
private function contentSectionsTable(): string
{
return $this->tableMap['content_sections'] ?? $this->lookupTableName('content_sections', 'emailtemplate_content_sections');
}
private function contentVersionsTable(): string
{
return $this->tableMap['content_versions'] ?? $this->lookupTableName('content_versions', 'emailtemplate_content_versions');
}
private function resolveContentItemColumns(string $table): array
{
$cols = $this->tableColumns($table);
return [
'category' => $this->firstExisting($cols, ['category', 'cat']),
'html' => $this->firstExisting($cols, ['html', 'html_content', 'body', 'markup', 'content']),
'css' => $this->firstExisting($cols, ['css', 'css_content']),
'json' => $this->firstExisting($cols, ['json_content']),
'editor' => $this->firstExisting($cols, ['editor_type', 'editor']),
'craft' => $this->firstExisting($cols, ['craft_json', 'craft_content', 'craft_data']),
'settings' => $this->firstExisting($cols, ['settings_json', 'settings']),
];
}
private function resolveContentVersionColumns(string $table): array
{
$cols = $this->tableColumns($table);
return [
'json' => $this->firstExisting($cols, ['json_content']),
'html' => $this->firstExisting($cols, ['html', 'html_content']),
'css' => $this->firstExisting($cols, ['css', 'css_content']),
'editor' => $this->firstExisting($cols, ['editor_type', 'editor']),
'craft' => $this->firstExisting($cols, ['craft_json', 'craft_content', 'craft_data']),
'settings' => $this->firstExisting($cols, ['settings_json', 'settings']),
'is_active' => $this->firstExisting($cols, ['is_active']),
'was_active' => $this->firstExisting($cols, ['was_active']),
];
}
private function useUnifiedContent(): bool
{
return $this->tableExists($this->contentItemsTable()) && $this->tableExists($this->contentSectionsTable());
}
private function createContentVersion(array $current, array $itemCols, int $customerId, int $sectionId): ?int
{
$table = $this->contentVersionsTable();
if (!$this->tableExists($table)) return null;
$contentId = (int)($current['id'] ?? 0);
if ($contentId <= 0) return null;
$jsonCol = $itemCols['json'] ?? null;
$htmlCol = $itemCols['html'] ?? null;
$cssCol = $itemCols['css'] ?? null;
$editorCol = $itemCols['editor'] ?? null;
$craftCol = $itemCols['craft'] ?? null;
$settingsCol = $itemCols['settings'] ?? null;
$json = $jsonCol ? ($current[$jsonCol] ?? null) : null;
$html = $htmlCol ? ($current[$htmlCol] ?? null) : null;
$css = $cssCol ? ($current[$cssCol] ?? null) : null;
$editorType = $editorCol ? ($current[$editorCol] ?? null) : null;
$craftJson = $craftCol ? ($current[$craftCol] ?? null) : null;
$settings = $settingsCol ? ($current[$settingsCol] ?? null) : null;
try {
$stmt = $this->pdo->prepare("SELECT MAX(`version_no`) FROM `$table` WHERE `content_id` = :cid");
$stmt->execute([':cid' => $contentId]);
$nextVersion = (int)($stmt->fetchColumn() ?: 0) + 1;
$versionCols = $this->resolveContentVersionColumns($table);
$data = [
'customer_id' => $customerId,
'content_id' => $contentId,
'section_id' => $sectionId,
'version_no' => $nextVersion,
];
if ($versionCols['editor']) $data[$versionCols['editor']] = $editorType;
if ($versionCols['json']) $data[$versionCols['json']] = $json;
if ($versionCols['html']) $data[$versionCols['html']] = $html;
if ($versionCols['css']) $data[$versionCols['css']] = $css;
if ($versionCols['craft']) $data[$versionCols['craft']] = $craftJson;
if ($versionCols['settings']) $data[$versionCols['settings']] = $settings;
$columns = array_keys($data);
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
$placeholders = implode(',', array_map(fn($c) => ":$c", $columns));
$stmt = $this->pdo->prepare("INSERT INTO `$table` ($insertCols) VALUES ($placeholders)");
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
$stmt->execute();
$newId = (int)$this->pdo->lastInsertId();
$this->applyContentVersionRetention($customerId, $contentId);
return $newId;
} catch (Throwable $e) {
// Versioning darf nicht das Speichern blockieren.
return null;
}
}
private function applyContentVersionRetention(int $customerId, int $contentId): void
{
$limit = $this->getContentVersionRetentionLimit($customerId);
if ($limit <= 0) return;
$table = $this->contentVersionsTable();
if (!$this->tableExists($table)) return;
try {
$versionCols = $this->resolveContentVersionColumns($table);
$isActiveCol = $versionCols['is_active'];
$activeFilter = $isActiveCol ? " AND (`$isActiveCol` IS NULL OR `$isActiveCol` = 0)" : '';
$keepSql = "SELECT `id` FROM `$table` WHERE `content_id` = :cid ORDER BY `version_no` DESC, `id` DESC LIMIT :lim";
$deleteSql = "DELETE FROM `$table` WHERE `content_id` = :cid$activeFilter AND `id` NOT IN (SELECT `id` FROM ($keepSql) AS keep_ids)";
$stmt = $this->pdo->prepare($deleteSql);
$stmt->bindValue(':cid', $contentId, PDO::PARAM_INT);
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->execute();
} catch (Throwable $e) {
// Retention darf nicht das Speichern blockieren.
}
}
private function getContentVersionRetentionLimit(int $customerId): int
{
if ($customerId <= 0) return 0;
$settings = $this->getCustomerSettings($customerId);
$limit = (int)($settings['versions_retention'] ?? 0);
return max(0, $limit);
}
private function activateContentVersion(int $customerId, int $contentId, int $versionId): bool
{
$table = $this->contentVersionsTable();
if (!$this->tableExists($table)) return false;
$versionCols = $this->resolveContentVersionColumns($table);
$isActiveCol = $versionCols['is_active'];
$wasActiveCol = $versionCols['was_active'];
if (!$isActiveCol) return false;
$this->pdo->prepare("UPDATE `$table` SET `$isActiveCol` = 0 WHERE `customer_id` = :cid AND `content_id` = :content")
->execute([':cid' => $customerId, ':content' => $contentId]);
$set = "`$isActiveCol` = 1";
if ($wasActiveCol) $set .= ", `$wasActiveCol` = 1";
$stmt = $this->pdo->prepare(
"UPDATE `$table` SET $set WHERE `customer_id` = :cid AND `content_id` = :content AND `id` = :id LIMIT 1"
);
$stmt->execute([':cid' => $customerId, ':content' => $contentId, ':id' => $versionId]);
return $stmt->rowCount() > 0;
}
private function deactivateContentVersion(int $customerId, int $contentId): bool
{
$table = $this->contentVersionsTable();
if (!$this->tableExists($table)) return false;
$versionCols = $this->resolveContentVersionColumns($table);
$isActiveCol = $versionCols['is_active'];
if (!$isActiveCol) return false;
$stmt = $this->pdo->prepare("UPDATE `$table` SET `$isActiveCol` = 0 WHERE `customer_id` = :cid AND `content_id` = :content");
$stmt->execute([':cid' => $customerId, ':content' => $contentId]);
return $stmt->rowCount() > 0;
}
private function isLegacyContentKind(string $kind): bool
{
return in_array($kind, ['templates', 'sections', 'blocks', 'snippets'], true);
}
private function resolveLegacySectionDefaults(string $kind): array
{
if ($kind === 'templates') {
return ['name' => 'Emailtemplate', 'slug' => 'emailtemplate', 'is_template' => true];
}
$name = ucfirst($kind);
return ['name' => $name, 'slug' => $this->normalizeSectionSlug($kind), 'is_template' => false];
}
private function resolveSectionSlugFromKind(string $kind): string
{
$legacy = $this->normalizeResourceKind($kind);
if ($legacy) {
return $legacy === 'templates' ? 'emailtemplate' : $legacy;
}
return $this->normalizeSectionSlug($kind);
}
private function fetchContentSectionBySlug(int $customerId, string $slug): ?array
{
$table = $this->contentSectionsTable();
if (!$this->tableExists($table)) return null;
$sql = "SELECT * FROM `$table` WHERE `customer_id` = :cid AND `slug` = :slug LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId, ':slug' => $slug]);
$row = $stmt->fetch();
return $row ?: null;
}
private function fetchContentSectionById(int $customerId, int $id): ?array
{
if ($id <= 0) return null;
$table = $this->contentSectionsTable();
if (!$this->tableExists($table)) return null;
$sql = "SELECT * FROM `$table` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId, ':id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
private function ensureContentSection(int $customerId, string $name, string $slug, bool $isTemplate): array
{
$slug = $this->normalizeSectionSlug($slug);
$existing = $this->fetchContentSectionBySlug($customerId, $slug);
if ($existing) return $existing;
$table = $this->contentSectionsTable();
if (!$this->tableExists($table)) {
$this->fail('Sections table not available', null, 500);
}
$position = 0;
if ($isTemplate) {
$stmt = $this->pdo->prepare("UPDATE `$table` SET `position` = `position` + 1 WHERE `customer_id` = :cid");
$stmt->execute([':cid' => $customerId]);
$position = 0;
} else {
$stmt = $this->pdo->prepare("SELECT MAX(`position`) FROM `$table` WHERE `customer_id` = :cid");
$stmt->execute([':cid' => $customerId]);
$position = (int)($stmt->fetchColumn() ?: 0) + 1;
}
$stmt = $this->pdo->prepare(
"INSERT INTO `$table` (`customer_id`,`name`,`slug`,`position`,`is_template`) VALUES (:cid,:name,:slug,:pos,:tpl)"
);
$stmt->execute([
':cid' => $customerId,
':name' => $name,
':slug' => $slug,
':pos' => $position,
':tpl' => $isTemplate ? 1 : 0,
]);
$id = (int)$this->pdo->lastInsertId();
return $this->fetchContentSectionById($customerId, $id) ?? [
'id' => $id,
'customer_id' => $customerId,
'name' => $name,
'slug' => $slug,
'position' => $position,
'is_template' => $isTemplate ? 1 : 0,
];
}
private function ensureEmailtemplateSection(int $customerId): array
{
$existing = $this->fetchContentSectionBySlug($customerId, 'emailtemplate');
if ($existing) {
$needsUpdate = false;
$updates = [];
if (empty($existing['is_template'])) {
$updates[] = "`is_template` = 1";
$needsUpdate = true;
}
if (($existing['name'] ?? '') !== 'Emailtemplate') {
$updates[] = "`name` = 'Emailtemplate'";
$needsUpdate = true;
}
if (($existing['slug'] ?? '') !== 'emailtemplate') {
$updates[] = "`slug` = 'emailtemplate'";
$needsUpdate = true;
}
if ((int)($existing['position'] ?? 0) !== 0) {
$updates[] = "`position` = 0";
$needsUpdate = true;
}
if ($needsUpdate) {
$table = $this->contentSectionsTable();
if ($this->tableExists($table)) {
$sql = "UPDATE `$table` SET " . implode(',', $updates) . " WHERE `id` = :id AND `customer_id` = :cid LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => (int)$existing['id'], ':cid' => $customerId]);
}
$existing = $this->fetchContentSectionById($customerId, (int)$existing['id']) ?? $existing;
}
return $existing;
}
return $this->ensureContentSection($customerId, 'Emailtemplate', 'emailtemplate', true);
}
private function resolveSectionFromInput(int $customerId): ?array
{
$sectionId = (int)$this->val($this->in, ['section_id', 'section', 'sectionId'], 0);
if ($sectionId > 0) {
return $this->fetchContentSectionById($customerId, $sectionId);
}
$sectionSlug = trim((string)$this->val($this->in, ['section_slug', 'sectionSlug', 'section_code'], ''));
if ($sectionSlug !== '') {
return $this->fetchContentSectionBySlug($customerId, $this->normalizeSectionSlug($sectionSlug));
}
return null;
}
private function assertTemplateApiNameUnique(
string $table,
string $apiCol,
string $idCol,
int $customerId,
string $apiName,
?int $excludeId
): void {
$sql = "SELECT COUNT(*) FROM `$table` WHERE `$apiCol` = :api AND `customer_id` = :cid";
$params = [':api' => $apiName, ':cid' => $customerId];
if ($excludeId !== null && $excludeId > 0) {
$sql .= " AND `$idCol` <> :id";
$params[':id'] = $excludeId;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$count = (int)$stmt->fetchColumn();
if ($count > 0) {
$this->fail('api_name already exists', ['api_name' => $apiName], 409);
}
}
private function assertContentApiNameUnique(
string $table,
string $apiCol,
string $idCol,
int $customerId,
string $apiName,
?int $excludeId
): void {
$sql = "SELECT COUNT(*) FROM `$table` WHERE `$apiCol` = :api AND `customer_id` = :cid";
$params = [':api' => $apiName, ':cid' => $customerId];
if ($excludeId !== null && $excludeId > 0) {
$sql .= " AND `$idCol` <> :id";
$params[':id'] = $excludeId;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$count = (int)$stmt->fetchColumn();
if ($count > 0) {
$this->fail('api_name already exists', ['api_name' => $apiName], 409);
}
}
// =================================================================
// 🚀 CRUD HANDLER METHODEN
// =================================================================
private function handleContentList(?array $fixedSection = null): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$itemsTable = $this->contentItemsTable();
$sectionsTable = $this->contentSectionsTable();
$versionsTable = $this->contentVersionsTable();
if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) {
$this->fail('Content tables not available', null, 500);
}
$itemCols = $this->resolveContentItemColumns($itemsTable);
$catCol = $itemCols['category'];
$htmlCol = $itemCols['html'];
$jsonCol = $itemCols['json'];
$onlyActive = (int)$this->val($this->in, ['active_only', 'only_active', 'active'], 0) === 1;
$versionCols = $onlyActive && $this->tableExists($versionsTable)
? $this->resolveContentVersionColumns($versionsTable)
: null;
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
$q = trim((string)$this->val($this->in, 'q', ''));
$limit = max(1, (int)$this->val($this->in, 'limit', 500));
$offset = max(0, (int)$this->val($this->in, 'offset', 0));
$where = " WHERE i.`customer_id` = :cid ";
$params = [':cid' => $customerId];
if ($section && !empty($section['id'])) {
$where .= " AND i.`section_id` = :sid ";
$params[':sid'] = (int)$section['id'];
}
if ($q !== '') {
$where .= " AND (i.`name` LIKE :q";
if ($catCol) {
$where .= " OR i.`$catCol` LIKE :q";
}
$where .= ") ";
$params[':q'] = '%' . $q . '%';
}
$join = '';
$select = "i.*, s.`name` AS section_name, s.`slug` AS section_slug, s.`position` AS section_position, s.`is_template` AS section_is_template";
if ($onlyActive && $versionCols) {
$join = " JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1";
$select .= ", v.`id` AS active_version_id, v.`version_no` AS active_version_no, v.`is_active` AS version_is_active, v.`was_active` AS version_was_active";
if (!empty($versionCols['html'])) $select .= ", v.`{$versionCols['html']}` AS version_html";
if (!empty($versionCols['json'])) $select .= ", v.`{$versionCols['json']}` AS version_json";
if (!empty($versionCols['craft'])) $select .= ", v.`{$versionCols['craft']}` AS version_craft";
if (!empty($versionCols['editor'])) $select .= ", v.`{$versionCols['editor']}` AS version_editor";
}
$sql = "SELECT $select
FROM `$itemsTable` i
JOIN `$sectionsTable` s ON s.`id` = i.`section_id`
$join
$where
ORDER BY i.`updated_at` DESC, i.`id` DESC
LIMIT :off,:lim";
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll() ?: [];
$out = [];
foreach ($rows as $r) {
$item = [
'id' => $r['id'] ?? null,
'name' => $r['name'] ?? null,
'api_name' => $r['api_name'] ?? null,
'category' => $catCol ? ($r[$catCol] ?? null) : null,
'section_id' => $r['section_id'] ?? null,
'section_name' => $r['section_name'] ?? null,
'section_slug' => $r['section_slug'] ?? null,
'section_position' => $r['section_position'] ?? null,
'section_is_template' => (int)($r['section_is_template'] ?? 0),
'updated_at' => $r['updated_at'] ?? null,
'created_at' => $r['created_at'] ?? null,
];
if ($onlyActive && $versionCols) {
if (!empty($r['active_version_id'])) $item['active_version_id'] = (int)$r['active_version_id'];
if (array_key_exists('version_html', $r)) $item['html'] = (string)($r['version_html'] ?? '');
if (array_key_exists('version_json', $r)) $item['content'] = $r['version_json'];
if (array_key_exists('version_craft', $r)) $item['craft_json'] = $r['version_craft'];
if (array_key_exists('version_editor', $r)) $item['editor_type'] = $r['version_editor'];
} else {
if ($htmlCol && array_key_exists($htmlCol, $r)) $item['html'] = (string)($r[$htmlCol] ?? '');
if ($jsonCol && array_key_exists($jsonCol, $r)) $item['content'] = $r[$jsonCol];
}
$out[] = $item;
}
$this->respond([
'ok' => true,
'kind' => 'content',
'items' => $out,
'data' => $out,
'count' => count($out),
'offset' => $offset,
'limit' => $limit,
]);
}
private function handleContentGet(?array $fixedSection = null): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
$itemsTable = $this->contentItemsTable();
$sectionsTable = $this->contentSectionsTable();
$versionsTable = $this->contentVersionsTable();
if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) {
$this->fail('Content tables not available', null, 500);
}
$itemCols = $this->resolveContentItemColumns($itemsTable);
$htmlCol = $itemCols['html'];
$jsonCol = $itemCols['json'];
$craftCol = $itemCols['craft'];
$editorCol = $itemCols['editor'];
$onlyActive = (int)$this->val($this->in, ['active_only', 'only_active', 'active'], 0) === 1;
$versionCols = $onlyActive && $this->tableExists($versionsTable)
? $this->resolveContentVersionColumns($versionsTable)
: null;
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
$params = [':cid' => $customerId, ':id' => $id];
$where = " WHERE i.`customer_id` = :cid AND i.`id` = :id ";
if ($section && !empty($section['id'])) {
$where .= " AND i.`section_id` = :sid ";
$params[':sid'] = (int)$section['id'];
}
$select = "i.*, s.`name` AS section_name, s.`slug` AS section_slug, s.`position` AS section_position, s.`is_template` AS section_is_template";
$join = '';
if ($onlyActive && $versionCols) {
$join = "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1";
if (!empty($versionCols['html'])) $select .= ", v.`{$versionCols['html']}` AS version_html";
if (!empty($versionCols['json'])) $select .= ", v.`{$versionCols['json']}` AS version_json";
if (!empty($versionCols['craft'])) $select .= ", v.`{$versionCols['craft']}` AS version_craft";
if (!empty($versionCols['editor'])) $select .= ", v.`{$versionCols['editor']}` AS version_editor";
$select .= ", v.`id` AS active_version_id, v.`version_no` AS active_version_no, v.`is_active` AS version_is_active, v.`was_active` AS version_was_active";
}
$sql = "SELECT $select
FROM `$itemsTable` i
JOIN `$sectionsTable` s ON s.`id` = i.`section_id`
$join
$where
LIMIT 1";
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
$stmt->execute();
$row = $stmt->fetch();
if (!$row) $this->fail('Not found', ['id' => $id], 404);
$html = $htmlCol ? (string)($row[$htmlCol] ?? '') : '';
$json = $jsonCol ? ($row[$jsonCol] ?? null) : null;
if ($onlyActive && $versionCols) {
if (array_key_exists('version_html', $row)) $html = (string)($row['version_html'] ?? $html);
if (array_key_exists('version_json', $row)) $json = $row['version_json'] ?? $json;
}
$gjsComponents = [];
if ($json !== null) {
$decoded = json_decode((string)$json, true);
if (is_array($decoded)) $gjsComponents = $decoded;
}
if (!$gjsComponents && $html !== '') {
$gjsComponents = $this->parseHtmlToGjsComponents($html);
}
$item = $row;
$item['content'] = $json;
$item['section_name'] = $row['section_name'] ?? null;
$item['section_slug'] = $row['section_slug'] ?? null;
$item['section_position'] = $row['section_position'] ?? null;
$item['section_is_template'] = (int)($row['section_is_template'] ?? 0);
$editorType = $editorCol ? ($row[$editorCol] ?? null) : null;
$craftJson = $craftCol ? ($row[$craftCol] ?? null) : null;
if ($onlyActive && $versionCols) {
if (array_key_exists('version_editor', $row)) $editorType = $row['version_editor'] ?? $editorType;
if (array_key_exists('version_craft', $row)) $craftJson = $row['version_craft'] ?? $craftJson;
}
$this->respond([
'ok' => true,
'kind' => 'content',
'id' => $row['id'] ?? $id,
'item' => $item,
'data' => $item,
'html' => $html,
'content' => $json,
'gjs_components' => $gjsComponents,
'editor_type' => $editorType,
'craft_json' => $craftJson,
]);
}
private function handleContentCreate(?array $fixedSection = null): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$itemsTable = $this->contentItemsTable();
if (!$this->tableExists($itemsTable)) {
$this->fail('Content table not available', null, 500);
}
$itemCols = $this->resolveContentItemColumns($itemsTable);
$catCol = $itemCols['category'];
$htmlCol = $itemCols['html'];
$jsonCol = $itemCols['json'];
$editorCol = $itemCols['editor'];
$craftCol = $itemCols['craft'];
$settingsCol = $itemCols['settings'];
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
if ($name === '') $this->fail('name required', null, 422);
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
if (!$section) $this->fail('section required', null, 422);
$apiRaw = trim((string)$this->val($this->in, ['api_name', 'apiName', 'api'], ''));
$apiName = $apiRaw !== '' ? $this->normalizeApiName($apiRaw) : '';
$isTemplate = !empty($section['is_template']);
if ($isTemplate && $apiName === '') {
$this->fail('api_name required', null, 422);
}
if ($apiName !== '') {
$this->assertContentApiNameUnique($itemsTable, 'api_name', 'id', $customerId, $apiName, null);
} else {
$apiName = null;
}
$html = $this->val($this->in, ['html', 'body', 'markup', 'content'], null);
$json = $this->val($this->in, ['content_json', 'json', 'structure_json'], null);
$category = $this->val($this->in, ['category', 'cat'], null);
$editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], '')));
$craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null);
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
$data = [
'customer_id' => $customerId,
'section_id' => (int)$section['id'],
'name' => $name,
'api_name' => $apiName,
];
if ($category !== null && $catCol) $data[$catCol] = (string)$category;
if ($editorType !== '' && $editorCol) $data[$editorCol] = $editorType;
if ($craftJson !== null && $craftCol) $data[$craftCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson);
if ($settings !== null && $settingsCol) $data[$settingsCol] = is_string($settings) ? $settings : $this->encodeJson($settings);
if ($json !== null) {
if (!$jsonCol) $this->fail('json_content column missing', null, 500);
$components = is_string($json) ? json_decode($json, true) : $json;
if (is_array($components)) {
$components = $this->cleanReferenceComponents($components);
$data[$jsonCol] = $this->encodeJson($components);
} else {
$data[$jsonCol] = is_string($json) ? $json : '';
}
if ($html !== null) {
if (!$htmlCol) $this->fail('html column missing', null, 500);
$data[$htmlCol] = (string)$html;
}
} elseif ($html !== null) {
if (!$htmlCol) $this->fail('html column missing', null, 500);
$data[$htmlCol] = (string)$html;
}
$columns = array_keys($data);
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
$placeholders = implode(',', array_map(fn($c) => ":$c", $columns));
$stmt = $this->pdo->prepare("INSERT INTO `$itemsTable` ($insertCols) VALUES ($placeholders)");
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
$stmt->execute();
$newId = (int)$this->pdo->lastInsertId();
$activateVersion = (int)$this->val($this->in, ['activate_version', 'activate', 'set_active'], 0) === 1;
try {
$stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
$stmt->execute([':id' => $newId, ':cid' => $customerId]);
$row = $stmt->fetch();
if ($row) {
$vid = $this->createContentVersion($row, $itemCols, $customerId, (int)$section['id']);
if ($activateVersion && $vid) {
$this->activateContentVersion($customerId, (int)$row['id'], $vid);
}
}
} catch (Throwable $e) {
// ignore versioning failures on create
}
$this->respond(['ok' => true, 'kind' => 'content', 'id' => $newId, 'item' => ['id' => $newId, 'name' => $name]]);
}
private function handleContentUpdate(?array $fixedSection = null): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$itemsTable = $this->contentItemsTable();
if (!$this->tableExists($itemsTable)) {
$this->fail('Content table not available', null, 500);
}
$itemCols = $this->resolveContentItemColumns($itemsTable);
$catCol = $itemCols['category'];
$htmlCol = $itemCols['html'];
$jsonCol = $itemCols['json'];
$editorCol = $itemCols['editor'];
$craftCol = $itemCols['craft'];
$settingsCol = $itemCols['settings'];
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
$stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1");
$stmt->execute([':cid' => $customerId, ':id' => $id]);
$current = $stmt->fetch();
if (!$current) $this->fail('Not found', ['id' => $id], 404);
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
if (!$section) {
$section = $this->fetchContentSectionById($customerId, (int)($current['section_id'] ?? 0));
}
if (!$section) $this->fail('section required', null, 422);
$data = [];
$name = $this->val($this->in, ['name', 'title'], null);
if ($name !== null) $data['name'] = (string)$name;
$category = $this->val($this->in, ['category', 'cat'], null);
if ($category !== null && $catCol) $data[$catCol] = (string)$category;
$apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null);
$apiName = $apiRaw !== null ? $this->normalizeApiName((string)$apiRaw) : null;
$isTemplate = !empty($section['is_template']);
if ($isTemplate && $apiRaw === null && empty($current['api_name'])) {
$this->fail('api_name required', null, 422);
}
if ($apiName !== null) {
if ($apiName === '' && $isTemplate) {
$this->fail('api_name required', null, 422);
}
if ($apiName !== '') {
$this->assertContentApiNameUnique($itemsTable, 'api_name', 'id', $customerId, $apiName, (int)$id);
$data['api_name'] = $apiName;
} else {
$data['api_name'] = null;
}
}
$sectionId = $section['id'] ?? null;
if ($sectionId && (int)$sectionId !== (int)($current['section_id'] ?? 0)) {
$data['section_id'] = (int)$sectionId;
}
$html = $this->val($this->in, ['html', 'body', 'markup', 'content'], null);
$json = $this->val($this->in, ['content_json', 'json', 'structure_json'], null);
if ($json !== null) {
if (!$jsonCol) $this->fail('json_content column missing', null, 500);
$components = is_string($json) ? json_decode($json, true) : $json;
if (is_array($components)) {
$components = $this->cleanReferenceComponents($components);
$data[$jsonCol] = $this->encodeJson($components);
} else {
$data[$jsonCol] = is_string($json) ? $json : '';
}
if ($html !== null) {
if (!$htmlCol) $this->fail('html column missing', null, 500);
$data[$htmlCol] = (string)$html;
}
} elseif ($html !== null) {
if (!$htmlCol) $this->fail('html column missing', null, 500);
$data[$htmlCol] = (string)$html;
}
$editorType = $this->val($this->in, ['editor_type', 'editor'], null);
if ($editorType !== null && $editorCol) $data[$editorCol] = strtolower(trim((string)$editorType));
$craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null);
if ($craftJson !== null && $craftCol) $data[$craftCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson);
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
if ($settings !== null && $settingsCol) $data[$settingsCol] = is_string($settings) ? $settings : $this->encodeJson($settings);
if (!$data) {
$this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'updated' => true]);
return;
}
$activateVersion = (int)$this->val($this->in, ['activate_version', 'activate', 'set_active'], 0) === 1;
$versionCols = array_filter([$jsonCol, $htmlCol, $craftCol, $settingsCol, $editorCol]);
$shouldSnapshot = false;
foreach ($versionCols as $col) {
if (array_key_exists($col, $data)) {
$shouldSnapshot = true;
break;
}
}
$requestedVersionId = (int)$this->val($this->in, ['version_id', 'versionId', 'version'], 0);
$updatedExistingVersion = false;
$set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data)));
$data['id'] = $id;
$data['customer_id'] = $customerId;
$sql = "UPDATE `$itemsTable` SET $set WHERE `id` = :id AND `customer_id` = :customer_id LIMIT 1";
$stmt = $this->pdo->prepare($sql);
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
$stmt->execute();
if ($shouldSnapshot && $requestedVersionId > 0) {
try {
$versionsTable = $this->contentVersionsTable();
if ($this->tableExists($versionsTable)) {
$vCols = $this->resolveContentVersionColumns($versionsTable);
$stmt = $this->pdo->prepare(
"SELECT `id`,`content_id`,`customer_id`,`section_id`,`is_active`,`was_active` FROM `$versionsTable`
WHERE `id` = :id AND `customer_id` = :cid AND `content_id` = :content LIMIT 1"
);
$stmt->execute([':id' => $requestedVersionId, ':cid' => $customerId, ':content' => $id]);
$versionRow = $stmt->fetch();
if ($versionRow && (int)($versionRow['is_active'] ?? 0) === 0 && (int)($versionRow['was_active'] ?? 0) === 0) {
$vdata = [];
if ($htmlCol && isset($data[$htmlCol]) && !empty($vCols['html'])) $vdata[$vCols['html']] = $data[$htmlCol];
if ($jsonCol && isset($data[$jsonCol]) && !empty($vCols['json'])) $vdata[$vCols['json']] = $data[$jsonCol];
if ($craftCol && isset($data[$craftCol]) && !empty($vCols['craft'])) $vdata[$vCols['craft']] = $data[$craftCol];
if ($settingsCol && isset($data[$settingsCol]) && !empty($vCols['settings'])) $vdata[$vCols['settings']] = $data[$settingsCol];
if ($editorCol && isset($data[$editorCol]) && !empty($vCols['editor'])) $vdata[$vCols['editor']] = $data[$editorCol];
if ($vdata) {
$setVersion = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($vdata)));
$vdata['id'] = $requestedVersionId;
$vdata['customer_id'] = $customerId;
$vdata['content_id'] = $id;
$sql = "UPDATE `$versionsTable` SET $setVersion WHERE `id` = :id AND `customer_id` = :customer_id AND `content_id` = :content_id LIMIT 1";
$stmt = $this->pdo->prepare($sql);
foreach ($vdata as $k => $v) $stmt->bindValue(":$k", $v);
$stmt->execute();
$updatedExistingVersion = true;
if ($updatedExistingVersion && $activateVersion) {
$this->activateContentVersion($customerId, (int)$id, (int)$requestedVersionId);
}
}
}
}
} catch (Throwable $e) {
$updatedExistingVersion = false;
}
}
if ($shouldSnapshot && !$updatedExistingVersion) {
try {
$stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1");
$stmt->execute([':cid' => $customerId, ':id' => $id]);
$row = $stmt->fetch();
if ($row) {
$vid = $this->createContentVersion($row, $itemCols, $customerId, (int)($section['id'] ?? 0));
if ($activateVersion && $vid) {
$this->activateContentVersion($customerId, (int)$row['id'], $vid);
}
}
} catch (Throwable $e) {
// ignore versioning failures
}
}
$this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'updated' => true]);
}
private function handleContentDelete(?array $fixedSection = null): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
$itemsTable = $this->contentItemsTable();
if (!$this->tableExists($itemsTable)) {
$this->fail('Content table not available', null, 500);
}
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
$params = [':cid' => $customerId, ':id' => $id];
$where = "WHERE `customer_id` = :cid AND `id` = :id";
if ($section && !empty($section['id'])) {
$where .= " AND `section_id` = :sid";
$params[':sid'] = (int)$section['id'];
}
$stmt = $this->pdo->prepare("DELETE FROM `$itemsTable` $where LIMIT 1");
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'deleted' => true]);
}
private function handleContentVersionsList(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$contentId = (int)$this->val($this->in, ['content_id', 'id'], 0);
if ($contentId <= 0) $this->fail('content_id required', null, 422);
$table = $this->contentVersionsTable();
if (!$this->tableExists($table)) {
$this->respond(['ok' => true, 'items' => [], 'data' => []]);
return;
}
$itemsTable = $this->contentItemsTable();
if ($this->tableExists($itemsTable)) {
$stmt = $this->pdo->prepare("SELECT `id` FROM `$itemsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
$stmt->execute([':id' => $contentId, ':cid' => $customerId]);
if (!$stmt->fetch()) $this->fail('Not found', ['id' => $contentId], 404);
}
$cols = $this->resolveContentVersionColumns($table);
$select = ['`id`','`content_id`','`section_id`','`version_no`','`editor_type`','`created_at`'];
if (!empty($cols['is_active'])) $select[] = "`{$cols['is_active']}` AS is_active";
if (!empty($cols['was_active'])) $select[] = "`{$cols['was_active']}` AS was_active";
$limit = (int)$this->val($this->in, ['limit', 'per_page', 'perPage'], 0);
if ($limit <= 0 || $limit > 500) $limit = 200;
$stmt = $this->pdo->prepare(
"SELECT " . implode(',', $select) . " FROM `$table` WHERE `customer_id` = :cid AND `content_id` = :content
ORDER BY `id` DESC LIMIT " . $limit
);
$stmt->execute([':cid' => $customerId, ':content' => $contentId]);
$rows = $stmt->fetchAll() ?: [];
$items = array_map(static function ($row) {
return [
'id' => (int)($row['id'] ?? 0),
'content_id' => (int)($row['content_id'] ?? 0),
'section_id' => (int)($row['section_id'] ?? 0),
'version_no' => (int)($row['version_no'] ?? 0),
'editor_type' => $row['editor_type'] ?? null,
'created_at' => $row['created_at'] ?? null,
'is_active' => (int)($row['is_active'] ?? 0),
'was_active' => (int)($row['was_active'] ?? 0),
];
}, $rows);
$this->respond(['ok' => true, 'items' => $items, 'data' => $items]);
}
private function handleContentVersionsGet(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$id = (int)$this->pullId($this->in);
if ($id <= 0) $this->fail('id required', null, 422);
$contentId = (int)$this->val($this->in, ['content_id', 'content'], 0);
$table = $this->contentVersionsTable();
if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500);
$sql = "SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid";
$params = [':id' => $id, ':cid' => $customerId];
if ($contentId > 0) {
$sql .= " AND `content_id` = :content";
$params[':content'] = $contentId;
}
$sql .= " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
if (!$row) $this->fail('Not found', ['id' => $id], 404);
$this->respond(['ok' => true, 'item' => $row, 'data' => $row]);
}
private function handleContentVersionsRestore(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0);
if ($versionId <= 0) $this->fail('version id required', null, 422);
$contentId = (int)$this->val($this->in, ['content_id', 'content'], 0);
$force = (int)$this->val($this->in, ['force', 'override'], 0) === 1;
$versionsTable = $this->contentVersionsTable();
$itemsTable = $this->contentItemsTable();
if (!$this->tableExists($versionsTable) || !$this->tableExists($itemsTable)) {
$this->fail('Content tables not available', null, 500);
}
$sql = "SELECT * FROM `$versionsTable` WHERE `id` = :id AND `customer_id` = :cid";
$params = [':id' => $versionId, ':cid' => $customerId];
if ($contentId > 0) {
$sql .= " AND `content_id` = :content";
$params[':content'] = $contentId;
}
$sql .= " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$version = $stmt->fetch();
if (!$version) $this->fail('Not found', ['id' => $versionId], 404);
$itemCols = $this->resolveContentItemColumns($itemsTable);
$versionCols = $this->resolveContentVersionColumns($versionsTable);
$data = [];
if (!empty($itemCols['json']) && $versionCols['json']) $data[$itemCols['json']] = $version[$versionCols['json']] ?? null;
if (!empty($itemCols['html']) && $versionCols['html']) $data[$itemCols['html']] = $version[$versionCols['html']] ?? null;
if (!empty($itemCols['css']) && $versionCols['css']) $data[$itemCols['css']] = $version[$versionCols['css']] ?? null;
if (!empty($itemCols['craft']) && $versionCols['craft']) $data[$itemCols['craft']] = $version[$versionCols['craft']] ?? null;
if (!empty($itemCols['settings']) && $versionCols['settings']) $data[$itemCols['settings']] = $version[$versionCols['settings']] ?? null;
if (!empty($itemCols['editor']) && $versionCols['editor']) $data[$itemCols['editor']] = $version[$versionCols['editor']] ?? null;
if ($data) {
$set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data)));
$data['id'] = (int)($version['content_id'] ?? 0);
$data['customer_id'] = $customerId;
$sql = "UPDATE `$itemsTable` SET $set WHERE `id` = :id AND `customer_id` = :customer_id LIMIT 1";
$stmt = $this->pdo->prepare($sql);
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
$stmt->execute();
}
$this->respond(['ok' => true, 'restored' => true, 'content_id' => (int)($version['content_id'] ?? 0)]);
}
private function handleContentVersionsActivate(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0);
if ($versionId <= 0) $this->fail('version id required', null, 422);
$table = $this->contentVersionsTable();
if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500);
$stmt = $this->pdo->prepare("SELECT `id`,`content_id` FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
$stmt->execute([':id' => $versionId, ':cid' => $customerId]);
$row = $stmt->fetch();
if (!$row) $this->fail('Not found', ['id' => $versionId], 404);
$ok = $this->activateContentVersion($customerId, (int)$row['content_id'], $versionId);
debug_log_write('templates_toggle', [
'time' => date(DATE_ATOM),
'action' => 'content_versions.activate',
'customer_id' => $customerId,
'content_id' => (int)$row['content_id'],
'version_id' => $versionId,
'ok' => $ok,
'input' => $this->in,
], [
'append' => true,
'json' => true,
'newline' => true,
]);
if (!$ok) $this->fail('Activation failed', ['id' => $versionId], 500);
$this->respond(['ok' => true, 'activated' => true, 'id' => $versionId]);
}
private function handleContentVersionsDeactivate(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$contentId = (int)$this->val($this->in, ['content_id', 'content'], 0);
if ($contentId <= 0) $this->fail('content_id required', null, 422);
$force = (int)$this->val($this->in, ['force', 'override'], 0) === 1;
if (!$force) {
$refsDebug = [];
$refs = $this->findTemplateReferences($customerId, $contentId, $refsDebug);
if (!empty($refsDebug)) {
$this->writeDebugLog('templates_references_debug', $refsDebug);
}
if (!empty($refs)) {
debug_log_write('templates_toggle', [
'time' => date(DATE_ATOM),
'action' => 'content_versions.deactivate.blocked',
'customer_id' => $customerId,
'content_id' => $contentId,
'references' => $refs,
'input' => $this->in,
], [
'append' => true,
'json' => true,
'newline' => true,
]);
$this->respond([
'ok' => false,
'error' => 'Template wird in anderen Templates verwendet',
'content_id' => $contentId,
'count' => count($refs),
'references' => $refs,
], 409);
}
}
$ok = $this->deactivateContentVersion($customerId, $contentId);
debug_log_write('templates_toggle', [
'time' => date(DATE_ATOM),
'action' => 'content_versions.deactivate',
'customer_id' => $customerId,
'content_id' => $contentId,
'ok' => $ok,
'input' => $this->in,
], [
'append' => true,
'json' => true,
'newline' => true,
]);
if (!$ok) $this->fail('Deactivation failed', ['content_id' => $contentId], 500);
$this->respond(['ok' => true, 'deactivated' => true, 'content_id' => $contentId]);
}
private function handleContentVersionsDelete(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0);
if ($versionId <= 0) $this->fail('version id required', null, 422);
$contentId = (int)$this->val($this->in, ['content_id', 'content'], 0);
$table = $this->contentVersionsTable();
if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500);
$sql = "SELECT `id`,`content_id`,`customer_id`,`is_active` FROM `$table` WHERE `id` = :id AND `customer_id` = :cid";
$params = [':id' => $versionId, ':cid' => $customerId];
if ($contentId > 0) {
$sql .= " AND `content_id` = :content";
$params[':content'] = $contentId;
}
$sql .= " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
if (!$row) $this->fail('Not found', ['id' => $versionId], 404);
if ((int)($row['is_active'] ?? 0) === 1) {
$this->fail('Active versions cannot be deleted', ['id' => $versionId], 422);
}
if (!$force && $contentId > 0) {
$refsDebug = [];
$refs = $this->findTemplateReferences($customerId, $contentId, $refsDebug);
if (!empty($refsDebug)) {
$this->writeDebugLog('templates_references_debug', $refsDebug);
}
if (!empty($refs)) {
debug_log_write('templates_toggle', [
'time' => date(DATE_ATOM),
'action' => 'content_versions.delete.blocked',
'customer_id' => $customerId,
'content_id' => $contentId,
'version_id' => $versionId,
'references' => $refs,
'input' => $this->in,
], [
'append' => true,
'json' => true,
'newline' => true,
]);
$this->respond([
'ok' => false,
'error' => 'Template wird in anderen Templates verwendet',
'content_id' => $contentId,
'version_id' => $versionId,
'count' => count($refs),
'references' => $refs,
], 409);
}
}
$stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
$stmt->execute([':id' => $versionId, ':cid' => $customerId]);
$this->respond(['ok' => true, 'deleted' => true, 'id' => $versionId]);
}
private function handleSectionsConfigList(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$table = $this->contentSectionsTable();
if (!$this->tableExists($table)) {
$this->fail('Sections table not available', null, 500);
}
$this->ensureEmailtemplateSection($customerId);
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `position` ASC, `id` ASC");
$stmt->execute([':cid' => $customerId]);
$rows = $stmt->fetchAll() ?: [];
$items = array_map(static function ($row) {
return [
'id' => (int)($row['id'] ?? 0),
'name' => $row['name'] ?? '',
'slug' => $row['slug'] ?? '',
'position' => (int)($row['position'] ?? 0),
'is_template' => (int)($row['is_template'] ?? 0),
];
}, $rows);
$this->respond(['ok' => true, 'items' => $items, 'data' => $items]);
}
private function handleSectionsConfigGet(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
$table = $this->contentSectionsTable();
if (!$this->tableExists($table)) {
$this->fail('Sections table not available', null, 500);
}
$row = $this->fetchContentSectionById($customerId, (int)$id);
if (!$row) $this->fail('Not found', ['id' => $id], 404);
$item = [
'id' => (int)($row['id'] ?? 0),
'name' => $row['name'] ?? '',
'slug' => $row['slug'] ?? '',
'position' => (int)($row['position'] ?? 0),
'is_template' => (int)($row['is_template'] ?? 0),
];
$this->respond(['ok' => true, 'item' => $item, 'data' => $item]);
}
private function handleSectionsConfigCreate(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$table = $this->contentSectionsTable();
if (!$this->tableExists($table)) {
$this->fail('Sections table not available', null, 500);
}
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
if ($name === '') $this->fail('name required', null, 422);
$slug = $this->normalizeSectionSlug($name);
if ($slug === '') $this->fail('slug required', null, 422);
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid AND (LOWER(`name`) = LOWER(:name) OR LOWER(`slug`) = LOWER(:slug))");
$stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug]);
if ((int)$stmt->fetchColumn() > 0) {
$this->fail('section name already exists', ['name' => $name], 409);
}
$stmt = $this->pdo->prepare("SELECT MAX(`position`) FROM `$table` WHERE `customer_id` = :cid");
$stmt->execute([':cid' => $customerId]);
$position = (int)($stmt->fetchColumn() ?: 0) + 1;
$stmt = $this->pdo->prepare(
"INSERT INTO `$table` (`customer_id`,`name`,`slug`,`position`,`is_template`) VALUES (:cid,:name,:slug,:pos,0)"
);
$stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug, ':pos' => $position]);
$id = (int)$this->pdo->lastInsertId();
$this->respond(['ok' => true, 'id' => $id, 'item' => ['id' => $id, 'name' => $name, 'slug' => $slug, 'position' => $position]]);
}
private function handleSectionsConfigUpdate(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
$table = $this->contentSectionsTable();
if (!$this->tableExists($table)) {
$this->fail('Sections table not available', null, 500);
}
$section = $this->fetchContentSectionById($customerId, (int)$id);
if (!$section) $this->fail('Not found', ['id' => $id], 404);
if (!empty($section['is_template'])) {
$this->fail('Emailtemplate section cannot be changed', null, 422);
}
$name = $this->val($this->in, ['name', 'title'], null);
if ($name === null) {
$this->respond(['ok' => true, 'id' => (int)$id, 'updated' => true]);
return;
}
$name = trim((string)$name);
if ($name === '') $this->fail('name required', null, 422);
$slug = $this->normalizeSectionSlug($name);
if ($slug === '') $this->fail('slug required', null, 422);
$stmt = $this->pdo->prepare(
"SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid AND (LOWER(`name`) = LOWER(:name) OR LOWER(`slug`) = LOWER(:slug)) AND `id` <> :id"
);
$stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug, ':id' => (int)$id]);
if ((int)$stmt->fetchColumn() > 0) {
$this->fail('section name already exists', ['name' => $name], 409);
}
$stmt = $this->pdo->prepare("UPDATE `$table` SET `name` = :name, `slug` = :slug WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
$stmt->execute([':name' => $name, ':slug' => $slug, ':id' => (int)$id, ':cid' => $customerId]);
$this->respond(['ok' => true, 'id' => (int)$id, 'updated' => true, 'item' => ['id' => (int)$id, 'name' => $name, 'slug' => $slug]]);
}
private function handleSectionsConfigDelete(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
$moveTo = (int)$this->val($this->in, ['move_to', 'move_to_id', 'target_section'], 0);
if ($moveTo <= 0) $this->fail('move_to required', null, 422);
$sectionsTable = $this->contentSectionsTable();
$itemsTable = $this->contentItemsTable();
if (!$this->tableExists($sectionsTable) || !$this->tableExists($itemsTable)) {
$this->fail('Content tables not available', null, 500);
}
$section = $this->fetchContentSectionById($customerId, (int)$id);
if (!$section) $this->fail('Not found', ['id' => $id], 404);
if (!empty($section['is_template'])) {
$this->fail('Emailtemplate section cannot be deleted', null, 422);
}
$target = $this->fetchContentSectionById($customerId, $moveTo);
if (!$target) $this->fail('move_to section not found', null, 404);
if ((int)$target['id'] === (int)$id) $this->fail('move_to must differ', null, 422);
$stmt = $this->pdo->prepare("UPDATE `$itemsTable` SET `section_id` = :target WHERE `customer_id` = :cid AND `section_id` = :sid");
$stmt->execute([':target' => (int)$target['id'], ':cid' => $customerId, ':sid' => (int)$id]);
$stmt = $this->pdo->prepare("DELETE FROM `$sectionsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
$stmt->execute([':id' => (int)$id, ':cid' => $customerId]);
$this->respond(['ok' => true, 'id' => (int)$id, 'deleted' => true]);
}
private function handleSectionsConfigReorder(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$order = $this->val($this->in, ['order', 'items', 'ids'], []);
if (!is_array($order)) $this->fail('order must be array', null, 422);
$table = $this->contentSectionsTable();
if (!$this->tableExists($table)) {
$this->fail('Sections table not available', null, 500);
}
$this->ensureEmailtemplateSection($customerId);
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `position` ASC, `id` ASC");
$stmt->execute([':cid' => $customerId]);
$rows = $stmt->fetchAll() ?: [];
$byId = [];
$emailtemplateId = null;
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
$byId[$id] = $row;
if (!empty($row['is_template'])) $emailtemplateId = $id;
}
$newOrder = [];
if ($emailtemplateId) $newOrder[] = $emailtemplateId;
foreach ($order as $rawId) {
$id = (int)$rawId;
if ($id <= 0 || $id === $emailtemplateId) continue;
if (!isset($byId[$id])) continue;
$newOrder[] = $id;
}
foreach ($byId as $id => $_row) {
if ($id === $emailtemplateId) continue;
if (!in_array($id, $newOrder, true)) $newOrder[] = $id;
}
$pos = 0;
$stmt = $this->pdo->prepare("UPDATE `$table` SET `position` = :pos WHERE `id` = :id AND `customer_id` = :cid");
foreach ($newOrder as $id) {
$stmt->execute([':pos' => $pos, ':id' => (int)$id, ':cid' => $customerId]);
$pos++;
}
$this->respond(['ok' => true, 'updated' => true]);
}
private function handleLegacyContentList(string $kind): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
$defaults = $this->resolveLegacySectionDefaults($kind);
if ($defaults['is_template']) {
$section = $this->ensureEmailtemplateSection($customerId);
} else {
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
}
if (!$section) {
$this->respond([
'ok' => true,
'kind' => 'content',
'items' => [],
'data' => [],
'count' => 0,
'offset' => 0,
'limit' => 0,
]);
return;
}
$this->handleContentList($section);
}
private function handleLegacyContentGet(string $kind): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
$defaults = $this->resolveLegacySectionDefaults($kind);
if ($defaults['is_template']) {
$section = $this->ensureEmailtemplateSection($customerId);
} else {
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
}
if (!$section) $this->fail('section not configured', ['kind' => $kind], 404);
$this->handleContentGet($section);
}
private function handleLegacyContentCreate(string $kind): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
$defaults = $this->resolveLegacySectionDefaults($kind);
if ($defaults['is_template']) {
$section = $this->ensureEmailtemplateSection($customerId);
} else {
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
}
if (!$section) $this->fail('section not configured', ['kind' => $kind], 404);
$this->handleContentCreate($section);
}
private function handleLegacyContentUpdate(string $kind): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
$defaults = $this->resolveLegacySectionDefaults($kind);
if ($defaults['is_template']) {
$section = $this->ensureEmailtemplateSection($customerId);
} else {
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
}
if (!$section) $this->fail('section not configured', ['kind' => $kind], 404);
$this->handleContentUpdate($section);
}
private function handleLegacyContentDelete(string $kind): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
$defaults = $this->resolveLegacySectionDefaults($kind);
if ($defaults['is_template']) {
$section = $this->ensureEmailtemplateSection($customerId);
} else {
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
}
if (!$section) $this->fail('section not configured', ['kind' => $kind], 404);
$this->handleContentDelete($section);
}
/**
* Allgemeine Methode zur Handhabung von LIST-Anfragen.
*/
private function handleList(string $kind): void
{
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
$this->handleLegacyContentList($kind);
return;
}
$auth = $this->requireAuth();
$t = $this->tableMap[$kind];
[$idCol, $allCols] = $this->resolveIdCol($kind);
$cfg = $this->conf['columns'][$kind] ?? [];
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
$apiCol = null;
$apiCol = ($kind === 'templates') ? $this->firstExisting($allCols, ['api_name']) : null;
$q = trim((string)$this->val($this->in, 'q', ''));
$limit = max(1, (int)$this->val($this->in, 'limit', 500));
$offset = max(0, (int)$this->val($this->in, 'offset', 0));
$where = ' WHERE 1=1 ';
$params = [];
// Suchlogik (q)
if ($q !== '') {
$parts = ["`$nameCol` LIKE :q"];
if ($descCol) $parts[] = "`$descCol` LIKE :q";
if ($catCol) $parts[] = "`$catCol` LIKE :q";
$where .= " AND (" . implode(' OR ', $parts) . ") ";
$params[':q'] = '%' . $q . '%';
}
// Filterlogik (parentFilters)
$parentFilters = [
'template_id' => $this->val($this->in, ['template_id', 'tpl_id'], null),
'section_id' => $this->val($this->in, ['section_id', 'sec_id'], null),
'block_id' => $this->val($this->in, ['block_id', 'blk_id'], null),
];
foreach ($parentFilters as $col => $v) {
if ($v === null || $v === '') continue;
if (in_array($col, $allCols, true)) { $where .= " AND `$col` = :$col "; $params[":$col"] = $v; }
}
// Tenant-Filter
[$tw, $tp] = $this->tenantWhere($auth);
$where .= $tw;
foreach ($tp as $k => $v) $params[$k] = $v;
$order = $updCol ? " ORDER BY `$updCol` DESC " : " ORDER BY `$nameCol` ASC ";
$sql = "SELECT * FROM `$t` $where $order LIMIT :off,:lim";
$stmt = $this->pdo->prepare($sql);
// Bind parameters
foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
$out = [];
foreach ($rows as $r) {
$item = [
'id' => $r[$idCol] ?? null,
'name' => $r[$nameCol] ?? null,
];
if ($apiCol && isset($r[$apiCol])) $item['api_name'] = $r[$apiCol];
if ($descCol && isset($r[$descCol])) $item['desc'] = $r[$descCol];
if ($catCol && isset($r[$catCol])) $item['category'] = $r[$catCol];
if ($updCol && isset($r[$updCol])) $item['updated_at'] = $r[$updCol];
$createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']);
if ($createdCol && isset($r[$createdCol])) $item['created_at'] = $r[$createdCol];
// Lade HTML und JSON aus den korrekten Spalten
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
if ($htmlCol && isset($r[$htmlCol])) $item['html'] = (string)$r[$htmlCol];
$jsonCol = $this->firstExisting($allCols, ['json_content']);
if ($jsonCol && isset($r[$jsonCol])) $item['content'] = $r[$jsonCol];
$out[] = $item;
}
$this->respond(['ok' => true, 'kind' => $kind, 'items' => $out, 'data' => $out, 'count' => count($out), 'offset' => $offset, 'limit' => $limit]);
}
/**
* Allgemeine Methode zur Handhabung von GET-Anfragen.
*/
private function handleGet(string $kind): void
{
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
$this->handleLegacyContentGet($kind);
return;
}
$auth = $this->requireAuth();
$t = $this->tableMap[$kind];
[$idCol, $allCols] = $this->resolveIdCol($kind);
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
[$tw, $tp] = $this->tenantWhere($auth);
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':id', $id);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$row = $stmt->fetch();
if (!$row) $this->fail('Not found', ['kind' => $kind, 'id' => $id], 404);
$rowOut = ['id' => $row[$idCol] ?? $id] + $row;
// Lade HTML und JSON aus den korrekten Spalten
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
$topHtml = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : null;
$jsonCol = $this->firstExisting($allCols, ['json_content']);
$topContent = ($jsonCol && isset($row[$jsonCol])) ? $row[$jsonCol] : null;
$editorCol = $this->firstExisting($allCols, ['editor_type', 'editor']);
$craftCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']);
$editorType = $editorCol && isset($row[$editorCol]) ? strtolower((string)$row[$editorCol]) : '';
$pendingUpdate = [];
$gjsComponents = [];
if ($editorCol && $editorType === '') {
$settings = $this->getCustomerSettings((int)($auth['customer_id'] ?? 0));
$editorType = strtolower((string)($settings['editor_default'] ?? 'grapesjs'));
if (!in_array($editorType, ['grapesjs', 'craftjs'], true)) {
$editorType = 'grapesjs';
}
$pendingUpdate[$editorCol] = $editorType;
$rowOut[$editorCol] = $editorType;
}
if ($topContent !== null) {
$decodedContent = json_decode($topContent, true);
if (is_array($decodedContent)) {
$gjsComponents = $decodedContent;
}
}
if (empty($gjsComponents) && $topHtml !== null) {
$gjsComponents = $this->parseHtmlToGjsComponents($topHtml);
}
if ($editorType === 'craftjs' && $craftCol) {
$craftPayload = isset($row[$craftCol]) ? (string)$row[$craftCol] : '';
if ($craftPayload === '') {
$pendingUpdate[$craftCol] = $this->encodeJson(['html' => (string)($topHtml ?? '')]);
$rowOut[$craftCol] = $pendingUpdate[$craftCol];
}
}
if ($pendingUpdate) {
$pendingUpdate[$idCol] = $row[$idCol] ?? $id;
[$tw, $tp] = $this->tenantWhere($auth);
$set = [];
foreach (array_keys($pendingUpdate) as $c) {
if ($c === $idCol) continue;
$set[] = "`$c` = :$c";
}
$sql = "UPDATE `$t` SET " . implode(',', $set) . " WHERE `$idCol` = :$idCol" . $tw . " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
foreach ($pendingUpdate as $k => $v) $stmt->bindValue(":$k", $v);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
}
$usage = $this->calculateUsage($kind, (int)$rowOut['id'], $auth);
$this->respond([
'ok' => true,
'kind' => $kind,
'id' => $rowOut['id'],
'item' => $rowOut,
'data' => $rowOut,
'html' => $topHtml,
'content' => $topContent,
'gjs_components' => $gjsComponents,
'editor_type' => $editorType ?: null,
'craft_json' => $craftCol && isset($rowOut[$craftCol]) ? $rowOut[$craftCol] : null,
'usage' => $usage,
]);
}
/**
* Allgemeine Methode zur Handhabung von CREATE-Anfragen (inkl. JSON-Bereinigung).
*/
private function handleCreate(string $kind): void
{
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
$this->handleLegacyContentCreate($kind);
return;
}
$auth = $this->requireAuth();
$t = $this->tableMap[$kind];
[$idCol, $allCols] = $this->resolveIdCol($kind);
$cfg = $this->conf['columns'][$kind] ?? [];
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
if ($name === '') $this->fail('name required', null, 422);
$desc = (string)$this->val($this->in, ['description', 'desc'], null);
$cat = (string)$this->val($this->in, ['category', 'cat'], null);
$html = (string)$this->val($this->in, ['html', 'body', 'markup'], null);
if ($kind === 'snippets' && ($html === null || $html === '')) {
$html = (string)$this->val($this->in, ['content'], $html);
}
$jsonKeys = ($kind === 'snippets')
? ['content_json', 'json', 'structure_json']
: ['content_json', 'json', 'content', 'structure_json'];
$json = $this->val($this->in, $jsonKeys, null);
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
$editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], '')));
$craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null);
$templateId = $this->val($this->in, ['template_id', 'tpl_id'], null);
$sectionId = $this->val($this->in, ['section_id', 'sec_id'], null);
$blockId = $this->val($this->in, ['block_id', 'blk_id'], null);
$editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], '')));
$craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null);
$data = [$nameCol => $name];
if ($kind === 'templates') {
$apiCol = $this->firstExisting($allCols, ['api_name']);
if ($apiCol) {
$apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null);
if ($apiRaw === null || trim((string)$apiRaw) === '') {
$apiName = $this->normalizeApiName($name);
if ($apiName === '') {
$this->fail('api_name required', null, 422);
}
} else {
$apiName = trim((string)$apiRaw);
if (preg_match('/\s/', $apiName)) {
$this->fail('api_name must not contain spaces', null, 422);
}
}
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$this->assertTemplateApiNameUnique($t, $apiCol, $idCol, $customerId, $apiName, null);
$data[$apiCol] = $apiName;
}
}
if ($desc !== null && $descCol) $data[$descCol] = $desc;
if ($cat !== null && $catCol) $data[$catCol] = $cat;
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
$editorDbCol = $this->firstExisting($allCols, ['editor_type', 'editor']);
$craftDbCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']);
$editorDbCol = $this->firstExisting($allCols, ['editor_type', 'editor']);
$craftDbCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']);
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
// 1. JSON-Content behandeln
if ($json !== null) {
if ($jsonDbCol) {
$components = is_string($json) ? json_decode($json, true) : $json;
if (is_array($components)) {
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
$data[$jsonDbCol] = $this->encodeJson($components);
} else {
$data[$jsonDbCol] = is_string($json) ? $json : '';
}
} else {
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
$this->fail(
'JSON content provided but no `json_content` column found',
['table' => $t, 'available_cols' => $allCols],
422
);
}
}
// 2. HTML-Content speichern
if ($htmlDbCol && $html !== null) {
$data[$htmlDbCol] = $html;
}
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
if ($editorDbCol) {
if ($editorType === '') {
$settings = $this->getCustomerSettings((int)($auth['customer_id'] ?? 0));
$editorType = strtolower((string)($settings['editor_default'] ?? 'grapesjs'));
}
if ($editorType !== '' && in_array($editorType, ['grapesjs', 'craftjs'], true)) {
$data[$editorDbCol] = $editorType;
}
}
if ($craftDbCol && $craftJson !== null) {
$data[$craftDbCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson);
}
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
if ($c && $settings !== null) $data[$c] = is_string($settings) ? $settings : $this->encodeJson($settings);
if ($templateId !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $templateId;
if ($sectionId !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sectionId;
if ($blockId !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blockId;
$data = $data + $this->tenantAssign($_SESSION['auth'] ?? [], $allCols);
$now = date('Y-m-d H:i:s');
$createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']);
if ($createdCol) $data[$createdCol] = $now;
if ($updCol) $data[$updCol] = $now;
$fields = array_keys($data);
$place = array_map(fn($c) => ":$c", $fields);
$sql = "INSERT INTO `$t` (" . implode(',', array_map(fn($c) => "`$c`", $fields)) . ") VALUES (" . implode(',', $place) . ")";
$stmt = $this->pdo->prepare($sql);
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
$stmt->execute();
$newId = $this->pdo->lastInsertId();
$out = ['id' => $newId, 'name' => $name];
if (!empty($apiCol) && isset($data[$apiCol])) {
$out['api_name'] = $data[$apiCol];
}
if ($desc !== null) $out['desc'] = $desc;
if ($cat !== null) $out['category'] = $cat;
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $newId, 'item' => $out, 'data' => $out]);
}
/**
* Allgemeine Methode zur Handhabung von UPDATE-Anfragen (inkl. JSON-Bereinigung).
*/
private function handleUpdate(string $kind): void
{
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
$this->handleLegacyContentUpdate($kind);
return;
}
$auth = $this->requireAuth();
$t = $this->tableMap[$kind];
[$idCol, $allCols] = $this->resolveIdCol($kind);
$cfg = $this->conf['columns'][$kind] ?? [];
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
$apiCol = ($kind === 'templates') ? $this->firstExisting($allCols, ['api_name']) : null;
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
$data = [];
$name = $this->val($this->in, ['name', 'title'], null);
$desc = $this->val($this->in, ['description', 'desc'], null);
$cat = $this->val($this->in, ['category', 'cat'], null);
$html = $this->val($this->in, ['html', 'body', 'markup'], null);
if ($kind === 'snippets' && $html === null) {
$html = $this->val($this->in, ['content'], null);
}
$jsonKeys = ($kind === 'snippets')
? ['content_json', 'json', 'structure_json']
: ['content_json', 'json', 'content', 'structure_json'];
$json = $this->val($this->in, $jsonKeys, null);
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
if ($name !== null) $data[$nameCol] = (string)$name;
if ($desc !== null && $descCol) $data[$descCol] = (string)$desc;
if ($cat !== null && $catCol) $data[$catCol] = (string)$cat;
if ($apiCol) {
$apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null);
if ($apiRaw !== null) {
$apiName = trim((string)$apiRaw);
if ($apiName === '') $this->fail('api_name required', null, 422);
if (preg_match('/\s/', $apiName)) {
$this->fail('api_name must not contain spaces', null, 422);
}
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$this->assertTemplateApiNameUnique($t, $apiCol, $idCol, $customerId, $apiName, (int)$id);
$data[$apiCol] = $apiName;
}
}
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
// 1. JSON-Content behandeln
if ($json !== null) {
if ($jsonDbCol) {
// Wenn JSON-Spalte existiert, JSON verarbeiten und speichern
$components = is_string($json) ? json_decode($json, true) : $json;
if (is_array($components)) {
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
$data[$jsonDbCol] = $this->encodeJson($components);
} else {
$data[$jsonDbCol] = is_string($json) ? $json : '';
}
} else {
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
$this->fail(
'JSON content provided but no `json_content` column found',
['table' => $t, 'available_cols' => $allCols],
422
);
}
// 2. Den zugehörigen HTML-Output speichern (wird vom Editor immer mitgesendet, wenn JSON da ist)
if ($html !== null && $htmlDbCol) {
$data[$htmlDbCol] = (string)$html;
}
} elseif ($html !== null && $htmlDbCol) {
// Wenn NUR HTML gesendet wird (für minimale Änderungen), speichern wir nur HTML.
$data[$htmlDbCol] = (string)$html;
}
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
if ($editorDbCol && $editorType !== '' && in_array($editorType, ['grapesjs', 'craftjs'], true)) {
$data[$editorDbCol] = $editorType;
}
if ($craftDbCol && $craftJson !== null) {
$data[$craftDbCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson);
}
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
if ($settings !== null && $c) $data[$c] = is_string($settings) ? $settings : $this->encodeJson($settings);
$tpl = $this->val($this->in, ['template_id', 'tpl_id'], null);
if ($tpl !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $tpl;
$sec = $this->val($this->in, ['section_id', 'sec_id'], null);
if ($sec !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sec;
$blk = $this->val($this->in, ['block_id', 'blk_id'], null);
if ($blk !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blk;
if ($updCol) $data[$updCol] = date('Y-m-d H:i:s');
$this->debugSavePayload($kind, $id, $html, $json);
if (!$data) $this->fail('nothing to update', null, 422);
[$tw, $tp] = $this->tenantWhere($auth);
$set = [];
foreach (array_keys($data) as $c) $set[] = "`$c` = :$c";
$sql = "UPDATE `$t` SET " . implode(',', $set) . " WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
$stmt->bindValue(':__id', $id);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'updated' => array_keys($data)]);
}
/**
* Allgemeine Methode zur Handhabung von DELETE-Anfragen.
*/
private function handleDelete(string $kind): void
{
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
$this->handleLegacyContentDelete($kind);
return;
}
$auth = $this->requireAuth();
$t = $this->tableMap[$kind];
[$idCol, $allCols] = $this->resolveIdCol($kind);
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
[$tw, $tp] = $this->tenantWhere($auth);
$sql = "DELETE FROM `$t` WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':__id', $id);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'deleted' => true]);
}
/**
* Sendet einen Testversand für Templates.
*/
private function handleTemplateTestSend(): void
{
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
$templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0);
if ($templateId <= 0) {
$this->fail('template_id required', null, 422);
}
$recipient = trim((string)$this->val($this->in, ['to', 'email', 'recipient'], ''));
if ($recipient === '' || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
$this->fail('Valid recipient required', null, 422);
}
$subject = trim((string)$this->val($this->in, ['subject'], 'Testversand'));
if ($subject === '') {
$subject = 'Testversand';
}
$senderId = (int)$this->val($this->in, ['sender_id'], 0);
$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 = '<p>(Dieses Template enthält noch keine HTML-Inhalte.)</p>';
}
$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, $customerId)) {
$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),
'mail_error' => $this->lastMailError,
]);
$this->fail('Send failed', null, 500);
}
if ($customerId > 0) {
$this->incrementTemplateUsage($customerId, $templateId);
}
$this->respond([
'ok' => true,
'template_id' => $templateId,
'to' => $recipient,
'subject' => $subject,
'sender_id' => $senderId > 0 ? $senderId : null,
]);
}
private function handleTemplateReferences(): void
{
$traceDir = dirname(__DIR__) . '/debug';
if (!is_dir($traceDir)) {
@mkdir($traceDir, 0777, true);
}
@file_put_contents($traceDir . '/templates_references_trace.log', json_encode([
'time' => date(DATE_ATOM),
'tag' => 'handleTemplateReferences',
'host' => $_SERVER['HTTP_HOST'] ?? '',
'xfh' => $_SERVER['HTTP_X_FORWARDED_HOST'] ?? '',
'env' => $this->conf['env'] ?? null,
'app_env' => defined('APP_ENV') ? APP_ENV : null,
'user' => ($this->in['user'] ?? null),
'input' => $this->in ?? null,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND);
$auth = $this->requireAuth();
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) {
$this->fail('Customer context missing', null, 500);
}
$templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0);
if ($templateId <= 0) {
$this->fail('template_id required', null, 422);
}
$debug = [];
$references = $this->findTemplateReferences($customerId, $templateId, $debug);
if (!empty($debug)) {
$this->writeDebugLog('templates_references_debug', $debug);
}
$this->respond([
'ok' => true,
'template_id' => $templateId,
'count' => count($references),
'references' => $references,
]);
}
private function findTemplateReferences(int $customerId, int $templateId, array &$debug = []): array
{
$out = [];
$byId = [];
$debug = [
'time' => date(DATE_ATOM),
'customer_id' => $customerId,
'template_id' => $templateId,
'use_unified' => $this->useUnifiedContent(),
'scanned_rows' => 0,
'matched_rows' => [],
'template_items_matches' => [],
];
$addRef = function (int $id, string $name) use (&$out, &$byId) {
if ($id <= 0) return;
if (!isset($byId[$id])) {
$entry = [
'id' => $id,
'name' => $name,
'versions' => [],
];
$byId[$id] = count($out);
$out[] = $entry;
} elseif ($name !== '') {
$out[$byId[$id]]['name'] = $name;
}
};
$addVersion = function (int $id, ?int $ver) use (&$out, &$byId) {
if ($id <= 0 || $ver === null || $ver <= 0) return;
if (!isset($byId[$id])) {
return;
}
$idx = $byId[$id];
if (!in_array($ver, $out[$idx]['versions'], true)) {
$out[$idx]['versions'][] = $ver;
sort($out[$idx]['versions']);
}
};
$matches = function (?string $html, array $libKinds = []) use ($templateId): bool {
if (!$html) return false;
$id = preg_quote((string)$templateId, '/');
$typePattern = '/data-ref-type\s*=\s*(["\"])template\1/i';
$idPattern = '/data-ref-id\s*=\s*(["\"])' . $id . '\1/i';
$jsonTypePattern = '/"data-ref-type"\s*:\s*"template"/i';
$jsonIdPattern = '/"data-ref-id"\s*:\s*("?)(?:' . $id . ')\1/i';
$hasHtmlAttrs = (bool)(preg_match($typePattern, $html) && preg_match($idPattern, $html));
if ($hasHtmlAttrs) return true;
if (preg_match($jsonTypePattern, $html) && preg_match($jsonIdPattern, $html)) return true;
$libIdPattern = '/(?:data-)?lib-id\s*=\s*(["\"])' . $id . '\1/i';
if (preg_match($libIdPattern, $html)) {
return true;
}
$jsonLibIdPattern = '/"(?:data-)?lib-id"\s*:\s*("?)(?:' . $id . ')\1/i';
if (preg_match($jsonLibIdPattern, $html)) {
return true;
}
return false;
};
if ($this->useUnifiedContent()) {
$section = $this->ensureEmailtemplateSection($customerId);
if (!$section) return [];
$libKinds = [strtolower((string)($section['slug'] ?? 'emailtemplate'))];
$debug['section'] = [
'id' => (int)($section['id'] ?? 0),
'slug' => (string)($section['slug'] ?? ''),
];
$itemsTable = $this->contentItemsTable();
if (!$this->tableExists($itemsTable)) return [];
$itemCols = $this->resolveContentItemColumns($itemsTable);
$htmlCol = $itemCols['html'];
$jsonCol = $itemCols['json'];
$craftCol = $itemCols['craft'];
$settingsCol = $itemCols['settings'];
$versionsTable = $this->contentVersionsTable();
$versionCols = ($this->tableExists($versionsTable)) ? $this->resolveContentVersionColumns($versionsTable) : null;
$versionHtmlCol = $versionCols['html'] ?? null;
$versionJsonCol = $versionCols['json'] ?? null;
$versionCraftCol = $versionCols['craft'] ?? null;
$versionActiveCol = $versionCols['is_active'] ?? null;
$versionSettingsCol = $versionCols['settings'] ?? null;
$debug['tables'] = [
'items' => $itemsTable,
'versions' => $versionsTable,
];
$debug['columns'] = [
'item' => $itemCols,
'version' => $versionCols,
];
$select = "i.`id` AS id, i.`name` AS name";
if ($htmlCol) $select .= ", i.`$htmlCol` AS item_html";
if ($jsonCol) $select .= ", i.`$jsonCol` AS item_json";
if ($craftCol) $select .= ", i.`$craftCol` AS item_craft";
if ($settingsCol) $select .= ", i.`$settingsCol` AS item_settings";
$join = '';
$hasVersionJoin = $versionActiveCol && ($versionHtmlCol || $versionJsonCol || $versionCraftCol);
if ($hasVersionJoin) {
$join = "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`$versionActiveCol` = 1";
}
if ($versionHtmlCol && $versionActiveCol) {
$select .= ", v.`$versionHtmlCol` AS version_html";
}
if ($versionJsonCol && $versionActiveCol) {
$select .= ", v.`$versionJsonCol` AS version_json";
}
if ($versionCraftCol && $versionActiveCol) {
$select .= ", v.`$versionCraftCol` AS version_craft";
}
if ($versionSettingsCol && $versionActiveCol) {
$select .= ", v.`$versionSettingsCol` AS version_settings";
}
$sql = "SELECT $select FROM `$itemsTable` i $join WHERE i.`customer_id` = :cid AND i.`section_id` = :sid AND i.`id` <> :id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $templateId]);
$rows = $stmt->fetchAll() ?: [];
foreach ($rows as $row) {
$debug['scanned_rows']++;
$blobs = [
(string)($row['version_html'] ?? ''),
(string)($row['item_html'] ?? ''),
(string)($row['version_json'] ?? ''),
(string)($row['item_json'] ?? ''),
(string)($row['version_craft'] ?? ''),
(string)($row['item_craft'] ?? ''),
(string)($row['version_settings'] ?? ''),
(string)($row['item_settings'] ?? ''),
];
$found = false;
$where = [];
$blobKeys = [
'version_html',
'item_html',
'version_json',
'item_json',
'version_craft',
'item_craft',
'version_settings',
'item_settings',
];
foreach ($blobs as $idx => $blob) {
if ($matches($blob, $libKinds)) {
$found = true;
$where[] = $blobKeys[$idx] ?? (string)$idx;
}
}
if ($found) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0) continue;
$addRef($id, (string)($row['name'] ?? ''));
if (count($debug['matched_rows']) < 50) {
$debug['matched_rows'][] = [
'id' => $id,
'name' => (string)($row['name'] ?? ''),
'where' => $where,
];
}
}
}
if ($this->tableExists($versionsTable)) {
$vCols = $this->tableColumns($versionsTable);
$vHtml = $versionHtmlCol;
$vJson = $versionJsonCol;
$vCraft = $versionCraftCol;
$vSettings = $versionSettingsCol;
$vNo = $this->firstExisting($vCols, ['version_no', 'version', 'ver', 'version_nr']);
$vContentId = $this->firstExisting($vCols, ['content_id', 'content']);
$vCustomerId = $this->firstExisting($vCols, ['customer_id', 'customer']);
$vSectionId = $this->firstExisting($vCols, ['section_id', 'section']);
if ($vContentId && $vNo) {
$select = "v.`$vContentId` AS content_id, v.`$vNo` AS version_no, i.`name` AS name";
if ($vHtml) $select .= ", v.`$vHtml` AS version_html";
if ($vJson) $select .= ", v.`$vJson` AS version_json";
if ($vCraft) $select .= ", v.`$vCraft` AS version_craft";
if ($vSettings) $select .= ", v.`$vSettings` AS version_settings";
$join = "LEFT JOIN `$itemsTable` i ON i.`id` = v.`$vContentId`";
$where = [];
$params = [];
if ($vCustomerId) {
$where[] = "v.`$vCustomerId` = :cid";
$params[':cid'] = $customerId;
}
if ($vSectionId) {
$where[] = "v.`$vSectionId` = :sid";
$params[':sid'] = (int)$section['id'];
}
$where[] = "v.`$vContentId` <> :id";
$params[':id'] = $templateId;
$sql = "SELECT $select FROM `$versionsTable` v $join WHERE " . implode(' AND ', $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll() ?: [];
foreach ($rows as $row) {
$blobs = [
(string)($row['version_html'] ?? ''),
(string)($row['version_json'] ?? ''),
(string)($row['version_craft'] ?? ''),
(string)($row['version_settings'] ?? ''),
];
$found = false;
foreach ($blobs as $blob) {
if ($matches($blob, $libKinds)) {
$found = true;
break;
}
}
if ($found) {
$cid = (int)($row['content_id'] ?? 0);
$addRef($cid, (string)($row['name'] ?? ''));
$addVersion($cid, (int)($row['version_no'] ?? 0));
}
}
}
}
}
if (!$this->useUnifiedContent()) {
$table = $this->tableMap['templates'] ?? null;
if ($table && $this->tableExists($table)) {
$libKinds = ['templates', 'template', 'emailtemplate'];
[$idCol, $allCols] = $this->resolveIdCol('templates');
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
$jsonCol = $this->firstExisting($allCols, ['json_content']);
$craftCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']);
$settingsCol = $this->firstExisting($allCols, ['settings_json', 'settings']);
if ($htmlCol || $jsonCol || $craftCol || $settingsCol) {
$nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
[$tw, $tp] = $this->tenantWhere(['customer_id' => $customerId]);
$select = "`$idCol` AS id, `$nameCol` AS name";
if ($htmlCol) $select .= ", `$htmlCol` AS html";
if ($jsonCol) $select .= ", `$jsonCol` AS json";
if ($craftCol) $select .= ", `$craftCol` AS craft";
if ($settingsCol) $select .= ", `$settingsCol` AS settings";
$sql = "SELECT $select FROM `$table` WHERE `$idCol` <> :id" . $tw;
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':id', $templateId, PDO::PARAM_INT);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$rows = $stmt->fetchAll() ?: [];
foreach ($rows as $row) {
$blobs = [
(string)($row['html'] ?? ''),
(string)($row['json'] ?? ''),
(string)($row['craft'] ?? ''),
(string)($row['settings'] ?? ''),
];
$found = false;
$where = [];
$blobKeys = ['html', 'json', 'craft', 'settings'];
foreach ($blobs as $idx => $blob) {
if ($matches($blob, $libKinds)) {
$found = true;
$where[] = $blobKeys[$idx] ?? (string)$idx;
}
}
if ($found) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0) continue;
$addRef($id, (string)($row['name'] ?? ''));
if (count($debug['matched_rows']) < 50) {
$debug['matched_rows'][] = [
'id' => $id,
'name' => (string)($row['name'] ?? ''),
'where' => $where,
];
}
}
}
}
}
}
$templateItemsTable = $this->lookupTableName('template_items', 'emailtemplate_template_items');
if ($this->tableExists($templateItemsTable)) {
$sql = "SELECT DISTINCT `template_id` FROM `$templateItemsTable` WHERE `customer_id` = :cid AND `ref_type` IN ('section','template') AND `ref_id` = :rid";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId, ':rid' => $templateId]);
$ids = array_filter(array_map('intval', array_column($stmt->fetchAll() ?: [], 'template_id')));
if ($ids) {
$debug['template_items_matches'] = $ids;
if ($this->useUnifiedContent()) {
$section = $this->ensureEmailtemplateSection($customerId);
if ($section) {
$itemsTable = $this->contentItemsTable();
$nameCol = $this->resolveContentItemColumns($itemsTable)['category'] ? 'name' : 'name';
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT `id`, `name` FROM `$itemsTable` WHERE `customer_id` = ? AND `section_id` = ? AND `id` IN ($placeholders)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(array_merge([$customerId, (int)$section['id']], $ids));
$rows = $stmt->fetchAll() ?: [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0) continue;
$addRef($id, (string)($row['name'] ?? ''));
}
}
} else {
$table = $this->tableMap['templates'] ?? null;
if ($table && $this->tableExists($table)) {
[$idCol, $allCols] = $this->resolveIdCol('templates');
$nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
$ph = [];
$params = [];
foreach ($ids as $idx => $val) {
$key = ':id' . $idx;
$ph[] = $key;
$params[$key] = $val;
}
[$tw, $tp] = $this->tenantWhere(['customer_id' => $customerId]);
$sql = "SELECT `$idCol` AS id, `$nameCol` AS name FROM `$table` WHERE `$idCol` IN (" . implode(',', $ph) . ")" . $tw;
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) $stmt->bindValue($k, $v, PDO::PARAM_INT);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$rows = $stmt->fetchAll() ?: [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0) continue;
$addRef($id, (string)($row['name'] ?? ''));
}
}
}
}
}
return $out;
}
private function handleExternalRender(): void
{
$token = trim((string)($this->in['token'] ?? ''));
if ($token === '') {
$this->fail('Token required', null, 401);
}
$settingsTable = $this->customerSettingsTable();
$stmt = $this->pdo->prepare("SELECT `customer_id` FROM `$settingsTable` WHERE `external_api_token` = :t LIMIT 1");
$stmt->execute([':t' => $token]);
$row = $stmt->fetch();
$customerId = (int)($row['customer_id'] ?? 0);
if ($customerId <= 0) {
$this->fail('Invalid token', null, 403);
}
$templateKey = $this->val($this->in, ['api_name', 'template', 'template_id', 'id', 'name'], '');
$templateId = is_numeric($templateKey) ? (int)$templateKey : null;
$tpl = null;
$html = '';
$templateName = null;
$apiName = null;
if ($this->useUnifiedContent()) {
$section = $this->ensureEmailtemplateSection($customerId);
$itemsTable = $this->contentItemsTable();
$where = "WHERE `customer_id` = :cid AND `section_id` = :sid ";
$params = [':cid' => $customerId, ':sid' => (int)$section['id']];
if ($templateId !== null && $templateId > 0) {
$where .= "AND `id` = :id ";
$params[':id'] = $templateId;
} else {
$name = trim((string)$templateKey);
if ($name === '') {
$this->fail('template required', null, 422);
}
$where .= "AND `api_name` = :name ";
$params[':name'] = $name;
}
$sql = "SELECT * FROM `$itemsTable` $where LIMIT 1";
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$stmt->execute();
$tpl = $stmt->fetch();
if ($tpl) {
$html = (string)($tpl['html'] ?? '');
$templateName = $tpl['name'] ?? null;
$apiName = $tpl['api_name'] ?? null;
}
} else {
$templatesTable = $this->tableMap['templates'] ?? null;
if (!$templatesTable || !$this->tableExists($templatesTable)) {
$this->fail('Templates table not available', null, 500);
}
[$idCol, $allCols] = $this->resolveIdCol('templates');
$nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
$apiCol = $this->firstExisting($allCols, ['api_name']);
$where = "WHERE `customer_id` = :cid ";
$params = [':cid' => $customerId];
if ($templateId !== null && $templateId > 0) {
$where .= "AND `$idCol` = :id ";
$params[':id'] = $templateId;
} else {
$name = trim((string)$templateKey);
if ($name === '') {
$this->fail('template required', null, 422);
}
if ($apiCol) {
$where .= "AND `$apiCol` = :name ";
$params[':name'] = $name;
} else {
$where .= "AND `$nameCol` = :name ";
$params[':name'] = $name;
}
}
$sql = "SELECT * FROM `$templatesTable` $where LIMIT 1";
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$stmt->execute();
$tpl = $stmt->fetch();
$htmlCol = $this->resolveHtmlColumn($allCols, 'templates');
$html = ($htmlCol && isset($tpl[$htmlCol])) ? (string)$tpl[$htmlCol] : '';
$templateName = $tpl[$nameCol] ?? null;
$apiName = $apiCol ? ($tpl[$apiCol] ?? null) : null;
}
if (!$tpl) {
$this->fail('Template not found', ['template' => $templateKey], 404);
}
if ($html === '' && !empty($tpl['json_content'])) {
$html = '<p>(Dieses Template enthält noch keine HTML-Inhalte.)</p>';
}
$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 '<p>(Kein Inhalt vorhanden)</p>';
}
if (!class_exists(CssToInlineStyles::class)) {
return $html;
}
try {
$inliner = new CssToInlineStyles();
return $inliner->convert($html);
} catch (Throwable $e) {
return $html;
}
}
private function debugSavePayload(string $kind, $id, $html, $json): void
{
if (empty($this->in['debug'])) {
return;
}
$payload = [
'time' => date(DATE_ATOM),
'kind' => $kind,
'id' => $id,
'html_length' => is_string($html) ? strlen($html) : null,
'json_length' => is_string($json) ? strlen($json) : null,
'html_preview' => is_string($html) ? substr($html, 0, 200) : null,
'json_preview' => is_string($json) ? substr($json, 0, 200) : null,
'json_is_empty' => is_string($json) ? trim($json) === '' : ($json === null),
];
$line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
@file_put_contents(sys_get_temp_dir() . '/emailtemplate_debug_save.log', $line, FILE_APPEND);
}
private function replacePlaceholders(string $html, array $placeholders): string
{
if ($html === '' || !$placeholders) {
return $html;
}
$map = [];
foreach ($placeholders as $key => $value) {
if (is_array($value) || is_object($value)) continue;
$value = (string)$value;
$trimmedKey = trim((string)$key);
if ($trimmedKey === '') continue;
$map['{{' . $trimmedKey . '}}'] = $value;
$map['{{ ' . $trimmedKey . ' }}'] = $value;
$map['[[' . $trimmedKey . ']]'] = $value;
$map['[[ ' . $trimmedKey . ' ]]'] = $value;
}
return $map ? strtr($html, $map) : $html;
}
private function dispatchTestMail(string $to, string $subject, string $html, ?array $sender = null, ?int $customerId = null, ?array $smtpOverride = null): bool
{
$this->lastMailError = null;
$smtpConf = $this->conf['smtp'] ?? [];
$settings = ($customerId && $customerId > 0) ? $this->getCustomerSettings($customerId) : [];
$override = is_array($smtpOverride) ? $smtpOverride : [];
$smtp = array_merge($smtpConf, array_filter([
'host' => $override['host'] ?? ($settings['smtp_host'] ?? null),
'port' => $override['port'] ?? ($settings['smtp_port'] ?? null),
'user' => $override['user'] ?? ($settings['smtp_user'] ?? null),
'pass' => array_key_exists('pass', $override) ? $override['pass'] : ($settings['smtp_pass'] ?? null),
'secure' => $override['secure'] ?? ($settings['smtp_secure'] ?? null),
'from_email' => $override['from_email'] ?? ($settings['smtp_from_email'] ?? null),
'from_name' => $override['from_name'] ?? ($settings['smtp_from_name'] ?? null),
'reply_to' => $override['reply_to'] ?? ($settings['smtp_reply_to'] ?? null),
'enabled' => $override['enabled'] ?? ($settings['smtp_enabled'] ?? null),
], static fn($v) => $v !== null && $v !== ''));
$smtpEnabled = !empty($smtp['enabled']);
$smtpHost = trim((string)($smtp['host'] ?? ''));
$smtpUser = trim((string)($smtp['user'] ?? ''));
$smtpPass = (string)($smtp['pass'] ?? '');
$smtpSecure = strtolower(trim((string)($smtp['secure'] ?? '')));
$smtpPort = (int)($smtp['port'] ?? 0);
$fromEmail = $sender['from_email'] ?? ($smtp['from_email'] ?? ($smtpConf['from_email'] ?? 'no-reply@example.com'));
$fromName = $sender['from_name'] ?? ($sender['label'] ?? ($smtp['from_name'] ?? ($smtpConf['from_name'] ?? 'EmailTemplate')));
$replyTo = $sender['reply_to'] ?? ($smtp['reply_to'] ?? '');
if ($smtpEnabled && $smtpHost !== '' && class_exists(PHPMailer::class)) {
try {
$mailer = new PHPMailer(true);
$mailer->CharSet = 'UTF-8';
$mailer->isSMTP();
$mailer->Host = $smtpHost;
$mailer->SMTPAuth = ($smtpUser !== '' || $smtpPass !== '');
if ($smtpUser !== '') $mailer->Username = $smtpUser;
if ($smtpPass !== '') $mailer->Password = $smtpPass;
if ($smtpSecure === 'ssl') {
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
if ($smtpPort <= 0) $smtpPort = 465;
} elseif ($smtpSecure === 'tls') {
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
if ($smtpPort <= 0) $smtpPort = 587;
} else {
$mailer->SMTPSecure = '';
$mailer->SMTPAutoTLS = false;
if ($smtpPort <= 0) $smtpPort = 25;
}
if ($smtpPort > 0) $mailer->Port = $smtpPort;
$mailer->setFrom($fromEmail, $fromName);
$mailer->addAddress($to);
if ($replyTo !== '') {
$mailer->addReplyTo($replyTo, $fromName ?: $fromEmail);
}
$mailer->isHTML(true);
$mailer->Subject = $subject;
$mailer->Body = $html;
$mailer->AltBody = trim(strip_tags($html));
return $mailer->send();
} catch (PHPMailerException $e) {
$this->lastMailError = $e->getMessage();
return false;
} catch (Throwable $e) {
$this->lastMailError = $e->getMessage();
return false;
}
}
if (!function_exists('mail')) {
$this->lastMailError = 'PHP mail() not available';
return false;
}
$headers = [
'MIME-Version: 1.0',
'Content-Type: text/html; charset=UTF-8',
'From: ' . $this->formatEmailAddress($fromEmail, $fromName),
];
if ($replyTo !== '') {
$headers[] = 'Reply-To: ' . $this->formatEmailAddress($replyTo, $fromName ?: $fromEmail);
}
$encodedSubject = function_exists('mb_encode_mimeheader')
? mb_encode_mimeheader($subject, 'UTF-8')
: $subject;
$sent = @mail($to, $encodedSubject, $html, implode("\r\n", $headers));
if (!$sent) {
$this->lastMailError = 'mail() returned false';
}
return $sent;
}
private function formatEmailAddress(string $email, string $name): string
{
$email = trim($email);
if ($email === '') {
return 'no-reply@example.com';
}
$name = trim($name);
if ($name === '') {
return $email;
}
$encoded = function_exists('mb_encode_mimeheader')
? mb_encode_mimeheader($name, 'UTF-8')
: $name;
return sprintf('%s <%s>', $encoded, $email);
}
// =================================================================
// 💡 Öffentliche run()-Methode (KORRIGIERT)
// =================================================================
public function run(): void
{
// 💡 KORREKTUR: Der Content-Type Header wird hier entfernt, da er jetzt in respond()
// zentralisiert wurde, um sicherzustellen, dass er auch bei Fehlern im Konstruktor oder
// im try-Block korrekt gesetzt wird.
// header('Content-Type: application/json; charset=utf-8'); // DIESE ZEILE ENTFERNT
try {
// Extrahiere den Ressourcen-Typ und die Operation (z.B. 'templates' und 'list')
[$kind, $operation] = explode('.', $this->action, 2) + [1 => ''];
switch ($this->action) {
case 'health':
$this->respond(['ok' => true, 'time' => date('c')]);
case 'external.render':
$this->handleExternalRender();
break;
/* ---------- AUTH ---------- */
case 'auth.login':
$result = $this->authService->login($this->in);
$this->respond(['ok' => true] + $result);
break;
case 'auth.me':
if (empty($_SESSION['auth'])) $this->fail('Not authenticated', null, 401);
$this->respond(['ok' => true, 'user' => $_SESSION['auth']]);
break;
case 'auth.logout':
$this->authService->logout();
$this->respond(['ok' => true]);
break;
case 'account.profile.get':
$this->handleAccountProfileGet();
break;
case 'account.profile.update':
$this->handleAccountProfileUpdate();
break;
case 'account.password.update':
$this->handleAccountPasswordUpdate();
break;
case 'account.settings.get':
$this->handleAccountSettingsGet();
break;
case 'account.settings.update':
$this->handleAccountSettingsUpdate();
break;
case 'account.users.list':
$this->handleAccountUsersList();
break;
case 'account.users.create':
$this->handleAccountUsersCreate();
break;
case 'account.users.update':
$this->handleAccountUsersUpdate();
break;
case 'account.users.delete':
$this->handleAccountUsersDelete();
break;
case 'account.senders.list':
$this->handleAccountSendersList();
break;
case 'account.senders.save':
$this->handleAccountSenderSave();
break;
case 'account.senders.delete':
$this->handleAccountSenderDelete();
break;
case 'dashboard.metrics':
$this->handleDashboardMetrics();
break;
case 'dashboard.reset_usage':
$this->handleDashboardResetUsage();
break;
case 'downloads.bridge':
$this->handleDownloadFile('bridge');
break;
case 'downloads.sender':
$this->handleDownloadFile('sender');
break;
case 'account.bridge.setup.get':
$this->handleAccountBridgeSetupGet();
break;
case 'account.bridge.setup.save':
$this->handleAccountBridgeSetupSave();
break;
case 'account.bridge.test':
$this->handleAccountBridgeTest();
break;
case 'account.smtp.test':
$this->handleAccountSmtpTest();
break;
case 'account.fonts.list':
$this->handleAccountFontsList();
break;
case 'blocks_custom.list':
$this->handleBlocksCustomList();
break;
case 'debug.logs.list':
$this->handleDebugLogsList();
break;
case 'debug.logs.read':
$this->handleDebugLogsRead();
break;
case 'debug.log.write':
$this->handleDebugLogWrite();
break;
case 'placeholders.status':
$this->handlePlaceholderStatus();
break;
case 'placeholders.schema':
$this->handlePlaceholderSchema();
break;
case 'debug.phpinfo':
$this->handleDebugPhpInfo();
break;
case 'templates.test_send':
$this->handleTemplateTestSend();
break;
case 'templates.references':
$this->handleTemplateReferences();
break;
case 'sections_config.reorder':
$this->handleSectionsConfigReorder();
break;
case 'content_versions.restore':
$this->handleContentVersionsRestore();
break;
case 'content_versions.activate':
$this->handleContentVersionsActivate();
break;
case 'content_versions.deactivate':
$this->handleContentVersionsDeactivate();
break;
case 'content_versions.delete':
$this->handleContentVersionsDelete();
break;
/* ---------- CRUD HANDLER ---------- */
default:
if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config', 'content_versions'])) {
switch ($operation) {
case 'list':
if ($kind === 'content') $this->handleContentList();
elseif ($kind === 'sections_config') $this->handleSectionsConfigList();
elseif ($kind === 'content_versions') $this->handleContentVersionsList();
else $this->handleList($kind);
break;
case 'get':
if ($kind === 'content') $this->handleContentGet();
elseif ($kind === 'sections_config') $this->handleSectionsConfigGet();
elseif ($kind === 'content_versions') $this->handleContentVersionsGet();
else $this->handleGet($kind);
break;
case 'create':
if ($kind === 'content') $this->handleContentCreate();
elseif ($kind === 'sections_config') $this->handleSectionsConfigCreate();
else $this->handleCreate($kind);
break;
case 'update':
if ($kind === 'content') $this->handleContentUpdate();
elseif ($kind === 'sections_config') $this->handleSectionsConfigUpdate();
else $this->handleUpdate($kind);
break;
case 'delete':
if ($kind === 'content') $this->handleContentDelete();
elseif ($kind === 'sections_config') $this->handleSectionsConfigDelete();
else $this->handleDelete($kind);
break;
default:
$this->fail('Unknown operation for resource: ' . $this->action, null, 404);
break;
}
} else {
$this->fail('Unknown action', $this->action ?: 'missing', 404);
}
break;
}
} catch (Throwable $e) {
$this->fail('Server error', get_class($e) . ': ' . $e->getMessage(), 500);
}
}
private function handleBlocksCustomList(): void
{
$this->requireAuth();
$baseDir = realpath(__DIR__ . '/../public/assets/js/bridge/blocks-custom/elements');
if (!$baseDir || !is_dir($baseDir)) {
$this->respond(['ok' => true, 'files' => []]);
return;
}
$files = glob($baseDir . '/*.js') ?: [];
$out = [];
foreach ($files as $file) {
$name = basename($file);
if ($name && $name[0] !== '.') $out[] = $name;
}
sort($out, SORT_NATURAL | SORT_FLAG_CASE);
$this->respond(['ok' => true, 'files' => $out]);
}
private function lookupTableName(string $key, string $default): string
{
$tables = $this->conf['tables'] ?? [];
if (!empty($tables[$key])) return $tables[$key];
$prefix = $this->conf['projectdb']['prefix'] ?? null;
if ($prefix && strpos($default, 'emailtemplate_') === 0) {
return $prefix . substr($default, strlen('emailtemplate_'));
}
return $default;
}
private function countRefsInTable(string $table, string $where, array $params, array $auth): int
{
try {
[$tw, $tp] = $this->tenantWhere($auth);
$sql = "SELECT COUNT(*) AS c FROM `$table` WHERE $where" . $tw;
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$row = $stmt->fetch();
return (int)($row['c'] ?? 0);
} catch (Throwable $e) {
return 0;
}
}
private function fetchResourceCounts(int $customerId): array
{
$counts = [
'templates' => 0,
'sections' => 0,
'blocks' => 0,
'snippets' => 0,
'renders_total' => 0,
];
if ($this->useUnifiedContent()) {
$itemsTable = $this->contentItemsTable();
$sectionsTable = $this->contentSectionsTable();
$stmt = $this->pdo->prepare("SELECT `id`,`slug` FROM `$sectionsTable` WHERE `customer_id` = :cid");
$stmt->execute([':cid' => $customerId]);
$sections = $stmt->fetchAll() ?: [];
$bySlug = [];
foreach ($sections as $row) {
$slug = $row['slug'] ?? '';
if ($slug !== '') $bySlug[$slug] = (int)$row['id'];
}
$slugs = [
'templates' => 'emailtemplate',
'sections' => 'sections',
'blocks' => 'blocks',
'snippets' => 'snippets',
];
foreach ($slugs as $key => $slug) {
$sid = $bySlug[$slug] ?? null;
if (!$sid) continue;
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$itemsTable` WHERE `customer_id` = :cid AND `section_id` = :sid");
$stmt->execute([':cid' => $customerId, ':sid' => $sid]);
$counts[$key] = (int)($stmt->fetchColumn() ?: 0);
}
} else {
$map = $this->tableMap ?? [];
foreach (['templates', 'sections', 'blocks', 'snippets'] as $kind) {
$table = $map[$kind] ?? null;
if (!$table || !$this->tableExists($table)) continue;
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid");
$stmt->execute([':cid' => $customerId]);
$counts[$kind] = (int)($stmt->fetchColumn() ?: 0);
}
}
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
if ($this->tableExists($usageTable)) {
$stmt = $this->pdo->prepare("SELECT SUM(`render_count`) FROM `$usageTable` WHERE `customer_id` = :cid");
$stmt->execute([':cid' => $customerId]);
$counts['renders_total'] = (int)($stmt->fetchColumn() ?: 0);
}
return $counts;
}
private function listTemplateUsage(int $customerId): array
{
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
if ($this->useUnifiedContent()) {
$itemsTable = $this->contentItemsTable();
$sectionsTable = $this->contentSectionsTable();
if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) {
return [];
}
$section = $this->ensureEmailtemplateSection($customerId);
if ($this->tableExists($usageTable)) {
$sql = "SELECT i.id, i.name, i.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at
FROM `$itemsTable` i
LEFT JOIN `$usageTable` u ON u.template_id = i.id
WHERE i.customer_id = :cid AND i.section_id = :sid
ORDER BY render_count DESC, i.updated_at DESC";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id']]);
$rows = $stmt->fetchAll() ?: [];
} else {
$sql = "SELECT i.id, i.name, i.updated_at FROM `$itemsTable` i WHERE i.customer_id = :cid AND i.section_id = :sid ORDER BY i.updated_at DESC";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id']]);
$rows = $stmt->fetchAll() ?: [];
foreach ($rows as &$row) {
$row['render_count'] = 0;
$row['last_rendered_at'] = null;
}
}
} else {
$table = $this->tableMap['templates'] ?? null;
if (!$table || !$this->tableExists($table)) {
return [];
}
if ($this->tableExists($usageTable)) {
$sql = "SELECT t.id, t.name, t.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at
FROM `$table` t
LEFT JOIN `$usageTable` u ON u.template_id = t.id
WHERE t.customer_id = :cid
ORDER BY render_count DESC, t.updated_at DESC";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId]);
$rows = $stmt->fetchAll() ?: [];
} else {
$sql = "SELECT t.id, t.name, t.updated_at FROM `$table` t WHERE t.customer_id = :cid ORDER BY t.updated_at DESC";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId]);
$rows = $stmt->fetchAll() ?: [];
foreach ($rows as &$row) {
$row['render_count'] = 0;
$row['last_rendered_at'] = null;
}
}
}
return array_map(static function ($row) {
return [
'template_id' => (int)($row['id'] ?? 0),
'name' => $row['name'] ?? '',
'render_count' => (int)($row['render_count'] ?? 0),
'last_rendered_at' => $row['last_rendered_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}, $rows);
}
private function resetTemplateUsage(int $customerId, array $templateIds): void
{
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
if (!$templateIds || !$this->tableExists($usageTable)) {
return;
}
$templateIds = array_values(array_unique(array_filter(array_map('intval', $templateIds), static fn ($v) => $v > 0)));
if (!$templateIds) return;
$placeholders = implode(',', array_fill(0, count($templateIds), '?'));
$sql = "DELETE FROM `$usageTable` WHERE `customer_id` = ? AND `template_id` IN ($placeholders)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(array_merge([$customerId], $templateIds));
}
private function extractIdList($raw): array
{
if ($raw === null) return [];
if (is_numeric($raw)) {
$raw = [(int)$raw];
} elseif (is_string($raw)) {
$raw = preg_split('/[\s,]+/', $raw);
} elseif (!is_array($raw)) {
return [];
}
$ids = [];
foreach ($raw as $value) {
if (is_array($value)) {
$ids = array_merge($ids, $this->extractIdList($value));
continue;
}
if ($value === '' || $value === null) continue;
$ids[] = (int)$value;
}
$ids = array_values(array_unique(array_filter($ids, static fn ($v) => $v > 0)));
return $ids;
}
private function calculateUsage(string $kind, int $id, array $auth): array
{
if ($id <= 0) return ['total' => 0];
if ($this->useUnifiedContent()) {
return ['total' => 0];
}
$summary = [];
$templateItemsTable = $this->lookupTableName('template_items', 'emailtemplate_template_items');
$sectionItemsTable = $this->lookupTableName('section_items', 'emailtemplate_section_items');
if ($kind === 'sections') {
$summary['templates'] = $this->countRefsInTable(
$templateItemsTable,
"`ref_type` = :rt AND `ref_id` = :rid",
[':rt' => 'section', ':rid' => $id],
$auth
);
} elseif ($kind === 'blocks') {
$summary['templates'] = $this->countRefsInTable(
$templateItemsTable,
"`ref_type` = :rt AND `ref_id` = :rid",
[':rt' => 'block', ':rid' => $id],
$auth
);
$summary['sections'] = $this->countRefsInTable(
$sectionItemsTable,
"`ref_id` = :rid",
[':rid' => $id],
$auth
);
$summary['snippets'] = $this->countRefsInTable(
$this->tableMap['snippets'],
"`block_id` = :rid",
[':rid' => $id],
$auth
);
}
$summary = array_filter($summary, fn($v) => (int)$v > 0);
$summary['total'] = array_sum($summary);
return $summary;
}
private function normalizeResourceKind(string $kind): ?string
{
$kind = strtolower(trim($kind));
$map = [
'template' => 'templates',
'templates' => 'templates',
'section' => 'sections',
'sections' => 'sections',
'block' => 'blocks',
'blocks' => 'blocks',
'snippet' => 'snippets',
'snippets' => 'snippets',
];
return $map[$kind] ?? null;
}
private function resolveHtmlColumn(array $columns, string $kindKey): ?string
{
$candidates = ($kindKey === 'snippets')
? ['content', 'html', 'body', 'markup']
: ['html', 'body', 'markup', 'content'];
return $this->firstExisting($columns, $candidates);
}
private function fetchResourceHtml(string $kind, int $id, array $auth, array &$cache, array &$stack): ?string
{
if ($id <= 0) return null;
$kindKey = $this->normalizeResourceKind($kind);
$cacheKey = ($kindKey ?: $kind) . ':' . $id;
if (array_key_exists($cacheKey, $cache)) return $cache[$cacheKey];
if (!empty($stack[$cacheKey])) return null;
if ($this->useUnifiedContent()) {
$customerId = (int)($auth['customer_id'] ?? 0);
$sectionSlug = $this->resolveSectionSlugFromKind($kind);
$section = $this->fetchContentSectionBySlug($customerId, $sectionSlug);
if (!$section) {
$cache[$cacheKey] = null;
return null;
}
$itemsTable = $this->contentItemsTable();
$itemCols = $this->resolveContentItemColumns($itemsTable);
$versionsTable = $this->contentVersionsTable();
$versionCols = $this->tableExists($versionsTable) ? $this->resolveContentVersionColumns($versionsTable) : null;
$htmlCol = $itemCols['html'];
$jsonCol = $itemCols['json'];
if (!$htmlCol && !$jsonCol && (!$versionCols || !$versionCols['html'])) {
$cache[$cacheKey] = null;
return null;
}
$selectCols = [];
if ($htmlCol) $selectCols[] = "i.`$htmlCol` AS item_html";
if ($jsonCol) $selectCols[] = "i.`$jsonCol` AS item_json";
if ($versionCols && $versionCols['html']) $selectCols[] = "v.`{$versionCols['html']}` AS version_html";
$join = $versionCols ? "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1" : "";
$sql = "SELECT " . implode(',', $selectCols) . " FROM `$itemsTable` i $join WHERE i.`customer_id` = :cid AND i.`section_id` = :sid AND i.`id` = :id LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $id]);
$row = $stmt->fetch();
if (!$row) {
$cache[$cacheKey] = null;
return null;
}
$html = (string)($row['version_html'] ?? $row['item_html'] ?? '');
} else {
if (!$kindKey) return null;
$table = $this->tableMap[$kindKey] ?? null;
if (!$table) return null;
[$idCol, $allCols] = $this->resolveIdCol($kindKey);
[$tw, $tp] = $this->tenantWhere($auth);
$sql = "SELECT * FROM `$table` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':id', $id);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$row = $stmt->fetch();
if (!$row) {
$cache[$cacheKey] = null;
return null;
}
$htmlCol = $this->resolveHtmlColumn($allCols, $kindKey);
$html = $htmlCol && isset($row[$htmlCol]) ? (string)$row[$htmlCol] : '';
}
$stack[$cacheKey] = true;
$html = $this->renderHtmlWithReferences($html, $auth, $cache, $stack);
unset($stack[$cacheKey]);
$cache[$cacheKey] = $html;
return $html;
}
private function renderHtmlWithReferences(string $html, array $auth, array &$cache, array &$stack): string
{
$trimmed = trim($html);
if ($trimmed === '') return $html;
if (!class_exists(DOMDocument::class)) return $html;
$flags = 0;
if (defined('LIBXML_HTML_NOIMPLIED')) {
$flags |= LIBXML_HTML_NOIMPLIED;
}
if (defined('LIBXML_HTML_NODEFDTD')) {
$flags |= LIBXML_HTML_NODEFDTD;
}
$doc = new DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$wrapper = '<div id="lib-ref-root">' . $html . '</div>';
$loaded = @$doc->loadHTML('<?xml encoding="utf-8"?>' . $wrapper, $flags);
libxml_clear_errors();
if (!$loaded) return $html;
$xpath = new DOMXPath($doc);
$nodes = $xpath->query('//*[@data-lib-id and (@data-lib-kind or @data-lib-section)]');
if ($nodes !== false) {
foreach ($nodes as $node) {
/** @var \DOMElement $node */
$kind = $node->getAttribute('data-lib-section') ?: $node->getAttribute('data-lib-kind');
$refId = (int)$node->getAttribute('data-lib-id');
if (!$kind || $refId <= 0) continue;
$replacement = $this->fetchResourceHtml($kind, $refId, $auth, $cache, $stack);
if ($replacement === null) continue;
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$this->appendHtmlToNode($doc, $node, $replacement);
}
}
$root = $doc->getElementById('lib-ref-root');
if (!$root) return $html;
$output = '';
foreach ($root->childNodes as $child) {
$output .= $doc->saveHTML($child);
}
return $output;
}
private function appendHtmlToNode(DOMDocument $targetDoc, DOMElement $node, string $html): void
{
if (trim($html) === '') return;
$flags = 0;
if (defined('LIBXML_HTML_NOIMPLIED')) {
$flags |= LIBXML_HTML_NOIMPLIED;
}
if (defined('LIBXML_HTML_NODEFDTD')) {
$flags |= LIBXML_HTML_NODEFDTD;
}
$fragmentDoc = new DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$wrapped = '<div id="fragment-wrapper">' . $html . '</div>';
$loaded = @$fragmentDoc->loadHTML('<?xml encoding="utf-8"?>' . $wrapped, $flags);
libxml_clear_errors();
if ($loaded) {
$wrapper = $fragmentDoc->getElementById('fragment-wrapper');
if ($wrapper) {
$children = [];
if ($wrapper->hasChildNodes()) {
foreach ($wrapper->childNodes as $child) {
$children[] = $child;
}
}
foreach ($children as $child) {
$imported = $targetDoc->importNode($child, true);
if ($imported) {
$node->appendChild($imported);
}
}
return;
}
}
$node->appendChild($targetDoc->createTextNode($html));
}
private function handlePlaceholderSchema(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
$bridge = $this->resolveBridgeConfig($customerId);
$url = trim((string)($bridge['url'] ?? ''));
$token = trim((string)($bridge['token'] ?? ''));
if ($url === '' || $token === '') {
$this->fail('Bridge not configured', null, 500);
}
$ttl = (int)($bridge['cache_ttl'] ?? 300);
try {
$schema = $this->fetchPlaceholderSchema($url, $token, $ttl);
} catch (Throwable $e) {
$this->fail('Bridge request failed', $e->getMessage(), 502);
return;
}
$settings = $this->getCustomerSettings($customerId);
$allowedTables = $this->normalizeBridgeTables($settings['bridge_tables'] ?? []);
if ($allowedTables && !empty($schema['tables']) && is_array($schema['tables'])) {
$allowedMap = array_fill_keys($allowedTables, true);
$schema['tables'] = array_values(array_filter($schema['tables'], static function ($entry) use ($allowedMap) {
if (is_string($entry)) {
$name = $entry;
} elseif (is_array($entry)) {
$name = $entry['name'] ?? $entry['table'] ?? $entry['label'] ?? '';
} else {
$name = '';
}
return $name !== '' && isset($allowedMap[$name]);
}));
}
$this->respond([
'ok' => true,
'tables' => $schema['tables'] ?? [],
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
]);
}
private function handlePlaceholderStatus(): void
{
$user = $this->requireAuth();
$customerId = (int)($user['customer_id'] ?? 0);
$bridge = $this->resolveBridgeConfig($customerId);
$url = trim((string)($bridge['url'] ?? ''));
$token = trim((string)($bridge['token'] ?? ''));
$available = ($url !== '' && $token !== '');
$this->respond([
'ok' => true,
'available' => $available,
]);
}
private function fetchPlaceholderSchema(string $url, string $token, int $ttl): array
{
$cacheFile = $this->placeholderCachePath($url, $token);
if ($ttl > 0 && is_file($cacheFile) && (filemtime($cacheFile) + $ttl) > time()) {
$cached = json_decode((string)@file_get_contents($cacheFile), true);
if (is_array($cached)) {
return $cached;
}
}
$endpoint = $url;
if (stripos($endpoint, 'action=') === false) {
$endpoint .= (strpos($endpoint, '?') === false ? '?' : '&') . 'action=schema';
}
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "Authorization: Bearer {$token}\r\nAccept: application/json\r\n",
'timeout' => 10,
],
]);
$response = @file_get_contents($endpoint, false, $context);
if ($response === false) {
throw new RuntimeException('Bridge endpoint unreachable');
}
$decoded = json_decode($response, true);
if (!is_array($decoded) || empty($decoded['ok'])) {
throw new RuntimeException('Bridge did not return a valid schema');
}
if ($ttl > 0) {
@file_put_contents($cacheFile, json_encode($decoded));
}
return $decoded;
}
private function placeholderCachePath(string $url, string $token): string
{
$hash = md5($url . '|' . $token);
return sys_get_temp_dir() . '/emailtemplate_placeholder_' . $hash . '.json';
}
// -----------------------------------------------------------------
// Account & User Management
// -----------------------------------------------------------------
private function handleAccountProfileGet(): void
{
$user = $this->requireAuth();
$customerId = (int)($user['customer_id'] ?? 0);
$settings = $customerId ? $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId)) : [];
$this->ensureAuthUserListSortColumn();
$settings['list_sort'] = $this->resolveUserListSort($user, $customerId);
$this->respond([
'ok' => true,
'user' => $user,
'customer' => $user['customer'] ?? null,
'settings' => $settings,
]);
}
private function handleAccountProfileUpdate(): void
{
$user = $this->requireAuth();
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
$name = trim((string)($this->in['name'] ?? ''));
$email = trim(strtolower((string)($this->in['email'] ?? '')));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->fail('Valid email required', null, 422);
}
if ($name === '') $this->fail('Name required', null, 422);
$userId = (int)($user['id'] ?? 0);
$customerId = (int)($user['customer_id'] ?? 0);
if (strtolower($email) !== strtolower((string)$user['email'])) {
$this->assertEmailUnique($email, $customerId, $userId);
}
$set = [];
$params = [':id' => $userId];
if ($this->columnExists($dbCols, $cols['col_name'])) {
$set[] = sprintf('`%s` = :name', $cols['col_name']);
$params[':name'] = $name;
}
if ($this->columnExists($dbCols, $cols['col_email'])) {
$set[] = sprintf('`%s` = :email', $cols['col_email']);
$params[':email'] = $email;
}
if (!$set) {
$this->fail('Profile update not supported', null, 500);
}
$sql = sprintf(
'UPDATE `%s` SET %s WHERE `%s` = :id LIMIT 1',
$table,
implode(',', $set),
$cols['col_id']
);
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$_SESSION['auth']['name'] = $name;
$_SESSION['auth']['email'] = $email;
$this->respond(['ok' => true, 'user' => $_SESSION['auth']]);
}
private function handleAccountPasswordUpdate(): void
{
$user = $this->requireAuth();
$current = (string)($this->in['current_password'] ?? '');
$new = (string)($this->in['new_password'] ?? '');
if ($current === '' || $new === '') {
$this->fail('Current and new password required', null, 422);
}
if (strlen($new) < 8) {
$this->fail('Password must be at least 8 characters', null, 422);
}
$cols = $this->authUserColumns();
$table = $cols['table'];
$sql = sprintf(
'SELECT `%1$s` FROM `%2$s` WHERE `%3$s` = :id LIMIT 1',
$cols['col_pass'],
$table,
$cols['col_id']
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => $user['id']]);
$row = $stmt->fetch();
if (!$row) $this->fail('User not found', null, 404);
$stored = (string)$row[$cols['col_pass']];
if (!$this->verifyUserPasswordValue($current, $stored)) {
$this->fail('Current password incorrect', null, 403);
}
$hash = $this->hashUserPassword($new);
$update = $this->pdo->prepare(
sprintf('UPDATE `%s` SET `%s` = :pwd WHERE `%s` = :id LIMIT 1', $table, $cols['col_pass'], $cols['col_id'])
);
$update->execute([':pwd' => $hash, ':id' => $user['id']]);
$this->respond(['ok' => true]);
}
private function handleAccountSettingsGet(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
$this->ensureAuthUserListSortColumn();
$settings['list_sort'] = $this->resolveUserListSort($user, $customerId);
$this->respond(['ok' => true, 'settings' => $settings]);
}
private function handleAccountSmtpTest(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$recipient = trim((string)($this->in['to'] ?? ''));
if ($recipient === '') {
$recipient = (string)($user['email'] ?? '');
}
if ($recipient === '' || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
$this->fail('Valid recipient required', null, 422);
}
$smtpOverride = null;
if (array_key_exists('smtp_host', $this->in) || array_key_exists('smtp_enabled', $this->in)) {
$smtpOverride = [
'enabled' => !empty($this->in['smtp_enabled']),
'host' => trim((string)($this->in['smtp_host'] ?? '')),
'port' => (int)($this->in['smtp_port'] ?? 0),
'user' => trim((string)($this->in['smtp_user'] ?? '')),
'pass' => (string)($this->in['smtp_pass'] ?? ''),
'secure' => strtolower(trim((string)($this->in['smtp_secure'] ?? ''))),
'from_email' => trim((string)($this->in['smtp_from_email'] ?? '')),
'from_name' => trim((string)($this->in['smtp_from_name'] ?? '')),
'reply_to' => trim((string)($this->in['smtp_reply_to'] ?? '')),
];
}
$subject = 'EmailTemplate SMTP Test';
$html = '<p>SMTP Test erfolgreich.</p><p>Zeit: ' . date(DATE_ATOM) . '</p>';
$ok = $this->dispatchTestMail($recipient, $subject, $html, null, $customerId, $smtpOverride);
$this->writeDebugLog('smtp_test', [
'time' => date(DATE_ATOM),
'customer_id' => $customerId,
'to' => $recipient,
'smtp_enabled' => $smtpOverride['enabled'] ?? null,
'smtp_host' => $smtpOverride['host'] ?? null,
'smtp_port' => $smtpOverride['port'] ?? null,
'smtp_secure' => $smtpOverride['secure'] ?? null,
'smtp_user' => $smtpOverride['user'] ?? null,
'smtp_pass_set' => isset($smtpOverride['pass']) ? ($smtpOverride['pass'] !== '') : null,
'smtp_pass_len' => isset($smtpOverride['pass']) ? strlen((string)$smtpOverride['pass']) : null,
'smtp_from_email' => $smtpOverride['from_email'] ?? null,
'smtp_from_name' => $smtpOverride['from_name'] ?? null,
'smtp_reply_to' => $smtpOverride['reply_to'] ?? null,
'ok' => $ok,
'error' => $ok ? null : $this->lastMailError,
]);
if (!$ok) {
$this->fail('SMTP test failed', $this->lastMailError ?: 'Send failed', 500);
}
$this->respond(['ok' => true, 'to' => $recipient]);
}
private function handleAccountSettingsUpdate(): void
{
$user = $this->requireAuth();
$customerId = (int)($user['customer_id'] ?? 0);
$hasBridgeUrl = array_key_exists('bridge_url', $this->in);
$hasBridgeToken = array_key_exists('bridge_token', $this->in);
$hasSenderToken = array_key_exists('sender_token', $this->in);
$hasExternalToken = array_key_exists('external_api_token', $this->in);
$hasEditorDefault = array_key_exists('editor_default', $this->in);
$hasVersionsRetention = array_key_exists('versions_retention', $this->in);
$hasListSort = array_key_exists('list_sort', $this->in);
$hasBridgeTables = array_key_exists('bridge_tables', $this->in);
$hasSmtpEnabled = array_key_exists('smtp_enabled', $this->in);
$hasSmtpHost = array_key_exists('smtp_host', $this->in);
$hasSmtpPort = array_key_exists('smtp_port', $this->in);
$hasSmtpUser = array_key_exists('smtp_user', $this->in);
$hasSmtpPass = array_key_exists('smtp_pass', $this->in);
$hasSmtpSecure = array_key_exists('smtp_secure', $this->in);
$hasSmtpFromEmail = array_key_exists('smtp_from_email', $this->in);
$hasSmtpFromName = array_key_exists('smtp_from_name', $this->in);
$hasSmtpReplyTo = array_key_exists('smtp_reply_to', $this->in);
$hasSmtpPassClear = array_key_exists('smtp_pass_clear', $this->in);
$rotateBridge = !empty($this->in['rotate_bridge_token']);
$rotateSender = !empty($this->in['rotate_sender_token']);
$rotateExternal = !empty($this->in['rotate_external_token']);
$onlyListSort = $hasListSort && !$hasBridgeUrl && !$hasBridgeToken && !$hasSenderToken && !$hasExternalToken
&& !$hasEditorDefault && !$hasBridgeTables && !$hasVersionsRetention && !$rotateBridge && !$rotateSender && !$rotateExternal
&& !$hasSmtpEnabled && !$hasSmtpHost && !$hasSmtpPort && !$hasSmtpUser && !$hasSmtpPass && !$hasSmtpSecure
&& !$hasSmtpFromEmail && !$hasSmtpFromName && !$hasSmtpReplyTo && !$hasSmtpPassClear;
if (!$onlyListSort) {
$this->ensureRole($user, ['owner', 'admin']);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
}
$settings = $customerId ? $this->getCustomerSettings($customerId) : [];
$bridgeUrl = $hasBridgeUrl ? trim((string)($this->in['bridge_url'] ?? '')) : (string)($settings['bridge_url'] ?? '');
$bridgeToken = $hasBridgeToken ? trim((string)($this->in['bridge_token'] ?? '')) : (string)($settings['bridge_token'] ?? '');
$senderToken = $hasSenderToken ? trim((string)($this->in['sender_token'] ?? '')) : (string)($settings['sender_token'] ?? '');
$externalToken = $hasExternalToken ? trim((string)($this->in['external_api_token'] ?? '')) : (string)($settings['external_api_token'] ?? '');
$editorDefault = $hasEditorDefault ? strtolower(trim((string)($this->in['editor_default'] ?? ''))) : strtolower((string)($settings['editor_default'] ?? ''));
$versionsRetention = $hasVersionsRetention ? (int)($this->in['versions_retention'] ?? 0) : (int)($settings['versions_retention'] ?? 0);
$listSort = $hasListSort ? strtolower(trim((string)($this->in['list_sort'] ?? ''))) : '';
$bridgeTables = $hasBridgeTables ? $this->normalizeBridgeTables($this->in['bridge_tables'] ?? []) : ($settings['bridge_tables'] ?? []);
$smtpEnabled = $hasSmtpEnabled ? (int)($this->in['smtp_enabled'] ?? 0) : (int)($settings['smtp_enabled'] ?? 0);
$smtpHost = $hasSmtpHost ? trim((string)($this->in['smtp_host'] ?? '')) : (string)($settings['smtp_host'] ?? '');
$smtpPort = $hasSmtpPort ? (int)($this->in['smtp_port'] ?? 0) : (int)($settings['smtp_port'] ?? 0);
$smtpUser = $hasSmtpUser ? trim((string)($this->in['smtp_user'] ?? '')) : (string)($settings['smtp_user'] ?? '');
$smtpPass = $hasSmtpPass ? (string)($this->in['smtp_pass'] ?? '') : (string)($settings['smtp_pass'] ?? '');
$smtpSecure = $hasSmtpSecure ? strtolower(trim((string)($this->in['smtp_secure'] ?? ''))) : strtolower((string)($settings['smtp_secure'] ?? ''));
$smtpFromEmail = $hasSmtpFromEmail ? trim((string)($this->in['smtp_from_email'] ?? '')) : (string)($settings['smtp_from_email'] ?? '');
$smtpFromName = $hasSmtpFromName ? trim((string)($this->in['smtp_from_name'] ?? '')) : (string)($settings['smtp_from_name'] ?? '');
$smtpReplyTo = $hasSmtpReplyTo ? trim((string)($this->in['smtp_reply_to'] ?? '')) : (string)($settings['smtp_reply_to'] ?? '');
$smtpPassClear = $hasSmtpPassClear ? !empty($this->in['smtp_pass_clear']) : false;
if ($bridgeUrl && !filter_var($bridgeUrl, FILTER_VALIDATE_URL)) {
$this->fail('Ungültige Bridge-URL', null, 422);
}
if ($listSort !== '' && !in_array($listSort, ['created_asc', 'name_asc', 'name_desc', 'updated_desc'], true)) {
$this->fail('Ungültige Sortierung', null, 422);
}
if ($smtpEnabled) {
if ($smtpHost === '') {
$this->fail('SMTP-Host erforderlich', null, 422);
}
if ($smtpPort < 0 || $smtpPort > 65535) {
$this->fail('Ungültiger SMTP-Port', null, 422);
}
if ($smtpSecure !== '' && !in_array($smtpSecure, ['tls', 'ssl', 'none'], true)) {
$this->fail('Ungültige SMTP-Sicherheit', null, 422);
}
}
if ($smtpFromEmail !== '' && !filter_var($smtpFromEmail, FILTER_VALIDATE_EMAIL)) {
$this->fail('Ungültige SMTP-Absenderadresse', null, 422);
}
if ($smtpReplyTo !== '' && !filter_var($smtpReplyTo, FILTER_VALIDATE_EMAIL)) {
$this->fail('Ungültige SMTP-Reply-To-Adresse', null, 422);
}
if (!$onlyListSort) {
if ($rotateBridge || $bridgeToken === '') $bridgeToken = $this->generateToken();
if ($rotateSender || $senderToken === '') $senderToken = $this->generateToken();
if ($rotateExternal || $externalToken === '') $externalToken = $this->generateToken();
if ($editorDefault !== '' && !in_array($editorDefault, ['grapesjs', 'craftjs'], true)) {
$this->fail('Ungültiger Editor-Typ', null, 422);
}
if ($versionsRetention < 0) {
$this->fail('Ungültiger Aufbewahrungswert', null, 422);
}
if ($smtpSecure === 'none') {
$smtpSecure = '';
}
$save = [
'bridge_url' => $bridgeUrl,
'bridge_token' => $bridgeToken,
'sender_token' => $senderToken,
'external_api_token' => $externalToken,
'editor_default' => $editorDefault ?: null,
'bridge_tables' => $bridgeTables,
'versions_retention' => $versionsRetention,
'smtp_enabled' => $smtpEnabled ? 1 : 0,
'smtp_host' => $smtpHost ?: null,
'smtp_port' => $smtpPort > 0 ? $smtpPort : null,
'smtp_user' => $smtpUser ?: null,
'smtp_secure' => $smtpSecure ?: null,
'smtp_from_email' => $smtpFromEmail ?: null,
'smtp_from_name' => $smtpFromName ?: null,
'smtp_reply_to' => $smtpReplyTo ?: null,
];
if ($smtpPassClear) {
$save['smtp_pass'] = null;
} elseif ($hasSmtpPass && $smtpPass !== '') {
$save['smtp_pass'] = $smtpPass;
}
$settings = $this->saveCustomerSettings($customerId, $save);
} else {
$settings = $customerId ? $this->ensureSettingsTokens($customerId, $settings) : $settings;
}
if ($hasListSort) {
$this->ensureAuthUserListSortColumn();
$this->updateUserListSort($user, $customerId, $listSort ?: null);
}
$settings['list_sort'] = $this->resolveUserListSort($user, $customerId, $listSort);
$this->respond(['ok' => true, 'settings' => $settings]);
}
private function handleAccountUsersList(): void
{
$user = $this->requireAuth();
$this->ensureOwner($user);
$customerId = (int)($user['customer_id'] ?? 0);
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
$select = [
sprintf('`%s` AS user_id', $cols['col_id']),
];
$nameSource = $this->columnExists($dbCols, $cols['col_name']) ? $cols['col_name'] : $cols['col_email'];
$select[] = sprintf('`%s` AS name', $nameSource);
$select[] = sprintf('`%s` AS email', $cols['col_email']);
if ($this->columnExists($dbCols, $cols['col_role'])) {
$select[] = sprintf('`%s` AS role', $cols['col_role']);
} else {
$select[] = "'user' AS role";
}
if ($this->columnExists($dbCols, $cols['col_status'])) {
$select[] = sprintf('`%s` AS is_active', $cols['col_status']);
} else {
$select[] = '1 AS is_active';
}
if ($this->columnExists($dbCols, 'created_at')) $select[] = '`created_at`';
if ($this->columnExists($dbCols, 'updated_at')) $select[] = '`updated_at`';
$sql = sprintf(
'SELECT %s FROM `%s` WHERE `%s` = :cid ORDER BY `%s` ASC',
implode(',', $select),
$table,
$cols['col_customer'],
$nameSource
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId]);
$items = [];
while ($row = $stmt->fetch()) {
$items[] = $this->formatUserOutput($row);
}
$this->respond(['ok' => true, 'items' => $items]);
}
private function handleAccountUsersCreate(): void
{
$owner = $this->requireAuth();
$this->ensureOwner($owner);
$customerId = (int)($owner['customer_id'] ?? 0);
$name = trim((string)($this->in['name'] ?? ''));
$email = trim(strtolower((string)($this->in['email'] ?? '')));
$role = $this->sanitizeRole((string)($this->in['role'] ?? 'user'));
if ($name === '' || $email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->fail('Name und gültige E-Mail sind erforderlich', null, 422);
}
$this->assertEmailUnique($email, $customerId, null);
$password = $this->generateReadablePassword();
$hash = $this->hashUserPassword($password);
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
$data = [];
if ($this->columnExists($dbCols, $cols['col_name'])) {
$data[$cols['col_name']] = $name;
}
$data[$cols['col_email']] = $email;
$data[$cols['col_pass']] = $hash;
if ($this->columnExists($dbCols, $cols['col_role'])) $data[$cols['col_role']] = $role;
if ($this->columnExists($dbCols, $cols['col_status'])) $data[$cols['col_status']] = 1;
if ($this->columnExists($dbCols, $cols['col_customer'])) $data[$cols['col_customer']] = $customerId;
if ($this->columnExists($dbCols, 'created_at')) $data['created_at'] = date('Y-m-d H:i:s');
if ($this->columnExists($dbCols, 'updated_at')) $data['updated_at'] = date('Y-m-d H:i:s');
$columns = array_keys($data);
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
$placeholders = implode(',', array_map(fn($c) => ":$c", $columns));
$sql = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $insertCols, $placeholders);
$stmt = $this->pdo->prepare($sql);
foreach ($data as $col => $value) $stmt->bindValue(":$col", $value);
$stmt->execute();
$newId = (int)$this->pdo->lastInsertId();
$newUser = $this->fetchUserRow($newId, $customerId);
$this->respond(['ok' => true, 'user' => $newUser, 'temp_password' => $password]);
}
private function handleAccountUsersUpdate(): void
{
$owner = $this->requireAuth();
$this->ensureOwner($owner);
$customerId = (int)($owner['customer_id'] ?? 0);
$userId = (int)($this->in['user_id'] ?? 0);
if ($userId <= 0) $this->fail('Ungültige Nutzer-ID', null, 422);
$target = $this->fetchUserRow($userId, $customerId);
if (!$target) $this->fail('Nutzer nicht gefunden', null, 404);
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
$set = [];
$params = [':id' => $userId];
$name = trim((string)($this->in['name'] ?? $target['name']));
$email = trim(strtolower((string)($this->in['email'] ?? $target['email'])));
$role = $this->sanitizeRole((string)($this->in['role'] ?? $target['role']));
$isActive = isset($this->in['is_active']) ? (int)(bool)$this->in['is_active'] : (int)$target['is_active'];
$resetPassword = !empty($this->in['reset_password']);
if ($name === '' || $email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->fail('Name und gültige E-Mail sind erforderlich', null, 422);
}
if (strtolower($email) !== strtolower($target['email'])) {
$this->assertEmailUnique($email, $customerId, $userId);
}
if ($this->columnExists($dbCols, $cols['col_name'])) {
$set[] = sprintf('`%s` = :name', $cols['col_name']);
$params[':name'] = $name;
}
if ($this->columnExists($dbCols, $cols['col_email'])) {
$set[] = sprintf('`%s` = :email', $cols['col_email']);
$params[':email'] = $email;
}
if ($this->columnExists($dbCols, $cols['col_role'])) {
if ($target['role'] === 'owner' && $role !== 'owner' && $this->countOwners($customerId, $userId) < 1) {
$this->fail('Mindestens ein Owner muss bestehen bleiben', null, 422);
}
$set[] = sprintf('`%s` = :role', $cols['col_role']);
$params[':role'] = $role;
}
if ($this->columnExists($dbCols, $cols['col_status'])) {
if ($target['role'] === 'owner' && !$isActive && $this->countOwners($customerId, $userId) < 1) {
$this->fail('Mindestens ein Owner muss aktiv bleiben', null, 422);
}
$set[] = sprintf('`%s` = :status', $cols['col_status']);
$params[':status'] = $isActive;
}
$tempPassword = null;
if ($resetPassword) {
$tempPassword = $this->generateReadablePassword();
$hash = $this->hashUserPassword($tempPassword);
$set[] = sprintf('`%s` = :pwd', $cols['col_pass']);
$params[':pwd'] = $hash;
}
if ($this->columnExists($dbCols, 'updated_at')) {
$set[] = '`updated_at` = :updated_at';
$params[':updated_at'] = date('Y-m-d H:i:s');
}
if (!$set) $this->fail('Keine Änderungen erkannt', null, 422);
$sql = sprintf('UPDATE `%s` SET %s WHERE `%s` = :id LIMIT 1', $table, implode(',', $set), $cols['col_id']);
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$updated = $this->fetchUserRow($userId, $customerId);
$resp = ['ok' => true, 'user' => $updated];
if ($tempPassword !== null) $resp['temp_password'] = $tempPassword;
$this->respond($resp);
}
private function handleAccountUsersDelete(): void
{
$owner = $this->requireAuth();
$this->ensureOwner($owner);
$customerId = (int)($owner['customer_id'] ?? 0);
$userId = (int)($this->in['user_id'] ?? 0);
if ($userId <= 0) $this->fail('Ungültige Nutzer-ID', null, 422);
if ($userId === (int)($owner['id'] ?? 0)) $this->fail('Du kannst dich nicht selbst löschen', null, 422);
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
$target = $this->fetchUserRow($userId, $customerId);
if (!$target) $this->fail('Nutzer nicht gefunden', null, 404);
if ($target['role'] === 'owner' && $this->countOwners($customerId, $userId) < 1) {
$this->fail('Mindestens ein Owner muss bestehen bleiben', null, 422);
}
if ($this->columnExists($dbCols, $cols['col_status'])) {
$sql = sprintf('UPDATE `%s` SET `%s` = 0 WHERE `%s` = :id LIMIT 1', $table, $cols['col_status'], $cols['col_id']);
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => $userId]);
} else {
$sql = sprintf('DELETE FROM `%s` WHERE `%s` = :id LIMIT 1', $table, $cols['col_id']);
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => $userId]);
}
$this->respond(['ok' => true, 'deleted' => true]);
}
private function handleAccountSendersList(): void
{
$user = $this->requireAuth();
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$table = $this->senderTable();
try {
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `label` ASC");
} catch (Throwable $e) {
$this->fail('Sender-Tabelle existiert nicht', $e->getMessage(), 500);
}
$stmt->execute([':cid' => $customerId]);
$items = [];
while ($row = $stmt->fetch()) {
$items[] = $this->formatSenderRow($row);
}
$this->respond(['ok' => true, 'items' => $items]);
}
private function handleAccountSenderSave(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
$senderId = (int)($this->in['sender_id'] ?? 0);
$label = trim((string)($this->in['label'] ?? ''));
$fromName = trim((string)($this->in['from_name'] ?? ''));
$fromEmail = trim((string)($this->in['from_email'] ?? ''));
$replyTo = trim((string)($this->in['reply_to'] ?? ''));
if ($label === '') $label = $fromName ?: $fromEmail;
if ($fromEmail === '' || !filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) {
$this->fail('Gültige Absender-Adresse erforderlich', null, 422);
}
if ($replyTo !== '' && !filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
$this->fail('Ungültige Reply-To-Adresse', null, 422);
}
$table = $this->senderTable();
if ($senderId > 0) {
$stmt = $this->pdo->prepare("UPDATE `$table` SET `label`=:label,`from_name`=:fname,`from_email`=:fmail,`reply_to`=:reply,`updated_at`=NOW() WHERE `id`=:id AND `customer_id`=:cid LIMIT 1");
$stmt->execute([
':label' => $label,
':fname' => $fromName ?: null,
':fmail' => $fromEmail,
':reply' => $replyTo ?: null,
':id' => $senderId,
':cid' => $customerId,
]);
if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404);
} else {
$stmt = $this->pdo->prepare("INSERT INTO `$table` (`customer_id`,`label`,`from_name`,`from_email`,`reply_to`,`created_at`,`updated_at`) VALUES (:cid,:label,:fname,:fmail,:reply,NOW(),NOW())");
$stmt->execute([
':cid' => $customerId,
':label' => $label,
':fname' => $fromName ?: null,
':fmail' => $fromEmail,
':reply' => $replyTo ?: null,
]);
$senderId = (int)$this->pdo->lastInsertId();
}
$sender = $this->fetchSenderRow($customerId, $senderId);
$this->respond(['ok' => true, 'sender' => $sender]);
}
private function handleAccountSenderDelete(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
$senderId = (int)($this->in['sender_id'] ?? 0);
if ($senderId <= 0) $this->fail('Ungültige Sender-ID', null, 422);
$table = $this->senderTable();
$stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
$stmt->execute([':id' => $senderId, ':cid' => $customerId]);
if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404);
$this->respond(['ok' => true, 'deleted' => true]);
}
private function handleDashboardMetrics(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$counts = $this->fetchResourceCounts($customerId);
$usage = $this->listTemplateUsage($customerId);
$this->respond([
'ok' => true,
'counts' => $counts,
'usage' => $usage,
]);
}
private function handleDashboardResetUsage(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$raw = $this->in['template_id'] ?? $this->in['templates'] ?? null;
$ids = $this->extractIdList($raw);
if (!$ids) {
$this->fail('template_id required', null, 422);
}
$this->resetTemplateUsage($customerId, $ids);
$this->respond(['ok' => true]);
}
private function handleDownloadFile(string $type): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
$bridgeSetup = $this->getBridgeSetupData($customerId);
$content = $this->loadDownloadTemplate($type);
if ($type === 'bridge') {
$content = $this->populateBridgeDownload($content, $settings, $bridgeSetup);
} else {
$content = $this->populateSenderDownload($content, $settings);
}
$this->respond([
'ok' => true,
'file_name' => $type === 'bridge' ? 'emailtemplate_bridge.php' : 'emailtemplate_sender.php',
'content' => base64_encode($content),
]);
}
private function handleAccountBridgeSetupGet(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$setup = $this->getBridgeSetupData($customerId);
$this->respond(['ok' => true, 'setup' => $setup]);
}
private function handleAccountBridgeSetupSave(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$tables = $this->normalizeBridgeTables($this->in['tables'] ?? $this->in['bridge_tables'] ?? []);
$mode = strtolower((string)($this->in['mode'] ?? $this->in['db_mode'] ?? 'direct'));
$direct = [
'host' => trim((string)($this->in['direct_host'] ?? '')),
'port' => (int)($this->in['direct_port'] ?? 3306),
'database' => trim((string)($this->in['direct_database'] ?? $this->in['direct_db'] ?? '')),
'user' => trim((string)($this->in['direct_user'] ?? '')),
'password' => (string)($this->in['direct_password'] ?? ''),
'charset' => trim((string)($this->in['direct_charset'] ?? '')) ?: 'utf8mb4',
];
$config = [
'file' => trim((string)($this->in['config_file'] ?? '')),
'base' => (string)($this->in['config_base'] ?? ''),
'host_key' => (string)($this->in['config_host_key'] ?? ''),
'port_key' => (string)($this->in['config_port_key'] ?? ''),
'database_key' => (string)($this->in['config_database_key'] ?? ''),
'user_key' => (string)($this->in['config_user_key'] ?? ''),
'password_key' => (string)($this->in['config_password_key'] ?? ''),
'charset_key' => (string)($this->in['config_charset_key'] ?? ''),
];
$fonts = [
'dir' => (string)($this->in['fonts_dir'] ?? ''),
'url_base' => (string)($this->in['fonts_url_base'] ?? ''),
'urls' => (string)($this->in['fonts_urls'] ?? ''),
];
$setup = $this->sanitizeBridgeSetup([
'tables' => $tables,
'mode' => $mode,
'direct' => $direct,
'config' => $config,
'fonts' => $fonts,
]);
$stored = $this->saveBridgeSetupData($customerId, $setup);
$this->respond(['ok' => true, 'setup' => $stored]);
}
private function handleAccountBridgeTest(): void
{
$user = $this->requireAuth();
$this->ensureRole($user, ['owner', 'admin']);
$customerId = (int)($user['customer_id'] ?? 0);
$bridgeUrl = trim((string)($this->in['bridge_url'] ?? ''));
$bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
if ($bridgeUrl === '' || $bridgeToken === '') {
$settings = $this->getCustomerSettings($customerId);
if ($bridgeUrl === '') $bridgeUrl = (string)($settings['bridge_url'] ?? '');
if ($bridgeToken === '') $bridgeToken = (string)($settings['bridge_token'] ?? '');
}
if ($bridgeUrl === '' || $bridgeToken === '') {
$this->fail('Bridge nicht konfiguriert', null, 422);
}
try {
$schema = $this->fetchPlaceholderSchema($bridgeUrl, $bridgeToken, 0);
} catch (Throwable $e) {
$this->fail('Bridge request failed', $e->getMessage(), 502);
return;
}
$this->respond([
'ok' => true,
'tables' => $schema['tables'] ?? [],
'setup_hint' => $schema['setup_hint'] ?? null,
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
]);
}
private function handleAccountFontsList(): void
{
$user = $this->requireAuth();
$customerId = (int)($user['customer_id'] ?? 0);
$setup = $this->getBridgeSetupData($customerId);
$fonts = $setup['fonts'] ?? [];
$payload = $this->buildFontCatalog($fonts);
$this->respond([
'ok' => true,
'fonts' => $payload['fonts'],
'font_face_css' => $payload['font_face_css'],
]);
}
private function handleDebugPhpInfo(): void
{
$user = $this->requireAuth();
$this->ensureDebugUser($user);
$this->ensureDebugEnv();
ob_start();
phpinfo(INFO_GENERAL | INFO_CONFIGURATION | INFO_MODULES | INFO_ENVIRONMENT);
$html = ob_get_clean() ?: '';
$this->respond(['ok' => true, 'html' => $html]);
}
private function handleDebugLogsList(): void
{
$user = $this->requireAuth();
$this->ensureDebugUser($user);
$this->ensureDebugEnv();
$dir = $this->debugDir();
if (!is_dir($dir)) {
$this->respond(['ok' => true, 'items' => []]);
}
$items = [];
foreach (glob($dir . '/*.log') ?: [] as $file) {
$items[] = [
'name' => basename($file),
'size' => filesize($file) ?: 0,
'updated_at' => date(DATE_ATOM, filemtime($file) ?: time()),
];
}
$this->respond(['ok' => true, 'items' => $items]);
}
private function handleDebugLogsRead(): void
{
$user = $this->requireAuth();
$this->ensureDebugUser($user);
$this->ensureDebugEnv();
$name = trim((string)($this->in['name'] ?? ''));
if ($name === '') {
$this->fail('Log name required', null, 422);
}
$name = preg_replace('/[^a-zA-Z0-9_\.\-]/', '', $name) ?: '';
if ($name === '' || strpos($name, '..') !== false) {
$this->fail('Invalid log name', null, 422);
}
$file = $this->debugDir() . '/' . $name;
if (!is_file($file)) {
$this->fail('Log not found', null, 404);
}
$content = (string)file_get_contents($file);
$this->respond(['ok' => true, 'content' => $content]);
}
private function handleDebugLogWrite(): void
{
$user = $this->requireAuth();
$this->ensureDebugUser($user);
$this->ensureDebugEnv();
$name = trim((string)($this->in['name'] ?? 'ui_editor_dirty.log'));
$line = $this->in['line'] ?? '';
$append = (int)($this->in['append'] ?? 1) === 1;
if ($name === '') {
$this->fail('Log name required', null, 422);
}
$name = preg_replace('/[^a-zA-Z0-9_\.\-]/', '', $name) ?: 'debug.log';
if (strpos($name, '..') !== false) {
$this->fail('Invalid log name', null, 422);
}
$payload = $line;
if (is_array($payload) || is_object($payload)) {
$payload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
$payload = (string)$payload;
}
debug_log_write($name, $payload, [
'append' => $append,
'json' => false,
'newline' => true,
]);
$this->respond(['ok' => true, 'name' => $name]);
}
private function resolveBridgeConfig(?int $customerId): array
{
$fileConf = $this->conf['placeholders']['bridge'] ?? [];
$settings = $customerId ? $this->getCustomerSettings($customerId) : [];
$url = $settings['bridge_url'] ?? ($fileConf['url'] ?? '');
$token = $settings['bridge_token'] ?? ($fileConf['token'] ?? '');
$ttl = $fileConf['cache_ttl'] ?? 300;
return ['url' => $url, 'token' => $token, 'cache_ttl' => $ttl];
}
private function getCustomerSettings(int $customerId): array
{
if ($customerId <= 0) return [];
$this->ensureCustomerSettingsTableExists();
$table = $this->customerSettingsTable();
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :id LIMIT 1");
$stmt->execute([':id' => $customerId]);
$row = $stmt->fetch();
return $row ? $this->formatCustomerSettingsRow($row) : [];
}
private function getBridgeSetupData(int $customerId): array
{
$settings = $this->getCustomerSettings($customerId);
$setup = $settings['bridge_setup'] ?? $this->defaultBridgeSetup();
if ((!$setup['tables'] || !count($setup['tables'])) && !empty($settings['bridge_tables'])) {
$setup['tables'] = $this->normalizeBridgeTables($settings['bridge_tables']);
}
return $setup;
}
private function saveCustomerSettings(int $customerId, array $data): array
{
if ($customerId <= 0) return [];
$this->ensureCustomerSettingsTableExists();
$allowed = [
'bridge_url',
'bridge_token',
'sender_token',
'external_api_token',
'editor_default',
'bridge_tables',
'bridge_setup',
'versions_retention',
'smtp_enabled',
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_pass',
'smtp_secure',
'smtp_from_email',
'smtp_from_name',
'smtp_reply_to',
];
$fields = array_intersect_key($data, array_flip($allowed));
if (!$fields) return $this->getCustomerSettings($customerId);
if (array_key_exists('versions_retention', $fields)) {
try {
$columns = $this->tableColumns($this->customerSettingsTable());
if (!in_array('versions_retention', $columns, true)) {
$sql = 'ALTER TABLE `' . $this->customerSettingsTable() . '` ADD COLUMN `versions_retention` int(10) unsigned DEFAULT 0';
$this->pdo->exec($sql);
}
} catch (Throwable $e) {
// Falls die Spalte nicht angelegt werden kann, Einstellung ignorieren.
unset($fields['versions_retention']);
}
}
if (array_key_exists('bridge_tables', $fields)) {
$normalized = $this->normalizeBridgeTables($fields['bridge_tables']);
$fields['bridge_tables'] = $normalized ? $this->encodeBridgeTables($normalized) : null;
}
if (array_key_exists('bridge_setup', $fields)) {
$fields['bridge_setup'] = $this->encodeBridgeSetup($fields['bridge_setup']);
}
$fields['customer_id'] = $customerId;
$columns = array_keys($fields);
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
$placeholders = implode(',', array_map(fn($c) => ":$c", $columns));
$updates = [];
foreach ($columns as $col) {
if ($col === 'customer_id') continue;
$updates[] = "`$col` = VALUES(`$col`)";
}
$table = $this->customerSettingsTable();
$sql = "INSERT INTO `$table` ($insertCols) VALUES ($placeholders) ON DUPLICATE KEY UPDATE " . implode(',', $updates);
$stmt = $this->pdo->prepare($sql);
foreach ($fields as $col => $value) {
$stmt->bindValue(":$col", $value);
}
$stmt->execute();
return $this->getCustomerSettings($customerId);
}
private function saveBridgeSetupData(int $customerId, array $setup): array
{
$settings = $this->saveCustomerSettings($customerId, ['bridge_setup' => $setup]);
return $settings['bridge_setup'] ?? $this->defaultBridgeSetup();
}
private function ensureSettingsTokens(int $customerId, array $settings): array
{
if ($customerId <= 0) return $settings;
$changed = false;
foreach (['bridge_token', 'sender_token', 'external_api_token'] as $key) {
if (empty($settings[$key])) {
$settings[$key] = $this->generateToken();
$changed = true;
}
}
if ($changed) {
$settings = $this->saveCustomerSettings($customerId, $settings);
}
return $settings;
}
private function formatCustomerSettingsRow(array $row): array
{
if (array_key_exists('bridge_tables', $row)) {
$row['bridge_tables'] = $this->decodeBridgeTables($row['bridge_tables']);
} else {
$row['bridge_tables'] = [];
}
if (array_key_exists('bridge_setup', $row)) {
$row['bridge_setup'] = $this->decodeBridgeSetup($row['bridge_setup']);
} else {
$row['bridge_setup'] = $this->defaultBridgeSetup();
}
if (empty($row['editor_default'])) {
$row['editor_default'] = 'grapesjs';
}
if (!isset($row['versions_retention']) || $row['versions_retention'] === '') {
$row['versions_retention'] = 0;
} else {
$row['versions_retention'] = max(0, (int)$row['versions_retention']);
}
if (!isset($row['smtp_enabled'])) {
$row['smtp_enabled'] = 0;
} else {
$row['smtp_enabled'] = (int)$row['smtp_enabled'] ? 1 : 0;
}
if (isset($row['smtp_port'])) {
$row['smtp_port'] = (int)$row['smtp_port'];
} else {
$row['smtp_port'] = 0;
}
$row['smtp_pass_set'] = !empty($row['smtp_pass']);
unset($row['smtp_pass']);
return $row;
}
private function normalizeBridgeTables($input): array
{
$items = [];
if (is_string($input)) {
$items = preg_split('/[\s,]+/', $input) ?: [];
} elseif (is_array($input)) {
$items = $input;
} elseif ($input === null) {
return [];
} else {
$items = [$input];
}
$items = array_map(static function ($value) {
return trim((string)$value);
}, $items);
$items = array_filter($items, fn($value) => $value !== '');
return array_values(array_unique($items));
}
private function encodeBridgeTables(?array $tables): ?string
{
if (empty($tables)) return null;
return json_encode(array_values($tables), JSON_UNESCAPED_SLASHES);
}
private function decodeBridgeTables($stored): array
{
if (is_array($stored)) {
return $this->normalizeBridgeTables($stored);
}
$str = (string)$stored;
if ($str === '') return [];
$decoded = json_decode($str, true);
if (is_array($decoded)) {
return $this->normalizeBridgeTables($decoded);
}
return $this->normalizeBridgeTables($str);
}
private function encodeBridgeSetup($setup)
{
if (is_array($setup)) {
$setup = $this->sanitizeBridgeSetup($setup);
return json_encode($setup, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
if (is_string($setup)) {
return $setup;
}
return null;
}
private function decodeBridgeSetup($stored): array
{
if (is_array($stored)) {
return $this->sanitizeBridgeSetup($stored);
}
$str = (string)$stored;
if ($str === '') {
return $this->defaultBridgeSetup();
}
$decoded = json_decode($str, true);
if (is_array($decoded)) {
return $this->sanitizeBridgeSetup($decoded);
}
return $this->defaultBridgeSetup();
}
private function defaultBridgeSetup(): array
{
return [
'tables' => [],
'mode' => 'direct',
'direct' => [
'host' => '',
'port' => 3306,
'database' => '',
'user' => '',
'password' => '',
'charset' => 'utf8mb4',
],
'config' => [
'file' => '',
'base' => '',
'host_key' => '',
'port_key' => '',
'database_key' => '',
'user_key' => '',
'password_key' => '',
'charset_key' => '',
],
'fonts' => [
'dir' => '',
'url_base' => '',
'urls' => '',
],
];
}
private function sanitizeBridgeSetup(?array $input): array
{
$defaults = $this->defaultBridgeSetup();
if (!is_array($input)) {
return $defaults;
}
$mode = strtolower((string)($input['mode'] ?? 'direct'));
if (!in_array($mode, ['direct', 'config'], true)) {
$mode = 'direct';
}
$tables = $this->normalizeBridgeTables($input['tables'] ?? []);
$direct = $input['direct'] ?? [];
$config = $input['config'] ?? [];
$fonts = $input['fonts'] ?? [];
$sanitizePath = function ($value) {
$value = trim((string)$value);
if ($value === '') return '';
return preg_replace('/[^a-zA-Z0-9_\.\-]/', '', $value) ?: '';
};
$sanitizeDir = function ($value) {
$value = trim((string)$value);
if ($value === '') return '';
return trim(preg_replace('/[^a-zA-Z0-9_\.\-\/\\\\:\s]/', '', $value)) ?: '';
};
$sanitizeUrl = function ($value) {
$value = trim((string)$value);
if ($value === '') return '';
$value = preg_replace('/[\x00-\x1f]/', '', $value);
return $value;
};
$sanitizeText = function ($value) {
$value = (string)$value;
$value = preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f]/', '', $value);
return $value;
};
$result = [
'tables' => $tables,
'mode' => $mode,
'direct' => [
'host' => trim((string)($direct['host'] ?? $defaults['direct']['host'])),
'port' => (int)($direct['port'] ?? $defaults['direct']['port']),
'database' => trim((string)($direct['database'] ?? $defaults['direct']['database'])),
'user' => trim((string)($direct['user'] ?? $defaults['direct']['user'])),
'password' => (string)($direct['password'] ?? $defaults['direct']['password']),
'charset' => trim((string)($direct['charset'] ?? $defaults['direct']['charset'])) ?: 'utf8mb4',
],
'config' => [
'file' => trim((string)($config['file'] ?? '')),
'base' => $sanitizePath($config['base'] ?? ''),
'host_key' => $sanitizePath($config['host_key'] ?? ''),
'port_key' => $sanitizePath($config['port_key'] ?? ''),
'database_key' => $sanitizePath($config['database_key'] ?? ''),
'user_key' => $sanitizePath($config['user_key'] ?? ''),
'password_key' => $sanitizePath($config['password_key'] ?? ''),
'charset_key' => $sanitizePath($config['charset_key'] ?? ''),
],
'fonts' => [
'dir' => $sanitizeDir($fonts['dir'] ?? ''),
'url_base' => $sanitizeUrl($fonts['url_base'] ?? ''),
'urls' => $sanitizeText($fonts['urls'] ?? ''),
],
];
if ($result['direct']['port'] <= 0) {
$result['direct']['port'] = 3306;
}
return $result;
}
private function buildFontCatalog(array $fonts): array
{
$items = [];
$faces = [];
$seen = [];
$groups = [];
$allowed = ['woff2', 'woff', 'ttf', 'otf'];
$addGroup = function (string $family, array $sources) use (&$items, &$faces, &$seen, $allowed) {
$family = trim($family);
if ($family === '') return;
if (!empty($seen[strtolower($family)])) return;
$srcParts = [];
foreach ($allowed as $ext) {
if (!empty($sources[$ext])) {
$url = $sources[$ext];
$srcParts[] = "url('{$url}') format('{$ext}')";
}
}
if (!$srcParts) return;
$safeFamily = str_replace("'", "\\'", $family);
$faces[] = "@font-face{font-family:'{$safeFamily}';font-style:normal;font-weight:400;src:" . implode(',', $srcParts) . ";}";
$items[] = [
'label' => $family,
'value' => "'" . $safeFamily . "', sans-serif",
];
$seen[strtolower($family)] = true;
};
$inferFamily = function (string $name): string {
$name = preg_replace('/[-_]+/', ' ', $name);
$name = preg_replace('/\s+/', ' ', $name);
return trim($name);
};
$dir = trim((string)($fonts['dir'] ?? ''));
$base = trim((string)($fonts['url_base'] ?? ''));
if ($dir !== '' && $base !== '' && is_dir($dir)) {
$base = rtrim($base, '/');
$pattern = $dir . '/*.{woff2,woff,ttf,otf,WOFF2,WOFF,TTF,OTF}';
$files = glob($pattern, GLOB_BRACE) ?: [];
foreach ($files as $file) {
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($ext, $allowed, true)) continue;
$name = pathinfo($file, PATHINFO_FILENAME);
$groups[$name][$ext] = $base . '/' . basename($file);
}
}
$rawUrls = (string)($fonts['urls'] ?? '');
if ($rawUrls !== '') {
$lines = preg_split('/\r?\n/', $rawUrls) ?: [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') continue;
$family = '';
$url = '';
if (strpos($line, '|') !== false) {
[$family, $url] = array_map('trim', explode('|', $line, 2));
} elseif (strpos($line, '=') !== false) {
[$family, $url] = array_map('trim', explode('=', $line, 2));
} else {
$url = $line;
}
if ($url === '') continue;
$path = parse_url($url, PHP_URL_PATH);
if (!$path) continue;
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (!in_array($ext, $allowed, true)) continue;
$name = pathinfo($path, PATHINFO_FILENAME);
$family = $family !== '' ? $family : $inferFamily($name);
if ($family === '') continue;
$groups[$family][$ext] = $url;
}
}
foreach ($groups as $family => $sources) {
$display = $inferFamily($family);
$addGroup($display, $sources);
}
return [
'fonts' => $items,
'font_face_css' => implode("\n", $faces),
];
}
private function customerSettingsTable(): string
{
return 'emailtemplate_customer_settings';
}
private function ensureCustomerSettingsTableExists(): void
{
$table = $this->customerSettingsTable();
$justCreated = false;
if (!$this->tableExists($table)) {
try {
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `$table` (
`customer_id` int(10) unsigned NOT NULL,
`bridge_url` varchar(500) DEFAULT NULL,
`bridge_token` varchar(255) DEFAULT NULL,
`sender_token` varchar(255) DEFAULT NULL,
`external_api_token` varchar(255) DEFAULT NULL,
`editor_default` varchar(32) DEFAULT NULL,
`bridge_tables` text DEFAULT NULL,
`versions_retention` int(10) unsigned DEFAULT 0,
`smtp_enabled` tinyint(1) DEFAULT 0,
`smtp_host` varchar(255) DEFAULT NULL,
`smtp_port` int(10) unsigned DEFAULT NULL,
`smtp_user` varchar(255) DEFAULT NULL,
`smtp_pass` varchar(255) DEFAULT NULL,
`smtp_secure` varchar(16) DEFAULT NULL,
`smtp_from_email` varchar(255) DEFAULT NULL,
`smtp_from_name` varchar(255) DEFAULT NULL,
`smtp_reply_to` varchar(255) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
SQL;
$this->pdo->exec($sql);
$this->tableExistsCache[$table] = true;
$justCreated = true;
} catch (Throwable $e) {
$this->fail('Customer-Settings Tabelle fehlt und konnte nicht erstellt werden', $e->getMessage(), 500);
}
}
if ($justCreated) {
return;
}
$this->ensureCustomerSettingsColumns($table);
}
private function ensureCustomerSettingsColumns(string $table): void
{
try {
$columns = $this->tableColumns($table);
} catch (Throwable $e) {
$this->fail('Customer-Settings Tabelle konnte nicht gelesen werden', $e->getMessage(), 500);
return;
}
$missing = [];
if (!in_array('bridge_tables', $columns, true)) {
$missing[] = 'ADD COLUMN `bridge_tables` text DEFAULT NULL';
}
if (!in_array('bridge_setup', $columns, true)) {
$missing[] = 'ADD COLUMN `bridge_setup` longtext DEFAULT NULL';
}
if (!in_array('editor_default', $columns, true)) {
$missing[] = 'ADD COLUMN `editor_default` varchar(32) DEFAULT NULL';
}
if (!in_array('versions_retention', $columns, true)) {
$missing[] = 'ADD COLUMN `versions_retention` int(10) unsigned DEFAULT 0';
}
if (!in_array('smtp_enabled', $columns, true)) {
$missing[] = 'ADD COLUMN `smtp_enabled` tinyint(1) DEFAULT 0';
}
if (!in_array('smtp_host', $columns, true)) {
$missing[] = 'ADD COLUMN `smtp_host` varchar(255) DEFAULT NULL';
}
if (!in_array('smtp_port', $columns, true)) {
$missing[] = 'ADD COLUMN `smtp_port` int(10) unsigned DEFAULT NULL';
}
if (!in_array('smtp_user', $columns, true)) {
$missing[] = 'ADD COLUMN `smtp_user` varchar(255) DEFAULT NULL';
}
if (!in_array('smtp_pass', $columns, true)) {
$missing[] = 'ADD COLUMN `smtp_pass` varchar(255) DEFAULT NULL';
}
if (!in_array('smtp_secure', $columns, true)) {
$missing[] = 'ADD COLUMN `smtp_secure` varchar(16) DEFAULT NULL';
}
if (!in_array('smtp_from_email', $columns, true)) {
$missing[] = 'ADD COLUMN `smtp_from_email` varchar(255) DEFAULT NULL';
}
if (!in_array('smtp_from_name', $columns, true)) {
$missing[] = 'ADD COLUMN `smtp_from_name` varchar(255) DEFAULT NULL';
}
if (!in_array('smtp_reply_to', $columns, true)) {
$missing[] = 'ADD COLUMN `smtp_reply_to` varchar(255) DEFAULT NULL';
}
if (!$missing) {
return;
}
try {
$sql = 'ALTER TABLE `' . $table . '` ' . implode(', ', $missing);
$this->pdo->exec($sql);
} catch (Throwable $e) {
$this->fail('Customer-Settings Tabelle konnte nicht aktualisiert werden', $e->getMessage(), 500);
}
}
private function generateToken(int $length = 48): string
{
return rtrim(strtr(base64_encode(random_bytes($length)), '+/', '-_'), '=');
}
private function generateReadablePassword(int $length = 12): string
{
$bytes = bin2hex(random_bytes($length));
return substr($bytes, 0, $length);
}
private function authUserColumns(): array
{
$db = $this->conf['auth']['db'] ?? [];
return [
'table' => $db['table'] ?? 'customer_users',
'col_id' => $db['col_id'] ?? 'id',
'col_email' => $db['col_user'] ?? 'email',
'col_pass' => $db['col_pass'] ?? 'password_hash',
'col_name' => $db['col_name'] ?? 'name',
'col_role' => $db['col_role'] ?? 'role',
'col_status' => $db['col_status'] ?? 'is_active',
'col_customer' => $db['customer_fk'] ?? 'customer_id',
];
}
private function ensureAuthUserListSortColumn(): void
{
if (!$this->pdo) {
return;
}
$cols = $this->authUserColumns();
$table = $cols['table'];
try {
$columns = $this->tableColumns($table);
} catch (Throwable $e) {
$this->fail('User-Tabelle konnte nicht gelesen werden', $e->getMessage(), 500);
return;
}
if (in_array('list_sort', $columns, true)) {
return;
}
try {
$sql = 'ALTER TABLE `' . $table . '` ADD COLUMN `list_sort` varchar(32) DEFAULT NULL';
$this->pdo->exec($sql);
} catch (Throwable $e) {
$this->fail('User-Tabelle konnte nicht aktualisiert werden', $e->getMessage(), 500);
}
}
private function getUserListSort(array $user, int $customerId): ?string
{
if (!$this->pdo) {
return null;
}
$userId = (int)($user['id'] ?? 0);
if ($userId <= 0) {
return null;
}
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
if (!$this->columnExists($dbCols, 'list_sort')) {
return null;
}
$where = sprintf('`%s` = :id', $cols['col_id']);
$params = [':id' => $userId];
if ($customerId > 0 && $this->columnExists($dbCols, $cols['col_customer'])) {
$where .= sprintf(' AND `%s` = :cid', $cols['col_customer']);
$params[':cid'] = $customerId;
}
$sql = sprintf('SELECT `list_sort` FROM `%s` WHERE %s LIMIT 1', $table, $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
if (!$row) {
return null;
}
return $row['list_sort'] !== null ? (string)$row['list_sort'] : null;
}
private function updateUserListSort(array $user, int $customerId, ?string $value): void
{
if (!$this->pdo) {
return;
}
$userId = (int)($user['id'] ?? 0);
if ($userId <= 0) {
return;
}
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
if (!$this->columnExists($dbCols, 'list_sort')) {
return;
}
$where = sprintf('`%s` = :id', $cols['col_id']);
$params = [
':id' => $userId,
':value' => $value,
];
if ($customerId > 0 && $this->columnExists($dbCols, $cols['col_customer'])) {
$where .= sprintf(' AND `%s` = :cid', $cols['col_customer']);
$params[':cid'] = $customerId;
}
$sql = sprintf('UPDATE `%s` SET `list_sort` = :value WHERE %s LIMIT 1', $table, $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
}
private function resolveUserListSort(array $user, int $customerId, string $fallback = ''): string
{
$value = $fallback !== '' ? $fallback : (string)($this->getUserListSort($user, $customerId) ?? '');
$allowed = ['created_asc', 'name_asc', 'name_desc', 'updated_desc'];
if ($value === '' || !in_array($value, $allowed, true)) {
return 'created_asc';
}
return $value;
}
private function ensureAuthUserHydrated(array $user): array
{
$role = (string)($user['role'] ?? '');
$hasOwnerFlag = isset($user['permissions']['owner']);
$hasCustomer = (int)($user['customer_id'] ?? 0) > 0;
if ($role !== '' && $hasOwnerFlag && $hasCustomer) {
return $user;
}
$userId = (int)($user['id'] ?? 0);
if ($userId <= 0 || !$this->pdo) {
if ($role === '') $user['role'] = 'user';
if (!$hasOwnerFlag) {
$user['permissions']['owner'] = ($user['role'] ?? '') === 'owner';
}
return $user;
}
try {
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
$where = sprintf('`%s` = :id', $cols['col_id']);
$params = [':id' => $userId];
$customerId = (int)($user['customer_id'] ?? 0);
if ($customerId > 0 && $this->columnExists($dbCols, $cols['col_customer'])) {
$where .= sprintf(' AND `%s` = :cid', $cols['col_customer']);
$params[':cid'] = $customerId;
}
$sql = sprintf('SELECT * FROM `%s` WHERE %s LIMIT 1', $table, $where);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
if ($row) {
if (isset($row[$cols['col_role']])) {
$roleValue = $this->sanitizeRole((string)$row[$cols['col_role']]);
$user['role'] = $roleValue;
$user['permissions']['owner'] = ($roleValue === 'owner');
} elseif ($role === '') {
$user['role'] = 'user';
$user['permissions']['owner'] = false;
}
if ((!$hasCustomer || $customerId <= 0) && isset($row[$cols['col_customer']])) {
$user['customer_id'] = (int)$row[$cols['col_customer']];
}
if (empty($user['name']) && $this->columnExists($dbCols, $cols['col_name']) && isset($row[$cols['col_name']])) {
$user['name'] = (string)$row[$cols['col_name']];
}
if (empty($user['email']) && isset($row[$cols['col_email']])) {
$user['email'] = (string)$row[$cols['col_email']];
}
$_SESSION['auth'] = array_merge($_SESSION['auth'] ?? [], $user);
} else {
if ($role === '') $user['role'] = 'user';
if (!$hasOwnerFlag) {
$user['permissions']['owner'] = ($user['role'] ?? '') === 'owner';
}
}
} catch (Throwable $e) {
if ($role === '') $user['role'] = 'user';
if (!$hasOwnerFlag) {
$user['permissions']['owner'] = ($user['role'] ?? '') === 'owner';
}
}
return $user;
}
private function columnExists(array $columns, string $name): bool
{
if ($name === '') return false;
return in_array($name, $columns, true);
}
private function sanitizeRole(string $role): string
{
$role = strtolower($role);
$valid = ['owner', 'admin', 'editor', 'viewer'];
return in_array($role, $valid, true) ? $role : 'user';
}
private function assertEmailUnique(string $email, int $customerId, ?int $excludeId): void
{
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
$conditions = [sprintf('`%s` = :email', $cols['col_email'])];
if ($this->columnExists($dbCols, $cols['col_customer'])) {
$conditions[] = sprintf('`%s` = :cid', $cols['col_customer']);
}
if ($excludeId) {
$conditions[] = sprintf('`%s` != :exclude', $cols['col_id']);
}
$sql = sprintf('SELECT COUNT(*) FROM `%s` WHERE %s', $table, implode(' AND ', $conditions));
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':email', $email);
if ($this->columnExists($dbCols, $cols['col_customer'])) {
$stmt->bindValue(':cid', $customerId);
}
if ($excludeId) $stmt->bindValue(':exclude', $excludeId, PDO::PARAM_INT);
$stmt->execute();
if ((int)$stmt->fetchColumn() > 0) {
$this->fail('E-Mail-Adresse ist bereits vergeben', null, 422);
}
}
private function fetchUserRow(int $userId, int $customerId): array
{
$cols = $this->authUserColumns();
$table = $cols['table'];
$sql = sprintf(
'SELECT * FROM `%s` WHERE `%s` = :id AND `%s` = :cid LIMIT 1',
$table,
$cols['col_id'],
$cols['col_customer']
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => $userId, ':cid' => $customerId]);
$row = $stmt->fetch();
if (!$row) $this->fail('Nutzer nicht gefunden', null, 404);
return $this->formatUserOutput([
'user_id' => $row[$cols['col_id']],
'name' => $row[$cols['col_name']] ?? '',
'email' => $row[$cols['col_email']] ?? '',
'role' => $row[$cols['col_role']] ?? 'user',
'is_active' => $row[$cols['col_status']] ?? 1,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
]);
}
private function senderTable(): string
{
return 'emailtemplate_sender_identities';
}
private function fetchSenderRow(int $customerId, int $senderId): array
{
if ($customerId <= 0 || $senderId <= 0) {
$this->fail('Absender nicht gefunden', null, 404);
}
$table = $this->senderTable();
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
$stmt->execute([':id' => $senderId, ':cid' => $customerId]);
$row = $stmt->fetch();
if (!$row) $this->fail('Absender nicht gefunden', null, 404);
return $this->formatSenderRow($row);
}
private function formatSenderRow(array $row): array
{
return [
'id' => (int)($row['id'] ?? 0),
'label' => $row['label'] ?? '',
'from_name' => $row['from_name'] ?? '',
'from_email' => $row['from_email'] ?? '',
'reply_to' => $row['reply_to'] ?? '',
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}
private function formatUserOutput(array $row): array
{
return [
'id' => (int)($row['user_id'] ?? $row['id'] ?? 0),
'name' => $row['name'] ?? '',
'email' => $row['email'] ?? '',
'role' => $row['role'] ?? 'user',
'is_active' => (int)($row['is_active'] ?? 1),
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}
private function countOwners(int $customerId, ?int $excludeId = null): int
{
$cols = $this->authUserColumns();
$table = $cols['table'];
$dbCols = $this->tableColumns($table);
$conditions = [
sprintf('`%s` = :cid', $cols['col_customer']),
sprintf('`%s` = :role', $cols['col_role']),
];
if ($this->columnExists($dbCols, $cols['col_status'])) {
$conditions[] = sprintf('`%s` = 1', $cols['col_status']);
}
if ($excludeId) {
$conditions[] = sprintf('`%s` != :exclude', $cols['col_id']);
}
$sql = sprintf('SELECT COUNT(*) FROM `%s` WHERE %s', $table, implode(' AND ', $conditions));
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':cid', $customerId);
$stmt->bindValue(':role', 'owner');
if ($excludeId) $stmt->bindValue(':exclude', $excludeId, PDO::PARAM_INT);
$stmt->execute();
return (int)$stmt->fetchColumn();
}
private function verifyUserPasswordValue(string $input, string $stored): bool
{
if (preg_match('~^\$2[aby]\$~', $stored) || strpos($stored, '$argon2') === 0) return password_verify($input, $stored);
$legacy = strtolower($this->conf['auth']['db']['legacy'] ?? '');
if ($legacy === 'md5') return hash_equals($stored, md5($input));
if ($legacy === 'sha1') return hash_equals($stored, sha1($input));
if (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored);
return hash_equals($stored, $input);
}
private function hashUserPassword(string $password): string
{
return password_hash($password, PASSWORD_DEFAULT);
}
private function ensureOwner(array &$user): void
{
$this->ensureRole($user, ['owner']);
}
private function ensureRole(array &$user, array $roles): void
{
$user = $this->ensureAuthUserHydrated($user);
$role = strtolower((string)($user['role'] ?? ''));
$allowed = array_values(array_unique(array_map('strtolower', $roles)));
if (!in_array($role, $allowed, true)) {
$this->fail('Unzureichende Berechtigungen', null, 403);
}
}
private function ensureDebugUser(array $user): void
{
$email = strtolower((string)($user['email'] ?? ''));
if ($email !== 'madmin@papa-kind-treff.info') {
$this->fail('Debug nicht erlaubt', null, 403);
}
}
private function ensureDebugEnv(): void
{
$env = strtolower((string)($this->conf['env'] ?? ''));
if ($env === 'staging') return;
if (defined('APP_ENV') && strtolower((string)APP_ENV) === 'staging') return;
$host = '';
if (!empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
$host = strtolower(trim(explode(',', (string)$_SERVER['HTTP_X_FORWARDED_HOST'])[0]));
} elseif (!empty($_SERVER['HTTP_HOST'])) {
$host = strtolower((string)$_SERVER['HTTP_HOST']);
}
if ($host !== '') {
$host = preg_replace('/:\\d+$/', '', $host);
if ($host === 'staging.emailtemplate.it') {
return;
}
}
$this->fail('Debug nur in Staging erlaubt', null, 403);
}
private function debugDir(): string
{
return dirname(__DIR__) . '/debug';
}
private function writeDebugLog(string $name, array $payload): void
{
$env = strtolower((string)($this->conf['env'] ?? ''));
if ($env !== 'staging' && (!defined('APP_ENV') || strtolower((string)APP_ENV) !== 'staging')) {
$host = '';
if (!empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
$host = strtolower(trim(explode(',', (string)$_SERVER['HTTP_X_FORWARDED_HOST'])[0]));
} elseif (!empty($_SERVER['HTTP_HOST'])) {
$host = strtolower((string)$_SERVER['HTTP_HOST']);
}
if ($host !== '') {
$host = preg_replace('/:\\d+$/', '', $host);
}
if ($host !== 'staging.emailtemplate.it') {
return;
}
}
$dir = $this->debugDir();
debug_log_write($name, $payload, [
'dir' => $dir,
'append' => false,
'json' => true,
'newline' => true,
]);
}
private function defaultApiBase(): string
{
$base = $this->conf['base_url'] ?? '';
if ($base === '' && !empty($_SERVER['HTTP_HOST'])) {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$base = $scheme . '://' . $_SERVER['HTTP_HOST'];
}
return $base ? rtrim($base, '/') : '';
}
private function loadDownloadTemplate(string $type): string
{
$map = [
'bridge' => 'emailtemplate_bridge.php',
'sender' => 'emailtemplate_sender.php',
];
if (!isset($map[$type])) {
$this->fail('Unknown download type', $type, 404);
}
$fileName = $map[$type];
$candidates = [
dirname(__DIR__) . '/download/' . $fileName,
__DIR__ . '/../download/' . $fileName,
dirname(__DIR__, 2) . '/download/' . $fileName,
];
foreach ($candidates as $candidate) {
if ($candidate && is_file($candidate)) {
$data = (string)file_get_contents($candidate);
if ($data !== '') {
return $data;
}
}
}
return $this->defaultDownloadTemplate($type);
}
private function defaultDownloadTemplate(string $type): string
{
if ($type === 'bridge') {
return $this->bridgeDownloadTemplate();
}
if ($type === 'sender') {
return $this->senderDownloadTemplate();
}
return '';
}
private function populateBridgeDownload(string $content, array $settings, array $setup): string
{
$token = (string)($settings['bridge_token'] ?? '');
$content = str_replace('REPLACE_WITH_SHARED_TOKEN', $token, $content);
$tables = array_values(array_filter(array_map('strval', $setup['tables'] ?? [])));
if (!$tables && !empty($settings['bridge_tables']) && is_array($settings['bridge_tables'])) {
$tables = array_values(array_filter(array_map('strval', $settings['bridge_tables'])));
}
$tablesExport = $this->exportPhpArray($tables);
$content = preg_replace(
"/'tables_allow'\\s*=>\\s*\\[\\s*\\],/",
"'tables_allow' => {$tablesExport},",
$content,
1
);
$mode = strtolower((string)($setup['mode'] ?? 'direct'));
if ($mode === 'direct') {
$dsn = $this->buildBridgeDsn($setup['direct'] ?? []);
if ($dsn !== '') {
$dsnValue = var_export($dsn, true);
$content = preg_replace("/'dsn'\\s*=>\\s*'[^']*',/", "'dsn' => {$dsnValue},", $content, 1);
}
$userValue = var_export((string)($setup['direct']['user'] ?? ''), true);
$passValue = var_export((string)($setup['direct']['password'] ?? ''), true);
$content = preg_replace("/'user'\\s*=>\\s*'[^']*',/", "'user' => {$userValue},", $content, 1);
$content = preg_replace("/'pass'\\s*=>\\s*'[^']*',/", "'pass' => {$passValue},", $content, 1);
}
$setupHint = [
'mode' => $setup['mode'] ?? 'direct',
'tables' => $tables,
'direct' => $setup['direct'] ?? [],
'config' => $setup['config'] ?? [],
];
$setupExport = $this->exportPhpValue($setupHint);
$content = str_replace("'setup_hint' => '__SETUP_HINT__',", "'setup_hint' => {$setupExport},", $content);
$snippet = $this->buildBridgeSetupSnippet($setup);
if (strpos($content, '// {{BRIDGE_DB_SETUP}}') !== false) {
$content = str_replace('// {{BRIDGE_DB_SETUP}}', $snippet, $content);
} else {
$content .= "\n" . $snippet;
}
return $content;
}
private function buildBridgeDsn(array $direct): string
{
$host = trim((string)($direct['host'] ?? ''));
$db = trim((string)($direct['database'] ?? ''));
if ($host === '' || $db === '') {
return '';
}
$port = (int)($direct['port'] ?? 3306);
$charset = trim((string)($direct['charset'] ?? 'utf8mb4')) ?: 'utf8mb4';
$parts = ["mysql:host={$host}"];
if ($port > 0) {
$parts[] = 'port=' . $port;
}
$parts[] = 'dbname=' . $db;
$parts[] = 'charset=' . $charset;
return implode(';', $parts);
}
private function buildBridgeSetupSnippet(array $setup): string
{
$mode = strtolower((string)($setup['mode'] ?? 'direct'));
if ($mode !== 'config') {
return "// Bridge DB Setup: direkte Angaben aus dem EmailTemplate-Backend.\n";
}
$config = $setup['config'] ?? [];
$file = trim((string)($config['file'] ?? ''));
if ($file === '') {
return "// Bridge DB Setup: Bitte im EmailTemplate-Backend eine Konfigurationsdatei angeben.\n";
}
$base = trim((string)($config['base'] ?? ''));
$paths = [
'host' => $this->bridgeCombinePath($base, $config['host_key'] ?? ''),
'port' => $this->bridgeCombinePath($base, $config['port_key'] ?? ''),
'database' => $this->bridgeCombinePath($base, $config['database_key'] ?? ''),
'user' => $this->bridgeCombinePath($base, $config['user_key'] ?? ''),
'password' => $this->bridgeCombinePath($base, $config['password_key'] ?? ''),
'charset' => $this->bridgeCombinePath($base, $config['charset_key'] ?? ''),
];
$defaults = [
'host' => $this->bridgeCombinePath($base, 'host'),
'port' => $this->bridgeCombinePath($base, 'port'),
'database' => $this->bridgeCombinePath($base, 'database'),
'user' => $this->bridgeCombinePath($base, 'user'),
'password' => $this->bridgeCombinePath($base, 'password'),
'charset' => $this->bridgeCombinePath($base, 'charset'),
];
foreach ($paths as $key => $value) {
if ($value === '') {
$paths[$key] = $defaults[$key];
}
}
$fileExpr = $this->bridgeConfigFileExpression($file);
$baseExport = var_export($base, true);
$lines = [];
$lines[] = '/** Bridge DB Setup: automatisch generiertes Mapping */';
$lines[] = '$bridgeConfigFile = ' . $fileExpr . ';';
$lines[] = '$bridgeConfigSource = is_file($bridgeConfigFile) ? include $bridgeConfigFile : null;';
$lines[] = 'if (is_array($bridgeConfigSource)) {';
$lines[] = ' $bridgeConfigData = $bridgeConfigSource;';
$lines[] = " if ({$baseExport} !== '') {";
$lines[] = " $bridgeConfigData = bridge_array_get($bridgeConfigSource, {$baseExport}, $bridgeConfigData);";
$lines[] = ' }';
$lines[] = ' if (!is_array($bridgeConfigData)) {';
$lines[] = ' $bridgeConfigData = (array)$bridgeConfigData;';
$lines[] = ' }';
$lines[] = ' $bridgeDbHost = (string)bridge_array_get($bridgeConfigData, ' . var_export($paths['host'], true) . ', \'\');';
$lines[] = ' $bridgeDbName = (string)bridge_array_get($bridgeConfigData, ' . var_export($paths['database'], true) . ', \'\');';
$lines[] = ' $bridgeDbUser = (string)bridge_array_get($bridgeConfigData, ' . var_export($paths['user'], true) . ', \'\');';
$lines[] = ' $bridgeDbPass = (string)bridge_array_get($bridgeConfigData, ' . var_export($paths['password'], true) . ', \'\');';
$lines[] = ' $bridgeDbCharset = (string)bridge_array_get($bridgeConfigData, ' . var_export($paths['charset'], true) . ', \'utf8mb4\');';
$lines[] = ' $bridgeDbPort = (int)bridge_array_get($bridgeConfigData, ' . var_export($paths['port'], true) . ', 3306);';
$lines[] = ' if ($bridgeDbHost !== \'\' && $bridgeDbName !== \'\') {';
$lines[] = ' $bridgeDsnParts = ["mysql:host={$bridgeDbHost}"];';
$lines[] = ' if ($bridgeDbPort > 0) {';
$lines[] = ' $bridgeDsnParts[] = "port={$bridgeDbPort}";';
$lines[] = ' }';
$lines[] = ' $bridgeDbCharset = $bridgeDbCharset ?: \'utf8mb4\';';
$lines[] = ' $bridgeDsnParts[] = "dbname={$bridgeDbName}";';
$lines[] = ' $bridgeDsnParts[] = "charset={$bridgeDbCharset}";';
$lines[] = ' $bridgeConfig[\'db\'][\'dsn\'] = implode(\';\', $bridgeDsnParts);';
$lines[] = ' }';
$lines[] = ' if ($bridgeDbUser !== \'\') {';
$lines[] = ' $bridgeConfig[\'db\'][\'user\'] = $bridgeDbUser;';
$lines[] = ' }';
$lines[] = ' if ($bridgeDbPass !== \'\') {';
$lines[] = ' $bridgeConfig[\'db\'][\'pass\'] = $bridgeDbPass;';
$lines[] = ' }';
$lines[] = '}';
return implode("\n", $lines) . "\n";
}
private function bridgeConfigFileExpression(string $file): string
{
if ($file === '') {
return var_export('', true);
}
if ($file[0] === '/' || preg_match('~^[A-Za-z]:\\\\~', $file)) {
return var_export($file, true);
}
$normalized = '/' . ltrim($file, '/');
return '__DIR__ . ' . var_export($normalized, true);
}
private function bridgeCombinePath(string $base, string $key): string
{
$base = trim($base, '.');
$key = trim($key, '.');
if ($base !== '' && $key !== '') {
return $base . '.' . $key;
}
if ($base !== '') {
return $base;
}
return $key;
}
private function populateSenderDownload(string $content, array $settings): string
{
$content = str_replace('REPLACE_WITH_SHARED_TOKEN', (string)($settings['sender_token'] ?? ''), $content);
$content = str_replace('REPLACE_WITH_TEMPLATE_API_TOKEN', (string)($settings['external_api_token'] ?? ''), $content);
$apiBase = $this->defaultApiBase();
if ($apiBase !== '') {
$content = str_replace('https://api.emailtemplate.it/external/render', rtrim($apiBase, '/') . '/external/render', $content);
}
return $content;
}
private function exportPhpArray(array $values): string
{
if (empty($values)) {
return '[]';
}
$escaped = array_map(function ($value) {
return "'" . str_replace(["\\", "'"], ["\\\\", "\\'"], $value) . "'";
}, $values);
return '[' . implode(', ', $escaped) . ']';
}
private function exportPhpValue($value): string
{
return var_export($value, true);
}
private function bridgeDownloadTemplate(): string
{
return <<<'PHP'
<?php
declare(strict_types=1);
/**
* EmailTemplate Bridge Schema-API für Quellsysteme.
*
* Diese Datei kann auf einer geschützten Quelle (z.B. Kundenserver) installiert werden.
* Sie liefert dem EmailTemplate-System Informationen über verfügbare Tabellen/Spalten,
* ohne direkten DB-Zugriff von außen zu erlauben.
*
* Sicherheit:
* - Authentifizierung per statischem Token (per Header oder Query-Parameter).
* - Optional können Host/IP-Checks ergänzt werden.
*
* Aktionen:
* - action=schema (Default) → Gibt Tabellen inkl. Spaltendefinition zurück.
* - action=ping → Kleiner Health-Check.
*
* Hinweise:
* - DB-Daten können direkt unten eingetragen oder aus einer separaten Datei geladen werden.
* - Der Token sollte für jede Installation eindeutig sein.
*/
$bridgeConfig = [
'token' => getenv('EMAILTEMPLATE_BRIDGE_TOKEN') ?: 'REPLACE_WITH_SHARED_TOKEN',
'db' => [
'dsn' => getenv('EMAILTEMPLATE_BRIDGE_DSN') ?: 'mysql:host=127.0.0.1;dbname=example;charset=utf8mb4',
'user' => getenv('EMAILTEMPLATE_BRIDGE_DB_USER') ?: 'root',
'pass' => getenv('EMAILTEMPLATE_BRIDGE_DB_PASS') ?: '',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
],
'tables_allow' => [], // optional whitelist: ['customers', 'orders']
];
$localOverride = __DIR__ . '/emailtemplate.bridge.conf.php';
if (is_file($localOverride)) {
$override = include $localOverride;
if (is_array($override)) {
$bridgeConfig = array_replace_recursive($bridgeConfig, $override);
}
}
function bridgeRespond($payload, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function bridgeRequireToken(array $config): void
{
$expected = (string)($config['token'] ?? '');
if ($expected === '') {
bridgeRespond(['ok' => false, 'error' => 'Bridge token not configured'], 500);
}
$provided = null;
if (!empty($_SERVER['HTTP_AUTHORIZATION']) && stripos($_SERVER['HTTP_AUTHORIZATION'], 'Bearer ') === 0) {
$provided = trim(substr($_SERVER['HTTP_AUTHORIZATION'], 7));
} elseif (!empty($_SERVER['HTTP_X_EMAILTEMPLATE_TOKEN'])) {
$provided = trim($_SERVER['HTTP_X_EMAILTEMPLATE_TOKEN']);
} elseif (isset($_GET['token'])) {
$provided = (string)$_GET['token'];
} elseif (isset($_POST['token'])) {
$provided = (string)$_POST['token'];
}
if (!$provided || !hash_equals($expected, $provided)) {
bridgeRespond(['ok' => false, 'error' => 'Unauthorized'], 403);
}
}
function bridgeDb(array $config): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
try {
$pdo = new PDO(
$config['db']['dsn'],
$config['db']['user'],
$config['db']['pass'],
$config['db']['options']
);
} catch (Throwable $e) {
bridgeRespond(['ok' => false, 'error' => 'DB connection failed', 'detail' => $e->getMessage()], 500);
}
return $pdo;
}
bridgeRequireToken($bridgeConfig);
$action = strtolower((string)($_GET['action'] ?? $_POST['action'] ?? 'schema'));
if ($action === 'ping') {
bridgeRespond(['ok' => true, 'time' => date(DATE_ATOM)]);
}
if ($action !== 'schema') {
bridgeRespond(['ok' => false, 'error' => 'Unknown action'], 404);
}
$pdo = bridgeDb($bridgeConfig);
try {
$dbName = '';
if (preg_match('/dbname=([^;]+)/i', $bridgeConfig['db']['dsn'], $m)) {
$dbName = $m[1];
}
$tablesStmt = $pdo->query('SHOW FULL TABLES');
$tables = [];
$whitelist = [];
if (!empty($bridgeConfig['tables_allow']) && is_array($bridgeConfig['tables_allow'])) {
foreach ($bridgeConfig['tables_allow'] as $tbl) {
if (is_string($tbl) && $tbl !== '') {
$whitelist[strtolower($tbl)] = true;
}
}
}
while ($row = $tablesStmt->fetch(PDO::FETCH_NUM)) {
$tableName = $row[0];
if ($tableName === null) {
continue;
}
if ($whitelist && empty($whitelist[strtolower($tableName)])) {
continue;
}
$columnsStmt = $pdo->prepare(
'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY, EXTRA
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table
ORDER BY ORDINAL_POSITION'
);
$columnsStmt->execute([
':schema' => $dbName ?: $pdo->query('SELECT DATABASE()')->fetchColumn(),
':table' => $tableName,
]);
$columns = [];
foreach ($columnsStmt as $col) {
$columns[] = [
'name' => $col['COLUMN_NAME'],
'type' => $col['DATA_TYPE'],
'nullable' => ($col['IS_NULLABLE'] === 'YES'),
'default' => $col['COLUMN_DEFAULT'],
'key' => $col['COLUMN_KEY'],
'extra' => $col['EXTRA'],
'placeholder'=> strtoupper($tableName) . '__' . strtoupper($col['COLUMN_NAME']),
];
}
$tables[] = [
'name' => $tableName,
'columns' => $columns,
];
}
bridgeRespond([
'ok' => true,
'tables' => $tables,
'fetched' => date(DATE_ATOM),
]);
} catch (Throwable $e) {
bridgeRespond(['ok' => false, 'error' => 'Schema fetch failed', 'detail' => $e->getMessage()], 500);
}
PHP;
}
private function senderDownloadTemplate(): string
{
return <<<'PHP'
<?php
declare(strict_types=1);
/**
* EmailTemplate Sender führt Platzhalter-Ersetzungen lokal aus und verschickt die Mail.
*
* Ablauf:
* 1. EmailTemplate ruft diese Datei per HTTPS auf und übergibt Template-ID/-Name,
* Empfänger sowie Platzhalter-Definitionen.
* 2. Dieses Skript ermittelt fehlende Werte (z. B. aus einer lokalen Datenbank),
* ruft anschließend die externe Template-API auf und verschickt die Mail.
*
* Sicherheit:
* - Authentifizierung über einen statischen Token (separat von der Template-API).
* - HTTPS und IP-Allowlists werden empfohlen.
*
* Konfiguration:
* - Direkt im Array $senderConfig anpassen oder eine Datei
* download/emailtemplate.sender.conf.php anlegen, die ein Array zurückgibt.
*/
$senderConfig = [
'token' => getenv('EMAILTEMPLATE_SENDER_TOKEN') ?: 'REPLACE_WITH_SHARED_TOKEN',
'template_api' => [
'base_url' => getenv('EMAILTEMPLATE_API_BASE') ?: ($GLOBALS['app_api_base'] ?? 'https://api.emailtemplate.it/external/render'),
'token' => getenv('EMAILTEMPLATE_API_TOKEN') ?: 'REPLACE_WITH_TEMPLATE_API_TOKEN',
'timeout' => 15,
],
'db' => [
'dsn' => getenv('EMAILTEMPLATE_LOCAL_DSN') ?: 'mysql:host=127.0.0.1;dbname=example;charset=utf8mb4',
'user' => getenv('EMAILTEMPLATE_LOCAL_DB_USER') ?: 'root',
'pass' => getenv('EMAILTEMPLATE_LOCAL_DB_PASS') ?: '',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
'enabled' => true,
],
'mail' => [
'from_email' => 'no-reply@example.com',
'from_name' => 'EmailTemplate Sender',
'transport' => 'mail', // aktuell nur mail()
],
];
$localSenderOverride = __DIR__ . '/emailtemplate.sender.conf.php';
if (is_file($localSenderOverride)) {
$override = include $localSenderOverride;
if (is_array($override)) {
$senderConfig = array_replace_recursive($senderConfig, $override);
}
}
function senderRespond($payload, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function senderRequireToken(array $config): void
{
$expected = (string)($config['token'] ?? '');
if ($expected === '') {
senderRespond(['ok' => false, 'error' => 'Sender token not configured'], 500);
}
$provided = null;
if (!empty($_SERVER['HTTP_AUTHORIZATION']) && stripos($_SERVER['HTTP_AUTHORIZATION'], 'Bearer ') === 0) {
$provided = trim(substr($_SERVER['HTTP_AUTHORIZATION'], 7));
} elseif (!empty($_SERVER['HTTP_X_EMAILTEMPLATE_TOKEN'])) {
$provided = trim($_SERVER['HTTP_X_EMAILTEMPLATE_TOKEN']);
} elseif (isset($_POST['token'])) {
$provided = (string)$_POST['token'];
}
if (!$provided || !hash_equals($expected, $provided)) {
senderRespond(['ok' => false, 'error' => 'Unauthorized'], 403);
}
}
function senderInput(): array
{
$raw = file_get_contents('php://input');
if ($raw !== false && trim($raw) !== '') {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
}
if (!empty($_POST)) {
return $_POST;
}
return [];
}
function senderDb(array $config): ?PDO
{
if (empty($config['db']['enabled'])) {
return null;
}
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
try {
$pdo = new PDO(
$config['db']['dsn'],
$config['db']['user'],
$config['db']['pass'],
$config['db']['options']
);
} catch (Throwable $e) {
senderRespond(['ok' => false, 'error' => 'DB connection failed', 'detail' => $e->getMessage()], 500);
}
return $pdo;
}
function resolvePlaceholderValue($definition, ?PDO $pdo)
{
if (is_scalar($definition)) {
return $definition;
}
if (!is_array($definition)) {
return null;
}
if (array_key_exists('value', $definition)) {
return $definition['value'];
}
if (($definition['source'] ?? '') === 'db') {
if (!$pdo) {
throw new RuntimeException('Database access disabled');
}
$table = $definition['table'] ?? null;
$column = $definition['column'] ?? null;
$where = $definition['where'] ?? [];
if (!$table || !$column || !is_array($where) || !$where) {
throw new InvalidArgumentException('Invalid DB placeholder definition');
}
$conditions = [];
$params = [];
foreach ($where as $key => $value) {
$param = ':w_' . preg_replace('/[^a-z0-9_]/i', '_', $key);
$conditions[] = sprintf('`%s` = %s', str_replace('`', '', $key), $param);
$params[$param] = $value;
}
$sql = sprintf(
'SELECT `%s` FROM `%s` WHERE %s LIMIT 1',
str_replace('`', '', $column),
str_replace('`', '', $table),
implode(' AND ', $conditions)
);
$stmt = $pdo->prepare($sql);
foreach ($params as $param => $value) {
$stmt->bindValue($param, $value);
}
$stmt->execute();
return $stmt->fetchColumn();
}
return null;
}
function fetchTemplateHtml(array $config, array $payload): array
{
$apiUrl = $config['template_api']['base_url'];
$apiToken = $config['template_api']['token'];
if (!$apiUrl || !$apiToken) {
senderRespond(['ok' => false, 'error' => 'Template API not configured'], 500);
}
$body = $payload;
$body['token'] = $apiToken;
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'timeout' => (int)($config['template_api']['timeout'] ?? 15),
'content' => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
]);
$response = @file_get_contents($apiUrl, false, $context);
if ($response === false) {
senderRespond(['ok' => false, 'error' => 'Template API unreachable'], 502);
}
$decoded = json_decode($response, true);
if (!is_array($decoded) || !empty($decoded['ok']) === false) {
senderRespond(['ok' => false, 'error' => 'Template API error', 'detail' => $decoded], 502);
}
return $decoded;
}
function sendMail(array $config, string $html, string $subject, array $payload): void
{
$headers = [];
if (!empty($config['mail']['from_email'])) {
$fromName = $config['mail']['from_name'] ?? '';
if ($fromName !== '') {
$headers[] = 'From: ' . sprintf('"%s" <%s>', addslashes($fromName), $config['mail']['from_email']);
} else {
$headers[] = 'From: ' . $config['mail']['from_email'];
}
}
$headers[] = 'Content-Type: text/html; charset=utf-8';
$to = $payload['to'] ?? '';
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
senderRespond(['ok' => false, 'error' => 'Invalid recipient'], 422);
}
if (!mail($to, $subject, $html, implode("\r\n", $headers))) {
senderRespond(['ok' => false, 'error' => 'mail() transport failed'], 500);
}
}
senderRequireToken($senderConfig);
$input = senderInput();
$pdo = senderDb($senderConfig);
try {
$placeholders = $input['placeholders'] ?? [];
if (!is_array($placeholders)) {
senderRespond(['ok' => false, 'error' => 'Invalid placeholders'], 422);
}
$resolved = [];
foreach ($placeholders as $key => $definition) {
$resolved[$key] = resolvePlaceholderValue($definition, $pdo);
}
$input['placeholders'] = $resolved;
$template = fetchTemplateHtml($senderConfig, $input);
if (empty($template['ok']) || empty($template['html'])) {
senderRespond(['ok' => false, 'error' => 'Template API returned no HTML'], 502);
}
$subject = $input['subject'] ?? ($template['subject'] ?? 'EmailTemplate');
sendMail($senderConfig, $template['html'], $subject, $input);
senderRespond(['ok' => true]);
} catch (Throwable $e) {
senderRespond(['ok' => false, 'error' => 'Sender failure', 'detail' => $e->getMessage()], 500);
}
PHP;
}
}