Files
reflector-dashboard/auth/admin_accounts.php
T

314 lines
13 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';
$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);