From 546146ed4e7c273eb6c958530822ebe01c9d9aed Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Fri, 5 Dec 2025 01:17:28 +0100 Subject: [PATCH] codex update --- config/domaindata.php | 4 +- config/emailtemplate.conf.php | 110 ++++++ config/fileload.php | 5 + config/prod/config.php | 24 +- config/prod/db.php | 6 +- config/staging/config.php | 22 +- config/staging/db.php | 6 +- inc/ApiKernel.php | 655 +-------------------------------- inc/AuthService.php | 103 +----- inc/config.php | 77 +--- public/api.php | 2 +- public/tools/db-doctor.php | 19 +- src/ApiKernel.php | 659 ++++++++++++++++++++++++++++++++++ src/AuthService.php | 105 ++++++ 14 files changed, 924 insertions(+), 873 deletions(-) create mode 100644 config/emailtemplate.conf.php create mode 100644 src/ApiKernel.php create mode 100644 src/AuthService.php diff --git a/config/domaindata.php b/config/domaindata.php index fe8f705..ae112d5 100644 --- a/config/domaindata.php +++ b/config/domaindata.php @@ -1,4 +1,4 @@ getenv('DB_HOST') ?: getenv('DB_TPL_HOST') ?: 'localhost', + 'db_name' => getenv('DB_NAME') ?: getenv('DB_TPL_NAME') ?: 'd044ae9e', + 'db_user' => getenv('DB_USER') ?: getenv('DB_TPL_USER') ?: 'd044ae9e', + 'db_pass' => getenv('DB_PASS') ?: getenv('DB_TPL_PASS') ?: '9BVUn)Töcü@ÖVÜfgO8!J', + 'db_charset' => getenv('DB_CHARSET') ?: 'utf8mb4', + 'db_port' => (int)(getenv('DB_PORT') ?: 3306), + 'prefix' => defined('APP_PREFIX') ? APP_PREFIX . '_' : 'emailtemplate_', +]; + +$legacyOverrides = []; +if (isset($overrides['project'])) { + $legacyOverrides = $overrides['project']; +} elseif (isset($overrides['templates'])) { + $legacyOverrides = $overrides['templates']; +} +$projectDb = array_replace($projectDbDefaults, $legacyOverrides, $overrides['projectdb'] ?? $overrides['dbsettings'] ?? []); + +$cors = $overrides['cors'] ?? (getenv('CORS_ORIGIN') ?: '*'); + +$authDefaults = [ + 'session_name' => $sessionNameDefault, + 'cookie_domain' => $cookieDomainDefault, + 'cookie_secure' => $env === 'prod', + 'cookie_httponly' => true, + 'cookie_samesite' => 'Lax', + 'cookie' => [ + 'domain' => $cookieDomainDefault, + 'secure' => $env === 'prod', + 'httponly' => true, + 'samesite' => 'Lax', + ], + 'db' => [ + 'table' => 'customer_users', + 'col_user' => 'email', + 'col_pass' => 'password_hash', + 'col_name' => 'name', + 'col_id' => 'id', + 'col_status' => 'is_active', + 'active_values' => ['active','1',1], + 'legacy' => 'md5', + ], +]; +$auth = array_replace_recursive($authDefaults, $overrides['auth'] ?? []); + +$smtpDefaults = [ + 'host' => 'smtp.example.com', + 'port' => 587, + 'user' => 'smtp-user', + 'pass' => 'smtp-pass', + 'secure' => 'tls', + 'from_email' => 'no-reply@example.com', + 'from_name' => 'EmailTemplate', +]; +$smtp = array_replace($smtpDefaults, $overrides['smtp'] ?? []); + +$exportDefaults = [ + 'api_keys' => ['dev-key-123', 'noch-ein-key'], +]; +$export = array_replace_recursive($exportDefaults, $overrides['export'] ?? []); + +$multiDefaults = [ + 'tenant_col' => 'customer_id', + 'map_session_to'=> 'id', +]; +$multi = array_replace($multiDefaults, $overrides['multi'] ?? []); + +$tablesDefaults = [ + 'templates' => 'emailtemplate_templates', + 'sections' => 'emailtemplate_sections', + 'blocks' => 'emailtemplate_blocks', + 'snippets' => 'emailtemplate_snippets', +]; +$tables = array_replace($tablesDefaults, $overrides['tables'] ?? []); + +$columnsDefaults = [ + 'templates' => ['id'=>'id','name'=>'name','desc'=>null,'cat'=>null,'upd'=>'updated_at'], + 'sections' => ['id'=>'id','name'=>'name','cat'=>null,'upd'=>'updated_at'], + 'blocks' => ['id'=>'id','name'=>'name','cat'=>'category','upd'=>'updated_at'], + 'snippets' => ['id'=>'id','name'=>'name','cat'=>'category','upd'=>'updated_at'], +]; +$columns = array_replace_recursive($columnsDefaults, $overrides['columns'] ?? []); + +return [ + 'projectdb' => $projectDb, + 'cors' => $cors, + 'env' => $env, + 'base_url' => $baseUrl, + 'auth' => $auth, + 'smtp' => $smtp, + 'export' => $export, + 'multi' => $multi, + 'tables' => $tables, + 'columns' => $columns, +]; diff --git a/config/fileload.php b/config/fileload.php index ca28ca7..78807df 100644 --- a/config/fileload.php +++ b/config/fileload.php @@ -7,6 +7,11 @@ // ----------------------------------------------------------- require_once __DIR__ . "/config.php"; +$emailtemplateConfigPath = __DIR__ . '/emailtemplate.conf.php'; +if (is_file($emailtemplateConfigPath)) { + $GLOBALS['emailtemplate_config'] = require $emailtemplateConfigPath; +} + // Diese Werte später ins Template schieben: $GLOBALS['app_env'] = APP_ENV; diff --git a/config/prod/config.php b/config/prod/config.php index b11bace..30607e3 100644 --- a/config/prod/config.php +++ b/config/prod/config.php @@ -1,16 +1,20 @@ PDO::ERRMODE_EXCEPTION, diff --git a/config/staging/config.php b/config/staging/config.php index e2ea33f..4e4d4d0 100644 --- a/config/staging/config.php +++ b/config/staging/config.php @@ -1,21 +1,24 @@ PDO::ERRMODE_EXCEPTION, diff --git a/inc/ApiKernel.php b/inc/ApiKernel.php index 26cc516..dc5269e 100644 --- a/inc/ApiKernel.php +++ b/inc/ApiKernel.php @@ -1,657 +1,4 @@ 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.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.php', 'config.php 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['templates']) || !is_array($this->conf['templates'])) { - $this->fail('Missing templates DB config', null, 500); - } - $c = $this->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); - } - 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 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 $components): array { - foreach ($components as &$component) { - if (is_array($component) && isset($component['type'])) { - - if ($component['type'] === 'library-reference') { - if (isset($component['content'])) { - $component['content'] = ''; - } - if (isset($component['components'])) { - $component['components'] = []; - } - } - if (isset($component['components']) && is_array($component['components'])) { - $component['components'] = $this->cleanReferenceComponents($component['components']); - } - } - } - return $components; - } - - - // ================================================================= - // 🚀 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); - } - - $this->respond([ - 'ok' => true, - 'kind' => $kind, - 'id' => $rowOut['id'], - 'item' => $rowOut, - 'data' => $rowOut, - 'html' => $topHtml, - 'content' => $topContent, - 'gjs_components' => $gjsComponents - ]); - } - - /** - * 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); - $json = $this->val($this->in, ['content_json', 'json', 'content', 'structure_json'], 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] = json_encode($components, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - } 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 : 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 + $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); - $json = $this->val($this->in, ['content_json', 'json', 'content', 'structure_json'], 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] = json_encode($components, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - } 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 : json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - - $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]); - } - - // ================================================================= - // 💡 Ö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; - - /* ---------- 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); - } - } -} +require_once __DIR__ . '/../src/ApiKernel.php'; diff --git a/inc/AuthService.php b/inc/AuthService.php index f0112d4..1d70eeb 100644 --- a/inc/AuthService.php +++ b/inc/AuthService.php @@ -1,105 +1,4 @@ conf = $conf; - $this->pdo = $pdo; - } - - // --- Private Utility Methoden --- - - private function fail(string $msg, $detail = null, int $code = 400): void - { - // Wir müssen hier direkt antworten, da wir das Fail-Verhalten des Kernels benötigen. - // Im ApiKernel werden wir die respond/fail-Methoden als public lassen, - // um sie hier injizieren zu können, oder wir lassen sie hier im Global Scope - // (WENN Sie die ursprünglichen globalen Funktionen respond/fail wieder zulassen). - // Für eine saubere Kapselung injizieren wir die Respond-Logik. - // HIER verwenden wir eine einfache JSON-Antwort, da die fail-Methode - // normalerweise den gesamten Kernel stoppt. Wir nutzen exit. - - http_response_code($code); - echo json_encode(['ok'=>false,'error'=>$msg,'detail'=>$detail], JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); - exit; - } - - private function verifyPassword(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 (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored); - return hash_equals($stored, $input); - } - - // --- Public Service Methoden --- - - public function requireAuth(): array - { - if (empty($_SESSION['auth'])) $this->fail('Not authenticated', null, 401); - return $_SESSION['auth']; - } - - public function logout(): bool - { - $_SESSION = []; - if (session_id() !== '') session_destroy(); - return true; - } - - public function login(array $in): array - { - $authDb = $this->conf['auth']['db'] ?? []; - $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]; - $table = $authDb['table'] ?? 'emailtemplate_users'; - - $identifier = trim((string)($in['username'] ?? $in['user'] ?? $in['email'] ?? $in['login'] ?? '')); - $password = (string)($in['password'] ?? $in['pass'] ?? $in['pwd'] ?? ''); - - if ($identifier === '' || $password === '') $this->fail('username/password required', null, 422); - - $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `$colUser` = :u LIMIT 1"); - $stmt->execute([':u'=>$identifier]); - $row = $stmt->fetch(); - - if (!$row) $this->fail('Invalid credentials', null, 401); - - if ($colStatus && isset($row[$colStatus])) { - if (!in_array($row[$colStatus], $activeValues, true)) { - $this->fail('Account inactive', null, 403); - } - } - - $stored = (string)($row[$colPass] ?? ''); - if ($stored === '' || !$this->verifyPassword($password, $stored, $authDb)) { - $this->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)); - return ['user'=>$_SESSION['auth'], 'token'=>$token]; - } -} +require_once __DIR__ . '/../src/AuthService.php'; diff --git a/inc/config.php b/inc/config.php index 3f2ed05..35aa48c 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1,76 +1,5 @@ [ - 'db_host' => getenv('DB_TPL_HOST') ?: 'localhost', - 'db_name' => getenv('DB_TPL_NAME') ?: 'd044ae9e', - 'db_user' => getenv('DB_TPL_USER') ?: 'd044ae9e', - 'db_pass' => getenv('DB_TPL_PASS') ?: '9BVUn)Töcü@ÖVÜfgO8!J', - 'db_charset' => 'utf8', - 'prefix' => getenv('DB_TPL_PREFIX') ?: 'emailtemplate_', - - ], - 'project' => [ - 'db_host' => getenv('DB_TPL_HOST') ?: 'w0207fd0.kasserver.com', - 'db_name' => getenv('DB_TPL_NAME') ?: 'd0444c25', - 'db_user' => getenv('DB_TPL_USER') ?: 'd0444c25', - 'db_pass' => getenv('DB_TPL_PASS') ?: '/7ü9+§ÄfkiQvGPr§2Op7', - 'db_charset' => 'utf8', - ], - 'cors' => getenv('CORS_ORIGIN') ?: '*', - 'env' => 'staging', - 'base_url' => 'https://staging.emailtemplate.it', - 'auth' => [ - 'session_name' => 'et_session', - 'cookie_domain' => 'staging.emailtemplate.it', - 'cookie_secure' => true, - 'cookie_httponly'=> true, - 'cookie_samesite'=> 'Lax', - 'db' => [ - 'table' => 'customer_users', - 'col_user' => 'email', // alternativ: 'username' - 'col_pass' => 'password_hash', - 'col_name' => 'name', // optional - 'col_id' => 'id', // optional - 'col_status' => 'is_active', // optional - 'active_values'=> ['active','1',1], // optional - 'legacy' => 'md5' // optional: 'md5' | 'sha1' | 'plain' (sonst bcrypt/argon2) - ], - - ], -'smtp' => [ - 'host' => 'smtp.example.com', - 'port' => 587, - 'user' => 'smtp-user', - 'pass' => 'smtp-pass', - 'secure' => 'tls', // oder 'ssl' - 'from_email' => 'no-reply@example.com', - 'from_name' => 'EmailTemplate', -], -'export' => [ - 'api_keys' => ['dev-key-123', 'noch-ein-key'], // füge hier deine Keys ein -], - 'multi' => [ - // Spalte in ALLEN Content-Tabellen, die dem Besitzer/Mandanten entspricht: - 'tenant_col' => 'customer_id', // <— falls es bei dir z. B. 'owner_id' heißt: entsprechend anpassen. - // Welche Session-Info darauf gemappt wird: - 'map_session_to' => 'id', // 'id' (Default) | 'email' | 'name' - ], - - // optional: abweichende Tabellennamen/Spalten: - 'tables' => [ - 'templates' => 'emailtemplate_templates', - 'sections' => 'emailtemplate_sections', - 'blocks' => 'emailtemplate_blocks', - 'snippets' => 'emailtemplate_snippets', - ], - 'columns' => [ - // Nur anpassen, wenn deine Spaltennamen abweichen - 'templates' => ['id'=>'id','name'=>'name','desc'=>null,'cat'=>null,'upd'=>'updated_at'], - 'sections' => ['id'=>'id','name'=>'name','cat'=>null,'upd'=>'updated_at'], - 'blocks' => ['id'=>'id','name'=>'name','cat'=>'category','upd'=>'updated_at'], - 'snippets' => ['id'=>'id','name'=>'name','cat'=>'category','upd'=>'updated_at'], - ], -]; - +// Legacy stub that keeps existing include paths working. +return require __DIR__ . '/../config/emailtemplate.conf.php'; diff --git a/public/api.php b/public/api.php index 3541a75..7def9f8 100644 --- a/public/api.php +++ b/public/api.php @@ -8,7 +8,7 @@ if (is_file($composerAutoload)) { } // 2. Lade die Service-Klasse (API-Kernel) -require_once __DIR__ . '/../inc/ApiKernel.php'; +require_once __DIR__ . '/../src/ApiKernel.php'; // 3. Erstelle eine Instanz und führe sie aus $api = new ApiKernel(); diff --git a/public/tools/db-doctor.php b/public/tools/db-doctor.php index 8b011c3..5e644cc 100644 --- a/public/tools/db-doctor.php +++ b/public/tools/db-doctor.php @@ -3,13 +3,12 @@ header('Content-Type: text/html; charset=utf-8'); $conf = @include __DIR__ . '/../../inc/config.php'; function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); } -if (!is_array($conf) || !isset($conf['templates'])) { - echo 'Invalid config.php (expected return array with keys templates/project)'; exit; +if (!is_array($conf) || !isset($conf['projectdb'])) { + echo 'Invalid config.php (expected return array with key projectdb)'; exit; } -$profile = $_GET['profile'] ?? 'templates'; -$cfg = ($profile==='project') ? ($conf['project'] ?? null) : $conf['templates']; -$prefix = (string)(($profile==='project') ? ($conf['project']['prefix'] ?? '') : ($conf['templates']['prefix'] ?? '')); +$cfg = $conf['projectdb']; +$prefix = (string)($cfg['prefix'] ?? ''); $attempts=[]; $pdo=null; $mkPdo=function(array $cfg) use(&$attempts){ @@ -57,7 +56,7 @@ if ($pdo){ -DB-Doctor (<?=h($profile)?>) +DB-Doctor -

