#!/usr/bin/env php
<?php
/* ============================================================
   alpha-openpencil — OpenPencil (ZSeven-W/openpencil, MIT) for the panel's add-on.

   OpenPencil is an AI-native vector design tool. Its web edition ships as an
   official image, ghcr.io/zseven-w/openpencil-web: a small Rust web host that
   serves a WebAssembly editor. This helper pulls it, re-tags it as
   alpha/openpencil:<version> with the label alpha.addon=openpencil (so the
   Container Manager shows who owns it and its prune leaves it alone), and runs
   it as alpha-openpencil.service on 127.0.0.1:3093 — memory and CPU capped,
   read-only, no capabilities, uid 33.

   ⚠️ It cannot live under a path of the admin host: the editor calls absolute
   /api/…, /pkg/…, /canvaskit/… and /assets/… URLs from inside its WebAssembly
   (and /assets/ is the panel's own). So it gets its OWN subdomain, chosen by
   the operator, with a certificate and a gate: nginx auth_request against a
   tiny PHP gate that only accepts a cookie signed with a server secret. The
   cookie is handed out by /__op_enter for a one-time token that only the panel
   (behind its login) mints. A visitor without it sees a "open it from your
   control panel" page — the admin hostname is never in anything public.

   ⚠️ ROOT-OWNED AND SELF-CONTAINED. Never includes anything from /var/www/alpha.
   ⚠️ nginx is only ever reloaded after `nginx -t` passes; a failed vhost is removed.

     status                  image / version / service / memory / subdomain / certificate
     preflight               what an install needs against what this server has now
     latest                  the newest release tag of the official image
     install [tag] | update  pull + re-tag + start (background job)
     job <id>                state, progress and log tail of a job
     start | stop | restart  the service (start also enables it at boot; stop disables it)
     set <key> <value>       mem <MB 256-4096> · cpus <0.5-4> · ai on|off (join the AI sandbox network)
     publish <sub>           serve it at <sub>.<primary domain> (vhost + certificate + gate)
     unpublish               remove the subdomain (the certificate is deleted too)
     delete-image            stop it and delete its images (designs and settings are kept); reports the space freed
     unit-text | unit-check  the systemd unit this helper installs / whether the installed one matches
   ============================================================ */

const ROOT     = '/var/www/alpha-private/openpencil';
const STATE    = '/var/lib/alpha-openpencil';
const UPSTREAM = 'ghcr.io/zseven-w/openpencil-web';
const IMAGE    = 'alpha/openpencil';
const NAME     = 'alpha-openpencil';
const UNIT     = 'alpha-openpencil';
const UNITFILE = '/etc/systemd/system/alpha-openpencil.service';
const ENVFILE  = '/etc/alpha-openpencil.env';
const PORT     = 3093;
const GATE     = '/var/www/openpencil-gate';
const VHOST    = '/etc/nginx/sites-available/sub-openpencil';
const VHOST_ON = '/etc/nginx/sites-enabled/sub-openpencil';
const ACME     = '/var/www/alpha';
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 panel_conf() {
  $c = []; foreach (@file('/etc/alpha-panel.conf', FILE_IGNORE_NEW_LINES) ?: [] as $l) if (preg_match('/^\s*([A-Z_]+)=["\']?([^"\']*)["\']?\s*$/', $l, $m)) $c[$m[1]] = $m[2];
  return $c;
}
function primary_domain() { $d = panel_conf()['PRIMARY_DOMAIN'] ?? ''; return preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $d) ? strtolower($d) : ''; }

/* ---------- jobs ---------- */
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 clean_log($raw) {
  $raw = preg_replace('/\x1b\[[0-9;?]*[A-Za-z]/', "\n", (string) $raw);   // the tty's cursor moves become line breaks
  $raw = str_replace("\r", "\n", $raw);
  return preg_replace("/\n{2,}/", "\n", $raw);
}
function mb_of($n, $u) { return (float) $n * (['B' => 1 / 1048576, 'kB' => 1 / 1024, 'KB' => 1 / 1024, 'MB' => 1, 'GB' => 1024][$u] ?? 1); }
/**
 * Pull progress from docker's own per-layer lines. Each layer's LAST line says where it is:
 * downloading x/y, extracting x/y, or complete. Downloading is 70% of a layer, extracting 30%;
 * layers are weighted by their size where docker has said it.
 */
