Files
emailtemplate.it/src/ApiKernel.php
2026-01-15 01:21:59 +01:00

3609 lines
141 KiB
PHP
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;
// 💡 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';
// -----------------------------------------------------------------
// 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 = [];
// --- 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'];
if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) {
$verb = strtolower((string)$action);
if (in_array($verb, ['list', 'get', 'create', 'update', 'delete'], true)) $action = $resource . '.' . $verb;
}
$this->action = $action;
}
private function resolveTableMap(): void { /* ... Logik bleibt unverändert ... */
$tables = $this->conf['tables'] ?? [];
$this->tableMap = [
'templates' => $tables['templates'] ?? 'emailtemplate_templates',
'sections' => $tables['sections'] ?? 'emailtemplate_sections',
'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks',
'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets',
];
}
private function val(array $in, $keys, $default = null) { /* ... Logik bleibt unverändert ... */
if (!is_array($keys)) $keys = [$keys];
foreach ($keys as $k) if (array_key_exists($k, $in)) return $in[$k];
return $default;
}
private function firstExisting(array $columns, array $candidates): ?string { /* ... Logik bleibt unverändert ... */
foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c;
return null;
}
private function tableColumns(string $table): array { /* ... Logik bleibt unverändert ... */
$cols = [];
$stmt = $this->pdo->query("SHOW COLUMNS FROM `$table`");
foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field'];
return $cols;
}
private function tableExists(string $table): bool
{
if ($table === '') return false;
if (array_key_exists($table, $this->tableExistsCache)) {
return $this->tableExistsCache[$table];
}
try {
$this->pdo->query("SELECT 1 FROM `$table` LIMIT 1");
$this->tableExistsCache[$table] = true;
} catch (Throwable $e) {
$this->tableExistsCache[$table] = false;
}
return $this->tableExistsCache[$table];
}
private function primaryKey(string $table): ?string { /* ... Logik bleibt unverändert ... */
$stmt = $this->pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'");
$stmt->execute();
$row = $stmt->fetch();
return $row['Column_name'] ?? null;
}
private function requireAuth(): array
{
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 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);
}
}
// =================================================================
// 🚀 CRUD HANDLER METHODEN
// =================================================================
/**
* Allgemeine Methode zur Handhabung von LIST-Anfragen.
*/
private function handleList(string $kind): void
{
$auth = $this->requireAuth();
$t = $this->tableMap[$kind];
[$idCol, $allCols] = $this->resolveIdCol($kind);
$cfg = $this->conf['columns'][$kind] ?? [];
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
$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];
// Lade HTML und JSON aus den korrekten Spalten
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
if ($htmlCol && isset($r[$htmlCol])) $item['html'] = (string)$r[$htmlCol];
$jsonCol = $this->firstExisting($allCols, ['json_content']);
if ($jsonCol && isset($r[$jsonCol])) $item['content'] = $r[$jsonCol];
$out[] = $item;
}
$this->respond(['ok' => true, 'kind' => $kind, 'items' => $out, 'data' => $out, 'count' => count($out), 'offset' => $offset, 'limit' => $limit]);
}
/**
* Allgemeine Methode zur Handhabung von GET-Anfragen.
*/
private function handleGet(string $kind): void
{
$auth = $this->requireAuth();
$t = $this->tableMap[$kind];
[$idCol, $allCols] = $this->resolveIdCol($kind);
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
[$tw, $tp] = $this->tenantWhere($auth);
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':id', $id);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$row = $stmt->fetch();
if (!$row) $this->fail('Not found', ['kind' => $kind, 'id' => $id], 404);
$rowOut = ['id' => $row[$idCol] ?? $id] + $row;
// Lade HTML und JSON aus den korrekten Spalten
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
$topHtml = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : null;
$jsonCol = $this->firstExisting($allCols, ['json_content']);
$topContent = ($jsonCol && isset($row[$jsonCol])) ? $row[$jsonCol] : null;
$gjsComponents = [];
if ($topContent !== null) {
$decodedContent = json_decode($topContent, true);
if (is_array($decodedContent)) {
$gjsComponents = $decodedContent;
}
}
if (empty($gjsComponents) && $topHtml !== null) {
$gjsComponents = $this->parseHtmlToGjsComponents($topHtml);
}
$usage = $this->calculateUsage($kind, (int)$rowOut['id'], $auth);
$this->respond([
'ok' => true,
'kind' => $kind,
'id' => $rowOut['id'],
'item' => $rowOut,
'data' => $rowOut,
'html' => $topHtml,
'content' => $topContent,
'gjs_components' => $gjsComponents,
'usage' => $usage,
]);
}
/**
* Allgemeine Methode zur Handhabung von CREATE-Anfragen (inkl. JSON-Bereinigung).
*/
private function handleCreate(string $kind): void
{
$auth = $this->requireAuth();
$t = $this->tableMap[$kind];
[$idCol, $allCols] = $this->resolveIdCol($kind);
$cfg = $this->conf['columns'][$kind] ?? [];
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
if ($name === '') $this->fail('name required', null, 422);
$desc = (string)$this->val($this->in, ['description', 'desc'], null);
$cat = (string)$this->val($this->in, ['category', 'cat'], null);
$html = (string)$this->val($this->in, ['html', 'body', 'markup'], null);
if ($kind === 'snippets' && ($html === null || $html === '')) {
$html = (string)$this->val($this->in, ['content'], $html);
}
$jsonKeys = ($kind === 'snippets')
? ['content_json', 'json', 'structure_json']
: ['content_json', 'json', 'content', 'structure_json'];
$json = $this->val($this->in, $jsonKeys, null);
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
$templateId = $this->val($this->in, ['template_id', 'tpl_id'], null);
$sectionId = $this->val($this->in, ['section_id', 'sec_id'], null);
$blockId = $this->val($this->in, ['block_id', 'blk_id'], null);
$data = [$nameCol => $name];
if ($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']);
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
// 1. JSON-Content behandeln
if ($json !== null) {
if ($jsonDbCol) {
$components = is_string($json) ? json_decode($json, true) : $json;
if (is_array($components)) {
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
$data[$jsonDbCol] = $this->encodeJson($components);
} else {
$data[$jsonDbCol] = is_string($json) ? $json : '';
}
} else {
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
$this->fail(
'JSON content provided but no `json_content` column found',
['table' => $t, 'available_cols' => $allCols],
422
);
}
}
// 2. HTML-Content speichern
if ($htmlDbCol && $html !== null) {
$data[$htmlDbCol] = $html;
}
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
if ($c && $settings !== null) $data[$c] = is_string($settings) ? $settings : $this->encodeJson($settings);
if ($templateId !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $templateId;
if ($sectionId !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sectionId;
if ($blockId !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blockId;
$data = $data + $this->tenantAssign($_SESSION['auth'] ?? [], $allCols);
$now = date('Y-m-d H:i:s');
$createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']);
if ($createdCol) $data[$createdCol] = $now;
if ($updCol) $data[$updCol] = $now;
$fields = array_keys($data);
$place = array_map(fn($c) => ":$c", $fields);
$sql = "INSERT INTO `$t` (" . implode(',', array_map(fn($c) => "`$c`", $fields)) . ") VALUES (" . implode(',', $place) . ")";
$stmt = $this->pdo->prepare($sql);
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
$stmt->execute();
$newId = $this->pdo->lastInsertId();
$out = ['id' => $newId, 'name' => $name];
if (!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
{
$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 ---
$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
{
$auth = $this->requireAuth();
$t = $this->tableMap[$kind];
[$idCol, $allCols] = $this->resolveIdCol($kind);
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
[$tw, $tp] = $this->tenantWhere($auth);
$sql = "DELETE FROM `$t` WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':__id', $id);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'deleted' => true]);
}
/**
* Sendet einen Testversand für Templates.
*/
private function handleTemplateTestSend(): void
{
$auth = $this->requireAuth();
$templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0);
if ($templateId <= 0) {
$this->fail('template_id required', null, 422);
}
$recipient = trim((string)$this->val($this->in, ['to', 'email', 'recipient'], ''));
if ($recipient === '' || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
$this->fail('Valid recipient required', null, 422);
}
$subject = trim((string)$this->val($this->in, ['subject'], 'Testversand'));
if ($subject === '') {
$subject = 'Testversand';
}
$senderId = (int)$this->val($this->in, ['sender_id'], 0);
$t = $this->tableMap['templates'];
[$idCol, $allCols] = $this->resolveIdCol('templates');
[$tw, $tp] = $this->tenantWhere($auth);
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':id', $templateId);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$row = $stmt->fetch();
if (!$row) {
$this->fail('Template not found', ['id' => $templateId], 404);
}
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
$html = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : '';
if ($html === '' && !empty($row['json_content'])) {
$html = '<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)) {
$this->writeDebugLog('templates_test_send', [
'time' => date(DATE_ATOM),
'template_id' => $templateId,
'to' => $recipient,
'subject' => $subject,
'sender_id' => $senderId > 0 ? $senderId : null,
'from_email' => $sender['from_email'] ?? ($this->conf['smtp']['from_email'] ?? null),
'from_name' => $sender['from_name'] ?? ($this->conf['smtp']['from_name'] ?? null),
'html_length' => strlen($html),
]);
$this->fail('Send failed', null, 500);
}
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId > 0) {
$this->incrementTemplateUsage($customerId, $templateId);
}
$this->respond([
'ok' => true,
'template_id' => $templateId,
'to' => $recipient,
'subject' => $subject,
'sender_id' => $senderId > 0 ? $senderId : null,
]);
}
private function 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);
}
$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']);
$templateKey = $this->val($this->in, ['api_name', 'template', 'template_id', 'id', 'name'], '');
$templateId = is_numeric($templateKey) ? (int)$templateKey : null;
$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();
if (!$tpl) {
$this->fail('Template not found', ['template' => $templateKey], 404);
}
$htmlCol = $this->resolveHtmlColumn($allCols, 'templates');
$html = ($htmlCol && isset($tpl[$htmlCol])) ? (string)$tpl[$htmlCol] : '';
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[$idCol] ?? 0),
'name' => $tpl[$nameCol] ?? null,
'api_name' => $apiCol ? ($tpl[$apiCol] ?? null) : null,
'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): bool
{
if (!function_exists('mail')) {
return false;
}
$smtp = $this->conf['smtp'] ?? [];
$fromEmail = $sender['from_email'] ?? ($smtp['from_email'] ?? 'no-reply@example.com');
$fromName = $sender['from_name'] ?? ($sender['label'] ?? ($smtp['from_name'] ?? 'EmailTemplate'));
$replyTo = $sender['reply_to'] ?? '';
$headers = [
'MIME-Version: 1.0',
'Content-Type: text/html; charset=UTF-8',
'From: ' . $this->formatEmailAddress($fromEmail, $fromName),
];
if ($replyTo !== '') {
$headers[] = 'Reply-To: ' . $this->formatEmailAddress($replyTo, $fromName ?: $fromEmail);
}
$encodedSubject = function_exists('mb_encode_mimeheader')
? mb_encode_mimeheader($subject, 'UTF-8')
: $subject;
return @mail($to, $encodedSubject, $html, implode("\r\n", $headers));
}
private function formatEmailAddress(string $email, string $name): string
{
$email = trim($email);
if ($email === '') {
return 'no-reply@example.com';
}
$name = trim($name);
if ($name === '') {
return $email;
}
$encoded = function_exists('mb_encode_mimeheader')
? mb_encode_mimeheader($name, 'UTF-8')
: $name;
return sprintf('%s <%s>', $encoded, $email);
}
// =================================================================
// 💡 Öffentliche run()-Methode (KORRIGIERT)
// =================================================================
public function run(): void
{
// 💡 KORREKTUR: Der Content-Type Header wird hier entfernt, da er jetzt in respond()
// zentralisiert wurde, um sicherzustellen, dass er auch bei Fehlern im Konstruktor oder
// im try-Block korrekt gesetzt wird.
// header('Content-Type: application/json; charset=utf-8'); // DIESE ZEILE ENTFERNT
try {
// Extrahiere den Ressourcen-Typ und die Operation (z.B. 'templates' und 'list')
[$kind, $operation] = explode('.', $this->action, 2) + [1 => ''];
switch ($this->action) {
case 'health':
$this->respond(['ok' => true, 'time' => date('c')]);
case 'external.render':
$this->handleExternalRender();
break;
/* ---------- AUTH ---------- */
case 'auth.login':
$result = $this->authService->login($this->in);
$this->respond(['ok' => true] + $result);
break;
case 'auth.me':
if (empty($_SESSION['auth'])) $this->fail('Not authenticated', null, 401);
$this->respond(['ok' => true, 'user' => $_SESSION['auth']]);
break;
case 'auth.logout':
$this->authService->logout();
$this->respond(['ok' => true]);
break;
case 'account.profile.get':
$this->handleAccountProfileGet();
break;
case 'account.profile.update':
$this->handleAccountProfileUpdate();
break;
case 'account.password.update':
$this->handleAccountPasswordUpdate();
break;
case 'account.settings.get':
$this->handleAccountSettingsGet();
break;
case 'account.settings.update':
$this->handleAccountSettingsUpdate();
break;
case 'account.users.list':
$this->handleAccountUsersList();
break;
case 'account.users.create':
$this->handleAccountUsersCreate();
break;
case 'account.users.update':
$this->handleAccountUsersUpdate();
break;
case 'account.users.delete':
$this->handleAccountUsersDelete();
break;
case 'account.senders.list':
$this->handleAccountSendersList();
break;
case 'account.senders.save':
$this->handleAccountSenderSave();
break;
case 'account.senders.delete':
$this->handleAccountSenderDelete();
break;
case 'dashboard.metrics':
$this->handleDashboardMetrics();
break;
case 'dashboard.reset_usage':
$this->handleDashboardResetUsage();
break;
case 'downloads.bridge':
$this->handleDownloadFile('bridge');
break;
case 'downloads.sender':
$this->handleDownloadFile('sender');
break;
case 'account.bridge.setup.get':
$this->handleAccountBridgeSetupGet();
break;
case 'account.bridge.setup.save':
$this->handleAccountBridgeSetupSave();
break;
case 'account.bridge.test':
$this->handleAccountBridgeTest();
break;
case 'account.fonts.list':
$this->handleAccountFontsList();
break;
case 'debug.logs.list':
$this->handleDebugLogsList();
break;
case 'debug.logs.read':
$this->handleDebugLogsRead();
break;
case 'placeholders.status':
$this->handlePlaceholderStatus();
break;
case 'placeholders.schema':
$this->handlePlaceholderSchema();
break;
case 'debug.phpinfo':
$this->handleDebugPhpInfo();
break;
case 'templates.test_send':
$this->handleTemplateTestSend();
break;
/* ---------- CRUD HANDLER ---------- */
default:
if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets'])) {
switch ($operation) {
case 'list':
$this->handleList($kind);
break;
case 'get':
$this->handleGet($kind);
break;
case 'create':
$this->handleCreate($kind);
break;
case 'update':
$this->handleUpdate($kind);
break;
case 'delete':
$this->handleDelete($kind);
break;
default:
$this->fail('Unknown operation for resource: ' . $this->action, null, 404);
break;
}
} else {
$this->fail('Unknown action', $this->action ?: 'missing', 404);
}
break;
}
} catch (Throwable $e) {
$this->fail('Server error', get_class($e) . ': ' . $e->getMessage(), 500);
}
}
private function lookupTableName(string $key, string $default): string
{
$tables = $this->conf['tables'] ?? [];
if (!empty($tables[$key])) return $tables[$key];
$prefix = $this->conf['projectdb']['prefix'] ?? null;
if ($prefix && strpos($default, 'emailtemplate_') === 0) {
return $prefix . substr($default, strlen('emailtemplate_'));
}
return $default;
}
private function countRefsInTable(string $table, string $where, array $params, array $auth): int
{
try {
[$tw, $tp] = $this->tenantWhere($auth);
$sql = "SELECT COUNT(*) AS c FROM `$table` WHERE $where" . $tw;
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$row = $stmt->fetch();
return (int)($row['c'] ?? 0);
} catch (Throwable $e) {
return 0;
}
}
private function fetchResourceCounts(int $customerId): array
{
$counts = [
'templates' => 0,
'sections' => 0,
'blocks' => 0,
'snippets' => 0,
'renders_total' => 0,
];
$map = $this->tableMap ?? [];
foreach (['templates', 'sections', 'blocks', 'snippets'] as $kind) {
$table = $map[$kind] ?? null;
if (!$table || !$this->tableExists($table)) continue;
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid");
$stmt->execute([':cid' => $customerId]);
$counts[$kind] = (int)($stmt->fetchColumn() ?: 0);
}
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
if ($this->tableExists($usageTable)) {
$stmt = $this->pdo->prepare("SELECT SUM(`render_count`) FROM `$usageTable` WHERE `customer_id` = :cid");
$stmt->execute([':cid' => $customerId]);
$counts['renders_total'] = (int)($stmt->fetchColumn() ?: 0);
}
return $counts;
}
private function listTemplateUsage(int $customerId): array
{
$table = $this->tableMap['templates'] ?? null;
if (!$table || !$this->tableExists($table)) {
return [];
}
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
if ($this->tableExists($usageTable)) {
$sql = "SELECT t.id, t.name, t.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at
FROM `$table` t
LEFT JOIN `$usageTable` u ON u.template_id = t.id
WHERE t.customer_id = :cid
ORDER BY render_count DESC, t.updated_at DESC";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId]);
$rows = $stmt->fetchAll() ?: [];
} else {
$sql = "SELECT t.id, t.name, t.updated_at FROM `$table` t WHERE t.customer_id = :cid ORDER BY t.updated_at DESC";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':cid' => $customerId]);
$rows = $stmt->fetchAll() ?: [];
foreach ($rows as &$row) {
$row['render_count'] = 0;
$row['last_rendered_at'] = null;
}
}
return array_map(static function ($row) {
return [
'template_id' => (int)($row['id'] ?? 0),
'name' => $row['name'] ?? '',
'render_count' => (int)($row['render_count'] ?? 0),
'last_rendered_at' => $row['last_rendered_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
}, $rows);
}
private function resetTemplateUsage(int $customerId, array $templateIds): void
{
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
if (!$templateIds || !$this->tableExists($usageTable)) {
return;
}
$templateIds = array_values(array_unique(array_filter(array_map('intval', $templateIds), static fn ($v) => $v > 0)));
if (!$templateIds) return;
$placeholders = implode(',', array_fill(0, count($templateIds), '?'));
$sql = "DELETE FROM `$usageTable` WHERE `customer_id` = ? AND `template_id` IN ($placeholders)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(array_merge([$customerId], $templateIds));
}
private function extractIdList($raw): array
{
if ($raw === null) return [];
if (is_numeric($raw)) {
$raw = [(int)$raw];
} elseif (is_string($raw)) {
$raw = preg_split('/[\s,]+/', $raw);
} elseif (!is_array($raw)) {
return [];
}
$ids = [];
foreach ($raw as $value) {
if (is_array($value)) {
$ids = array_merge($ids, $this->extractIdList($value));
continue;
}
if ($value === '' || $value === null) continue;
$ids[] = (int)$value;
}
$ids = array_values(array_unique(array_filter($ids, static fn ($v) => $v > 0)));
return $ids;
}
private function calculateUsage(string $kind, int $id, array $auth): array
{
if ($id <= 0) return ['total' => 0];
$summary = [];
$templateItemsTable = $this->lookupTableName('template_items', 'emailtemplate_template_items');
$sectionItemsTable = $this->lookupTableName('section_items', 'emailtemplate_section_items');
if ($kind === 'sections') {
$summary['templates'] = $this->countRefsInTable(
$templateItemsTable,
"`ref_type` = :rt AND `ref_id` = :rid",
[':rt' => 'section', ':rid' => $id],
$auth
);
} elseif ($kind === 'blocks') {
$summary['templates'] = $this->countRefsInTable(
$templateItemsTable,
"`ref_type` = :rt AND `ref_id` = :rid",
[':rt' => 'block', ':rid' => $id],
$auth
);
$summary['sections'] = $this->countRefsInTable(
$sectionItemsTable,
"`ref_id` = :rid",
[':rid' => $id],
$auth
);
$summary['snippets'] = $this->countRefsInTable(
$this->tableMap['snippets'],
"`block_id` = :rid",
[':rid' => $id],
$auth
);
}
$summary = array_filter($summary, fn($v) => (int)$v > 0);
$summary['total'] = array_sum($summary);
return $summary;
}
private function normalizeResourceKind(string $kind): ?string
{
$kind = strtolower(trim($kind));
$map = [
'template' => 'templates',
'templates' => 'templates',
'section' => 'sections',
'sections' => 'sections',
'block' => 'blocks',
'blocks' => 'blocks',
'snippet' => 'snippets',
'snippets' => 'snippets',
];
return $map[$kind] ?? null;
}
private function resolveHtmlColumn(array $columns, string $kindKey): ?string
{
$candidates = ($kindKey === 'snippets')
? ['content', 'html', 'body', 'markup']
: ['html', 'body', 'markup', 'content'];
return $this->firstExisting($columns, $candidates);
}
private function fetchResourceHtml(string $kind, int $id, array $auth, array &$cache, array &$stack): ?string
{
$kindKey = $this->normalizeResourceKind($kind);
if (!$kindKey || $id <= 0) return null;
$cacheKey = $kindKey . ':' . $id;
if (array_key_exists($cacheKey, $cache)) return $cache[$cacheKey];
if (!empty($stack[$cacheKey])) return null;
$table = $this->tableMap[$kindKey] ?? null;
if (!$table) return null;
[$idCol, $allCols] = $this->resolveIdCol($kindKey);
[$tw, $tp] = $this->tenantWhere($auth);
$sql = "SELECT * FROM `$table` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':id', $id);
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$row = $stmt->fetch();
if (!$row) {
$cache[$cacheKey] = null;
return null;
}
$htmlCol = $this->resolveHtmlColumn($allCols, $kindKey);
$html = $htmlCol && isset($row[$htmlCol]) ? (string)$row[$htmlCol] : '';
$stack[$cacheKey] = true;
$html = $this->renderHtmlWithReferences($html, $auth, $cache, $stack);
unset($stack[$cacheKey]);
$cache[$cacheKey] = $html;
return $html;
}
private function renderHtmlWithReferences(string $html, array $auth, array &$cache, array &$stack): string
{
$trimmed = trim($html);
if ($trimmed === '') return $html;
if (!class_exists(DOMDocument::class)) return $html;
$flags = 0;
if (defined('LIBXML_HTML_NOIMPLIED')) {
$flags |= LIBXML_HTML_NOIMPLIED;
}
if (defined('LIBXML_HTML_NODEFDTD')) {
$flags |= LIBXML_HTML_NODEFDTD;
}
$doc = new DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$wrapper = '<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-kind and @data-lib-id]');
if ($nodes !== false) {
foreach ($nodes as $node) {
/** @var \DOMElement $node */
$kind = $node->getAttribute('data-lib-kind');
$refId = (int)$node->getAttribute('data-lib-id');
if (!$kind || $refId <= 0) continue;
$replacement = $this->fetchResourceHtml($kind, $refId, $auth, $cache, $stack);
if ($replacement === null) continue;
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$this->appendHtmlToNode($doc, $node, $replacement);
}
}
$root = $doc->getElementById('lib-ref-root');
if (!$root) return $html;
$output = '';
foreach ($root->childNodes as $child) {
$output .= $doc->saveHTML($child);
}
return $output;
}
private function appendHtmlToNode(DOMDocument $targetDoc, DOMElement $node, string $html): void
{
if (trim($html) === '') return;
$flags = 0;
if (defined('LIBXML_HTML_NOIMPLIED')) {
$flags |= LIBXML_HTML_NOIMPLIED;
}
if (defined('LIBXML_HTML_NODEFDTD')) {
$flags |= LIBXML_HTML_NODEFDTD;
}
$fragmentDoc = new DOMDocument('1.0', 'UTF-8');
libxml_use_internal_errors(true);
$wrapped = '<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->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->respond(['ok' => true, 'settings' => $settings]);
}
private function handleAccountSettingsUpdate(): 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);
$bridgeUrl = trim((string)($this->in['bridge_url'] ?? ''));
$bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
$senderToken = trim((string)($this->in['sender_token'] ?? ''));
$externalToken = trim((string)($this->in['external_api_token'] ?? ''));
$rotateBridge = !empty($this->in['rotate_bridge_token']);
$rotateSender = !empty($this->in['rotate_sender_token']);
$rotateExternal = !empty($this->in['rotate_external_token']);
$bridgeTables = $this->normalizeBridgeTables($this->in['bridge_tables'] ?? []);
if ($bridgeUrl && !filter_var($bridgeUrl, FILTER_VALIDATE_URL)) {
$this->fail('Ungültige Bridge-URL', null, 422);
}
$settings = $this->getCustomerSettings($customerId);
if ($rotateBridge || $bridgeToken === '') $bridgeToken = $this->generateToken();
if ($rotateSender || $senderToken === '') $senderToken = $this->generateToken();
if ($rotateExternal || $externalToken === '') $externalToken = $this->generateToken();
$settings = $this->saveCustomerSettings($customerId, [
'bridge_url' => $bridgeUrl,
'bridge_token' => $bridgeToken,
'sender_token' => $senderToken,
'external_api_token' => $externalToken,
'bridge_tables' => $bridgeTables,
]);
$this->respond(['ok' => true, 'settings' => $settings]);
}
private function handleAccountUsersList(): void
{
$user = $this->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 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', 'bridge_tables', 'bridge_setup'];
$fields = array_intersect_key($data, array_flip($allowed));
if (!$fields) return $this->getCustomerSettings($customerId);
if (array_key_exists('bridge_tables', $fields)) {
$normalized = $this->normalizeBridgeTables($fields['bridge_tables']);
$fields['bridge_tables'] = $normalized ? $this->encodeBridgeTables($normalized) : null;
}
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();
}
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,
`bridge_tables` text 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 (!$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 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') {
$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
{
if (strtolower((string)($this->conf['env'] ?? '')) !== 'staging') {
return;
}
$dir = $this->debugDir();
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$safeName = preg_replace('/[^a-zA-Z0-9_\.\-]/', '_', $name) ?: 'debug';
$file = rtrim($dir, '/') . '/' . $safeName . '.log';
$data = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
@file_put_contents($file, $data ?: '');
}
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;
}
}