<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Ad-hoc rsync between hosts. Picks a partner, browses both filesystems to choose a source
//   and destination, runs a transfer in the background, streams its output, and cancels it —
//   the manual sync panel on the rsync tab.
//
// OPERATIONAL MODEL
//   Entirely outside the profile system. The scheduled tiers sync configured shares with
//   configured profiles; this is for the one-off move that does not belong in a conf file —
//   seeding a new host, recovering a share, copying something once. Nothing here is recorded,
//   scheduled, or repeated.
//
//   Six actions on one URL, in the order the panel uses them: hosts, browse, browse_local,
//   run, poll, stop. run returns a token immediately and the transfer continues detached; poll
//   reads its log until a sentinel appears; stop kills it.
//
//   Progress is a log file in /tmp keyed by token, terminated by a __DONE__ sentinel the
//   wrapper appends after rsync exits. That sentinel is what lets poll distinguish "still
//   running" from "finished" without inspecting a process — and the pid file is the backstop
//   for the case where rsync died without reaching it.
//
//   Both filesystems are browsed the same way, one directory at a time. The remote side runs
//   the same find over SSH that the local side runs directly, so the two panes behave
//   identically.
//
// DESIGN PRINCIPLES
//   Source is always local, destination always remote.
//       The direction is fixed. A tool that could pull as well as push would need twice the
//       path validation and would make "which side am I about to overwrite" a question the
//       user has to answer correctly every time.
//
//   Trailing slashes are normalised onto both paths.
//       rsync's most consequential piece of syntax is whether the source ends in a slash. It
//       is appended unconditionally here so the transfer always means "the contents of", never
//       "the directory itself nested inside".
//
//   One manual sync at a time, host-wide.
//       Enforced by scanning for any live pid file, not per token. Two concurrent ad-hoc
//       transfers would compete for the same bandwidth the scheduled tiers are also using.
//
//   An inconclusive remote check proceeds; a definite failure stops.
//       A destination that reports 'missing' is refused. An SSH check that answers neither
//       'ok' nor 'missing' — Tailscale still coming up, for instance — is allowed through,
//       because rsync will fail cleanly and visibly in the log if the path really is wrong.
//
// OPERATIONAL SAFEGUARDS
//   System paths are refused as both source and destination.
//       An explicit blocklist — /, /proc, /sys, /dev, /run, /etc, /bin, /sbin, /usr, /lib,
//       /lib64, /boot/EFI, /tmp — compared after trailing-slash normalisation so /etc and
//       /etc/ are the same answer. This is the guard against a blank or truncated field
//       collapsing into a path that would sync the operating system over the network.
//
//   Both paths must be absolute, traversal-free, and free of shell metacharacters.
//       Leading slash required, '..' rejected, and null bytes, newlines, backticks and $
//       rejected outright — then escapeshellarg() on top.
//
//   rsync flags cannot become shell syntax.
//       Control characters are rejected and every remaining token is escaped individually.
//       The previous blocklist stripped metacharacters but not newlines, and these flags are
//       spliced into a `bash -c` script — a newline would have started a second command
//       inside it. Escaping each token is right about every character; a blocklist has to be.
//
//   The bandwidth limit is cast, not filtered.
//       (int) with a max(0, …) floor, so it can only ever be a non-negative number.
//
//   The remote user is reduced to a safe character set before it is used.
//       preg_replace strips everything outside [a-z0-9_.-], with 'root' as the fallback when
//       nothing survives.
//
//   The local source is confirmed to exist before anything is launched.
//
//   Tokens are random and fixed-length.
//       bin2hex(random_bytes(8)) produces the token; poll and stop both reduce the submitted
//       value to hex and require exactly 16 characters, so neither can name a file outside
//       the /tmp/vv_ms_ namespace. A transfer's log is readable by anyone who can guess its
//       token, so the token is not guessable.
//
//   Stop escalates and then cleans up.
//       SIGTERM, 400ms, SIGKILL — rsync should be given the chance to finish its current file
//       and close its connection. The cancellation is then written into the log with the
//       sentinel, so a poll already in flight terminates cleanly instead of hanging.
//
//   Poll detects a crashed transfer as well as a finished one.
//       A missing sentinel with a dead pid is reported done with an explicit note, so a
//       transfer killed by OOM or a reboot does not leave the panel polling forever.
//
//   SSH is non-interactive and time-boxed.
//       BatchMode=yes and ConnectTimeout=10 on the transfer, 8s and 4s on the browse and ping
//       calls — nothing here can sit waiting for a password or a dead host.
//
//   Log files are removed on the terminal poll, so a completed transfer does not leave its
//   output in /tmp indefinitely. /tmp is tmpfs, so an abandoned one clears at reboot anyway.
//
//   Accepted exposure: browse can list any directory on either host.
//       Directory names only — no file contents, no file names. browse and poll are GET reads
//       bounded by the WebGUI session; run and stop are POST and are CSRF-guarded by Unraid's
//       auto_prepend before any code here runs. See README-unraid.md.
//
// REQUEST
//   GET  ?action=hosts                             partners, Tailscale state, key presence
//   GET  ?action=browse&host=<slot>&path=/dir      remote directory listing over SSH
//   GET  ?action=browse_local&path=/dir            local directory listing
//   POST action=run  local=/src  host=<slot>  remote_path=/dst
//        [user=root] [bw_limit=<KB/s>] [use_key=0|1] [flags=<rsync flags>]
//   GET  ?action=poll&token=<hex16>                output so far, and whether it finished
//   POST action=stop  token=<hex16>                cancel a running transfer
//
// RESPONSE
//   hosts    {"ok":true,"hosts":[{slot,id,hostname,online,ip}],"has_key":bool,"ssh_key":"…"}
//   browse   {"ok":true,"path","dirs":[…],"parent":…}      max 200 entries
//   run      {"ok":true,"token":"<hex16>"}
//   poll     {"ok":true,"output":"…","done":bool,"started":bool}
//   stop     {"ok":true}
//   {"ok":false,"error":"Invalid path"|"Missing: …"|"Refusing to sync from system path: …"
//                      |"Local source does not exist: …"|"Another manual sync is already
//                        running — stop it first."|"Remote destination does not exist: …"
//                      |"SSH connection failed to …"|"Invalid flags"|"Invalid token"
//                      |"Unknown action"}
//
// DEPENDS ON
//   include/config.php        vv_detect_host(), vv_read_conf_raw(), vv_conf_vars(),
//                             vv_resolve_tailscale_ip()
//   include/arrs.php          vv_arr_known_hosts(), vv_arr_scalar()
//   include/partnership.php   vv_pt_ts_peers(), vv_pt_ssh()
//   /tmp/vv_ms_<token>.log|.pid   per-transfer state
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/arrs.php';
require_once dirname(__DIR__) . '/include/partnership.php'; // vv_pt_ts_peers, vv_pt_ssh

