Connector Open-Source Code

Browse connector files locally. Exchange API keys stay on your device.

ui/common.php

<?php
// /opt/nuxvision_connector/ui/common.php
declare(strict_types=1);

if (session_status() !== PHP_SESSION_ACTIVE) {
    $sessionLifetime = 172800; // 2 days
    ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
    session_set_cookie_params([
        'lifetime' => $sessionLifetime,
        'path' => '/',
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
    session_start();
}

/* =========================================================
   PHP ERROR DISPLAY (DEV MODE)
   ========================================================= */
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
error_reporting(E_ALL);

/* -------------------- paths / constants -------------------- */
$UI_DIR = __DIR__;
$BASE_DIR = realpath(__DIR__ . '/..') ?: (__DIR__ . '/..');
$INSTANCES_DIR = $BASE_DIR . '/instances';

/* -------------------- local auth -------------------- */
function nv_ui_auth_default_password(): string {
    return 'nuxvision';
}

function nv_ui_auth_file_path(): string {
    global $INSTANCES_DIR;
    return rtrim((string)$INSTANCES_DIR, '/') . '/_ui_auth.php';
}

function nv_ui_auth_load_hash(): string {
    $path = nv_ui_auth_file_path();
    if (!is_file($path)) return '';

    $data = @require $path;
    if (!is_array($data)) return '';

    $hash = trim((string)($data['password_hash'] ?? ''));
    if ($hash === '') return '';

    $info = password_get_info($hash);
    if (empty($info['algo'])) return '';
    return $hash;
}

function nv_ui_auth_verify(string $password): bool {
    $hash = nv_ui_auth_load_hash();
    if ($hash !== '') {
        return password_verify($password, $hash);
    }
    return hash_equals(nv_ui_auth_default_password(), $password);
}

function nv_ui_auth_save_password(string $password, ?string &$error = null): bool {
    $password = trim($password);
    if ($password === '') {
        $error = 'Password cannot be empty.';
        return false;
    }

    $hash = password_hash($password, PASSWORD_DEFAULT);
    if (!is_string($hash) || $hash === '') {
        $error = 'Failed to generate password hash.';
        return false;
    }

    $authFile = nv_ui_auth_file_path();
    $authDir = dirname($authFile);
    if (!is_dir($authDir) && !@mkdir($authDir, 0775, true)) {
        $error = 'Failed to create auth directory.';
        return false;
    }

    $payload = "<?php\n";
    $payload .= "// generated by ui/common.php\n";
    $payload .= "return [\n";
    $payload .= "    'password_hash' => " . var_export($hash, true) . ",\n";
    $payload .= "    'updated_at' => " . var_export(date('c'), true) . ",\n";
    $payload .= "];\n";

    $tmp = $authFile . '.tmp.' . getmypid() . '.' . mt_rand(1000, 9999);
    if (@file_put_contents($tmp, $payload, LOCK_EX) === false) {
        $error = 'Failed to write auth file.';
        return false;
    }
    if (!@rename($tmp, $authFile)) {
        @unlink($tmp);
        $error = 'Failed to replace auth file.';
        return false;
    }

    @chmod($authFile, 0640);
    $error = null;
    return true;
}

function nv_ui_is_logged_in(): bool {
    return !empty($_SESSION['nv_ui_auth_ok']);
}

function nv_ui_mark_logged_in(): void {
    @session_regenerate_id(true);
    $_SESSION['nv_ui_auth_ok'] = 1;
}

function nv_ui_logout(): void {
    unset($_SESSION['nv_ui_auth_ok']);
}

function nv_ui_require_login(): void {
    $self = strtolower((string)basename((string)($_SERVER['SCRIPT_NAME'] ?? '')));
    if (in_array($self, ['login.php', 'forgot_password.php'], true)) return;
    if (nv_ui_is_logged_in()) return;
    header('Location: /login.php');
    exit;
}

nv_ui_require_login();

/* -------------------- HTML escaping -------------------- */
function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc($s): string { return h($s); }

/* -------------------- misc helpers -------------------- */
function to_int($v, int $fallback = 0): int {
    if (!is_numeric($v)) return $fallback;
    return (int)$v;
}

function to_float($v, float $fallback = 0.0): float {
    if (!is_numeric($v)) return $fallback;
    return (float)$v;
}

function clamp_int($v, int $min, int $max, int $fallback): int {
    if (!is_numeric($v)) return $fallback;
    $n = (int)$v;
    if ($n < $min) return $min;
    if ($n > $max) return $max;
    return $n;
}

function clamp_float($v, float $min, float $max, float $fallback): float {
    if (!is_numeric($v)) return $fallback;
    $n = (float)$v;
    if ($n < $min) return $min;
    if ($n > $max) return $max;
    return $n;
}

function is_valid_url(string $url): bool {
    $url = trim($url);
    if ($url === '') return false;
    return (bool)filter_var($url, FILTER_VALIDATE_URL);
}

function v(array $arr, string $path, $default = null) {
    $cur = $arr;
    foreach (explode('.', $path) as $k) {
        if (!is_array($cur) || !array_key_exists($k, $cur)) return $default;
        $cur = $cur[$k];
    }
    return $cur;
}

/* -------------------- wizard session -------------------- */
function wiz_get(): array {
    $w = $_SESSION['wiz'] ?? [];
    return is_array($w) ? $w : [];
}

function wiz_set(array $w): void {
    $_SESSION['wiz'] = $w;
}

/* expose $wiz as a convenience for pages that expect it */
$wiz = wiz_get();

/* -------------------- instance selection in session -------------------- */
function nv_instance_id_from_session(): int {
    $id = $_SESSION['instance_id'] ?? 0;
    $id = is_numeric($id) ? (int)$id : 0;
    return $id > 0 ? $id : 0;
}

function nv_set_instance_in_session(int $instanceId): void {
    $_SESSION['instance_id'] = $instanceId > 0 ? $instanceId : 0;
}

/* -------------------- flash -------------------- */
function nv_flash_set(string $type, string $msg): void {
    nv_toast_set($type, $msg);
}

function nv_flash_get(): ?array {
    return null;
}

function nv_toast_normalize_type(string $type): string {
    $t = strtolower(trim($type));
    if (in_array($t, ['ok', 'success'], true)) return 'success';
    if (in_array($t, ['err', 'error', 'danger'], true)) return 'danger';
    if (in_array($t, ['warn', 'warning'], true)) return 'warning';
    return 'info';
}

function nv_toast_set(string $type, string $msg, int $durationMs = 5000): void {
    $msg = trim($msg);
    if ($msg === '') return;

    $durationMs = max(1500, min(12000, $durationMs));
    $toast = [
        'type' => nv_toast_normalize_type($type),
        'msg' => $msg,
        'duration' => $durationMs,
    ];

    if (!isset($_SESSION['nv_toasts']) || !is_array($_SESSION['nv_toasts'])) {
        $_SESSION['nv_toasts'] = [];
    }
    $_SESSION['nv_toasts'][] = $toast;
}

function nv_toast_take_all(): array {
    $toasts = $_SESSION['nv_toasts'] ?? [];
    unset($_SESSION['nv_toasts']);
    return is_array($toasts) ? array_values($toasts) : [];
}

function nv_toast_collect_legacy(): void {
    $legacy = $_SESSION['inst_toast'] ?? null;
    if (!is_array($legacy)) return;

    $msg = trim((string)($legacy['msg'] ?? ''));
    if ($msg !== '') {
        nv_toast_set((string)($legacy['type'] ?? 'info'), $msg, 5000);
    }
    unset($_SESSION['inst_toast']);
}

function render_flash(): void {
    // Kept for backward compatibility: flashes now render as toasts in footer.
}

/* -------------------- UI chrome -------------------- */
function nv_current_step_from_subtitle(string $subtitle): int {
    if (preg_match('~Step\s+(\d+)~i', $subtitle, $m)) {
        return max(1, min(3, (int)$m[1]));
    }
    return 1;
}

function render_stepper(int $activeStep): void {
    $steps = [
        ['label' => 'NuxVision', 'href' => './nuxvision.php'],
        ['label' => 'Exchange',  'href' => './exchange.php'],
        ['label' => 'Finish',    'href' => './finish.php'],
    ];
    ?>
    <div class="nv-card">
        <div class="nv-titlebar">
            <strong>Setup steps</strong>
            <span class="nv-muted">Step <?=h((string)$activeStep)?>/3</span>
        </div>
        <div class="nv-pad">
            <div class="row g-2">
                <?php foreach ($steps as $i => $st):
                    $n = $i + 1;
                    $cls = 'nv-step';
                    if ($n < $activeStep) $cls .= ' done';
                    if ($n === $activeStep) $cls .= ' active';
                ?>
                    <div class="col-12 col-md-4">
                        <div class="<?=h($cls)?>">
                            <span class="dot"><?=h((string)$n)?></span>
                            <div>
                                <?php if ($n <= $activeStep): ?>
                                    <a href="<?=h($st['href'])?>"><?=h($st['label'])?></a>
                                <?php else: ?>
                                    <span class="nv-muted2"><?=h($st['label'])?></span>
                                <?php endif; ?>
                            </div>
                        </div>
                    </div>
                <?php endforeach; ?>
            </div>

            <div class="nv-muted small mt-2">
                Trading settings are managed on NuxVision. The connector fetches settings via API at runtime.
            </div>
        </div>
    </div>
    <?php
}

function render_header(string $title, string $subtitle = '', bool $showStepper = true, bool $fluid = false): void {
    nv_toast_collect_legacy();
    $step = nv_current_step_from_subtitle($subtitle);
    ?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title><?=h($title)?></title>
    <link href="/css/bootstrap.min.css" rel="stylesheet">
    <link href="/css/bootstrap-icons/bootstrap-icons.css" rel="stylesheet">
    <style>
        :root{
            --bg:#070a16;
            --bg-2:#0e152c;
            --card:#11182d;
            --card-2:#0f1629;
            --text:#e9eefc;
            --muted:#a8b4d4;
            --muted-2:#95a1c0;
            --line:rgba(156,182,255,.22);
            --accent:#2f7bff;
            --accent-2:#1f5cd3;
            --ok:#22b07d;
            --warn:#cf9c45;
            --danger:#cf4d62;
            --field-bg:rgba(8,14,30,.72);
            --field-bd:rgba(156,182,255,.22);
            --field-bd-focus:rgba(75,134,255,.78);
            --ring:rgba(47,123,255,.30);
        }
        *{box-sizing:border-box}
        body{
            background:
                radial-gradient(1200px 680px at 5% -10%, rgba(69,108,255,.22), transparent 60%),
                radial-gradient(980px 540px at 100% 20%, rgba(35,95,220,.24), transparent 58%),
                linear-gradient(180deg, #070a16 0%, #050813 100%);
            color:var(--text);
            min-height:100vh;
            font-family:"Avenir Next","Segoe UI Variable","Segoe UI",sans-serif;
            letter-spacing:.01em;
        }
        .nv-card{
            background:linear-gradient(130deg, rgba(16,26,50,.86) 0%, rgba(13,19,36,.92) 100%);
            border:1px solid var(--line);
            border-radius:16px;
            box-shadow:0 14px 40px rgba(0,0,0,.36), inset 0 1px 0 rgba(255,255,255,.05);
        }
        .nv-muted{color:var(--muted)!important}
        .nv-muted2{color:var(--muted-2)!important}
        .nv-titlebar{
            display:flex;
            align-items:center;
            justify-content:space-between;
            border-bottom:1px solid var(--line);
            padding:.78rem 1rem
        }
        .nv-pad{padding:1rem}
        .form-control,.form-select{
            background:var(--field-bg)!important;
            color:#eef3ff!important;
            border:1px solid var(--field-bd)!important;
            border-radius:12px
        }
        .form-control::placeholder{color:#92a1c5!important}
        .form-control:focus,.form-select:focus{
            border-color:var(--field-bd-focus)!important;
            box-shadow:0 0 0 .2rem var(--ring)!important
        }
        .btn{
            border-radius:12px;
            border-width:1px;
            transition:all .2s ease;
        }
        .btn-soft{
            background:rgba(17,28,54,.84);
            border:1px solid var(--line);
            color:#eff3ff
        }
        .btn-soft:hover{
            background:rgba(30,46,84,.96);
            color:#fff;
            border-color:rgba(145,171,245,.45)
        }
        .btn-accent{
            background:linear-gradient(180deg,var(--accent),var(--accent-2));
            color:#fff;
            font-weight:700;
            border:1px solid rgba(125,170,255,.45);
            padding:.58rem 1.1rem;
            box-shadow:0 10px 24px rgba(41,102,231,.35), inset 0 -1px 0 rgba(0,0,0,.28);
        }
        .btn-accent:hover{filter:brightness(1.08);transform:translateY(-1px)}
        .nv-badge{
            display:inline-flex;
            align-items:center;
            gap:.45rem;
            background:rgba(45,99,210,.2);
            border:1px solid rgba(110,151,247,.42);
            color:#d8e7ff;
            border-radius:999px;
            padding:.28rem .62rem;
            font-size:.85rem
        }
        .nv-step{
            display:flex;
            align-items:center;
            gap:.62rem;
            padding:.58rem .75rem;
            border-radius:14px;
            border:1px solid var(--line);
            background:rgba(255,255,255,.02)
        }
        .nv-step .dot{
            width:29px;
            height:29px;
            border-radius:999px;
            display:inline-flex;
            align-items:center;
            justify-content:center;
            border:1px solid rgba(157,185,255,.35);
            background:rgba(10,16,34,.9);
            color:#f4f7ff;
            font-weight:700
        }
        .nv-step.active{
            border-color:rgba(91,148,255,.55);
            background:rgba(34,75,170,.18)
        }
        .nv-step.done{
            border-color:rgba(59,181,129,.35);
            background:rgba(41,147,104,.14)
        }
        .nv-step a{color:inherit;text-decoration:none}
        .nv-step a:hover{text-decoration:underline}
        hr.nv-soft{border-color:var(--line)}
        .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;}
        .tile{
            border:1px solid var(--line);
            border-radius:16px;
            background:linear-gradient(140deg, rgba(18,26,49,.8), rgba(13,19,36,.9));
            padding:16px
        }
        .smallhint{font-size:.9rem;color:var(--muted)}
        .badge-soft{
            background:rgba(38,50,84,.76)!important;
            border:1px solid rgba(146,170,240,.26)!important
        }
        .howitworks-box{
            border:1px solid var(--line);
            border-radius:14px;
            background:rgba(13,22,44,.58);
            padding:14px
        }
        .howitworks-link{color:#d9e6ff;text-decoration:none}
        .howitworks-link:hover{text-decoration:underline}
        details summary{list-style:none}
        details summary::-webkit-details-marker{display:none}
        .nv-account-actions{
            display:flex;
            justify-content:flex-end;
            gap:.5rem;
            margin-bottom:.8rem;
        }
        .nv-account-actions .btn{
            padding:.35rem .72rem;
            font-size:.86rem;
        }
        #nv-toast-stack{
            position:fixed;
            right:20px;
            top:20px;
            z-index:1080;
            display:flex;
            flex-direction:column;
            gap:12px;
            pointer-events:none;
            max-width:min(520px, calc(100vw - 24px));
        }
        .nv-toast{
            pointer-events:auto;
            border-radius:16px;
            border:2px solid rgba(170,200,255,.55);
            background:linear-gradient(155deg, rgba(26,44,83,.98), rgba(16,28,56,.98));
            color:#f6f9ff;
            box-shadow:0 18px 42px rgba(0,0,0,.48), inset 0 1px 0 rgba(255,255,255,.14);
            padding:.9rem 1rem;
            transform:translateY(-10px) scale(.98);
            opacity:0;
            transition:opacity .24s ease, transform .24s ease;
            backdrop-filter:blur(2px);
        }
        .nv-toast.show{opacity:1;transform:translateY(0) scale(1)}
        .nv-toast .rowx{display:flex;gap:.7rem;align-items:flex-start}
        .nv-toast .icon{
            width:28px;height:28px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;
            background:rgba(255,255,255,.16);flex:0 0 28px;font-size:1rem;
        }
        .nv-toast .msg{font-size:1rem;line-height:1.35;font-weight:600}
        .nv-toast.success{
            border-color:rgba(100,231,172,.75);
            box-shadow:0 18px 42px rgba(0,0,0,.48), 0 0 0 1px rgba(104,235,176,.22), inset 0 1px 0 rgba(255,255,255,.14);
        }
        .nv-toast.success .icon{background:rgba(55,198,131,.3);color:#8ff1c4}
        .nv-toast.danger{
            border-color:rgba(255,130,151,.82);
            box-shadow:0 18px 42px rgba(0,0,0,.48), 0 0 0 1px rgba(255,122,145,.22), inset 0 1px 0 rgba(255,255,255,.14);
        }
        .nv-toast.danger .icon{background:rgba(234,92,118,.33);color:#ffc0cc}
        .nv-toast.warning{
            border-color:rgba(255,198,101,.82);
            box-shadow:0 18px 42px rgba(0,0,0,.48), 0 0 0 1px rgba(255,198,101,.2), inset 0 1px 0 rgba(255,255,255,.14);
        }
        .nv-toast.warning .icon{background:rgba(221,166,74,.33);color:#ffe1a8}
    </style>
</head>
<body>
<main class="<?= $fluid ? 'container-fluid' : 'container' ?> my-4">
    <?php if (nv_ui_is_logged_in()): ?>
      <div class="nv-account-actions">
        <a class="btn btn-soft btn-sm" href="/account.php#security"><i class="bi bi-shield-lock me-1"></i>Change password</a>
        <a class="btn btn-soft btn-sm" href="/login.php?action=logout"><i class="bi bi-box-arrow-right me-1"></i>Log out</a>
      </div>
    <?php endif; ?>
    <?php if ($showStepper): ?>
      <?php render_stepper($step); ?>
      <div class="mt-3">
    <?php else: ?>
      <div class="mt-2">
    <?php endif; ?>

<?php
}

function render_footer(): void {
    $toasts = nv_toast_take_all();
    ?>
    </div>
    <div class="mt-4 nv-muted small">
        <hr class="nv-soft">
        <div>NuxVision Connector UI - https://nuxvision.com/</div>
    </div>
</main>
<div id="nv-toast-stack"></div>
<?php if (is_file(__DIR__ . '/js/bootstrap.bundle.min.js')): ?>
<script src="/js/bootstrap.bundle.min.js"></script>
<?php endif; ?>
<script>
(function () {
    var initialToasts = <?=json_encode($toasts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)?>;
    var stack = document.getElementById('nv-toast-stack');
    if (!stack) return;

    var iconByType = {
        success: 'bi-check2-circle',
        danger: 'bi-exclamation-triangle',
        warning: 'bi-exclamation-circle',
        info: 'bi-info-circle'
    };

    function normalizeType(type) {
        if (type === 'success' || type === 'danger' || type === 'warning' || type === 'info') return type;
        return 'info';
    }

    function showToast(type, message, duration) {
        var msg = (message || '').toString().trim();
        if (!msg) return;
        var t = normalizeType((type || '').toString());
        var ms = parseInt(duration, 10);
        if (!Number.isFinite(ms)) ms = 5000;
        if (ms < 1500) ms = 1500;
        if (ms > 12000) ms = 12000;

        var toast = document.createElement('div');
        toast.className = 'nv-toast ' + t;
        toast.innerHTML = '<div class="rowx"><span class="icon"><i class="bi ' + iconByType[t] + '"></i></span><div class="msg"></div></div>';
        toast.querySelector('.msg').textContent = msg;
        stack.appendChild(toast);
        requestAnimationFrame(function () { toast.classList.add('show'); });

        window.setTimeout(function () {
            toast.classList.remove('show');
            window.setTimeout(function () {
                if (toast.parentNode) toast.parentNode.removeChild(toast);
            }, 260);
        }, ms);
    }

    window.nvShowToast = showToast;
    if (Array.isArray(initialToasts)) {
        initialToasts.forEach(function (t) {
            if (!t || typeof t !== 'object') return;
            showToast((t.type || 'info').toString(), (t.msg || '').toString(), t.duration || 5000);
        });
    }
})();
</script>
</body>
</html>
<?php
}

/* -------------------- instances helpers -------------------- */
function nv_instance_dir(int $instanceId): string {
    global $INSTANCES_DIR;
    return rtrim($INSTANCES_DIR, '/') . '/' . $instanceId;
}

function nv_instance_config_path(int $instanceId): string {
    return nv_instance_dir($instanceId) . '/config.php';
}

function nv_instance_default_log_path(int $instanceId): string {
    // runner.php default: instances/<id>/runner.log
    return nv_instance_dir($instanceId) . '/runner.log';
}

function nv_delete_instance_dir(int $instanceId): array {
    $dir = nv_instance_dir($instanceId);
    if (!is_dir($dir)) return ['ok'=>true];

    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );

    foreach ($it as $f) {
        $p = $f->getPathname();
        if ($f->isDir()) @rmdir($p);
        else @unlink($p);
    }

    $ok = @rmdir($dir);
    return $ok ? ['ok'=>true] : ['ok'=>false,'error'=>'rmdir_failed'];
}

/* -------------------- HTTP / curl -------------------- */
function curl_json(string $url, array $headers = [], int $timeout = 6): array {
    $ch = curl_init();
    if (!$ch) return [0, 'curl_init_failed', null, null];

    $hdr = [];
    foreach ($headers as $k => $v) {
        if (is_int($k)) $hdr[] = (string)$v;
        else $hdr[] = (string)$k . ': ' . (string)$v;
    }

    curl_setopt_array($ch, [
        CURLOPT_URL => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_CONNECTTIMEOUT => min(3, $timeout),
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS => 3,
        CURLOPT_HTTPHEADER => $hdr,
        CURLOPT_USERAGENT => 'nuxvision-connector-ui/1.0',
    ]);

    $body = curl_exec($ch);
    $err  = curl_error($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    $json = null;
    if (is_string($body)) {
        $tmp = json_decode($body, true);
        if (is_array($tmp)) $json = $tmp;
    }

    return [$code, $err ?: '', $body, $json];
}

/* -------------------- NuxVision API (UI usage only) -------------------- */
function nv_auth_headers(string $apiKey): array {
    // adapte si ton backend attend un header différent
    return [
        'Accept' => 'application/json',
        'X-API-KEY' => $apiKey,
    ];
}

function nv_fetch_instances(string $baseUrl, string $apiKey, int $timeout = 6): array {
    $baseUrl = rtrim(trim($baseUrl), '/');
    $url = $baseUrl . '/api/nuxvision/v1/instance_list.php';

    [$code, $err, $body, $json] = curl_json($url, nv_auth_headers($apiKey), $timeout);

    if ($err !== '' || $code < 200 || $code >= 400 || !is_array($json)) {
        return ['ok'=>false,'http'=>$code,'curl_error'=>$err,'raw'=>is_string($body)?substr($body,0,200):null];
    }

    // attend { ok: true, instances: [...] } ou { instances: [...] }
    $instances = [];
    if (isset($json['instances']) && is_array($json['instances'])) $instances = $json['instances'];
    elseif (isset($json['data']) && is_array($json['data'])) $instances = $json['data'];

    return ['ok'=>true,'http'=>$code,'instances'=>$instances,'raw'=>null];
}

function nv_heartbeat(string $baseUrl, string $apiKey, int $instanceId, int $timeout = 6): array {
    $baseUrl = rtrim(trim($baseUrl), '/');
    $url = $baseUrl . '/api/nuxvision/v1/heartbeat.php?instance_id=' . urlencode((string)$instanceId);

    [$code, $err, $body, $json] = curl_json($url, nv_auth_headers($apiKey), $timeout);

    if ($err !== '' || $code < 200 || $code >= 400 || !is_array($json)) {
        return ['ok'=>false,'http'=>$code,'curl_error'=>$err,'raw'=>is_string($body)?substr($body,0,200):null];
    }

    $ok = !empty($json['ok']) || (isset($json['status']) && $json['status'] === 'ok');
    return ['ok'=>$ok,'http'=>$code,'json'=>$json,'raw'=>null];
}