Eleven call sites wrote master.conf with tmp+rename and nothing else — no backup, no parse check, no audit — including the two toggles the UI uses most and the raw editor that installs a whole hand-edited file.
206 lines
9.1 KiB
PHP
206 lines
9.1 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Rewrites one *_SCRIPTS array in master.conf with a new order and a new enabled/disabled
|
|
// state per entry — the drag-to-reorder on the scheduler page's orchestrator view.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Order is behaviour, not presentation. An orchestrator runs its array top to bottom, so
|
|
// moving an entry changes when that script runs relative to the others — which is why this
|
|
// writes to master.conf rather than to a UI preference.
|
|
//
|
|
// The array block is located, its entries harvested, and the whole block replaced. Only the
|
|
// lines between the opening and closing parens are touched; everything else in master.conf
|
|
// is written back byte for byte, because the file is hand-maintained and full of comments
|
|
// and grouping no round trip through a parser would preserve.
|
|
//
|
|
// Disabled entries stay in the file, commented. Enabled state is expressed by the presence
|
|
// or absence of a leading '# ', the same convention conf_toggle.php uses.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Original entry text is preserved, not regenerated.
|
|
// Entries are harvested from the existing block keyed by script path, and reused
|
|
// verbatim when the same path appears in the new order. That is what keeps inline flags
|
|
// and arguments — "Media/cleanup.sh --deep" — through a reorder. Only a script that was
|
|
// not previously in the array is written fresh, as a bare quoted path.
|
|
//
|
|
// The opening and closing lines are preserved exactly.
|
|
// Both are carried over untouched rather than rebuilt, so a trailing comment on the
|
|
// array declaration survives.
|
|
//
|
|
// Absent means removed.
|
|
// A script in the file but not in the submitted order is dropped from the array. The
|
|
// page always submits the complete list, so absence is an instruction, not an omission.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// POST only, checked before any parameter is read.
|
|
//
|
|
// The array name is pattern-matched and only ever used as a needle.
|
|
// ^[A-Z_]+_SCRIPTS$, then preg_quote()d and matched against existing lines. Nothing is
|
|
// opened or executed from it.
|
|
//
|
|
// Every entry is validated, and an invalid one fails the request rather than being skipped.
|
|
// ^[A-Za-z0-9_.\-/]+\.sh$ with an explicit '..' check per entry. Skipping would be
|
|
// actively dangerous here: the array is rebuilt from the validated list alone, so a
|
|
// silently dropped entry is a script silently removed from its orchestrator. Validation
|
|
// completes before anything is written.
|
|
//
|
|
// A missing array aborts before the write.
|
|
// Both the block start and end must be found, otherwise the endpoint returns a named
|
|
// error and writes nothing. Without that, a typo'd array name would splice a block into
|
|
// an undefined position.
|
|
//
|
|
// master.conf is confirmed present and readable before either pass.
|
|
//
|
|
// 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 reorder.
|
|
//
|
|
// The push happens only after a confirmed write, and its result is returned — a partner
|
|
// that did not receive the new order runs these scripts in a different sequence.
|
|
//
|
|
// Known limit: block detection counts parens textually.
|
|
// depth is tracked with substr_count over '(' and ')', which does not know about quotes
|
|
// or comments. An entry whose arguments contained an unbalanced paren would end the
|
|
// block early. No current entry does, and the alternative is a bash parser — but a
|
|
// future entry with a paren in its arguments would be the thing that broke this.
|
|
//
|
|
// REQUEST
|
|
// POST array_name=<NAME>_SCRIPTS
|
|
// scripts=<JSON array of {"id":"Category/name.sh","enabled":bool}>
|
|
//
|
|
// RESPONSE
|
|
// {"ok":true,"push":[{"host","ok","ready","error"}, …]}
|
|
// {"ok":false,"error":"POST only"|"Invalid array_name"|"Invalid scripts JSON"
|
|
// |"Invalid script id: …"|"master.conf not found"
|
|
// |"Could not read master.conf"|"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;
|
|
}
|
|
|
|
$arrayName = trim($_POST['array_name'] ?? '');
|
|
$raw = $_POST['scripts'] ?? '';
|
|
$decoded = json_decode($raw, true);
|
|
|
|
if (!$arrayName || !preg_match('/^[A-Z_]+_SCRIPTS$/', $arrayName)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid array_name']);
|
|
exit;
|
|
}
|
|
if (!is_array($decoded)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid scripts JSON']);
|
|
exit;
|
|
}
|
|
|
|
// Validate every entry before anything is written. A rejected entry cannot be skipped here:
|
|
// this endpoint rewrites the array from $order alone, so a silently dropped entry is a script
|
|
// silently removed from its orchestrator.
|
|
$order = [];
|
|
foreach ($decoded as $item) {
|
|
$id = trim((string)($item['id'] ?? ''));
|
|
$enabled = (bool)($item['enabled'] ?? true);
|
|
if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid script id: ' . $id]);
|
|
exit;
|
|
}
|
|
$order[] = ['id' => $id, 'enabled' => $enabled];
|
|
}
|
|
|
|
$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 array_splice rewrites the block in place — this is what the write compares
|
|
// against to prove master.conf did not change while the new order was being assembled.
|
|
$origConf = implode('', $lines ?: []);
|
|
if (!$lines) {
|
|
echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
|
|
exit;
|
|
}
|
|
|
|
// Find the array block and extract original entry lines keyed by script path.
|
|
$arrayEsc = preg_quote($arrayName, '/');
|
|
$blockStart = null;
|
|
$blockEnd = null;
|
|
$depth = 0;
|
|
$origEntries = []; // path → original trimmed content line (e.g. '"Daily/script.sh --flag"')
|
|
|
|
foreach ($lines as $i => $line) {
|
|
if ($blockStart === null) {
|
|
if (preg_match('/^\s*' . $arrayEsc . '\s*=\s*\(/', $line)) {
|
|
$blockStart = $i;
|
|
$depth = 1;
|
|
}
|
|
continue;
|
|
}
|
|
$depth += substr_count($line, '(');
|
|
$depth -= substr_count($line, ')');
|
|
if ($depth <= 0) {
|
|
$blockEnd = $i;
|
|
break;
|
|
}
|
|
// Collect entries (enabled and commented)
|
|
if (preg_match('/^\s*(?:#\s*)?"([^"]+)"/', $line, $m)) {
|
|
$parts = preg_split('/\s+/', trim($m[1]));
|
|
$path = $parts[0] ?? '';
|
|
if (substr($path, -3) === '.sh' && !isset($origEntries[$path])) {
|
|
// Store the full quoted expression (may include flags after the path)
|
|
$origEntries[$path] = '"' . $m[1] . '"';
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($blockStart === null || $blockEnd === null) {
|
|
echo json_encode(['ok' => false, 'error' => "Array $arrayName not found in master.conf"]);
|
|
exit;
|
|
}
|
|
|
|
// Build replacement block lines
|
|
$newBlockLines = [];
|
|
// Preserve the opening line exactly (e.g. "DAILY_SCRIPTS=(")
|
|
$newBlockLines[] = $lines[$blockStart];
|
|
|
|
foreach ($order as $item) {
|
|
$id = $item['id'];
|
|
$enabled = $item['enabled'];
|
|
$entry = $origEntries[$id] ?? '"' . $id . '"';
|
|
$prefix = $enabled ? ' ' : ' # ';
|
|
$newBlockLines[] = $prefix . $entry . "\n";
|
|
}
|
|
|
|
// Preserve the closing line exactly
|
|
$newBlockLines[] = $lines[$blockEnd];
|
|
|
|
// Replace the original block in $lines
|
|
array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlockLines);
|
|
|
|
// See movescript.php: the block was rebuilt from a copy read before the lock, so the closure
|
|
// compares against the current contents and abandons the reorder if anything changed in
|
|
// between. Backup, bash -n, read-back and audit come with the shared path.
|
|
$ok = vv_conf_edit('master.conf', fn(string $cur): ?string =>
|
|
$cur === $origConf ? implode('', $lines) : null, [], [$arrayName]);
|
|
|
|
if (!$ok) {
|
|
echo json_encode(['ok' => false, 'error' => 'Write failed']);
|
|
exit;
|
|
}
|
|
|
|
// Reported rather than discarded — a partner that did not receive the new order is a partner
|
|
// running these scripts in a different sequence. Mirrors rawconf/confform/movescript.
|
|
$push = vv_push_master_conf();
|
|
vv_push_setup_state();
|
|
echo json_encode(['ok' => true, 'push' => $push]);
|