function _ms_caller(): array {
    $host   = vv_detect_host();
    $id     = strtoupper($host);
    $raw    = vv_read_conf_raw($host . '.conf');
    $sshKey = vv_arr_scalar($raw, $id . '_SSH_KEY');
    return ['host' => $host, 'ssh_key' => $sshKey];
}

function _ms_target(string $slot, string $sshKey): array {
    $vars     = vv_conf_vars();
    $hostname = $vars[strtoupper($slot)] ?? '';
    if (!$hostname) return ['ok' => false, 'error' => 'Unknown host: ' . $slot];
    if (!$sshKey || !file_exists($sshKey))
        return ['ok' => false, 'error' => 'SSH key not configured on this host'];
    $ip = vv_resolve_tailscale_ip($hostname);
    if (!$ip) return ['ok' => false, 'error' => 'Cannot reach ' . $hostname . ' via Tailscale'];
    return ['ok' => true, 'ip' => $ip, 'hostname' => $hostname];
}

$action = trim($_GET['action'] ?? $_POST['action'] ?? '');

// ── hosts ─────────────────────────────────────────────────────────────────────
if ($action === 'hosts') {
    ['host' => $current, 'ssh_key' => $sshKey] = _ms_caller();
    $all   = vv_arr_known_hosts();
    $peers = vv_pt_ts_peers();
    $out   = [];
    foreach ($all as $slot => $hostname) {
        if ($slot === $current) continue;
        $label = strtolower($hostname);
        $ts    = $peers[$label] ?? [];
        $out[] = [
            'slot'     => $slot,
            'id'       => strtoupper($slot),
            'hostname' => $hostname,
            'online'   => $ts['online'] ?? null,
            'ip'       => $ts['ip']     ?? null,
        ];
    }
    echo json_encode(['ok' => true, 'hosts' => $out, 'has_key' => !empty($sshKey) && file_exists($sshKey), 'ssh_key' => $sshKey]);
    exit;
}