function pull_progress($log) {
  $layers = [];
  foreach (explode("\n", $log) as $l) {
    if (!preg_match('/^([0-9a-f]{12}): (.+)$/', trim($l), $m)) continue;
    $id = $m[1]; $s = $m[2]; $L = $layers[$id] ?? ['p' => 0.0, 'w' => 0.0];
    if (preg_match('/^Downloading.*?([\d.]+)\s*([kMG]?B)\/([\d.]+)\s*([kMG]?B)/', $s, $x)) { $L['w'] = mb_of($x[3], $x[4]); $L['p'] = 0.7 * min(1, mb_of($x[1], $x[2]) / max(0.001, $L['w'])); }
    elseif (preg_match('/^(Verifying Checksum|Download complete)/', $s)) $L['p'] = max($L['p'], 0.7);
    elseif (preg_match('/^Extracting.*?([\d.]+)\s*([kMG]?B)\/([\d.]+)\s*([kMG]?B)/', $s, $x)) { if (!$L['w']) $L['w'] = mb_of($x[3], $x[4]); $L['p'] = 0.7 + 0.3 * min(1, mb_of($x[1], $x[2]) / max(0.001, mb_of($x[3], $x[4]))); }
    elseif (preg_match('/^(Pull complete|Already exists)/', $s)) $L['p'] = 1.0;
    $layers[$id] = $L;
  }
  if (!$layers) return [0.0, 0, 0.0];
  $known = array_filter(array_column($layers, 'w')); $avg = $known ? array_sum($known) / count($known) : 1.0;
  $tw = 0; $tp = 0; $dl = 0;
  foreach ($layers as $L) { $w = $L['w'] ?: $avg; $tw += $w; $tp += $w * $L['p']; $dl += $L['w'] * min(1, $L['p'] / 0.7); }
  return [$tp / max(0.001, $tw), count($layers), $dl];
}
function job_view($d) {
  $m = meta($d); [$st, $rc] = job_state($d);
  $raw = clean_log(is_file("$d/log") ? (string) @file_get_contents("$d/log") : '');
  // the layer chatter is summarised by the progress bar; the log keeps the readable lines
  $lines = array_values(array_filter(explode("\n", rtrim($raw)), fn($l) => !preg_match('/^[0-9a-f]{12}: (Downloading|Extracting|Waiting|Verifying)/', trim($l))));
  $tail = implode("\n", array_slice($lines, -50)); if (strlen($tail) > 9000) $tail = substr($tail, -9000);
  $elapsed = max(0, (is_file("$d/rc") ? (int) @filemtime("$d/rc") : time()) - (int) ($m['started'] ?? time()));
  [$frac, $nl, $dlmb] = pull_progress($raw);
  if ($st === 'done') { $pct = $rc === 0 ? 100 : 0; $phase = $rc === 0 ? 'Finished' : 'Failed'; }
  elseif (strpos($raw, '== start') !== false) { $pct = 97; $phase = 'Starting OpenPencil'; }
  elseif (strpos($raw, '== label') !== false) { $pct = 92; $phase = 'Labelling the image for this panel'; }
  elseif ($nl) { $pct = 2 + (int) round($frac * 88); $phase = 'Downloading ' . $nl . ' layers (' . round($dlmb) . ' MB so far)'; }
  else { $pct = 1; $phase = 'Contacting the registry'; }
  $eta = ($st === 'running' && $pct >= 5) ? (int) round($elapsed * (100 - $pct) / $pct) : null;
  return $m + ['state' => $st, 'rc' => $rc, 'pct' => $pct, 'phase' => $phase, 'eta' => $eta, 'elapsed' => $elapsed, 'tail' => $tail, 'load' => sys_load()];
}
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()]));
  $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;
}
function running_job() { foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); if ($st === 'running') { $m = meta($d); return ['id' => $m['id'], 'kind' => $m['kind'], 'started' => $m['started']]; } } return null; }

/* ---------- server numbers ---------- */
function sys_load() {
  $la = array_map('floatval', array_slice(explode(' ', (string) @file_get_contents('/proc/loadavg')), 0, 3));
  $mi = (string) @file_get_contents('/proc/meminfo'); $kb = fn($k) => preg_match('/^' . $k . ':\s+(\d+)/m', $mi, $x) ? (int) $x[1] : 0;
  [$o] = sh('nproc');
  return ['load1' => $la[0] ?? 0, 'load5' => $la[1] ?? 0, 'cores' => max(1, (int) trim($o)), 'mem_total_mb' => intdiv($kb('MemTotal'), 1024), 'mem_avail_mb' => intdiv($kb('MemAvailable'), 1024)];
}
function docker_root_free_mb() {
  [$root] = sh('docker info --format "{{.DockerRootDir}}"'); $root = trim($root) ?: '/var/lib/docker';
  [$o] = sh('df -Pm ' . escapeshellarg(is_dir($root) ? $root : '/') . ' | tail -1');
  $p = preg_split('/\s+/', trim($o));
  return [(int) ($p[3] ?? 0), $root];
}

/* ---------- settings, unit ---------- */
function settings() {
  $s = @json_decode((string) @file_get_contents(STATE . '/settings.json'), true); $s = is_array($s) ? $s : [];
  return ['mem' => (int) ($s['mem'] ?? 512), 'cpus' => (float) ($s['cpus'] ?? 1), 'version' => (string) ($s['version'] ?? ''), 'sub' => (string) ($s['sub'] ?? ''),
          'ai' => !empty($s['ai'])];
}
/* ---------- AI from this server ----------
   The panel's AI sandbox (Container Manager › AI Sandbox) runs Ollama as container `alpha-ai` on the walled
   `alpha-ai` network. With AI linked, OpenPencil joins that same network — the wall's own rules let the two talk
   (intra-subnet) and keep both away from the host and the private ranges — and its AI endpoint allow-list names
   exactly that one internal origin. Without the network (sandbox never set up) it stays on the default bridge. */
