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>
275 lines
16 KiB
PHP
275 lines
16 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
require __DIR__ . '/lib.php';
|
|
|
|
start_session();
|
|
$uuid = $_SESSION['uid'] ?? null;
|
|
if (!is_string($uuid) || $uuid === '') { header('Location: /'); exit; }
|
|
$me = acct_get($uuid);
|
|
if (!$me) { header('Location: /'); exit; }
|
|
|
|
$msg = ''; $err = false;
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$action = $_POST['action'] ?? '';
|
|
if ($action === 'save_profile') {
|
|
$allowed = ['callsign','firstname','lastname','shortname','email','city','state','country'];
|
|
$updates = [];
|
|
foreach ($allowed as $f) {
|
|
if (isset($_POST[$f]) && is_string($_POST[$f])) $updates[$f] = trim($_POST[$f]);
|
|
}
|
|
if (isset($updates['callsign'])) {
|
|
$old_cs = strtoupper(trim($me['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), $uuid);
|
|
}
|
|
}
|
|
if (isset($updates['email'])) {
|
|
$old_em = strtolower(trim($me['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), $uuid);
|
|
}
|
|
}
|
|
if (!empty($updates)) {
|
|
redis()->hMSet(acct_key($uuid), $updates);
|
|
$me = acct_get($uuid);
|
|
$msg = 'Saved!';
|
|
}
|
|
} elseif ($action === 'change_password') {
|
|
$old = (string)($_POST['old_password'] ?? '');
|
|
$new = (string)($_POST['new_password'] ?? '');
|
|
$new2= (string)($_POST['new_password2'] ?? '');
|
|
$hash = (string)($me['password_hash'] ?? '');
|
|
if ($old === '' || $new === '') { $msg = 'Fill all fields'; $err = true; }
|
|
elseif ($new !== $new2) { $msg = 'Passwords do not match'; $err = true; }
|
|
elseif (strlen($new) < 6) { $msg = 'Password too short (min 6 chars)'; $err = true; }
|
|
elseif (!password_verify($old, $hash)) { $msg = 'Invalid current password'; $err = true; }
|
|
else {
|
|
redis()->hSet(acct_key($uuid), 'password_hash', password_hash($new, PASSWORD_DEFAULT));
|
|
$msg = 'Password changed!';
|
|
}
|
|
}
|
|
}
|
|
|
|
// h() comes from auth/lib/html.php.
|
|
|
|
$call = (string)($me['callsign'] ?? '');
|
|
$first = (string)($me['firstname'] ?? '');
|
|
$last = (string)($me['lastname'] ?? '');
|
|
$short = (string)($me['shortname'] ?? '');
|
|
$email = (string)($me['email'] ?? '');
|
|
$city = (string)($me['city'] ?? '');
|
|
$state = (string)($me['state'] ?? '');
|
|
$country = (string)($me['country'] ?? '');
|
|
$flags = acct_flags_from($me);
|
|
$admin = acct_level_from_flags($flags);
|
|
$email_verified = ((($me['email_verified'] ?? '0')) === '1');
|
|
$parts = [];
|
|
foreach ([1=>'unreg',2=>'reg',4=>'normal',8=>'lvl1',16=>'lvl2',32=>'lvl3'] as $bit => $name) {
|
|
if ($flags & $bit) $parts[] = $name;
|
|
}
|
|
$lvlText = $parts ? implode(', ', $parts) : '-';
|
|
$lvlColor = $admin >= 2 ? '#ffc800' : 'rgba(0,255,255,.75)';
|
|
$star = $admin >= 2 ? ' ★' : '';
|
|
|
|
// Callsigns tab (read-only): the account's callsigns (primary + admin-added
|
|
// extras/club calls) and the nodes where one of them is a listed node admin.
|
|
// Only site admins can change these lists.
|
|
$my_calls = acct_all_calls($uuid);
|
|
$extra_calls = array_values(array_filter(array_map(
|
|
fn($c) => strtoupper(trim((string)$c)),
|
|
redis()->sMembers(acct_calls_key($uuid)) ?: []
|
|
)));
|
|
sort($extra_calls);
|
|
$admin_nodes = [];
|
|
$rds = redis();
|
|
$cursor = null;
|
|
do {
|
|
$res = $rds->scan($cursor, rkey('node_admins:*'), 200);
|
|
if ($res === false) break;
|
|
foreach ($res as $key) {
|
|
$node = substr($key, strlen(rkey('node_admins:')));
|
|
$members = $rds->sMembers($key) ?: [];
|
|
foreach ($my_calls as $c) {
|
|
if (in_array($c, $members, true)) { $admin_nodes[] = $node; break; }
|
|
}
|
|
}
|
|
} while ($cursor > 0);
|
|
$admin_nodes = array_values(array_unique($admin_nodes));
|
|
sort($admin_nodes);
|
|
|
|
$page_title = 'Profile — SvxPortal';
|
|
require __DIR__ . '/header.php';
|
|
?>
|
|
|
|
<div class="container py-4" style="max-width:800px;">
|
|
|
|
<?php if ($msg): ?>
|
|
<div class="panel" style="border-color: <?= $err ? '#ff8080' : '#00ff80' ?>;">
|
|
<span style="color: <?= $err ? '#ff8080' : '#00ff80' ?>;"><?=h($msg)?></span>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<nav class="profile-tabs" role="tablist">
|
|
<button type="button" class="profile-tab active" data-ptab="account" role="tab">
|
|
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/></svg>
|
|
<span>My Account</span>
|
|
</button>
|
|
<button type="button" class="profile-tab" data-ptab="nodes" role="tab">
|
|
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5"/><path d="M10 8a2 2 0 1 1-4 0V3a2 2 0 1 1 4 0zM8 0a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V3a3 3 0 0 0-3-3"/></svg>
|
|
<span>My Nodes</span>
|
|
</button>
|
|
<button type="button" class="profile-tab" data-ptab="nodesec" role="tab">
|
|
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M8 0c-.69 0-1.843.265-2.928.56-1.11.3-2.229.655-2.887.87a1.54 1.54 0 0 0-1.044 1.262c-.596 4.477.787 7.795 2.465 9.99a11.8 11.8 0 0 0 2.517 2.453c.386.273.744.482 1.048.625.28.132.581.24.829.24s.548-.108.829-.24a7 7 0 0 0 1.048-.625 11.8 11.8 0 0 0 2.517-2.453c1.678-2.195 3.061-5.513 2.465-9.99a1.54 1.54 0 0 0-1.044-1.263 63 63 0 0 0-2.887-.87C9.843.266 8.69 0 8 0m0 5a1.5 1.5 0 0 1 .5 2.915l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99A1.5 1.5 0 0 1 8 5"/></svg>
|
|
<span>Node Security</span>
|
|
</button>
|
|
<button type="button" class="profile-tab" data-ptab="calls" role="tab">
|
|
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5m-3 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2m0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/></svg>
|
|
<span>Callsigns</span>
|
|
</button>
|
|
<button type="button" class="profile-tab" data-ptab="mqtt" role="tab">
|
|
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M2.146 2.146a.5.5 0 0 1 .708 0C4.63 3.921 5.5 6.14 5.5 8s-.87 4.079-2.646 5.854a.5.5 0 0 1-.708-.708C3.87 11.579 4.5 9.66 4.5 8s-.63-3.579-2.354-5.146a.5.5 0 0 1 0-.708m11.708 0a.5.5 0 0 1 .708.708C12.63 4.421 12 6.34 12 8s.63 3.579 2.562 5.146a.5.5 0 0 1-.708.708C12.37 12.079 11.5 9.86 11.5 8s.87-4.079 2.354-5.854M8 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3"/></svg>
|
|
<span>Remote (MQTT)</span>
|
|
</button>
|
|
<button type="button" class="profile-tab" data-ptab="password" role="tab">
|
|
<svg width="18" height="18" fill="currentColor" viewBox="0 0 16 16"><path d="M0 8a4 4 0 0 1 7.465-2H14a.5.5 0 0 1 .354.146l1.5 1.5a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0L13 9.207l-.646.647a.5.5 0 0 1-.708 0L11 9.207l-.646.647a.5.5 0 0 1-.708 0L9 9.207l-.646.647A.5.5 0 0 1 8 10h-.535A4 4 0 0 1 0 8m4-3a3 3 0 1 0 2.959 3.5H8a.5.5 0 0 0 .354-.146l.853-.854 1.146 1.147a.5.5 0 0 0 .708 0l.646-.647.646.647a.5.5 0 0 0 .708 0l.646-.647.646.647a.5.5 0 0 0 .708 0L15 7.207l-1-1H7.465A3 3 0 0 0 4 5m-1 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/></svg>
|
|
<span>Password</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<div id="ptab-account" class="profile-tab-pane">
|
|
<div class="panel">
|
|
<div class="mb-3 d-flex gap-3 align-items-center flex-wrap">
|
|
<span class="small text-secondary">UUID: <span class="mono"><?=h($uuid)?></span></span>
|
|
<span class="small" style="color:<?=$lvlColor?>;"><?=h($lvlText)?><?=$star?></span>
|
|
</div>
|
|
<form method="post" action="">
|
|
<input type="hidden" name="action" value="save_profile">
|
|
<h6 class="mt-2 mb-2" style="color:var(--fg2);">Identity</h6>
|
|
<div class="d-flex gap-2 align-items-center mb-1">
|
|
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_callsign">Callsign</label>
|
|
<input class="form-control form-control-sm mono" id="f_callsign" name="callsign" value="<?=h($call)?>" style="max-width:400px;">
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center mb-1">
|
|
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_firstname">First Name</label>
|
|
<input class="form-control form-control-sm mono" id="f_firstname" name="firstname" value="<?=h($first)?>" style="max-width:400px;">
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center mb-1">
|
|
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_lastname">Last Name</label>
|
|
<input class="form-control form-control-sm mono" id="f_lastname" name="lastname" value="<?=h($last)?>" style="max-width:400px;">
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center mb-1">
|
|
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_shortname">Short Name</label>
|
|
<input class="form-control form-control-sm mono" id="f_shortname" name="shortname" value="<?=h($short)?>" style="max-width:400px;">
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center mb-1">
|
|
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_email">Email</label>
|
|
<input class="form-control form-control-sm mono" id="f_email" name="email" value="<?=h($email)?>" style="max-width:400px;">
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center mb-1" id="emailVerifyRow">
|
|
<span class="small text-secondary" style="min-width:200px;text-align:right;">Email status</span>
|
|
<span>
|
|
<?php if ($email_verified): ?>
|
|
<span class="node-badge" style="border-color:#00c864;color:#00c864;">✓ Verified</span>
|
|
<?php else: ?>
|
|
<span class="node-badge" style="border-color:#ffc800;color:#ffc800;">Not verified</span>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm ms-2" id="btnResendVerify">Send verification email</button>
|
|
<?php endif; ?>
|
|
<span id="resendMsg" class="ms-2 small"></span>
|
|
</span>
|
|
</div>
|
|
<h6 class="mt-3 mb-2" style="color:var(--fg2);">Location</h6>
|
|
<div class="d-flex gap-2 align-items-center mb-1">
|
|
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_city">City</label>
|
|
<input class="form-control form-control-sm mono" id="f_city" name="city" value="<?=h($city)?>" style="max-width:400px;">
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center mb-1">
|
|
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_state">State</label>
|
|
<input class="form-control form-control-sm mono" id="f_state" name="state" value="<?=h($state)?>" style="max-width:400px;">
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center mb-1">
|
|
<label class="small text-secondary" style="min-width:200px;text-align:right;" for="f_country">Country</label>
|
|
<input class="form-control form-control-sm mono" id="f_country" name="country" value="<?=h($country)?>" style="max-width:400px;">
|
|
</div>
|
|
<button class="btn btn-primary btn-sm mt-3" type="submit">Save</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ptab-nodes" class="profile-tab-pane" style="display:none;">
|
|
<div class="panel">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="mb-0">My Nodes</h5>
|
|
<div class="d-flex gap-2 align-items-center">
|
|
<div id="nodeSelWrap" class="d-flex gap-2 align-items-center" style="display:none;">
|
|
<label class="small text-secondary mb-0">Node:</label>
|
|
<select class="form-select form-select-sm" id="nodeSelect" style="max-width:180px;"></select>
|
|
</div>
|
|
<span id="nodesStatus" class="small text-secondary">Loading...</span>
|
|
</div>
|
|
</div>
|
|
<div id="nodesList"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ptab-nodesec" class="profile-tab-pane" style="display:none;">
|
|
<div class="panel">
|
|
<h5 class="mb-3">Node Security</h5>
|
|
<p class="small text-secondary">All reflector nodes you administer — by your callsign, as a listed node admin, or via your account email in the node's certificate (shown even when offline). The login password goes in the node's <span class="mono">AUTH_KEY</span>; copy it below. A reset restarts the reflector.</p>
|
|
<div id="nodeAuthList"><span class="small text-secondary">Loading…</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ptab-calls" class="profile-tab-pane" style="display:none;">
|
|
<div class="panel">
|
|
<h5 class="mb-3">Callsigns</h5>
|
|
<p class="small text-secondary">The callsigns you are admin for. Nodes whose name contains any of these callsigns belong to your account. Only site admins can change these lists.</p>
|
|
<h6 class="mb-2" style="color:var(--fg2);">Account callsigns</h6>
|
|
<div class="mb-3">
|
|
<?php if ($call !== ''): ?><span class="node-badge mono me-1"><?=h(strtoupper($call))?> · primary</span><?php endif; ?>
|
|
<?php foreach ($extra_calls as $xc): ?><span class="node-badge mono me-1"><?=h($xc)?></span><?php endforeach; ?>
|
|
<?php if ($call === '' && !$extra_calls): ?><span class="small text-secondary">None.</span><?php endif; ?>
|
|
</div>
|
|
<h6 class="mb-2" style="color:var(--fg2);">Node admin for</h6>
|
|
<div>
|
|
<?php if ($admin_nodes): foreach ($admin_nodes as $an): ?><span class="node-badge mono me-1"><?=h($an)?></span><?php endforeach; else: ?><span class="small text-secondary">None — you are not listed as an extra admin on any node.</span><?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ptab-mqtt" class="profile-tab-pane" style="display:none;">
|
|
<div class="panel">
|
|
<h5 class="mb-3">Remote control (MQTT)</h5>
|
|
<div class="text-secondary small mb-3">Generate the MQTT credentials for your node, then paste them into the node dashboard → <b>Admin → Remote Ctrl</b> and press <b>Turn ON</b>. This lets the reflector portal remote-set your node's talkgroup over an encrypted, outbound link (works behind NAT — no port-forwarding).</div>
|
|
<div id="mqttPane"><span class="small text-secondary">Loading…</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ptab-password" class="profile-tab-pane" style="display:none;">
|
|
<div class="panel">
|
|
<h6 class="mb-3">Reset Web Password</h6>
|
|
<form method="post" action="">
|
|
<input type="hidden" name="action" value="change_password">
|
|
<label class="form-label small text-secondary" for="f_old">Current password</label>
|
|
<input type="password" class="form-control form-control-sm mb-2" id="f_old" name="old_password">
|
|
<label class="form-label small text-secondary" for="f_new">New password</label>
|
|
<input type="password" class="form-control form-control-sm mb-2" id="f_new" name="new_password">
|
|
<label class="form-label small text-secondary" for="f_new2">Repeat new password</label>
|
|
<input type="password" class="form-control form-control-sm mb-2" id="f_new2" name="new_password2">
|
|
<button class="btn btn-warning btn-sm mt-2" type="submit">Change Password</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<?php
|
|
$extra_scripts = '<script src="/auth/profile_nodes.js?v=' . @filemtime(__DIR__ . '/profile_nodes.js') . '"></script>';
|
|
require __DIR__ . '/footer.php';
|
|
?>
|