5629 lines
237 KiB
PHP
5629 lines
237 KiB
PHP
<?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', 'content', 'sections_config', 'content_versions'];
|
||
if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) {
|
||
$verb = strtolower((string)$action);
|
||
if (in_array($verb, ['list', 'get', 'create', 'update', 'delete'], true)) $action = $resource . '.' . $verb;
|
||
}
|
||
$this->action = $action;
|
||
}
|
||
private function resolveTableMap(): void { /* ... Logik bleibt unverändert ... */
|
||
$tables = $this->conf['tables'] ?? [];
|
||
$this->tableMap = [
|
||
'templates' => $tables['templates'] ?? 'emailtemplate_templates',
|
||
'sections' => $tables['sections'] ?? 'emailtemplate_sections',
|
||
'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks',
|
||
'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets',
|
||
'content_items' => $tables['content_items'] ?? 'emailtemplate_content_items',
|
||
'content_sections' => $tables['content_sections'] ?? 'emailtemplate_content_sections',
|
||
'content_versions' => $tables['content_versions'] ?? 'emailtemplate_content_versions',
|
||
];
|
||
}
|
||
|
||
private function val(array $in, $keys, $default = null) { /* ... Logik bleibt unverändert ... */
|
||
if (!is_array($keys)) $keys = [$keys];
|
||
foreach ($keys as $k) if (array_key_exists($k, $in)) return $in[$k];
|
||
return $default;
|
||
}
|
||
private function firstExisting(array $columns, array $candidates): ?string { /* ... Logik bleibt unverändert ... */
|
||
foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c;
|
||
return null;
|
||
}
|
||
private function tableColumns(string $table): array { /* ... Logik bleibt unverändert ... */
|
||
$cols = [];
|
||
$stmt = $this->pdo->query("SHOW COLUMNS FROM `$table`");
|
||
foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field'];
|
||
return $cols;
|
||
}
|
||
|
||
private function tableExists(string $table): bool
|
||
{
|
||
if ($table === '') return false;
|
||
if (array_key_exists($table, $this->tableExistsCache)) {
|
||
return $this->tableExistsCache[$table];
|
||
}
|
||
try {
|
||
$this->pdo->query("SELECT 1 FROM `$table` LIMIT 1");
|
||
$this->tableExistsCache[$table] = true;
|
||
} catch (Throwable $e) {
|
||
$this->tableExistsCache[$table] = false;
|
||
}
|
||
return $this->tableExistsCache[$table];
|
||
}
|
||
private function primaryKey(string $table): ?string { /* ... Logik bleibt unverändert ... */
|
||
$stmt = $this->pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'");
|
||
$stmt->execute();
|
||
$row = $stmt->fetch();
|
||
return $row['Column_name'] ?? null;
|
||
}
|
||
private function requireAuth(): array
|
||
{
|
||
return $this->ensureAuthUserHydrated($this->authService->requireAuth());
|
||
}
|
||
private function pullId(array $src) { /* ... Logik bleibt unverändert ... */
|
||
$aliases = ['id', 'item_id', 'template_id', 'tpl_id', 'section_id', 'sec_id', 'block_id', 'blk_id', 'snippet_id', 'snip_id'];
|
||
foreach ($aliases as $a) if (isset($src[$a]) && $src[$a] !== '') return $src[$a];
|
||
return null;
|
||
}
|
||
private function tenantWhere(array $session): array { /* ... Logik bleibt unverändert ... */
|
||
$multi = $this->conf['multi'] ?? [];
|
||
$tenantCol = $multi['tenant_col'] ?? null;
|
||
$mapSess = $multi['map_session_to'] ?? 'id';
|
||
|
||
if (!$tenantCol) return ['', []];
|
||
if (!$session) return [' AND 1=0 ', []];
|
||
$val = $session[$mapSess] ?? null;
|
||
if ($val === null || $val === '') return [' AND 1=0 ', []];
|
||
return [" AND `$tenantCol` = :__tenant", [':__tenant' => $val]];
|
||
}
|
||
private function tenantAssign(array $session, array $columns): array { /* ... Logik bleibt unverändert ... */
|
||
$multi = $this->conf['multi'] ?? [];
|
||
$tenantCol = $multi['tenant_col'] ?? null;
|
||
$mapSess = $multi['map_session_to'] ?? 'id';
|
||
|
||
if (!$tenantCol || !in_array($tenantCol, $columns, true)) return [];
|
||
$val = $session[$mapSess] ?? null;
|
||
return ($val === null || $val === '') ? [] : [$tenantCol => $val];
|
||
}
|
||
private function resolveIdCol(string $kind): array { /* ... Logik bleibt unverändert ... */
|
||
$t = $this->tableMap[$kind];
|
||
$cfg = $this->conf['columns'][$kind] ?? [];
|
||
$cols = $this->tableColumns($t);
|
||
$idCol = $cfg['id'] ?? ($this->firstExisting($cols, ['id']) ?: $this->primaryKey($t));
|
||
if (!$idCol) $idCol = 'id';
|
||
return [$idCol, $cols];
|
||
}
|
||
private function parseHtmlToGjsComponents(string $html): array { /* ... Logik bleibt unverändert ... */
|
||
if (trim($html) === '') return [];
|
||
|
||
return [
|
||
[
|
||
'type' => 'html',
|
||
'content' => $html,
|
||
'removable' => true,
|
||
'draggable' => true,
|
||
'droppable' => true,
|
||
'copyable' => true,
|
||
'selectable' => true,
|
||
'editable' => false,
|
||
'traits' => [],
|
||
]
|
||
];
|
||
}
|
||
|
||
// 💡 Bereinigungsmethode
|
||
private function cleanReferenceComponents(array $node): array
|
||
{
|
||
if (isset($node['type']) && $node['type'] === 'library-reference') {
|
||
$node['content'] = '';
|
||
$node['components'] = [];
|
||
}
|
||
|
||
foreach ($node as $key => $value) {
|
||
if (is_array($value)) {
|
||
$node[$key] = $this->cleanReferenceComponents($value);
|
||
}
|
||
}
|
||
|
||
return $node;
|
||
}
|
||
|
||
private function encodeJson($value): string
|
||
{
|
||
$options = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||
$json = json_encode($value, $options, 2048);
|
||
if ($json === false) {
|
||
$json = json_encode(
|
||
$value,
|
||
$options | JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE,
|
||
2048
|
||
);
|
||
}
|
||
|
||
return $json === false ? '' : $json;
|
||
}
|
||
|
||
private function normalizeApiName(string $value): string
|
||
{
|
||
$value = trim($value);
|
||
$value = preg_replace('/\s+/', '-', $value);
|
||
$value = preg_replace('/[^A-Za-z0-9_-]+/', '-', $value);
|
||
$value = preg_replace('/-+/', '-', $value);
|
||
return trim($value, '-');
|
||
}
|
||
|
||
private function normalizeSectionSlug(string $value): string
|
||
{
|
||
$value = strtolower(trim($value));
|
||
$value = preg_replace('/\s+/', '-', $value);
|
||
$value = preg_replace('/[^a-z0-9_-]+/', '-', $value);
|
||
$value = preg_replace('/-+/', '-', $value);
|
||
return trim($value, '-');
|
||
}
|
||
|
||
private function contentItemsTable(): string
|
||
{
|
||
return $this->tableMap['content_items'] ?? $this->lookupTableName('content_items', 'emailtemplate_content_items');
|
||
}
|
||
|
||
private function contentSectionsTable(): string
|
||
{
|
||
return $this->tableMap['content_sections'] ?? $this->lookupTableName('content_sections', 'emailtemplate_content_sections');
|
||
}
|
||
|
||
private function contentVersionsTable(): string
|
||
{
|
||
return $this->tableMap['content_versions'] ?? $this->lookupTableName('content_versions', 'emailtemplate_content_versions');
|
||
}
|
||
|
||
private function resolveContentItemColumns(string $table): array
|
||
{
|
||
$cols = $this->tableColumns($table);
|
||
return [
|
||
'category' => $this->firstExisting($cols, ['category', 'cat']),
|
||
'html' => $this->firstExisting($cols, ['html', 'html_content', 'body', 'markup', 'content']),
|
||
'css' => $this->firstExisting($cols, ['css', 'css_content']),
|
||
'json' => $this->firstExisting($cols, ['json_content']),
|
||
'editor' => $this->firstExisting($cols, ['editor_type', 'editor']),
|
||
'craft' => $this->firstExisting($cols, ['craft_json', 'craft_content', 'craft_data']),
|
||
'settings' => $this->firstExisting($cols, ['settings_json', 'settings']),
|
||
];
|
||
}
|
||
|
||
private function resolveContentVersionColumns(string $table): array
|
||
{
|
||
$cols = $this->tableColumns($table);
|
||
return [
|
||
'json' => $this->firstExisting($cols, ['json_content']),
|
||
'html' => $this->firstExisting($cols, ['html', 'html_content']),
|
||
'css' => $this->firstExisting($cols, ['css', 'css_content']),
|
||
'editor' => $this->firstExisting($cols, ['editor_type', 'editor']),
|
||
'craft' => $this->firstExisting($cols, ['craft_json', 'craft_content', 'craft_data']),
|
||
'settings' => $this->firstExisting($cols, ['settings_json', 'settings']),
|
||
'is_active' => $this->firstExisting($cols, ['is_active']),
|
||
'was_active' => $this->firstExisting($cols, ['was_active']),
|
||
];
|
||
}
|
||
|
||
private function useUnifiedContent(): bool
|
||
{
|
||
return $this->tableExists($this->contentItemsTable()) && $this->tableExists($this->contentSectionsTable());
|
||
}
|
||
|
||
private function createContentVersion(array $current, array $itemCols, int $customerId, int $sectionId): ?int
|
||
{
|
||
$table = $this->contentVersionsTable();
|
||
if (!$this->tableExists($table)) return null;
|
||
$contentId = (int)($current['id'] ?? 0);
|
||
if ($contentId <= 0) return null;
|
||
|
||
$jsonCol = $itemCols['json'] ?? null;
|
||
$htmlCol = $itemCols['html'] ?? null;
|
||
$cssCol = $itemCols['css'] ?? null;
|
||
$editorCol = $itemCols['editor'] ?? null;
|
||
$craftCol = $itemCols['craft'] ?? null;
|
||
$settingsCol = $itemCols['settings'] ?? null;
|
||
|
||
$json = $jsonCol ? ($current[$jsonCol] ?? null) : null;
|
||
$html = $htmlCol ? ($current[$htmlCol] ?? null) : null;
|
||
$css = $cssCol ? ($current[$cssCol] ?? null) : null;
|
||
$editorType = $editorCol ? ($current[$editorCol] ?? null) : null;
|
||
$craftJson = $craftCol ? ($current[$craftCol] ?? null) : null;
|
||
$settings = $settingsCol ? ($current[$settingsCol] ?? null) : null;
|
||
|
||
try {
|
||
$stmt = $this->pdo->prepare("SELECT MAX(`version_no`) FROM `$table` WHERE `content_id` = :cid");
|
||
$stmt->execute([':cid' => $contentId]);
|
||
$nextVersion = (int)($stmt->fetchColumn() ?: 0) + 1;
|
||
|
||
$versionCols = $this->resolveContentVersionColumns($table);
|
||
$data = [
|
||
'customer_id' => $customerId,
|
||
'content_id' => $contentId,
|
||
'section_id' => $sectionId,
|
||
'version_no' => $nextVersion,
|
||
];
|
||
if ($versionCols['editor']) $data[$versionCols['editor']] = $editorType;
|
||
if ($versionCols['json']) $data[$versionCols['json']] = $json;
|
||
if ($versionCols['html']) $data[$versionCols['html']] = $html;
|
||
if ($versionCols['css']) $data[$versionCols['css']] = $css;
|
||
if ($versionCols['craft']) $data[$versionCols['craft']] = $craftJson;
|
||
if ($versionCols['settings']) $data[$versionCols['settings']] = $settings;
|
||
|
||
$columns = array_keys($data);
|
||
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
|
||
$placeholders = implode(',', array_map(fn($c) => ":$c", $columns));
|
||
$stmt = $this->pdo->prepare("INSERT INTO `$table` ($insertCols) VALUES ($placeholders)");
|
||
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||
$stmt->execute();
|
||
$newId = (int)$this->pdo->lastInsertId();
|
||
|
||
$cleanup = $this->pdo->prepare(
|
||
"DELETE FROM `$table` WHERE `id` IN (
|
||
SELECT `id` FROM (
|
||
SELECT `id` FROM `$table` WHERE `content_id` = :cid ORDER BY `id` DESC LIMIT 10, 1000000
|
||
) t
|
||
)"
|
||
);
|
||
$cleanup->execute([':cid' => $contentId]);
|
||
return $newId;
|
||
} catch (Throwable $e) {
|
||
// Versioning darf nicht das Speichern blockieren.
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function activateContentVersion(int $customerId, int $contentId, int $versionId): bool
|
||
{
|
||
$table = $this->contentVersionsTable();
|
||
if (!$this->tableExists($table)) return false;
|
||
$versionCols = $this->resolveContentVersionColumns($table);
|
||
$isActiveCol = $versionCols['is_active'];
|
||
$wasActiveCol = $versionCols['was_active'];
|
||
if (!$isActiveCol) return false;
|
||
|
||
$this->pdo->prepare("UPDATE `$table` SET `$isActiveCol` = 0 WHERE `customer_id` = :cid AND `content_id` = :content")
|
||
->execute([':cid' => $customerId, ':content' => $contentId]);
|
||
|
||
$set = "`$isActiveCol` = 1";
|
||
if ($wasActiveCol) $set .= ", `$wasActiveCol` = 1";
|
||
$stmt = $this->pdo->prepare(
|
||
"UPDATE `$table` SET $set WHERE `customer_id` = :cid AND `content_id` = :content AND `id` = :id LIMIT 1"
|
||
);
|
||
$stmt->execute([':cid' => $customerId, ':content' => $contentId, ':id' => $versionId]);
|
||
return $stmt->rowCount() > 0;
|
||
}
|
||
|
||
private function deactivateContentVersion(int $customerId, int $contentId): bool
|
||
{
|
||
$table = $this->contentVersionsTable();
|
||
if (!$this->tableExists($table)) return false;
|
||
$versionCols = $this->resolveContentVersionColumns($table);
|
||
$isActiveCol = $versionCols['is_active'];
|
||
if (!$isActiveCol) return false;
|
||
$stmt = $this->pdo->prepare("UPDATE `$table` SET `$isActiveCol` = 0 WHERE `customer_id` = :cid AND `content_id` = :content");
|
||
$stmt->execute([':cid' => $customerId, ':content' => $contentId]);
|
||
return $stmt->rowCount() > 0;
|
||
}
|
||
|
||
private function isLegacyContentKind(string $kind): bool
|
||
{
|
||
return in_array($kind, ['templates', 'sections', 'blocks', 'snippets'], true);
|
||
}
|
||
|
||
private function resolveLegacySectionDefaults(string $kind): array
|
||
{
|
||
if ($kind === 'templates') {
|
||
return ['name' => 'Emailtemplate', 'slug' => 'emailtemplate', 'is_template' => true];
|
||
}
|
||
$name = ucfirst($kind);
|
||
return ['name' => $name, 'slug' => $this->normalizeSectionSlug($kind), 'is_template' => false];
|
||
}
|
||
|
||
private function resolveSectionSlugFromKind(string $kind): string
|
||
{
|
||
$legacy = $this->normalizeResourceKind($kind);
|
||
if ($legacy) {
|
||
return $legacy === 'templates' ? 'emailtemplate' : $legacy;
|
||
}
|
||
return $this->normalizeSectionSlug($kind);
|
||
}
|
||
|
||
private function fetchContentSectionBySlug(int $customerId, string $slug): ?array
|
||
{
|
||
$table = $this->contentSectionsTable();
|
||
if (!$this->tableExists($table)) return null;
|
||
$sql = "SELECT * FROM `$table` WHERE `customer_id` = :cid AND `slug` = :slug LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId, ':slug' => $slug]);
|
||
$row = $stmt->fetch();
|
||
return $row ?: null;
|
||
}
|
||
|
||
private function fetchContentSectionById(int $customerId, int $id): ?array
|
||
{
|
||
if ($id <= 0) return null;
|
||
$table = $this->contentSectionsTable();
|
||
if (!$this->tableExists($table)) return null;
|
||
$sql = "SELECT * FROM `$table` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId, ':id' => $id]);
|
||
$row = $stmt->fetch();
|
||
return $row ?: null;
|
||
}
|
||
|
||
private function ensureContentSection(int $customerId, string $name, string $slug, bool $isTemplate): array
|
||
{
|
||
$slug = $this->normalizeSectionSlug($slug);
|
||
$existing = $this->fetchContentSectionBySlug($customerId, $slug);
|
||
if ($existing) return $existing;
|
||
|
||
$table = $this->contentSectionsTable();
|
||
if (!$this->tableExists($table)) {
|
||
$this->fail('Sections table not available', null, 500);
|
||
}
|
||
|
||
$position = 0;
|
||
if ($isTemplate) {
|
||
$stmt = $this->pdo->prepare("UPDATE `$table` SET `position` = `position` + 1 WHERE `customer_id` = :cid");
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$position = 0;
|
||
} else {
|
||
$stmt = $this->pdo->prepare("SELECT MAX(`position`) FROM `$table` WHERE `customer_id` = :cid");
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$position = (int)($stmt->fetchColumn() ?: 0) + 1;
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare(
|
||
"INSERT INTO `$table` (`customer_id`,`name`,`slug`,`position`,`is_template`) VALUES (:cid,:name,:slug,:pos,:tpl)"
|
||
);
|
||
$stmt->execute([
|
||
':cid' => $customerId,
|
||
':name' => $name,
|
||
':slug' => $slug,
|
||
':pos' => $position,
|
||
':tpl' => $isTemplate ? 1 : 0,
|
||
]);
|
||
$id = (int)$this->pdo->lastInsertId();
|
||
return $this->fetchContentSectionById($customerId, $id) ?? [
|
||
'id' => $id,
|
||
'customer_id' => $customerId,
|
||
'name' => $name,
|
||
'slug' => $slug,
|
||
'position' => $position,
|
||
'is_template' => $isTemplate ? 1 : 0,
|
||
];
|
||
}
|
||
|
||
private function ensureEmailtemplateSection(int $customerId): array
|
||
{
|
||
$existing = $this->fetchContentSectionBySlug($customerId, 'emailtemplate');
|
||
if ($existing) {
|
||
$needsUpdate = false;
|
||
$updates = [];
|
||
if (empty($existing['is_template'])) {
|
||
$updates[] = "`is_template` = 1";
|
||
$needsUpdate = true;
|
||
}
|
||
if (($existing['name'] ?? '') !== 'Emailtemplate') {
|
||
$updates[] = "`name` = 'Emailtemplate'";
|
||
$needsUpdate = true;
|
||
}
|
||
if (($existing['slug'] ?? '') !== 'emailtemplate') {
|
||
$updates[] = "`slug` = 'emailtemplate'";
|
||
$needsUpdate = true;
|
||
}
|
||
if ((int)($existing['position'] ?? 0) !== 0) {
|
||
$updates[] = "`position` = 0";
|
||
$needsUpdate = true;
|
||
}
|
||
if ($needsUpdate) {
|
||
$table = $this->contentSectionsTable();
|
||
if ($this->tableExists($table)) {
|
||
$sql = "UPDATE `$table` SET " . implode(',', $updates) . " WHERE `id` = :id AND `customer_id` = :cid LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':id' => (int)$existing['id'], ':cid' => $customerId]);
|
||
}
|
||
$existing = $this->fetchContentSectionById($customerId, (int)$existing['id']) ?? $existing;
|
||
}
|
||
return $existing;
|
||
}
|
||
return $this->ensureContentSection($customerId, 'Emailtemplate', 'emailtemplate', true);
|
||
}
|
||
|
||
private function resolveSectionFromInput(int $customerId): ?array
|
||
{
|
||
$sectionId = (int)$this->val($this->in, ['section_id', 'section', 'sectionId'], 0);
|
||
if ($sectionId > 0) {
|
||
return $this->fetchContentSectionById($customerId, $sectionId);
|
||
}
|
||
$sectionSlug = trim((string)$this->val($this->in, ['section_slug', 'sectionSlug', 'section_code'], ''));
|
||
if ($sectionSlug !== '') {
|
||
return $this->fetchContentSectionBySlug($customerId, $this->normalizeSectionSlug($sectionSlug));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private function assertTemplateApiNameUnique(
|
||
string $table,
|
||
string $apiCol,
|
||
string $idCol,
|
||
int $customerId,
|
||
string $apiName,
|
||
?int $excludeId
|
||
): void {
|
||
$sql = "SELECT COUNT(*) FROM `$table` WHERE `$apiCol` = :api AND `customer_id` = :cid";
|
||
$params = [':api' => $apiName, ':cid' => $customerId];
|
||
if ($excludeId !== null && $excludeId > 0) {
|
||
$sql .= " AND `$idCol` <> :id";
|
||
$params[':id'] = $excludeId;
|
||
}
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$count = (int)$stmt->fetchColumn();
|
||
if ($count > 0) {
|
||
$this->fail('api_name already exists', ['api_name' => $apiName], 409);
|
||
}
|
||
}
|
||
|
||
private function assertContentApiNameUnique(
|
||
string $table,
|
||
string $apiCol,
|
||
string $idCol,
|
||
int $customerId,
|
||
string $apiName,
|
||
?int $excludeId
|
||
): void {
|
||
$sql = "SELECT COUNT(*) FROM `$table` WHERE `$apiCol` = :api AND `customer_id` = :cid";
|
||
$params = [':api' => $apiName, ':cid' => $customerId];
|
||
if ($excludeId !== null && $excludeId > 0) {
|
||
$sql .= " AND `$idCol` <> :id";
|
||
$params[':id'] = $excludeId;
|
||
}
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$count = (int)$stmt->fetchColumn();
|
||
if ($count > 0) {
|
||
$this->fail('api_name already exists', ['api_name' => $apiName], 409);
|
||
}
|
||
}
|
||
|
||
|
||
// =================================================================
|
||
// 🚀 CRUD HANDLER METHODEN
|
||
// =================================================================
|
||
|
||
private function handleContentList(?array $fixedSection = null): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
|
||
$itemsTable = $this->contentItemsTable();
|
||
$sectionsTable = $this->contentSectionsTable();
|
||
$versionsTable = $this->contentVersionsTable();
|
||
if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) {
|
||
$this->fail('Content tables not available', null, 500);
|
||
}
|
||
$itemCols = $this->resolveContentItemColumns($itemsTable);
|
||
$catCol = $itemCols['category'];
|
||
$htmlCol = $itemCols['html'];
|
||
$jsonCol = $itemCols['json'];
|
||
$onlyActive = (int)$this->val($this->in, ['active_only', 'only_active', 'active'], 0) === 1;
|
||
$versionCols = $onlyActive && $this->tableExists($versionsTable)
|
||
? $this->resolveContentVersionColumns($versionsTable)
|
||
: null;
|
||
|
||
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
|
||
$q = trim((string)$this->val($this->in, 'q', ''));
|
||
$limit = max(1, (int)$this->val($this->in, 'limit', 500));
|
||
$offset = max(0, (int)$this->val($this->in, 'offset', 0));
|
||
|
||
$where = " WHERE i.`customer_id` = :cid ";
|
||
$params = [':cid' => $customerId];
|
||
if ($section && !empty($section['id'])) {
|
||
$where .= " AND i.`section_id` = :sid ";
|
||
$params[':sid'] = (int)$section['id'];
|
||
}
|
||
if ($q !== '') {
|
||
$where .= " AND (i.`name` LIKE :q";
|
||
if ($catCol) {
|
||
$where .= " OR i.`$catCol` LIKE :q";
|
||
}
|
||
$where .= ") ";
|
||
$params[':q'] = '%' . $q . '%';
|
||
}
|
||
|
||
$join = '';
|
||
$select = "i.*, s.`name` AS section_name, s.`slug` AS section_slug, s.`position` AS section_position, s.`is_template` AS section_is_template";
|
||
if ($onlyActive && $versionCols) {
|
||
$join = " JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1";
|
||
$select .= ", v.`id` AS active_version_id, v.`version_no` AS active_version_no, v.`is_active` AS version_is_active, v.`was_active` AS version_was_active";
|
||
if (!empty($versionCols['html'])) $select .= ", v.`{$versionCols['html']}` AS version_html";
|
||
if (!empty($versionCols['json'])) $select .= ", v.`{$versionCols['json']}` AS version_json";
|
||
if (!empty($versionCols['craft'])) $select .= ", v.`{$versionCols['craft']}` AS version_craft";
|
||
if (!empty($versionCols['editor'])) $select .= ", v.`{$versionCols['editor']}` AS version_editor";
|
||
}
|
||
$sql = "SELECT $select
|
||
FROM `$itemsTable` i
|
||
JOIN `$sectionsTable` s ON s.`id` = i.`section_id`
|
||
$join
|
||
$where
|
||
ORDER BY i.`updated_at` DESC, i.`id` DESC
|
||
LIMIT :off,:lim";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
|
||
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||
$stmt->execute();
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
|
||
$out = [];
|
||
foreach ($rows as $r) {
|
||
$item = [
|
||
'id' => $r['id'] ?? null,
|
||
'name' => $r['name'] ?? null,
|
||
'api_name' => $r['api_name'] ?? null,
|
||
'category' => $catCol ? ($r[$catCol] ?? null) : null,
|
||
'section_id' => $r['section_id'] ?? null,
|
||
'section_name' => $r['section_name'] ?? null,
|
||
'section_slug' => $r['section_slug'] ?? null,
|
||
'section_position' => $r['section_position'] ?? null,
|
||
'section_is_template' => (int)($r['section_is_template'] ?? 0),
|
||
'updated_at' => $r['updated_at'] ?? null,
|
||
'created_at' => $r['created_at'] ?? null,
|
||
];
|
||
if ($onlyActive && $versionCols) {
|
||
if (!empty($r['active_version_id'])) $item['active_version_id'] = (int)$r['active_version_id'];
|
||
if (array_key_exists('version_html', $r)) $item['html'] = (string)($r['version_html'] ?? '');
|
||
if (array_key_exists('version_json', $r)) $item['content'] = $r['version_json'];
|
||
if (array_key_exists('version_craft', $r)) $item['craft_json'] = $r['version_craft'];
|
||
if (array_key_exists('version_editor', $r)) $item['editor_type'] = $r['version_editor'];
|
||
} else {
|
||
if ($htmlCol && array_key_exists($htmlCol, $r)) $item['html'] = (string)($r[$htmlCol] ?? '');
|
||
if ($jsonCol && array_key_exists($jsonCol, $r)) $item['content'] = $r[$jsonCol];
|
||
}
|
||
$out[] = $item;
|
||
}
|
||
|
||
$this->respond([
|
||
'ok' => true,
|
||
'kind' => 'content',
|
||
'items' => $out,
|
||
'data' => $out,
|
||
'count' => count($out),
|
||
'offset' => $offset,
|
||
'limit' => $limit,
|
||
]);
|
||
}
|
||
|
||
private function handleContentGet(?array $fixedSection = null): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$id = $this->pullId($this->in);
|
||
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||
|
||
$itemsTable = $this->contentItemsTable();
|
||
$sectionsTable = $this->contentSectionsTable();
|
||
$versionsTable = $this->contentVersionsTable();
|
||
if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) {
|
||
$this->fail('Content tables not available', null, 500);
|
||
}
|
||
$itemCols = $this->resolveContentItemColumns($itemsTable);
|
||
$htmlCol = $itemCols['html'];
|
||
$jsonCol = $itemCols['json'];
|
||
$craftCol = $itemCols['craft'];
|
||
$editorCol = $itemCols['editor'];
|
||
$onlyActive = (int)$this->val($this->in, ['active_only', 'only_active', 'active'], 0) === 1;
|
||
$versionCols = $onlyActive && $this->tableExists($versionsTable)
|
||
? $this->resolveContentVersionColumns($versionsTable)
|
||
: null;
|
||
|
||
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
|
||
$params = [':cid' => $customerId, ':id' => $id];
|
||
$where = " WHERE i.`customer_id` = :cid AND i.`id` = :id ";
|
||
if ($section && !empty($section['id'])) {
|
||
$where .= " AND i.`section_id` = :sid ";
|
||
$params[':sid'] = (int)$section['id'];
|
||
}
|
||
|
||
$select = "i.*, s.`name` AS section_name, s.`slug` AS section_slug, s.`position` AS section_position, s.`is_template` AS section_is_template";
|
||
$join = '';
|
||
if ($onlyActive && $versionCols) {
|
||
$join = "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1";
|
||
if (!empty($versionCols['html'])) $select .= ", v.`{$versionCols['html']}` AS version_html";
|
||
if (!empty($versionCols['json'])) $select .= ", v.`{$versionCols['json']}` AS version_json";
|
||
if (!empty($versionCols['craft'])) $select .= ", v.`{$versionCols['craft']}` AS version_craft";
|
||
if (!empty($versionCols['editor'])) $select .= ", v.`{$versionCols['editor']}` AS version_editor";
|
||
$select .= ", v.`id` AS active_version_id, v.`version_no` AS active_version_no, v.`is_active` AS version_is_active, v.`was_active` AS version_was_active";
|
||
}
|
||
$sql = "SELECT $select
|
||
FROM `$itemsTable` i
|
||
JOIN `$sectionsTable` s ON s.`id` = i.`section_id`
|
||
$join
|
||
$where
|
||
LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||
$stmt->execute();
|
||
$row = $stmt->fetch();
|
||
if (!$row) $this->fail('Not found', ['id' => $id], 404);
|
||
|
||
$html = $htmlCol ? (string)($row[$htmlCol] ?? '') : '';
|
||
$json = $jsonCol ? ($row[$jsonCol] ?? null) : null;
|
||
if ($onlyActive && $versionCols) {
|
||
if (array_key_exists('version_html', $row)) $html = (string)($row['version_html'] ?? $html);
|
||
if (array_key_exists('version_json', $row)) $json = $row['version_json'] ?? $json;
|
||
}
|
||
$gjsComponents = [];
|
||
if ($json !== null) {
|
||
$decoded = json_decode((string)$json, true);
|
||
if (is_array($decoded)) $gjsComponents = $decoded;
|
||
}
|
||
if (!$gjsComponents && $html !== '') {
|
||
$gjsComponents = $this->parseHtmlToGjsComponents($html);
|
||
}
|
||
|
||
$item = $row;
|
||
$item['content'] = $json;
|
||
$item['section_name'] = $row['section_name'] ?? null;
|
||
$item['section_slug'] = $row['section_slug'] ?? null;
|
||
$item['section_position'] = $row['section_position'] ?? null;
|
||
$item['section_is_template'] = (int)($row['section_is_template'] ?? 0);
|
||
|
||
$editorType = $editorCol ? ($row[$editorCol] ?? null) : null;
|
||
$craftJson = $craftCol ? ($row[$craftCol] ?? null) : null;
|
||
if ($onlyActive && $versionCols) {
|
||
if (array_key_exists('version_editor', $row)) $editorType = $row['version_editor'] ?? $editorType;
|
||
if (array_key_exists('version_craft', $row)) $craftJson = $row['version_craft'] ?? $craftJson;
|
||
}
|
||
|
||
$this->respond([
|
||
'ok' => true,
|
||
'kind' => 'content',
|
||
'id' => $row['id'] ?? $id,
|
||
'item' => $item,
|
||
'data' => $item,
|
||
'html' => $html,
|
||
'content' => $json,
|
||
'gjs_components' => $gjsComponents,
|
||
'editor_type' => $editorType,
|
||
'craft_json' => $craftJson,
|
||
]);
|
||
}
|
||
|
||
private function handleContentCreate(?array $fixedSection = null): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
|
||
$itemsTable = $this->contentItemsTable();
|
||
if (!$this->tableExists($itemsTable)) {
|
||
$this->fail('Content table not available', null, 500);
|
||
}
|
||
$itemCols = $this->resolveContentItemColumns($itemsTable);
|
||
$catCol = $itemCols['category'];
|
||
$htmlCol = $itemCols['html'];
|
||
$jsonCol = $itemCols['json'];
|
||
$editorCol = $itemCols['editor'];
|
||
$craftCol = $itemCols['craft'];
|
||
$settingsCol = $itemCols['settings'];
|
||
|
||
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
|
||
if ($name === '') $this->fail('name required', null, 422);
|
||
|
||
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
|
||
if (!$section) $this->fail('section required', null, 422);
|
||
|
||
$apiRaw = trim((string)$this->val($this->in, ['api_name', 'apiName', 'api'], ''));
|
||
$apiName = $apiRaw !== '' ? $this->normalizeApiName($apiRaw) : '';
|
||
$isTemplate = !empty($section['is_template']);
|
||
if ($isTemplate && $apiName === '') {
|
||
$this->fail('api_name required', null, 422);
|
||
}
|
||
|
||
if ($apiName !== '') {
|
||
$this->assertContentApiNameUnique($itemsTable, 'api_name', 'id', $customerId, $apiName, null);
|
||
} else {
|
||
$apiName = null;
|
||
}
|
||
|
||
$html = $this->val($this->in, ['html', 'body', 'markup', 'content'], null);
|
||
$json = $this->val($this->in, ['content_json', 'json', 'structure_json'], null);
|
||
$category = $this->val($this->in, ['category', 'cat'], null);
|
||
$editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], '')));
|
||
$craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null);
|
||
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
|
||
|
||
$data = [
|
||
'customer_id' => $customerId,
|
||
'section_id' => (int)$section['id'],
|
||
'name' => $name,
|
||
'api_name' => $apiName,
|
||
];
|
||
if ($category !== null && $catCol) $data[$catCol] = (string)$category;
|
||
if ($editorType !== '' && $editorCol) $data[$editorCol] = $editorType;
|
||
if ($craftJson !== null && $craftCol) $data[$craftCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson);
|
||
if ($settings !== null && $settingsCol) $data[$settingsCol] = is_string($settings) ? $settings : $this->encodeJson($settings);
|
||
|
||
if ($json !== null) {
|
||
if (!$jsonCol) $this->fail('json_content column missing', null, 500);
|
||
$components = is_string($json) ? json_decode($json, true) : $json;
|
||
if (is_array($components)) {
|
||
$components = $this->cleanReferenceComponents($components);
|
||
$data[$jsonCol] = $this->encodeJson($components);
|
||
} else {
|
||
$data[$jsonCol] = is_string($json) ? $json : '';
|
||
}
|
||
if ($html !== null) {
|
||
if (!$htmlCol) $this->fail('html column missing', null, 500);
|
||
$data[$htmlCol] = (string)$html;
|
||
}
|
||
} elseif ($html !== null) {
|
||
if (!$htmlCol) $this->fail('html column missing', null, 500);
|
||
$data[$htmlCol] = (string)$html;
|
||
}
|
||
|
||
$columns = array_keys($data);
|
||
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
|
||
$placeholders = implode(',', array_map(fn($c) => ":$c", $columns));
|
||
$stmt = $this->pdo->prepare("INSERT INTO `$itemsTable` ($insertCols) VALUES ($placeholders)");
|
||
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||
$stmt->execute();
|
||
$newId = (int)$this->pdo->lastInsertId();
|
||
$activateVersion = (int)$this->val($this->in, ['activate_version', 'activate', 'set_active'], 0) === 1;
|
||
try {
|
||
$stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||
$stmt->execute([':id' => $newId, ':cid' => $customerId]);
|
||
$row = $stmt->fetch();
|
||
if ($row) {
|
||
$vid = $this->createContentVersion($row, $itemCols, $customerId, (int)$section['id']);
|
||
if ($activateVersion && $vid) {
|
||
$this->activateContentVersion($customerId, (int)$row['id'], $vid);
|
||
}
|
||
}
|
||
} catch (Throwable $e) {
|
||
// ignore versioning failures on create
|
||
}
|
||
$this->respond(['ok' => true, 'kind' => 'content', 'id' => $newId, 'item' => ['id' => $newId, 'name' => $name]]);
|
||
}
|
||
|
||
private function handleContentUpdate(?array $fixedSection = null): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
|
||
$itemsTable = $this->contentItemsTable();
|
||
if (!$this->tableExists($itemsTable)) {
|
||
$this->fail('Content table not available', null, 500);
|
||
}
|
||
$itemCols = $this->resolveContentItemColumns($itemsTable);
|
||
$catCol = $itemCols['category'];
|
||
$htmlCol = $itemCols['html'];
|
||
$jsonCol = $itemCols['json'];
|
||
$editorCol = $itemCols['editor'];
|
||
$craftCol = $itemCols['craft'];
|
||
$settingsCol = $itemCols['settings'];
|
||
|
||
$id = $this->pullId($this->in);
|
||
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||
|
||
$stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1");
|
||
$stmt->execute([':cid' => $customerId, ':id' => $id]);
|
||
$current = $stmt->fetch();
|
||
if (!$current) $this->fail('Not found', ['id' => $id], 404);
|
||
|
||
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
|
||
if (!$section) {
|
||
$section = $this->fetchContentSectionById($customerId, (int)($current['section_id'] ?? 0));
|
||
}
|
||
if (!$section) $this->fail('section required', null, 422);
|
||
|
||
$data = [];
|
||
$name = $this->val($this->in, ['name', 'title'], null);
|
||
if ($name !== null) $data['name'] = (string)$name;
|
||
|
||
$category = $this->val($this->in, ['category', 'cat'], null);
|
||
if ($category !== null && $catCol) $data[$catCol] = (string)$category;
|
||
|
||
$apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null);
|
||
$apiName = $apiRaw !== null ? $this->normalizeApiName((string)$apiRaw) : null;
|
||
$isTemplate = !empty($section['is_template']);
|
||
if ($isTemplate && $apiRaw === null && empty($current['api_name'])) {
|
||
$this->fail('api_name required', null, 422);
|
||
}
|
||
if ($apiName !== null) {
|
||
if ($apiName === '' && $isTemplate) {
|
||
$this->fail('api_name required', null, 422);
|
||
}
|
||
if ($apiName !== '') {
|
||
$this->assertContentApiNameUnique($itemsTable, 'api_name', 'id', $customerId, $apiName, (int)$id);
|
||
$data['api_name'] = $apiName;
|
||
} else {
|
||
$data['api_name'] = null;
|
||
}
|
||
}
|
||
|
||
$sectionId = $section['id'] ?? null;
|
||
if ($sectionId && (int)$sectionId !== (int)($current['section_id'] ?? 0)) {
|
||
$data['section_id'] = (int)$sectionId;
|
||
}
|
||
|
||
$html = $this->val($this->in, ['html', 'body', 'markup', 'content'], null);
|
||
$json = $this->val($this->in, ['content_json', 'json', 'structure_json'], null);
|
||
if ($json !== null) {
|
||
if (!$jsonCol) $this->fail('json_content column missing', null, 500);
|
||
$components = is_string($json) ? json_decode($json, true) : $json;
|
||
if (is_array($components)) {
|
||
$components = $this->cleanReferenceComponents($components);
|
||
$data[$jsonCol] = $this->encodeJson($components);
|
||
} else {
|
||
$data[$jsonCol] = is_string($json) ? $json : '';
|
||
}
|
||
if ($html !== null) {
|
||
if (!$htmlCol) $this->fail('html column missing', null, 500);
|
||
$data[$htmlCol] = (string)$html;
|
||
}
|
||
} elseif ($html !== null) {
|
||
if (!$htmlCol) $this->fail('html column missing', null, 500);
|
||
$data[$htmlCol] = (string)$html;
|
||
}
|
||
|
||
$editorType = $this->val($this->in, ['editor_type', 'editor'], null);
|
||
if ($editorType !== null && $editorCol) $data[$editorCol] = strtolower(trim((string)$editorType));
|
||
$craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null);
|
||
if ($craftJson !== null && $craftCol) $data[$craftCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson);
|
||
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
|
||
if ($settings !== null && $settingsCol) $data[$settingsCol] = is_string($settings) ? $settings : $this->encodeJson($settings);
|
||
|
||
if (!$data) {
|
||
$this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'updated' => true]);
|
||
return;
|
||
}
|
||
|
||
$activateVersion = (int)$this->val($this->in, ['activate_version', 'activate', 'set_active'], 0) === 1;
|
||
$versionCols = array_filter([$jsonCol, $htmlCol, $craftCol, $settingsCol, $editorCol]);
|
||
$shouldSnapshot = false;
|
||
foreach ($versionCols as $col) {
|
||
if (array_key_exists($col, $data)) {
|
||
$shouldSnapshot = true;
|
||
break;
|
||
}
|
||
}
|
||
$requestedVersionId = (int)$this->val($this->in, ['version_id', 'versionId', 'version'], 0);
|
||
$updatedExistingVersion = false;
|
||
|
||
$set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data)));
|
||
$data['id'] = $id;
|
||
$data['customer_id'] = $customerId;
|
||
$sql = "UPDATE `$itemsTable` SET $set WHERE `id` = :id AND `customer_id` = :customer_id LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||
$stmt->execute();
|
||
if ($shouldSnapshot && $requestedVersionId > 0) {
|
||
try {
|
||
$versionsTable = $this->contentVersionsTable();
|
||
if ($this->tableExists($versionsTable)) {
|
||
$vCols = $this->resolveContentVersionColumns($versionsTable);
|
||
$stmt = $this->pdo->prepare(
|
||
"SELECT `id`,`content_id`,`customer_id`,`section_id`,`is_active`,`was_active` FROM `$versionsTable`
|
||
WHERE `id` = :id AND `customer_id` = :cid AND `content_id` = :content LIMIT 1"
|
||
);
|
||
$stmt->execute([':id' => $requestedVersionId, ':cid' => $customerId, ':content' => $id]);
|
||
$versionRow = $stmt->fetch();
|
||
if ($versionRow && (int)($versionRow['is_active'] ?? 0) === 0 && (int)($versionRow['was_active'] ?? 0) === 0) {
|
||
$vdata = [];
|
||
if ($htmlCol && isset($data[$htmlCol]) && !empty($vCols['html'])) $vdata[$vCols['html']] = $data[$htmlCol];
|
||
if ($jsonCol && isset($data[$jsonCol]) && !empty($vCols['json'])) $vdata[$vCols['json']] = $data[$jsonCol];
|
||
if ($craftCol && isset($data[$craftCol]) && !empty($vCols['craft'])) $vdata[$vCols['craft']] = $data[$craftCol];
|
||
if ($settingsCol && isset($data[$settingsCol]) && !empty($vCols['settings'])) $vdata[$vCols['settings']] = $data[$settingsCol];
|
||
if ($editorCol && isset($data[$editorCol]) && !empty($vCols['editor'])) $vdata[$vCols['editor']] = $data[$editorCol];
|
||
if ($vdata) {
|
||
$setVersion = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($vdata)));
|
||
$vdata['id'] = $requestedVersionId;
|
||
$vdata['customer_id'] = $customerId;
|
||
$vdata['content_id'] = $id;
|
||
$sql = "UPDATE `$versionsTable` SET $setVersion WHERE `id` = :id AND `customer_id` = :customer_id AND `content_id` = :content_id LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($vdata as $k => $v) $stmt->bindValue(":$k", $v);
|
||
$stmt->execute();
|
||
$updatedExistingVersion = $stmt->rowCount() > 0;
|
||
if ($updatedExistingVersion && $activateVersion) {
|
||
$this->activateContentVersion($customerId, (int)$id, (int)$requestedVersionId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (Throwable $e) {
|
||
$updatedExistingVersion = false;
|
||
}
|
||
}
|
||
if ($shouldSnapshot && !$updatedExistingVersion) {
|
||
try {
|
||
$stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1");
|
||
$stmt->execute([':cid' => $customerId, ':id' => $id]);
|
||
$row = $stmt->fetch();
|
||
if ($row) {
|
||
$vid = $this->createContentVersion($row, $itemCols, $customerId, (int)($section['id'] ?? 0));
|
||
if ($activateVersion && $vid) {
|
||
$this->activateContentVersion($customerId, (int)$row['id'], $vid);
|
||
}
|
||
}
|
||
} catch (Throwable $e) {
|
||
// ignore versioning failures
|
||
}
|
||
}
|
||
$this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'updated' => true]);
|
||
}
|
||
|
||
private function handleContentDelete(?array $fixedSection = null): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$id = $this->pullId($this->in);
|
||
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||
|
||
$itemsTable = $this->contentItemsTable();
|
||
if (!$this->tableExists($itemsTable)) {
|
||
$this->fail('Content table not available', null, 500);
|
||
}
|
||
|
||
$section = $fixedSection ?: $this->resolveSectionFromInput($customerId);
|
||
$params = [':cid' => $customerId, ':id' => $id];
|
||
$where = "WHERE `customer_id` = :cid AND `id` = :id";
|
||
if ($section && !empty($section['id'])) {
|
||
$where .= " AND `section_id` = :sid";
|
||
$params[':sid'] = (int)$section['id'];
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare("DELETE FROM `$itemsTable` $where LIMIT 1");
|
||
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
$this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'deleted' => true]);
|
||
}
|
||
|
||
private function handleContentVersionsList(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$contentId = (int)$this->val($this->in, ['content_id', 'id'], 0);
|
||
if ($contentId <= 0) $this->fail('content_id required', null, 422);
|
||
|
||
$table = $this->contentVersionsTable();
|
||
if (!$this->tableExists($table)) {
|
||
$this->respond(['ok' => true, 'items' => [], 'data' => []]);
|
||
return;
|
||
}
|
||
|
||
$itemsTable = $this->contentItemsTable();
|
||
if ($this->tableExists($itemsTable)) {
|
||
$stmt = $this->pdo->prepare("SELECT `id` FROM `$itemsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||
$stmt->execute([':id' => $contentId, ':cid' => $customerId]);
|
||
if (!$stmt->fetch()) $this->fail('Not found', ['id' => $contentId], 404);
|
||
}
|
||
|
||
$cols = $this->resolveContentVersionColumns($table);
|
||
$select = ['`id`','`content_id`','`section_id`','`version_no`','`editor_type`','`created_at`'];
|
||
if (!empty($cols['is_active'])) $select[] = "`{$cols['is_active']}` AS is_active";
|
||
if (!empty($cols['was_active'])) $select[] = "`{$cols['was_active']}` AS was_active";
|
||
$stmt = $this->pdo->prepare(
|
||
"SELECT " . implode(',', $select) . " FROM `$table` WHERE `customer_id` = :cid AND `content_id` = :content
|
||
ORDER BY `id` DESC LIMIT 10"
|
||
);
|
||
$stmt->execute([':cid' => $customerId, ':content' => $contentId]);
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
$items = array_map(static function ($row) {
|
||
return [
|
||
'id' => (int)($row['id'] ?? 0),
|
||
'content_id' => (int)($row['content_id'] ?? 0),
|
||
'section_id' => (int)($row['section_id'] ?? 0),
|
||
'version_no' => (int)($row['version_no'] ?? 0),
|
||
'editor_type' => $row['editor_type'] ?? null,
|
||
'created_at' => $row['created_at'] ?? null,
|
||
'is_active' => (int)($row['is_active'] ?? 0),
|
||
'was_active' => (int)($row['was_active'] ?? 0),
|
||
];
|
||
}, $rows);
|
||
$this->respond(['ok' => true, 'items' => $items, 'data' => $items]);
|
||
}
|
||
|
||
private function handleContentVersionsGet(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$id = (int)$this->pullId($this->in);
|
||
if ($id <= 0) $this->fail('id required', null, 422);
|
||
$contentId = (int)$this->val($this->in, ['content_id', 'content'], 0);
|
||
|
||
$table = $this->contentVersionsTable();
|
||
if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500);
|
||
|
||
$sql = "SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid";
|
||
$params = [':id' => $id, ':cid' => $customerId];
|
||
if ($contentId > 0) {
|
||
$sql .= " AND `content_id` = :content";
|
||
$params[':content'] = $contentId;
|
||
}
|
||
$sql .= " LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$row = $stmt->fetch();
|
||
if (!$row) $this->fail('Not found', ['id' => $id], 404);
|
||
$this->respond(['ok' => true, 'item' => $row, 'data' => $row]);
|
||
}
|
||
|
||
private function handleContentVersionsRestore(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0);
|
||
if ($versionId <= 0) $this->fail('version id required', null, 422);
|
||
$contentId = (int)$this->val($this->in, ['content_id', 'content'], 0);
|
||
|
||
$versionsTable = $this->contentVersionsTable();
|
||
$itemsTable = $this->contentItemsTable();
|
||
if (!$this->tableExists($versionsTable) || !$this->tableExists($itemsTable)) {
|
||
$this->fail('Content tables not available', null, 500);
|
||
}
|
||
|
||
$sql = "SELECT * FROM `$versionsTable` WHERE `id` = :id AND `customer_id` = :cid";
|
||
$params = [':id' => $versionId, ':cid' => $customerId];
|
||
if ($contentId > 0) {
|
||
$sql .= " AND `content_id` = :content";
|
||
$params[':content'] = $contentId;
|
||
}
|
||
$sql .= " LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$version = $stmt->fetch();
|
||
if (!$version) $this->fail('Not found', ['id' => $versionId], 404);
|
||
|
||
$itemCols = $this->resolveContentItemColumns($itemsTable);
|
||
$versionCols = $this->resolveContentVersionColumns($versionsTable);
|
||
$data = [];
|
||
if (!empty($itemCols['json']) && $versionCols['json']) $data[$itemCols['json']] = $version[$versionCols['json']] ?? null;
|
||
if (!empty($itemCols['html']) && $versionCols['html']) $data[$itemCols['html']] = $version[$versionCols['html']] ?? null;
|
||
if (!empty($itemCols['css']) && $versionCols['css']) $data[$itemCols['css']] = $version[$versionCols['css']] ?? null;
|
||
if (!empty($itemCols['craft']) && $versionCols['craft']) $data[$itemCols['craft']] = $version[$versionCols['craft']] ?? null;
|
||
if (!empty($itemCols['settings']) && $versionCols['settings']) $data[$itemCols['settings']] = $version[$versionCols['settings']] ?? null;
|
||
if (!empty($itemCols['editor']) && $versionCols['editor']) $data[$itemCols['editor']] = $version[$versionCols['editor']] ?? null;
|
||
|
||
if ($data) {
|
||
$set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data)));
|
||
$data['id'] = (int)($version['content_id'] ?? 0);
|
||
$data['customer_id'] = $customerId;
|
||
$sql = "UPDATE `$itemsTable` SET $set WHERE `id` = :id AND `customer_id` = :customer_id LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||
$stmt->execute();
|
||
}
|
||
|
||
$this->respond(['ok' => true, 'restored' => true, 'content_id' => (int)($version['content_id'] ?? 0)]);
|
||
}
|
||
|
||
private function handleContentVersionsActivate(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0);
|
||
if ($versionId <= 0) $this->fail('version id required', null, 422);
|
||
|
||
$table = $this->contentVersionsTable();
|
||
if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500);
|
||
|
||
$stmt = $this->pdo->prepare("SELECT `id`,`content_id` FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||
$stmt->execute([':id' => $versionId, ':cid' => $customerId]);
|
||
$row = $stmt->fetch();
|
||
if (!$row) $this->fail('Not found', ['id' => $versionId], 404);
|
||
|
||
$ok = $this->activateContentVersion($customerId, (int)$row['content_id'], $versionId);
|
||
if (!$ok) $this->fail('Activation failed', ['id' => $versionId], 500);
|
||
$this->respond(['ok' => true, 'activated' => true, 'id' => $versionId]);
|
||
}
|
||
|
||
private function handleContentVersionsDeactivate(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$contentId = (int)$this->val($this->in, ['content_id', 'content'], 0);
|
||
if ($contentId <= 0) $this->fail('content_id required', null, 422);
|
||
|
||
$ok = $this->deactivateContentVersion($customerId, $contentId);
|
||
if (!$ok) $this->fail('Deactivation failed', ['content_id' => $contentId], 500);
|
||
$this->respond(['ok' => true, 'deactivated' => true, 'content_id' => $contentId]);
|
||
}
|
||
|
||
private function handleContentVersionsDelete(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0);
|
||
if ($versionId <= 0) $this->fail('version id required', null, 422);
|
||
$contentId = (int)$this->val($this->in, ['content_id', 'content'], 0);
|
||
|
||
$table = $this->contentVersionsTable();
|
||
if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500);
|
||
|
||
$sql = "SELECT `id`,`content_id`,`customer_id`,`is_active` FROM `$table` WHERE `id` = :id AND `customer_id` = :cid";
|
||
$params = [':id' => $versionId, ':cid' => $customerId];
|
||
if ($contentId > 0) {
|
||
$sql .= " AND `content_id` = :content";
|
||
$params[':content'] = $contentId;
|
||
}
|
||
$sql .= " LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$row = $stmt->fetch();
|
||
if (!$row) $this->fail('Not found', ['id' => $versionId], 404);
|
||
if ((int)($row['is_active'] ?? 0) === 1) {
|
||
$this->fail('Active versions cannot be deleted', ['id' => $versionId], 422);
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||
$stmt->execute([':id' => $versionId, ':cid' => $customerId]);
|
||
$this->respond(['ok' => true, 'deleted' => true, 'id' => $versionId]);
|
||
}
|
||
|
||
private function handleSectionsConfigList(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
|
||
$table = $this->contentSectionsTable();
|
||
if (!$this->tableExists($table)) {
|
||
$this->fail('Sections table not available', null, 500);
|
||
}
|
||
|
||
$this->ensureEmailtemplateSection($customerId);
|
||
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `position` ASC, `id` ASC");
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
$items = array_map(static function ($row) {
|
||
return [
|
||
'id' => (int)($row['id'] ?? 0),
|
||
'name' => $row['name'] ?? '',
|
||
'slug' => $row['slug'] ?? '',
|
||
'position' => (int)($row['position'] ?? 0),
|
||
'is_template' => (int)($row['is_template'] ?? 0),
|
||
];
|
||
}, $rows);
|
||
$this->respond(['ok' => true, 'items' => $items, 'data' => $items]);
|
||
}
|
||
|
||
private function handleSectionsConfigGet(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$id = $this->pullId($this->in);
|
||
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||
|
||
$table = $this->contentSectionsTable();
|
||
if (!$this->tableExists($table)) {
|
||
$this->fail('Sections table not available', null, 500);
|
||
}
|
||
|
||
$row = $this->fetchContentSectionById($customerId, (int)$id);
|
||
if (!$row) $this->fail('Not found', ['id' => $id], 404);
|
||
$item = [
|
||
'id' => (int)($row['id'] ?? 0),
|
||
'name' => $row['name'] ?? '',
|
||
'slug' => $row['slug'] ?? '',
|
||
'position' => (int)($row['position'] ?? 0),
|
||
'is_template' => (int)($row['is_template'] ?? 0),
|
||
];
|
||
$this->respond(['ok' => true, 'item' => $item, 'data' => $item]);
|
||
}
|
||
|
||
private function handleSectionsConfigCreate(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$table = $this->contentSectionsTable();
|
||
if (!$this->tableExists($table)) {
|
||
$this->fail('Sections table not available', null, 500);
|
||
}
|
||
|
||
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
|
||
if ($name === '') $this->fail('name required', null, 422);
|
||
$slug = $this->normalizeSectionSlug($name);
|
||
if ($slug === '') $this->fail('slug required', null, 422);
|
||
|
||
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid AND (LOWER(`name`) = LOWER(:name) OR LOWER(`slug`) = LOWER(:slug))");
|
||
$stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug]);
|
||
if ((int)$stmt->fetchColumn() > 0) {
|
||
$this->fail('section name already exists', ['name' => $name], 409);
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare("SELECT MAX(`position`) FROM `$table` WHERE `customer_id` = :cid");
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$position = (int)($stmt->fetchColumn() ?: 0) + 1;
|
||
|
||
$stmt = $this->pdo->prepare(
|
||
"INSERT INTO `$table` (`customer_id`,`name`,`slug`,`position`,`is_template`) VALUES (:cid,:name,:slug,:pos,0)"
|
||
);
|
||
$stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug, ':pos' => $position]);
|
||
$id = (int)$this->pdo->lastInsertId();
|
||
$this->respond(['ok' => true, 'id' => $id, 'item' => ['id' => $id, 'name' => $name, 'slug' => $slug, 'position' => $position]]);
|
||
}
|
||
|
||
private function handleSectionsConfigUpdate(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$id = $this->pullId($this->in);
|
||
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||
$table = $this->contentSectionsTable();
|
||
if (!$this->tableExists($table)) {
|
||
$this->fail('Sections table not available', null, 500);
|
||
}
|
||
|
||
$section = $this->fetchContentSectionById($customerId, (int)$id);
|
||
if (!$section) $this->fail('Not found', ['id' => $id], 404);
|
||
if (!empty($section['is_template'])) {
|
||
$this->fail('Emailtemplate section cannot be changed', null, 422);
|
||
}
|
||
|
||
$name = $this->val($this->in, ['name', 'title'], null);
|
||
if ($name === null) {
|
||
$this->respond(['ok' => true, 'id' => (int)$id, 'updated' => true]);
|
||
return;
|
||
}
|
||
$name = trim((string)$name);
|
||
if ($name === '') $this->fail('name required', null, 422);
|
||
|
||
$slug = $this->normalizeSectionSlug($name);
|
||
if ($slug === '') $this->fail('slug required', null, 422);
|
||
|
||
$stmt = $this->pdo->prepare(
|
||
"SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid AND (LOWER(`name`) = LOWER(:name) OR LOWER(`slug`) = LOWER(:slug)) AND `id` <> :id"
|
||
);
|
||
$stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug, ':id' => (int)$id]);
|
||
if ((int)$stmt->fetchColumn() > 0) {
|
||
$this->fail('section name already exists', ['name' => $name], 409);
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare("UPDATE `$table` SET `name` = :name, `slug` = :slug WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||
$stmt->execute([':name' => $name, ':slug' => $slug, ':id' => (int)$id, ':cid' => $customerId]);
|
||
$this->respond(['ok' => true, 'id' => (int)$id, 'updated' => true, 'item' => ['id' => (int)$id, 'name' => $name, 'slug' => $slug]]);
|
||
}
|
||
|
||
private function handleSectionsConfigDelete(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$id = $this->pullId($this->in);
|
||
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||
$moveTo = (int)$this->val($this->in, ['move_to', 'move_to_id', 'target_section'], 0);
|
||
if ($moveTo <= 0) $this->fail('move_to required', null, 422);
|
||
|
||
$sectionsTable = $this->contentSectionsTable();
|
||
$itemsTable = $this->contentItemsTable();
|
||
if (!$this->tableExists($sectionsTable) || !$this->tableExists($itemsTable)) {
|
||
$this->fail('Content tables not available', null, 500);
|
||
}
|
||
|
||
$section = $this->fetchContentSectionById($customerId, (int)$id);
|
||
if (!$section) $this->fail('Not found', ['id' => $id], 404);
|
||
if (!empty($section['is_template'])) {
|
||
$this->fail('Emailtemplate section cannot be deleted', null, 422);
|
||
}
|
||
|
||
$target = $this->fetchContentSectionById($customerId, $moveTo);
|
||
if (!$target) $this->fail('move_to section not found', null, 404);
|
||
if ((int)$target['id'] === (int)$id) $this->fail('move_to must differ', null, 422);
|
||
|
||
$stmt = $this->pdo->prepare("UPDATE `$itemsTable` SET `section_id` = :target WHERE `customer_id` = :cid AND `section_id` = :sid");
|
||
$stmt->execute([':target' => (int)$target['id'], ':cid' => $customerId, ':sid' => (int)$id]);
|
||
|
||
$stmt = $this->pdo->prepare("DELETE FROM `$sectionsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||
$stmt->execute([':id' => (int)$id, ':cid' => $customerId]);
|
||
|
||
$this->respond(['ok' => true, 'id' => (int)$id, 'deleted' => true]);
|
||
}
|
||
|
||
private function handleSectionsConfigReorder(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$order = $this->val($this->in, ['order', 'items', 'ids'], []);
|
||
if (!is_array($order)) $this->fail('order must be array', null, 422);
|
||
|
||
$table = $this->contentSectionsTable();
|
||
if (!$this->tableExists($table)) {
|
||
$this->fail('Sections table not available', null, 500);
|
||
}
|
||
|
||
$this->ensureEmailtemplateSection($customerId);
|
||
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `position` ASC, `id` ASC");
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
$byId = [];
|
||
$emailtemplateId = null;
|
||
foreach ($rows as $row) {
|
||
$id = (int)($row['id'] ?? 0);
|
||
$byId[$id] = $row;
|
||
if (!empty($row['is_template'])) $emailtemplateId = $id;
|
||
}
|
||
|
||
$newOrder = [];
|
||
if ($emailtemplateId) $newOrder[] = $emailtemplateId;
|
||
foreach ($order as $rawId) {
|
||
$id = (int)$rawId;
|
||
if ($id <= 0 || $id === $emailtemplateId) continue;
|
||
if (!isset($byId[$id])) continue;
|
||
$newOrder[] = $id;
|
||
}
|
||
foreach ($byId as $id => $_row) {
|
||
if ($id === $emailtemplateId) continue;
|
||
if (!in_array($id, $newOrder, true)) $newOrder[] = $id;
|
||
}
|
||
|
||
$pos = 0;
|
||
$stmt = $this->pdo->prepare("UPDATE `$table` SET `position` = :pos WHERE `id` = :id AND `customer_id` = :cid");
|
||
foreach ($newOrder as $id) {
|
||
$stmt->execute([':pos' => $pos, ':id' => (int)$id, ':cid' => $customerId]);
|
||
$pos++;
|
||
}
|
||
$this->respond(['ok' => true, 'updated' => true]);
|
||
}
|
||
|
||
private function handleLegacyContentList(string $kind): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
$defaults = $this->resolveLegacySectionDefaults($kind);
|
||
if ($defaults['is_template']) {
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
} else {
|
||
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
|
||
}
|
||
if (!$section) {
|
||
$this->respond([
|
||
'ok' => true,
|
||
'kind' => 'content',
|
||
'items' => [],
|
||
'data' => [],
|
||
'count' => 0,
|
||
'offset' => 0,
|
||
'limit' => 0,
|
||
]);
|
||
return;
|
||
}
|
||
$this->handleContentList($section);
|
||
}
|
||
|
||
private function handleLegacyContentGet(string $kind): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
$defaults = $this->resolveLegacySectionDefaults($kind);
|
||
if ($defaults['is_template']) {
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
} else {
|
||
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
|
||
}
|
||
if (!$section) $this->fail('section not configured', ['kind' => $kind], 404);
|
||
$this->handleContentGet($section);
|
||
}
|
||
|
||
private function handleLegacyContentCreate(string $kind): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
$defaults = $this->resolveLegacySectionDefaults($kind);
|
||
if ($defaults['is_template']) {
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
} else {
|
||
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
|
||
}
|
||
if (!$section) $this->fail('section not configured', ['kind' => $kind], 404);
|
||
$this->handleContentCreate($section);
|
||
}
|
||
|
||
private function handleLegacyContentUpdate(string $kind): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
$defaults = $this->resolveLegacySectionDefaults($kind);
|
||
if ($defaults['is_template']) {
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
} else {
|
||
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
|
||
}
|
||
if (!$section) $this->fail('section not configured', ['kind' => $kind], 404);
|
||
$this->handleContentUpdate($section);
|
||
}
|
||
|
||
private function handleLegacyContentDelete(string $kind): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
$defaults = $this->resolveLegacySectionDefaults($kind);
|
||
if ($defaults['is_template']) {
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
} else {
|
||
$section = $this->fetchContentSectionBySlug($customerId, $defaults['slug']);
|
||
}
|
||
if (!$section) $this->fail('section not configured', ['kind' => $kind], 404);
|
||
$this->handleContentDelete($section);
|
||
}
|
||
|
||
/**
|
||
* Allgemeine Methode zur Handhabung von LIST-Anfragen.
|
||
*/
|
||
private function handleList(string $kind): void
|
||
{
|
||
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
|
||
$this->handleLegacyContentList($kind);
|
||
return;
|
||
}
|
||
$auth = $this->requireAuth();
|
||
$t = $this->tableMap[$kind];
|
||
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||
$cfg = $this->conf['columns'][$kind] ?? [];
|
||
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
||
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
||
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
||
$apiCol = null;
|
||
$apiCol = ($kind === 'templates') ? $this->firstExisting($allCols, ['api_name']) : null;
|
||
$q = trim((string)$this->val($this->in, 'q', ''));
|
||
$limit = max(1, (int)$this->val($this->in, 'limit', 500));
|
||
$offset = max(0, (int)$this->val($this->in, 'offset', 0));
|
||
|
||
$where = ' WHERE 1=1 ';
|
||
$params = [];
|
||
// Suchlogik (q)
|
||
if ($q !== '') {
|
||
$parts = ["`$nameCol` LIKE :q"];
|
||
if ($descCol) $parts[] = "`$descCol` LIKE :q";
|
||
if ($catCol) $parts[] = "`$catCol` LIKE :q";
|
||
$where .= " AND (" . implode(' OR ', $parts) . ") ";
|
||
$params[':q'] = '%' . $q . '%';
|
||
}
|
||
|
||
// Filterlogik (parentFilters)
|
||
$parentFilters = [
|
||
'template_id' => $this->val($this->in, ['template_id', 'tpl_id'], null),
|
||
'section_id' => $this->val($this->in, ['section_id', 'sec_id'], null),
|
||
'block_id' => $this->val($this->in, ['block_id', 'blk_id'], null),
|
||
];
|
||
foreach ($parentFilters as $col => $v) {
|
||
if ($v === null || $v === '') continue;
|
||
if (in_array($col, $allCols, true)) { $where .= " AND `$col` = :$col "; $params[":$col"] = $v; }
|
||
}
|
||
|
||
// Tenant-Filter
|
||
[$tw, $tp] = $this->tenantWhere($auth);
|
||
$where .= $tw;
|
||
foreach ($tp as $k => $v) $params[$k] = $v;
|
||
|
||
$order = $updCol ? " ORDER BY `$updCol` DESC " : " ORDER BY `$nameCol` ASC ";
|
||
$sql = "SELECT * FROM `$t` $where $order LIMIT :off,:lim";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
|
||
// Bind parameters
|
||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
|
||
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||
$stmt->execute();
|
||
$rows = $stmt->fetchAll();
|
||
|
||
$out = [];
|
||
foreach ($rows as $r) {
|
||
$item = [
|
||
'id' => $r[$idCol] ?? null,
|
||
'name' => $r[$nameCol] ?? null,
|
||
];
|
||
if ($apiCol && isset($r[$apiCol])) $item['api_name'] = $r[$apiCol];
|
||
if ($descCol && isset($r[$descCol])) $item['desc'] = $r[$descCol];
|
||
if ($catCol && isset($r[$catCol])) $item['category'] = $r[$catCol];
|
||
if ($updCol && isset($r[$updCol])) $item['updated_at'] = $r[$updCol];
|
||
$createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']);
|
||
if ($createdCol && isset($r[$createdCol])) $item['created_at'] = $r[$createdCol];
|
||
|
||
// Lade HTML und JSON aus den korrekten Spalten
|
||
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
||
if ($htmlCol && isset($r[$htmlCol])) $item['html'] = (string)$r[$htmlCol];
|
||
$jsonCol = $this->firstExisting($allCols, ['json_content']);
|
||
if ($jsonCol && isset($r[$jsonCol])) $item['content'] = $r[$jsonCol];
|
||
|
||
$out[] = $item;
|
||
}
|
||
$this->respond(['ok' => true, 'kind' => $kind, 'items' => $out, 'data' => $out, 'count' => count($out), 'offset' => $offset, 'limit' => $limit]);
|
||
}
|
||
|
||
/**
|
||
* Allgemeine Methode zur Handhabung von GET-Anfragen.
|
||
*/
|
||
private function handleGet(string $kind): void
|
||
{
|
||
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
|
||
$this->handleLegacyContentGet($kind);
|
||
return;
|
||
}
|
||
$auth = $this->requireAuth();
|
||
$t = $this->tableMap[$kind];
|
||
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||
$id = $this->pullId($this->in);
|
||
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||
|
||
[$tw, $tp] = $this->tenantWhere($auth);
|
||
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':id', $id);
|
||
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
$row = $stmt->fetch();
|
||
if (!$row) $this->fail('Not found', ['kind' => $kind, 'id' => $id], 404);
|
||
$rowOut = ['id' => $row[$idCol] ?? $id] + $row;
|
||
|
||
// Lade HTML und JSON aus den korrekten Spalten
|
||
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
||
$topHtml = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : null;
|
||
$jsonCol = $this->firstExisting($allCols, ['json_content']);
|
||
$topContent = ($jsonCol && isset($row[$jsonCol])) ? $row[$jsonCol] : null;
|
||
$editorCol = $this->firstExisting($allCols, ['editor_type', 'editor']);
|
||
$craftCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']);
|
||
$editorType = $editorCol && isset($row[$editorCol]) ? strtolower((string)$row[$editorCol]) : '';
|
||
$pendingUpdate = [];
|
||
|
||
$gjsComponents = [];
|
||
|
||
if ($editorCol && $editorType === '') {
|
||
$settings = $this->getCustomerSettings((int)($auth['customer_id'] ?? 0));
|
||
$editorType = strtolower((string)($settings['editor_default'] ?? 'grapesjs'));
|
||
if (!in_array($editorType, ['grapesjs', 'craftjs'], true)) {
|
||
$editorType = 'grapesjs';
|
||
}
|
||
$pendingUpdate[$editorCol] = $editorType;
|
||
$rowOut[$editorCol] = $editorType;
|
||
}
|
||
|
||
if ($topContent !== null) {
|
||
$decodedContent = json_decode($topContent, true);
|
||
if (is_array($decodedContent)) {
|
||
$gjsComponents = $decodedContent;
|
||
}
|
||
}
|
||
|
||
if (empty($gjsComponents) && $topHtml !== null) {
|
||
$gjsComponents = $this->parseHtmlToGjsComponents($topHtml);
|
||
}
|
||
|
||
if ($editorType === 'craftjs' && $craftCol) {
|
||
$craftPayload = isset($row[$craftCol]) ? (string)$row[$craftCol] : '';
|
||
if ($craftPayload === '') {
|
||
$pendingUpdate[$craftCol] = $this->encodeJson(['html' => (string)($topHtml ?? '')]);
|
||
$rowOut[$craftCol] = $pendingUpdate[$craftCol];
|
||
}
|
||
}
|
||
|
||
if ($pendingUpdate) {
|
||
$pendingUpdate[$idCol] = $row[$idCol] ?? $id;
|
||
[$tw, $tp] = $this->tenantWhere($auth);
|
||
$set = [];
|
||
foreach (array_keys($pendingUpdate) as $c) {
|
||
if ($c === $idCol) continue;
|
||
$set[] = "`$c` = :$c";
|
||
}
|
||
$sql = "UPDATE `$t` SET " . implode(',', $set) . " WHERE `$idCol` = :$idCol" . $tw . " LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($pendingUpdate as $k => $v) $stmt->bindValue(":$k", $v);
|
||
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
}
|
||
|
||
$usage = $this->calculateUsage($kind, (int)$rowOut['id'], $auth);
|
||
|
||
$this->respond([
|
||
'ok' => true,
|
||
'kind' => $kind,
|
||
'id' => $rowOut['id'],
|
||
'item' => $rowOut,
|
||
'data' => $rowOut,
|
||
'html' => $topHtml,
|
||
'content' => $topContent,
|
||
'gjs_components' => $gjsComponents,
|
||
'editor_type' => $editorType ?: null,
|
||
'craft_json' => $craftCol && isset($rowOut[$craftCol]) ? $rowOut[$craftCol] : null,
|
||
'usage' => $usage,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Allgemeine Methode zur Handhabung von CREATE-Anfragen (inkl. JSON-Bereinigung).
|
||
*/
|
||
private function handleCreate(string $kind): void
|
||
{
|
||
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
|
||
$this->handleLegacyContentCreate($kind);
|
||
return;
|
||
}
|
||
$auth = $this->requireAuth();
|
||
$t = $this->tableMap[$kind];
|
||
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||
$cfg = $this->conf['columns'][$kind] ?? [];
|
||
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
||
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
||
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
||
|
||
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
|
||
if ($name === '') $this->fail('name required', null, 422);
|
||
|
||
$desc = (string)$this->val($this->in, ['description', 'desc'], null);
|
||
$cat = (string)$this->val($this->in, ['category', 'cat'], null);
|
||
$html = (string)$this->val($this->in, ['html', 'body', 'markup'], null);
|
||
if ($kind === 'snippets' && ($html === null || $html === '')) {
|
||
$html = (string)$this->val($this->in, ['content'], $html);
|
||
}
|
||
$jsonKeys = ($kind === 'snippets')
|
||
? ['content_json', 'json', 'structure_json']
|
||
: ['content_json', 'json', 'content', 'structure_json'];
|
||
$json = $this->val($this->in, $jsonKeys, null);
|
||
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
|
||
$editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], '')));
|
||
$craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null);
|
||
$templateId = $this->val($this->in, ['template_id', 'tpl_id'], null);
|
||
$sectionId = $this->val($this->in, ['section_id', 'sec_id'], null);
|
||
$blockId = $this->val($this->in, ['block_id', 'blk_id'], null);
|
||
$editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], '')));
|
||
$craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null);
|
||
|
||
$data = [$nameCol => $name];
|
||
if ($kind === 'templates') {
|
||
$apiCol = $this->firstExisting($allCols, ['api_name']);
|
||
if ($apiCol) {
|
||
$apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null);
|
||
if ($apiRaw === null || trim((string)$apiRaw) === '') {
|
||
$apiName = $this->normalizeApiName($name);
|
||
if ($apiName === '') {
|
||
$this->fail('api_name required', null, 422);
|
||
}
|
||
} else {
|
||
$apiName = trim((string)$apiRaw);
|
||
if (preg_match('/\s/', $apiName)) {
|
||
$this->fail('api_name must not contain spaces', null, 422);
|
||
}
|
||
}
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$this->assertTemplateApiNameUnique($t, $apiCol, $idCol, $customerId, $apiName, null);
|
||
$data[$apiCol] = $apiName;
|
||
}
|
||
}
|
||
if ($desc !== null && $descCol) $data[$descCol] = $desc;
|
||
if ($cat !== null && $catCol) $data[$catCol] = $cat;
|
||
|
||
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
|
||
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
|
||
$editorDbCol = $this->firstExisting($allCols, ['editor_type', 'editor']);
|
||
$craftDbCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']);
|
||
$editorDbCol = $this->firstExisting($allCols, ['editor_type', 'editor']);
|
||
$craftDbCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']);
|
||
|
||
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
|
||
|
||
// 1. JSON-Content behandeln
|
||
if ($json !== null) {
|
||
if ($jsonDbCol) {
|
||
$components = is_string($json) ? json_decode($json, true) : $json;
|
||
if (is_array($components)) {
|
||
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
|
||
$data[$jsonDbCol] = $this->encodeJson($components);
|
||
} else {
|
||
$data[$jsonDbCol] = is_string($json) ? $json : '';
|
||
}
|
||
} else {
|
||
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
|
||
$this->fail(
|
||
'JSON content provided but no `json_content` column found',
|
||
['table' => $t, 'available_cols' => $allCols],
|
||
422
|
||
);
|
||
}
|
||
}
|
||
|
||
// 2. HTML-Content speichern
|
||
if ($htmlDbCol && $html !== null) {
|
||
$data[$htmlDbCol] = $html;
|
||
}
|
||
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
|
||
|
||
if ($editorDbCol) {
|
||
if ($editorType === '') {
|
||
$settings = $this->getCustomerSettings((int)($auth['customer_id'] ?? 0));
|
||
$editorType = strtolower((string)($settings['editor_default'] ?? 'grapesjs'));
|
||
}
|
||
if ($editorType !== '' && in_array($editorType, ['grapesjs', 'craftjs'], true)) {
|
||
$data[$editorDbCol] = $editorType;
|
||
}
|
||
}
|
||
if ($craftDbCol && $craftJson !== null) {
|
||
$data[$craftDbCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson);
|
||
}
|
||
|
||
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
|
||
if ($c && $settings !== null) $data[$c] = is_string($settings) ? $settings : $this->encodeJson($settings);
|
||
|
||
if ($templateId !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $templateId;
|
||
if ($sectionId !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sectionId;
|
||
if ($blockId !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blockId;
|
||
|
||
$data = $data + $this->tenantAssign($_SESSION['auth'] ?? [], $allCols);
|
||
|
||
$now = date('Y-m-d H:i:s');
|
||
$createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']);
|
||
if ($createdCol) $data[$createdCol] = $now;
|
||
if ($updCol) $data[$updCol] = $now;
|
||
|
||
$fields = array_keys($data);
|
||
$place = array_map(fn($c) => ":$c", $fields);
|
||
$sql = "INSERT INTO `$t` (" . implode(',', array_map(fn($c) => "`$c`", $fields)) . ") VALUES (" . implode(',', $place) . ")";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||
$stmt->execute();
|
||
$newId = $this->pdo->lastInsertId();
|
||
|
||
$out = ['id' => $newId, 'name' => $name];
|
||
if (!empty($apiCol) && isset($data[$apiCol])) {
|
||
$out['api_name'] = $data[$apiCol];
|
||
}
|
||
if ($desc !== null) $out['desc'] = $desc;
|
||
if ($cat !== null) $out['category'] = $cat;
|
||
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $newId, 'item' => $out, 'data' => $out]);
|
||
}
|
||
|
||
/**
|
||
* Allgemeine Methode zur Handhabung von UPDATE-Anfragen (inkl. JSON-Bereinigung).
|
||
*/
|
||
private function handleUpdate(string $kind): void
|
||
{
|
||
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
|
||
$this->handleLegacyContentUpdate($kind);
|
||
return;
|
||
}
|
||
$auth = $this->requireAuth();
|
||
$t = $this->tableMap[$kind];
|
||
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||
$cfg = $this->conf['columns'][$kind] ?? [];
|
||
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
||
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
||
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
||
$apiCol = ($kind === 'templates') ? $this->firstExisting($allCols, ['api_name']) : null;
|
||
$id = $this->pullId($this->in);
|
||
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||
|
||
$data = [];
|
||
$name = $this->val($this->in, ['name', 'title'], null);
|
||
$desc = $this->val($this->in, ['description', 'desc'], null);
|
||
$cat = $this->val($this->in, ['category', 'cat'], null);
|
||
$html = $this->val($this->in, ['html', 'body', 'markup'], null);
|
||
if ($kind === 'snippets' && $html === null) {
|
||
$html = $this->val($this->in, ['content'], null);
|
||
}
|
||
$jsonKeys = ($kind === 'snippets')
|
||
? ['content_json', 'json', 'structure_json']
|
||
: ['content_json', 'json', 'content', 'structure_json'];
|
||
$json = $this->val($this->in, $jsonKeys, null);
|
||
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
|
||
|
||
if ($name !== null) $data[$nameCol] = (string)$name;
|
||
if ($desc !== null && $descCol) $data[$descCol] = (string)$desc;
|
||
if ($cat !== null && $catCol) $data[$catCol] = (string)$cat;
|
||
if ($apiCol) {
|
||
$apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null);
|
||
if ($apiRaw !== null) {
|
||
$apiName = trim((string)$apiRaw);
|
||
if ($apiName === '') $this->fail('api_name required', null, 422);
|
||
if (preg_match('/\s/', $apiName)) {
|
||
$this->fail('api_name must not contain spaces', null, 422);
|
||
}
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$this->assertTemplateApiNameUnique($t, $apiCol, $idCol, $customerId, $apiName, (int)$id);
|
||
$data[$apiCol] = $apiName;
|
||
}
|
||
}
|
||
|
||
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
|
||
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
|
||
|
||
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
|
||
|
||
// 1. JSON-Content behandeln
|
||
if ($json !== null) {
|
||
if ($jsonDbCol) {
|
||
// Wenn JSON-Spalte existiert, JSON verarbeiten und speichern
|
||
$components = is_string($json) ? json_decode($json, true) : $json;
|
||
if (is_array($components)) {
|
||
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
|
||
$data[$jsonDbCol] = $this->encodeJson($components);
|
||
} else {
|
||
$data[$jsonDbCol] = is_string($json) ? $json : '';
|
||
}
|
||
} else {
|
||
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
|
||
$this->fail(
|
||
'JSON content provided but no `json_content` column found',
|
||
['table' => $t, 'available_cols' => $allCols],
|
||
422
|
||
);
|
||
}
|
||
|
||
// 2. Den zugehörigen HTML-Output speichern (wird vom Editor immer mitgesendet, wenn JSON da ist)
|
||
if ($html !== null && $htmlDbCol) {
|
||
$data[$htmlDbCol] = (string)$html;
|
||
}
|
||
} elseif ($html !== null && $htmlDbCol) {
|
||
// Wenn NUR HTML gesendet wird (für minimale Änderungen), speichern wir nur HTML.
|
||
$data[$htmlDbCol] = (string)$html;
|
||
}
|
||
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
|
||
|
||
if ($editorDbCol && $editorType !== '' && in_array($editorType, ['grapesjs', 'craftjs'], true)) {
|
||
$data[$editorDbCol] = $editorType;
|
||
}
|
||
if ($craftDbCol && $craftJson !== null) {
|
||
$data[$craftDbCol] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson);
|
||
}
|
||
|
||
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
|
||
if ($settings !== null && $c) $data[$c] = is_string($settings) ? $settings : $this->encodeJson($settings);
|
||
|
||
$tpl = $this->val($this->in, ['template_id', 'tpl_id'], null);
|
||
if ($tpl !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $tpl;
|
||
$sec = $this->val($this->in, ['section_id', 'sec_id'], null);
|
||
if ($sec !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sec;
|
||
$blk = $this->val($this->in, ['block_id', 'blk_id'], null);
|
||
if ($blk !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blk;
|
||
|
||
if ($updCol) $data[$updCol] = date('Y-m-d H:i:s');
|
||
$this->debugSavePayload($kind, $id, $html, $json);
|
||
if (!$data) $this->fail('nothing to update', null, 422);
|
||
|
||
[$tw, $tp] = $this->tenantWhere($auth);
|
||
$set = [];
|
||
foreach (array_keys($data) as $c) $set[] = "`$c` = :$c";
|
||
$sql = "UPDATE `$t` SET " . implode(',', $set) . " WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
|
||
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||
$stmt->bindValue(':__id', $id);
|
||
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'updated' => array_keys($data)]);
|
||
}
|
||
|
||
/**
|
||
* Allgemeine Methode zur Handhabung von DELETE-Anfragen.
|
||
*/
|
||
private function handleDelete(string $kind): void
|
||
{
|
||
if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) {
|
||
$this->handleLegacyContentDelete($kind);
|
||
return;
|
||
}
|
||
$auth = $this->requireAuth();
|
||
$t = $this->tableMap[$kind];
|
||
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||
$id = $this->pullId($this->in);
|
||
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||
|
||
[$tw, $tp] = $this->tenantWhere($auth);
|
||
$sql = "DELETE FROM `$t` WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':__id', $id);
|
||
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'deleted' => true]);
|
||
}
|
||
|
||
/**
|
||
* Sendet einen Testversand für Templates.
|
||
*/
|
||
private function handleTemplateTestSend(): void
|
||
{
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
$templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0);
|
||
if ($templateId <= 0) {
|
||
$this->fail('template_id required', null, 422);
|
||
}
|
||
|
||
$recipient = trim((string)$this->val($this->in, ['to', 'email', 'recipient'], ''));
|
||
if ($recipient === '' || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||
$this->fail('Valid recipient required', null, 422);
|
||
}
|
||
|
||
$subject = trim((string)$this->val($this->in, ['subject'], 'Testversand'));
|
||
if ($subject === '') {
|
||
$subject = 'Testversand';
|
||
}
|
||
$senderId = (int)$this->val($this->in, ['sender_id'], 0);
|
||
|
||
$row = null;
|
||
$html = '';
|
||
if ($this->useUnifiedContent()) {
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
$itemsTable = $this->contentItemsTable();
|
||
$sql = "SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `section_id` = :sid AND `id` = :id LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $templateId]);
|
||
$row = $stmt->fetch();
|
||
$html = $row ? (string)($row['html'] ?? '') : '';
|
||
} else {
|
||
$t = $this->tableMap['templates'];
|
||
[$idCol, $allCols] = $this->resolveIdCol('templates');
|
||
[$tw, $tp] = $this->tenantWhere($auth);
|
||
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':id', $templateId);
|
||
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
$row = $stmt->fetch();
|
||
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
||
$html = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : '';
|
||
}
|
||
if (!$row) {
|
||
$this->fail('Template not found', ['id' => $templateId], 404);
|
||
}
|
||
if ($html === '' && !empty($row['json_content'])) {
|
||
$html = '<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);
|
||
}
|
||
|
||
if ($customerId > 0) {
|
||
$this->incrementTemplateUsage($customerId, $templateId);
|
||
}
|
||
|
||
$this->respond([
|
||
'ok' => true,
|
||
'template_id' => $templateId,
|
||
'to' => $recipient,
|
||
'subject' => $subject,
|
||
'sender_id' => $senderId > 0 ? $senderId : null,
|
||
]);
|
||
}
|
||
|
||
private function handleTemplateReferences(): void
|
||
{
|
||
@file_put_contents(sys_get_temp_dir() . '/emailtemplate_debug.log', json_encode([
|
||
'time' => date(DATE_ATOM),
|
||
'tag' => 'handleTemplateReferences',
|
||
'input' => $this->in ?? null,
|
||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND);
|
||
|
||
$auth = $this->requireAuth();
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
if ($customerId <= 0) {
|
||
$this->fail('Customer context missing', null, 500);
|
||
}
|
||
|
||
$templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0);
|
||
if ($templateId <= 0) {
|
||
$this->fail('template_id required', null, 422);
|
||
}
|
||
|
||
$debug = [];
|
||
$references = $this->findTemplateReferences($customerId, $templateId, $debug);
|
||
if (!empty($debug)) {
|
||
$this->writeDebugLog('templates_references_debug', $debug);
|
||
}
|
||
|
||
$this->respond([
|
||
'ok' => true,
|
||
'template_id' => $templateId,
|
||
'count' => count($references),
|
||
'references' => $references,
|
||
]);
|
||
}
|
||
|
||
private function findTemplateReferences(int $customerId, int $templateId, array &$debug = []): array
|
||
{
|
||
$out = [];
|
||
$seen = [];
|
||
$debug = [
|
||
'time' => date(DATE_ATOM),
|
||
'customer_id' => $customerId,
|
||
'template_id' => $templateId,
|
||
'use_unified' => $this->useUnifiedContent(),
|
||
'scanned_rows' => 0,
|
||
'matched_rows' => [],
|
||
'template_items_matches' => [],
|
||
];
|
||
$matches = function (?string $html, array $libKinds = []) use ($templateId): bool {
|
||
if (!$html) return false;
|
||
$id = preg_quote((string)$templateId, '/');
|
||
$typePattern = '/data-ref-type\s*=\s*(["\"])template\1/i';
|
||
$idPattern = '/data-ref-id\s*=\s*(["\"])' . $id . '\1/i';
|
||
$jsonTypePattern = '/"data-ref-type"\s*:\s*"template"/i';
|
||
$jsonIdPattern = '/"data-ref-id"\s*:\s*("?)(?:' . $id . ')\1/i';
|
||
$hasHtmlAttrs = (bool)(preg_match($typePattern, $html) && preg_match($idPattern, $html));
|
||
if ($hasHtmlAttrs) return true;
|
||
if (preg_match($jsonTypePattern, $html) && preg_match($jsonIdPattern, $html)) return true;
|
||
|
||
$libIdPattern = '/(?:data-)?lib-id\s*=\s*(["\"])' . $id . '\1/i';
|
||
if (preg_match($libIdPattern, $html)) {
|
||
return true;
|
||
}
|
||
|
||
$jsonLibIdPattern = '/"(?:data-)?lib-id"\s*:\s*("?)(?:' . $id . ')\1/i';
|
||
if (preg_match($jsonLibIdPattern, $html)) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
if ($this->useUnifiedContent()) {
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
if (!$section) return [];
|
||
$libKinds = [strtolower((string)($section['slug'] ?? 'emailtemplate'))];
|
||
$debug['section'] = [
|
||
'id' => (int)($section['id'] ?? 0),
|
||
'slug' => (string)($section['slug'] ?? ''),
|
||
];
|
||
|
||
$itemsTable = $this->contentItemsTable();
|
||
if (!$this->tableExists($itemsTable)) return [];
|
||
$itemCols = $this->resolveContentItemColumns($itemsTable);
|
||
$htmlCol = $itemCols['html'];
|
||
$jsonCol = $itemCols['json'];
|
||
$craftCol = $itemCols['craft'];
|
||
$settingsCol = $itemCols['settings'];
|
||
|
||
$versionsTable = $this->contentVersionsTable();
|
||
$versionCols = ($this->tableExists($versionsTable)) ? $this->resolveContentVersionColumns($versionsTable) : null;
|
||
$versionHtmlCol = $versionCols['html'] ?? null;
|
||
$versionJsonCol = $versionCols['json'] ?? null;
|
||
$versionCraftCol = $versionCols['craft'] ?? null;
|
||
$versionActiveCol = $versionCols['is_active'] ?? null;
|
||
$versionSettingsCol = $versionCols['settings'] ?? null;
|
||
$debug['tables'] = [
|
||
'items' => $itemsTable,
|
||
'versions' => $versionsTable,
|
||
];
|
||
$debug['columns'] = [
|
||
'item' => $itemCols,
|
||
'version' => $versionCols,
|
||
];
|
||
|
||
$select = "i.`id` AS id, i.`name` AS name";
|
||
if ($htmlCol) $select .= ", i.`$htmlCol` AS item_html";
|
||
if ($jsonCol) $select .= ", i.`$jsonCol` AS item_json";
|
||
if ($craftCol) $select .= ", i.`$craftCol` AS item_craft";
|
||
if ($settingsCol) $select .= ", i.`$settingsCol` AS item_settings";
|
||
$join = '';
|
||
$hasVersionJoin = $versionActiveCol && ($versionHtmlCol || $versionJsonCol || $versionCraftCol);
|
||
if ($hasVersionJoin) {
|
||
$join = "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`$versionActiveCol` = 1";
|
||
}
|
||
if ($versionHtmlCol && $versionActiveCol) {
|
||
$select .= ", v.`$versionHtmlCol` AS version_html";
|
||
}
|
||
if ($versionJsonCol && $versionActiveCol) {
|
||
$select .= ", v.`$versionJsonCol` AS version_json";
|
||
}
|
||
if ($versionCraftCol && $versionActiveCol) {
|
||
$select .= ", v.`$versionCraftCol` AS version_craft";
|
||
}
|
||
if ($versionSettingsCol && $versionActiveCol) {
|
||
$select .= ", v.`$versionSettingsCol` AS version_settings";
|
||
}
|
||
$sql = "SELECT $select FROM `$itemsTable` i $join WHERE i.`customer_id` = :cid AND i.`section_id` = :sid AND i.`id` <> :id";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $templateId]);
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
|
||
foreach ($rows as $row) {
|
||
$debug['scanned_rows']++;
|
||
$blobs = [
|
||
(string)($row['version_html'] ?? ''),
|
||
(string)($row['item_html'] ?? ''),
|
||
(string)($row['version_json'] ?? ''),
|
||
(string)($row['item_json'] ?? ''),
|
||
(string)($row['version_craft'] ?? ''),
|
||
(string)($row['item_craft'] ?? ''),
|
||
(string)($row['version_settings'] ?? ''),
|
||
(string)($row['item_settings'] ?? ''),
|
||
];
|
||
$found = false;
|
||
$where = [];
|
||
$blobKeys = [
|
||
'version_html',
|
||
'item_html',
|
||
'version_json',
|
||
'item_json',
|
||
'version_craft',
|
||
'item_craft',
|
||
'version_settings',
|
||
'item_settings',
|
||
];
|
||
foreach ($blobs as $idx => $blob) {
|
||
if ($matches($blob, $libKinds)) {
|
||
$found = true;
|
||
$where[] = $blobKeys[$idx] ?? (string)$idx;
|
||
}
|
||
}
|
||
if ($found) {
|
||
$id = (int)($row['id'] ?? 0);
|
||
if ($id <= 0 || isset($seen[$id])) continue;
|
||
$seen[$id] = true;
|
||
$out[] = [
|
||
'id' => $id,
|
||
'name' => (string)($row['name'] ?? ''),
|
||
];
|
||
if (count($debug['matched_rows']) < 50) {
|
||
$debug['matched_rows'][] = [
|
||
'id' => $id,
|
||
'name' => (string)($row['name'] ?? ''),
|
||
'where' => $where,
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!$this->useUnifiedContent()) {
|
||
$table = $this->tableMap['templates'] ?? null;
|
||
if ($table && $this->tableExists($table)) {
|
||
$libKinds = ['templates', 'template', 'emailtemplate'];
|
||
[$idCol, $allCols] = $this->resolveIdCol('templates');
|
||
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
||
$jsonCol = $this->firstExisting($allCols, ['json_content']);
|
||
$craftCol = $this->firstExisting($allCols, ['craft_json', 'craft_content', 'craft_data']);
|
||
$settingsCol = $this->firstExisting($allCols, ['settings_json', 'settings']);
|
||
if ($htmlCol || $jsonCol || $craftCol || $settingsCol) {
|
||
$nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||
[$tw, $tp] = $this->tenantWhere(['customer_id' => $customerId]);
|
||
$select = "`$idCol` AS id, `$nameCol` AS name";
|
||
if ($htmlCol) $select .= ", `$htmlCol` AS html";
|
||
if ($jsonCol) $select .= ", `$jsonCol` AS json";
|
||
if ($craftCol) $select .= ", `$craftCol` AS craft";
|
||
if ($settingsCol) $select .= ", `$settingsCol` AS settings";
|
||
$sql = "SELECT $select FROM `$table` WHERE `$idCol` <> :id" . $tw;
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':id', $templateId, PDO::PARAM_INT);
|
||
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
foreach ($rows as $row) {
|
||
$blobs = [
|
||
(string)($row['html'] ?? ''),
|
||
(string)($row['json'] ?? ''),
|
||
(string)($row['craft'] ?? ''),
|
||
(string)($row['settings'] ?? ''),
|
||
];
|
||
$found = false;
|
||
$where = [];
|
||
$blobKeys = ['html', 'json', 'craft', 'settings'];
|
||
foreach ($blobs as $idx => $blob) {
|
||
if ($matches($blob, $libKinds)) {
|
||
$found = true;
|
||
$where[] = $blobKeys[$idx] ?? (string)$idx;
|
||
}
|
||
}
|
||
if ($found) {
|
||
$id = (int)($row['id'] ?? 0);
|
||
if ($id <= 0 || isset($seen[$id])) continue;
|
||
$seen[$id] = true;
|
||
$out[] = [
|
||
'id' => $id,
|
||
'name' => (string)($row['name'] ?? ''),
|
||
];
|
||
if (count($debug['matched_rows']) < 50) {
|
||
$debug['matched_rows'][] = [
|
||
'id' => $id,
|
||
'name' => (string)($row['name'] ?? ''),
|
||
'where' => $where,
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$templateItemsTable = $this->lookupTableName('template_items', 'emailtemplate_template_items');
|
||
if ($this->tableExists($templateItemsTable)) {
|
||
$sql = "SELECT DISTINCT `template_id` FROM `$templateItemsTable` WHERE `customer_id` = :cid AND `ref_type` IN ('section','template') AND `ref_id` = :rid";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId, ':rid' => $templateId]);
|
||
$ids = array_filter(array_map('intval', array_column($stmt->fetchAll() ?: [], 'template_id')));
|
||
if ($ids) {
|
||
$debug['template_items_matches'] = $ids;
|
||
if ($this->useUnifiedContent()) {
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
if ($section) {
|
||
$itemsTable = $this->contentItemsTable();
|
||
$nameCol = $this->resolveContentItemColumns($itemsTable)['category'] ? 'name' : 'name';
|
||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||
$sql = "SELECT `id`, `name` FROM `$itemsTable` WHERE `customer_id` = ? AND `section_id` = ? AND `id` IN ($placeholders)";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute(array_merge([$customerId, (int)$section['id']], $ids));
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
foreach ($rows as $row) {
|
||
$id = (int)($row['id'] ?? 0);
|
||
if ($id <= 0 || isset($seen[$id])) continue;
|
||
$seen[$id] = true;
|
||
$out[] = [
|
||
'id' => $id,
|
||
'name' => (string)($row['name'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
} else {
|
||
$table = $this->tableMap['templates'] ?? null;
|
||
if ($table && $this->tableExists($table)) {
|
||
[$idCol, $allCols] = $this->resolveIdCol('templates');
|
||
$nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||
$ph = [];
|
||
$params = [];
|
||
foreach ($ids as $idx => $val) {
|
||
$key = ':id' . $idx;
|
||
$ph[] = $key;
|
||
$params[$key] = $val;
|
||
}
|
||
[$tw, $tp] = $this->tenantWhere(['customer_id' => $customerId]);
|
||
$sql = "SELECT `$idCol` AS id, `$nameCol` AS name FROM `$table` WHERE `$idCol` IN (" . implode(',', $ph) . ")" . $tw;
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, PDO::PARAM_INT);
|
||
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
foreach ($rows as $row) {
|
||
$id = (int)($row['id'] ?? 0);
|
||
if ($id <= 0 || isset($seen[$id])) continue;
|
||
$seen[$id] = true;
|
||
$out[] = [
|
||
'id' => $id,
|
||
'name' => (string)($row['name'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
private function handleExternalRender(): void
|
||
{
|
||
$token = trim((string)($this->in['token'] ?? ''));
|
||
if ($token === '') {
|
||
$this->fail('Token required', null, 401);
|
||
}
|
||
|
||
$settingsTable = $this->customerSettingsTable();
|
||
$stmt = $this->pdo->prepare("SELECT `customer_id` FROM `$settingsTable` WHERE `external_api_token` = :t LIMIT 1");
|
||
$stmt->execute([':t' => $token]);
|
||
$row = $stmt->fetch();
|
||
$customerId = (int)($row['customer_id'] ?? 0);
|
||
if ($customerId <= 0) {
|
||
$this->fail('Invalid token', null, 403);
|
||
}
|
||
|
||
$templateKey = $this->val($this->in, ['api_name', 'template', 'template_id', 'id', 'name'], '');
|
||
$templateId = is_numeric($templateKey) ? (int)$templateKey : null;
|
||
$tpl = null;
|
||
$html = '';
|
||
$templateName = null;
|
||
$apiName = null;
|
||
|
||
if ($this->useUnifiedContent()) {
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
$itemsTable = $this->contentItemsTable();
|
||
$where = "WHERE `customer_id` = :cid AND `section_id` = :sid ";
|
||
$params = [':cid' => $customerId, ':sid' => (int)$section['id']];
|
||
if ($templateId !== null && $templateId > 0) {
|
||
$where .= "AND `id` = :id ";
|
||
$params[':id'] = $templateId;
|
||
} else {
|
||
$name = trim((string)$templateKey);
|
||
if ($name === '') {
|
||
$this->fail('template required', null, 422);
|
||
}
|
||
$where .= "AND `api_name` = :name ";
|
||
$params[':name'] = $name;
|
||
}
|
||
$sql = "SELECT * FROM `$itemsTable` $where LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($params as $k => $v) {
|
||
$stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||
}
|
||
$stmt->execute();
|
||
$tpl = $stmt->fetch();
|
||
if ($tpl) {
|
||
$html = (string)($tpl['html'] ?? '');
|
||
$templateName = $tpl['name'] ?? null;
|
||
$apiName = $tpl['api_name'] ?? null;
|
||
}
|
||
} else {
|
||
$templatesTable = $this->tableMap['templates'] ?? null;
|
||
if (!$templatesTable || !$this->tableExists($templatesTable)) {
|
||
$this->fail('Templates table not available', null, 500);
|
||
}
|
||
|
||
[$idCol, $allCols] = $this->resolveIdCol('templates');
|
||
$nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||
$apiCol = $this->firstExisting($allCols, ['api_name']);
|
||
|
||
$where = "WHERE `customer_id` = :cid ";
|
||
$params = [':cid' => $customerId];
|
||
if ($templateId !== null && $templateId > 0) {
|
||
$where .= "AND `$idCol` = :id ";
|
||
$params[':id'] = $templateId;
|
||
} else {
|
||
$name = trim((string)$templateKey);
|
||
if ($name === '') {
|
||
$this->fail('template required', null, 422);
|
||
}
|
||
if ($apiCol) {
|
||
$where .= "AND `$apiCol` = :name ";
|
||
$params[':name'] = $name;
|
||
} else {
|
||
$where .= "AND `$nameCol` = :name ";
|
||
$params[':name'] = $name;
|
||
}
|
||
}
|
||
|
||
$sql = "SELECT * FROM `$templatesTable` $where LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($params as $k => $v) {
|
||
$stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||
}
|
||
$stmt->execute();
|
||
$tpl = $stmt->fetch();
|
||
$htmlCol = $this->resolveHtmlColumn($allCols, 'templates');
|
||
$html = ($htmlCol && isset($tpl[$htmlCol])) ? (string)$tpl[$htmlCol] : '';
|
||
$templateName = $tpl[$nameCol] ?? null;
|
||
$apiName = $apiCol ? ($tpl[$apiCol] ?? null) : null;
|
||
}
|
||
if (!$tpl) {
|
||
$this->fail('Template not found', ['template' => $templateKey], 404);
|
||
}
|
||
if ($html === '' && !empty($tpl['json_content'])) {
|
||
$html = '<p>(Dieses Template enthält noch keine HTML-Inhalte.)</p>';
|
||
}
|
||
|
||
$auth = ['id' => $customerId, 'customer_id' => $customerId];
|
||
$cache = [];
|
||
$stack = [];
|
||
$html = $this->renderHtmlWithReferences($html, $auth, $cache, $stack);
|
||
$html = $this->replacePlaceholders($html, (array)($this->in['placeholders'] ?? []));
|
||
$html = $this->prepareEmailHtml($html);
|
||
|
||
$this->respond([
|
||
'ok' => true,
|
||
'template_id' => (int)($tpl['id'] ?? 0),
|
||
'name' => $templateName,
|
||
'api_name' => $apiName,
|
||
'html' => $html,
|
||
]);
|
||
}
|
||
|
||
private function prepareEmailHtml(string $html): string
|
||
{
|
||
$html = trim($html);
|
||
if ($html === '') {
|
||
return '<p>(Kein Inhalt vorhanden)</p>';
|
||
}
|
||
|
||
if (!class_exists(CssToInlineStyles::class)) {
|
||
return $html;
|
||
}
|
||
|
||
try {
|
||
$inliner = new CssToInlineStyles();
|
||
return $inliner->convert($html);
|
||
} catch (Throwable $e) {
|
||
return $html;
|
||
}
|
||
}
|
||
|
||
private function debugSavePayload(string $kind, $id, $html, $json): void
|
||
{
|
||
if (empty($this->in['debug'])) {
|
||
return;
|
||
}
|
||
|
||
$payload = [
|
||
'time' => date(DATE_ATOM),
|
||
'kind' => $kind,
|
||
'id' => $id,
|
||
'html_length' => is_string($html) ? strlen($html) : null,
|
||
'json_length' => is_string($json) ? strlen($json) : null,
|
||
'html_preview' => is_string($html) ? substr($html, 0, 200) : null,
|
||
'json_preview' => is_string($json) ? substr($json, 0, 200) : null,
|
||
'json_is_empty' => is_string($json) ? trim($json) === '' : ($json === null),
|
||
];
|
||
|
||
$line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
||
@file_put_contents(sys_get_temp_dir() . '/emailtemplate_debug_save.log', $line, FILE_APPEND);
|
||
}
|
||
|
||
private function replacePlaceholders(string $html, array $placeholders): string
|
||
{
|
||
if ($html === '' || !$placeholders) {
|
||
return $html;
|
||
}
|
||
|
||
$map = [];
|
||
foreach ($placeholders as $key => $value) {
|
||
if (is_array($value) || is_object($value)) continue;
|
||
$value = (string)$value;
|
||
$trimmedKey = trim((string)$key);
|
||
if ($trimmedKey === '') continue;
|
||
$map['{{' . $trimmedKey . '}}'] = $value;
|
||
$map['{{ ' . $trimmedKey . ' }}'] = $value;
|
||
$map['[[' . $trimmedKey . ']]'] = $value;
|
||
$map['[[ ' . $trimmedKey . ' ]]'] = $value;
|
||
}
|
||
|
||
return $map ? strtr($html, $map) : $html;
|
||
}
|
||
|
||
private function dispatchTestMail(string $to, string $subject, string $html, ?array $sender = null): bool
|
||
{
|
||
if (!function_exists('mail')) {
|
||
return false;
|
||
}
|
||
|
||
$smtp = $this->conf['smtp'] ?? [];
|
||
$fromEmail = $sender['from_email'] ?? ($smtp['from_email'] ?? 'no-reply@example.com');
|
||
$fromName = $sender['from_name'] ?? ($sender['label'] ?? ($smtp['from_name'] ?? 'EmailTemplate'));
|
||
$replyTo = $sender['reply_to'] ?? '';
|
||
$headers = [
|
||
'MIME-Version: 1.0',
|
||
'Content-Type: text/html; charset=UTF-8',
|
||
'From: ' . $this->formatEmailAddress($fromEmail, $fromName),
|
||
];
|
||
if ($replyTo !== '') {
|
||
$headers[] = 'Reply-To: ' . $this->formatEmailAddress($replyTo, $fromName ?: $fromEmail);
|
||
}
|
||
|
||
$encodedSubject = function_exists('mb_encode_mimeheader')
|
||
? mb_encode_mimeheader($subject, 'UTF-8')
|
||
: $subject;
|
||
return @mail($to, $encodedSubject, $html, implode("\r\n", $headers));
|
||
}
|
||
|
||
private function formatEmailAddress(string $email, string $name): string
|
||
{
|
||
$email = trim($email);
|
||
if ($email === '') {
|
||
return 'no-reply@example.com';
|
||
}
|
||
|
||
$name = trim($name);
|
||
if ($name === '') {
|
||
return $email;
|
||
}
|
||
|
||
$encoded = function_exists('mb_encode_mimeheader')
|
||
? mb_encode_mimeheader($name, 'UTF-8')
|
||
: $name;
|
||
return sprintf('%s <%s>', $encoded, $email);
|
||
}
|
||
|
||
// =================================================================
|
||
// 💡 Öffentliche run()-Methode (KORRIGIERT)
|
||
// =================================================================
|
||
|
||
public function run(): void
|
||
{
|
||
// 💡 KORREKTUR: Der Content-Type Header wird hier entfernt, da er jetzt in respond()
|
||
// zentralisiert wurde, um sicherzustellen, dass er auch bei Fehlern im Konstruktor oder
|
||
// im try-Block korrekt gesetzt wird.
|
||
// header('Content-Type: application/json; charset=utf-8'); // DIESE ZEILE ENTFERNT
|
||
|
||
try {
|
||
// Extrahiere den Ressourcen-Typ und die Operation (z.B. 'templates' und 'list')
|
||
[$kind, $operation] = explode('.', $this->action, 2) + [1 => ''];
|
||
|
||
switch ($this->action) {
|
||
case 'health':
|
||
$this->respond(['ok' => true, 'time' => date('c')]);
|
||
case 'external.render':
|
||
$this->handleExternalRender();
|
||
break;
|
||
|
||
/* ---------- AUTH ---------- */
|
||
case 'auth.login':
|
||
$result = $this->authService->login($this->in);
|
||
$this->respond(['ok' => true] + $result);
|
||
break;
|
||
case 'auth.me':
|
||
if (empty($_SESSION['auth'])) $this->fail('Not authenticated', null, 401);
|
||
$this->respond(['ok' => true, 'user' => $_SESSION['auth']]);
|
||
break;
|
||
case 'auth.logout':
|
||
$this->authService->logout();
|
||
$this->respond(['ok' => true]);
|
||
break;
|
||
case 'account.profile.get':
|
||
$this->handleAccountProfileGet();
|
||
break;
|
||
case 'account.profile.update':
|
||
$this->handleAccountProfileUpdate();
|
||
break;
|
||
case 'account.password.update':
|
||
$this->handleAccountPasswordUpdate();
|
||
break;
|
||
case 'account.settings.get':
|
||
$this->handleAccountSettingsGet();
|
||
break;
|
||
case 'account.settings.update':
|
||
$this->handleAccountSettingsUpdate();
|
||
break;
|
||
case 'account.users.list':
|
||
$this->handleAccountUsersList();
|
||
break;
|
||
case 'account.users.create':
|
||
$this->handleAccountUsersCreate();
|
||
break;
|
||
case 'account.users.update':
|
||
$this->handleAccountUsersUpdate();
|
||
break;
|
||
case 'account.users.delete':
|
||
$this->handleAccountUsersDelete();
|
||
break;
|
||
case 'account.senders.list':
|
||
$this->handleAccountSendersList();
|
||
break;
|
||
case 'account.senders.save':
|
||
$this->handleAccountSenderSave();
|
||
break;
|
||
case 'account.senders.delete':
|
||
$this->handleAccountSenderDelete();
|
||
break;
|
||
case 'dashboard.metrics':
|
||
$this->handleDashboardMetrics();
|
||
break;
|
||
case 'dashboard.reset_usage':
|
||
$this->handleDashboardResetUsage();
|
||
break;
|
||
case 'downloads.bridge':
|
||
$this->handleDownloadFile('bridge');
|
||
break;
|
||
case 'downloads.sender':
|
||
$this->handleDownloadFile('sender');
|
||
break;
|
||
case 'account.bridge.setup.get':
|
||
$this->handleAccountBridgeSetupGet();
|
||
break;
|
||
case 'account.bridge.setup.save':
|
||
$this->handleAccountBridgeSetupSave();
|
||
break;
|
||
case 'account.bridge.test':
|
||
$this->handleAccountBridgeTest();
|
||
break;
|
||
case 'account.fonts.list':
|
||
$this->handleAccountFontsList();
|
||
break;
|
||
case 'debug.logs.list':
|
||
$this->handleDebugLogsList();
|
||
break;
|
||
case 'debug.logs.read':
|
||
$this->handleDebugLogsRead();
|
||
break;
|
||
case 'placeholders.status':
|
||
$this->handlePlaceholderStatus();
|
||
break;
|
||
case 'placeholders.schema':
|
||
$this->handlePlaceholderSchema();
|
||
break;
|
||
case 'debug.phpinfo':
|
||
$this->handleDebugPhpInfo();
|
||
break;
|
||
case 'templates.test_send':
|
||
$this->handleTemplateTestSend();
|
||
break;
|
||
case 'templates.references':
|
||
$this->handleTemplateReferences();
|
||
break;
|
||
case 'sections_config.reorder':
|
||
$this->handleSectionsConfigReorder();
|
||
break;
|
||
case 'content_versions.restore':
|
||
$this->handleContentVersionsRestore();
|
||
break;
|
||
case 'content_versions.activate':
|
||
$this->handleContentVersionsActivate();
|
||
break;
|
||
case 'content_versions.deactivate':
|
||
$this->handleContentVersionsDeactivate();
|
||
break;
|
||
case 'content_versions.delete':
|
||
$this->handleContentVersionsDelete();
|
||
break;
|
||
|
||
/* ---------- CRUD HANDLER ---------- */
|
||
default:
|
||
if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config', 'content_versions'])) {
|
||
switch ($operation) {
|
||
case 'list':
|
||
if ($kind === 'content') $this->handleContentList();
|
||
elseif ($kind === 'sections_config') $this->handleSectionsConfigList();
|
||
elseif ($kind === 'content_versions') $this->handleContentVersionsList();
|
||
else $this->handleList($kind);
|
||
break;
|
||
case 'get':
|
||
if ($kind === 'content') $this->handleContentGet();
|
||
elseif ($kind === 'sections_config') $this->handleSectionsConfigGet();
|
||
elseif ($kind === 'content_versions') $this->handleContentVersionsGet();
|
||
else $this->handleGet($kind);
|
||
break;
|
||
case 'create':
|
||
if ($kind === 'content') $this->handleContentCreate();
|
||
elseif ($kind === 'sections_config') $this->handleSectionsConfigCreate();
|
||
else $this->handleCreate($kind);
|
||
break;
|
||
case 'update':
|
||
if ($kind === 'content') $this->handleContentUpdate();
|
||
elseif ($kind === 'sections_config') $this->handleSectionsConfigUpdate();
|
||
else $this->handleUpdate($kind);
|
||
break;
|
||
case 'delete':
|
||
if ($kind === 'content') $this->handleContentDelete();
|
||
elseif ($kind === 'sections_config') $this->handleSectionsConfigDelete();
|
||
else $this->handleDelete($kind);
|
||
break;
|
||
default:
|
||
$this->fail('Unknown operation for resource: ' . $this->action, null, 404);
|
||
break;
|
||
}
|
||
} else {
|
||
$this->fail('Unknown action', $this->action ?: 'missing', 404);
|
||
}
|
||
break;
|
||
}
|
||
} catch (Throwable $e) {
|
||
$this->fail('Server error', get_class($e) . ': ' . $e->getMessage(), 500);
|
||
}
|
||
}
|
||
|
||
private function lookupTableName(string $key, string $default): string
|
||
{
|
||
$tables = $this->conf['tables'] ?? [];
|
||
if (!empty($tables[$key])) return $tables[$key];
|
||
$prefix = $this->conf['projectdb']['prefix'] ?? null;
|
||
if ($prefix && strpos($default, 'emailtemplate_') === 0) {
|
||
return $prefix . substr($default, strlen('emailtemplate_'));
|
||
}
|
||
return $default;
|
||
}
|
||
|
||
private function countRefsInTable(string $table, string $where, array $params, array $auth): int
|
||
{
|
||
try {
|
||
[$tw, $tp] = $this->tenantWhere($auth);
|
||
$sql = "SELECT COUNT(*) AS c FROM `$table` WHERE $where" . $tw;
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
$row = $stmt->fetch();
|
||
return (int)($row['c'] ?? 0);
|
||
} catch (Throwable $e) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
private function fetchResourceCounts(int $customerId): array
|
||
{
|
||
$counts = [
|
||
'templates' => 0,
|
||
'sections' => 0,
|
||
'blocks' => 0,
|
||
'snippets' => 0,
|
||
'renders_total' => 0,
|
||
];
|
||
|
||
if ($this->useUnifiedContent()) {
|
||
$itemsTable = $this->contentItemsTable();
|
||
$sectionsTable = $this->contentSectionsTable();
|
||
$stmt = $this->pdo->prepare("SELECT `id`,`slug` FROM `$sectionsTable` WHERE `customer_id` = :cid");
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$sections = $stmt->fetchAll() ?: [];
|
||
$bySlug = [];
|
||
foreach ($sections as $row) {
|
||
$slug = $row['slug'] ?? '';
|
||
if ($slug !== '') $bySlug[$slug] = (int)$row['id'];
|
||
}
|
||
$slugs = [
|
||
'templates' => 'emailtemplate',
|
||
'sections' => 'sections',
|
||
'blocks' => 'blocks',
|
||
'snippets' => 'snippets',
|
||
];
|
||
foreach ($slugs as $key => $slug) {
|
||
$sid = $bySlug[$slug] ?? null;
|
||
if (!$sid) continue;
|
||
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$itemsTable` WHERE `customer_id` = :cid AND `section_id` = :sid");
|
||
$stmt->execute([':cid' => $customerId, ':sid' => $sid]);
|
||
$counts[$key] = (int)($stmt->fetchColumn() ?: 0);
|
||
}
|
||
} else {
|
||
$map = $this->tableMap ?? [];
|
||
foreach (['templates', 'sections', 'blocks', 'snippets'] as $kind) {
|
||
$table = $map[$kind] ?? null;
|
||
if (!$table || !$this->tableExists($table)) continue;
|
||
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid");
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$counts[$kind] = (int)($stmt->fetchColumn() ?: 0);
|
||
}
|
||
}
|
||
|
||
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
|
||
if ($this->tableExists($usageTable)) {
|
||
$stmt = $this->pdo->prepare("SELECT SUM(`render_count`) FROM `$usageTable` WHERE `customer_id` = :cid");
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$counts['renders_total'] = (int)($stmt->fetchColumn() ?: 0);
|
||
}
|
||
|
||
return $counts;
|
||
}
|
||
|
||
private function listTemplateUsage(int $customerId): array
|
||
{
|
||
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
|
||
if ($this->useUnifiedContent()) {
|
||
$itemsTable = $this->contentItemsTable();
|
||
$sectionsTable = $this->contentSectionsTable();
|
||
if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) {
|
||
return [];
|
||
}
|
||
$section = $this->ensureEmailtemplateSection($customerId);
|
||
if ($this->tableExists($usageTable)) {
|
||
$sql = "SELECT i.id, i.name, i.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at
|
||
FROM `$itemsTable` i
|
||
LEFT JOIN `$usageTable` u ON u.template_id = i.id
|
||
WHERE i.customer_id = :cid AND i.section_id = :sid
|
||
ORDER BY render_count DESC, i.updated_at DESC";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id']]);
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
} else {
|
||
$sql = "SELECT i.id, i.name, i.updated_at FROM `$itemsTable` i WHERE i.customer_id = :cid AND i.section_id = :sid ORDER BY i.updated_at DESC";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id']]);
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
foreach ($rows as &$row) {
|
||
$row['render_count'] = 0;
|
||
$row['last_rendered_at'] = null;
|
||
}
|
||
}
|
||
} else {
|
||
$table = $this->tableMap['templates'] ?? null;
|
||
if (!$table || !$this->tableExists($table)) {
|
||
return [];
|
||
}
|
||
|
||
if ($this->tableExists($usageTable)) {
|
||
$sql = "SELECT t.id, t.name, t.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at
|
||
FROM `$table` t
|
||
LEFT JOIN `$usageTable` u ON u.template_id = t.id
|
||
WHERE t.customer_id = :cid
|
||
ORDER BY render_count DESC, t.updated_at DESC";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
} else {
|
||
$sql = "SELECT t.id, t.name, t.updated_at FROM `$table` t WHERE t.customer_id = :cid ORDER BY t.updated_at DESC";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$rows = $stmt->fetchAll() ?: [];
|
||
foreach ($rows as &$row) {
|
||
$row['render_count'] = 0;
|
||
$row['last_rendered_at'] = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
return array_map(static function ($row) {
|
||
return [
|
||
'template_id' => (int)($row['id'] ?? 0),
|
||
'name' => $row['name'] ?? '',
|
||
'render_count' => (int)($row['render_count'] ?? 0),
|
||
'last_rendered_at' => $row['last_rendered_at'] ?? null,
|
||
'updated_at' => $row['updated_at'] ?? null,
|
||
];
|
||
}, $rows);
|
||
}
|
||
|
||
private function resetTemplateUsage(int $customerId, array $templateIds): void
|
||
{
|
||
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
|
||
if (!$templateIds || !$this->tableExists($usageTable)) {
|
||
return;
|
||
}
|
||
$templateIds = array_values(array_unique(array_filter(array_map('intval', $templateIds), static fn ($v) => $v > 0)));
|
||
if (!$templateIds) return;
|
||
|
||
$placeholders = implode(',', array_fill(0, count($templateIds), '?'));
|
||
$sql = "DELETE FROM `$usageTable` WHERE `customer_id` = ? AND `template_id` IN ($placeholders)";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute(array_merge([$customerId], $templateIds));
|
||
}
|
||
|
||
private function extractIdList($raw): array
|
||
{
|
||
if ($raw === null) return [];
|
||
if (is_numeric($raw)) {
|
||
$raw = [(int)$raw];
|
||
} elseif (is_string($raw)) {
|
||
$raw = preg_split('/[\s,]+/', $raw);
|
||
} elseif (!is_array($raw)) {
|
||
return [];
|
||
}
|
||
|
||
$ids = [];
|
||
foreach ($raw as $value) {
|
||
if (is_array($value)) {
|
||
$ids = array_merge($ids, $this->extractIdList($value));
|
||
continue;
|
||
}
|
||
if ($value === '' || $value === null) continue;
|
||
$ids[] = (int)$value;
|
||
}
|
||
|
||
$ids = array_values(array_unique(array_filter($ids, static fn ($v) => $v > 0)));
|
||
return $ids;
|
||
}
|
||
|
||
private function calculateUsage(string $kind, int $id, array $auth): array
|
||
{
|
||
if ($id <= 0) return ['total' => 0];
|
||
if ($this->useUnifiedContent()) {
|
||
return ['total' => 0];
|
||
}
|
||
|
||
$summary = [];
|
||
$templateItemsTable = $this->lookupTableName('template_items', 'emailtemplate_template_items');
|
||
$sectionItemsTable = $this->lookupTableName('section_items', 'emailtemplate_section_items');
|
||
|
||
if ($kind === 'sections') {
|
||
$summary['templates'] = $this->countRefsInTable(
|
||
$templateItemsTable,
|
||
"`ref_type` = :rt AND `ref_id` = :rid",
|
||
[':rt' => 'section', ':rid' => $id],
|
||
$auth
|
||
);
|
||
} elseif ($kind === 'blocks') {
|
||
$summary['templates'] = $this->countRefsInTable(
|
||
$templateItemsTable,
|
||
"`ref_type` = :rt AND `ref_id` = :rid",
|
||
[':rt' => 'block', ':rid' => $id],
|
||
$auth
|
||
);
|
||
$summary['sections'] = $this->countRefsInTable(
|
||
$sectionItemsTable,
|
||
"`ref_id` = :rid",
|
||
[':rid' => $id],
|
||
$auth
|
||
);
|
||
$summary['snippets'] = $this->countRefsInTable(
|
||
$this->tableMap['snippets'],
|
||
"`block_id` = :rid",
|
||
[':rid' => $id],
|
||
$auth
|
||
);
|
||
}
|
||
|
||
$summary = array_filter($summary, fn($v) => (int)$v > 0);
|
||
$summary['total'] = array_sum($summary);
|
||
return $summary;
|
||
}
|
||
|
||
private function normalizeResourceKind(string $kind): ?string
|
||
{
|
||
$kind = strtolower(trim($kind));
|
||
$map = [
|
||
'template' => 'templates',
|
||
'templates' => 'templates',
|
||
'section' => 'sections',
|
||
'sections' => 'sections',
|
||
'block' => 'blocks',
|
||
'blocks' => 'blocks',
|
||
'snippet' => 'snippets',
|
||
'snippets' => 'snippets',
|
||
];
|
||
return $map[$kind] ?? null;
|
||
}
|
||
|
||
private function resolveHtmlColumn(array $columns, string $kindKey): ?string
|
||
{
|
||
$candidates = ($kindKey === 'snippets')
|
||
? ['content', 'html', 'body', 'markup']
|
||
: ['html', 'body', 'markup', 'content'];
|
||
return $this->firstExisting($columns, $candidates);
|
||
}
|
||
|
||
private function fetchResourceHtml(string $kind, int $id, array $auth, array &$cache, array &$stack): ?string
|
||
{
|
||
if ($id <= 0) return null;
|
||
$kindKey = $this->normalizeResourceKind($kind);
|
||
$cacheKey = ($kindKey ?: $kind) . ':' . $id;
|
||
if (array_key_exists($cacheKey, $cache)) return $cache[$cacheKey];
|
||
if (!empty($stack[$cacheKey])) return null;
|
||
if ($this->useUnifiedContent()) {
|
||
$customerId = (int)($auth['customer_id'] ?? 0);
|
||
$sectionSlug = $this->resolveSectionSlugFromKind($kind);
|
||
$section = $this->fetchContentSectionBySlug($customerId, $sectionSlug);
|
||
if (!$section) {
|
||
$cache[$cacheKey] = null;
|
||
return null;
|
||
}
|
||
$itemsTable = $this->contentItemsTable();
|
||
$itemCols = $this->resolveContentItemColumns($itemsTable);
|
||
$versionsTable = $this->contentVersionsTable();
|
||
$versionCols = $this->tableExists($versionsTable) ? $this->resolveContentVersionColumns($versionsTable) : null;
|
||
$htmlCol = $itemCols['html'];
|
||
$jsonCol = $itemCols['json'];
|
||
if (!$htmlCol && !$jsonCol && (!$versionCols || !$versionCols['html'])) {
|
||
$cache[$cacheKey] = null;
|
||
return null;
|
||
}
|
||
$selectCols = [];
|
||
if ($htmlCol) $selectCols[] = "i.`$htmlCol` AS item_html";
|
||
if ($jsonCol) $selectCols[] = "i.`$jsonCol` AS item_json";
|
||
if ($versionCols && $versionCols['html']) $selectCols[] = "v.`{$versionCols['html']}` AS version_html";
|
||
$join = $versionCols ? "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1" : "";
|
||
$sql = "SELECT " . implode(',', $selectCols) . " FROM `$itemsTable` i $join WHERE i.`customer_id` = :cid AND i.`section_id` = :sid AND i.`id` = :id LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $id]);
|
||
$row = $stmt->fetch();
|
||
if (!$row) {
|
||
$cache[$cacheKey] = null;
|
||
return null;
|
||
}
|
||
$html = (string)($row['version_html'] ?? $row['item_html'] ?? '');
|
||
} else {
|
||
if (!$kindKey) return null;
|
||
$table = $this->tableMap[$kindKey] ?? null;
|
||
if (!$table) return null;
|
||
[$idCol, $allCols] = $this->resolveIdCol($kindKey);
|
||
[$tw, $tp] = $this->tenantWhere($auth);
|
||
|
||
$sql = "SELECT * FROM `$table` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':id', $id);
|
||
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
$row = $stmt->fetch();
|
||
if (!$row) {
|
||
$cache[$cacheKey] = null;
|
||
return null;
|
||
}
|
||
|
||
$htmlCol = $this->resolveHtmlColumn($allCols, $kindKey);
|
||
$html = $htmlCol && isset($row[$htmlCol]) ? (string)$row[$htmlCol] : '';
|
||
}
|
||
|
||
$stack[$cacheKey] = true;
|
||
$html = $this->renderHtmlWithReferences($html, $auth, $cache, $stack);
|
||
unset($stack[$cacheKey]);
|
||
|
||
$cache[$cacheKey] = $html;
|
||
return $html;
|
||
}
|
||
|
||
private function renderHtmlWithReferences(string $html, array $auth, array &$cache, array &$stack): string
|
||
{
|
||
$trimmed = trim($html);
|
||
if ($trimmed === '') return $html;
|
||
if (!class_exists(DOMDocument::class)) return $html;
|
||
|
||
$flags = 0;
|
||
if (defined('LIBXML_HTML_NOIMPLIED')) {
|
||
$flags |= LIBXML_HTML_NOIMPLIED;
|
||
}
|
||
if (defined('LIBXML_HTML_NODEFDTD')) {
|
||
$flags |= LIBXML_HTML_NODEFDTD;
|
||
}
|
||
|
||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||
libxml_use_internal_errors(true);
|
||
$wrapper = '<div id="lib-ref-root">' . $html . '</div>';
|
||
$loaded = @$doc->loadHTML('<?xml encoding="utf-8"?>' . $wrapper, $flags);
|
||
libxml_clear_errors();
|
||
if (!$loaded) return $html;
|
||
|
||
$xpath = new DOMXPath($doc);
|
||
$nodes = $xpath->query('//*[@data-lib-id and (@data-lib-kind or @data-lib-section)]');
|
||
if ($nodes !== false) {
|
||
foreach ($nodes as $node) {
|
||
/** @var \DOMElement $node */
|
||
$kind = $node->getAttribute('data-lib-section') ?: $node->getAttribute('data-lib-kind');
|
||
$refId = (int)$node->getAttribute('data-lib-id');
|
||
if (!$kind || $refId <= 0) continue;
|
||
$replacement = $this->fetchResourceHtml($kind, $refId, $auth, $cache, $stack);
|
||
if ($replacement === null) continue;
|
||
|
||
while ($node->firstChild) {
|
||
$node->removeChild($node->firstChild);
|
||
}
|
||
|
||
$this->appendHtmlToNode($doc, $node, $replacement);
|
||
}
|
||
}
|
||
|
||
$root = $doc->getElementById('lib-ref-root');
|
||
if (!$root) return $html;
|
||
|
||
$output = '';
|
||
foreach ($root->childNodes as $child) {
|
||
$output .= $doc->saveHTML($child);
|
||
}
|
||
return $output;
|
||
}
|
||
|
||
private function appendHtmlToNode(DOMDocument $targetDoc, DOMElement $node, string $html): void
|
||
{
|
||
if (trim($html) === '') return;
|
||
|
||
$flags = 0;
|
||
if (defined('LIBXML_HTML_NOIMPLIED')) {
|
||
$flags |= LIBXML_HTML_NOIMPLIED;
|
||
}
|
||
if (defined('LIBXML_HTML_NODEFDTD')) {
|
||
$flags |= LIBXML_HTML_NODEFDTD;
|
||
}
|
||
|
||
$fragmentDoc = new DOMDocument('1.0', 'UTF-8');
|
||
libxml_use_internal_errors(true);
|
||
$wrapped = '<div id="fragment-wrapper">' . $html . '</div>';
|
||
$loaded = @$fragmentDoc->loadHTML('<?xml encoding="utf-8"?>' . $wrapped, $flags);
|
||
libxml_clear_errors();
|
||
|
||
if ($loaded) {
|
||
$wrapper = $fragmentDoc->getElementById('fragment-wrapper');
|
||
if ($wrapper) {
|
||
$children = [];
|
||
if ($wrapper->hasChildNodes()) {
|
||
foreach ($wrapper->childNodes as $child) {
|
||
$children[] = $child;
|
||
}
|
||
}
|
||
foreach ($children as $child) {
|
||
$imported = $targetDoc->importNode($child, true);
|
||
if ($imported) {
|
||
$node->appendChild($imported);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
$node->appendChild($targetDoc->createTextNode($html));
|
||
}
|
||
|
||
private function handlePlaceholderSchema(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
$bridge = $this->resolveBridgeConfig($customerId);
|
||
$url = trim((string)($bridge['url'] ?? ''));
|
||
$token = trim((string)($bridge['token'] ?? ''));
|
||
if ($url === '' || $token === '') {
|
||
$this->fail('Bridge not configured', null, 500);
|
||
}
|
||
|
||
$ttl = (int)($bridge['cache_ttl'] ?? 300);
|
||
try {
|
||
$schema = $this->fetchPlaceholderSchema($url, $token, $ttl);
|
||
} catch (Throwable $e) {
|
||
$this->fail('Bridge request failed', $e->getMessage(), 502);
|
||
return;
|
||
}
|
||
|
||
$settings = $this->getCustomerSettings($customerId);
|
||
$allowedTables = $this->normalizeBridgeTables($settings['bridge_tables'] ?? []);
|
||
if ($allowedTables && !empty($schema['tables']) && is_array($schema['tables'])) {
|
||
$allowedMap = array_fill_keys($allowedTables, true);
|
||
$schema['tables'] = array_values(array_filter($schema['tables'], static function ($entry) use ($allowedMap) {
|
||
if (is_string($entry)) {
|
||
$name = $entry;
|
||
} elseif (is_array($entry)) {
|
||
$name = $entry['name'] ?? $entry['table'] ?? $entry['label'] ?? '';
|
||
} else {
|
||
$name = '';
|
||
}
|
||
return $name !== '' && isset($allowedMap[$name]);
|
||
}));
|
||
}
|
||
|
||
$this->respond([
|
||
'ok' => true,
|
||
'tables' => $schema['tables'] ?? [],
|
||
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
|
||
]);
|
||
}
|
||
|
||
private function handlePlaceholderStatus(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
$bridge = $this->resolveBridgeConfig($customerId);
|
||
$url = trim((string)($bridge['url'] ?? ''));
|
||
$token = trim((string)($bridge['token'] ?? ''));
|
||
$available = ($url !== '' && $token !== '');
|
||
$this->respond([
|
||
'ok' => true,
|
||
'available' => $available,
|
||
]);
|
||
}
|
||
|
||
private function fetchPlaceholderSchema(string $url, string $token, int $ttl): array
|
||
{
|
||
$cacheFile = $this->placeholderCachePath($url, $token);
|
||
if ($ttl > 0 && is_file($cacheFile) && (filemtime($cacheFile) + $ttl) > time()) {
|
||
$cached = json_decode((string)@file_get_contents($cacheFile), true);
|
||
if (is_array($cached)) {
|
||
return $cached;
|
||
}
|
||
}
|
||
|
||
$endpoint = $url;
|
||
if (stripos($endpoint, 'action=') === false) {
|
||
$endpoint .= (strpos($endpoint, '?') === false ? '?' : '&') . 'action=schema';
|
||
}
|
||
|
||
$context = stream_context_create([
|
||
'http' => [
|
||
'method' => 'GET',
|
||
'header' => "Authorization: Bearer {$token}\r\nAccept: application/json\r\n",
|
||
'timeout' => 10,
|
||
],
|
||
]);
|
||
$response = @file_get_contents($endpoint, false, $context);
|
||
if ($response === false) {
|
||
throw new RuntimeException('Bridge endpoint unreachable');
|
||
}
|
||
|
||
$decoded = json_decode($response, true);
|
||
if (!is_array($decoded) || empty($decoded['ok'])) {
|
||
throw new RuntimeException('Bridge did not return a valid schema');
|
||
}
|
||
|
||
if ($ttl > 0) {
|
||
@file_put_contents($cacheFile, json_encode($decoded));
|
||
}
|
||
|
||
return $decoded;
|
||
}
|
||
|
||
private function placeholderCachePath(string $url, string $token): string
|
||
{
|
||
$hash = md5($url . '|' . $token);
|
||
return sys_get_temp_dir() . '/emailtemplate_placeholder_' . $hash . '.json';
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// Account & User Management
|
||
// -----------------------------------------------------------------
|
||
|
||
private function handleAccountProfileGet(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
$settings = $customerId ? $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId)) : [];
|
||
$this->ensureAuthUserListSortColumn();
|
||
$settings['list_sort'] = $this->resolveUserListSort($user, $customerId);
|
||
$this->respond([
|
||
'ok' => true,
|
||
'user' => $user,
|
||
'customer' => $user['customer'] ?? null,
|
||
'settings' => $settings,
|
||
]);
|
||
}
|
||
|
||
private function handleAccountProfileUpdate(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
$name = trim((string)($this->in['name'] ?? ''));
|
||
$email = trim(strtolower((string)($this->in['email'] ?? '')));
|
||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
$this->fail('Valid email required', null, 422);
|
||
}
|
||
if ($name === '') $this->fail('Name required', null, 422);
|
||
$userId = (int)($user['id'] ?? 0);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
if (strtolower($email) !== strtolower((string)$user['email'])) {
|
||
$this->assertEmailUnique($email, $customerId, $userId);
|
||
}
|
||
|
||
$set = [];
|
||
$params = [':id' => $userId];
|
||
if ($this->columnExists($dbCols, $cols['col_name'])) {
|
||
$set[] = sprintf('`%s` = :name', $cols['col_name']);
|
||
$params[':name'] = $name;
|
||
}
|
||
if ($this->columnExists($dbCols, $cols['col_email'])) {
|
||
$set[] = sprintf('`%s` = :email', $cols['col_email']);
|
||
$params[':email'] = $email;
|
||
}
|
||
if (!$set) {
|
||
$this->fail('Profile update not supported', null, 500);
|
||
}
|
||
$sql = sprintf(
|
||
'UPDATE `%s` SET %s WHERE `%s` = :id LIMIT 1',
|
||
$table,
|
||
implode(',', $set),
|
||
$cols['col_id']
|
||
);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
|
||
$_SESSION['auth']['name'] = $name;
|
||
$_SESSION['auth']['email'] = $email;
|
||
|
||
$this->respond(['ok' => true, 'user' => $_SESSION['auth']]);
|
||
}
|
||
|
||
private function handleAccountPasswordUpdate(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$current = (string)($this->in['current_password'] ?? '');
|
||
$new = (string)($this->in['new_password'] ?? '');
|
||
if ($current === '' || $new === '') {
|
||
$this->fail('Current and new password required', null, 422);
|
||
}
|
||
if (strlen($new) < 8) {
|
||
$this->fail('Password must be at least 8 characters', null, 422);
|
||
}
|
||
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$sql = sprintf(
|
||
'SELECT `%1$s` FROM `%2$s` WHERE `%3$s` = :id LIMIT 1',
|
||
$cols['col_pass'],
|
||
$table,
|
||
$cols['col_id']
|
||
);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':id' => $user['id']]);
|
||
$row = $stmt->fetch();
|
||
if (!$row) $this->fail('User not found', null, 404);
|
||
|
||
$stored = (string)$row[$cols['col_pass']];
|
||
if (!$this->verifyUserPasswordValue($current, $stored)) {
|
||
$this->fail('Current password incorrect', null, 403);
|
||
}
|
||
|
||
$hash = $this->hashUserPassword($new);
|
||
$update = $this->pdo->prepare(
|
||
sprintf('UPDATE `%s` SET `%s` = :pwd WHERE `%s` = :id LIMIT 1', $table, $cols['col_pass'], $cols['col_id'])
|
||
);
|
||
$update->execute([':pwd' => $hash, ':id' => $user['id']]);
|
||
$this->respond(['ok' => true]);
|
||
}
|
||
|
||
private function handleAccountSettingsGet(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
|
||
$this->ensureAuthUserListSortColumn();
|
||
$settings['list_sort'] = $this->resolveUserListSort($user, $customerId);
|
||
$this->respond(['ok' => true, 'settings' => $settings]);
|
||
}
|
||
|
||
private function handleAccountSettingsUpdate(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
|
||
$hasBridgeUrl = array_key_exists('bridge_url', $this->in);
|
||
$hasBridgeToken = array_key_exists('bridge_token', $this->in);
|
||
$hasSenderToken = array_key_exists('sender_token', $this->in);
|
||
$hasExternalToken = array_key_exists('external_api_token', $this->in);
|
||
$hasEditorDefault = array_key_exists('editor_default', $this->in);
|
||
$hasListSort = array_key_exists('list_sort', $this->in);
|
||
$hasBridgeTables = array_key_exists('bridge_tables', $this->in);
|
||
$rotateBridge = !empty($this->in['rotate_bridge_token']);
|
||
$rotateSender = !empty($this->in['rotate_sender_token']);
|
||
$rotateExternal = !empty($this->in['rotate_external_token']);
|
||
$onlyListSort = $hasListSort && !$hasBridgeUrl && !$hasBridgeToken && !$hasSenderToken && !$hasExternalToken
|
||
&& !$hasEditorDefault && !$hasBridgeTables && !$rotateBridge && !$rotateSender && !$rotateExternal;
|
||
|
||
if (!$onlyListSort) {
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
}
|
||
|
||
$settings = $customerId ? $this->getCustomerSettings($customerId) : [];
|
||
$bridgeUrl = $hasBridgeUrl ? trim((string)($this->in['bridge_url'] ?? '')) : (string)($settings['bridge_url'] ?? '');
|
||
$bridgeToken = $hasBridgeToken ? trim((string)($this->in['bridge_token'] ?? '')) : (string)($settings['bridge_token'] ?? '');
|
||
$senderToken = $hasSenderToken ? trim((string)($this->in['sender_token'] ?? '')) : (string)($settings['sender_token'] ?? '');
|
||
$externalToken = $hasExternalToken ? trim((string)($this->in['external_api_token'] ?? '')) : (string)($settings['external_api_token'] ?? '');
|
||
$editorDefault = $hasEditorDefault ? strtolower(trim((string)($this->in['editor_default'] ?? ''))) : strtolower((string)($settings['editor_default'] ?? ''));
|
||
$listSort = $hasListSort ? strtolower(trim((string)($this->in['list_sort'] ?? ''))) : '';
|
||
$bridgeTables = $hasBridgeTables ? $this->normalizeBridgeTables($this->in['bridge_tables'] ?? []) : ($settings['bridge_tables'] ?? []);
|
||
|
||
if ($bridgeUrl && !filter_var($bridgeUrl, FILTER_VALIDATE_URL)) {
|
||
$this->fail('Ungültige Bridge-URL', null, 422);
|
||
}
|
||
|
||
if ($listSort !== '' && !in_array($listSort, ['created_asc', 'name_asc', 'name_desc', 'updated_desc'], true)) {
|
||
$this->fail('Ungültige Sortierung', null, 422);
|
||
}
|
||
|
||
if (!$onlyListSort) {
|
||
if ($rotateBridge || $bridgeToken === '') $bridgeToken = $this->generateToken();
|
||
if ($rotateSender || $senderToken === '') $senderToken = $this->generateToken();
|
||
if ($rotateExternal || $externalToken === '') $externalToken = $this->generateToken();
|
||
|
||
if ($editorDefault !== '' && !in_array($editorDefault, ['grapesjs', 'craftjs'], true)) {
|
||
$this->fail('Ungültiger Editor-Typ', null, 422);
|
||
}
|
||
|
||
$settings = $this->saveCustomerSettings($customerId, [
|
||
'bridge_url' => $bridgeUrl,
|
||
'bridge_token' => $bridgeToken,
|
||
'sender_token' => $senderToken,
|
||
'external_api_token' => $externalToken,
|
||
'editor_default' => $editorDefault ?: null,
|
||
'bridge_tables' => $bridgeTables,
|
||
]);
|
||
} else {
|
||
$settings = $customerId ? $this->ensureSettingsTokens($customerId, $settings) : $settings;
|
||
}
|
||
if ($hasListSort) {
|
||
$this->ensureAuthUserListSortColumn();
|
||
$this->updateUserListSort($user, $customerId, $listSort ?: null);
|
||
}
|
||
$settings['list_sort'] = $this->resolveUserListSort($user, $customerId, $listSort);
|
||
|
||
$this->respond(['ok' => true, 'settings' => $settings]);
|
||
}
|
||
|
||
private function handleAccountUsersList(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureOwner($user);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
$select = [
|
||
sprintf('`%s` AS user_id', $cols['col_id']),
|
||
];
|
||
$nameSource = $this->columnExists($dbCols, $cols['col_name']) ? $cols['col_name'] : $cols['col_email'];
|
||
$select[] = sprintf('`%s` AS name', $nameSource);
|
||
$select[] = sprintf('`%s` AS email', $cols['col_email']);
|
||
if ($this->columnExists($dbCols, $cols['col_role'])) {
|
||
$select[] = sprintf('`%s` AS role', $cols['col_role']);
|
||
} else {
|
||
$select[] = "'user' AS role";
|
||
}
|
||
if ($this->columnExists($dbCols, $cols['col_status'])) {
|
||
$select[] = sprintf('`%s` AS is_active', $cols['col_status']);
|
||
} else {
|
||
$select[] = '1 AS is_active';
|
||
}
|
||
if ($this->columnExists($dbCols, 'created_at')) $select[] = '`created_at`';
|
||
if ($this->columnExists($dbCols, 'updated_at')) $select[] = '`updated_at`';
|
||
|
||
$sql = sprintf(
|
||
'SELECT %s FROM `%s` WHERE `%s` = :cid ORDER BY `%s` ASC',
|
||
implode(',', $select),
|
||
$table,
|
||
$cols['col_customer'],
|
||
$nameSource
|
||
);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$items = [];
|
||
while ($row = $stmt->fetch()) {
|
||
$items[] = $this->formatUserOutput($row);
|
||
}
|
||
$this->respond(['ok' => true, 'items' => $items]);
|
||
}
|
||
|
||
private function handleAccountUsersCreate(): void
|
||
{
|
||
$owner = $this->requireAuth();
|
||
$this->ensureOwner($owner);
|
||
$customerId = (int)($owner['customer_id'] ?? 0);
|
||
|
||
$name = trim((string)($this->in['name'] ?? ''));
|
||
$email = trim(strtolower((string)($this->in['email'] ?? '')));
|
||
$role = $this->sanitizeRole((string)($this->in['role'] ?? 'user'));
|
||
|
||
if ($name === '' || $email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
$this->fail('Name und gültige E-Mail sind erforderlich', null, 422);
|
||
}
|
||
$this->assertEmailUnique($email, $customerId, null);
|
||
|
||
$password = $this->generateReadablePassword();
|
||
$hash = $this->hashUserPassword($password);
|
||
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
|
||
$data = [];
|
||
if ($this->columnExists($dbCols, $cols['col_name'])) {
|
||
$data[$cols['col_name']] = $name;
|
||
}
|
||
$data[$cols['col_email']] = $email;
|
||
$data[$cols['col_pass']] = $hash;
|
||
if ($this->columnExists($dbCols, $cols['col_role'])) $data[$cols['col_role']] = $role;
|
||
if ($this->columnExists($dbCols, $cols['col_status'])) $data[$cols['col_status']] = 1;
|
||
if ($this->columnExists($dbCols, $cols['col_customer'])) $data[$cols['col_customer']] = $customerId;
|
||
if ($this->columnExists($dbCols, 'created_at')) $data['created_at'] = date('Y-m-d H:i:s');
|
||
if ($this->columnExists($dbCols, 'updated_at')) $data['updated_at'] = date('Y-m-d H:i:s');
|
||
|
||
$columns = array_keys($data);
|
||
$insertCols = implode(',', array_map(fn($c) => "`$c`", $columns));
|
||
$placeholders = implode(',', array_map(fn($c) => ":$c", $columns));
|
||
$sql = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $insertCols, $placeholders);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($data as $col => $value) $stmt->bindValue(":$col", $value);
|
||
$stmt->execute();
|
||
$newId = (int)$this->pdo->lastInsertId();
|
||
|
||
$newUser = $this->fetchUserRow($newId, $customerId);
|
||
$this->respond(['ok' => true, 'user' => $newUser, 'temp_password' => $password]);
|
||
}
|
||
|
||
private function handleAccountUsersUpdate(): void
|
||
{
|
||
$owner = $this->requireAuth();
|
||
$this->ensureOwner($owner);
|
||
$customerId = (int)($owner['customer_id'] ?? 0);
|
||
|
||
$userId = (int)($this->in['user_id'] ?? 0);
|
||
if ($userId <= 0) $this->fail('Ungültige Nutzer-ID', null, 422);
|
||
$target = $this->fetchUserRow($userId, $customerId);
|
||
if (!$target) $this->fail('Nutzer nicht gefunden', null, 404);
|
||
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
$set = [];
|
||
$params = [':id' => $userId];
|
||
|
||
$name = trim((string)($this->in['name'] ?? $target['name']));
|
||
$email = trim(strtolower((string)($this->in['email'] ?? $target['email'])));
|
||
$role = $this->sanitizeRole((string)($this->in['role'] ?? $target['role']));
|
||
$isActive = isset($this->in['is_active']) ? (int)(bool)$this->in['is_active'] : (int)$target['is_active'];
|
||
$resetPassword = !empty($this->in['reset_password']);
|
||
|
||
if ($name === '' || $email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
$this->fail('Name und gültige E-Mail sind erforderlich', null, 422);
|
||
}
|
||
if (strtolower($email) !== strtolower($target['email'])) {
|
||
$this->assertEmailUnique($email, $customerId, $userId);
|
||
}
|
||
|
||
if ($this->columnExists($dbCols, $cols['col_name'])) {
|
||
$set[] = sprintf('`%s` = :name', $cols['col_name']);
|
||
$params[':name'] = $name;
|
||
}
|
||
if ($this->columnExists($dbCols, $cols['col_email'])) {
|
||
$set[] = sprintf('`%s` = :email', $cols['col_email']);
|
||
$params[':email'] = $email;
|
||
}
|
||
if ($this->columnExists($dbCols, $cols['col_role'])) {
|
||
if ($target['role'] === 'owner' && $role !== 'owner' && $this->countOwners($customerId, $userId) < 1) {
|
||
$this->fail('Mindestens ein Owner muss bestehen bleiben', null, 422);
|
||
}
|
||
$set[] = sprintf('`%s` = :role', $cols['col_role']);
|
||
$params[':role'] = $role;
|
||
}
|
||
if ($this->columnExists($dbCols, $cols['col_status'])) {
|
||
if ($target['role'] === 'owner' && !$isActive && $this->countOwners($customerId, $userId) < 1) {
|
||
$this->fail('Mindestens ein Owner muss aktiv bleiben', null, 422);
|
||
}
|
||
$set[] = sprintf('`%s` = :status', $cols['col_status']);
|
||
$params[':status'] = $isActive;
|
||
}
|
||
$tempPassword = null;
|
||
if ($resetPassword) {
|
||
$tempPassword = $this->generateReadablePassword();
|
||
$hash = $this->hashUserPassword($tempPassword);
|
||
$set[] = sprintf('`%s` = :pwd', $cols['col_pass']);
|
||
$params[':pwd'] = $hash;
|
||
}
|
||
if ($this->columnExists($dbCols, 'updated_at')) {
|
||
$set[] = '`updated_at` = :updated_at';
|
||
$params[':updated_at'] = date('Y-m-d H:i:s');
|
||
}
|
||
if (!$set) $this->fail('Keine Änderungen erkannt', null, 422);
|
||
|
||
$sql = sprintf('UPDATE `%s` SET %s WHERE `%s` = :id LIMIT 1', $table, implode(',', $set), $cols['col_id']);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||
$stmt->execute();
|
||
|
||
$updated = $this->fetchUserRow($userId, $customerId);
|
||
$resp = ['ok' => true, 'user' => $updated];
|
||
if ($tempPassword !== null) $resp['temp_password'] = $tempPassword;
|
||
$this->respond($resp);
|
||
}
|
||
|
||
private function handleAccountUsersDelete(): void
|
||
{
|
||
$owner = $this->requireAuth();
|
||
$this->ensureOwner($owner);
|
||
$customerId = (int)($owner['customer_id'] ?? 0);
|
||
|
||
$userId = (int)($this->in['user_id'] ?? 0);
|
||
if ($userId <= 0) $this->fail('Ungültige Nutzer-ID', null, 422);
|
||
if ($userId === (int)($owner['id'] ?? 0)) $this->fail('Du kannst dich nicht selbst löschen', null, 422);
|
||
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
|
||
$target = $this->fetchUserRow($userId, $customerId);
|
||
if (!$target) $this->fail('Nutzer nicht gefunden', null, 404);
|
||
if ($target['role'] === 'owner' && $this->countOwners($customerId, $userId) < 1) {
|
||
$this->fail('Mindestens ein Owner muss bestehen bleiben', null, 422);
|
||
}
|
||
|
||
if ($this->columnExists($dbCols, $cols['col_status'])) {
|
||
$sql = sprintf('UPDATE `%s` SET `%s` = 0 WHERE `%s` = :id LIMIT 1', $table, $cols['col_status'], $cols['col_id']);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':id' => $userId]);
|
||
} else {
|
||
$sql = sprintf('DELETE FROM `%s` WHERE `%s` = :id LIMIT 1', $table, $cols['col_id']);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':id' => $userId]);
|
||
}
|
||
|
||
$this->respond(['ok' => true, 'deleted' => true]);
|
||
}
|
||
|
||
private function handleAccountSendersList(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$table = $this->senderTable();
|
||
try {
|
||
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `label` ASC");
|
||
} catch (Throwable $e) {
|
||
$this->fail('Sender-Tabelle existiert nicht', $e->getMessage(), 500);
|
||
}
|
||
$stmt->execute([':cid' => $customerId]);
|
||
$items = [];
|
||
while ($row = $stmt->fetch()) {
|
||
$items[] = $this->formatSenderRow($row);
|
||
}
|
||
$this->respond(['ok' => true, 'items' => $items]);
|
||
}
|
||
|
||
private function handleAccountSenderSave(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
|
||
$senderId = (int)($this->in['sender_id'] ?? 0);
|
||
$label = trim((string)($this->in['label'] ?? ''));
|
||
$fromName = trim((string)($this->in['from_name'] ?? ''));
|
||
$fromEmail = trim((string)($this->in['from_email'] ?? ''));
|
||
$replyTo = trim((string)($this->in['reply_to'] ?? ''));
|
||
if ($label === '') $label = $fromName ?: $fromEmail;
|
||
if ($fromEmail === '' || !filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) {
|
||
$this->fail('Gültige Absender-Adresse erforderlich', null, 422);
|
||
}
|
||
if ($replyTo !== '' && !filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
|
||
$this->fail('Ungültige Reply-To-Adresse', null, 422);
|
||
}
|
||
|
||
$table = $this->senderTable();
|
||
if ($senderId > 0) {
|
||
$stmt = $this->pdo->prepare("UPDATE `$table` SET `label`=:label,`from_name`=:fname,`from_email`=:fmail,`reply_to`=:reply,`updated_at`=NOW() WHERE `id`=:id AND `customer_id`=:cid LIMIT 1");
|
||
$stmt->execute([
|
||
':label' => $label,
|
||
':fname' => $fromName ?: null,
|
||
':fmail' => $fromEmail,
|
||
':reply' => $replyTo ?: null,
|
||
':id' => $senderId,
|
||
':cid' => $customerId,
|
||
]);
|
||
if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404);
|
||
} else {
|
||
$stmt = $this->pdo->prepare("INSERT INTO `$table` (`customer_id`,`label`,`from_name`,`from_email`,`reply_to`,`created_at`,`updated_at`) VALUES (:cid,:label,:fname,:fmail,:reply,NOW(),NOW())");
|
||
$stmt->execute([
|
||
':cid' => $customerId,
|
||
':label' => $label,
|
||
':fname' => $fromName ?: null,
|
||
':fmail' => $fromEmail,
|
||
':reply' => $replyTo ?: null,
|
||
]);
|
||
$senderId = (int)$this->pdo->lastInsertId();
|
||
}
|
||
|
||
$sender = $this->fetchSenderRow($customerId, $senderId);
|
||
$this->respond(['ok' => true, 'sender' => $sender]);
|
||
}
|
||
|
||
private function handleAccountSenderDelete(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
$senderId = (int)($this->in['sender_id'] ?? 0);
|
||
if ($senderId <= 0) $this->fail('Ungültige Sender-ID', null, 422);
|
||
$table = $this->senderTable();
|
||
$stmt = $this->pdo->prepare("DELETE FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||
$stmt->execute([':id' => $senderId, ':cid' => $customerId]);
|
||
if ($stmt->rowCount() === 0) $this->fail('Absender nicht gefunden', null, 404);
|
||
$this->respond(['ok' => true, 'deleted' => true]);
|
||
}
|
||
|
||
private function handleDashboardMetrics(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$counts = $this->fetchResourceCounts($customerId);
|
||
$usage = $this->listTemplateUsage($customerId);
|
||
$this->respond([
|
||
'ok' => true,
|
||
'counts' => $counts,
|
||
'usage' => $usage,
|
||
]);
|
||
}
|
||
|
||
private function handleDashboardResetUsage(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
$raw = $this->in['template_id'] ?? $this->in['templates'] ?? null;
|
||
$ids = $this->extractIdList($raw);
|
||
if (!$ids) {
|
||
$this->fail('template_id required', null, 422);
|
||
}
|
||
$this->resetTemplateUsage($customerId, $ids);
|
||
$this->respond(['ok' => true]);
|
||
}
|
||
|
||
private function handleDownloadFile(string $type): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
|
||
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
|
||
$bridgeSetup = $this->getBridgeSetupData($customerId);
|
||
$content = $this->loadDownloadTemplate($type);
|
||
if ($type === 'bridge') {
|
||
$content = $this->populateBridgeDownload($content, $settings, $bridgeSetup);
|
||
} else {
|
||
$content = $this->populateSenderDownload($content, $settings);
|
||
}
|
||
|
||
$this->respond([
|
||
'ok' => true,
|
||
'file_name' => $type === 'bridge' ? 'emailtemplate_bridge.php' : 'emailtemplate_sender.php',
|
||
'content' => base64_encode($content),
|
||
]);
|
||
}
|
||
|
||
private function handleAccountBridgeSetupGet(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
|
||
$setup = $this->getBridgeSetupData($customerId);
|
||
$this->respond(['ok' => true, 'setup' => $setup]);
|
||
}
|
||
|
||
private function handleAccountBridgeSetupSave(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
||
|
||
$tables = $this->normalizeBridgeTables($this->in['tables'] ?? $this->in['bridge_tables'] ?? []);
|
||
$mode = strtolower((string)($this->in['mode'] ?? $this->in['db_mode'] ?? 'direct'));
|
||
$direct = [
|
||
'host' => trim((string)($this->in['direct_host'] ?? '')),
|
||
'port' => (int)($this->in['direct_port'] ?? 3306),
|
||
'database' => trim((string)($this->in['direct_database'] ?? $this->in['direct_db'] ?? '')),
|
||
'user' => trim((string)($this->in['direct_user'] ?? '')),
|
||
'password' => (string)($this->in['direct_password'] ?? ''),
|
||
'charset' => trim((string)($this->in['direct_charset'] ?? '')) ?: 'utf8mb4',
|
||
];
|
||
$config = [
|
||
'file' => trim((string)($this->in['config_file'] ?? '')),
|
||
'base' => (string)($this->in['config_base'] ?? ''),
|
||
'host_key' => (string)($this->in['config_host_key'] ?? ''),
|
||
'port_key' => (string)($this->in['config_port_key'] ?? ''),
|
||
'database_key' => (string)($this->in['config_database_key'] ?? ''),
|
||
'user_key' => (string)($this->in['config_user_key'] ?? ''),
|
||
'password_key' => (string)($this->in['config_password_key'] ?? ''),
|
||
'charset_key' => (string)($this->in['config_charset_key'] ?? ''),
|
||
];
|
||
$fonts = [
|
||
'dir' => (string)($this->in['fonts_dir'] ?? ''),
|
||
'url_base' => (string)($this->in['fonts_url_base'] ?? ''),
|
||
'urls' => (string)($this->in['fonts_urls'] ?? ''),
|
||
];
|
||
|
||
$setup = $this->sanitizeBridgeSetup([
|
||
'tables' => $tables,
|
||
'mode' => $mode,
|
||
'direct' => $direct,
|
||
'config' => $config,
|
||
'fonts' => $fonts,
|
||
]);
|
||
|
||
$stored = $this->saveBridgeSetupData($customerId, $setup);
|
||
$this->respond(['ok' => true, 'setup' => $stored]);
|
||
}
|
||
|
||
private function handleAccountBridgeTest(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureRole($user, ['owner', 'admin']);
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
$bridgeUrl = trim((string)($this->in['bridge_url'] ?? ''));
|
||
$bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
|
||
if ($bridgeUrl === '' || $bridgeToken === '') {
|
||
$settings = $this->getCustomerSettings($customerId);
|
||
if ($bridgeUrl === '') $bridgeUrl = (string)($settings['bridge_url'] ?? '');
|
||
if ($bridgeToken === '') $bridgeToken = (string)($settings['bridge_token'] ?? '');
|
||
}
|
||
if ($bridgeUrl === '' || $bridgeToken === '') {
|
||
$this->fail('Bridge nicht konfiguriert', null, 422);
|
||
}
|
||
try {
|
||
$schema = $this->fetchPlaceholderSchema($bridgeUrl, $bridgeToken, 0);
|
||
} catch (Throwable $e) {
|
||
$this->fail('Bridge request failed', $e->getMessage(), 502);
|
||
return;
|
||
}
|
||
|
||
$this->respond([
|
||
'ok' => true,
|
||
'tables' => $schema['tables'] ?? [],
|
||
'setup_hint' => $schema['setup_hint'] ?? null,
|
||
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
|
||
]);
|
||
}
|
||
|
||
private function handleAccountFontsList(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
$setup = $this->getBridgeSetupData($customerId);
|
||
$fonts = $setup['fonts'] ?? [];
|
||
$payload = $this->buildFontCatalog($fonts);
|
||
$this->respond([
|
||
'ok' => true,
|
||
'fonts' => $payload['fonts'],
|
||
'font_face_css' => $payload['font_face_css'],
|
||
]);
|
||
}
|
||
|
||
private function handleDebugPhpInfo(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureDebugUser($user);
|
||
$this->ensureDebugEnv();
|
||
ob_start();
|
||
phpinfo(INFO_GENERAL | INFO_CONFIGURATION | INFO_MODULES | INFO_ENVIRONMENT);
|
||
$html = ob_get_clean() ?: '';
|
||
$this->respond(['ok' => true, 'html' => $html]);
|
||
}
|
||
|
||
private function handleDebugLogsList(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureDebugUser($user);
|
||
$this->ensureDebugEnv();
|
||
$dir = $this->debugDir();
|
||
if (!is_dir($dir)) {
|
||
$this->respond(['ok' => true, 'items' => []]);
|
||
}
|
||
$items = [];
|
||
foreach (glob($dir . '/*.log') ?: [] as $file) {
|
||
$items[] = [
|
||
'name' => basename($file),
|
||
'size' => filesize($file) ?: 0,
|
||
'updated_at' => date(DATE_ATOM, filemtime($file) ?: time()),
|
||
];
|
||
}
|
||
$this->respond(['ok' => true, 'items' => $items]);
|
||
}
|
||
|
||
private function handleDebugLogsRead(): void
|
||
{
|
||
$user = $this->requireAuth();
|
||
$this->ensureDebugUser($user);
|
||
$this->ensureDebugEnv();
|
||
$name = trim((string)($this->in['name'] ?? ''));
|
||
if ($name === '') {
|
||
$this->fail('Log name required', null, 422);
|
||
}
|
||
$name = preg_replace('/[^a-zA-Z0-9_\.\-]/', '', $name) ?: '';
|
||
if ($name === '' || strpos($name, '..') !== false) {
|
||
$this->fail('Invalid log name', null, 422);
|
||
}
|
||
$file = $this->debugDir() . '/' . $name;
|
||
if (!is_file($file)) {
|
||
$this->fail('Log not found', null, 404);
|
||
}
|
||
$content = (string)file_get_contents($file);
|
||
$this->respond(['ok' => true, 'content' => $content]);
|
||
}
|
||
|
||
private function resolveBridgeConfig(?int $customerId): array
|
||
{
|
||
$fileConf = $this->conf['placeholders']['bridge'] ?? [];
|
||
$settings = $customerId ? $this->getCustomerSettings($customerId) : [];
|
||
$url = $settings['bridge_url'] ?? ($fileConf['url'] ?? '');
|
||
$token = $settings['bridge_token'] ?? ($fileConf['token'] ?? '');
|
||
$ttl = $fileConf['cache_ttl'] ?? 300;
|
||
return ['url' => $url, 'token' => $token, 'cache_ttl' => $ttl];
|
||
}
|
||
|
||
private function getCustomerSettings(int $customerId): array
|
||
{
|
||
if ($customerId <= 0) return [];
|
||
$this->ensureCustomerSettingsTableExists();
|
||
$table = $this->customerSettingsTable();
|
||
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :id LIMIT 1");
|
||
$stmt->execute([':id' => $customerId]);
|
||
$row = $stmt->fetch();
|
||
return $row ? $this->formatCustomerSettingsRow($row) : [];
|
||
}
|
||
|
||
private function getBridgeSetupData(int $customerId): array
|
||
{
|
||
$settings = $this->getCustomerSettings($customerId);
|
||
$setup = $settings['bridge_setup'] ?? $this->defaultBridgeSetup();
|
||
if ((!$setup['tables'] || !count($setup['tables'])) && !empty($settings['bridge_tables'])) {
|
||
$setup['tables'] = $this->normalizeBridgeTables($settings['bridge_tables']);
|
||
}
|
||
return $setup;
|
||
}
|
||
|
||
private function saveCustomerSettings(int $customerId, array $data): array
|
||
{
|
||
if ($customerId <= 0) return [];
|
||
$this->ensureCustomerSettingsTableExists();
|
||
$allowed = ['bridge_url', 'bridge_token', 'sender_token', 'external_api_token', 'editor_default', 'bridge_tables', 'bridge_setup'];
|
||
$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();
|
||
}
|
||
if (empty($row['editor_default'])) {
|
||
$row['editor_default'] = 'grapesjs';
|
||
}
|
||
return $row;
|
||
}
|
||
|
||
private function normalizeBridgeTables($input): array
|
||
{
|
||
$items = [];
|
||
if (is_string($input)) {
|
||
$items = preg_split('/[\s,]+/', $input) ?: [];
|
||
} elseif (is_array($input)) {
|
||
$items = $input;
|
||
} elseif ($input === null) {
|
||
return [];
|
||
} else {
|
||
$items = [$input];
|
||
}
|
||
$items = array_map(static function ($value) {
|
||
return trim((string)$value);
|
||
}, $items);
|
||
$items = array_filter($items, fn($value) => $value !== '');
|
||
return array_values(array_unique($items));
|
||
}
|
||
|
||
private function encodeBridgeTables(?array $tables): ?string
|
||
{
|
||
if (empty($tables)) return null;
|
||
return json_encode(array_values($tables), JSON_UNESCAPED_SLASHES);
|
||
}
|
||
|
||
private function decodeBridgeTables($stored): array
|
||
{
|
||
if (is_array($stored)) {
|
||
return $this->normalizeBridgeTables($stored);
|
||
}
|
||
$str = (string)$stored;
|
||
if ($str === '') return [];
|
||
$decoded = json_decode($str, true);
|
||
if (is_array($decoded)) {
|
||
return $this->normalizeBridgeTables($decoded);
|
||
}
|
||
return $this->normalizeBridgeTables($str);
|
||
}
|
||
|
||
private function encodeBridgeSetup($setup)
|
||
{
|
||
if (is_array($setup)) {
|
||
$setup = $this->sanitizeBridgeSetup($setup);
|
||
return json_encode($setup, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
}
|
||
if (is_string($setup)) {
|
||
return $setup;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private function decodeBridgeSetup($stored): array
|
||
{
|
||
if (is_array($stored)) {
|
||
return $this->sanitizeBridgeSetup($stored);
|
||
}
|
||
$str = (string)$stored;
|
||
if ($str === '') {
|
||
return $this->defaultBridgeSetup();
|
||
}
|
||
$decoded = json_decode($str, true);
|
||
if (is_array($decoded)) {
|
||
return $this->sanitizeBridgeSetup($decoded);
|
||
}
|
||
return $this->defaultBridgeSetup();
|
||
}
|
||
|
||
private function defaultBridgeSetup(): array
|
||
{
|
||
return [
|
||
'tables' => [],
|
||
'mode' => 'direct',
|
||
'direct' => [
|
||
'host' => '',
|
||
'port' => 3306,
|
||
'database' => '',
|
||
'user' => '',
|
||
'password' => '',
|
||
'charset' => 'utf8mb4',
|
||
],
|
||
'config' => [
|
||
'file' => '',
|
||
'base' => '',
|
||
'host_key' => '',
|
||
'port_key' => '',
|
||
'database_key' => '',
|
||
'user_key' => '',
|
||
'password_key' => '',
|
||
'charset_key' => '',
|
||
],
|
||
'fonts' => [
|
||
'dir' => '',
|
||
'url_base' => '',
|
||
'urls' => '',
|
||
],
|
||
];
|
||
}
|
||
|
||
private function sanitizeBridgeSetup(?array $input): array
|
||
{
|
||
$defaults = $this->defaultBridgeSetup();
|
||
if (!is_array($input)) {
|
||
return $defaults;
|
||
}
|
||
$mode = strtolower((string)($input['mode'] ?? 'direct'));
|
||
if (!in_array($mode, ['direct', 'config'], true)) {
|
||
$mode = 'direct';
|
||
}
|
||
$tables = $this->normalizeBridgeTables($input['tables'] ?? []);
|
||
$direct = $input['direct'] ?? [];
|
||
$config = $input['config'] ?? [];
|
||
$fonts = $input['fonts'] ?? [];
|
||
$sanitizePath = function ($value) {
|
||
$value = trim((string)$value);
|
||
if ($value === '') return '';
|
||
return preg_replace('/[^a-zA-Z0-9_\.\-]/', '', $value) ?: '';
|
||
};
|
||
$sanitizeDir = function ($value) {
|
||
$value = trim((string)$value);
|
||
if ($value === '') return '';
|
||
return trim(preg_replace('/[^a-zA-Z0-9_\.\-\/\\\\:\s]/', '', $value)) ?: '';
|
||
};
|
||
$sanitizeUrl = function ($value) {
|
||
$value = trim((string)$value);
|
||
if ($value === '') return '';
|
||
$value = preg_replace('/[\x00-\x1f]/', '', $value);
|
||
return $value;
|
||
};
|
||
$sanitizeText = function ($value) {
|
||
$value = (string)$value;
|
||
$value = preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f]/', '', $value);
|
||
return $value;
|
||
};
|
||
$result = [
|
||
'tables' => $tables,
|
||
'mode' => $mode,
|
||
'direct' => [
|
||
'host' => trim((string)($direct['host'] ?? $defaults['direct']['host'])),
|
||
'port' => (int)($direct['port'] ?? $defaults['direct']['port']),
|
||
'database' => trim((string)($direct['database'] ?? $defaults['direct']['database'])),
|
||
'user' => trim((string)($direct['user'] ?? $defaults['direct']['user'])),
|
||
'password' => (string)($direct['password'] ?? $defaults['direct']['password']),
|
||
'charset' => trim((string)($direct['charset'] ?? $defaults['direct']['charset'])) ?: 'utf8mb4',
|
||
],
|
||
'config' => [
|
||
'file' => trim((string)($config['file'] ?? '')),
|
||
'base' => $sanitizePath($config['base'] ?? ''),
|
||
'host_key' => $sanitizePath($config['host_key'] ?? ''),
|
||
'port_key' => $sanitizePath($config['port_key'] ?? ''),
|
||
'database_key' => $sanitizePath($config['database_key'] ?? ''),
|
||
'user_key' => $sanitizePath($config['user_key'] ?? ''),
|
||
'password_key' => $sanitizePath($config['password_key'] ?? ''),
|
||
'charset_key' => $sanitizePath($config['charset_key'] ?? ''),
|
||
],
|
||
'fonts' => [
|
||
'dir' => $sanitizeDir($fonts['dir'] ?? ''),
|
||
'url_base' => $sanitizeUrl($fonts['url_base'] ?? ''),
|
||
'urls' => $sanitizeText($fonts['urls'] ?? ''),
|
||
],
|
||
];
|
||
if ($result['direct']['port'] <= 0) {
|
||
$result['direct']['port'] = 3306;
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
private function buildFontCatalog(array $fonts): array
|
||
{
|
||
$items = [];
|
||
$faces = [];
|
||
$seen = [];
|
||
$groups = [];
|
||
$allowed = ['woff2', 'woff', 'ttf', 'otf'];
|
||
|
||
$addGroup = function (string $family, array $sources) use (&$items, &$faces, &$seen, $allowed) {
|
||
$family = trim($family);
|
||
if ($family === '') return;
|
||
if (!empty($seen[strtolower($family)])) return;
|
||
$srcParts = [];
|
||
foreach ($allowed as $ext) {
|
||
if (!empty($sources[$ext])) {
|
||
$url = $sources[$ext];
|
||
$srcParts[] = "url('{$url}') format('{$ext}')";
|
||
}
|
||
}
|
||
if (!$srcParts) return;
|
||
$safeFamily = str_replace("'", "\\'", $family);
|
||
$faces[] = "@font-face{font-family:'{$safeFamily}';font-style:normal;font-weight:400;src:" . implode(',', $srcParts) . ";}";
|
||
$items[] = [
|
||
'label' => $family,
|
||
'value' => "'" . $safeFamily . "', sans-serif",
|
||
];
|
||
$seen[strtolower($family)] = true;
|
||
};
|
||
|
||
$inferFamily = function (string $name): string {
|
||
$name = preg_replace('/[-_]+/', ' ', $name);
|
||
$name = preg_replace('/\s+/', ' ', $name);
|
||
return trim($name);
|
||
};
|
||
|
||
$dir = trim((string)($fonts['dir'] ?? ''));
|
||
$base = trim((string)($fonts['url_base'] ?? ''));
|
||
if ($dir !== '' && $base !== '' && is_dir($dir)) {
|
||
$base = rtrim($base, '/');
|
||
$pattern = $dir . '/*.{woff2,woff,ttf,otf,WOFF2,WOFF,TTF,OTF}';
|
||
$files = glob($pattern, GLOB_BRACE) ?: [];
|
||
foreach ($files as $file) {
|
||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||
if (!in_array($ext, $allowed, true)) continue;
|
||
$name = pathinfo($file, PATHINFO_FILENAME);
|
||
$groups[$name][$ext] = $base . '/' . basename($file);
|
||
}
|
||
}
|
||
|
||
$rawUrls = (string)($fonts['urls'] ?? '');
|
||
if ($rawUrls !== '') {
|
||
$lines = preg_split('/\r?\n/', $rawUrls) ?: [];
|
||
foreach ($lines as $line) {
|
||
$line = trim($line);
|
||
if ($line === '') continue;
|
||
$family = '';
|
||
$url = '';
|
||
if (strpos($line, '|') !== false) {
|
||
[$family, $url] = array_map('trim', explode('|', $line, 2));
|
||
} elseif (strpos($line, '=') !== false) {
|
||
[$family, $url] = array_map('trim', explode('=', $line, 2));
|
||
} else {
|
||
$url = $line;
|
||
}
|
||
if ($url === '') continue;
|
||
$path = parse_url($url, PHP_URL_PATH);
|
||
if (!$path) continue;
|
||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||
if (!in_array($ext, $allowed, true)) continue;
|
||
$name = pathinfo($path, PATHINFO_FILENAME);
|
||
$family = $family !== '' ? $family : $inferFamily($name);
|
||
if ($family === '') continue;
|
||
$groups[$family][$ext] = $url;
|
||
}
|
||
}
|
||
|
||
foreach ($groups as $family => $sources) {
|
||
$display = $inferFamily($family);
|
||
$addGroup($display, $sources);
|
||
}
|
||
|
||
return [
|
||
'fonts' => $items,
|
||
'font_face_css' => implode("\n", $faces),
|
||
];
|
||
}
|
||
|
||
private function customerSettingsTable(): string
|
||
{
|
||
return 'emailtemplate_customer_settings';
|
||
}
|
||
|
||
private function ensureCustomerSettingsTableExists(): void
|
||
{
|
||
$table = $this->customerSettingsTable();
|
||
$justCreated = false;
|
||
if (!$this->tableExists($table)) {
|
||
try {
|
||
$sql = <<<SQL
|
||
CREATE TABLE IF NOT EXISTS `$table` (
|
||
`customer_id` int(10) unsigned NOT NULL,
|
||
`bridge_url` varchar(500) DEFAULT NULL,
|
||
`bridge_token` varchar(255) DEFAULT NULL,
|
||
`sender_token` varchar(255) DEFAULT NULL,
|
||
`external_api_token` varchar(255) DEFAULT NULL,
|
||
`editor_default` varchar(32) DEFAULT NULL,
|
||
`bridge_tables` text DEFAULT NULL,
|
||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (`customer_id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||
SQL;
|
||
$this->pdo->exec($sql);
|
||
$this->tableExistsCache[$table] = true;
|
||
$justCreated = true;
|
||
} catch (Throwable $e) {
|
||
$this->fail('Customer-Settings Tabelle fehlt und konnte nicht erstellt werden', $e->getMessage(), 500);
|
||
}
|
||
}
|
||
|
||
if ($justCreated) {
|
||
return;
|
||
}
|
||
|
||
$this->ensureCustomerSettingsColumns($table);
|
||
}
|
||
|
||
private function ensureCustomerSettingsColumns(string $table): void
|
||
{
|
||
try {
|
||
$columns = $this->tableColumns($table);
|
||
} catch (Throwable $e) {
|
||
$this->fail('Customer-Settings Tabelle konnte nicht gelesen werden', $e->getMessage(), 500);
|
||
return;
|
||
}
|
||
|
||
$missing = [];
|
||
|
||
if (!in_array('bridge_tables', $columns, true)) {
|
||
$missing[] = 'ADD COLUMN `bridge_tables` text DEFAULT NULL';
|
||
}
|
||
if (!in_array('bridge_setup', $columns, true)) {
|
||
$missing[] = 'ADD COLUMN `bridge_setup` longtext DEFAULT NULL';
|
||
}
|
||
if (!in_array('editor_default', $columns, true)) {
|
||
$missing[] = 'ADD COLUMN `editor_default` varchar(32) DEFAULT NULL';
|
||
}
|
||
|
||
if (!$missing) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$sql = 'ALTER TABLE `' . $table . '` ' . implode(', ', $missing);
|
||
$this->pdo->exec($sql);
|
||
} catch (Throwable $e) {
|
||
$this->fail('Customer-Settings Tabelle konnte nicht aktualisiert werden', $e->getMessage(), 500);
|
||
}
|
||
}
|
||
|
||
private function generateToken(int $length = 48): string
|
||
{
|
||
return rtrim(strtr(base64_encode(random_bytes($length)), '+/', '-_'), '=');
|
||
}
|
||
|
||
private function generateReadablePassword(int $length = 12): string
|
||
{
|
||
$bytes = bin2hex(random_bytes($length));
|
||
return substr($bytes, 0, $length);
|
||
}
|
||
|
||
private function authUserColumns(): array
|
||
{
|
||
$db = $this->conf['auth']['db'] ?? [];
|
||
return [
|
||
'table' => $db['table'] ?? 'customer_users',
|
||
'col_id' => $db['col_id'] ?? 'id',
|
||
'col_email' => $db['col_user'] ?? 'email',
|
||
'col_pass' => $db['col_pass'] ?? 'password_hash',
|
||
'col_name' => $db['col_name'] ?? 'name',
|
||
'col_role' => $db['col_role'] ?? 'role',
|
||
'col_status' => $db['col_status'] ?? 'is_active',
|
||
'col_customer' => $db['customer_fk'] ?? 'customer_id',
|
||
];
|
||
}
|
||
|
||
private function ensureAuthUserListSortColumn(): void
|
||
{
|
||
if (!$this->pdo) {
|
||
return;
|
||
}
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
try {
|
||
$columns = $this->tableColumns($table);
|
||
} catch (Throwable $e) {
|
||
$this->fail('User-Tabelle konnte nicht gelesen werden', $e->getMessage(), 500);
|
||
return;
|
||
}
|
||
if (in_array('list_sort', $columns, true)) {
|
||
return;
|
||
}
|
||
try {
|
||
$sql = 'ALTER TABLE `' . $table . '` ADD COLUMN `list_sort` varchar(32) DEFAULT NULL';
|
||
$this->pdo->exec($sql);
|
||
} catch (Throwable $e) {
|
||
$this->fail('User-Tabelle konnte nicht aktualisiert werden', $e->getMessage(), 500);
|
||
}
|
||
}
|
||
|
||
private function getUserListSort(array $user, int $customerId): ?string
|
||
{
|
||
if (!$this->pdo) {
|
||
return null;
|
||
}
|
||
$userId = (int)($user['id'] ?? 0);
|
||
if ($userId <= 0) {
|
||
return null;
|
||
}
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
if (!$this->columnExists($dbCols, 'list_sort')) {
|
||
return null;
|
||
}
|
||
$where = sprintf('`%s` = :id', $cols['col_id']);
|
||
$params = [':id' => $userId];
|
||
if ($customerId > 0 && $this->columnExists($dbCols, $cols['col_customer'])) {
|
||
$where .= sprintf(' AND `%s` = :cid', $cols['col_customer']);
|
||
$params[':cid'] = $customerId;
|
||
}
|
||
$sql = sprintf('SELECT `list_sort` FROM `%s` WHERE %s LIMIT 1', $table, $where);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$row = $stmt->fetch();
|
||
if (!$row) {
|
||
return null;
|
||
}
|
||
return $row['list_sort'] !== null ? (string)$row['list_sort'] : null;
|
||
}
|
||
|
||
private function updateUserListSort(array $user, int $customerId, ?string $value): void
|
||
{
|
||
if (!$this->pdo) {
|
||
return;
|
||
}
|
||
$userId = (int)($user['id'] ?? 0);
|
||
if ($userId <= 0) {
|
||
return;
|
||
}
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
if (!$this->columnExists($dbCols, 'list_sort')) {
|
||
return;
|
||
}
|
||
$where = sprintf('`%s` = :id', $cols['col_id']);
|
||
$params = [
|
||
':id' => $userId,
|
||
':value' => $value,
|
||
];
|
||
if ($customerId > 0 && $this->columnExists($dbCols, $cols['col_customer'])) {
|
||
$where .= sprintf(' AND `%s` = :cid', $cols['col_customer']);
|
||
$params[':cid'] = $customerId;
|
||
}
|
||
$sql = sprintf('UPDATE `%s` SET `list_sort` = :value WHERE %s LIMIT 1', $table, $where);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
}
|
||
|
||
private function resolveUserListSort(array $user, int $customerId, string $fallback = ''): string
|
||
{
|
||
$value = $fallback !== '' ? $fallback : (string)($this->getUserListSort($user, $customerId) ?? '');
|
||
$allowed = ['created_asc', 'name_asc', 'name_desc', 'updated_desc'];
|
||
if ($value === '' || !in_array($value, $allowed, true)) {
|
||
return 'created_asc';
|
||
}
|
||
return $value;
|
||
}
|
||
|
||
private function ensureAuthUserHydrated(array $user): array
|
||
{
|
||
$role = (string)($user['role'] ?? '');
|
||
$hasOwnerFlag = isset($user['permissions']['owner']);
|
||
$hasCustomer = (int)($user['customer_id'] ?? 0) > 0;
|
||
if ($role !== '' && $hasOwnerFlag && $hasCustomer) {
|
||
return $user;
|
||
}
|
||
$userId = (int)($user['id'] ?? 0);
|
||
if ($userId <= 0 || !$this->pdo) {
|
||
if ($role === '') $user['role'] = 'user';
|
||
if (!$hasOwnerFlag) {
|
||
$user['permissions']['owner'] = ($user['role'] ?? '') === 'owner';
|
||
}
|
||
return $user;
|
||
}
|
||
|
||
try {
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
$where = sprintf('`%s` = :id', $cols['col_id']);
|
||
$params = [':id' => $userId];
|
||
$customerId = (int)($user['customer_id'] ?? 0);
|
||
if ($customerId > 0 && $this->columnExists($dbCols, $cols['col_customer'])) {
|
||
$where .= sprintf(' AND `%s` = :cid', $cols['col_customer']);
|
||
$params[':cid'] = $customerId;
|
||
}
|
||
$sql = sprintf('SELECT * FROM `%s` WHERE %s LIMIT 1', $table, $where);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$row = $stmt->fetch();
|
||
if ($row) {
|
||
if (isset($row[$cols['col_role']])) {
|
||
$roleValue = $this->sanitizeRole((string)$row[$cols['col_role']]);
|
||
$user['role'] = $roleValue;
|
||
$user['permissions']['owner'] = ($roleValue === 'owner');
|
||
} elseif ($role === '') {
|
||
$user['role'] = 'user';
|
||
$user['permissions']['owner'] = false;
|
||
}
|
||
if ((!$hasCustomer || $customerId <= 0) && isset($row[$cols['col_customer']])) {
|
||
$user['customer_id'] = (int)$row[$cols['col_customer']];
|
||
}
|
||
if (empty($user['name']) && $this->columnExists($dbCols, $cols['col_name']) && isset($row[$cols['col_name']])) {
|
||
$user['name'] = (string)$row[$cols['col_name']];
|
||
}
|
||
if (empty($user['email']) && isset($row[$cols['col_email']])) {
|
||
$user['email'] = (string)$row[$cols['col_email']];
|
||
}
|
||
$_SESSION['auth'] = array_merge($_SESSION['auth'] ?? [], $user);
|
||
} else {
|
||
if ($role === '') $user['role'] = 'user';
|
||
if (!$hasOwnerFlag) {
|
||
$user['permissions']['owner'] = ($user['role'] ?? '') === 'owner';
|
||
}
|
||
}
|
||
} catch (Throwable $e) {
|
||
if ($role === '') $user['role'] = 'user';
|
||
if (!$hasOwnerFlag) {
|
||
$user['permissions']['owner'] = ($user['role'] ?? '') === 'owner';
|
||
}
|
||
}
|
||
|
||
return $user;
|
||
}
|
||
|
||
private function columnExists(array $columns, string $name): bool
|
||
{
|
||
if ($name === '') return false;
|
||
return in_array($name, $columns, true);
|
||
}
|
||
|
||
private function sanitizeRole(string $role): string
|
||
{
|
||
$role = strtolower($role);
|
||
$valid = ['owner', 'admin', 'editor', 'viewer'];
|
||
return in_array($role, $valid, true) ? $role : 'user';
|
||
}
|
||
|
||
private function assertEmailUnique(string $email, int $customerId, ?int $excludeId): void
|
||
{
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
$conditions = [sprintf('`%s` = :email', $cols['col_email'])];
|
||
if ($this->columnExists($dbCols, $cols['col_customer'])) {
|
||
$conditions[] = sprintf('`%s` = :cid', $cols['col_customer']);
|
||
}
|
||
if ($excludeId) {
|
||
$conditions[] = sprintf('`%s` != :exclude', $cols['col_id']);
|
||
}
|
||
$sql = sprintf('SELECT COUNT(*) FROM `%s` WHERE %s', $table, implode(' AND ', $conditions));
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':email', $email);
|
||
if ($this->columnExists($dbCols, $cols['col_customer'])) {
|
||
$stmt->bindValue(':cid', $customerId);
|
||
}
|
||
if ($excludeId) $stmt->bindValue(':exclude', $excludeId, PDO::PARAM_INT);
|
||
$stmt->execute();
|
||
if ((int)$stmt->fetchColumn() > 0) {
|
||
$this->fail('E-Mail-Adresse ist bereits vergeben', null, 422);
|
||
}
|
||
}
|
||
|
||
private function fetchUserRow(int $userId, int $customerId): array
|
||
{
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$sql = sprintf(
|
||
'SELECT * FROM `%s` WHERE `%s` = :id AND `%s` = :cid LIMIT 1',
|
||
$table,
|
||
$cols['col_id'],
|
||
$cols['col_customer']
|
||
);
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute([':id' => $userId, ':cid' => $customerId]);
|
||
$row = $stmt->fetch();
|
||
if (!$row) $this->fail('Nutzer nicht gefunden', null, 404);
|
||
return $this->formatUserOutput([
|
||
'user_id' => $row[$cols['col_id']],
|
||
'name' => $row[$cols['col_name']] ?? '',
|
||
'email' => $row[$cols['col_email']] ?? '',
|
||
'role' => $row[$cols['col_role']] ?? 'user',
|
||
'is_active' => $row[$cols['col_status']] ?? 1,
|
||
'created_at' => $row['created_at'] ?? null,
|
||
'updated_at' => $row['updated_at'] ?? null,
|
||
]);
|
||
}
|
||
|
||
private function senderTable(): string
|
||
{
|
||
return 'emailtemplate_sender_identities';
|
||
}
|
||
|
||
private function fetchSenderRow(int $customerId, int $senderId): array
|
||
{
|
||
if ($customerId <= 0 || $senderId <= 0) {
|
||
$this->fail('Absender nicht gefunden', null, 404);
|
||
}
|
||
$table = $this->senderTable();
|
||
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1");
|
||
$stmt->execute([':id' => $senderId, ':cid' => $customerId]);
|
||
$row = $stmt->fetch();
|
||
if (!$row) $this->fail('Absender nicht gefunden', null, 404);
|
||
return $this->formatSenderRow($row);
|
||
}
|
||
|
||
private function formatSenderRow(array $row): array
|
||
{
|
||
return [
|
||
'id' => (int)($row['id'] ?? 0),
|
||
'label' => $row['label'] ?? '',
|
||
'from_name' => $row['from_name'] ?? '',
|
||
'from_email' => $row['from_email'] ?? '',
|
||
'reply_to' => $row['reply_to'] ?? '',
|
||
'created_at' => $row['created_at'] ?? null,
|
||
'updated_at' => $row['updated_at'] ?? null,
|
||
];
|
||
}
|
||
|
||
private function formatUserOutput(array $row): array
|
||
{
|
||
return [
|
||
'id' => (int)($row['user_id'] ?? $row['id'] ?? 0),
|
||
'name' => $row['name'] ?? '',
|
||
'email' => $row['email'] ?? '',
|
||
'role' => $row['role'] ?? 'user',
|
||
'is_active' => (int)($row['is_active'] ?? 1),
|
||
'created_at' => $row['created_at'] ?? null,
|
||
'updated_at' => $row['updated_at'] ?? null,
|
||
];
|
||
}
|
||
|
||
private function countOwners(int $customerId, ?int $excludeId = null): int
|
||
{
|
||
$cols = $this->authUserColumns();
|
||
$table = $cols['table'];
|
||
$dbCols = $this->tableColumns($table);
|
||
$conditions = [
|
||
sprintf('`%s` = :cid', $cols['col_customer']),
|
||
sprintf('`%s` = :role', $cols['col_role']),
|
||
];
|
||
if ($this->columnExists($dbCols, $cols['col_status'])) {
|
||
$conditions[] = sprintf('`%s` = 1', $cols['col_status']);
|
||
}
|
||
if ($excludeId) {
|
||
$conditions[] = sprintf('`%s` != :exclude', $cols['col_id']);
|
||
}
|
||
$sql = sprintf('SELECT COUNT(*) FROM `%s` WHERE %s', $table, implode(' AND ', $conditions));
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':cid', $customerId);
|
||
$stmt->bindValue(':role', 'owner');
|
||
if ($excludeId) $stmt->bindValue(':exclude', $excludeId, PDO::PARAM_INT);
|
||
$stmt->execute();
|
||
return (int)$stmt->fetchColumn();
|
||
}
|
||
|
||
private function verifyUserPasswordValue(string $input, string $stored): bool
|
||
{
|
||
if (preg_match('~^\$2[aby]\$~', $stored) || strpos($stored, '$argon2') === 0) return password_verify($input, $stored);
|
||
$legacy = strtolower($this->conf['auth']['db']['legacy'] ?? '');
|
||
if ($legacy === 'md5') return hash_equals($stored, md5($input));
|
||
if ($legacy === 'sha1') return hash_equals($stored, sha1($input));
|
||
if (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored);
|
||
return hash_equals($stored, $input);
|
||
}
|
||
|
||
private function hashUserPassword(string $password): string
|
||
{
|
||
return password_hash($password, PASSWORD_DEFAULT);
|
||
}
|
||
|
||
private function ensureOwner(array &$user): void
|
||
{
|
||
$this->ensureRole($user, ['owner']);
|
||
}
|
||
|
||
private function ensureRole(array &$user, array $roles): void
|
||
{
|
||
$user = $this->ensureAuthUserHydrated($user);
|
||
$role = strtolower((string)($user['role'] ?? ''));
|
||
$allowed = array_values(array_unique(array_map('strtolower', $roles)));
|
||
if (!in_array($role, $allowed, true)) {
|
||
$this->fail('Unzureichende Berechtigungen', null, 403);
|
||
}
|
||
}
|
||
|
||
private function ensureDebugUser(array $user): void
|
||
{
|
||
$email = strtolower((string)($user['email'] ?? ''));
|
||
if ($email !== 'madmin@papa-kind-treff.info') {
|
||
$this->fail('Debug nicht erlaubt', null, 403);
|
||
}
|
||
}
|
||
|
||
private function ensureDebugEnv(): void
|
||
{
|
||
$env = strtolower((string)($this->conf['env'] ?? ''));
|
||
if ($env === 'staging') return;
|
||
if (defined('APP_ENV') && strtolower((string)APP_ENV) === 'staging') return;
|
||
$host = '';
|
||
if (!empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
|
||
$host = strtolower(trim(explode(',', (string)$_SERVER['HTTP_X_FORWARDED_HOST'])[0]));
|
||
} elseif (!empty($_SERVER['HTTP_HOST'])) {
|
||
$host = strtolower((string)$_SERVER['HTTP_HOST']);
|
||
}
|
||
if ($host !== '') {
|
||
$host = preg_replace('/:\\d+$/', '', $host);
|
||
if ($host === 'staging.emailtemplate.it') {
|
||
return;
|
||
}
|
||
}
|
||
$this->fail('Debug nur in Staging erlaubt', null, 403);
|
||
}
|
||
|
||
private function debugDir(): string
|
||
{
|
||
return dirname(__DIR__) . '/debug';
|
||
}
|
||
|
||
private function writeDebugLog(string $name, array $payload): void
|
||
{
|
||
$env = strtolower((string)($this->conf['env'] ?? ''));
|
||
if ($env !== 'staging' && (!defined('APP_ENV') || strtolower((string)APP_ENV) !== 'staging')) {
|
||
$host = '';
|
||
if (!empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
|
||
$host = strtolower(trim(explode(',', (string)$_SERVER['HTTP_X_FORWARDED_HOST'])[0]));
|
||
} elseif (!empty($_SERVER['HTTP_HOST'])) {
|
||
$host = strtolower((string)$_SERVER['HTTP_HOST']);
|
||
}
|
||
if ($host !== '') {
|
||
$host = preg_replace('/:\\d+$/', '', $host);
|
||
}
|
||
if ($host !== 'staging.emailtemplate.it') {
|
||
return;
|
||
}
|
||
}
|
||
$dir = $this->debugDir();
|
||
if (!is_dir($dir)) {
|
||
@mkdir($dir, 0777, true);
|
||
}
|
||
@chmod($dir, 0777);
|
||
$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 ?: '');
|
||
@chmod($file, 0666);
|
||
|
||
$fallback = sys_get_temp_dir() . '/emailtemplate_debug.log';
|
||
$line = json_encode([
|
||
'time' => date(DATE_ATOM),
|
||
'name' => $safeName,
|
||
'file' => $file,
|
||
'wrote_bytes' => is_string($data) ? strlen($data) : 0,
|
||
'dir_exists' => is_dir($dir),
|
||
'dir_writable' => is_writable($dir),
|
||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
@file_put_contents($fallback, ($line ?: '') . "\n", FILE_APPEND);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|