to_array=_SCRIPTS // POST script= 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]);