<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Script import. Browses the filesystem for .sh files and moves a chosen one into
//   CUSTOM_SCRIPTS_DIR, so an existing script can be brought under Varaverk's scheduler
//   without retyping it.
//
// OPERATIONAL MODEL
//   Two actions on one URL: a GET browser and a POST import. The browser is rooted at / and
//   walks one directory at a time, because the scripts people want to import live wherever
//   they happened to put them — most often the User Scripts plugin's own folders.
//
//   This is a move, not a copy. The source is deleted once the destination is verified, so
//   there is exactly one copy afterwards and no chance of editing the wrong one. That is also
//   why the verification is so deliberate: a move that half-succeeds destroys the only copy.
//
// DESIGN PRINCIPLES
//   Copy, verify, then delete — in that order, always.
//       copy() first, then a size comparison and a SHA-256 of both files, and only then the
//       unlink. Source and destination are routinely on different filesystems, where rename()
//       is not atomic and a partial write is a real outcome rather than a theoretical one.
//
//   A failed verification leaves the source untouched.
//       The destination is removed and the source is left exactly where it was. Between
//       losing the import and losing the script, the import is the acceptable loss.
//
//   An undeletable source is a warning, not a failure.
//       If the copy verified but the original could not be removed — read-only mount,
//       permissions — the import is reported successful with a warning naming the file to
//       clean up. The script works from its new home either way, and failing the whole
//       operation would leave the user with two copies and an error message.
//
//   Directory listings are capped and sorted.
//       300 entries each for directories and .sh files. A browser rooted at / will eventually
//       be pointed at something enormous.
//
// OPERATIONAL SAFEGUARDS
//   Refuses to import from inside the Varaverk repo.
//       Both paths are resolved with realpath() and compared by prefix. Importing a tracked
//       file would delete it out from under git with no commit recording it — the next pull
//       would either restore it as a phantom or report a deletion nobody made. This check is
//       the reason realpath() is used rather than the submitted string: a symlink into the
//       repo would otherwise slip past a textual comparison.
//
//   Refuses a source already inside CUSTOM_SCRIPTS_DIR.
//       Also compared after realpath(). Without it, the move would copy a file onto itself
//       and then delete it.
//
//   Refuses to overwrite an existing custom script.
//       file_exists() on the destination aborts with the conflicting name. A silent overwrite
//       here would destroy a script that may already be scheduled and running.
//
//   Both paths are validated as absolute with no traversal.
//       ^/[^\0]*$ for the browse path and ^/[^\0]*\.sh$ for the import, plus an explicit '..'
//       check on each. Null bytes are excluded by the character class, which matters because
//       these strings reach both the filesystem and a shell.
//
//   Both find invocations escape their argument, and neither takes anything else from the
//   request — depth, type and name filters are all literals.
//
//   The destination is made executable before it is reported.
//       chmod 0755 after verification, so a freshly imported script is immediately runnable
//       rather than failing the first time it is scheduled.
//
//   Existence is confirmed before work begins — is_dir() for browse, is_file() for import —
//   so a bad path returns a named error rather than a warning leaking into the JSON body.
//
//   Accepted exposure: the browser can list any directory on the host.
//       It returns directory names and .sh filenames only — no file contents, and nothing
//       outside those two types. That is the minimum a file picker rooted at / can do. browse
//       is a GET read, so the WebGUI session is its whole boundary; the import itself is POST
//       and is CSRF-guarded by Unraid's auto_prepend. See README-unraid.md.
//
// REQUEST
//   GET  ?action=browse&path=/absolute/dir     list subdirectories and .sh files
//   POST action=import  path=/absolute/file.sh move it into CUSTOM_SCRIPTS_DIR
//
// RESPONSE
//   browse  {"ok":true,"path","dirs":[…],"files":[…],"parent":…}
//   import  {"ok":true,"id":"Custom/<name>.sh"}  optionally with a "warning"
//   {"ok":false,"error":"Invalid path"|"Not a directory: …"|"Invalid script path"
//                      |"Not found: …"|"Could not resolve path"
//                      |"Refusing to import from inside the Varaverk repo …"
//                      |"Already in Custom Scripts."|"A script named \"…\" already exists …"
//                      |"Copy failed"|"Copy verification failed — source left untouched"
//                      |"Invalid request"}
//
// DEPENDS ON
//   include/config.php   SCRIPTS_DIR, CUSTOM_SCRIPTS_DIR
//   api/script.php       manages the scripts once they are here
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/config.php';

