false,'error'=>$msg,'detail'=>$detail], $code); } function load_config(): array { $paths = [ __DIR__ . '/../inc/config.php', __DIR__ . '/inc/config.php', __DIR__ . '/config.php', ]; foreach ($paths as $p) { if (is_file($p)) { $conf = @include $p; if (is_array($conf)) return $conf; } } fail('Invalid config.php', 'config.php not found or not returning array', 500); } function cors(array $conf): void { $cors = $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') respond(['ok'=>true]); } function get_input(): array { $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; return $data; } function val(array $in, $keys, $default=null) { if (!is_array($keys)) $keys = [$keys]; foreach ($keys as $k) if (array_key_exists($k,$in)) return $in[$k]; return $default; } /* ===================== DB helpers ===================== */ function pdo_templates(array $conf): PDO { if (!isset($conf['templates']) || !is_array($conf['templates'])) { fail('Missing templates DB config', null, 500); } $c = $conf['templates']; $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); } function verify_password(string $input, string $stored, array $authDbConf): bool { if (preg_match('~^\$2[aby]\$~', $stored) || strpos($stored, '$argon2') === 0) return password_verify($input, $stored); $legacy = strtolower($authDbConf['legacy'] ?? ''); if ($legacy === 'md5') return hash_equals($stored, md5($input)); if ($legacy === 'sha1') return hash_equals($stored, sha1($input)); if ($legacy === 'plain')return hash_equals($stored, $input); if (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored); return hash_equals($stored, $input); } /* ============ schema helpers ============ */ function table_columns(PDO $pdo, string $table): array { $cols = []; $stmt = $pdo->query("SHOW COLUMNS FROM `$table`"); foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field']; return $cols; } function primary_key(PDO $pdo, string $table): ?string { $stmt = $pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'"); $stmt->execute(); $row = $stmt->fetch(); return $row['Column_name'] ?? null; } function first_existing(array $columns, array $candidates): ?string { foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c; return null; } /* ===================== Boot ===================== */ try { $conf = load_config(); cors($conf); if (session_status() === PHP_SESSION_NONE) session_start(); if (!empty($conf['auth']['cookie'])) { $c = $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); } $in = get_input(); /* ---- Compat: ?resource=blocks&action=list -> blocks.list ---- */ $action = val($in, 'action', ''); $resource = val($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; } /* ---- Multi-tenant ---- */ $multi = $conf['multi'] ?? []; $tenantCol = $multi['tenant_col'] ?? null; $mapSess = $multi['map_session_to'] ?? 'id'; // 'id'|'email'|'name' $tenantWhere = function(array $session) use ($tenantCol, $mapSess) { 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]]; }; $tenantAssign = function(array $session, array $columns) use ($tenantCol, $mapSess) { if (!$tenantCol || !in_array($tenantCol, $columns, true)) return []; $val = $session[$mapSess] ?? null; return ($val===null || $val==='') ? [] : [$tenantCol => $val]; }; $requireAuth = function() { if (empty($_SESSION['auth'])) fail('Not authenticated', null, 401); return $_SESSION['auth']; }; /* ---- config tables/columns ---- */ $tables = $conf['tables'] ?? []; $tableMap = [ 'templates' => $tables['templates'] ?? 'emailtemplate_templates', 'sections' => $tables['sections'] ?? 'emailtemplate_sections', 'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks', 'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets', ]; $colsDefault = [ 'id' => 'id', 'name' => 'name', 'desc' => 'description', 'cat' => 'category', 'upd' => 'updated_at', ]; $pdo = pdo_templates($conf); /* helper: resolve id column for a table */ $resolveIdCol = function(string $kind) use ($conf, $colsDefault, $tableMap, $pdo) { $t = $tableMap[$kind]; $cfg = $conf['columns'][$kind] ?? []; $cols = table_columns($pdo, $t); $idCol = $cfg['id'] ?? (in_array('id', $cols, true) ? 'id' : primary_key($pdo, $t)); if (!$idCol) $idCol = 'id'; // fallback return [$idCol, $cols]; }; /* helper: accept many id aliases */ $pullId = function(array $src) { $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; }; /* ===================== Router ===================== */ switch ($action) { case 'health': respond(['ok'=>true,'time'=>date('c')]); /* ---------- AUTH ---------- */ case 'auth.login': { $identifier = trim((string)val($in, ['username','user','email','login'], '')); $password = (string)val($in, ['password','pass','pwd'], ''); if ($identifier === '' || $password === '') fail('username/password required', null, 422); $authDb = $conf['auth']['db'] ?? []; $table = $authDb['table'] ?? 'emailtemplate_users'; $colUser = $authDb['col_user'] ?? 'email'; $colPass = $authDb['col_pass'] ?? 'password'; $colName = $authDb['col_name'] ?? 'name'; $colId = $authDb['col_id'] ?? 'id'; $colStatus = $authDb['col_status']?? null; $activeValues = $authDb['active_values'] ?? ['active','1',1]; $stmt = $pdo->prepare("SELECT * FROM `$table` WHERE `$colUser` = :u LIMIT 1"); $stmt->execute([':u'=>$identifier]); $row = $stmt->fetch(); if (!$row) fail('Invalid credentials', null, 401); if ($colStatus && isset($row[$colStatus])) { if (!in_array($row[$colStatus], $activeValues, true)) { fail('Account inactive', null, 403); } } $stored = (string)($row[$colPass] ?? ''); if ($stored === '' || !verify_password($password, $stored, $authDb)) { fail('Invalid credentials', null, 401); } $_SESSION['auth'] = [ 'id' => $row[$colId] ?? null, 'name' => $row[$colName] ?? ($row[$colUser] ?? $identifier), 'email' => $row[$colUser] ?? $identifier, 'at' => time(), ]; $token = base64_encode(hash('sha256', ($_SESSION['auth']['id'] ?? $identifier).'|'.session_id(), true)); respond(['ok'=>true,'user'=>$_SESSION['auth'],'token'=>$token]); } case 'auth.me': if (empty($_SESSION['auth'])) fail('Not authenticated', null, 401); respond(['ok'=>true,'user'=>$_SESSION['auth']]); case 'auth.logout': $_SESSION = []; if (session_id() !== '') session_destroy(); respond(['ok'=>true]); /* ---------- LIST (mit Parent-Filtern) ---------- */ case 'templates.list': case 'sections.list': case 'blocks.list': case 'snippets.list': { $auth = $requireAuth(); $kind = explode('.', $action)[0]; $t = $tableMap[$kind]; [$idCol, $allCols] = $resolveIdCol($kind); $cfg = $conf['columns'][$kind] ?? []; $nameCol = $cfg['name'] ?? (in_array('name',$allCols,true) ? 'name' : $idCol); $descCol = $cfg['desc'] ?? first_existing($allCols, ['description','desc','descr']); $catCol = $cfg['cat'] ?? first_existing($allCols, ['category','cat']); $updCol = $cfg['upd'] ?? first_existing($allCols, ['updated_at','updated','updatedAt']); $q = trim((string)val($in,'q','')); $limit = max(1, (int)val($in,'limit', 500)); $offset = max(0, (int)val($in,'offset',0)); $where = ' WHERE 1=1 '; $params = []; 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.'%'; } // Parent-Filtern (falls Spalten existieren) $parentFilters = [ 'template_id' => val($in, ['template_id','tpl_id'], null), 'section_id' => val($in, ['section_id','sec_id'], null), 'block_id' => val($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; } } // Mandant [$tw,$tp] = $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 = $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[$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]; $out[] = $item; } respond(['ok'=>true,'kind'=>$kind,'items'=>$out,'data'=>$out,'count'=>count($out),'offset'=>$offset,'limit'=>$limit]); } /* ---------- GET (JETZT mit top-level html/content) ---------- */ case 'templates.get': case 'sections.get': case 'blocks.get': case 'snippets.get': { $auth = $requireAuth(); $kind = explode('.', $action)[0]; $t = $tableMap[$kind]; [$idCol, $allCols] = $resolveIdCol($kind); $id = $pullId($in); if ($id === null || $id === '') fail('id required', null, 422); [$tw,$tp] = $tenantWhere($auth); $sql = "SELECT * FROM `$t` WHERE `$idCol` = :id".$tw." LIMIT 1"; $stmt = $pdo->prepare($sql); $stmt->bindValue(':id', $id); foreach ($tp as $k=>$v) $stmt->bindValue($k,$v); $stmt->execute(); $row = $stmt->fetch(); if (!$row) fail('Not found', ['kind'=>$kind,'id'=>$id], 404); $rowOut = ['id' => $row[$idCol] ?? $id] + $row; // NEU: Spalten für HTML/JSON erkennen und top-level ausgeben $htmlCol = first_existing($allCols, ['html','body','markup']); $jsonCol = first_existing($allCols, ['content_json','json','content','structure_json']); $topHtml = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : null; $topContent = ($jsonCol && isset($row[$jsonCol])) ? $row[$jsonCol] : null; respond([ 'ok'=>true, 'kind'=>$kind, 'id'=>$rowOut['id'], 'item'=>$rowOut, 'data'=>$rowOut, 'html'=>$topHtml, // <— wichtig für Vorschau 'content'=>$topContent // optional (z. B. GrapesJS JSON) ]); } /* ---------- CREATE ---------- */ case 'templates.create': case 'sections.create': case 'blocks.create': case 'snippets.create': { $auth = $requireAuth(); $kind = explode('.', $action)[0]; $t = $tableMap[$kind]; [$idCol, $allCols] = $resolveIdCol($kind); $cfg = $conf['columns'][$kind] ?? []; $nameCol = $cfg['name'] ?? (in_array('name',$allCols,true) ? 'name' : $idCol); $descCol = $cfg['desc'] ?? first_existing($allCols, ['description','desc','descr']); $catCol = $cfg['cat'] ?? first_existing($allCols, ['category','cat']); $updCol = $cfg['upd'] ?? first_existing($allCols, ['updated_at','updated','updatedAt']); $name = trim((string)val($in, ['name','title'], '')); if ($name === '') fail('name required', null, 422); $desc = (string)val($in, ['description','desc'], null); $cat = (string)val($in, ['category','cat'], null); $html = (string)val($in, ['html','body','markup'], null); $json = val($in, ['content_json','json','content','structure_json'], null); $settings = val($in, ['settings_json','settings'], null); $templateId = val($in, ['template_id','tpl_id'], null); $sectionId = val($in, ['section_id','sec_id'], null); $blockId = val($in, ['block_id','blk_id'], null); $data = [ $nameCol => $name ]; if ($desc !== null && $descCol) $data[$descCol] = $desc; if ($cat !== null && $catCol) $data[$catCol] = $cat; $c = first_existing($allCols, ['html','body','markup']); if ($c && $html !== null) $data[$c]=$html; $c = first_existing($allCols, ['content_json','json','content','structure_json']); if ($c && $json !== null) $data[$c]= is_string($json)?$json:json_encode($json, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); $c = first_existing($allCols, ['settings_json','settings']); if ($c && $settings !== null) $data[$c]= is_string($settings)?$settings:json_encode($settings, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); 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 + $tenantAssign($_SESSION['auth'] ?? [], $allCols); $now = date('Y-m-d H:i:s'); $createdCol = first_existing($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 = $pdo->prepare($sql); foreach ($data as $k=>$v) $stmt->bindValue(":$k", $v); $stmt->execute(); $newId = $pdo->lastInsertId(); $out = ['id'=>$newId,'name'=>$name]; if ($desc !== null) $out['desc']=$desc; if ($cat !== null) $out['category']=$cat; respond(['ok'=>true,'kind'=>$kind,'id'=>$newId,'item'=>$out,'data'=>$out]); } /* ---------- UPDATE ---------- */ case 'templates.update': case 'sections.update': case 'blocks.update': case 'snippets.update': { $auth = $requireAuth(); $kind = explode('.', $action)[0]; $t = $tableMap[$kind]; [$idCol, $allCols] = $resolveIdCol($kind); $cfg = $conf['columns'][$kind] ?? []; $nameCol = $cfg['name'] ?? (in_array('name',$allCols,true) ? 'name' : $idCol); $descCol = $cfg['desc'] ?? first_existing($allCols, ['description','desc','descr']); $catCol = $cfg['cat'] ?? first_existing($allCols, ['category','cat']); $updCol = $cfg['upd'] ?? first_existing($allCols, ['updated_at','updated','updatedAt']); $id = $pullId($in); if ($id === null || $id === '') fail('id required', null, 422); $data = []; $name = val($in, ['name','title'], null); $desc = val($in, ['description','desc'], null); $cat = val($in, ['category','cat'], null); $html = val($in, ['html','body','markup'], null); $json = val($in, ['content_json','json','content','structure_json'], null); $settings = val($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; $c = first_existing($allCols, ['html','body','markup']); if ($html !== null && $c) $data[$c]=(string)$html; $c = first_existing($allCols, ['content_json','json','content','structure_json']); if ($json !== null && $c) $data[$c]= is_string($json)?$json:json_encode($json, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); $c = first_existing($allCols, ['settings_json','settings']); if ($settings !== null && $c) $data[$c]= is_string($settings)?$settings:json_encode($settings, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); $tpl = val($in, ['template_id','tpl_id'], null); if ($tpl !== null && in_array('template_id',$allCols,true)) $data['template_id']=$tpl; $sec = val($in, ['section_id','sec_id'], null); if ($sec !== null && in_array('section_id',$allCols,true)) $data['section_id']=$sec; $blk = val($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) fail('nothing to update', null, 422); [$tw,$tp] = $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 = $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(); respond(['ok'=>true,'kind'=>$kind,'id'=>$id,'updated'=>array_keys($data)]); } /* ---------- DELETE ---------- */ case 'templates.delete': case 'sections.delete': case 'blocks.delete': case 'snippets.delete': { $auth = $requireAuth(); $kind = explode('.', $action)[0]; $t = $tableMap[$kind]; [$idCol, $allCols] = $resolveIdCol($kind); $id = $pullId($in); if ($id === null || $id === '') fail('id required', null, 422); [$tw,$tp] = $tenantWhere($auth); $sql = "DELETE FROM `$t` WHERE `$idCol` = :__id".$tw." LIMIT 1"; $stmt = $pdo->prepare($sql); $stmt->bindValue(':__id', $id); foreach ($tp as $k=>$v) $stmt->bindValue($k,$v); $stmt->execute(); respond(['ok'=>true,'kind'=>$kind,'id'=>$id,'deleted'=>true]); } /* ---------- Platzhalter für kommende Features ---------- */ case 'render.preview': fail('Not implemented', 'Preview wird im Feature-Patch geliefert', 501); case 'templates.test_send': fail('Not implemented', 'Testversand wird im Feature-Patch geliefert', 501); case 'export.render': fail('Not implemented', 'Export-API wird im Feature-Patch geliefert', 501); default: fail('Unknown action', $action ?: 'missing', 404); } } catch (Throwable $e) { fail('Server error', get_class($e).': '.$e->getMessage(), 500); }