Files
reflector-dashboard/auth/tglist_api.php
T

228 lines
12 KiB
PHP

<?php
/*
* Reflector Dashboard — web front-end for SvxReflector
* Copyright (C) 2026 SA7AUX (Björn)
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version. See the LICENSE file for details.
*/
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);