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>
304 lines
13 KiB
PHP
304 lines
13 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
require __DIR__ . '/lib.php';
|
|
|
|
$acting_uuid = require_login();
|
|
$acting_level = acct_admin_level($acting_uuid);
|
|
if ($acting_level < 2) json_out(['ok'=>false,'error'=>'forbidden'], 403);
|
|
|
|
$acting_acct = acct_get($acting_uuid);
|
|
$acting_cs = $acting_acct['callsign'] ?? 'unknown';
|
|
$j = json_in();
|
|
$action = (string)($j['action'] ?? 'list');
|
|
if ($action !== 'list' && $action !== 'get') csrf_check();
|
|
|
|
// require_privilege() comes from auth/lib/acl.php.
|
|
|
|
// Flags/derivation live in lib.php (acct_flags_from / acct_level_from_flags).
|
|
|
|
// ── list ──
|
|
if ($action === 'list') {
|
|
$r = redis();
|
|
$pattern = call_index_key('*');
|
|
$cursor = null;
|
|
$accounts = [];
|
|
do {
|
|
$result = $r->scan($cursor, $pattern, 200);
|
|
if ($result === false) break;
|
|
foreach ($result as $key) {
|
|
$cs = substr($key, strlen(call_index_key('')));
|
|
$uuid = $r->get($key);
|
|
if (!is_string($uuid) || $uuid === '') continue;
|
|
$a = $r->hMGet(acct_key($uuid), ['callsign','firstname','lastname','email','admin_level','email_verified','banned','last_login_at','acct_flags']);
|
|
$accounts[] = [
|
|
'callsign' => $a['callsign'] ?? $cs,
|
|
'uuid' => $uuid,
|
|
'firstname' => $a['firstname'] ?? '',
|
|
'lastname' => $a['lastname'] ?? '',
|
|
'email' => $a['email'] ?? '',
|
|
'level' => acct_level_from_flags(acct_flags_from(is_array($a) ? $a : [])),
|
|
'flags' => acct_flags_from(is_array($a) ? $a : []),
|
|
'verified' => (int)($a['email_verified'] ?? 0),
|
|
'banned' => (int)($a['banned'] ?? 0) === 1,
|
|
'last_login' => (int)($a['last_login_at'] ?? 0),
|
|
];
|
|
}
|
|
} while ($cursor > 0);
|
|
usort($accounts, fn($a,$b) => strcasecmp($a['callsign'],$b['callsign']));
|
|
json_out(['ok'=>true,'accounts'=>$accounts]);
|
|
}
|
|
|
|
// ── get single ──
|
|
if ($action === 'get') {
|
|
$target = (string)($j['uuid'] ?? '');
|
|
if ($target === '') json_out(['ok'=>false,'error'=>'missing_uuid'], 400);
|
|
$a = acct_get($target);
|
|
if (!$a) json_out(['ok'=>false,'error'=>'not_found'], 404);
|
|
unset($a['password_hash']);
|
|
$a['flags'] = acct_flags_from($a);
|
|
$a['level'] = acct_level_from_flags($a['flags']);
|
|
json_out(['ok'=>true,'account'=>$a]);
|
|
}
|
|
|
|
// ── update ──
|
|
if ($action === 'update') {
|
|
$target = (string)($j['uuid'] ?? '');
|
|
if ($target === '') json_out(['ok'=>false,'error'=>'missing_uuid'], 400);
|
|
require_privilege($acting_uuid, $acting_level, $target);
|
|
$allowed = ['firstname','lastname','shortname','email','city','state','country','callsign'];
|
|
$updates = [];
|
|
foreach ($allowed as $f) if (isset($j[$f]) && is_string($j[$f])) $updates[$f] = trim($j[$f]);
|
|
if (!empty($updates)) {
|
|
if (isset($updates['callsign'])) {
|
|
$old = acct_get($target);
|
|
$old_cs = strtoupper(trim($old['callsign'] ?? ''));
|
|
$new_cs = base_call($updates['callsign']);
|
|
$updates['callsign'] = $new_cs;
|
|
if ($old_cs !== $new_cs) {
|
|
if ($old_cs !== '') redis()->del(call_index_key($old_cs));
|
|
redis()->set(call_index_key($new_cs), $target);
|
|
}
|
|
}
|
|
if (isset($updates['email'])) {
|
|
$old = acct_get($target);
|
|
$old_em = strtolower(trim($old['email'] ?? ''));
|
|
$new_em = norm_email($updates['email']);
|
|
$updates['email'] = $new_em;
|
|
if ($old_em !== $new_em) {
|
|
if ($old_em !== '') redis()->del(email_index_key($old_em));
|
|
redis()->set(email_index_key($new_em), $target);
|
|
}
|
|
}
|
|
redis()->hMSet(acct_key($target), $updates);
|
|
}
|
|
json_out(['ok'=>true]);
|
|
}
|
|
|
|
// ── set account flags (8-bit: 1=unreg 2=reg 4=normal 8=lvl1 16=lvl2 32=lvl3) ──
|
|
if ($action === 'set_flags') {
|
|
$target = (string)($j['uuid'] ?? '');
|
|
$flags = (int)($j['flags'] ?? -1);
|
|
if ($target === '' || $flags < 0 || $flags > 255) json_out(['ok'=>false,'error'=>'bad_flags'], 400);
|
|
$flags &= 0x3F;
|
|
// Admin level is exclusive — at most one of lvl1/lvl2/lvl3 may be set.
|
|
$lvlbits = $flags & (8|16|32);
|
|
if ($lvlbits && ($lvlbits & ($lvlbits - 1)) !== 0) json_out(['ok'=>false,'error'=>'only_one_level'], 400);
|
|
$cur = redis()->hMGet(acct_key($target), ['acct_flags','admin_level','email_verified']);
|
|
$old = acct_flags_from(is_array($cur) ? $cur : []);
|
|
// Level rules: lvl3 may change everything. Below lvl3 you may neither change the
|
|
// level of someone at or above your own rank, nor grant a level at or above it.
|
|
$acting_rank = acct_admin_rank($acting_uuid);
|
|
if ($acting_rank < 3) {
|
|
$old_rank = acct_admin_rank_from($old);
|
|
$new_rank = acct_admin_rank_from($flags);
|
|
if ($old_rank !== $new_rank) {
|
|
if ($old_rank >= $acting_rank) json_out(['ok'=>false,'error'=>'cannot_change_level_at_or_above_own'], 403);
|
|
if ($new_rank >= $acting_rank) json_out(['ok'=>false,'error'=>'cannot_grant_level_at_or_above_own'], 403);
|
|
}
|
|
}
|
|
// reg requires a verified email; normal requires reg
|
|
if (($flags & 2) && (string)($cur['email_verified'] ?? '0') !== '1') json_out(['ok'=>false,'error'=>'email_not_verified'], 400);
|
|
if (($flags & 4) && !($flags & 2)) json_out(['ok'=>false,'error'=>'normal_requires_reg'], 400);
|
|
// any admin level requires normal (mirrors the dmr site: admin requires verified)
|
|
if (($flags & (8|16|32)) && !($flags & 4)) json_out(['ok'=>false,'error'=>'admin_requires_normal'], 400);
|
|
// flags are the only admin system — drop the legacy field from the account
|
|
redis()->hSet(acct_key($target), 'acct_flags', (string)$flags);
|
|
redis()->hDel(acct_key($target), 'admin_level');
|
|
json_out(['ok'=>true,'flags'=>$flags,'level'=>acct_level_from_flags($flags)]);
|
|
}
|
|
|
|
// ── ban / unban ──
|
|
if ($action === 'ban' || $action === 'unban') {
|
|
if ($acting_level < 3) json_out(['ok'=>false,'error'=>'superadmin_required'], 403);
|
|
$target = (string)($j['uuid'] ?? '');
|
|
if ($target === '') json_out(['ok'=>false,'error'=>'missing_uuid'], 400);
|
|
require_privilege($acting_uuid, $acting_level, $target);
|
|
$reason = trim((string)($j['reason'] ?? ''));
|
|
$r = redis();
|
|
$r->hSet(acct_key($target), 'banned', $action === 'ban' ? '1' : '0');
|
|
if ($action === 'ban') {
|
|
$r->hSet(acct_key($target), 'ban_reason', $reason);
|
|
$r->hSet(acct_key($target), 'banned_at', (string)time());
|
|
$r->hSet(acct_key($target), 'banned_by', $acting_cs);
|
|
} else {
|
|
$r->hDel(acct_key($target), 'ban_reason', 'banned_at', 'banned_by');
|
|
}
|
|
// Log user event
|
|
$evt = json_encode(['type' => $action, 'reason' => $reason, 'by' => $acting_cs, 'ts' => time()]);
|
|
$r->lPush(rkey("user_events:$target"), $evt);
|
|
$r->lTrim(rkey("user_events:$target"), 0, 99);
|
|
json_out(['ok'=>true,'banned'=>$action === 'ban']);
|
|
}
|
|
|
|
// ── lock / unlock ──
|
|
if ($action === 'lock' || $action === 'unlock') {
|
|
if ($acting_level < 3) json_out(['ok'=>false,'error'=>'superadmin_required'], 403);
|
|
$target = (string)($j['uuid'] ?? '');
|
|
if ($target === '') json_out(['ok'=>false,'error'=>'missing_uuid'], 400);
|
|
require_privilege($acting_uuid, $acting_level, $target);
|
|
$reason = trim((string)($j['reason'] ?? ''));
|
|
$r = redis();
|
|
$r->hSet(acct_key($target), 'locked', $action === 'lock' ? '1' : '0');
|
|
if ($action === 'lock') {
|
|
$r->hSet(acct_key($target), 'lock_reason', $reason);
|
|
$r->hSet(acct_key($target), 'locked_at', (string)time());
|
|
$r->hSet(acct_key($target), 'locked_by', $acting_cs);
|
|
} else {
|
|
$r->hDel(acct_key($target), 'lock_reason', 'locked_at', 'locked_by');
|
|
}
|
|
$evt = json_encode(['type' => $action, 'reason' => $reason, 'by' => $acting_cs, 'ts' => time()]);
|
|
$r->lPush(rkey("user_events:$target"), $evt);
|
|
$r->lTrim(rkey("user_events:$target"), 0, 99);
|
|
json_out(['ok'=>true,'locked'=>$action === 'lock']);
|
|
}
|
|
|
|
// ── set email verified (admin override) ──
|
|
if ($action === 'set_verified') {
|
|
$target = (string)($j['uuid'] ?? '');
|
|
if ($target === '') json_out(['ok'=>false,'error'=>'missing_uuid'], 400);
|
|
require_privilege($acting_uuid, $acting_level, $target);
|
|
$verified = !empty($j['verified']);
|
|
redis()->hSet(acct_key($target), 'email_verified', $verified ? '1' : '0');
|
|
json_out(['ok'=>true,'verified'=>$verified]);
|
|
}
|
|
|
|
// ── reset password (admin) ──
|
|
if ($action === 'reset_password') {
|
|
$target = (string)($j['uuid'] ?? '');
|
|
$newpass = (string)($j['new_password'] ?? '');
|
|
if ($target === '' || strlen($newpass) < 6) json_out(['ok'=>false,'error'=>'missing_or_short'], 400);
|
|
require_privilege($acting_uuid, $acting_level, $target);
|
|
redis()->hSet(acct_key($target), 'password_hash', password_hash($newpass, PASSWORD_DEFAULT));
|
|
json_out(['ok'=>true]);
|
|
}
|
|
|
|
// ── delete ──
|
|
if ($action === 'delete') {
|
|
if ($acting_level < 3) json_out(['ok'=>false,'error'=>'superadmin_required'], 403);
|
|
$target = (string)($j['uuid'] ?? '');
|
|
if ($target === '') json_out(['ok'=>false,'error'=>'missing_uuid'], 400);
|
|
if ($target === $acting_uuid) json_out(['ok'=>false,'error'=>'cannot_delete_yourself'], 403);
|
|
require_privilege($acting_uuid, $acting_level, $target);
|
|
$a = acct_get($target);
|
|
$r = redis();
|
|
$cs = strtoupper(trim($a['callsign'] ?? ''));
|
|
$em = strtolower(trim($a['email'] ?? ''));
|
|
if ($cs !== '') $r->del(call_index_key($cs));
|
|
if ($em !== '') $r->del(email_index_key($em));
|
|
foreach ($r->sMembers(acct_calls_key($target)) ?: [] as $xc) {
|
|
$r->sRem(call_accts_key((string)$xc), $target);
|
|
}
|
|
$r->del(acct_calls_key($target));
|
|
$r->del(acct_nodes_key($target));
|
|
$r->del(acct_key($target));
|
|
json_out(['ok'=>true]);
|
|
}
|
|
|
|
// ── extra callsigns: list ──
|
|
if ($action === 'acct_calls_list') {
|
|
$target = (string)($j['uuid'] ?? '');
|
|
if ($target === '') json_out(['ok'=>false,'error'=>'missing_uuid'], 400);
|
|
$calls = redis()->sMembers(acct_calls_key($target));
|
|
if (!is_array($calls)) $calls = [];
|
|
sort($calls);
|
|
json_out(['ok'=>true,'uuid'=>$target,'callsigns'=>$calls]);
|
|
}
|
|
|
|
// ── extra callsigns: add (club calls etc.) ──
|
|
// Nodes whose name contains the callsign belong to the account. The same
|
|
// callsign may sit on several accounts at once (a club call shared by its
|
|
// operators) — matching uses the acct_calls/call_accts sets, never the unique
|
|
// call: login index.
|
|
if ($action === 'acct_calls_add') {
|
|
$target = (string)($j['uuid'] ?? '');
|
|
$cs = strtoupper(trim((string)($j['callsign'] ?? '')));
|
|
if ($target === '' || $cs === '') json_out(['ok'=>false,'error'=>'missing_fields'], 400);
|
|
if (!acct_get($target)) json_out(['ok'=>false,'error'=>'not_found'], 404);
|
|
if (!preg_match('/^[A-Z0-9]{2,20}$/', $cs)) json_out(['ok'=>false,'error'=>'invalid_callsign'], 400);
|
|
redis()->sAdd(acct_calls_key($target), $cs);
|
|
redis()->sAdd(call_accts_key($cs), $target);
|
|
json_out(['ok'=>true,'message'=>"Added $cs"]);
|
|
}
|
|
|
|
// ── extra callsigns: remove ──
|
|
if ($action === 'acct_calls_remove') {
|
|
$target = (string)($j['uuid'] ?? '');
|
|
$cs = strtoupper(trim((string)($j['callsign'] ?? '')));
|
|
if ($target === '' || $cs === '') json_out(['ok'=>false,'error'=>'missing_fields'], 400);
|
|
redis()->sRem(acct_calls_key($target), $cs);
|
|
redis()->sRem(call_accts_key($cs), $target);
|
|
json_out(['ok'=>true,'message'=>"Removed $cs"]);
|
|
}
|
|
|
|
// ── node admins: list ──
|
|
if ($action === "node_admins_list") {
|
|
$node = strtoupper(trim($j["node"] ?? ""));
|
|
if ($node === "") json_out(["ok"=>false,"error"=>"missing_node"], 400);
|
|
$admins = redis()->sMembers(rkey("node_admins:$node"));
|
|
if (!is_array($admins)) $admins = [];
|
|
sort($admins);
|
|
json_out(["ok"=>true,"node"=>$node,"admins"=>$admins]);
|
|
}
|
|
|
|
// ── node admins: add ──
|
|
if ($action === "node_admin_add") {
|
|
$node = strtoupper(trim($j["node"] ?? ""));
|
|
$admin_cs = strtoupper(trim($j["admin_callsign"] ?? ""));
|
|
if ($node === "" || $admin_cs === "") json_out(["ok"=>false,"error"=>"missing_fields"], 400);
|
|
redis()->sAdd(rkey("node_admins:$node"), $admin_cs);
|
|
json_out(["ok"=>true,"message"=>"Added $admin_cs as admin for $node"]);
|
|
}
|
|
|
|
// ── node admins: remove ──
|
|
if ($action === "node_admin_remove") {
|
|
$node = strtoupper(trim($j["node"] ?? ""));
|
|
$admin_cs = strtoupper(trim($j["admin_callsign"] ?? ""));
|
|
if ($node === "" || $admin_cs === "") json_out(["ok"=>false,"error"=>"missing_fields"], 400);
|
|
redis()->sRem(rkey("node_admins:$node"), $admin_cs);
|
|
json_out(["ok"=>true,"message"=>"Removed $admin_cs from $node"]);
|
|
}
|
|
|
|
// ── node admins: list all nodes with admins ──
|
|
if ($action === "node_admins_all") {
|
|
$r = redis();
|
|
$pattern = rkey("node_admins:*");
|
|
$cursor = null;
|
|
$result_map = [];
|
|
do {
|
|
$result = $r->scan($cursor, $pattern, 200);
|
|
if ($result === false) break;
|
|
foreach ($result as $key) {
|
|
$node = substr($key, strlen(rkey("node_admins:")));
|
|
$members = $r->sMembers($key);
|
|
if (is_array($members) && count($members) > 0) {
|
|
sort($members);
|
|
$result_map[$node] = $members;
|
|
}
|
|
}
|
|
} while ($cursor > 0);
|
|
ksort($result_map);
|
|
json_out(["ok"=>true,"node_admins"=>$result_map]);
|
|
}
|
|
|
|
json_out(['ok'=>false,'error'=>'unknown_action'], 400);
|