DB-Doctor (Profil: )

- - +

DB-Doctor

Verbindungsversuche

@@ -115,4 +109,3 @@ if ($pdo){ ], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES))?>
- diff --git a/src/ApiKernel.php b/src/ApiKernel.php new file mode 100644 index 0000000..4209f1c --- /dev/null +++ b/src/ApiKernel.php @@ -0,0 +1,659 @@ +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 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 $components): array { + foreach ($components as &$component) { + if (is_array($component) && isset($component['type'])) { + + if ($component['type'] === 'library-reference') { + if (isset($component['content'])) { + $component['content'] = ''; + } + if (isset($component['components'])) { + $component['components'] = []; + } + } + if (isset($component['components']) && is_array($component['components'])) { + $component['components'] = $this->cleanReferenceComponents($component['components']); + } + } + } + return $components; + } + + + // ================================================================= + // 🚀 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); + } + + $this->respond([ + 'ok' => true, + 'kind' => $kind, + 'id' => $rowOut['id'], + 'item' => $rowOut, + 'data' => $rowOut, + 'html' => $topHtml, + 'content' => $topContent, + 'gjs_components' => $gjsComponents + ]); + } + + /** + * 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); + $json = $this->val($this->in, ['content_json', 'json', 'content', 'structure_json'], 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] = json_encode($components, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } 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 : 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 + $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); + $json = $this->val($this->in, ['content_json', 'json', 'content', 'structure_json'], 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] = json_encode($components, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } 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 : json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $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]); + } + + // ================================================================= + // 💡 Ö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; + + /* ---------- 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); + } + } +} diff --git a/src/AuthService.php b/src/AuthService.php new file mode 100644 index 0000000..f0112d4 --- /dev/null +++ b/src/AuthService.php @@ -0,0 +1,105 @@ +conf = $conf; + $this->pdo = $pdo; + } + + // --- Private Utility Methoden --- + + private function fail(string $msg, $detail = null, int $code = 400): void + { + // Wir müssen hier direkt antworten, da wir das Fail-Verhalten des Kernels benötigen. + // Im ApiKernel werden wir die respond/fail-Methoden als public lassen, + // um sie hier injizieren zu können, oder wir lassen sie hier im Global Scope + // (WENN Sie die ursprünglichen globalen Funktionen respond/fail wieder zulassen). + // Für eine saubere Kapselung injizieren wir die Respond-Logik. + // HIER verwenden wir eine einfache JSON-Antwort, da die fail-Methode + // normalerweise den gesamten Kernel stoppt. Wir nutzen exit. + + http_response_code($code); + echo json_encode(['ok'=>false,'error'=>$msg,'detail'=>$detail], JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + exit; + } + + private function verifyPassword(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 (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored); + return hash_equals($stored, $input); + } + + // --- Public Service Methoden --- + + public function requireAuth(): array + { + if (empty($_SESSION['auth'])) $this->fail('Not authenticated', null, 401); + return $_SESSION['auth']; + } + + public function logout(): bool + { + $_SESSION = []; + if (session_id() !== '') session_destroy(); + return true; + } + + public function login(array $in): array + { + $authDb = $this->conf['auth']['db'] ?? []; + $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]; + $table = $authDb['table'] ?? 'emailtemplate_users'; + + $identifier = trim((string)($in['username'] ?? $in['user'] ?? $in['email'] ?? $in['login'] ?? '')); + $password = (string)($in['password'] ?? $in['pass'] ?? $in['pwd'] ?? ''); + + if ($identifier === '' || $password === '') $this->fail('username/password required', null, 422); + + $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `$colUser` = :u LIMIT 1"); + $stmt->execute([':u'=>$identifier]); + $row = $stmt->fetch(); + + if (!$row) $this->fail('Invalid credentials', null, 401); + + if ($colStatus && isset($row[$colStatus])) { + if (!in_array($row[$colStatus], $activeValues, true)) { + $this->fail('Account inactive', null, 403); + } + } + + $stored = (string)($row[$colPass] ?? ''); + if ($stored === '' || !$this->verifyPassword($password, $stored, $authDb)) { + $this->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)); + return ['user'=>$_SESSION['auth'], 'token'=>$token]; + } +}