Files
AvianVisitors/avian/api/config.php
T
Twarner491 87981a62fb [PROD]
2026-05-28 15:15:22 -07:00

195 lines
7.7 KiB
PHP

<?php
// AvianVisitors - read/write a small, whitelisted subset of BirdNET-Pi
// settings from the admin overlay's settings panel. Fetched by the
// frontend at /avian/api/config.php.
//
// Endpoints:
// GET -> returns current values as JSON.
// POST -> JSON body with any whitelisted key. Writes through to
// birdnet.conf and restarts birdnet_analysis + birdnet_recording
// so the changes take effect immediately.
//
// Default LAN deploy: returns data immediately, no auth.
// Forwarded deploy: set AV_REQUIRE_AUTH=1 (env) AND configure Caddy
// basic_auth on /avian/api/.
//
// Restart requires passwordless sudo for the caddy user that runs
// php-fpm, dropped in place by install_services.sh at
// /etc/sudoers.d/020_avian-admin.
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
if (getenv('AV_REQUIRE_AUTH') === '1' && empty($_SERVER['HTTP_AUTHORIZATION'])) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized']);
exit;
}
// Path layout: /home/{USER}/BirdNET-Pi/avian/api/config.php
$BIRDNETPI_DIR = dirname(__DIR__, 2);
$CONF_PATH = "$BIRDNETPI_DIR/birdnet.conf";
// Whitelist: { config_key => { type, min?, max?, restart? } }
$ALLOWED = [
'CONFIDENCE' => ['type' => 'float', 'min' => 0.05, 'max' => 0.99, 'restart' => true],
'SENSITIVITY' => ['type' => 'float', 'min' => 0.5, 'max' => 1.5, 'restart' => true],
'SF_THRESH' => ['type' => 'float', 'min' => 0.0, 'max' => 1.0, 'restart' => true],
'OVERLAP' => ['type' => 'float', 'min' => 0.0, 'max' => 2.5, 'restart' => true],
'MAX_FILES_SPECIES' => ['type' => 'int', 'min' => 0, 'max' => 100000],
'FULL_DISK' => ['type' => 'enum', 'values' => ['purge', 'keep']],
'PURGE_THRESHOLD' => ['type' => 'int', 'min' => 50, 'max' => 99],
'LATITUDE' => ['type' => 'float', 'min' => -90, 'max' => 90, 'restart' => true],
'LONGITUDE' => ['type' => 'float', 'min' => -180, 'max' => 180, 'restart' => true],
'SITE_NAME' => ['type' => 'string', 'maxlen' => 60],
];
function read_conf(string $path): array {
if (!is_readable($path)) return [];
$out = [];
foreach (file($path, FILE_IGNORE_NEW_LINES) as $line) {
if (!$line || $line[0] === '#') continue;
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i', $line, $m)) {
$val = trim($m[2]);
if (strlen($val) >= 2 && $val[0] === '"' && substr($val, -1) === '"') {
$val = substr($val, 1, -1);
}
$out[$m[1]] = $val;
}
}
return $out;
}
function write_conf(string $path, array $updates): bool {
if (!is_writable($path) && !is_writable(dirname($path))) return false;
$lines = is_readable($path) ? file($path, FILE_IGNORE_NEW_LINES) : [];
$seen = [];
foreach ($lines as $i => $line) {
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=/i', $line, $m)) {
$k = $m[1];
if (array_key_exists($k, $updates)) {
$lines[$i] = $k . '=' . quote_val($updates[$k]);
$seen[$k] = true;
}
}
}
foreach ($updates as $k => $v) {
if (empty($seen[$k])) $lines[] = $k . '=' . quote_val($v);
}
$tmp = $path . '.tmp.' . getmypid();
if (file_put_contents($tmp, implode("\n", $lines) . "\n") === false) return false;
return rename($tmp, $path);
}
function quote_val($v): string {
$s = (string)$v;
// Bare value if it's all "shell-safe" characters.
if ($s === '' || preg_match('/[^A-Za-z0-9._\/+-]/', $s)) {
// Quoted form: escape backslash, double-quote, dollar, and backtick.
// birdnet.conf is `source`d by BirdNET-Pi shell scripts and bash
// expands $(), $VAR, `cmd` inside double-quotes - without these
// escapes a controlled string field becomes command injection.
return '"' . addcslashes($s, "\\\"\$`") . '"';
}
return $s;
}
// Tight allowlist for string fields (currently SITE_NAME). Defence in
// depth on top of quote_val: even if shell escaping ever regresses, the
// only characters that reach birdnet.conf are letters, digits, and a
// short list of punctuation that shell won't interpret.
function safe_string_value(string $v): bool {
return (bool)preg_match("/^[A-Za-z0-9 _.,'-]*$/u", $v);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$conf = read_conf($CONF_PATH);
$out = [];
foreach ($ALLOWED as $k => $spec) {
if (!array_key_exists($k, $conf)) continue;
$v = $conf[$k];
if ($spec['type'] === 'float') $v = (float)$v;
elseif ($spec['type'] === 'int') $v = (int)$v;
$out[$k] = $v;
}
echo json_encode([
'values' => $out,
'meta' => $ALLOWED,
'preserve' => (int)($conf['MAX_FILES_SPECIES'] ?? 0) >= 10000,
]);
exit;
}
if ($method === 'POST') {
$raw = file_get_contents('php://input');
$body = json_decode((string)$raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['error' => 'bad json']);
exit;
}
$updates = [];
$errors = [];
foreach ($body as $k => $v) {
// 'preserve' is a UI-side convenience flag handled below - skip it here.
if ($k === 'preserve') continue;
if (!isset($ALLOWED[$k])) { $errors[$k] = 'unknown'; continue; }
$spec = $ALLOWED[$k];
if ($spec['type'] === 'float') {
$v = (float)$v;
if ($v < ($spec['min'] ?? -INF) || $v > ($spec['max'] ?? INF)) { $errors[$k] = 'out of range'; continue; }
} elseif ($spec['type'] === 'int') {
$v = (int)$v;
if ($v < ($spec['min'] ?? -PHP_INT_MAX) || $v > ($spec['max'] ?? PHP_INT_MAX)) { $errors[$k] = 'out of range'; continue; }
} elseif ($spec['type'] === 'enum') {
if (!in_array($v, $spec['values'], true)) { $errors[$k] = 'invalid value'; continue; }
} elseif ($spec['type'] === 'string') {
$v = (string)$v;
if (strlen($v) > ($spec['maxlen'] ?? 200)) { $errors[$k] = 'too long'; continue; }
// String fields land in birdnet.conf which is sourced by bash;
// reject anything outside a known-safe punctuation set so a
// bash metacharacter can't get there even if quote_val regresses.
if (!safe_string_value($v)) { $errors[$k] = 'invalid characters'; continue; }
}
$updates[$k] = $v;
}
if ($errors) {
http_response_code(400);
echo json_encode(['error' => 'validation', 'fields' => $errors]);
exit;
}
// Convenience flag: "preserve" toggle in the UI sets a high recording cap.
if (isset($body['preserve'])) {
$updates['MAX_FILES_SPECIES'] = $body['preserve'] ? 99999 : 50;
}
if (!write_conf($CONF_PATH, $updates)) {
http_response_code(500);
echo json_encode(['error' => 'write failed (check perms on birdnet.conf)']);
exit;
}
// Restart services if any setting requires it.
$needsRestart = false;
foreach (array_keys($updates) as $k) {
if (!empty($ALLOWED[$k]['restart'])) { $needsRestart = true; break; }
}
$restarted = [];
if ($needsRestart) {
foreach (['birdnet_analysis', 'birdnet_recording'] as $svc) {
// Pre-baked sudoers rule: caddy NOPASSWD: /bin/systemctl restart birdnet_*
$rc = 0; $out = [];
exec('sudo /bin/systemctl restart ' . escapeshellarg($svc) . ' 2>&1', $out, $rc);
$restarted[$svc] = $rc === 0;
}
}
echo json_encode(['ok' => true, 'updates' => $updates, 'restarted' => $restarted]);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'method not allowed']);