#!/usr/bin/env php
<?php
/* ============================================================
   alpha-drawdb — self-host drawDB (drawdb.app, MIT) for the panel's add-on.

   drawDB is a client-side React app. This helper clones it, builds it with the
   base path /drawdb/, publishes the static build under alpha-private and gives
   the ADMIN vhost one location for it: served by nginx straight from disk, but
   only behind the panel session (auth_request → the panel's own check). Same
   origin as the panel, so the add-on screen can read the editor's local store.

   ⚠️ ROOT-OWNED AND SELF-CONTAINED. Never includes anything from /var/www/alpha.
   ⚠️ The build runs as www-data (npm executes package scripts; root must not).
   ⚠️ nginx is only ever reloaded after `nginx -t` passes; a bad vhost edit is
      rolled back from its backup.

     status            installed / version / built / nginx state
     install           clone + npm ci + build + publish + nginx (background job)
     update            pull + rebuild (background job)
     job <id>          state + log tail of a job
     nginx             (re)install the location snippet + vhost include, reload
     remove            drop the build and the location (diagrams are kept)

   State: /var/lib/alpha-drawdb/jobs/<id>/{meta.json,log,rc,pid}
   Tree:  /var/www/alpha-private/drawdb/{src,dist,home}  (www-data)
   ============================================================ */

const ROOT    = '/var/www/alpha-private/drawdb';
const STATE   = '/var/lib/alpha-drawdb';
const REPO    = 'https://github.com/drawdb-io/drawdb';
const BASE    = '/drawdb/';
const SNIPDIR = '/etc/nginx/snippets/alpha-admin.d';
const SNIPPET = SNIPDIR . '/drawdb.conf';
const MAX_JOBS = 20;

function fail($m, $c = 2) { fwrite(STDERR, $m . "\n"); exit($c); }
function out($a) { echo json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n"; exit(0); }
function sh($cmd) { $o = []; $rc = 1; @exec($cmd . ' 2>&1', $o, $rc); return [implode("\n", $o), $rc]; }
function bin($n) { [$o, $rc] = sh('command -v ' . escapeshellarg($n)); return $rc === 0 ? trim($o) : ''; }
function ensure_state() { foreach ([STATE, STATE . '/jobs'] as $d) if (!is_dir($d)) { @mkdir($d, 0700, true); @chmod($d, 0700); } }
function meta($d) { $m = @json_decode((string) @file_get_contents("$d/meta.json"), true); return is_array($m) ? $m : []; }
function job_state($d) {
  $rc = is_file("$d/rc") ? (int) trim((string) @file_get_contents("$d/rc")) : null;
  $pid = (int) trim((string) @file_get_contents("$d/pid"));
  if ($rc === null && $pid > 0 && @file_exists("/proc/$pid")) return ['running', null];
  return $rc === null ? ['lost', null] : ['done', $rc];
}
function job_dir($id) { if (!preg_match('/^[a-z0-9]{8,24}$/', (string) $id)) fail('bad job id'); $d = STATE . '/jobs/' . $id; if (!is_dir($d)) fail('no such job'); return $d; }
function job_view($d) {
  $m = meta($d); [$st, $rc] = job_state($d);
  $raw = is_file("$d/log") ? (string) @file_get_contents("$d/log") : '';
  $lines = explode("\n", rtrim($raw)); $tail = implode("\n", array_slice($lines, -50)); if (strlen($tail) > 8192) $tail = substr($tail, -8192);
  return $m + ['state' => $st, 'rc' => $rc, 'elapsed' => max(0, time() - (int) ($m['started'] ?? time())), 'tail' => $tail, 'ended' => is_file("$d/rc") ? (int) @filemtime("$d/rc") : 0];
}
function job_start($kind, $cmd) {
  ensure_state();
  foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); if ($st === 'running') { $m = meta($d); out(['ok' => true, 'job' => $m['id'], 'already' => true]); } }
  $id = date('ymdHis') . substr(bin2hex(random_bytes(3)), 0, 6);
  $d = STATE . '/jobs/' . $id; @mkdir($d, 0700, true);
  file_put_contents("$d/meta.json", json_encode(['id' => $id, 'kind' => $kind, 'started' => time()]));
  /* subshell: the whole body logs to the job log and its exit code lands in rc */
  $inner = 'echo $$ > ' . escapeshellarg("$d/pid") . '; ( ' . $cmd . ' ) > ' . escapeshellarg("$d/log") . ' 2>&1; echo $? > ' . escapeshellarg("$d/rc");
  exec('setsid nohup bash -c ' . escapeshellarg($inner) . ' > /dev/null 2>&1 &');
  $dirs = glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: []; rsort($dirs);
  foreach (array_slice($dirs, MAX_JOBS) as $old) { foreach (glob("$old/*") ?: [] as $f) @unlink($f); @rmdir($old); }
  return $id;
}