const AI_NET    = 'alpha-ai';
const AI_ORIGIN = 'http://alpha-ai:11434';
function ai_net_exists() { [, $rc] = sh('docker network inspect ' . AI_NET); return $rc === 0; }
function ai_linked(array $s) { return !empty($s['ai']) && ai_net_exists(); }
function fqdn(array $s = null) { $s = $s ?? settings(); $d = primary_domain(); return $s['sub'] !== '' && $d !== '' ? $s['sub'] . '.' . $d : ''; }
function env_text(array $s) {
  $f = fqdn($s);
  return "# OpenPencil — written by /usr/local/sbin/alpha-openpencil (Settings in the add-on). Do not hand-edit.\n"
       . "IMAGE=" . IMAGE . ':' . ($s['version'] !== '' ? $s['version'] : 'latest') . "\nPORT=" . PORT . "\nMEM=" . (int) $s['mem'] . "m\n"
       . "CPUS=" . rtrim(rtrim(number_format($s['cpus'], 1, '.', ''), '0'), '.') . "\nDATA=" . ROOT . "\n"
       // the AI and credential endpoints only answer browsers from this exact origin
       . "ORIGIN=" . ($f !== '' ? 'https://' . $f : 'https://localhost') . "\n"
       . "NET=" . (ai_linked($s) ? AI_NET : 'bridge') . "\n"
       . "AIALLOW=" . (ai_linked($s) ? AI_ORIGIN : '') . "\n";
}
function write_settings(array $s) {
  file_put_contents(STATE . '/settings.json', json_encode($s)); @chmod(STATE . '/settings.json', 0600);
  file_put_contents(ENVFILE, env_text($s)); @chmod(ENVFILE, 0644);
}
function unit_text() {
  return "[Unit]\n"
       . "Description=OpenPencil design studio (Alpha Panel add-on)\n"
       . "After=docker.service network-online.target\n"
       . "Requires=docker.service\n"
       . "# without its image (not installed yet, or deleted to free space) it must give up, not retry for ever\n"
       . "StartLimitIntervalSec=120\n"
       . "StartLimitBurst=3\n\n"
       . "[Service]\n"
       . "Type=simple\n"
       . "EnvironmentFile=/etc/alpha-openpencil.env\n"
       . "ExecStartPre=-/usr/bin/docker rm -f alpha-openpencil\n"
       . "# 127.0.0.1 only: the subdomain's gated nginx server is the one way in. Read-only root, no capabilities, uid 33.\n"
       . "ExecStart=/usr/bin/docker run --rm --pull never --name alpha-openpencil --init --user 33:33 --memory \${MEM} --cpus \${CPUS} --pids-limit 256 \\\n"
       . "  --cap-drop ALL --security-opt no-new-privileges --read-only --tmpfs /tmp:size=64m -e HOME=/data \\\n"
       . "  -e OPENPENCIL_WEB_ALLOWED_ORIGINS=\${ORIGIN} -e OPENPENCIL_WEB_AI_ENDPOINT_ALLOWLIST=\${AIALLOW} --network \${NET} \\\n"
       . "  -p 127.0.0.1:\${PORT}:3100 -v \${DATA}/data:/data \${IMAGE}\n"
       . "# '-': after a stop from outside the container is already gone; a failing ExecStop would count as a crash\n"
       . "ExecStop=-/usr/bin/docker stop -t 10 alpha-openpencil\n"
       . "SuccessExitStatus=143 137\n"
       . "Restart=on-failure\n"
       . "RestartSec=5\n\n"
       . "[Install]\n"
       . "WantedBy=multi-user.target\n";
}
function ensure_unit() {
  if (!is_file(UNITFILE) || file_get_contents(UNITFILE) !== unit_text()) { file_put_contents(UNITFILE, unit_text()); @chmod(UNITFILE, 0644); sh('systemctl daemon-reload'); }
  if (!is_file(ENVFILE) || file_get_contents(ENVFILE) !== env_text(settings())) write_settings(settings());
}
function ensure_tree() {
  foreach ([ROOT, ROOT . '/data', ROOT . '/tokens', ROOT . '/library'] as $d) if (!is_dir($d)) @mkdir($d, 0750, true);
  sh('chown www-data:www-data ' . implode(' ', array_map('escapeshellarg', [ROOT, ROOT . '/data', ROOT . '/tokens', ROOT . '/library'])));
  $sec = ROOT . '/gate.secret';
  if (!is_file($sec) || strlen(trim((string) file_get_contents($sec))) < 32) { file_put_contents($sec, bin2hex(random_bytes(32)) . "\n"); }
  sh('chown root:www-data ' . escapeshellarg($sec)); @chmod($sec, 0640);
}
function image_ref() { $v = settings()['version']; return IMAGE . ':' . ($v !== '' ? $v : 'latest'); }
function image_info() {
  [$o, $rc] = sh('docker image inspect ' . escapeshellarg(image_ref()) . ' --format "{{.Created}}"');
  if ($rc !== 0 || trim($o) === '') return null;
  [$sz] = sh('docker images ' . escapeshellarg(image_ref()) . ' --format "{{.Size}}"');
  $mb = 0; if (preg_match('/^([\d.]+)\s*([kKMG]?B)/', trim($sz), $m)) $mb = (int) round(mb_of($m[1], $m[2] === 'KB' ? 'kB' : $m[2]));
  return ['size_mb' => $mb, 'created' => (int) strtotime(trim($o))];
}
function unit_active() { [$o] = sh('systemctl is-active ' . UNIT); return trim($o) === 'active'; }
function registry_tags() {
  [$t] = sh('curl -fsS --max-time 15 ' . escapeshellarg('https://ghcr.io/token?scope=repository:zseven-w/openpencil-web:pull'));
  $tok = json_decode((string) $t, true)['token'] ?? '';
  if ($tok === '') return [];
  [$l] = sh('curl -fsS --max-time 15 -H ' . escapeshellarg('Authorization: Bearer ' . $tok) . ' ' . escapeshellarg('https://ghcr.io/v2/zseven-w/openpencil-web/tags/list'));
  $tags = array_values(array_filter((array) (json_decode((string) $l, true)['tags'] ?? []), fn($x) => preg_match('/^v\d+\.\d+\.\d+$/', $x)));
  usort($tags, 'version_compare');
  return $tags;
}

