Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr), deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles live in install/ with CHANGEME placeholders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
218 lines
11 KiB
PHP
218 lines
11 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
require __DIR__ . '/lib.php';
|
|
|
|
// Canonical TG list for the SVX side — stored as a JSON FILE (source of truth),
|
|
// NOT Redis. SVX config lives in files under /etc/svxlink (like svxdr.json);
|
|
// DMR keeps its talkgroups in Redis (auth/tgs.php, untouched). The file holds:
|
|
// { "tgs": [ {tg,name,system,allstar,mode,owner,admins[]}, ... ],
|
|
// "requests": [ {tg,name,system,allstar,mode,callsign,uuid,ts}, ... ] }
|
|
// Served publicly by /tglist.php; read directly by svxdr for Location naming.
|
|
// Path is config-driven (cfg()['tglist_file']), default /etc/svxlink/tglist.json.
|
|
|
|
$uuid = require_login();
|
|
$level = acct_admin_level($uuid);
|
|
$calls = acct_all_calls($uuid); // uppercase, incl. club calls
|
|
$isAdmin = $level >= 2;
|
|
|
|
function tglist_file(): string {
|
|
$p = cfg()['tglist_file'] ?? '/etc/svxlink/tglist.json';
|
|
return is_string($p) && $p !== '' ? $p : '/etc/svxlink/tglist.json';
|
|
}
|
|
|
|
function tglist_load(): array {
|
|
$d = ['tgs' => [], 'requests' => []];
|
|
$f = tglist_file();
|
|
if (is_file($f)) {
|
|
$raw = @file_get_contents($f);
|
|
if (is_string($raw) && $raw !== '') {
|
|
$j = json_decode($raw, true);
|
|
if (is_array($j)) {
|
|
if (isset($j['tgs']) && is_array($j['tgs'])) $d['tgs'] = array_values($j['tgs']);
|
|
if (isset($j['requests']) && is_array($j['requests'])) $d['requests'] = array_values($j['requests']);
|
|
}
|
|
}
|
|
}
|
|
return $d;
|
|
}
|
|
|
|
// Persist the list. Prefer an atomic temp+rename (needs write on the directory);
|
|
// fall back to an in-place LOCK_EX write (needs write only on the FILE) so the
|
|
// JSON can live in a root-owned dir like /etc/svxlink with just the file chowned
|
|
// to the web user. Returns false on failure so the caller surfaces it instead of
|
|
// silently losing the edit.
|
|
function tglist_store(array $d): bool {
|
|
$f = tglist_file();
|
|
if (!is_dir(dirname($f))) return false;
|
|
$json = json_encode($d, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
|
if ($json === false) return false;
|
|
$tmp = $f . '.tmp.' . getmypid();
|
|
if (@file_put_contents($tmp, $json, LOCK_EX) !== false) {
|
|
@chmod($tmp, 0644);
|
|
if (@rename($tmp, $f)) return true;
|
|
@unlink($tmp);
|
|
}
|
|
// Fallback: write in place (only needs the file itself to be writable).
|
|
return @file_put_contents($f, $json, LOCK_EX) !== false;
|
|
}
|
|
|
|
function tg_norm(array $o): array {
|
|
$admins = [];
|
|
if (isset($o['admins']) && is_array($o['admins'])) {
|
|
foreach ($o['admins'] as $a) { $a = strtoupper(trim((string)$a)); if ($a !== '' && !in_array($a, $admins, true)) $admins[] = $a; }
|
|
}
|
|
return [
|
|
'tg' => (int)($o['tg'] ?? 0),
|
|
'name' => (string)($o['name'] ?? ''),
|
|
'system' => (string)($o['system'] ?? ''),
|
|
'allstar' => (string)($o['allstar'] ?? ''),
|
|
'mode' => (string)($o['mode'] ?? ''),
|
|
'owner' => strtoupper((string)($o['owner'] ?? '')),
|
|
'admins' => $admins,
|
|
];
|
|
}
|
|
|
|
function tg_find(array $tgs, int $tg): int { foreach ($tgs as $i => $e) if ((int)($e['tg'] ?? 0) === $tg) return $i; return -1; }
|
|
|
|
// Edit rights: a global admin, the owner (always admin of their TG), or a co-admin.
|
|
function tg_can_edit(?array $entry, bool $isAdmin, array $calls): bool {
|
|
if ($isAdmin) return true;
|
|
if (!$entry) return false;
|
|
if ($entry['owner'] !== '' && in_array($entry['owner'], $calls, true)) return true;
|
|
foreach ($entry['admins'] as $a) if (in_array($a, $calls, true)) return true;
|
|
return false;
|
|
}
|
|
|
|
$j = json_in();
|
|
$action = (string)($j['action'] ?? 'list');
|
|
$data = tglist_load();
|
|
|
|
// ── reads (no CSRF) ──────────────────────────────────────────────────────
|
|
if ($action === 'list') {
|
|
$out = array_map('tg_norm', $data['tgs']);
|
|
usort($out, fn($a, $b) => $a['tg'] <=> $b['tg']);
|
|
json_out(['ok' => true, 'tgs' => $out, 'can_admin' => $isAdmin, 'my_calls' => $calls]);
|
|
}
|
|
if ($action === 'my_request') {
|
|
foreach ($data['requests'] as $rq) if ((string)($rq['uuid'] ?? '') === $uuid) json_out(['ok' => true, 'pending' => $rq]);
|
|
json_out(['ok' => true, 'pending' => null]);
|
|
}
|
|
if ($action === 'pending_list') {
|
|
if (!$isAdmin) json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
|
$out = [];
|
|
foreach ($data['requests'] as $rq) {
|
|
$out[] = ['uuid' => (string)($rq['uuid'] ?? ''), 'tg' => (int)($rq['tg'] ?? 0), 'name' => (string)($rq['name'] ?? ''),
|
|
'system' => (string)($rq['system'] ?? ''), 'allstar' => (string)($rq['allstar'] ?? ''), 'mode' => (string)($rq['mode'] ?? ''),
|
|
'callsign' => (string)($rq['callsign'] ?? ''), 'ts' => (int)($rq['ts'] ?? 0)];
|
|
}
|
|
usort($out, fn($a, $b) => $a['ts'] <=> $b['ts']);
|
|
json_out(['ok' => true, 'pending' => $out]);
|
|
}
|
|
|
|
csrf_check();
|
|
function tglist_fail(): void { json_out(['ok' => false, 'error' => 'write_failed'], 500); }
|
|
|
|
// ── user: request a TG (one open at a time) ──────────────────────────────
|
|
if ($action === 'request') {
|
|
$acct = acct_get($uuid);
|
|
$callsign = strtoupper(trim((string)($acct['callsign'] ?? '')));
|
|
if ($callsign === '') json_out(['ok' => false, 'error' => 'no_callsign'], 400);
|
|
$tg = (int)($j['tg'] ?? 0);
|
|
if ($tg <= 0) json_out(['ok' => false, 'error' => 'invalid_tg'], 400);
|
|
if (tg_find($data['tgs'], $tg) >= 0) json_out(['ok' => false, 'error' => 'tg_already_taken'], 409);
|
|
foreach ($data['requests'] as $rq) if ((string)($rq['uuid'] ?? '') === $uuid) json_out(['ok' => false, 'error' => 'pending_request_exists'], 409);
|
|
$name = trim((string)($j['name'] ?? ''));
|
|
if ($name === '') json_out(['ok' => false, 'error' => 'name_required'], 400);
|
|
$allstar = trim((string)($j['allstar'] ?? ''));
|
|
if ($allstar !== '' && !preg_match('/^[0-9]{1,10}$/', $allstar)) json_out(['ok' => false, 'error' => 'invalid_allstar'], 400);
|
|
$data['requests'][] = ['tg' => $tg, 'name' => mb_substr($name, 0, 64), 'system' => mb_substr(trim((string)($j['system'] ?? '')), 0, 64),
|
|
'allstar' => $allstar, 'mode' => mb_substr(trim((string)($j['mode'] ?? '')), 0, 16), 'callsign' => $callsign, 'uuid' => $uuid, 'ts' => time()];
|
|
if (!tglist_store($data)) tglist_fail();
|
|
json_out(['ok' => true]);
|
|
}
|
|
if ($action === 'request_cancel') {
|
|
$data['requests'] = array_values(array_filter($data['requests'], fn($rq) => (string)($rq['uuid'] ?? '') !== $uuid));
|
|
if (!tglist_store($data)) tglist_fail();
|
|
json_out(['ok' => true]);
|
|
}
|
|
|
|
// ── admin: approve / deny ────────────────────────────────────────────────
|
|
if ($action === 'approve') {
|
|
if (!$isAdmin) json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
|
$ru = (string)($j['uuid'] ?? '');
|
|
if ($ru === '') json_out(['ok' => false, 'error' => 'uuid_required'], 400);
|
|
$req = null;
|
|
foreach ($data['requests'] as $rq) if ((string)($rq['uuid'] ?? '') === $ru) { $req = $rq; break; }
|
|
if (!$req) json_out(['ok' => false, 'error' => 'request_not_found'], 404);
|
|
$tg = (int)($req['tg'] ?? 0);
|
|
$data['requests'] = array_values(array_filter($data['requests'], fn($rq) => (string)($rq['uuid'] ?? '') !== $ru));
|
|
if (tg_find($data['tgs'], $tg) >= 0) { tglist_store($data); json_out(['ok' => false, 'error' => 'tg_taken_during_review'], 409); }
|
|
$data['tgs'][] = ['tg' => $tg, 'name' => (string)($req['name'] ?? ''), 'system' => (string)($req['system'] ?? ''),
|
|
'allstar' => (string)($req['allstar'] ?? ''), 'mode' => (string)($req['mode'] ?? ''), 'owner' => strtoupper((string)($req['callsign'] ?? '')), 'admins' => []];
|
|
if (!tglist_store($data)) tglist_fail();
|
|
json_out(['ok' => true, 'tg' => $tg]);
|
|
}
|
|
if ($action === 'deny') {
|
|
if (!$isAdmin) json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
|
$ru = (string)($j['uuid'] ?? '');
|
|
if ($ru === '') json_out(['ok' => false, 'error' => 'uuid_required'], 400);
|
|
$data['requests'] = array_values(array_filter($data['requests'], fn($rq) => (string)($rq['uuid'] ?? '') !== $ru));
|
|
if (!tglist_store($data)) tglist_fail();
|
|
json_out(['ok' => true]);
|
|
}
|
|
|
|
// ── save TG fields (admin OR owner OR co-admin). owner is NOT edited here ──
|
|
if ($action === 'save') {
|
|
$tg = (int)($j['tg'] ?? 0);
|
|
if ($tg <= 0) json_out(['ok' => false, 'error' => 'invalid_tg'], 400);
|
|
$idx = tg_find($data['tgs'], $tg);
|
|
$existing = $idx >= 0 ? tg_norm($data['tgs'][$idx]) : null;
|
|
if ($existing === null && !$isAdmin) json_out(['ok' => false, 'error' => 'add_admin_only'], 403);
|
|
if ($existing !== null && !tg_can_edit($existing, $isAdmin, $calls)) json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
|
$name = trim((string)($j['name'] ?? ($existing['name'] ?? '')));
|
|
$system = trim((string)($j['system'] ?? ($existing['system'] ?? '')));
|
|
$allstar = trim((string)($j['allstar'] ?? ($existing['allstar'] ?? '')));
|
|
$mode = trim((string)($j['mode'] ?? ($existing['mode'] ?? '')));
|
|
if (mb_strlen($name) > 64) json_out(['ok' => false, 'error' => 'name_too_long'], 400);
|
|
if (mb_strlen($system) > 64) json_out(['ok' => false, 'error' => 'system_too_long'], 400);
|
|
if ($allstar !== '' && !preg_match('/^[0-9]{1,10}$/', $allstar)) json_out(['ok' => false, 'error' => 'invalid_allstar'], 400);
|
|
if (mb_strlen($mode) > 16) json_out(['ok' => false, 'error' => 'mode_too_long'], 400);
|
|
// owner set only via approve; admins via admin_set. Preserve both here.
|
|
$owner = $existing ? $existing['owner'] : '';
|
|
$admins = $existing ? $existing['admins'] : [];
|
|
$entry = ['tg' => $tg, 'name' => $name, 'system' => $system, 'allstar' => $allstar, 'mode' => $mode, 'owner' => $owner, 'admins' => $admins];
|
|
if ($idx >= 0) $data['tgs'][$idx] = $entry; else $data['tgs'][] = $entry;
|
|
if (!tglist_store($data)) tglist_fail();
|
|
json_out(['ok' => true, 'entry' => $entry]);
|
|
}
|
|
|
|
// ── replace a TG's co-admins list ────────────────────────────────────────
|
|
if ($action === 'admin_set') {
|
|
$tg = (int)($j['tg'] ?? 0);
|
|
if ($tg <= 0) json_out(['ok' => false, 'error' => 'invalid_tg'], 400);
|
|
$idx = tg_find($data['tgs'], $tg);
|
|
if ($idx < 0) json_out(['ok' => false, 'error' => 'tg_not_found'], 404);
|
|
$existing = tg_norm($data['tgs'][$idx]);
|
|
if (!tg_can_edit($existing, $isAdmin, $calls)) json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
|
$in = $j['admins'] ?? [];
|
|
if (!is_array($in)) json_out(['ok' => false, 'error' => 'invalid_admins'], 400);
|
|
$admins = [];
|
|
foreach ($in as $a) { $a = base_call((string)$a); if ($a === '' || !preg_match('/^[A-Z0-9]+$/', $a)) continue; if ($a === $existing['owner']) continue; if (!in_array($a, $admins, true)) $admins[] = $a; }
|
|
if (count($admins) > 50) json_out(['ok' => false, 'error' => 'too_many_admins'], 400);
|
|
$existing['admins'] = $admins;
|
|
$data['tgs'][$idx] = $existing;
|
|
if (!tglist_store($data)) tglist_fail();
|
|
json_out(['ok' => true, 'admins' => $admins]);
|
|
}
|
|
|
|
if ($action === 'delete') {
|
|
if (!$isAdmin) json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
|
$tg = (int)($j['tg'] ?? 0);
|
|
if ($tg <= 0) json_out(['ok' => false, 'error' => 'invalid_tg'], 400);
|
|
$data['tgs'] = array_values(array_filter($data['tgs'], fn($e) => (int)($e['tg'] ?? 0) !== $tg));
|
|
if (!tglist_store($data)) tglist_fail();
|
|
json_out(['ok' => true]);
|
|
}
|
|
|
|
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|