<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Raw conf read and write. Serves the full text of one configuration file and saves it
//   back, and — when the file is master.conf — distributes the result to every partner host.
//
// OPERATIONAL MODEL
//   Read and write on one URL, split on method, sharing one allowlist. GET also returns the
//   allowlist itself, so the editor can populate its file picker from the same authority that
//   will later authorise the save. The two can therefore never disagree about what this host
//   is permitted to edit.
//
//   master.conf is shared; host*.conf is not. A master.conf save is followed by a push to
//   every partner, because a threshold or toggle that differs between hosts produces
//   behaviour neither side expects. A host conf is local by definition and is never pushed.
//
// DESIGN PRINCIPLES
//   The allowlist encodes sparse checkout.
//       vv_get_conf_files() returns master.conf plus its own host conf on HOST1, and its own
//       conf alone elsewhere. That is the same split git enforces at checkout — HOST2 has no
//       host1.conf to read, and this endpoint will not name one either.
//
//   master.conf defaults on read, nothing defaults on write.
//       GET with no file returns master.conf, because that is what the editor opens to. POST
//       has no default: a save must name its target explicitly.
//
//   Push results travel with the response.
//       Per-host outcomes are returned rather than logged, so the page can show that a partner
//       did not receive the change instead of leaving the two hosts quietly divergent.
//
// OPERATIONAL SAFEGUARDS
//   Exact allowlist membership with strict comparison, on both paths.
//       in_array(..., true) against vv_get_conf_files() — not a pattern, not basename(). A
//       filename that is not literally one of the permitted strings is rejected, which makes
//       traversal and absolute paths unreachable rather than merely filtered. The redundant
//       '..' check is kept as a second, explicit statement of intent.
//
//   The content is syntax-checked before it can replace a working file.
//       Conf files are sourced by every script in the system, and master.conf is pushed from
//       here to every partner — so a stray quote saved through this endpoint would not just
//       break this host's orchestrators, watchdogs and fallback, it would distribute that
//       break across the mesh. `bash -n` on a private temp copy is checked first, and a file
//       that does not parse is refused with the previous version left untouched.
//
//   The temp copy is created with tempnam() and always removed, so a rejected save cannot
//   leave a stray file beside the real conf for a script to source.
//
//   The real write is atomic.
//       vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf during
//       the save reads either the old file or the new one, never a half-written one.
//
//   The push only happens after a confirmed write.
//       Guarded on $written, so a failed save cannot distribute a stale or partly-written
//       master.conf. The push is also a no-op on a host with no SSH key, which is what stops a
//       partner from overwriting the owner's file.
//
//   Unknown methods are refused explicitly, so a PUT or DELETE cannot fall through the two
//   handled blocks into an empty 200.
//
// REQUEST
//   GET  ?file=<allowed conf name>          defaults to master.conf
//   POST file=<allowed conf name>  content=<full file text>
//
// RESPONSE
//   GET   {"ok":true,"content":"…","file":"…","allowed":["…"]}
//   POST  {"ok":true,"push":[{"host","ok","ready","error"}, …]}
//         push is empty for host confs and on hosts with no partners
//   {"ok":false,"error":"Not allowed"|"Syntax error: …"|"Method not allowed"}
//
// DEPENDS ON
//   include/config.php   vv_get_conf_files(), vv_read_conf_raw(), vv_write_conf_raw(),
//                        vv_push_master_conf(), vv_push_setup_state()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$allowed = vv_get_conf_files();

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    $file = trim($_GET['file'] ?? 'master.conf');
    if (!in_array($file, $allowed, true) || str_contains($file, '..')) {
        echo json_encode(['ok' => false, 'error' => 'Not allowed']);
        exit;
    }
    echo json_encode(['ok' => true, 'content' => vv_read_conf_raw($file), 'file' => $file, 'allowed' => $allowed]);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $file    = trim($_POST['file'] ?? '');
    $content = $_POST['content'] ?? '';
    if (!in_array($file, $allowed, true) || str_contains($file, '..')) {
        echo json_encode(['ok' => false, 'error' => 'Not allowed']);
        exit;
    }
    // Every script sources these, and master.conf is pushed to every partner from here — a
    // syntax error saved through this endpoint would propagate the outage across the mesh.
    $check = tempnam(sys_get_temp_dir(), 'vvconf');
    if ($check !== false) {
        file_put_contents($check, $content);
        $out = []; $rc = 0;
        exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc);
        @unlink($check);
        if ($rc !== 0) {
            $msg = implode(' ', array_filter(array_map('trim', $out)));
            echo json_encode([
                'ok'    => false,
                'error' => 'Syntax error: ' . str_replace($check, $file, $msg ?: 'conf does not parse'),
                'push'  => [],
            ]);
            exit;
        }
    }

    $written = vv_write_conf_raw($file, $content);
    $push    = [];
    if ($written && $file === 'master.conf') {
        $push = vv_push_master_conf();
        vv_push_setup_state();
    }
    echo json_encode(['ok' => $written, 'push' => $push]);
    exit;
}

echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
