Files
emailtemplate.it/public/api.php.txt
2025-12-04 22:33:05 +01:00

493 lines
22 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// api.php — Multi-User API mit Legacy-Kompatibilität
// Stand: 2025-09-05
declare(strict_types=1);
header('X-API-Version: 2025-09-05');
/* ------------------------- Fehler als JSON ------------------------- */
set_error_handler(static function ($severity, $message, $file, $line) {
throw new ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(static function (Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => 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 = "<!-- preview {$tpl['name']} (#{$tpl['id']}) -->\n<div style=\"padding:16px;font:14px/1.4 system-ui\">Preview okay.</div>";
json_out(['ok'=>true, 'template'=>$tpl, 'html'=>$html]);
}
/* ------------------------- Fallback ------------------------- */
json_out(['ok'=>false,'error'=>'unknown_action','action'=>$action], 404);