// ── browse remote (SSH) ───────────────────────────────────────────────────────
if ($action === 'browse') {
    $slot = trim($_GET['host'] ?? '');
    $path = trim($_GET['path'] ?? '/mnt/user');
    if (!preg_match('#^/[^\0]*$#', $path) || str_contains($path, '..')) {
        echo json_encode(['ok' => false, 'error' => 'Invalid path']); exit;
    }
    ['ssh_key' => $sshKey] = _ms_caller();
    $t = _ms_target($slot, $sshKey);
    if (!$t['ok']) { echo json_encode($t); exit; }

    $clean  = rtrim($path, '/') ?: '/';
    $out    = vv_pt_ssh($t['ip'], $sshKey,
        'find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null',
        8);
    $dirs = array_values(array_filter(array_map('trim', explode("\n", $out))));
    sort($dirs);
    $dirs = array_slice($dirs, 0, 200);
    // If empty, do a quick SSH echo to distinguish "no dirs" from "SSH failed"
    if (empty($dirs)) {
        $ping = trim(vv_pt_ssh($t['ip'], $sshKey, 'echo ok', 4));
        if ($ping !== 'ok') {
            echo json_encode(['ok' => false, 'error' => 'SSH connection failed to ' . $t['hostname']]);
            exit;
        }
    }
    $parent = ($clean !== '/') ? (dirname($clean) ?: '/') : null;
    echo json_encode(['ok' => true, 'path' => $clean, 'dirs' => $dirs, 'parent' => $parent]);
    exit;
}

// ── browse local ──────────────────────────────────────────────────────────────
if ($action === 'browse_local') {
    $path = trim($_GET['path'] ?? '/mnt/user');
    if (!preg_match('#^/[^\0]*$#', $path) || str_contains($path, '..')) {
        echo json_encode(['ok' => false, 'error' => 'Invalid path']); exit;
    }
    $clean = rtrim($path, '/') ?: '/';
    if (!is_dir($clean)) {
        echo json_encode(['ok' => false, 'error' => 'Not a directory: ' . $clean]); exit;
    }
    $out  = shell_exec('find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -200') ?: '';
    $dirs = array_values(array_filter(array_map('trim', explode("\n", $out))));
    $parent = ($clean !== '/') ? (dirname($clean) ?: '/') : null;
    echo json_encode(['ok' => true, 'path' => $clean, 'dirs' => $dirs, 'parent' => $parent]);
    exit;
}

// ── run (background, returns token) ──────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'run') {
    $local      = trim($_POST['local']       ?? '');
    $slot       = trim($_POST['host']        ?? '');
    $remotePath = trim($_POST['remote_path'] ?? '');
    $user       = preg_replace('/[^a-z0-9_.-]/i', '', trim($_POST['user'] ?? 'root')) ?: 'root';
    $bwLimit    = max(0, (int)($_POST['bw_limit'] ?? 0));
    $useKey     = ($_POST['use_key'] ?? '1') !== '0';
    // Control characters are rejected outright — a newline would start a second command
    // inside the bash -c script this is spliced into. What survives is split on whitespace
    // and escaped per token below, so the shell never parses any of it as syntax.
    $rawFlags = trim($_POST['flags'] ?? '');
    if ($rawFlags !== '' && preg_match('/[\x00-\x1f\x7f]/', $rawFlags)) {
        echo json_encode(['ok' => false, 'error' => 'Invalid flags']); exit;
    }
    $flagList = $rawFlags !== ''
        ? preg_split('/\s+/', $rawFlags, -1, PREG_SPLIT_NO_EMPTY)
        : ['-av', '--stats'];

    foreach (['local' => $local, 'host' => $slot, 'remote_path' => $remotePath] as $f => $v) {
        if (!$v) { echo json_encode(['ok' => false, 'error' => 'Missing: ' . $f]); exit; }
    }
    foreach ([$local, $remotePath] as $p) {
        if (!str_starts_with($p, '/') || str_contains($p, '..') || preg_match('/[\x00\n\r`$]/', $p)) {
            echo json_encode(['ok' => false, 'error' => 'Invalid path: ' . $p]); exit;
        }
    }

    // ── Safeguards ────────────────────────────────────────────────────────────

    // Block syncing from/to dangerous system paths
    $blocked = ['/', '/proc', '/sys', '/dev', '/run', '/etc', '/bin', '/sbin',
                 '/usr', '/lib', '/lib64', '/boot/EFI', '/tmp'];
    foreach ($blocked as $b) {
        if (rtrim($local, '/') === $b) {
            echo json_encode(['ok' => false, 'error' => 'Refusing to sync from system path: ' . $b]); exit;
        }
        if (rtrim($remotePath, '/') === $b) {
            echo json_encode(['ok' => false, 'error' => 'Refusing to sync to system path: ' . $b]); exit;
        }
    }

    // Local source must exist
    if (!file_exists($local)) {
        echo json_encode(['ok' => false, 'error' => 'Local source does not exist: ' . $local]); exit;
    }

    // Prevent concurrent manual syncs
    foreach (glob('/tmp/vv_ms_*.pid') ?: [] as $pf) {
        $pid = (int)trim(@file_get_contents($pf) ?: '0');
        if ($pid > 0 && file_exists('/proc/' . $pid)) {
            echo json_encode(['ok' => false, 'error' => 'Another manual sync is already running — stop it first.']); exit;
        }
        @unlink($pf); // stale
    }

    ['ssh_key' => $sshKey] = _ms_caller();
    $t = _ms_target($slot, $sshKey);
    if (!$t['ok']) { echo json_encode($t); exit; }

    // Remote destination must exist
    $destCheck = trim(vv_pt_ssh($t['ip'], $sshKey,
        'test -d ' . escapeshellarg($remotePath) . ' && echo ok || echo missing', 5));
    if ($destCheck === 'missing') {
        echo json_encode(['ok' => false, 'error' => 'Remote destination does not exist: ' . $remotePath]); exit;
    }
    if ($destCheck !== 'ok') {
        // SSH check inconclusive — log warning but proceed; rsync will fail cleanly if needed
        // (e.g. Tailscale not yet up, rsync error will surface in output)
    }

    // ── Build and launch ──────────────────────────────────────────────────────

    $token   = bin2hex(random_bytes(8));
    $logFile = '/tmp/vv_ms_' . $token . '.log';
    $pidFile = '/tmp/vv_ms_' . $token . '.pid';

    if ($useKey && $sshKey && file_exists($sshKey)) {
        $sshOpts = 'ssh -i ' . escapeshellarg($sshKey) . ' -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10';
    } else {
        $sshOpts = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10';
    }
    if ($bwLimit) $flagList[] = '--bwlimit=' . (int)$bwLimit;
    $flags = implode(' ', array_map('escapeshellarg', $flagList));

    $src = escapeshellarg(rtrim($local, '/') . '/');
    $dst = escapeshellarg($user . '@' . $t['ip'] . ':' . rtrim($remotePath, '/') . '/');

    // Start rsync in background, capture its PID for stop support
    $inner = "rsync $flags -e " . escapeshellarg($sshOpts) . " $src $dst >> " . escapeshellarg($logFile) . " 2>&1 &"
           . " RSYNC_PID=\$!;"
           . " echo \$RSYNC_PID > " . escapeshellarg($pidFile) . ";"
           . " wait \$RSYNC_PID;"
           . " echo __DONE__ >> " . escapeshellarg($logFile) . ";"
           . " rm -f " . escapeshellarg($pidFile);
    shell_exec('nohup bash -c ' . escapeshellarg($inner) . ' &>/dev/null &');

    echo json_encode(['ok' => true, 'token' => $token]);
    exit;
}