// ── browse (GET): list subdirectories and .sh files at $path, rooted at / ─────
if ($_SERVER['REQUEST_METHOD'] === 'GET' && ($_GET['action'] ?? '') === 'browse') {
    $path = trim($_GET['path'] ?? '/');
    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;
    }

    $dirOut = shell_exec('find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -300') ?: '';
    $dirs   = array_values(array_filter(array_map('trim', explode("\n", $dirOut))));

    $fileOut = shell_exec('find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type f -iname "*.sh" 2>/dev/null | sort | head -300') ?: '';
    $files   = array_values(array_filter(array_map('trim', explode("\n", $fileOut))));

    $parent = ($clean !== '/') ? (dirname($clean) ?: '/') : null;
    echo json_encode(['ok' => true, 'path' => $clean, 'dirs' => $dirs, 'files' => $files, 'parent' => $parent]);
    exit;
}

// ── import (POST): move the chosen .sh file into CUSTOM_SCRIPTS_DIR ───────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'import') {
    $src = trim($_POST['path'] ?? '');
    if (!preg_match('#^/[^\0]*\.sh$#i', $src) || str_contains($src, '..')) {
        echo json_encode(['ok' => false, 'error' => 'Invalid script path']);
        exit;
    }
    if (!is_file($src)) {
        echo json_encode(['ok' => false, 'error' => 'Not found: ' . $src]);
        exit;
    }

    $srcReal = realpath($src);
    if ($srcReal === false) {
        echo json_encode(['ok' => false, 'error' => 'Could not resolve path']);
        exit;
    }

    // Refuse to move a file out of the git-tracked repo — that would delete a
    // tracked file out from under git without a commit recording it.
    $repoReal = realpath(SCRIPTS_DIR);
    if ($repoReal && str_starts_with($srcReal, $repoReal . '/')) {
        echo json_encode(['ok' => false, 'error' => 'Refusing to import from inside the Varaverk repo — that would delete a git-tracked file.']);
        exit;
    }

    // Already there — nothing to do.
    $customReal = realpath(CUSTOM_SCRIPTS_DIR) ?: CUSTOM_SCRIPTS_DIR;
    if (str_starts_with($srcReal, rtrim($customReal, '/') . '/')) {
        echo json_encode(['ok' => false, 'error' => 'Already in Custom Scripts.']);
        exit;
    }

    if (!is_dir(CUSTOM_SCRIPTS_DIR)) mkdir(CUSTOM_SCRIPTS_DIR, 0755, true);

    $name = basename($srcReal);
    $dest = CUSTOM_SCRIPTS_DIR . '/' . $name;
    if (file_exists($dest)) {
        echo json_encode(['ok' => false, 'error' => "A script named \"$name\" already exists in Custom Scripts."]);
        exit;
    }

    // Copy across filesystems, verify, THEN delete the source — never remove the
    // only copy on a failed or partial copy.
    if (!copy($srcReal, $dest)) {
        @unlink($dest);
        echo json_encode(['ok' => false, 'error' => 'Copy failed']);
        exit;
    }
    if (filesize($srcReal) !== filesize($dest) || hash_file('sha256', $srcReal) !== hash_file('sha256', $dest)) {
        @unlink($dest);
        echo json_encode(['ok' => false, 'error' => 'Copy verification failed — source left untouched']);
        exit;
    }
    chmod($dest, 0755);

    if (!@unlink($srcReal)) {
        // Copied and verified but couldn't remove the original (permissions, read-only
        // mount). The script is usable from its new home either way — surface a warning
        // rather than failing the import outright.
        echo json_encode([
            'ok'      => true,
            'id'      => 'Custom/' . $name,
            'warning' => 'Imported, but could not delete the original at ' . $srcReal . ' — remove it manually.',
        ]);
        exit;
    }

    echo json_encode(['ok' => true, 'id' => 'Custom/' . $name]);
    exit;
}

echo json_encode(['ok' => false, 'error' => 'Invalid request']);
