198 lines
9.8 KiB
PHP
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]);
|