[PROD]
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
// AvianVisitors - system / service / log JSON facade for the admin
|
||||
// overlay (settings/system/logs/tools sections). Fetched by the
|
||||
// frontend at /avian/api/birdnet-status.php?action=...
|
||||
//
|
||||
// Endpoints (?action=...):
|
||||
// system - uptime / load / disk / mem / temp / audio device / db file age
|
||||
// services - status of every birdnet_* unit + caddy + php-fpm
|
||||
// logs - &unit=<name>&lines=N: last N lines of that unit's journal
|
||||
// restart - GET/POST &unit=<name>: restart a single service (whitelisted)
|
||||
// diag - everything in one go (system + services + recent logs)
|
||||
//
|
||||
// Default LAN deploy: returns data immediately, no auth.
|
||||
// Forwarded deploy: set AV_REQUIRE_AUTH=1 (env) AND configure Caddy
|
||||
// basic_auth on /avian/api/ to gate everything.
|
||||
//
|
||||
// Service restart + journalctl need passwordless sudo for the caddy
|
||||
// user that runs php-fpm. install_services.sh drops the matching
|
||||
// sudoers rule at /etc/sudoers.d/020_avian-admin with an explicit
|
||||
// command allowlist.
|
||||
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
if (getenv('AV_REQUIRE_AUTH') === '1' && empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? 'diag';
|
||||
|
||||
// Path layout: /home/{USER}/BirdNET-Pi/avian/api/birdnet-status.php
|
||||
// __DIR__ -> .../BirdNET-Pi/avian/api
|
||||
// dirname(__DIR__, 2) -> .../BirdNET-Pi
|
||||
// dirname(__DIR__, 3) -> /home/{USER}
|
||||
$BIRDNETPI_DIR = dirname(__DIR__, 2);
|
||||
$BIRDSONGS_DIR = dirname(__DIR__, 3) . '/BirdSongs';
|
||||
$DB_PATH = "$BIRDNETPI_DIR/scripts/birds.db";
|
||||
$CONF_PATH = "$BIRDNETPI_DIR/birdnet.conf";
|
||||
$STREAM_DIR = "$BIRDSONGS_DIR/StreamData";
|
||||
|
||||
function shellout(string $cmd): string {
|
||||
// Always merge stderr so a broken command shows what failed.
|
||||
$rc = 0; $out = [];
|
||||
exec($cmd . ' 2>&1', $out, $rc);
|
||||
return implode("\n", $out);
|
||||
}
|
||||
|
||||
function read_uptime(): array {
|
||||
$up = @file_get_contents('/proc/uptime');
|
||||
$sec = $up ? (float)explode(' ', trim($up))[0] : 0;
|
||||
return [
|
||||
'seconds' => $sec,
|
||||
'pretty' => human_duration((int)$sec),
|
||||
'load' => sys_getloadavg(),
|
||||
'now' => date('c'),
|
||||
];
|
||||
}
|
||||
|
||||
function human_duration(int $s): string {
|
||||
$d = intdiv($s, 86400); $s -= $d * 86400;
|
||||
$h = intdiv($s, 3600); $s -= $h * 3600;
|
||||
$m = intdiv($s, 60);
|
||||
$parts = [];
|
||||
if ($d) $parts[] = $d . 'd';
|
||||
if ($h) $parts[] = $h . 'h';
|
||||
if ($m && !$d) $parts[] = $m . 'm';
|
||||
return $parts ? implode(' ', $parts) : '<1m';
|
||||
}
|
||||
|
||||
function read_mem(): array {
|
||||
$info = @file_get_contents('/proc/meminfo') ?: '';
|
||||
preg_match('/MemTotal:\s+(\d+)/', $info, $t);
|
||||
preg_match('/MemAvailable:\s+(\d+)/', $info, $a);
|
||||
$tot = isset($t[1]) ? (int)$t[1] * 1024 : 0;
|
||||
$avail = isset($a[1]) ? (int)$a[1] * 1024 : 0;
|
||||
$used = $tot - $avail;
|
||||
return [
|
||||
'total_bytes' => $tot,
|
||||
'used_bytes' => $used,
|
||||
'used_pct' => $tot ? round($used / $tot * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
function read_disk(string $path): array {
|
||||
if (!is_dir($path)) return ['path' => $path, 'error' => 'not found'];
|
||||
$tot = @disk_total_space($path);
|
||||
$free = @disk_free_space($path);
|
||||
if (!$tot) return ['path' => $path, 'error' => 'stat failed'];
|
||||
return [
|
||||
'path' => $path,
|
||||
'total_bytes' => (int)$tot,
|
||||
'free_bytes' => (int)$free,
|
||||
'used_pct' => round(($tot - $free) / $tot * 100, 1),
|
||||
];
|
||||
}
|
||||
|
||||
function read_temp(): ?float {
|
||||
$f = '/sys/class/thermal/thermal_zone0/temp';
|
||||
if (!is_readable($f)) return null;
|
||||
$raw = trim((string)@file_get_contents($f));
|
||||
return $raw === '' ? null : round((int)$raw / 1000, 1);
|
||||
}
|
||||
|
||||
function read_audio(): array {
|
||||
// Read /proc/asound/cards directly - works even when the capture
|
||||
// device is busy (arecord -l would fail with "no soundcards" if
|
||||
// birdnet_recording holds the mic). The file is two lines per card.
|
||||
$raw = @file_get_contents('/proc/asound/cards') ?: '';
|
||||
$lines = array_values(array_filter(array_map('rtrim', explode("\n", $raw)), 'strlen'));
|
||||
$cards = [];
|
||||
for ($i = 0; $i < count($lines); $i += 2) {
|
||||
$head = trim($lines[$i]);
|
||||
$detail = isset($lines[$i + 1]) ? trim($lines[$i + 1]) : '';
|
||||
$cards[] = $detail !== '' ? "$head — $detail" : $head;
|
||||
}
|
||||
$usb = shellout('lsusb');
|
||||
return [
|
||||
'arecord_l' => $cards,
|
||||
'usb' => array_values(array_filter(explode("\n", $usb), function ($l) {
|
||||
return $l !== '' && (
|
||||
stripos($l, 'audio') !== false ||
|
||||
stripos($l, 'microphone') !== false ||
|
||||
stripos($l, 'mic') !== false
|
||||
);
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function read_streamdata(string $dir): array {
|
||||
if (!is_dir($dir)) return ['exists' => false];
|
||||
$files = @scandir($dir, SCANDIR_SORT_DESCENDING) ?: [];
|
||||
$wav = array_values(array_filter($files, function ($f) {
|
||||
return $f !== '.' && $f !== '..' && preg_match('/\.(wav|mp3|raw)$/i', $f);
|
||||
}));
|
||||
$newest_age = null;
|
||||
if (count($wav) > 0) {
|
||||
$newest_age = time() - (int)@filemtime("$dir/" . $wav[0]);
|
||||
}
|
||||
return [
|
||||
'exists' => true,
|
||||
'file_count' => count($wav),
|
||||
'newest_age_s' => $newest_age,
|
||||
'newest_name' => $wav[0] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function read_db_age(string $db): array {
|
||||
if (!is_file($db)) return ['exists' => false];
|
||||
return [
|
||||
'exists' => true,
|
||||
'size_bytes' => (int)filesize($db),
|
||||
'modified_s' => time() - (int)filemtime($db),
|
||||
'mtime' => date('c', (int)filemtime($db)),
|
||||
];
|
||||
}
|
||||
|
||||
function read_conf_summary(string $p): array {
|
||||
if (!is_readable($p)) return ['readable' => false];
|
||||
$keys = [
|
||||
'CONFIDENCE','SENSITIVITY','OVERLAP','REC_CARD','LATITUDE','LONGITUDE',
|
||||
'MODEL','SITE_NAME','RTSP_STREAM',
|
||||
];
|
||||
$vals = [];
|
||||
foreach (file($p, 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)) {
|
||||
if (in_array($m[1], $keys, true)) {
|
||||
$v = trim($m[2]);
|
||||
if (strlen($v) >= 2 && $v[0] === '"' && substr($v, -1) === '"') $v = substr($v, 1, -1);
|
||||
$vals[$m[1]] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['readable' => true, 'values' => $vals];
|
||||
}
|
||||
|
||||
// Whitelisted units we'll surface in the system page + allow restart on.
|
||||
// Includes both 8.2 and 8.4 php-fpm so older Debian + Trixie both report
|
||||
// the right unit name; missing units come back as "inactive (not-found)".
|
||||
const ALLOWED_UNITS = [
|
||||
'birdnet_recording',
|
||||
'birdnet_analysis',
|
||||
'birdnet_log',
|
||||
'birdnet_stats',
|
||||
'spectrogram_viewer',
|
||||
'livestream',
|
||||
'chart_viewer',
|
||||
'icecast2',
|
||||
'caddy',
|
||||
'php8.4-fpm',
|
||||
'php8.3-fpm',
|
||||
'php8.2-fpm',
|
||||
];
|
||||
|
||||
function services_status(): array {
|
||||
$out = [];
|
||||
foreach (ALLOWED_UNITS as $u) {
|
||||
$state = trim(shellout('systemctl is-active ' . escapeshellarg($u)));
|
||||
// Skip units that systemd doesn't know about at all (e.g. php8.2-fpm
|
||||
// on a Trixie box that ships php8.4). Keeps the table tidy.
|
||||
if ($state === 'inactive') {
|
||||
$exists = trim(shellout('systemctl cat ' . escapeshellarg($u) . ' >/dev/null 2>&1 && echo Y || echo N'));
|
||||
if ($exists !== 'Y') continue;
|
||||
}
|
||||
$enabled = trim(shellout('systemctl is-enabled ' . escapeshellarg($u)));
|
||||
$since = trim(shellout("systemctl show -p ActiveEnterTimestamp --value " . escapeshellarg($u)));
|
||||
$out[$u] = [
|
||||
'active' => $state,
|
||||
'enabled' => $enabled,
|
||||
'since' => $since ?: null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function logs_for(string $unit, int $lines): array {
|
||||
if (!in_array($unit, ALLOWED_UNITS, true)) {
|
||||
http_response_code(400);
|
||||
return ['error' => 'unit not allowed', 'allowed' => ALLOWED_UNITS];
|
||||
}
|
||||
$lines = max(10, min(500, $lines));
|
||||
$out = shellout(
|
||||
'sudo /bin/journalctl -u ' . escapeshellarg($unit) .
|
||||
' --no-pager -n ' . $lines . ' -o short-iso'
|
||||
);
|
||||
return [
|
||||
'unit' => $unit,
|
||||
'lines' => $lines,
|
||||
'text' => $out,
|
||||
];
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
|
||||
case 'system': {
|
||||
echo json_encode([
|
||||
'uptime' => read_uptime(),
|
||||
'mem' => read_mem(),
|
||||
'disk_root' => read_disk('/'),
|
||||
'disk_birds' => read_disk($BIRDSONGS_DIR),
|
||||
'temp_c' => read_temp(),
|
||||
'audio' => read_audio(),
|
||||
'stream_data' => read_streamdata($STREAM_DIR),
|
||||
'birds_db' => read_db_age($DB_PATH),
|
||||
'conf' => read_conf_summary($CONF_PATH),
|
||||
'hostname' => trim(shellout('hostname')),
|
||||
'kernel' => trim(shellout('uname -r')),
|
||||
'as_of' => date('c'),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'services': {
|
||||
echo json_encode(['services' => services_status(), 'as_of' => date('c')]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'logs': {
|
||||
$unit = (string)($_GET['unit'] ?? 'birdnet_recording');
|
||||
$lines = (int)($_GET['lines'] ?? 60);
|
||||
echo json_encode(logs_for($unit, $lines));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'restart': {
|
||||
// POST-only: blocks a stray <img src="...?action=restart...">
|
||||
// tag on any LAN-reachable page from disrupting the recording
|
||||
// pipeline. The frontend already POSTs; only thing this rejects
|
||||
// is a passive cross-page GET.
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'POST required']);
|
||||
break;
|
||||
}
|
||||
$unit = (string)($_GET['unit'] ?? '');
|
||||
if (!in_array($unit, ALLOWED_UNITS, true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'unit not allowed', 'allowed' => ALLOWED_UNITS]);
|
||||
break;
|
||||
}
|
||||
// Sudoers rule (dropped in by install_services.sh):
|
||||
// caddy ALL=(root) NOPASSWD: /bin/systemctl restart birdnet_*, ...
|
||||
$rc = 0; $out = [];
|
||||
exec('sudo /bin/systemctl restart ' . escapeshellarg($unit) . ' 2>&1', $out, $rc);
|
||||
echo json_encode([
|
||||
'unit' => $unit,
|
||||
'ok' => $rc === 0,
|
||||
'rc' => $rc,
|
||||
'out' => implode("\n", $out),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'diag': {
|
||||
// Everything a /system page wants in one fetch.
|
||||
$svc = services_status();
|
||||
$key_units = ['birdnet_recording', 'birdnet_analysis'];
|
||||
$recent_logs = [];
|
||||
foreach ($key_units as $u) {
|
||||
$recent_logs[$u] = trim(shellout(
|
||||
'sudo /bin/journalctl -u ' . escapeshellarg($u) .
|
||||
' --no-pager -n 20 -o short-iso'
|
||||
));
|
||||
}
|
||||
echo json_encode([
|
||||
'system' => [
|
||||
'uptime' => read_uptime(),
|
||||
'mem' => read_mem(),
|
||||
'disk_root' => read_disk('/'),
|
||||
'disk_birds' => read_disk($BIRDSONGS_DIR),
|
||||
'temp_c' => read_temp(),
|
||||
'audio' => read_audio(),
|
||||
'stream_data' => read_streamdata($STREAM_DIR),
|
||||
'birds_db' => read_db_age($DB_PATH),
|
||||
'conf' => read_conf_summary($CONF_PATH),
|
||||
'hostname' => trim(shellout('hostname')),
|
||||
'kernel' => trim(shellout('uname -r')),
|
||||
],
|
||||
'services' => $svc,
|
||||
'recent_logs' => $recent_logs,
|
||||
'as_of' => date('c'),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'unknown action']);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?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']);
|
||||
+9
-8
@@ -23,15 +23,16 @@ if (getenv('AV_REQUIRE_AUTH') === '1' && empty($_SERVER['HTTP_AUTHORIZATION']))
|
||||
exit;
|
||||
}
|
||||
|
||||
// All hrefs are the canonical paths BirdNET-Pi's views.php recognises
|
||||
// (lifted from homepage/views.php). AvianVisitors took over `/`, so
|
||||
// the stock home is at /index.php.
|
||||
// All four items are in-app overlays. `native: true` tells the FE to
|
||||
// route via `#admin=<section>` rather than opening a new window. We
|
||||
// deliberately don't link out to BirdNET-Pi's stock pages - those stay
|
||||
// reachable at /index.php, and the github link lives in the drawer
|
||||
// footer next to "built by teddy".
|
||||
echo json_encode([
|
||||
'items' => [
|
||||
['label' => 'birdnet-pi', 'href' => '/index.php', 'native' => false],
|
||||
['label' => 'detections', 'href' => '/views.php?view=Todays+Detections', 'native' => false],
|
||||
['label' => 'log', 'href' => '/views.php?view=View+Log', 'native' => false],
|
||||
['label' => 'system', 'href' => '/views.php?view=Services', 'native' => false],
|
||||
['label' => 'github', 'href' => 'https://github.com/Twarner491/AvianVisitors', 'native' => false],
|
||||
['label' => 'settings', 'href' => '/#admin=settings', 'native' => true],
|
||||
['label' => 'system', 'href' => '/#admin=system', 'native' => true],
|
||||
['label' => 'logs', 'href' => '/#admin=logs', 'native' => true],
|
||||
['label' => 'tools', 'href' => '/#admin=tools', 'native' => true],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -42,7 +42,10 @@ $BY_DATE = dirname(__DIR__, 3) . '/BirdSongs/Extracted/By_Date';
|
||||
// directory under By_Date/. Whitelisted character set keeps this
|
||||
// safe against path-traversal payloads.
|
||||
if ($file !== '') {
|
||||
if (!preg_match('/^[A-Za-z0-9_.:-]+\.mp3$/', $file)) {
|
||||
// BirdNET-Pi keeps apostrophes in some common names (e.g.
|
||||
// Anna's_Hummingbird-...mp3), so allow ' in the whitelist; the
|
||||
// regex still blocks "/" and ".." so path traversal isn't reachable.
|
||||
if (!preg_match("/^[A-Za-z0-9_.:'-]+\\.mp3$/", $file)) {
|
||||
http_response_code(400);
|
||||
echo 'invalid file name';
|
||||
exit;
|
||||
|
||||
@@ -38,7 +38,10 @@ $BY_DATE = dirname(__DIR__, 3) . '/BirdSongs/Extracted/By_Date';
|
||||
// By_Date/<date>/<Common_Name>/. So we accept the mp3 filename, swap
|
||||
// the extension, and search the same way recording.php does.
|
||||
if ($file !== '') {
|
||||
if (!preg_match('/^[A-Za-z0-9_.:-]+\.(mp3|png)$/', $file)) {
|
||||
// BirdNET-Pi keeps apostrophes in some common names (e.g.
|
||||
// Anna's_Hummingbird-...mp3.png), so allow ' in the whitelist; the
|
||||
// regex still blocks "/" and ".." so path traversal isn't reachable.
|
||||
if (!preg_match("/^[A-Za-z0-9_.:'-]+\\.(mp3|png)$/", $file)) {
|
||||
http_response_code(400);
|
||||
echo 'invalid file name';
|
||||
exit;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
+36
-43
@@ -1209,19 +1209,12 @@
|
||||
document.addEventListener('click', function (e) { if (!dd.contains(e.target) && e.target !== menuBtn) closeDd(); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeDd(); });
|
||||
|
||||
// Session is now an HTTP-only cookie set by /api/auth/login — we no
|
||||
// longer cache the Basic-auth password in sessionStorage where any
|
||||
// page-scoped script could read it. authHdr is kept around as a
|
||||
// transient value for the legacy-UI warm-up POST only, and is never
|
||||
// persisted.
|
||||
// Drop any prior sessionStorage we left behind (security cleanup —
|
||||
// previous builds stored Basic auth across reloads).
|
||||
try { sessionStorage.removeItem('apt-birds-auth'); } catch (e) {}
|
||||
var authHdr = null;
|
||||
|
||||
// Probe the cookie session: hit /api/menu without any header. If the
|
||||
// worker accepts the cookie we skip the lock screen; if it 401s we
|
||||
// show the lock as usual.
|
||||
// Probe menu.php with no Authorization header. On a LAN deploy
|
||||
// (AV_REQUIRE_AUTH=0) it returns 200 immediately so the drawer
|
||||
// renders directly. On a forwarded deploy with Caddy basic_auth in
|
||||
// front, Caddy will already have validated credentials before this
|
||||
// request reaches PHP - so a 200 here means we're authed, a 401
|
||||
// means Caddy rejected and we need the lock-screen flow.
|
||||
function tryAutoUnlock() {
|
||||
fetch('./avian/api/menu.php', { credentials: 'same-origin' }).then(function (r) {
|
||||
if (r.status === 200) {
|
||||
@@ -1233,23 +1226,22 @@
|
||||
|
||||
document.getElementById('unlockForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var u = 'monalisa';
|
||||
// BirdNET-Pi's upstream Caddyfile basicauth user is `birdnet`.
|
||||
// If your install changed it (custom Caddyfile), set window.AV_AUTH_USER
|
||||
// before this script loads - e.g. an inline <script> in index.html.
|
||||
var u = (window.AV_AUTH_USER || 'birdnet');
|
||||
var p = document.getElementById('lockPass').value;
|
||||
var hdr = 'Basic ' + btoa(u + ':' + p);
|
||||
// POST the credentials to /api/auth/login — the worker validates
|
||||
// them, sets an HTTP-only signed session cookie, and replies 200.
|
||||
// We never store the password anywhere on the client.
|
||||
fetch('/api/auth/login', {
|
||||
// POST to menu.php with the header so the browser caches the basic
|
||||
// creds for every subsequent request. If Caddy basic_auth accepts
|
||||
// them we get a 200 and the drawer renders; 401 means wrong password.
|
||||
fetch('./avian/api/menu.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': hdr },
|
||||
credentials: 'same-origin',
|
||||
}).then(function (r) {
|
||||
if (r.status === 200) {
|
||||
// Cookie is set — fetch the drawer JSON the same way every
|
||||
// protected endpoint will be called from now on (cookie-based).
|
||||
return fetch('./avian/api/menu.php', { credentials: 'same-origin' })
|
||||
.then(function (m) { return m.json(); })
|
||||
.then(function (j) { renderMenu(j.items || []); });
|
||||
return r.json().then(function (j) { renderMenu(j.items || []); });
|
||||
} else if (r.status === 401) {
|
||||
lockHint.textContent = 'wrong password.';
|
||||
lockHint.classList.add('lock-err');
|
||||
@@ -1458,7 +1450,7 @@
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
fetch('/api/config', { credentials: 'same-origin', cache: 'no-store' })
|
||||
fetch('./avian/api/config.php', { credentials: 'same-origin', cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||
.then(function (cfg) {
|
||||
var v = cfg.values || {};
|
||||
@@ -1560,7 +1552,7 @@
|
||||
if (Object.keys(pending).length === 0) return;
|
||||
var body = JSON.stringify(pending);
|
||||
setSaveState('saving…');
|
||||
fetch('/api/config', {
|
||||
fetch('./avian/api/config.php', {
|
||||
method: 'POST', body: body,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2054,7 +2046,7 @@
|
||||
|
||||
function renderAdminSettings() {
|
||||
adminBody.innerHTML = '<p style="font:11px ui-monospace,monospace;color:var(--ink-soft);text-align:center">loading settings…</p>';
|
||||
fetch('/api/config', { credentials: 'same-origin', cache: 'no-store' })
|
||||
fetch('./avian/api/config.php', { credentials: 'same-origin', cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||
.then(function (cfg) {
|
||||
var v = cfg.values || {};
|
||||
@@ -2086,7 +2078,7 @@
|
||||
function renderAdminSystem() {
|
||||
adminBody.innerHTML = '<p style="font:11px ui-monospace,monospace;color:var(--ink-soft);text-align:center">loading…</p>';
|
||||
function tick() {
|
||||
adminApi('/api/status?action=diag')
|
||||
adminApi('./avian/api/birdnet-status.php?action=diag')
|
||||
.then(function (r) { return r.text().then(function (raw) { return { status: r.status, raw: raw }; }); })
|
||||
.then(function (res) {
|
||||
var j = null;
|
||||
@@ -2179,7 +2171,7 @@
|
||||
var unit = b.dataset.unit;
|
||||
if (!confirm('Restart ' + unit + '?')) return;
|
||||
b.disabled = true; var old = b.textContent; b.textContent = '…';
|
||||
fetch('/api/status?action=restart&unit=' + encodeURIComponent(unit), {
|
||||
fetch('./avian/api/birdnet-status.php?action=restart&unit=' + encodeURIComponent(unit), {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
@@ -2197,7 +2189,11 @@
|
||||
adminBody.innerHTML =
|
||||
'<div class="admin-logs-toolbar">'
|
||||
+ ' <label>unit</label><select id="adminLogsUnit">'
|
||||
+ ['birdnet_recording','birdnet_analysis','birdnet_log','birdnet_stats','spectrogram_viewer','livestream','icecast2','caddy','php8.2-fpm']
|
||||
// php-fpm unit name differs per Debian version (8.2 on Bookworm,
|
||||
// 8.4 on Trixie). List all three so the dropdown has the right one
|
||||
// regardless of host - birdnet-status.php's ALLOWED_UNITS already
|
||||
// skips ones systemd doesn't know about.
|
||||
+ ['birdnet_recording','birdnet_analysis','birdnet_log','birdnet_stats','spectrogram_viewer','livestream','icecast2','caddy','php8.4-fpm','php8.3-fpm','php8.2-fpm']
|
||||
.map(function (u) { return '<option value="' + u + '">' + u + '</option>'; }).join('')
|
||||
+ ' </select>'
|
||||
+ ' <label>lines</label><input id="adminLogsLines" type="number" value="120" min="20" max="500" step="20">'
|
||||
@@ -2212,7 +2208,7 @@
|
||||
autoScroll = pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 20;
|
||||
});
|
||||
function tick() {
|
||||
adminApi('/api/status?action=logs&unit=' + encodeURIComponent(unit) + '&lines=' + lines)
|
||||
adminApi('./avian/api/birdnet-status.php?action=logs&unit=' + encodeURIComponent(unit) + '&lines=' + lines)
|
||||
.then(function (r) { return r.text().then(function (raw) { return { status: r.status, raw: raw }; }); })
|
||||
.then(function (res) {
|
||||
var j = null;
|
||||
@@ -2248,7 +2244,7 @@
|
||||
+ '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '<h2 class="admin-section-head">pi-side install / heal</h2>';
|
||||
html += '<h2 class="admin-section-head">heal / update</h2>';
|
||||
html += '<div class="admin-actions-grid">';
|
||||
function deployCard(title, desc, lines) {
|
||||
return '<div class="admin-action deploy">'
|
||||
@@ -2258,20 +2254,17 @@
|
||||
+ '<button class="copy" type="button">copy</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
html += deployCard('install birdnet-status.php',
|
||||
'adds the /system + /logs json backend on the pi (only needed once).',
|
||||
html += deployCard('pull latest from github',
|
||||
'fetches the newest AvianVisitors + BirdNET-Pi changes; the symlinks already in /BirdSongs/Extracted/ pick up new code on the next request.',
|
||||
[
|
||||
'curl -fsSL https://bird.onethreenine.net/install/birdnet-status.php -o /tmp/birdnet-status.php',
|
||||
'sudo install -o monalisa -g monalisa -m 0644 /tmp/birdnet-status.php /home/monalisa/BirdSongs/Extracted/birdnet-status.php',
|
||||
'cd ~/BirdNET-Pi && git pull',
|
||||
'# substitute the right php-fpm unit if your debian ships a different version:',
|
||||
'sudo systemctl reload caddy "$(systemctl list-unit-files \'php*-fpm.service\' --no-legend | awk \'{print $1; exit}\')"',
|
||||
]);
|
||||
html += deployCard('full heal (services + reinstall)',
|
||||
're-pulls all php endpoints and restarts every birdnet-pi service.',
|
||||
html += deployCard('rerun install_services.sh',
|
||||
'refreshes every symlink + service file. safe to run anytime; only takes ~10 seconds.',
|
||||
[
|
||||
'for f in birdnet-api.php cutout.php recording.php spectrogram.php config.php birdnet-status.php; do',
|
||||
' curl -fsSL "https://bird.onethreenine.net/install/$f" -o "/tmp/$f"',
|
||||
' sudo install -o monalisa -g monalisa -m 0644 "/tmp/$f" "/home/monalisa/BirdSongs/Extracted/$f"',
|
||||
'done',
|
||||
'sudo systemctl restart birdnet_recording birdnet_analysis birdnet_log birdnet_stats spectrogram_viewer livestream',
|
||||
'cd ~/BirdNET-Pi && ./scripts/install_services.sh',
|
||||
]);
|
||||
html += '</div>';
|
||||
adminBody.innerHTML = html;
|
||||
@@ -2282,7 +2275,7 @@
|
||||
if (!confirm('restart ' + unit + '?')) return;
|
||||
b.disabled = true; var old = b.textContent; b.textContent = '…';
|
||||
var out = adminBody.querySelector('.out[data-out="' + unit.replace(/[^a-z0-9_.-]/gi,'_') + '"]');
|
||||
fetch('/api/status?action=restart&unit=' + encodeURIComponent(unit), {
|
||||
fetch('./avian/api/birdnet-status.php?action=restart&unit=' + encodeURIComponent(unit), {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
|
||||
@@ -5,9 +5,21 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>your birds</title>
|
||||
<meta name="description" content="A live bird collage from your window.">
|
||||
<link rel="icon" type="image/png" href="./favicon.png">
|
||||
<link rel="apple-touch-icon" href="./favicon.png">
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body class="av-local">
|
||||
<!-- Pill in the top-left slot, only visible on admin overlays
|
||||
(body.admin-on); CSS in styles.css hides it everywhere else.
|
||||
href="/" returns to the collage view via a full reload, which is
|
||||
fine for an off-hot-path control. -->
|
||||
<a class="return-to-atlas" id="returnToAtlas" href="/" aria-label="back to collage">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 3 L4 8 L10 13"/>
|
||||
</svg>
|
||||
collage
|
||||
</a>
|
||||
<header class="top">
|
||||
<div class="window-pick" id="winPick" role="tablist">
|
||||
<i class="seg-pill" aria-hidden="true"></i>
|
||||
@@ -29,7 +41,7 @@
|
||||
<p class="lock-hint" id="lockHint">enter password to unlock tools.</p>
|
||||
</div>
|
||||
<nav class="menu-items" id="dd-items"></nav>
|
||||
<p class="built-by">built by <a href="https://theodore.net" target="_blank" rel="noopener">teddy</a></p>
|
||||
<p class="built-by">built by <a href="https://theodore.net" target="_blank" rel="noopener">teddy</a> · <a href="https://github.com/Twarner491/AvianVisitors" target="_blank" rel="noopener">github</a></p>
|
||||
</aside>
|
||||
|
||||
<main class="stage">
|
||||
|
||||
Reference in New Issue
Block a user