/* ---------- the gate (served by the subdomain's nginx through php-fpm) ---------- */
function gate_files() {
  $gate = <<<'PHP'
<?php
/* OpenPencil gate — the auth_request target. 204 for a cookie signed with this server's secret, else 401.
   Written by /usr/local/sbin/alpha-openpencil; do not hand-edit. */
$secret = trim((string) @file_get_contents('/var/www/alpha-private/openpencil/gate.secret'));
$c = (string) ($_COOKIE['opg'] ?? '');
if ($secret !== '' && preg_match('/^(\d{10})\.([a-f0-9]{64})$/', $c, $m) && (int) $m[1] > time()
    && hash_equals(hash_hmac('sha256', $m[1] . '|' . strtolower((string) ($_SERVER['HTTP_HOST'] ?? '')), $secret), $m[2])) {
  http_response_code(204); exit;
}
http_response_code(401);
PHP;
  $enter = <<<'PHP'
<?php
/* OpenPencil entry — trades a one-time token minted by the control panel for a signed 12-hour cookie.
   Written by /usr/local/sbin/alpha-openpencil; do not hand-edit. */
$t = (string) ($_GET['t'] ?? '');
$dir = '/var/www/alpha-private/openpencil/tokens';
$f = preg_match('/^[a-f0-9]{48}$/', $t) ? $dir . '/' . $t : '';
$okTok = $f !== '' && is_file($f) && filemtime($f) >= time() - 120;
if ($f !== '' && is_file($f)) @unlink($f);                          // one use, valid or not
foreach (glob($dir . '/*') ?: [] as $old) if (filemtime($old) < time() - 600) @unlink($old);
if (!$okTok) { http_response_code(403); readfile(__DIR__ . '/closed.html'); exit; }
$secret = trim((string) @file_get_contents('/var/www/alpha-private/openpencil/gate.secret'));
$exp = time() + 12 * 3600;
setcookie('opg', $exp . '.' . hash_hmac('sha256', $exp . '|' . strtolower((string) ($_SERVER['HTTP_HOST'] ?? '')), $secret),
          ['expires' => $exp, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);
header('Cache-Control: no-store');
/* The panel writes ai.json when OpenPencil is linked to this server's AI sandbox: the models to offer. */
$ai = json_decode((string) @file_get_contents('/var/www/alpha-private/openpencil/ai.json'), true);
$ai = is_array($ai) ? ['enabled' => !empty($ai['enabled']), 'base_url' => (string) ($ai['base_url'] ?? ''), 'models' => array_values(array_filter((array) ($ai['models'] ?? []), 'is_array'))]
                    : ['enabled' => false, 'base_url' => '', 'models' => []];
?><!doctype html><meta charset="utf-8"><title>OpenPencil</title><style>html,body{margin:0;height:100%;background:#18181b}</style>
<script>
/* This server's AI models, managed from the panel: the previous set is replaced by the current one, and any
   provider the user added in OpenPencil themselves is left exactly as it is. Listed first, so a design prompt
   uses this server unless the user picks another. */
try {
  var AI = <?= json_encode($ai, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  var ck = 'openpencil-rust-web-credentials::anon';
  var c = JSON.parse(localStorage.getItem(ck) || 'null') || { version: 2, builtin_agents: [], image_gen_profiles: [], active_image_gen_profile_id: null, openverse_oauth: null };
  if (!Array.isArray(c.builtin_agents)) c.builtin_agents = [];
  var mine = c.builtin_agents.filter(function (a) { return String(a.id || '').indexOf('alpha-ai-') !== 0; });
  var ours = !AI.enabled ? [] : AI.models.map(function (m, i) {
    return { id: 'alpha-ai-' + i, preset: 'custom', display_name: String(m.label || m.id), kind: 'openai-compat', api_key: 'ollama',
             model: String(m.id), base_url: AI.base_url, enabled: true };
  });
  c.builtin_agents = ours.concat(mine);
  localStorage.setItem(ck, JSON.stringify(c));
} catch (e) {}
</script>
<script>
/* OpenPencil starts in Chinese for a new browser. Give a first-time visitor their own language when OpenPencil has it. */
try {
  var k = 'openpencil-rust-web-settings::anon';
  if (!localStorage.getItem(k)) {
    var have = ['en-US', 'zh-CN', 'zh-TW', 'ja', 'ko', 'fr', 'es', 'de', 'pt', 'ru', 'hi', 'tr', 'th', 'vi', 'id'];
    var want = (navigator.languages || [navigator.language || 'en-US']).map(function (l) { return String(l); });
    var pick = 'en-US';
    for (var i = 0; i < want.length; i++) {
      var w = want[i], b = w.split('-')[0];
      if (have.indexOf(w) >= 0) { pick = w; break; }
      if (b === 'zh') { pick = /TW|HK|Hant/i.test(w) ? 'zh-TW' : 'zh-CN'; break; }
      if (b !== 'en' && have.indexOf(b) >= 0) { pick = b; break; }
      if (b === 'en') break;
    }
    localStorage.setItem(k, JSON.stringify({ version: 1, theme: 'dark', locale: pick, mcp_port: 3100, mcp_cli_enabled: [false, false, false, false, false, false, false, false, false, false, false, false], images_advanced_open: false, auto_update_enabled: false, experimental_features_enabled: false, recent_files: [] }));
  }
} catch (e) {}
location.replace('/');
</script>
PHP;
  $closed = <<<'HTML'
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex, nofollow">
<title>OpenPencil · private design studio</title>
<style>
html,body{margin:0;height:100%;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#0f0f12;color:#e4e4e7}
.w{min-height:100%;display:grid;place-items:center;padding:24px;box-sizing:border-box}
.c{max-width:440px;text-align:center}
.m{width:64px;height:64px;border-radius:18px;margin:0 auto 18px;background:linear-gradient(135deg,#f59e0b,#ea580c);display:grid;place-items:center;box-shadow:0 10px 30px rgba(234,88,12,.35)}
h1{font-size:20px;margin:0 0 8px}p{font-size:14px;line-height:1.6;color:#a1a1aa;margin:0}
</style></head>
<body><div class="w"><div class="c">
<div class="m"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg></div>
<h1>This design studio is private</h1>
<p>OpenPencil on this server opens from its owner's control panel. If you are the owner, open it from there — the link it gives you signs this browser in for twelve hours.</p>
</div></div></body></html>
HTML;
  return ['gate.php' => $gate . "\n", 'enter.php' => $enter . "\n", 'closed.html' => $closed . "\n"];
}
function write_gate() {
  if (!is_dir(GATE)) @mkdir(GATE, 0755, true);
  foreach (gate_files() as $f => $txt) { file_put_contents(GATE . '/' . $f, $txt); @chmod(GATE . '/' . $f, 0644); }
}
/** The PHP-FPM socket the panel's own admin vhost uses (the PHP version differs between installs). */
function fpm_sock() {
  [$o] = sh('grep -hoE "fastcgi_pass unix:[^;]+" $(grep -ls "__auth_check" /etc/nginx/sites-enabled/* 2>/dev/null) 2>/dev/null | head -1');
  if (preg_match('#unix:(\S+)#', $o, $m)) return $m[1];
  $g = glob('/run/php/php*-fpm.sock') ?: []; rsort($g);
  return $g[0] ?? '/run/php/php-fpm.sock';
}
function vhost_text($fqdn, $tls) {
  $http = "# ALPHA-OPENPENCIL — OpenPencil design studio (alpha-openpencil on 127.0.0.1:" . PORT . "), gated. Written by /usr/local/sbin/alpha-openpencil.\n"
        . "server {\n    listen 80; listen [::]:80;\n    server_name $fqdn;\n"
        . "    location ^~ /.well-known/acme-challenge/ { allow all; root " . ACME . "; default_type \"text/plain\"; }\n"
        . "    location / { return 301 https://\$host\$request_uri; }\n}\n";
  if (!$tls) return $http;
  return $http
    . "server {\n"
    . "    listen 443 ssl http2; listen [::]:443 ssl http2;\n"
    . "    server_name $fqdn;\n"
    . "    ssl_certificate     /etc/letsencrypt/live/$fqdn/fullchain.pem;\n"
    . "    ssl_certificate_key /etc/letsencrypt/live/$fqdn/privkey.pem;\n"
    . "    client_max_body_size 64m;\n"
    . "    add_header X-Robots-Tag \"noindex, nofollow\" always;\n"
    . "    # the gate: a cookie signed with the server secret, handed out only for a token the panel minted\n"
    . "    location = /__op_gate {\n        internal;\n        include fastcgi_params;\n        fastcgi_pass unix:" . fpm_sock() . ";\n"
    . "        fastcgi_param SCRIPT_FILENAME " . GATE . "/gate.php;\n        fastcgi_pass_request_body off;\n        fastcgi_param CONTENT_LENGTH \"\";\n    }\n"
    . "    location = /__op_enter {\n        include fastcgi_params;\n        fastcgi_pass unix:" . fpm_sock() . ";\n"
    . "        fastcgi_param SCRIPT_FILENAME " . GATE . "/enter.php;\n    }\n"
    // a URI error_page keeps the 401: a refused request must not look like a successful one
    . "    location = /__op_closed { internal; default_type text/html; alias " . GATE . "/closed.html; }\n"
    . "    location @op_down { root " . GATE . "; default_type text/html; return 503 '<!doctype html><meta charset=utf-8><title>OpenPencil</title><body style=\"margin:0;display:grid;place-items:center;height:100vh;background:#0f0f12;color:#a1a1aa;font-family:system-ui\"><p>OpenPencil is stopped. Start it from your control panel.</p>'; }\n"
    . "    location / {\n"
    . "        auth_request /__op_gate;\n"
    . "        error_page 401 /__op_closed;\n"
    . "        error_page 502 504 = @op_down;\n"
    . "        # framed by the panel: a wildcard of the panel's own domain, never the admin hostname (this header is public)\n"
    . "        proxy_hide_header X-Frame-Options;\n"
    . "        add_header Content-Security-Policy \"frame-ancestors 'self' https://*." . primary_domain() . "\" always;\n"
    . "        add_header X-Robots-Tag \"noindex, nofollow\" always;\n"
    . "        proxy_pass http://127.0.0.1:" . PORT . ";\n"
    . "        proxy_http_version 1.1;\n"
    . "        proxy_set_header Upgrade \$http_upgrade;\n"
    . "        proxy_set_header Connection \"upgrade\";\n"
    . "        proxy_set_header Host \$host;\n"
    . "        proxy_set_header Origin \$http_origin;\n"
    . "        proxy_read_timeout 1h;\n"
    . "        proxy_buffering off;\n"
    . "    }\n}\n";
}
function nginx_apply($text) {
  $bak = is_file(VHOST) ? file_get_contents(VHOST) : null;
  file_put_contents(VHOST, $text); @chmod(VHOST, 0644);
  if (!is_link(VHOST_ON)) @symlink(VHOST, VHOST_ON);
  [$o, $rc] = sh('nginx -t');
  if ($rc !== 0) {
    if ($bak !== null) file_put_contents(VHOST, $bak); else { @unlink(VHOST_ON); @unlink(VHOST); }
    return [false, $o];
  }
  sh('systemctl reload nginx');
  return [true, ''];
}

ensure_state();
$cmd = (string) ($_SERVER['argv'][1] ?? '');
switch ($cmd) {
  case 'status': {
    $img = image_info(); $act = unit_active(); $s = settings();
    $mem = 0; $started = 0;
    if ($act) {
      [$o] = sh('docker stats --no-stream --format "{{.MemUsage}}" ' . NAME);
      if (preg_match('/^([\d.]+)\s*([KMG]i?B)/', trim($o), $m)) $mem = (int) round((float) $m[1] * ['KiB' => 1 / 1024, 'MiB' => 1, 'GiB' => 1024][$m[2]] ?? 1);
      [$o] = sh('docker inspect -f "{{.State.StartedAt}}" ' . NAME); $started = (int) strtotime(trim($o));
    }
    $f = fqdn($s);
    [$en] = sh('systemctl is-enabled ' . UNIT);
    out(['docker' => bin('docker') !== '', 'installed' => $img !== null, 'image' => $img, 'version' => $s['version'], 'unit' => is_file(UNITFILE),
         'unit_current' => is_file(UNITFILE) && file_get_contents(UNITFILE) === unit_text(), 'active' => $act, 'enabled' => trim($en) === 'enabled',
         'mem_mb' => $mem, 'started' => $started, 'settings' => $s, 'port' => PORT, 'domain' => primary_domain(), 'sub' => $s['sub'], 'fqdn' => $f,
         'cert' => $f !== '' && is_file("/etc/letsencrypt/live/$f/fullchain.pem"), 'vhost' => is_file(VHOST) && strpos((string) file_get_contents(VHOST), '__op_gate') !== false,
         'gate' => is_file(GATE . '/gate.php') && file_get_contents(GATE . '/gate.php') === gate_files()['gate.php']
                   && is_file(GATE . '/enter.php') && file_get_contents(GATE . '/enter.php') === gate_files()['enter.php'],
         'ai' => !empty($s['ai']), 'ai_network' => ai_net_exists(), 'ai_linked' => ai_linked($s),
         'net' => $act ? trim((string) sh('docker inspect -f "{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}} {{end}}" ' . NAME)[0]) : '',
         'running' => running_job()]);
  }
  case 'preflight': {
    [$free, $root] = docker_root_free_mb(); $img = image_info(); $s = settings();
    out(['ok' => true, 'installed' => $img !== null, 'docker_root' => $root, 'disk_free_mb' => $free, 'disk_need_mb' => $img ? 0 : 650,
         'download_mb' => $img ? 0 : 180, 'image_mb' => 475, 'run_mem_mb' => 40, 'run_cap_mb' => (int) $s['mem'], 'build_cores' => 1, 'typical_secs' => 60] + sys_load());
  }
  case 'latest': { $t = registry_tags(); if (!$t) fail('could not read the image registry'); out(['ok' => true, 'latest' => end($t), 'tags' => array_slice($t, -6)]); }
  case 'install': case 'update': {
    if (bin('docker') === '') fail('docker is required');
    $tag = (string) ($_SERVER['argv'][2] ?? '');
    if ($tag === '' || $cmd === 'update') { $t = registry_tags(); $tag = $t ? end($t) : ''; }
    if (!preg_match('/^v\d+\.\d+\.\d+$/', $tag)) fail('could not find an OpenPencil release to install');
    ensure_tree(); write_gate();
    $s = settings(); $prev = $s['version'];
    $q = 'escapeshellarg'; $up = UPSTREAM . ':' . $tag; $mine = IMAGE . ':' . $tag;
    $body = 'set -e; '
      . 'echo "== pull ' . $up . '"; '
      // docker 29 prints per-layer progress only to a terminal: give it one
      . 'script -qfec ' . $q('docker pull ' . $up) . ' /dev/null; '
      . 'echo "== label"; printf ' . $q("FROM $up\nLABEL alpha.addon=\"openpencil\" alpha.openpencil.version=\"$tag\"\n") . ' | DOCKER_BUILDKIT=0 docker build -q -t ' . $q($mine) . ' - ; '
      . '/usr/local/sbin/alpha-openpencil set version ' . $q($tag) . ' >/dev/null; /usr/local/sbin/alpha-openpencil unit-install >/dev/null; '
      . 'echo "== start"; systemctl enable --now ' . UNIT . '; systemctl restart ' . UNIT . '; '
      . 'for i in $(seq 1 40); do curl -fs -o /dev/null http://127.0.0.1:' . PORT . '/ && break; sleep 0.5; done; '
      . ($prev !== '' && $prev !== $tag ? 'echo "== drop ' . $prev . '"; docker rmi ' . $q(IMAGE . ':' . $prev) . ' ' . $q(UPSTREAM . ':' . $prev) . ' >/dev/null 2>&1 || true; ' : '')
      . 'echo "== done: OpenPencil ' . $tag . ' running"';
    out(['ok' => true, 'job' => job_start($cmd, $body), 'version' => $tag]);
  }
  case 'job': out(['ok' => true] + job_view(job_dir((string) ($_SERVER['argv'][2] ?? ''))));
  case 'start': case 'restart': {
    if (!image_info()) fail('OpenPencil is not installed yet');
    ensure_tree(); ensure_unit(); write_gate();
    [$o, $rc] = sh('systemctl enable ' . UNIT . ' && systemctl ' . $cmd . ' ' . UNIT);
    if ($rc !== 0) fail('systemctl ' . $cmd . ' failed: ' . $o);
    $ok = false; for ($i = 0; $i < 40; $i++) { [$c] = sh('curl -s -o /dev/null -w "%{http_code}" --max-time 2 http://127.0.0.1:' . PORT . '/'); if (trim($c) === '200') { $ok = true; break; } usleep(500000); }
    out(['ok' => true, 'ready' => $ok]);
  }
  case 'stop': { sh('systemctl disable --now ' . UNIT); out(['ok' => true, 'active' => unit_active()]); }
  case 'set': {
    $k = (string) ($_SERVER['argv'][2] ?? ''); $v = (string) ($_SERVER['argv'][3] ?? ''); $s = settings();
    switch ($k) {
      case 'mem':  if (!preg_match('/^\d{3,4}$/', $v) || (int) $v < 256 || (int) $v > 4096) fail('mem: MB 256-4096'); $s['mem'] = (int) $v; break;
      case 'cpus': if (!preg_match('/^\d(\.\d)?$/', $v) || (float) $v < 0.5 || (float) $v > 4) fail('cpus: 0.5-4'); $s['cpus'] = (float) $v; break;
      case 'version': if (!preg_match('/^v\d+\.\d+\.\d+$/', $v)) fail('version: vX.Y.Z'); $s['version'] = $v; break;
      case 'ai':
        if (!in_array($v, ['on', 'off'], true)) fail('ai: on|off');
        if ($v === 'on' && !ai_net_exists()) fail('the AI sandbox network does not exist yet — set the sandbox up in Container Manager › AI Sandbox first');
        $s['ai'] = $v === 'on';
        write_settings($s); write_gate();
        // the network and the allow-list are start-time settings: apply them now
        if (unit_active()) { sh('systemctl restart ' . UNIT); for ($i = 0; $i < 40; $i++) { [$c] = sh('curl -s -o /dev/null -w "%{http_code}" --max-time 2 http://127.0.0.1:' . PORT . '/'); if (trim($c) === '200') break; usleep(500000); } }
        out(['ok' => true, 'settings' => $s, 'net' => ai_linked($s) ? AI_NET : 'bridge']);
      default: fail('set mem|cpus|ai <value>');
    }
    write_settings($s);
    out(['ok' => true, 'settings' => $s, 'restart_needed' => $k !== 'version' && unit_active()]);
  }
  case 'publish': {
    $sub = strtolower((string) ($_SERVER['argv'][2] ?? ''));
    if (!preg_match('/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/', $sub)) fail('use letters, digits and dashes (1-40), e.g. design');
    $dom = primary_domain(); if ($dom === '') fail('PRIMARY_DOMAIN is not set in /etc/alpha-panel.conf');
    $f = "$sub.$dom";
    // another site already answering on this name is never taken over
    [$who] = sh('grep -Rls --include=* -E ' . escapeshellarg('server_name[^;]*[[:space:]]' . preg_quote($f, '/') . '[[:space:];]') . ' /etc/nginx/sites-enabled/');
    foreach (array_filter(explode("\n", trim($who))) as $w) if (realpath($w) !== realpath(VHOST)) fail("$f is already served by " . basename($w));
    [$ip] = sh('getent hosts ' . escapeshellarg($f)); if (trim($ip) === '') fail("$f does not resolve — add a DNS record (or a * wildcard) for it first");
    ensure_tree(); write_gate();
    $s = settings(); $old = fqdn($s); $s['sub'] = $sub; write_settings($s);
    if (!is_file("/etc/letsencrypt/live/$f/fullchain.pem")) {
      [$ok, $err] = nginx_apply(vhost_text($f, false)); if (!$ok) fail('nginx refused the server block: ' . $err);
      $em = panel_conf()['ADMIN_EMAIL'] ?? '';
      [$o, $rc] = sh('certbot certonly --webroot -w ' . escapeshellarg(ACME) . ' -d ' . escapeshellarg($f) . ' --non-interactive --agree-tos '
                    . ($em !== '' ? '-m ' . escapeshellarg($em) : '--register-unsafely-without-email'));
      if ($rc !== 0) fail('could not get a certificate for ' . $f . ': ' . trim(substr($o, -400)));
    }
    [$ok, $err] = nginx_apply(vhost_text($f, true)); if (!$ok) fail('nginx refused the server block: ' . $err);
    if ($old !== '' && $old !== $f) sh('certbot delete --cert-name ' . escapeshellarg($old) . ' --non-interactive');
    if (unit_active()) {                                                  // the allowed origin changed
      sh('systemctl restart ' . UNIT);
      for ($i = 0; $i < 40; $i++) { [$c] = sh('curl -s -o /dev/null -w "%{http_code}" --max-time 2 http://127.0.0.1:' . PORT . '/'); if (trim($c) === '200') break; usleep(500000); }
    }
    out(['ok' => true, 'fqdn' => $f, 'url' => "https://$f/"]);
  }
  case 'unpublish': {
    $s = settings(); $f = fqdn($s);
    @unlink(VHOST_ON); @unlink(VHOST); [$o, $rc] = sh('nginx -t'); if ($rc === 0) sh('systemctl reload nginx');
    if ($f !== '') sh('certbot delete --cert-name ' . escapeshellarg($f) . ' --non-interactive');
    $s['sub'] = ''; write_settings($s);
    out(['ok' => true]);
  }
  case 'delete-image': {
    [$before] = docker_root_free_mb();
    sh('systemctl disable --now ' . UNIT);
    [$ids] = sh('docker images --filter label=alpha.addon=openpencil -q');
    foreach (array_unique(array_filter(explode("\n", trim($ids)))) as $id) sh('docker rmi -f ' . escapeshellarg($id));
    [$ups] = sh('docker images ' . escapeshellarg(UPSTREAM) . ' --format "{{.Repository}}:{{.Tag}}"');
    foreach (array_filter(explode("\n", trim($ups))) as $u) sh('docker rmi ' . escapeshellarg($u));
    $s = settings(); $s['version'] = ''; write_settings($s);
    [$after] = docker_root_free_mb();
    out(['ok' => true, 'freed_mb' => max(0, $after - $before)]);
  }
  case 'unit-install': { ensure_unit(); out(['ok' => true]); }
  case 'unit-text': { echo unit_text(); exit(0); }
  case 'unit-check': { out(['ok' => is_file(UNITFILE) && file_get_contents(UNITFILE) === unit_text(), 'installed' => is_file(UNITFILE)]); }
  default: fail('usage: alpha-openpencil status|preflight|latest|install [tag]|update|job <id>|start|stop|restart|set mem|cpus|ai <value>|publish <sub>|unpublish|delete-image|unit-text|unit-check');
}