/* ---------- nginx: one location on the admin vhost ---------- */
function admin_vhost() { [$o] = sh('grep -ls "__auth_check" /etc/nginx/sites-enabled/* 2>/dev/null | head -1'); return trim($o); }
function snippet_text() {
  return "# drawDB (Addons/drawdb): the schema editor's static build, served from disk on the ADMIN host only,\n"
       . "# behind the panel session. Written by /usr/local/sbin/alpha-drawdb — do not hand-edit.\n"
       . "location ^~ " . BASE . " {\n"
       . "    auth_request /__auth_check;\n"
       . "    error_page 401 = @applogin;\n"
       . "    alias " . ROOT . "/dist/;\n"
       . "    index index.html;\n"
       // `$uri/index.html`, never `$uri/`: with alias, a bare directory match answered /drawdb/ with 403 before the SPA fallback
       . "    try_files \$uri \$uri/index.html " . BASE . "index.html;\n"
       // add_header in a location REPLACES the server-level set, so the server's own headers are repeated here
       . "    add_header Strict-Transport-Security \"max-age=15768000\" always;\n"
       . "    add_header X-Frame-Options SAMEORIGIN always;\n"
       . "    add_header X-Content-Type-Options nosniff always;\n"
       . "    add_header Referrer-Policy same-origin always;\n"
       . "}\n";
}
function nginx_install() {
  $vh = admin_vhost();
  if ($vh === '') return ['ok' => false, 'msg' => 'could not find the admin vhost (the one with /__auth_check)'];
  if (!is_dir(SNIPDIR)) { @mkdir(SNIPDIR, 0755, true); @chmod(SNIPDIR, 0755); }
  $changed = !is_file(SNIPPET) || file_get_contents(SNIPPET) !== snippet_text();
  if ($changed) { file_put_contents(SNIPPET, snippet_text()); @chmod(SNIPPET, 0644); }
  $conf = file_get_contents($vh); $bak = '';
  if (strpos($conf, 'snippets/alpha-admin.d/') === false) {
    /* the include goes just before the panel's catch-all in the 443 block; the 80 block's `location /` is a redirect, not this line */
    $anchor = "    location / { try_files";
    if (strpos($conf, $anchor) === false) return ['ok' => false, 'msg' => 'the admin vhost has no recognisable catch-all location to anchor the include on'];
    $bak = $vh . '.bak-' . date('Ymd-His'); copy($vh, $bak);
    $conf = preg_replace('/^    location \/ \{ try_files/m', "    include snippets/alpha-admin.d/*.conf;\n    location / { try_files", $conf, 1);
    file_put_contents($vh, $conf); $changed = true;
  }
  [$o, $rc] = sh('nginx -t');
  if ($rc !== 0) { if ($bak !== '') copy($bak, $vh); @unlink(SNIPPET); return ['ok' => false, 'msg' => 'nginx -t failed, change rolled back: ' . $o]; }
  if ($changed) sh('systemctl reload nginx');
  return ['ok' => true, 'changed' => $changed, 'vhost' => $vh];
}

