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);