// POST id= changes= // // RESPONSE // GET {"ok":true,"groups":[…]} // POST {"ok":bool,"files":{"":bool, …},"push":[{"host","ok","ready","error"}, …]} // {"ok":false,"error":"Invalid id"|"Missing id"|"Invalid changes" // |"Unauthorized file: …"|"Invalid key: …"|"Method not allowed"} // // DEPENDS ON // include/confform.php vv_conf_fields_for_script(), vv_conf_write_changes() // include/config.php vv_get_conf_files(), vv_push_master_conf(), vv_push_setup_state() // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/scheduler.php'; require_once dirname(__DIR__) . '/include/confform.php'; if ($_SERVER['REQUEST_METHOD'] === 'GET') { // Section-scoped read. A page that owns a subject rather than a script — the AI tab, and // partnership before it — wants the sections whose header names that subject, across every // conf file it is allowed to see. Same fields, same shape, same write path back; only the // question of "which fields" differs, so it is a mode here rather than a second endpoint // with its own copy of the allowlist and the master push. $match = trim($_GET['sections'] ?? ''); if ($match !== '') { // Whole word, case-insensitive. A plain substring is far too loose on these headers — // "ai" alone also selects Maintenance, Containers, Failover and Arr Failed/Stalled // Recovery, which is nine wrong sections out of twenty-one and every one of them looks // deliberate once it is on the page. // // preg_quote first: the needle arrives from a query string, so it is matched as a literal // with boundaries around it rather than as a pattern a caller could widen to everything. $re = '/\b' . preg_quote($match, '/') . '\b/i'; $out = []; foreach (vv_get_conf_files() as $f) { foreach (vv_conf_all_groups($f) as $g) { if (preg_match($re, (string) ($g['subsection'] ?? ''))) $out[] = $g; } } echo json_encode(['ok' => true, 'groups' => $out]); exit; } $id = trim($_GET['id'] ?? ''); if (!$id || str_contains($id, '..')) { echo json_encode(['ok' => false, 'error' => 'Invalid id']); exit; } $groups = vv_conf_fields_for_script($id); echo json_encode(['ok' => true, 'groups' => $groups]); exit; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $id = trim($_POST['id'] ?? ''); $rawJson = $_POST['changes'] ?? '[]'; // Optional. It labels which script's form was open and is used nowhere in the write — every // change already names its own file and key, and those are what is validated below. A // section-scoped save has no script to name, and inventing one so this check would pass // would be a guard that only ever guarded against itself. $changes = json_decode($rawJson, true); if (!is_array($changes)) { echo json_encode(['ok' => false, 'error' => 'Invalid changes']); exit; } $allowed = vv_get_conf_files(); foreach ($changes as $c) { if (empty($c['file']) || !in_array($c['file'], $allowed, true)) { echo json_encode(['ok' => false, 'error' => 'Unauthorized file: ' . ($c['file'] ?? '')]); exit; } if (empty($c['key']) || !preg_match('/^[A-Z_][A-Z0-9_]*$/', $c['key'])) { echo json_encode(['ok' => false, 'error' => 'Invalid key: ' . ($c['key'] ?? '')]); exit; } } // Declared before it is passed. It is a by-reference array parameter, and an undefined // variable arrives there as null — which under PHP 8 is a TypeError thrown before a single // byte is written, so every save through this endpoint died with a 500 and the page saw an // unparseable response rather than a refusal it could report. The callers that pass no // second argument were never affected, which is why it survived: this is the only one. $rejected = []; $results = vv_conf_write_changes($changes, $rejected); // Propagate master.conf to partner hosts when the owner edits it (mirrors rawconf.php). $push = []; if (($results['master.conf'] ?? false) === true) { $push = vv_push_master_conf(); vv_push_setup_state(); } // A refused change never reaches a file, so it leaves no false in $results — a save whose // only change was refused used to answer ok:true and show the operator their old value back // with no explanation. Refusals are failures here and they are named: the whole point of the // path guard is that someone learns their edit would have widened a delete target. echo json_encode(['ok' => !in_array(false, $results, true) && !$rejected, 'files' => $results, 'rejected' => $rejected, 'error' => $rejected ? 'Refused: ' . implode(', ', array_map(fn($r) => $r['key'] . ' (' . $r['reason'] . ')', $rejected)) : null, 'push' => $push]); exit; } echo json_encode(['ok' => false, 'error' => 'Method not allowed']);