Files

198 lines
9.8 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Moves a script between orchestrators. Removes its line from whichever *_SCRIPTS array in
// master.conf currently holds it and inserts it into the named one — or into none, which
// removes it from every orchestrator.
//
// OPERATIONAL MODEL
// Line surgery on master.conf, not a re-serialisation. The file is read as lines, one line
// is relocated, and the rest is written back byte for byte. master.conf is hand-maintained
// and full of comments, grouping and deliberate ordering that no round trip through a
// parser would preserve.
//
// Two passes, in order: remove first, then insert. Doing it in one pass would need to know
// whether the target array comes before or after the source, and getting that wrong would
// either duplicate the entry or drop it.
//
// Position within the target array is the end, immediately before its closing paren.
// Orchestrators run their arrays in order, so appending is the only placement that does not
// silently reorder someone else's work.
//
// DESIGN PRINCIPLES
// Move is remove-plus-insert, and remove alone is a valid operation.
// An empty to_array performs only the removal pass, which is how a script is taken out
// of every orchestrator. That is a distinct intent from conf_toggle.php's commenting
// out — this removes the line, that disables it in place.
//
// Entry text moves verbatim; only a fresh entry is written from the bare path.
// An entry may carry inline arguments ("Media/media_cleaner.sh anime"), a trailing
// comment and its own indentation. Regenerating the line from the script path drops
// all three — six live entries in master.conf carry arguments, and stripping them
// would leave media_cleaner.sh with no share and fallback.sh without --stop. The
// matched line is therefore carried across untouched, which is the same rule
// reorderarray.php follows. A script that was in no array is written fresh, indented
// to match the entries already in the target.
//
// One path may hold several entries, and they move together.
// "Media/media_cleaner.sh anime" and "… media" are two jobs sharing a path. Every
// match is collected and re-inserted, rather than collapsing them into one.
//
// A move that finds nothing to move still succeeds.
// The removal pass is best-effort; only a missing *target* is an error. A script that
// was in no array is simply added to the one requested.
//
// OPERATIONAL SAFEGUARDS
// POST only, checked before any parameter is read.
//
// Both inputs are pattern-matched, and neither is used as a path.
// The script must match ^[A-Za-z0-9_.\-/]+\.sh$ with an explicit '..' check, and the
// array name ^[A-Z_]+_SCRIPTS$. Both then reach the matcher only through preg_quote(),
// so they are needles matched against existing lines — nothing is opened or executed
// from either.
//
// The scope of both passes is bounded to array bodies.
// Each pass tracks whether it is inside a *_SCRIPTS=( block and ignores every line
// outside one, so a matching string in a comment or an unrelated variable is never
// moved or displaced.
//
// A missing target array aborts before the write.
// If the insert pass never finds the target, the endpoint returns an error and writes
// nothing — the removal is discarded with it. Without that check a typo'd array name
// would silently delete the script from the orchestrator it was in.
//
// master.conf is confirmed present and readable before either pass.
// Both file_exists() and the file() result are checked, so a missing or unreadable conf
// returns a named error rather than writing a file built from an empty line list.
//
// The write is atomic.
// vv_write_conf_raw() writes .vv.tmp and rename()s. Every script sources master.conf,
// so a truncated write here would be a system-wide outage rather than a lost move.
//
// The push happens only after a confirmed write, and its result is returned.
// A partner that did not receive the move is exactly the state that makes one host run
// a script the other does not, so the per-host outcome is reported rather than
// discarded.
//
// REQUEST
// POST script=<Category/name.sh> to_array=<NAME>_SCRIPTS
// POST script=<Category/name.sh> to_array= remove from all arrays
//
// RESPONSE
// {"ok":true,"push":[{"host","ok","ready","error"}, …]}
// {"ok":false,"error":"POST only"|"Invalid script"|"Invalid array name"
// |"master.conf not found"|"Could not read master.conf"
// |"Target array \"…\" not found in master.conf"|"Write failed"}
//
// DEPENDS ON
// include/config.php CONF_DIR, vv_write_conf_raw(), vv_push_master_conf(),
// vv_push_setup_state()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$script = trim($_POST['script'] ?? '');
$toArray = trim($_POST['to_array'] ?? '');
if (!$script || str_contains($script, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $script)) {
echo json_encode(['ok' => false, 'error' => 'Invalid script']);
exit;
}
if ($toArray && !preg_match('/^[A-Z_]+_SCRIPTS$/', $toArray)) {
echo json_encode(['ok' => false, 'error' => 'Invalid array name']);
exit;
}
$confPath = CONF_DIR . '/master.conf';
if (!file_exists($confPath)) {
echo json_encode(['ok' => false, 'error' => 'master.conf not found']);
exit;
}
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
// Captured before anything is rebuilt from it — this is what the write compares against to
// prove master.conf did not change while the move was being worked out.
$origConf = implode('', $lines ?: []);
if (!$lines) {
echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
exit;
}
$scriptEsc = preg_quote($script, '/');
$removedLines = [];
$inArray = false;
// Step 1: find and remove the script's line(s) from whatever array they are in.
//
// The original text is carried across verbatim. An entry is not just a path — it may hold
// inline arguments ("Media/media_cleaner.sh anime"), a trailing comment, and the file's
// indentation, and regenerating the line from the bare path silently drops all three. Six
// live entries in master.conf carry arguments; a move that strips them would leave
// media_cleaner.sh with no share to clean and fallback.sh without --stop.
//
// reorderarray.php preserves entry text for exactly this reason. A move must not be the one
// operation that loses it.
//
// A path can legitimately appear more than once in the same array with different arguments,
// so every match is collected and re-inserted together rather than collapsing to one.
$newLines = [];
foreach ($lines as $line) {
if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
if ($inArray && preg_match('/^\s*(?:#\s*)?"' . $scriptEsc . '(?:\s[^"]*)?"/', $line)) {
$removedLines[] = $line;
continue; // drop from current location
}
$newLines[] = $line;
}
// Step 2: insert into target array (if specified).
if ($toArray) {
$resultLines = [];
$inTarget = false;
$inserted = false;
$indent = ' '; // master.conf indents array entries eight spaces
foreach ($newLines as $line) {
if (preg_match('/^\s*' . preg_quote($toArray, '/') . '\s*=\s*\(/', $line)) $inTarget = true;
// Match the indentation the target array actually uses rather than assuming it.
if ($inTarget && preg_match('/^(\s+)(?:#\s*)?"/', $line, $im)) $indent = $im[1];
if ($inTarget && !$inserted && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) {
foreach ($removedLines ?: [$indent . '"' . $script . '"' . "\n"] as $moved) {
$resultLines[] = $moved;
}
$inTarget = false;
$inserted = true;
}
$resultLines[] = $line;
}
if (!$inserted) {
echo json_encode(['ok' => false, 'error' => 'Target array "' . $toArray . '" not found in master.conf']);
exit;
}
$newLines = $resultLines;
}
// The array was rebuilt from a copy read before the lock was taken. Handing vv_conf_edit() a
// closure that compares against the current contents turns that into an optimistic check: if
// anything changed master.conf in between, the move is abandoned rather than written over the
// top of it. Everything else — backup, bash -n, read-back, audit — comes with the shared path.
$ok = vv_conf_edit('master.conf', fn(string $cur): ?string =>
$cur === $origConf ? implode('', $newLines) : null, [], [$script]);
if (!$ok) {
echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit;
}
// Reported rather than discarded — a partner that did not receive the move is exactly the
// state that makes one host run a script the other does not. Mirrors rawconf/confform.
$push = vv_push_master_conf();
vv_push_setup_state();
echo json_encode(['ok' => true, 'push' => $push]);