false, 'error' => 'internal', 'type' => get_class($e), 'msg' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine(), ], JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); exit; }); /* ------------------------- Helpers ------------------------- */ function json_out(array $data, int $code = 200): void { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); exit; } function in_json(): array { $raw = file_get_contents('php://input') ?: ''; if ($raw === '') return []; $j = json_decode($raw, true); return is_array($j) ? $j : []; } /* ------------------------- Config laden ------------------------- */ // Parent /inc/config.php (wie von dir beschrieben) $cfgFile = dirname(__DIR__) . '/inc/config.php'; if (!is_file($cfgFile)) { json_out(['ok'=>false,'error'=>'config_missing','hint'=>'config.php nicht gefunden','path'=>$cfgFile], 500); } $CFG = include $cfgFile; if (!is_array($CFG)) { json_out(['ok'=>false,'error'=>'config_invalid','hint'=>'config.php muss ein Array zurückgeben'], 500); } $ENV = $CFG['env'] ?? 'prod'; /* ------------------------- PDO-Factories ------------------------- */ function pdo_from_cfg(?array $dbc): ?PDO { if (!$dbc) return null; $dsn = sprintf( 'mysql:host=%s;dbname=%s;charset=%s', $dbc['db_host'] ?? 'localhost', $dbc['db_name'] ?? '', $dbc['db_charset']?? 'utf8mb4' ); return new PDO($dsn, $dbc['db_user'] ?? '', $dbc['db_pass'] ?? '', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); } $pdoTpl = pdo_from_cfg($CFG['templates'] ?? null); // Template-Daten $pdoCust = pdo_from_cfg(($CFG['customers'] ?? null) ?: ($CFG['templates'] ?? null)); // Kunden/Users $TPL_DB = $CFG['templates']['db_name'] ?? null; // für information_schema function has_column(PDO $pdo, ?string $db, string $table, string $col): bool { if (!$db) return false; $st = $pdo->prepare("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=:db AND TABLE_NAME=:t AND COLUMN_NAME=:c LIMIT 1"); $st->execute([':db'=>$db, ':t'=>$table, ':c'=>$col]); return (bool)$st->fetchColumn(); } /* ------------------------- Auth (Helper oder Fallback) ------------------------- */ $authFile = dirname(__DIR__) . '/inc/auth_helpers.php'; $useHelpers = is_file($authFile); if ($useHelpers) { require_once $authFile; // stellt auth_start_session(), auth_require(), auth_logout(), require_role() bereit } else { // interner Fallback – kompatibel zu deinen Erwartungen function auth_start_session(array $CFG): void { $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); session_set_cookie_params([ 'httponly' => true, 'samesite' => 'Lax', 'secure' => $secure, 'path' => rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/' ]); session_name('et_session'); if (session_status() !== PHP_SESSION_ACTIVE) session_start(); } function auth_require(array $CFG): void { auth_start_session($CFG); if (empty($_SESSION['user'])) { json_out(['ok'=>false,'error'=>'unauthorized'], 401); } } function auth_logout(array $CFG): void { auth_start_session($CFG); $_SESSION = []; if (ini_get('session.use_cookies')) { $p = session_get_cookie_params(); setcookie(session_name(), '', time()-42000, $p['path'], $p['domain'] ?? '', $p['secure'], $p['httponly']); } session_destroy(); } function require_role(array $CFG, array $roles): void { auth_start_session($CFG); $r = $_SESSION['user']['role'] ?? null; if (!$r || !in_array($r, $roles, true)) json_out(['ok'=>false,'error'=>'forbidden'], 403); } } /* ------------------------- Routing + Legacy-Mapping ------------------------- */ $action = $_GET['action'] ?? $_POST['action'] ?? null; // Alt: ?resource=blocks&action=list|get|create|update|delete|sync if (!empty($_GET['resource'])) { $res = (string)$_GET['resource']; $act = (string)($_GET['action'] ?? ''); $allowed = ['templates','sections','blocks','snippets','assets','template_items','section_items']; if (in_array($res, $allowed, true)) { if ($act === 'list') $action = $res.'.list'; if ($act === 'get') $action = $res.'.get'; if ($act === 'create') $action = $res.'.create'; if ($act === 'update') $action = $res.'.update'; if ($act === 'delete') $action = $res.'.delete'; if ($act === 'sync') $action = $res.'.sync'; } } /* ------------------------- Meta/Health ------------------------- */ if ($action === 'health' || $action === 'ping') json_out(['ok'=>true,'env'=>$ENV,'time'=>date('c')]); if ($action === 'version') json_out(['ok'=>true,'version'=>'2025-09-05','env'=>$ENV]); /* ------------------------- Diagnose (leichtgewichtig) ------------------------- */ if ($action === 'debug.diag') { $diag = [ 'php' => PHP_VERSION, 'pdo' => extension_loaded('pdo'), 'pdo_mysql'=> extension_loaded('pdo_mysql'), 'cfg' => ['templates'=>!!$pdoTpl, 'customers'=>!!$pdoCust], ]; json_out(['ok'=>true,'diag'=>$diag]); } /* ------------------------- STAGING: User-Debug ------------------------- */ if (in_array($action, ['debug.users','debug.users.check','debug.users.setpass','debug.users.peek'], true)) { if ($ENV !== 'staging') json_out(['ok'=>false,'error'=>'forbidden'], 403); if (!$pdoCust) json_out(['ok'=>false,'error'=>'customers_db_not_configured'], 500); if ($action === 'debug.users') { $email = isset($_GET['email']) ? trim((string)$_GET['email']) : ''; if ($email !== '') { $st = $pdoCust->prepare("SELECT id, customer_id, email, role, is_active, created_at, updated_at FROM customer_users WHERE email=:email"); $st->execute([':email'=>$email]); $rows = $st->fetchAll(); } else { $st = $pdoCust->query("SELECT id, customer_id, email, role, is_active, created_at, updated_at FROM customer_users ORDER BY id DESC LIMIT 50"); $rows = $st->fetchAll(); } json_out(['ok'=>true,'items'=>$rows]); } if ($action === 'debug.users.check') { $in = in_json(); $email = trim((string)($in['email'] ?? '')); $pass = (string)($in['password'] ?? ''); if ($email==='' || $pass==='') json_out(['ok'=>false,'error'=>'missing_params'], 400); $st = $pdoCust->prepare("SELECT id, customer_id, email, password_hash, role, is_active FROM customer_users WHERE email=:email LIMIT 1"); $st->execute([':email'=>$email]); $u = $st->fetch(); if (!$u) json_out(['ok'=>true,'exists'=>false,'password_match'=>false]); json_out(['ok'=>true,'exists'=>true,'password_match'=>password_verify($pass,$u['password_hash'])]); } if ($action === 'debug.users.setpass') { $in = in_json(); $email = trim((string)($in['email'] ?? '')); $pass = (string)($in['password'] ?? ''); if ($email==='' || $pass==='') json_out(['ok'=>false,'error'=>'missing_params'], 400); $st = $pdoCust->prepare("SELECT id FROM customer_users WHERE email=:email LIMIT 1"); $st->execute([':email'=>$email]); $u = $st->fetch(); if (!$u) json_out(['ok'=>false,'error'=>'user_not_found'], 404); $hash = password_hash($pass, PASSWORD_DEFAULT); $upd = $pdoCust->prepare("UPDATE customer_users SET password_hash=:h, is_active=1 WHERE id=:id"); $upd->execute([':h'=>$hash, ':id'=>$u['id']]); json_out(['ok'=>true,'set'=>true]); } if ($action === 'debug.users.peek') { $email = isset($_GET['email']) ? trim((string)$_GET['email']) : ''; if ($email==='') json_out(['ok'=>false,'error'=>'missing_email'], 400); $st = $pdoCust->prepare("SELECT id, customer_id, email, LENGTH(password_hash) len FROM customer_users WHERE email=:email"); $st->execute([':email'=>$email]); json_out(['ok'=>true,'user'=>$st->fetch()]); } } /* ------------------------- AUTH: Login / Logout / Me ------------------------- */ if ($action === 'auth.login') { $in = in_json(); $email = trim(strtolower((string)($in['email'] ?? ''))); $pass = (string)($in['password'] ?? ''); if ($email==='' || $pass==='') json_out(['ok'=>false,'error'=>'missing_credentials'], 400); if (!$pdoCust) json_out(['ok'=>false,'error'=>'customers_db_not_configured'], 500); // Mehrfachkunden mit gleicher Mail erlauben → best match über password_verify $st = $pdoCust->prepare("SELECT cu.id, cu.customer_id, cu.email, cu.password_hash, cu.role, cu.is_active, c.slug AS customer_slug, c.plan, c.status FROM customer_users cu JOIN customers c ON c.id = cu.customer_id WHERE cu.email=:email"); $st->execute([':email'=>$email]); $rows = $st->fetchAll(); $match = null; foreach ($rows as $r) { if ((int)$r['is_active'] === 1 && !empty($r['password_hash']) && password_verify($pass, $r['password_hash'])) { $match = $r; break; } } if (!$match) json_out(['ok'=>false,'error'=>'invalid_credentials'], 401); if (($match['status'] ?? 'active') !== 'active') json_out(['ok'=>false,'error'=>'customer_inactive'], 403); auth_start_session($CFG); $_SESSION['user'] = [ 'id' => (int)$match['id'], 'email' => $match['email'], 'role' => $match['role'], 'customer_id' => (int)$match['customer_id'], 'customer_slug' => $match['customer_slug'], 'plan' => $match['plan'] ?? null, ]; json_out(['ok'=>true,'user'=>$_SESSION['user']]); } if ($action === 'auth.logout') { auth_logout($CFG); json_out(['ok'=>true]); } if ($action === 'auth.me') { auth_start_session($CFG); json_out(['ok'=>!empty($_SESSION['user']), 'user'=>$_SESSION['user'] ?? null]); } /* ------------------------- ab hier: geschützt ------------------------- */ $public = ['auth.login','auth.logout','auth.me','health','ping','version','debug.diag','debug.users','debug.users.check','debug.users.setpass','debug.users.peek']; if (!in_array($action, $public, true)) auth_require($CFG); $customerId = (int)($_SESSION['user']['customer_id'] ?? 0); /* ------------------------- Templates ------------------------- */ if ($action === 'templates.list') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $st = $pdoTpl->prepare("SELECT id, name, updated_at FROM emailtemplate_templates WHERE customer_id = :cid ORDER BY updated_at DESC, id DESC LIMIT 1000"); $st->execute([':cid'=>$customerId]); json_out(['ok'=>true,'items'=>$st->fetchAll()]); } if ($action === 'templates.get') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $id = isset($_GET['id']) ? (int)$_GET['id'] : (int)($_POST['id'] ?? 0); if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400); $hasHtml = has_column($pdoTpl, $TPL_DB, 'emailtemplate_templates', 'html'); $cols = $hasHtml ? "id, customer_id, name, html, updated_at" : "id, customer_id, name, NULL AS html, updated_at"; $st = $pdoTpl->prepare("SELECT $cols FROM emailtemplate_templates WHERE id=:id AND customer_id=:cid LIMIT 1"); $st->execute([':id'=>$id, ':cid'=>$customerId]); $row = $st->fetch(); if (!$row) json_out(['ok'=>false,'error'=>'not_found'], 404); json_out(['ok'=>true,'item'=>$row]); } if ($action === 'templates.create') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $in = in_json(); $name = trim((string)($in['name'] ?? '')); $html = (string)($in['html'] ?? ''); if ($name==='') json_out(['ok'=>false,'error'=>'name_required'], 400); $hasHtml = has_column($pdoTpl, $TPL_DB, 'emailtemplate_templates', 'html'); if ($hasHtml) { $st = $pdoTpl->prepare("INSERT INTO emailtemplate_templates (customer_id,name,html,created_at,updated_at) VALUES (:cid,:name,:html,NOW(),NOW())"); $st->execute([':cid'=>$customerId, ':name'=>$name, ':html'=>$html]); } else { $st = $pdoTpl->prepare("INSERT INTO emailtemplate_templates (customer_id,name,created_at,updated_at) VALUES (:cid,:name,NOW(),NOW())"); $st->execute([':cid'=>$customerId, ':name'=>$name]); } json_out(['ok'=>true,'id'=>(int)$pdoTpl->lastInsertId()]); } if ($action === 'templates.update') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $in = in_json(); $id = (int)($in['id'] ?? 0); $name = array_key_exists('name',$in) ? trim((string)$in['name']) : null; $html = array_key_exists('html',$in) ? (string)$in['html'] : null; if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400); $hasHtml = has_column($pdoTpl, $TPL_DB, 'emailtemplate_templates', 'html'); $sets=[]; $p=[':id'=>$id, ':cid'=>$customerId]; if ($name!==null) { $sets[]="name=:name"; $p[':name']=$name; } if ($hasHtml && $html!==null) { $sets[]="html=:html"; $p[':html']=$html; } if (!$sets) json_out(['ok'=>false,'error'=>'nothing_to_update'], 400); $sql = "UPDATE emailtemplate_templates SET ".implode(',',$sets).", updated_at=NOW() WHERE id=:id AND customer_id=:cid"; $st = $pdoTpl->prepare($sql); $st->execute($p); json_out(['ok'=>true,'updated'=>$st->rowCount()]); } if ($action === 'templates.delete') { require_role($CFG, ['owner','admin']); if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $in = in_json(); $id=(int)($in['id'] ?? 0); if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400); $pdoTpl->beginTransaction(); try { $pdoTpl->prepare("DELETE FROM emailtemplate_template_items WHERE template_id=:id AND customer_id=:cid")->execute([':id'=>$id, ':cid'=>$customerId]); $pdoTpl->prepare("DELETE FROM emailtemplate_templates WHERE id=:id AND customer_id=:cid")->execute([':id'=>$id, ':cid'=>$customerId]); $pdoTpl->commit(); json_out(['ok'=>true]); } catch (Throwable $e) { $pdoTpl->rollBack(); throw $e; } } /* ------------------------- Sections ------------------------- */ if ($action === 'sections.list') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $templateId = isset($_GET['template_id']) ? (int)$_GET['template_id'] : 0; if ($templateId>0) { $st = $pdoTpl->prepare("SELECT id, template_id, name, z_index, type, updated_at FROM emailtemplate_sections WHERE customer_id=:cid AND template_id=:tid ORDER BY z_index ASC, id ASC LIMIT 1000"); $st->execute([':cid'=>$customerId, ':tid'=>$templateId]); } else { $st = $pdoTpl->prepare("SELECT id, template_id, name, z_index, type, updated_at FROM emailtemplate_sections WHERE customer_id=:cid ORDER BY template_id ASC, z_index ASC, id ASC LIMIT 1000"); $st->execute([':cid'=>$customerId]); } json_out(['ok'=>true,'items'=>$st->fetchAll()]); } if ($action === 'sections.get') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $id = isset($_GET['id']) ? (int)$_GET['id'] : (int)($_POST['id'] ?? 0); if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400); $st = $pdoTpl->prepare("SELECT id, customer_id, template_id, name, z_index, type, updated_at FROM emailtemplate_sections WHERE id=:id AND customer_id=:cid LIMIT 1"); $st->execute([':id'=>$id, ':cid'=>$customerId]); $row = $st->fetch(); if (!$row) json_out(['ok'=>false,'error'=>'not_found'], 404); json_out(['ok'=>true,'item'=>$row]); } /* ------------------------- Blocks ------------------------- */ if ($action === 'blocks.list') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $st = $pdoTpl->prepare("SELECT id, name, category, updated_at FROM emailtemplate_blocks WHERE customer_id=:cid ORDER BY name ASC LIMIT 1000"); $st->execute([':cid'=>$customerId]); json_out(['ok'=>true,'items'=>$st->fetchAll()]); } if ($action === 'blocks.get') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $id = isset($_GET['id']) ? (int)$_GET['id'] : (int)($_POST['id'] ?? 0); if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400); $st = $pdoTpl->prepare("SELECT id, customer_id, name, category, updated_at FROM emailtemplate_blocks WHERE id=:id AND customer_id=:cid LIMIT 1"); $st->execute([':id'=>$id, ':cid'=>$customerId]); $row = $st->fetch(); if (!$row) json_out(['ok'=>false,'error'=>'not_found'], 404); json_out(['ok'=>true,'item'=>$row]); } if ($action === 'blocks.create') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $in=in_json(); $name=trim((string)($in['name']??'')); $cat=trim((string)($in['category']??'')); if ($name==='') json_out(['ok'=>false,'error'=>'name_required'], 400); $st=$pdoTpl->prepare("INSERT INTO emailtemplate_blocks (customer_id,name,category,created_at,updated_at) VALUES (:cid,:name,COALESCE(NULLIF(:cat,''),'Default'),NOW(),NOW())"); $st->execute([':cid'=>$customerId, ':name'=>$name, ':cat'=>$cat]); json_out(['ok'=>true,'id'=>(int)$pdoTpl->lastInsertId()]); } if ($action === 'blocks.update') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $in=in_json(); $id=(int)($in['id']??0); $name=trim((string)($in['name']??'')); $cat=trim((string)($in['category']??'')); if ($id<=0 || $name==='') json_out(['ok'=>false,'error'=>'invalid_params'], 400); $st=$pdoTpl->prepare("UPDATE emailtemplate_blocks SET name=:name, category=COALESCE(NULLIF(:cat,''),category), updated_at=NOW() WHERE id=:id AND customer_id=:cid"); $st->execute([':name'=>$name, ':cat'=>$cat, ':id'=>$id, ':cid'=>$customerId]); json_out(['ok'=>true,'updated'=>$st->rowCount()]); } if ($action === 'blocks.delete') { require_role($CFG, ['owner','admin']); if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $in=in_json(); $id=(int)($in['id']??0); if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400); $st=$pdoTpl->prepare("DELETE FROM emailtemplate_blocks WHERE id=:id AND customer_id=:cid"); $st->execute([':id'=>$id, ':cid'=>$customerId]); json_out(['ok'=>true,'deleted'=>$st->rowCount()]); } /* ------------------------- Snippets ------------------------- */ if ($action === 'snippets.list') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $st=$pdoTpl->prepare("SELECT id, name, category, updated_at FROM emailtemplate_snippets WHERE customer_id=:cid ORDER BY name ASC LIMIT 1000"); $st->execute([':cid'=>$customerId]); json_out(['ok'=>true,'items'=>$st->fetchAll()]); } if ($action === 'snippets.get') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $id = isset($_GET['id']) ? (int)$_GET['id'] : (int)($_POST['id'] ?? 0); if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400); $st=$pdoTpl->prepare("SELECT id, customer_id, name, category, updated_at FROM emailtemplate_snippets WHERE id=:id AND customer_id=:cid LIMIT 1"); $st->execute([':id'=>$id, ':cid'=>$customerId]); $row=$st->fetch(); if (!$row) json_out(['ok'=>false,'error'=>'not_found'], 404); json_out(['ok'=>true,'item'=>$row]); } /* ------------------------- Assets (READ) ------------------------- */ if ($action === 'assets.list') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $st=$pdoTpl->prepare("SELECT id, name, type, mime_type, size_bytes, public_url, updated_at FROM emailtemplate_assets WHERE customer_id=:cid ORDER BY updated_at DESC, id DESC LIMIT 200"); $st->execute([':cid'=>$customerId]); json_out(['ok'=>true,'items'=>$st->fetchAll()]); } /* ------------------------- Editor-Referenzen (Placeholders) ------------------------- */ if ($action === 'template_items.sync') { json_out(['ok'=>true]); } if ($action === 'section_items.sync') { json_out(['ok'=>true]); } /* ------------------------- Render (Fallback) ------------------------- */ if ($action === 'render.preview') { if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500); $in = in_json(); $templateId = (int)($in['template_id'] ?? 0); if ($templateId<=0) json_out(['ok'=>false,'error'=>'template_id_required'], 400); $st = $pdoTpl->prepare("SELECT id, name FROM emailtemplate_templates WHERE id=:id AND customer_id=:cid LIMIT 1"); $st->execute([':id'=>$templateId, ':cid'=>$customerId]); $tpl = $st->fetch(); if (!$tpl) json_out(['ok'=>false,'error'=>'not_found'], 404); $html = "\n
Preview okay.
"; json_out(['ok'=>true, 'template'=>$tpl, 'html'=>$html]); } /* ------------------------- Fallback ------------------------- */ json_out(['ok'=>false,'error'=>'unknown_action','action'=>$action], 404);