ensure_state();
$cmd = (string) ($_SERVER['argv'][1] ?? '');
switch ($cmd) {
  case 'status': {
    $dist = is_file(ROOT . '/dist/index.html');
    $ver = ''; $date = '';
    if (is_dir(ROOT . '/src/.git')) { [$o] = sh('git -C ' . escapeshellarg(ROOT . '/src') . ' log -1 --format=%h/%cs'); if (strpos($o, '/') !== false) [$ver, $date] = explode('/', trim($o), 2); }
    $vh = admin_vhost();
    $running = null; foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); if ($st === 'running') { $m = meta($d); $running = ['id' => $m['id'], 'kind' => $m['kind'], 'started' => $m['started']]; } }
    [$du] = sh('du -sm ' . escapeshellarg(ROOT . '/dist') . ' 2>/dev/null | cut -f1');
    out(['installed' => $dist, 'commit' => $ver, 'commit_date' => $date, 'built' => $dist ? (int) filemtime(ROOT . '/dist/index.html') : 0, 'dist_mb' => (int) trim($du),
         'src' => is_dir(ROOT . '/src/.git'), 'node' => trim(sh('node -v')[0]), 'nginx_snippet' => is_file(SNIPPET),
         'nginx_included' => $vh !== '' && strpos((string) @file_get_contents($vh), 'snippets/alpha-admin.d/') !== false, 'base' => BASE, 'running' => $running]);
  }
  case 'install': case 'update': {
    if (bin('node') === '' || bin('npm') === '') fail('node and npm are required (node 20+)');
    if (bin('git') === '') fail('git is required');
    $root = ROOT; $src = ROOT . '/src'; $home = ROOT . '/home';
    $nodeDir = dirname(realpath(bin('node')));
    /* the whole tree becomes www-data's: the build runs as www-data, and a source tree left behind by a root-run clone must not stop it */
    $pre = 'set -e; mkdir -p ' . escapeshellarg($root) . ' ' . escapeshellarg($home) . '; chown -R www-data:www-data ' . escapeshellarg($root) . '; '
         . 'W="sudo -u www-data env HOME=' . escapeshellarg($home) . ' PATH=' . escapeshellarg($nodeDir . ':/usr/local/bin:/usr/bin:/bin') . '"; ';
    $clone = $cmd === 'install'
      ? 'if [ ! -d ' . escapeshellarg($src . '/.git') . ' ]; then echo "== clone"; $W git clone --depth 1 ' . escapeshellarg(REPO) . ' ' . escapeshellarg($src) . '; else echo "== source present"; fi; '
      : 'echo "== pull"; cd ' . escapeshellarg($src) . ' && $W git checkout -q -- src/App.jsx && $W git pull --ff-only; ';
    $body = $pre . $clone
      . 'cd ' . escapeshellarg($src) . '; '
      . 'echo "== route base ' . BASE . '"; grep -q "basename=\"/drawdb\"" src/App.jsx || $W sed -i "s#<BrowserRouter>#<BrowserRouter basename=\"/drawdb\">#" src/App.jsx; grep -c "basename=\"/drawdb\"" src/App.jsx; '
      . 'echo "== npm ci"; $W npm ci --no-audit --no-fund --loglevel=error; '
      . 'echo "== vite build"; $W npx vite build --base=' . escapeshellarg(BASE) . ' --logLevel warn; '
      . 'echo "== publish"; rm -rf ' . escapeshellarg($root . '/dist.new') . '; cp -a dist ' . escapeshellarg($root . '/dist.new') . '; rm -rf ' . escapeshellarg($root . '/dist.old') . '; [ -d ' . escapeshellarg($root . '/dist') . ' ] && mv ' . escapeshellarg($root . '/dist') . ' ' . escapeshellarg($root . '/dist.old') . '; mv ' . escapeshellarg($root . '/dist.new') . ' ' . escapeshellarg($root . '/dist') . '; rm -rf ' . escapeshellarg($root . '/dist.old') . '; chown -R www-data:www-data ' . escapeshellarg($root . '/dist') . '; '
      . 'echo "== nginx"; /usr/local/sbin/alpha-drawdb nginx; echo "== done: $(git -C ' . escapeshellarg($src) . ' log -1 --format=%h)"';
    out(['ok' => true, 'job' => job_start($cmd, $body)]);
  }
  case 'job':   out(['ok' => true] + job_view(job_dir((string) ($_SERVER['argv'][2] ?? ''))));
  case 'nginx': { $r = nginx_install(); if (empty($r['ok'])) fail($r['msg']); out($r); }
  case 'remove': {
    sh('rm -rf ' . escapeshellarg(ROOT . '/dist') . ' ' . escapeshellarg(ROOT . '/src') . ' ' . escapeshellarg(ROOT . '/home'));
    @unlink(SNIPPET);
    [$o, $rc] = sh('nginx -t'); if ($rc === 0) sh('systemctl reload nginx');
    out(['ok' => true, 'nginx' => $rc === 0]);
  }
  default: fail('usage: alpha-drawdb status|install|update|job <id>|nginx|remove');
}
