2246 lines
90 KiB
PHP
2246 lines
90 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use DOMDocument;
|
|
use DOMXPath;
|
|
use RuntimeException;
|
|
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 ... */
|
|
$cors = $this->conf['cors'] ?? '*';
|
|
if ($cors) {
|
|
header('Access-Control-Allow-Origin: ' . $cors);
|
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
|
header('Access-Control-Allow-Credentials: true');
|
|
}
|
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') $this->respond(['ok' => true]);
|
|
|
|
if (!empty($this->conf['auth']['cookie'])) {
|
|
$c = $this->conf['auth']['cookie'];
|
|
$params = session_get_cookie_params();
|
|
$params['lifetime'] = $c['lifetime'] ?? $params['lifetime'];
|
|
$params['path'] = $c['path'] ?? $params['path'];
|
|
$params['domain'] = $c['domain'] ?? $params['domain'];
|
|
$params['secure'] = $c['secure'] ?? $params['secure'];
|
|
$params['httponly'] = $c['httponly'] ?? $params['httponly'];
|
|
if (isset($c['samesite'])) $params['samesite'] = $c['samesite'];
|
|
session_set_cookie_params($params);
|
|
}
|
|
}
|
|
private function setInput(): void { /* ... Logik bleibt unverändert ... */
|
|
$data = [];
|
|
$ct = $_SERVER['CONTENT_TYPE'] ?? '';
|
|
if (stripos($ct, 'application/json') !== false) {
|
|
$raw = file_get_contents('php://input');
|
|
if ($raw !== false && $raw !== '') {
|
|
$js = json_decode($raw, true);
|
|
if (is_array($js)) $data = $js;
|
|
}
|
|
}
|
|
foreach ($_POST as $k => $v) $data[$k] = $v;
|
|
foreach ($_GET as $k => $v) if (!array_key_exists($k, $data)) $data[$k] = $v;
|
|
$this->in = $data;
|
|
}
|
|
private function getPdoTemplates(): PDO { /* ... Logik bleibt unverändert ... */
|
|
if (!isset($this->conf['projectdb']) || !is_array($this->conf['projectdb'])) {
|
|
$this->fail('Missing project DB config', null, 500);
|
|
}
|
|
$c = $this->conf['projectdb'];
|
|
$host = $c['db_host'] ?? 'localhost';
|
|
$db = $c['db_name'] ?? ($c['database'] ?? '');
|
|
$user = $c['db_user'] ?? ($c['username'] ?? '');
|
|
$pass = $c['db_pass'] ?? ($c['password'] ?? '');
|
|
$charset = $c['db_charset'] ?? 'utf8mb4';
|
|
$port = $c['db_port'] ?? 3306;
|
|
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=$charset";
|
|
$opt = [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
];
|
|
return new PDO($dsn, $user, $pass, $opt);
|
|
}
|
|
private function resolveAction(): void { /* ... Logik bleibt unverändert ... */
|
|
$action = $this->val($this->in, 'action', '');
|
|
$resource = $this->val($this->in, 'resource', null);
|
|
$allowedResources = ['templates', 'sections', 'blocks', 'snippets'];
|
|
if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) {
|
|
$verb = strtolower((string)$action);
|
|
if (in_array($verb, ['list', 'get', 'create', 'update', 'delete'], true)) $action = $resource . '.' . $verb;
|
|
}
|
|
$this->action = $action;
|
|
}
|
|
private function resolveTableMap(): void { /* ... Logik bleibt unverändert ... */
|
|
$tables = $this->conf['tables'] ?? [];
|
|
$this->tableMap = [
|
|
'templates' => $tables['templates'] ?? 'emailtemplate_templates',
|
|
'sections' => $tables['sections'] ?? 'emailtemplate_sections',
|
|
'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks',
|
|
'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets',
|
|
];
|
|
}
|
|
|
|
private function val(array $in, $keys, $default = null) { /* ... Logik bleibt unverändert ... */
|
|
if (!is_array($keys)) $keys = [$keys];
|
|
foreach ($keys as $k) if (array_key_exists($k, $in)) return $in[$k];
|
|
return $default;
|
|
}
|
|
private function firstExisting(array $columns, array $candidates): ?string { /* ... Logik bleibt unverändert ... */
|
|
foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c;
|
|
return null;
|
|
}
|
|
private function tableColumns(string $table): array { /* ... Logik bleibt unverändert ... */
|
|
$cols = [];
|
|
$stmt = $this->pdo->query("SHOW COLUMNS FROM `$table`");
|
|
foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field'];
|
|
return $cols;
|
|
}
|
|
|
|
private function tableExists(string $table): bool
|
|
{
|
|
if ($table === '') return false;
|
|
if (array_key_exists($table, $this->tableExistsCache)) {
|
|
return $this->tableExistsCache[$table];
|
|
}
|
|
try {
|
|
$this->pdo->query("SELECT 1 FROM `$table` LIMIT 1");
|
|
$this->tableExistsCache[$table] = true;
|
|
} catch (Throwable $e) {
|
|
$this->tableExistsCache[$table] = false;
|
|
}
|
|
return $this->tableExistsCache[$table];
|
|
}
|
|
private function primaryKey(string $table): ?string { /* ... Logik bleibt unverändert ... */
|
|
$stmt = $this->pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'");
|
|
$stmt->execute();
|
|
$row = $stmt->fetch();
|
|
return $row['Column_name'] ?? null;
|
|
}
|
|
private function requireAuth(): array { /* ... Logik bleibt unverändert ... */
|
|
return $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;
|
|
}
|
|
|
|
|
|
// =================================================================
|
|
// 🚀 CRUD HANDLER METHODEN
|
|
// =================================================================
|
|
|
|
/**
|
|
* Allgemeine Methode zur Handhabung von LIST-Anfragen.
|
|
*/
|
|
private function handleList(string $kind): void
|
|
{
|
|
$auth = $this->requireAuth();
|
|
$t = $this->tableMap[$kind];
|
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
|
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
|
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
|
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
|
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
|
$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 ($descCol && isset($r[$descCol])) $item['desc'] = $r[$descCol];
|
|
if ($catCol && isset($r[$catCol])) $item['category'] = $r[$catCol];
|
|
if ($updCol && isset($r[$updCol])) $item['updated_at'] = $r[$updCol];
|
|
|
|
// Lade HTML und JSON aus den korrekten Spalten
|
|
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
|
if ($htmlCol && isset($r[$htmlCol])) $item['html'] = (string)$r[$htmlCol];
|
|
$jsonCol = $this->firstExisting($allCols, ['json_content']);
|
|
if ($jsonCol && isset($r[$jsonCol])) $item['content'] = $r[$jsonCol];
|
|
|
|
$out[] = $item;
|
|
}
|
|
$this->respond(['ok' => true, 'kind' => $kind, 'items' => $out, 'data' => $out, 'count' => count($out), 'offset' => $offset, 'limit' => $limit]);
|
|
}
|
|
|
|
/**
|
|
* Allgemeine Methode zur Handhabung von GET-Anfragen.
|
|
*/
|
|
private function handleGet(string $kind): void
|
|
{
|
|
$auth = $this->requireAuth();
|
|
$t = $this->tableMap[$kind];
|
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
|
$id = $this->pullId($this->in);
|
|
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
|
|
|
[$tw, $tp] = $this->tenantWhere($auth);
|
|
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindValue(':id', $id);
|
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
|
$stmt->execute();
|
|
$row = $stmt->fetch();
|
|
if (!$row) $this->fail('Not found', ['kind' => $kind, 'id' => $id], 404);
|
|
$rowOut = ['id' => $row[$idCol] ?? $id] + $row;
|
|
|
|
// Lade HTML und JSON aus den korrekten Spalten
|
|
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
|
$topHtml = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : null;
|
|
$jsonCol = $this->firstExisting($allCols, ['json_content']);
|
|
$topContent = ($jsonCol && isset($row[$jsonCol])) ? $row[$jsonCol] : null;
|
|
|
|
$gjsComponents = [];
|
|
|
|
if ($topContent !== null) {
|
|
$decodedContent = json_decode($topContent, true);
|
|
if (is_array($decodedContent)) {
|
|
$gjsComponents = $decodedContent;
|
|
}
|
|
}
|
|
|
|
if (empty($gjsComponents) && $topHtml !== null) {
|
|
$gjsComponents = $this->parseHtmlToGjsComponents($topHtml);
|
|
}
|
|
|
|
$usage = $this->calculateUsage($kind, (int)$rowOut['id'], $auth);
|
|
|
|
$this->respond([
|
|
'ok' => true,
|
|
'kind' => $kind,
|
|
'id' => $rowOut['id'],
|
|
'item' => $rowOut,
|
|
'data' => $rowOut,
|
|
'html' => $topHtml,
|
|
'content' => $topContent,
|
|
'gjs_components' => $gjsComponents,
|
|
'usage' => $usage,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Allgemeine Methode zur Handhabung von CREATE-Anfragen (inkl. JSON-Bereinigung).
|
|
*/
|
|
private function handleCreate(string $kind): void
|
|
{
|
|
$auth = $this->requireAuth();
|
|
$t = $this->tableMap[$kind];
|
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
|
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
|
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
|
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
|
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
|
|
|
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
|
|
if ($name === '') $this->fail('name required', null, 422);
|
|
|
|
$desc = (string)$this->val($this->in, ['description', 'desc'], null);
|
|
$cat = (string)$this->val($this->in, ['category', 'cat'], null);
|
|
$html = (string)$this->val($this->in, ['html', 'body', 'markup'], null);
|
|
if ($kind === 'snippets' && ($html === null || $html === '')) {
|
|
$html = (string)$this->val($this->in, ['content'], $html);
|
|
}
|
|
$jsonKeys = ($kind === 'snippets')
|
|
? ['content_json', 'json', 'structure_json']
|
|
: ['content_json', 'json', 'content', 'structure_json'];
|
|
$json = $this->val($this->in, $jsonKeys, null);
|
|
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
|
|
$templateId = $this->val($this->in, ['template_id', 'tpl_id'], null);
|
|
$sectionId = $this->val($this->in, ['section_id', 'sec_id'], null);
|
|
$blockId = $this->val($this->in, ['block_id', 'blk_id'], null);
|
|
|
|
$data = [$nameCol => $name];
|
|
if ($desc !== null && $descCol) $data[$descCol] = $desc;
|
|
if ($cat !== null && $catCol) $data[$catCol] = $cat;
|
|
|
|
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
|
|
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
|
|
|
|
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
|
|
|
|
// 1. JSON-Content behandeln
|
|
if ($json !== null) {
|
|
if ($jsonDbCol) {
|
|
$components = is_string($json) ? json_decode($json, true) : $json;
|
|
if (is_array($components)) {
|
|
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
|
|
$data[$jsonDbCol] = $this->encodeJson($components);
|
|
} else {
|
|
$data[$jsonDbCol] = is_string($json) ? $json : '';
|
|
}
|
|
} else {
|
|
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
|
|
$this->fail(
|
|
'JSON content provided but no `json_content` column found',
|
|
['table' => $t, 'available_cols' => $allCols],
|
|
422
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. HTML-Content speichern
|
|
if ($htmlDbCol && $html !== null) {
|
|
$data[$htmlDbCol] = $html;
|
|
}
|
|
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
|
|
|
|
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
|
|
if ($c && $settings !== null) $data[$c] = is_string($settings) ? $settings : $this->encodeJson($settings);
|
|
|
|
if ($templateId !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $templateId;
|
|
if ($sectionId !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sectionId;
|
|
if ($blockId !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blockId;
|
|
|
|
$data = $data + $this->tenantAssign($_SESSION['auth'] ?? [], $allCols);
|
|
|
|
$now = date('Y-m-d H:i:s');
|
|
$createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']);
|
|
if ($createdCol) $data[$createdCol] = $now;
|
|
if ($updCol) $data[$updCol] = $now;
|
|
|
|
$fields = array_keys($data);
|
|
$place = array_map(fn($c) => ":$c", $fields);
|
|
$sql = "INSERT INTO `$t` (" . implode(',', array_map(fn($c) => "`$c`", $fields)) . ") VALUES (" . implode(',', $place) . ")";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
|
$stmt->execute();
|
|
$newId = $this->pdo->lastInsertId();
|
|
|
|
$out = ['id' => $newId, 'name' => $name];
|
|
if ($desc !== null) $out['desc'] = $desc;
|
|
if ($cat !== null) $out['category'] = $cat;
|
|
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $newId, 'item' => $out, 'data' => $out]);
|
|
}
|
|
|
|
/**
|
|
* Allgemeine Methode zur Handhabung von UPDATE-Anfragen (inkl. JSON-Bereinigung).
|
|
*/
|
|
private function handleUpdate(string $kind): void
|
|
{
|
|
$auth = $this->requireAuth();
|
|
$t = $this->tableMap[$kind];
|
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
|
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
|
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
|
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
|
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
|
$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;
|
|
|
|
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
|
|
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
|
|
|
|
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
|
|
|
|
// 1. JSON-Content behandeln
|
|
if ($json !== null) {
|
|
if ($jsonDbCol) {
|
|
// Wenn JSON-Spalte existiert, JSON verarbeiten und speichern
|
|
$components = is_string($json) ? json_decode($json, true) : $json;
|
|
if (is_array($components)) {
|
|
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
|
|
$data[$jsonDbCol] = $this->encodeJson($components);
|
|
} else {
|
|
$data[$jsonDbCol] = is_string($json) ? $json : '';
|
|
}
|
|
} else {
|
|
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
|
|
$this->fail(
|
|
'JSON content provided but no `json_content` column found',
|
|
['table' => $t, 'available_cols' => $allCols],
|
|
422
|
|
);
|
|
}
|
|
|
|
// 2. Den zugehörigen HTML-Output speichern (wird vom Editor immer mitgesendet, wenn JSON da ist)
|
|
if ($html !== null && $htmlDbCol) {
|
|
$data[$htmlDbCol] = (string)$html;
|
|
}
|
|
} elseif ($html !== null && $htmlDbCol) {
|
|
// Wenn NUR HTML gesendet wird (für minimale Änderungen), speichern wir nur HTML.
|
|
$data[$htmlDbCol] = (string)$html;
|
|
}
|
|
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
|
|
|
|
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
|
|
if ($settings !== null && $c) $data[$c] = is_string($settings) ? $settings : $this->encodeJson($settings);
|
|
|
|
$tpl = $this->val($this->in, ['template_id', 'tpl_id'], null);
|
|
if ($tpl !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $tpl;
|
|
$sec = $this->val($this->in, ['section_id', 'sec_id'], null);
|
|
if ($sec !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sec;
|
|
$blk = $this->val($this->in, ['block_id', 'blk_id'], null);
|
|
if ($blk !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blk;
|
|
|
|
if ($updCol) $data[$updCol] = date('Y-m-d H:i:s');
|
|
if (!$data) $this->fail('nothing to update', null, 422);
|
|
|
|
[$tw, $tp] = $this->tenantWhere($auth);
|
|
$set = [];
|
|
foreach (array_keys($data) as $c) $set[] = "`$c` = :$c";
|
|
$sql = "UPDATE `$t` SET " . implode(',', $set) . " WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
|
|
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
|
$stmt->bindValue(':__id', $id);
|
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
|
$stmt->execute();
|
|
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'updated' => array_keys($data)]);
|
|
}
|
|
|
|
/**
|
|
* Allgemeine Methode zur Handhabung von DELETE-Anfragen.
|
|
*/
|
|
private function handleDelete(string $kind): void
|
|
{
|
|
$auth = $this->requireAuth();
|
|
$t = $this->tableMap[$kind];
|
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
|
$id = $this->pullId($this->in);
|
|
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
|
|
|
[$tw, $tp] = $this->tenantWhere($auth);
|
|
$sql = "DELETE FROM `$t` WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindValue(':__id', $id);
|
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
|
$stmt->execute();
|
|
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'deleted' => true]);
|
|
}
|
|
|
|
/**
|
|
* Sendet einen Testversand für Templates.
|
|
*/
|
|
private function handleTemplateTestSend(): void
|
|
{
|
|
$auth = $this->requireAuth();
|
|
$templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0);
|
|
if ($templateId <= 0) {
|
|
$this->fail('template_id required', null, 422);
|
|
}
|
|
|
|
$recipient = trim((string)$this->val($this->in, ['to', 'email', 'recipient'], ''));
|
|
if ($recipient === '' || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
|
$this->fail('Valid recipient required', null, 422);
|
|
}
|
|
|
|
$subject = trim((string)$this->val($this->in, ['subject'], 'Testversand'));
|
|
if ($subject === '') {
|
|
$subject = 'Testversand';
|
|
}
|
|
$senderId = (int)$this->val($this->in, ['sender_id'], 0);
|
|
|
|
$t = $this->tableMap['templates'];
|
|
[$idCol, $allCols] = $this->resolveIdCol('templates');
|
|
[$tw, $tp] = $this->tenantWhere($auth);
|
|
|
|
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindValue(':id', $templateId);
|
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
|
$stmt->execute();
|
|
$row = $stmt->fetch();
|
|
if (!$row) {
|
|
$this->fail('Template not found', ['id' => $templateId], 404);
|
|
}
|
|
|
|
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
|
$html = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : '';
|
|
if ($html === '' && !empty($row['json_content'])) {
|
|
$html = '<p>(Dieses Template enthält noch keine HTML-Inhalte.)</p>';
|
|
}
|
|
|
|
$renderCache = [];
|
|
$renderStack = [];
|
|
$html = $this->renderHtmlWithReferences($html, $auth, $renderCache, $renderStack);
|
|
$html = $this->prepareEmailHtml($html);
|
|
|
|
$sender = null;
|
|
if ($senderId > 0) {
|
|
$customerId = (int)($auth['customer_id'] ?? 0);
|
|
if ($customerId > 0) {
|
|
$sender = $this->fetchSenderRow($customerId, $senderId);
|
|
}
|
|
}
|
|
|
|
if (!$this->dispatchTestMail($recipient, $subject, $html, $sender)) {
|
|
$this->fail('Send failed', null, 500);
|
|
}
|
|
|
|
$customerId = (int)($auth['customer_id'] ?? 0);
|
|
if ($customerId > 0) {
|
|
$this->incrementTemplateUsage($customerId, $templateId);
|
|
}
|
|
|
|
$this->respond([
|
|
'ok' => true,
|
|
'template_id' => $templateId,
|
|
'to' => $recipient,
|
|
'subject' => $subject,
|
|
'sender_id' => $senderId > 0 ? $senderId : null,
|
|
]);
|
|
}
|
|
|
|
private function 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 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')]);
|
|
|
|
/* ---------- 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.test':
|
|
$this->handleAccountBridgeTest();
|
|
break;
|
|
case 'placeholders.status':
|
|
$this->handlePlaceholderStatus();
|
|
break;
|
|
case 'placeholders.schema':
|
|
$this->handlePlaceholderSchema();
|
|
break;
|
|
case 'debug.phpinfo':
|
|
$this->handleDebugPhpInfo();
|
|
break;
|
|
case 'templates.test_send':
|
|
$this->handleTemplateTestSend();
|
|
break;
|
|
|
|
/* ---------- CRUD HANDLER ---------- */
|
|
default:
|
|
if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets'])) {
|
|
switch ($operation) {
|
|
case 'list':
|
|
$this->handleList($kind);
|
|
break;
|
|
case 'get':
|
|
$this->handleGet($kind);
|
|
break;
|
|
case 'create':
|
|
$this->handleCreate($kind);
|
|
break;
|
|
case 'update':
|
|
$this->handleUpdate($kind);
|
|
break;
|
|
case 'delete':
|
|
$this->handleDelete($kind);
|
|
break;
|
|
default:
|
|
$this->fail('Unknown operation for resource: ' . $this->action, null, 404);
|
|
break;
|
|
}
|
|
} else {
|
|
$this->fail('Unknown action', $this->action ?: 'missing', 404);
|
|
}
|
|
break;
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->fail('Server error', get_class($e) . ': ' . $e->getMessage(), 500);
|
|
}
|
|
}
|
|
|
|
private function lookupTableName(string $key, string $default): string
|
|
{
|
|
$tables = $this->conf['tables'] ?? [];
|
|
if (!empty($tables[$key])) return $tables[$key];
|
|
$prefix = $this->conf['projectdb']['prefix'] ?? null;
|
|
if ($prefix && strpos($default, 'emailtemplate_') === 0) {
|
|
return $prefix . substr($default, strlen('emailtemplate_'));
|
|
}
|
|
return $default;
|
|
}
|
|
|
|
private function countRefsInTable(string $table, string $where, array $params, array $auth): int
|
|
{
|
|
try {
|
|
[$tw, $tp] = $this->tenantWhere($auth);
|
|
$sql = "SELECT COUNT(*) AS c FROM `$table` WHERE $where" . $tw;
|
|
$stmt = $this->pdo->prepare($sql);
|
|
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
|
$stmt->execute();
|
|
$row = $stmt->fetch();
|
|
return (int)($row['c'] ?? 0);
|
|
} catch (Throwable $e) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private function fetchResourceCounts(int $customerId): array
|
|
{
|
|
$counts = [
|
|
'templates' => 0,
|
|
'sections' => 0,
|
|
'blocks' => 0,
|
|
'snippets' => 0,
|
|
'renders_total' => 0,
|
|
];
|
|
|
|
$map = $this->tableMap ?? [];
|
|
foreach (['templates', 'sections', 'blocks', 'snippets'] as $kind) {
|
|
$table = $map[$kind] ?? null;
|
|
if (!$table || !$this->tableExists($table)) continue;
|
|
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid");
|
|
$stmt->execute([':cid' => $customerId]);
|
|
$counts[$kind] = (int)($stmt->fetchColumn() ?: 0);
|
|
}
|
|
|
|
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
|
|
if ($this->tableExists($usageTable)) {
|
|
$stmt = $this->pdo->prepare("SELECT SUM(`render_count`) FROM `$usageTable` WHERE `customer_id` = :cid");
|
|
$stmt->execute([':cid' => $customerId]);
|
|
$counts['renders_total'] = (int)($stmt->fetchColumn() ?: 0);
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
private function listTemplateUsage(int $customerId): array
|
|
{
|
|
$table = $this->tableMap['templates'] ?? null;
|
|
if (!$table || !$this->tableExists($table)) {
|
|
return [];
|
|
}
|
|
|
|
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
|
|
if ($this->tableExists($usageTable)) {
|
|
$sql = "SELECT t.id, t.name, t.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at
|
|
FROM `$table` t
|
|
LEFT JOIN `$usageTable` u ON u.template_id = t.id
|
|
WHERE t.customer_id = :cid
|
|
ORDER BY render_count DESC, t.updated_at DESC";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([':cid' => $customerId]);
|
|
$rows = $stmt->fetchAll() ?: [];
|
|
} else {
|
|
$sql = "SELECT t.id, t.name, t.updated_at FROM `$table` t WHERE t.customer_id = :cid ORDER BY t.updated_at DESC";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute([':cid' => $customerId]);
|
|
$rows = $stmt->fetchAll() ?: [];
|
|
foreach ($rows as &$row) {
|
|
$row['render_count'] = 0;
|
|
$row['last_rendered_at'] = null;
|
|
}
|
|
}
|
|
|
|
return array_map(static function ($row) {
|
|
return [
|
|
'template_id' => (int)($row['id'] ?? 0),
|
|
'name' => $row['name'] ?? '',
|
|
'render_count' => (int)($row['render_count'] ?? 0),
|
|
'last_rendered_at' => $row['last_rendered_at'] ?? null,
|
|
'updated_at' => $row['updated_at'] ?? null,
|
|
];
|
|
}, $rows);
|
|
}
|
|
|
|
private function resetTemplateUsage(int $customerId, array $templateIds): void
|
|
{
|
|
$usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage');
|
|
if (!$templateIds || !$this->tableExists($usageTable)) {
|
|
return;
|
|
}
|
|
$templateIds = array_values(array_unique(array_filter(array_map('intval', $templateIds), static fn ($v) => $v > 0)));
|
|
if (!$templateIds) return;
|
|
|
|
$placeholders = implode(',', array_fill(0, count($templateIds), '?'));
|
|
$sql = "DELETE FROM `$usageTable` WHERE `customer_id` = ? AND `template_id` IN ($placeholders)";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute(array_merge([$customerId], $templateIds));
|
|
}
|
|
|
|
private function extractIdList($raw): array
|
|
{
|
|
if ($raw === null) return [];
|
|
if (is_numeric($raw)) {
|
|
$raw = [(int)$raw];
|
|
} elseif (is_string($raw)) {
|
|
$raw = preg_split('/[\s,]+/', $raw);
|
|
} elseif (!is_array($raw)) {
|
|
return [];
|
|
}
|
|
|
|
$ids = [];
|
|
foreach ($raw as $value) {
|
|
if (is_array($value)) {
|
|
$ids = array_merge($ids, $this->extractIdList($value));
|
|
continue;
|
|
}
|
|
if ($value === '' || $value === null) continue;
|
|
$ids[] = (int)$value;
|
|
}
|
|
|
|
$ids = array_values(array_unique(array_filter($ids, static fn ($v) => $v > 0)));
|
|
return $ids;
|
|
}
|
|
|
|
private function calculateUsage(string $kind, int $id, array $auth): array
|
|
{
|
|
if ($id <= 0) return ['total' => 0];
|
|
|
|
$summary = [];
|
|
$templateItemsTable = $this->lookupTableName('template_items', 'emailtemplate_template_items');
|
|
$sectionItemsTable = $this->lookupTableName('section_items', 'emailtemplate_section_items');
|
|
|
|
if ($kind === 'sections') {
|
|
$summary['templates'] = $this->countRefsInTable(
|
|
$templateItemsTable,
|
|
"`ref_type` = :rt AND `ref_id` = :rid",
|
|
[':rt' => 'section', ':rid' => $id],
|
|
$auth
|
|
);
|
|
} elseif ($kind === 'blocks') {
|
|
$summary['templates'] = $this->countRefsInTable(
|
|
$templateItemsTable,
|
|
"`ref_type` = :rt AND `ref_id` = :rid",
|
|
[':rt' => 'block', ':rid' => $id],
|
|
$auth
|
|
);
|
|
$summary['sections'] = $this->countRefsInTable(
|
|
$sectionItemsTable,
|
|
"`ref_id` = :rid",
|
|
[':rid' => $id],
|
|
$auth
|
|
);
|
|
$summary['snippets'] = $this->countRefsInTable(
|
|
$this->tableMap['snippets'],
|
|
"`block_id` = :rid",
|
|
[':rid' => $id],
|
|
$auth
|
|
);
|
|
}
|
|
|
|
$summary = array_filter($summary, fn($v) => (int)$v > 0);
|
|
$summary['total'] = array_sum($summary);
|
|
return $summary;
|
|
}
|
|
|
|
private function normalizeResourceKind(string $kind): ?string
|
|
{
|
|
$kind = strtolower(trim($kind));
|
|
$map = [
|
|
'template' => 'templates',
|
|
'templates' => 'templates',
|
|
'section' => 'sections',
|
|
'sections' => 'sections',
|
|
'block' => 'blocks',
|
|
'blocks' => 'blocks',
|
|
'snippet' => 'snippets',
|
|
'snippets' => 'snippets',
|
|
];
|
|
return $map[$kind] ?? null;
|
|
}
|
|
|
|
private function resolveHtmlColumn(array $columns, string $kindKey): ?string
|
|
{
|
|
$candidates = ($kindKey === 'snippets')
|
|
? ['content', 'html', 'body', 'markup']
|
|
: ['html', 'body', 'markup', 'content'];
|
|
return $this->firstExisting($columns, $candidates);
|
|
}
|
|
|
|
private function fetchResourceHtml(string $kind, int $id, array $auth, array &$cache, array &$stack): ?string
|
|
{
|
|
$kindKey = $this->normalizeResourceKind($kind);
|
|
if (!$kindKey || $id <= 0) return null;
|
|
|
|
$cacheKey = $kindKey . ':' . $id;
|
|
if (array_key_exists($cacheKey, $cache)) return $cache[$cacheKey];
|
|
if (!empty($stack[$cacheKey])) return null;
|
|
|
|
$table = $this->tableMap[$kindKey] ?? null;
|
|
if (!$table) return null;
|
|
[$idCol, $allCols] = $this->resolveIdCol($kindKey);
|
|
[$tw, $tp] = $this->tenantWhere($auth);
|
|
|
|
$sql = "SELECT * FROM `$table` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->bindValue(':id', $id);
|
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
|
$stmt->execute();
|
|
$row = $stmt->fetch();
|
|
if (!$row) {
|
|
$cache[$cacheKey] = null;
|
|
return null;
|
|
}
|
|
|
|
$htmlCol = $this->resolveHtmlColumn($allCols, $kindKey);
|
|
$html = $htmlCol && isset($row[$htmlCol]) ? (string)$row[$htmlCol] : '';
|
|
|
|
$stack[$cacheKey] = true;
|
|
$html = $this->renderHtmlWithReferences($html, $auth, $cache, $stack);
|
|
unset($stack[$cacheKey]);
|
|
|
|
$cache[$cacheKey] = $html;
|
|
return $html;
|
|
}
|
|
|
|
private function renderHtmlWithReferences(string $html, array $auth, array &$cache, array &$stack): string
|
|
{
|
|
$trimmed = trim($html);
|
|
if ($trimmed === '') return $html;
|
|
if (!class_exists(DOMDocument::class)) return $html;
|
|
|
|
$flags = 0;
|
|
if (defined('LIBXML_HTML_NOIMPLIED')) {
|
|
$flags |= LIBXML_HTML_NOIMPLIED;
|
|
}
|
|
if (defined('LIBXML_HTML_NODEFDTD')) {
|
|
$flags |= LIBXML_HTML_NODEFDTD;
|
|
}
|
|
|
|
$doc = new DOMDocument('1.0', 'UTF-8');
|
|
libxml_use_internal_errors(true);
|
|
$wrapper = '<div id="lib-ref-root">' . $html . '</div>';
|
|
$loaded = @$doc->loadHTML('<?xml encoding="utf-8"?>' . $wrapper, $flags);
|
|
libxml_clear_errors();
|
|
if (!$loaded) return $html;
|
|
|
|
$xpath = new DOMXPath($doc);
|
|
$nodes = $xpath->query('//*[@data-lib-kind and @data-lib-id]');
|
|
if ($nodes !== false) {
|
|
foreach ($nodes as $node) {
|
|
/** @var \DOMElement $node */
|
|
$kind = $node->getAttribute('data-lib-kind');
|
|
$refId = (int)$node->getAttribute('data-lib-id');
|
|
if (!$kind || $refId <= 0) continue;
|
|
$replacement = $this->fetchResourceHtml($kind, $refId, $auth, $cache, $stack);
|
|
if ($replacement === null) continue;
|
|
|
|
while ($node->firstChild) {
|
|
$node->removeChild($node->firstChild);
|
|
}
|
|
|
|
$this->appendHtmlToNode($doc, $node, $replacement);
|
|
}
|
|
}
|
|
|
|
$root = $doc->getElementById('lib-ref-root');
|
|
if (!$root) return $html;
|
|
|
|
$output = '';
|
|
foreach ($root->childNodes as $child) {
|
|
$output .= $doc->saveHTML($child);
|
|
}
|
|
return $output;
|
|
}
|
|
|
|
private function appendHtmlToNode(DOMDocument $targetDoc, DOMElement $node, string $html): void
|
|
{
|
|
if (trim($html) === '') return;
|
|
|
|
$flags = 0;
|
|
if (defined('LIBXML_HTML_NOIMPLIED')) {
|
|
$flags |= LIBXML_HTML_NOIMPLIED;
|
|
}
|
|
if (defined('LIBXML_HTML_NODEFDTD')) {
|
|
$flags |= LIBXML_HTML_NODEFDTD;
|
|
}
|
|
|
|
$fragmentDoc = new DOMDocument('1.0', 'UTF-8');
|
|
libxml_use_internal_errors(true);
|
|
$wrapped = '<div id="fragment-wrapper">' . $html . '</div>';
|
|
$loaded = @$fragmentDoc->loadHTML('<?xml encoding="utf-8"?>' . $wrapped, $flags);
|
|
libxml_clear_errors();
|
|
|
|
if ($loaded) {
|
|
$wrapper = $fragmentDoc->getElementById('fragment-wrapper');
|
|
if ($wrapper) {
|
|
$children = [];
|
|
if ($wrapper->hasChildNodes()) {
|
|
foreach ($wrapper->childNodes as $child) {
|
|
$children[] = $child;
|
|
}
|
|
}
|
|
foreach ($children as $child) {
|
|
$imported = $targetDoc->importNode($child, true);
|
|
if ($imported) {
|
|
$node->appendChild($imported);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
$node->appendChild($targetDoc->createTextNode($html));
|
|
}
|
|
|
|
private function handlePlaceholderSchema(): void
|
|
{
|
|
$user = $this->authService->requireAuth();
|
|
$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);
|
|
$tables = $schema['tables'] ?? [];
|
|
if (!empty($settings['bridge_tables'])) {
|
|
$tables = $this->filterSchemaTables($tables, $settings['bridge_tables']);
|
|
}
|
|
|
|
$this->respond([
|
|
'ok' => true,
|
|
'tables' => $tables,
|
|
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
|
|
]);
|
|
}
|
|
|
|
private function handlePlaceholderStatus(): void
|
|
{
|
|
$user = $this->authService->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->ensureAuthUserHydrated($this->authService->requireAuth());
|
|
$customerId = (int)($user['customer_id'] ?? 0);
|
|
$settings = $customerId ? $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId)) : [];
|
|
$this->respond([
|
|
'ok' => true,
|
|
'user' => $user,
|
|
'customer' => $user['customer'] ?? null,
|
|
'settings' => $settings,
|
|
]);
|
|
}
|
|
|
|
private function handleAccountProfileUpdate(): void
|
|
{
|
|
$user = $this->authService->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->authService->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->authService->requireAuth();
|
|
$this->ensureRole($user, ['owner', 'admin']);
|
|
$customerId = (int)($user['customer_id'] ?? 0);
|
|
$settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId));
|
|
$this->respond(['ok' => true, 'settings' => $settings]);
|
|
}
|
|
|
|
private function handleAccountSettingsUpdate(): void
|
|
{
|
|
$user = $this->authService->requireAuth();
|
|
$this->ensureRole($user, ['owner', 'admin']);
|
|
$customerId = (int)($user['customer_id'] ?? 0);
|
|
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
|
|
|
$bridgeUrl = trim((string)($this->in['bridge_url'] ?? ''));
|
|
$bridgeToken = trim((string)($this->in['bridge_token'] ?? ''));
|
|
$senderToken = trim((string)($this->in['sender_token'] ?? ''));
|
|
$externalToken = trim((string)($this->in['external_api_token'] ?? ''));
|
|
$bridgeTablesInput = $this->in['bridge_tables'] ?? null;
|
|
$bridgeTables = $this->normalizeBridgeTables($bridgeTablesInput);
|
|
$rotateBridge = !empty($this->in['rotate_bridge_token']);
|
|
$rotateSender = !empty($this->in['rotate_sender_token']);
|
|
$rotateExternal = !empty($this->in['rotate_external_token']);
|
|
|
|
if ($bridgeUrl && !filter_var($bridgeUrl, FILTER_VALIDATE_URL)) {
|
|
$this->fail('Ungültige Bridge-URL', null, 422);
|
|
}
|
|
|
|
$settings = $this->getCustomerSettings($customerId);
|
|
if ($rotateBridge || $bridgeToken === '') $bridgeToken = $this->generateToken();
|
|
if ($rotateSender || $senderToken === '') $senderToken = $this->generateToken();
|
|
if ($rotateExternal || $externalToken === '') $externalToken = $this->generateToken();
|
|
|
|
$settings = $this->saveCustomerSettings($customerId, [
|
|
'bridge_url' => $bridgeUrl,
|
|
'bridge_token' => $bridgeToken,
|
|
'sender_token' => $senderToken,
|
|
'external_api_token' => $externalToken,
|
|
'bridge_tables' => $bridgeTables,
|
|
]);
|
|
|
|
$this->respond(['ok' => true, 'settings' => $settings]);
|
|
}
|
|
|
|
private function handleAccountUsersList(): void
|
|
{
|
|
$user = $this->authService->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']),
|
|
sprintf('`%s` AS name', $cols['col_name']),
|
|
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'],
|
|
$cols['col_name']
|
|
);
|
|
$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->authService->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 = [];
|
|
$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->authService->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->authService->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->authService->requireAuth();
|
|
$customerId = (int)($user['customer_id'] ?? 0);
|
|
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
|
|
$table = $this->senderTable();
|
|
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `label` ASC");
|
|
$stmt->execute([':cid' => $customerId]);
|
|
$items = [];
|
|
while ($row = $stmt->fetch()) {
|
|
$items[] = $this->formatSenderRow($row);
|
|
}
|
|
$this->respond(['ok' => true, 'items' => $items]);
|
|
}
|
|
|
|
private function handleAccountSenderSave(): void
|
|
{
|
|
$user = $this->authService->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->authService->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->authService->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->authService->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->authService->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));
|
|
$baseDir = dirname(__DIR__);
|
|
if ($type === 'bridge') {
|
|
$path = $baseDir . '/download/emailtemplate_bridge.php';
|
|
} elseif ($type === 'sender') {
|
|
$path = $baseDir . '/download/emailtemplate_sender.php';
|
|
} else {
|
|
$this->fail('Unknown download type', $type, 404);
|
|
}
|
|
if (!is_file($path)) {
|
|
$this->fail('Datei nicht gefunden', basename($path), 404);
|
|
}
|
|
$content = (string)file_get_contents($path);
|
|
if ($type === 'bridge') {
|
|
$content = str_replace('REPLACE_WITH_SHARED_TOKEN', $settings['bridge_token'] ?? '', $content);
|
|
} else {
|
|
$apiBase = $this->defaultApiBase();
|
|
$content = str_replace('REPLACE_WITH_SHARED_TOKEN', $settings['sender_token'] ?? '', $content);
|
|
$content = str_replace('REPLACE_WITH_TEMPLATE_API_TOKEN', $settings['external_api_token'] ?? '', $content);
|
|
if ($apiBase) {
|
|
$content = str_replace('https://api.emailtemplate.it/external/render', $apiBase, $content);
|
|
}
|
|
}
|
|
|
|
$this->respond([
|
|
'ok' => true,
|
|
'file_name' => basename($path),
|
|
'content' => base64_encode($content),
|
|
]);
|
|
}
|
|
|
|
private function handleAccountBridgeTest(): void
|
|
{
|
|
$user = $this->authService->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);
|
|
}
|
|
$settings = $this->getCustomerSettings($customerId);
|
|
try {
|
|
$schema = $this->fetchPlaceholderSchema($bridgeUrl, $bridgeToken, 0);
|
|
} catch (Throwable $e) {
|
|
$this->fail('Bridge request failed', $e->getMessage(), 502);
|
|
return;
|
|
}
|
|
$tables = $schema['tables'] ?? [];
|
|
if (!empty($settings['bridge_tables'])) {
|
|
$tables = $this->filterSchemaTables($tables, $settings['bridge_tables']);
|
|
}
|
|
$this->respond([
|
|
'ok' => true,
|
|
'tables' => $tables,
|
|
'fetched' => $schema['fetched'] ?? date(DATE_ATOM),
|
|
]);
|
|
}
|
|
|
|
private function handleDebugPhpInfo(): void
|
|
{
|
|
$user = $this->authService->requireAuth();
|
|
$this->ensureDebugUser($user);
|
|
ob_start();
|
|
phpinfo(INFO_GENERAL | INFO_CONFIGURATION | INFO_MODULES | INFO_ENVIRONMENT);
|
|
$html = ob_get_clean() ?: '';
|
|
$this->respond(['ok' => true, 'html' => $html]);
|
|
}
|
|
|
|
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 [];
|
|
$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 saveCustomerSettings(int $customerId, array $data): array
|
|
{
|
|
if ($customerId <= 0) return [];
|
|
$allowed = ['bridge_url', 'bridge_token', 'sender_token', 'external_api_token', 'bridge_tables'];
|
|
$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;
|
|
}
|
|
$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 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'] = [];
|
|
}
|
|
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 filterSchemaTables(array $tables, array $allowed): array
|
|
{
|
|
if (empty($allowed)) return $tables;
|
|
$allowedLower = array_map('strtolower', $allowed);
|
|
$filtered = [];
|
|
foreach ($tables as $entry) {
|
|
if (is_array($entry)) {
|
|
$name = strtolower((string)($entry['name'] ?? $entry['table'] ?? $entry['label'] ?? ''));
|
|
if ($name !== '' && in_array($name, $allowedLower, true)) {
|
|
$filtered[] = $entry;
|
|
}
|
|
} else {
|
|
$name = strtolower((string)$entry);
|
|
if ($name !== '' && in_array($name, $allowedLower, true)) {
|
|
$filtered[] = $entry;
|
|
}
|
|
}
|
|
}
|
|
return $filtered;
|
|
}
|
|
|
|
private function customerSettingsTable(): string
|
|
{
|
|
return 'emailtemplate_customer_settings';
|
|
}
|
|
|
|
private function generateToken(int $length = 48): string
|
|
{
|
|
return rtrim(strtr(base64_encode(random_bytes($length)), '+/', '-_'), '=');
|
|
}
|
|
|
|
private function generateReadablePassword(int $length = 12): string
|
|
{
|
|
$bytes = bin2hex(random_bytes($length));
|
|
return substr($bytes, 0, $length);
|
|
}
|
|
|
|
private function authUserColumns(): array
|
|
{
|
|
$db = $this->conf['auth']['db'] ?? [];
|
|
return [
|
|
'table' => $db['table'] ?? 'customer_users',
|
|
'col_id' => $db['col_id'] ?? 'id',
|
|
'col_email' => $db['col_user'] ?? 'email',
|
|
'col_pass' => $db['col_pass'] ?? 'password_hash',
|
|
'col_name' => $db['col_name'] ?? 'name',
|
|
'col_role' => $db['col_role'] ?? 'role',
|
|
'col_status' => $db['col_status'] ?? 'is_active',
|
|
'col_customer' => $db['customer_fk'] ?? 'customer_id',
|
|
];
|
|
}
|
|
|
|
private function ensureAuthUserHydrated(array $user): array
|
|
{
|
|
$role = (string)($user['role'] ?? '');
|
|
$hasOwnerFlag = isset($user['permissions']['owner']);
|
|
if ($role !== '' && $hasOwnerFlag) {
|
|
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 `%s` FROM `%s` WHERE %s LIMIT 1', $cols['col_role'], $table, $where);
|
|
$stmt = $this->pdo->prepare($sql);
|
|
$stmt->execute($params);
|
|
$row = $stmt->fetch();
|
|
if ($row && isset($row[$cols['col_role']])) {
|
|
$roleValue = $this->sanitizeRole((string)$row[$cols['col_role']]);
|
|
$user['role'] = $roleValue;
|
|
$user['permissions']['owner'] = ($roleValue === 'owner');
|
|
$_SESSION['auth']['role'] = $roleValue;
|
|
$_SESSION['auth']['permissions']['owner'] = ($roleValue === 'owner');
|
|
} 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
|
|
{
|
|
$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 defaultApiBase(): string
|
|
{
|
|
$base = $this->conf['base_url'] ?? '';
|
|
return $base ? rtrim($base, '/') . '/api.php' : '/api.php';
|
|
}
|
|
}
|