// ── stop ──────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'stop') {
    $token = preg_replace('/[^a-f0-9]/', '', trim($_POST['token'] ?? ''));
    if (!$token || strlen($token) !== 16) {
        echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
    }
    $pidFile = '/tmp/vv_ms_' . $token . '.pid';
    $logFile = '/tmp/vv_ms_' . $token . '.log';

    $pid = (int)trim(@file_get_contents($pidFile) ?: '0');
    if ($pid > 0 && file_exists('/proc/' . $pid)) {
        shell_exec('kill -TERM ' . $pid . ' 2>/dev/null');
        usleep(400000); // 400ms grace
        if (file_exists('/proc/' . $pid)) shell_exec('kill -KILL ' . $pid . ' 2>/dev/null');
    }
    @unlink($pidFile);
    @file_put_contents($logFile, "\n\n--- Cancelled by user ---\n__DONE__\n", FILE_APPEND);
    echo json_encode(['ok' => true]);
    exit;
}

// ── poll ──────────────────────────────────────────────────────────────────────
if ($action === 'poll') {
    $token = preg_replace('/[^a-f0-9]/', '', trim($_GET['token'] ?? ''));
    if (!$token || strlen($token) !== 16) {
        echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
    }
    $logFile = '/tmp/vv_ms_' . $token . '.log';
    $pidFile = '/tmp/vv_ms_' . $token . '.pid';
    if (!file_exists($logFile)) {
        echo json_encode(['ok' => true, 'output' => '', 'done' => false, 'started' => false]); exit;
    }
    $content = file_get_contents($logFile) ?: '';
    $done    = str_contains($content, '__DONE__');
    if ($done) {
        $content = str_replace(['__DONE__', "\n\n\n"], ['', "\n\n"], $content);
        @unlink($logFile);
        @unlink($pidFile);
    }
    // Check if rsync process is actually alive (catches crashes without __DONE__)
    $pid = (int)trim(@file_get_contents($pidFile) ?: '0');
    if (!$done && $pid > 0 && !file_exists('/proc/' . $pid)) {
        $done    = true;
        $content .= "\n\n--- Process ended unexpectedly ---";
        @unlink($pidFile);
    }
    echo json_encode(['ok' => true, 'output' => $content, 'done' => $done, 'started' => true]);
    exit;
}

echo json_encode(['ok' => false, 'error' => 'Unknown action']);
