Public web-only release: SvxPortal front-end + auth backend, install templates and INSTALL.md

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>
This commit is contained in:
2026-09-26 23:07:05 +02:00
co-authored by Claude Fable 5.1
commit 25b481e2ae
93 changed files with 13357 additions and 0 deletions
+847
View File
@@ -0,0 +1,847 @@
'use strict';
var LEVEL_LABELS = {1:'Normal', 2:'Admin', 3:'SuperAdmin'};
var allAccounts = [];
var currentPage = 1;
var pageSize = 25;
var currentUuid = null;
var currentAcct = null;
var editNodes = {};
var editNodesLoaded = false;
var $ = function(id) { return document.getElementById(id); };
function setMsg(el, txt, err) {
if (!el) return;
if (!txt) { el.style.display = 'none'; el.textContent = ''; return; }
el.style.display = 'block';
el.textContent = txt;
el.style.color = err ? '#ff8080' : '#00ff80';
}
function fmtTime(t) { if (!t) return '-'; return new Date(t * 1000).toISOString().slice(0,16).replace('T',' '); }
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
async function ensureCsrf() {
if (window._csrfToken) return;
try {
var m = await (await fetch('/auth/me.php', {credentials:'same-origin'})).json();
if (m && m.csrf_token) window._csrfToken = m.csrf_token;
if (m && typeof m.flags !== 'undefined') window._actingFlags = (m.flags | 0);
} catch (_) {}
}
async function api(body) {
await ensureCsrf();
var send = function() {
return fetch('/auth/admin_accounts.php', { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''}, body:JSON.stringify(body) })
.then(function(r){ return r.json().catch(function(){ return null; }); });
};
var j = await send();
// Stale/missing token → fetch a fresh one and retry once, so writes never fail silently.
if (j && j.error === 'invalid_csrf_token') {
window._csrfToken = null;
await ensureCsrf();
j = await send();
}
return j;
}
/* ======== ACCOUNT FLAGS POPUP ========
8-bit flags as on the dmr site: several can be set at once.
1=unreg 2=reg 4=normal 8=lvl1 16=lvl2 32=lvl3 */
var ACCT_FLAGS = [
{bit:1, label:'unreg'},
{bit:2, label:'reg'},
{bit:4, label:'normal'},
{bit:8, label:'lvl1'},
{bit:16, label:'lvl2'},
{bit:32, label:'lvl3'}
];
function flagsLabel(f) {
var names = [];
for (var i = 0; i < ACCT_FLAGS.length; i++) if (f & ACCT_FLAGS[i].bit) names.push(ACCT_FLAGS[i].label);
return names.length ? names.join(', ') : '-';
}
var flagsModalState = null; // {uuid, flags, pending, verified, onChange}
// enable/disable control, same as the dmr site: a one-click Enable/Disable toggle
// (green Enable when off / gold Disable when on). Behåller interfacet (.value ==
// 'true'/'false', 'change'-event, .disabled) så anroparen är oförändrad.
function makeBoolSelect(val, disabled) {
var b = document.createElement('button');
b.type = 'button';
function paint(state) {
b.className = 'btn btn-sm ' + (state ? 'btn-warning' : 'btn-success');
b.textContent = state ? 'Disable' : 'Enable';
b.value = state ? 'true' : 'false';
b.dataset.on = state ? '1' : '0';
}
paint(!!val);
b.disabled = !!disabled;
b.addEventListener('click', function() {
if (b.disabled) return;
paint(b.dataset.on !== '1');
b.dispatchEvent(new Event('change', { bubbles: true }));
});
return b;
}
function renderFlagsModal() {
var st = flagsModalState;
var box = $('flagsModalBtns');
if (!st || !box) return;
// Acting flags come from the fresh me.php fetch first (never cached/undefined),
// then the inline global. If neither is known yet, act as full admin (rank 3) so
// the level controls are never wrongly locked — the server still enforces the rules.
var af = (typeof window._actingFlags === 'number') ? window._actingFlags
: ((typeof ADMIN_FLAGS !== 'undefined') ? ADMIN_FLAGS : null);
var actingRank = (af === null) ? 3
: ((af & 32) ? 3 : ((af & 16) ? 2 : ((af & 8) ? 1 : 0)));
box.innerHTML = '';
ACCT_FLAGS.forEach(function(fl) {
var on = (st.pending & fl.bit) !== 0;
var dis = false;
if (fl.bit === 2 && !st.verified && !on) dis = true; // reg requires verified email
if (fl.bit === 4 && !(st.pending & 2) && !on) dis = true; // normal requires reg
if ((fl.bit & (8|16|32)) && !(st.pending & 4) && !on) dis = true; // admin requires normal (mirrors dmr: admin requires verified)
if (fl.bit & (8|16|32)) {
var oldRank = (st.pending & 32) ? 3 : ((st.pending & 16) ? 2 : ((st.pending & 8) ? 1 : 0));
var thisRank = (fl.bit === 32) ? 3 : ((fl.bit === 16) ? 2 : 1);
if (actingRank < 3 && (oldRank >= actingRank || thisRank >= actingRank)) dis = true;
}
var row = document.createElement('div');
row.className = 'd-flex align-items-center justify-content-between';
var lbl = document.createElement('label');
lbl.className = 'small mono mb-0';
lbl.textContent = fl.label;
var sel = makeBoolSelect(on, dis);
sel.addEventListener('change', function() {
var turnOn = sel.value === 'true';
if (fl.bit & (8|16|32)) {
// Admin level is exclusive — one of lvl1/lvl2/lvl3 at a time.
st.pending = turnOn ? ((st.pending & ~(8|16|32)) | fl.bit) : (st.pending & ~fl.bit);
} else {
st.pending = turnOn ? (st.pending | fl.bit) : (st.pending & ~fl.bit);
if (!(st.pending & 2)) st.pending &= ~4; // dropping reg also drops normal
if (!(st.pending & 4)) st.pending &= ~(8|16|32); // dropping normal also drops admin (mirrors dmr)
}
renderFlagsModal();
});
row.appendChild(lbl);
row.appendChild(sel);
box.appendChild(row);
});
}
function openFlagsModal(uuid, callsign, flags, verified, onChange) {
flagsModalState = {uuid:uuid, flags:flags, pending:flags, verified:verified, onChange:onChange};
$('flagsModalCs').textContent = callsign || '';
setMsg($('flagsModalMsg'), null);
renderFlagsModal();
if (window.bootstrap) new bootstrap.Modal($('acctFlagsModal')).show();
}
async function rapi(body) {
var r = await fetch('/auth/admin_reflector_api.php', { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''}, body:JSON.stringify(body) });
return r.json().catch(function(){ return null; });
}
/* ======== TAB SWITCHING ======== */
function switchTab(tab) {
document.querySelectorAll('.profile-tab').forEach(function(b) {
var active = b.dataset.ptab === tab;
b.classList.toggle('active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('.profile-tab-pane').forEach(function(p) {
p.style.display = p.id === 'ptab-' + tab ? '' : 'none';
});
if (tab === 'nodes' && !editNodesLoaded) loadEditNodes();
}
/* ======== ACCOUNT LIST ======== */
async function loadAccounts() {
var j = await api({action:'list'});
if (j && j.ok) {
allAccounts = j.accounts;
$('loadMsg').textContent = ''; // count is shown by the pagination row
renderAccounts('');
} else {
$('loadMsg').textContent = 'Error';
}
}
function renderAccounts(filter) {
var tbody = $('acctTable').querySelector('tbody');
tbody.innerHTML = '';
var f = (filter || '').toLowerCase();
var allRows = f ? allAccounts.filter(function(a) {
return (a.callsign||'').toLowerCase().includes(f) || ((a.firstname||'')+' '+(a.lastname||'')).toLowerCase().includes(f) || (a.email||'').toLowerCase().includes(f);
}) : allAccounts;
var totalPages = Math.max(1, Math.ceil(allRows.length / pageSize));
if (currentPage > totalPages) currentPage = totalPages;
var start = (currentPage - 1) * pageSize;
var rows = allRows.slice(start, start + pageSize);
renderPagination(allRows.length, totalPages);
for (var i = 0; i < rows.length; i++) {
var a = rows[i];
// Status reflects login-eligibility: approved (normal bit 4) = can log in.
// Verified-but-unapproved is the "waiting for admin" state the bell surfaces.
var approved = ((a.flags || 0) & 4) !== 0;
var status = a.banned ? '<span style="color:#ff4040;">BANNED</span>'
: (!a.verified ? '<span style="color:#888;">Unverified</span>'
: (approved ? '<span style="color:#0f0;">Active</span>' : '<span style="color:#ff9800;">Pending approval</span>'));
var tr = document.createElement('tr');
tr.innerHTML = '<td class="mono"><strong>'+esc(a.callsign||'')+'</strong></td><td>'+esc((a.firstname||'')+' '+(a.lastname||''))+'</td><td class="small">'+esc(a.email||'')+'</td><td class="acct-flags-cell" data-uuid="'+a.uuid+'">'+esc(flagsLabel(a.flags||0))+'</td><td>'+status+'</td><td class="small mono">'+fmtTime(a.last_login)+'</td><td>'
+ '<button type="button" class="btn btn-outline-secondary btn-sm acct-level me-1" data-uuid="'+a.uuid+'">Admin</button>'
+ '<button type="button" class="btn btn-outline-secondary btn-sm acct-edit me-1" data-uuid="'+a.uuid+'">Edit</button>'
+ '<button type="button" class="btn btn-outline-danger btn-sm acct-del me-1" data-uuid="'+a.uuid+'" data-cs="'+esc(a.callsign||'')+'" title="Permanently delete this account">Delete</button>'
+ '<button type="button" class="btn btn-outline-danger btn-sm acct-ban me-1" data-uuid="'+a.uuid+'" data-cs="'+esc(a.callsign||'')+'" title="Ban this user — they will not be able to log in or use any services.">Ban</button>'
+ '<button type="button" class="btn btn-outline-success btn-sm acct-unban" data-uuid="'+a.uuid+'" title="Remove ban — restore access for this user.">Unban</button>'
+ '</td>';
tbody.appendChild(tr);
}
tbody.querySelectorAll('.acct-level').forEach(function(btn) {
btn.addEventListener('click', function() {
var acct = null;
for (var k = 0; k < allAccounts.length; k++) if (allAccounts[k].uuid === btn.dataset.uuid) { acct = allAccounts[k]; break; }
if (!acct) return;
openFlagsModal(acct.uuid, acct.callsign || '', acct.flags || 0, !!acct.verified, function(nf) {
acct.flags = nf;
var cell = tbody.querySelector('.acct-flags-cell[data-uuid="'+acct.uuid+'"]');
if (cell) cell.textContent = flagsLabel(nf);
});
});
});
tbody.querySelectorAll('.acct-ban').forEach(function(btn) {
btn.addEventListener('click', async function() {
if (!confirm('Ban ' + (btn.dataset.cs || btn.dataset.uuid) + '? They will not be able to log in or use any services.')) return;
var reason = prompt('Ban reason (optional):') || '';
var j = await api({action:'ban', uuid:btn.dataset.uuid, reason:reason});
if (j && j.ok) loadAccounts();
else alert((j && j.error) || 'Failed');
});
});
tbody.querySelectorAll('.acct-unban').forEach(function(btn) {
btn.addEventListener('click', async function() {
var j = await api({action:'unban', uuid:btn.dataset.uuid});
if (j && j.ok) loadAccounts();
else alert((j && j.error) || 'Failed');
});
});
tbody.querySelectorAll('.acct-edit').forEach(function(btn) {
btn.addEventListener('click', function() { openEdit(btn.dataset.uuid); });
});
tbody.querySelectorAll('.acct-del').forEach(function(btn) {
btn.addEventListener('click', async function() {
if (!confirm('Permanently delete account ' + (btn.dataset.cs || btn.dataset.uuid) + '?')) return;
var j = await api({action:'delete', uuid:btn.dataset.uuid});
if (j && j.ok) loadAccounts();
else alert((j && j.error) || 'Failed');
});
});
}
/* ======== EDIT VIEW ======== */
function setVerifiedStatus() {
var el = document.getElementById('acctVerifiedStatus');
if (!el || !currentAcct) return;
var v = String(currentAcct.email_verified || '0') === '1';
el.innerHTML = v ? '<span style="color:#00c864;">Verified</span>' : '<span style="color:#ffc800;">Not verified</span>';
}
// Approval badge next to the callsign — mirrors the dmr site so an admin can
// see login-eligibility at a glance. Approved = normal bit (4) set.
function setApprovalBadge() {
var el = document.getElementById('acctApprovalBadge');
if (!el || !currentAcct) return;
var banned = (parseInt(currentAcct.banned, 10) || 0) === 1;
var verified = String(currentAcct.email_verified || '0') === '1';
var approved = ((parseInt(currentAcct.flags, 10) || 0) & 4) !== 0;
el.className = 'badge ms-2';
if (banned) { el.style.background = '#ff4040'; el.style.color = '#fff'; el.textContent = 'BANNED'; }
else if (approved) { el.style.background = '#00c864'; el.style.color = '#062'; el.textContent = 'Approved — can log in'; }
else if (verified) { el.style.background = '#ff9800'; el.style.color = '#3a2500'; el.textContent = 'Pending approval — cannot log in'; }
else { el.style.background = '#888'; el.style.color = '#fff'; el.textContent = 'Not verified — email not confirmed'; }
// Approve only makes sense once email is verified (set_flags rejects reg on an
// unverified account). Show Approve when pending, Unapprove when approved.
var bA = document.getElementById('btnApprove');
var bU = document.getElementById('btnUnapprove');
if (bA) bA.style.display = (!banned && verified && !approved) ? '' : 'none';
if (bU) bU.style.display = (!banned && approved) ? '' : 'none';
}
async function openEdit(uuid) {
currentUuid = uuid;
editNodesLoaded = false;
var j = await api({action:'get', uuid:uuid});
if (!j || !j.ok) return;
currentAcct = j.account;
$('acctEditCs').textContent = currentAcct.callsign || uuid;
setApprovalBadge();
// Render fields — dmr peer-editor "stuk": grouped headers, one row per field
// with a right-aligned 200px label and a capped mono input.
var container = $('acctFields');
container.innerHTML = '';
var groups = [
{label:'Identity', fields:[{key:'callsign',label:'Callsign'},{key:'firstname',label:'First name'},{key:'lastname',label:'Last name'},{key:'shortname',label:'Short name'},{key:'email',label:'Email'}]},
{label:'Location', fields:[{key:'city',label:'City'},{key:'state',label:'State'},{key:'country',label:'Country'}]}
];
for (var g = 0; g < groups.length; g++) {
var h = document.createElement('h6');
h.className = (g === 0 ? 'mt-2' : 'mt-3') + ' mb-2';
h.style.color = 'var(--fg2)';
h.textContent = groups[g].label;
container.appendChild(h);
for (var i = 0; i < groups[g].fields.length; i++) {
var f = groups[g].fields[i];
var row = document.createElement('div');
row.className = 'd-flex gap-2 align-items-center mb-1';
var lbl = document.createElement('label');
lbl.className = 'small text-secondary';
lbl.style.minWidth = '200px';
lbl.style.textAlign = 'right';
lbl.htmlFor = 'aedit_' + f.key;
lbl.textContent = f.label;
var inp = document.createElement('input');
inp.className = 'form-control form-control-sm mono';
inp.style.maxWidth = '400px';
inp.id = 'aedit_' + f.key;
inp.value = currentAcct[f.key] || '';
row.appendChild(lbl);
row.appendChild(inp);
container.appendChild(row);
}
}
$('acctFlagsText').textContent = flagsLabel(parseInt(currentAcct.flags, 10) || 0);
setVerifiedStatus();
loadAcctCalls();
// Hide restricted actions for level 2
var isSuperAdmin = (typeof ADMIN_LEVEL !== 'undefined') && ADMIN_LEVEL >= 3;
$('btnBan').style.display = isSuperAdmin ? '' : 'none';
$('btnUnban').style.display = isSuperAdmin ? '' : 'none';
$('btnLock').style.display = isSuperAdmin ? '' : 'none';
$('btnUnlock').style.display = isSuperAdmin ? '' : 'none';
$('btnDelete').style.display = isSuperAdmin ? '' : 'none';
// Show edit, hide list
$('acctListPanel').style.display = 'none';
$('acctEditPanel').style.display = '';
switchTab('account');
setMsg($('acctMsg'), null);
}
function backToList() {
$('acctEditPanel').style.display = 'none';
$('acctListPanel').style.display = '';
currentUuid = null;
currentAcct = null;
}
/* ======== CALLSIGNS TAB (callsigns the account is admin for) ======== */
// Same dmr-style editor as the node-admins table: Add on top, editable rows
// with Delete, Save at the bottom diffing against the server.
async function loadAcctCalls() {
var box = $('acctCallsEditor');
if (!box || !currentUuid) return;
box.innerHTML = '<span class="small text-secondary">Loading…</span>';
var j = await api({action:'acct_calls_list', uuid:currentUuid});
if (!j || !j.ok) { box.innerHTML = '<span class="small text-secondary">' + esc((j && j.error) || 'Failed') + '</span>'; return; }
var server = (j.callsigns || []).map(function(c){ return String(c).toUpperCase(); });
var working = server.slice();
box.innerHTML =
'<div class="d-flex gap-2 mb-2"><button type="button" class="btn btn-sm btn-outline-success ac-add">Add</button></div>'
+ '<div class="ac-wrap mb-2"></div>'
+ '<div class="d-flex gap-2 mt-2"><button type="button" class="btn btn-sm btn-primary ac-save">Save</button></div>'
+ '<div class="ac-msg small mt-2" style="display:none;"></div>';
var tw = box.querySelector('.ac-wrap'), msg = box.querySelector('.ac-msg');
function setErr(t, ok){ msg.style.display = t?'':'none'; msg.style.color = ok?'#00ff80':'#ff8080'; msg.textContent = t||''; }
function renderTable(){
var h = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:280px;"><thead><tr><th>Callsign</th><th style="width:120px;">Action</th></tr></thead><tbody>';
if (!working.length) h += '<tr><td colspan="2" class="small text-secondary">No extra callsigns — click Add. (The primary callsign always counts.)</td></tr>';
working.forEach(function(v, i){
h += '<tr><td><input type="text" class="form-control form-control-sm mono ac-cs" data-i="'+i+'" value="'+esc(v)+'" placeholder="SK7AX" style="text-transform:uppercase;"></td>'
+ '<td><button type="button" class="btn btn-sm btn-outline-danger ac-del" data-i="'+i+'">Delete</button></td></tr>';
});
tw.innerHTML = h + '</tbody></table></div>';
tw.querySelectorAll('.ac-cs').forEach(function(inp){ inp.addEventListener('input', function(){ working[+inp.dataset.i] = inp.value; }); });
tw.querySelectorAll('.ac-del').forEach(function(b){ b.addEventListener('click', function(){ working.splice(+b.dataset.i, 1); renderTable(); }); });
}
renderTable();
box.querySelector('.ac-add').addEventListener('click', function(){ working.push(''); renderTable(); });
box.querySelector('.ac-save').addEventListener('click', async function(){
var seen = {}, norm = [];
working.forEach(function(c){ c = String(c).trim().toUpperCase(); if (c && !seen[c]){ seen[c] = 1; norm.push(c); } });
var toRemove = server.filter(function(c){ return norm.indexOf(c) < 0; });
var toAdd = norm.filter(function(c){ return server.indexOf(c) < 0; });
setErr('Saving…', true);
for (var i = 0; i < toRemove.length; i++){ var r = await api({action:'acct_calls_remove', uuid:currentUuid, callsign:toRemove[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to remove '+toRemove[i]), false); return; } }
for (var i = 0; i < toAdd.length; i++){ var r = await api({action:'acct_calls_add', uuid:currentUuid, callsign:toAdd[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to add '+toAdd[i]), false); return; } }
server = norm.slice(); working = norm.slice(); renderTable();
setErr('✓ Saved', true);
});
}
/* ======== NODES TAB (per user) ======== */
async function loadEditNodes() {
if (!currentAcct) return;
var status = $('editNodesStatus');
var list = $('editNodesList');
// Fetch all connected nodes from reflector — by uuid so the account's extra
// callsigns (club calls) are matched too.
try {
var ch = await fetch('/auth/admin_nodes.php?uuid=' + encodeURIComponent(currentUuid), {credentials:'same-origin'});
var j = await ch.json();
if (!j || !j.ok) { status.textContent = j && j.error || 'Error'; return; }
editNodesLoaded = true;
editNodes = j.nodes || {};
var keys = Object.keys(editNodes);
// Populate selector
var sel = $('editNodeSelect');
sel.innerHTML = '';
if (keys.length > 1) {
var o = document.createElement('option');
o.value = '__all__';
o.textContent = 'All (' + keys.length + ')';
sel.appendChild(o);
}
for (var i = 0; i < keys.length; i++) {
var o = document.createElement('option');
o.value = keys[i];
o.textContent = keys[i];
sel.appendChild(o);
}
$('editNodeSelWrap').style.display = keys.length > 0 ? '' : 'none';
status.textContent = keys.length ? keys.length + ' node' + (keys.length > 1 ? 's' : '') : 'No nodes';
renderEditNodes('__all__');
} catch(e) { status.textContent = 'Failed'; }
loadEditNodeAuth();
}
// POST to the shared node-auth API for the account being edited (target = its uuid).
async function nauth(body) {
body = body || {};
body.target = currentUuid;
var r = await fetch('/auth/node_auth_api.php', { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''}, body:JSON.stringify(body) });
return r.json().catch(function(){ return null; });
}
// Reflector login & certificate — same layout/actions as the profile page, but
// for the edited account (renewals, remove cert, accept/issue pending, password).
async function loadEditNodeAuth() {
var cont = $('editNodeAuthList');
if (!cont) return;
cont.innerHTML = '<span class="small text-secondary">Loading…</span>';
var j = await nauth({action:'list'});
if (!j || !j.ok) { cont.innerHTML = '<span class="small text-secondary">' + esc((j && j.error) || 'Failed to load') + '</span>'; return; }
var list = j.nodes || [], pend = j.pending || [];
if (!list.length && !pend.length) { cont.innerHTML = '<span class="small text-secondary">No reflector logins or certificates are linked to this account.</span>'; return; }
cont.innerHTML = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:520px;"><thead><tr><th>Callsign</th><th>Auth</th><th>Password</th><th>Actions</th></tr></thead><tbody></tbody></table></div>';
var tb = cont.querySelector('tbody');
list.forEach(function(n) {
var auth = [];
if (n.cert) {
var expired = n.validTo && (n.validTo * 1000 < Date.now());
auth.push('<span class="node-badge">cert' + (n.validTo ? ' · ' + new Date(n.validTo*1000).toISOString().slice(0,10) : '') + '</span>' + (expired ? ' <span class="node-badge" style="border-color:#ff4040;color:#ff4040;">EXPIRED</span>' : ''));
if (typeof n.renewals !== 'undefined') auth.push('<span class="node-badge" title="Times issued/renewed">renewals: ' + (n.renewals|0) + '</span>');
}
if (n.password) auth.push('<span class="node-badge">password</span>');
var tr = document.createElement('tr');
tr.innerHTML = '<td class="mono"><strong>' + esc(n.node) + '</strong></td><td>' + auth.join(' ') + '</td>'
+ '<td class="na-pw mono small">' + (n.password ? 'Loading…' : '<span class="text-secondary">—</span>') + '</td>'
+ '<td class="na-act" style="white-space:nowrap;"></td>';
tb.appendChild(tr);
var act = tr.querySelector('.na-act');
if (n.cert) {
// Revoke — remove the cert; renewals kept so the node can re-issue.
// Allowed for callsign-admins and any site admin (lvl>=1).
var rv = document.createElement('button');
rv.type = 'button'; rv.className = 'btn btn-sm btn-outline-warning py-0 me-1'; rv.style.fontSize = '.75rem';
rv.textContent = 'Revoke'; rv.title = 'Remove the certificate; the node can request a new one (renewals kept)';
rv.addEventListener('click', function() {
if (!confirm('Revoke the certificate for ' + n.node + '?\nThe node can request a new one afterwards.')) return;
rv.disabled = true;
nauth({action:'self_revoke', node:n.node}).then(function(r) {
if (r && r.ok) loadEditNodeAuth(); else { rv.disabled = false; alert((r && (r.detail||r.error)) || 'Failed'); }
});
});
act.appendChild(rv);
// Delete cert — permanent full wipe. Site admins level 2-3 only.
if (typeof ADMIN_LEVEL !== 'undefined' && ADMIN_LEVEL >= 2) {
var dl = document.createElement('button');
dl.type = 'button'; dl.className = 'btn btn-sm btn-outline-danger py-0 me-1'; dl.style.fontSize = '.75rem';
dl.textContent = 'Delete cert'; dl.title = 'Permanently delete cert + pending CSR + renewal counters + node admins';
dl.addEventListener('click', function() {
if (!confirm('DELETE the certificate for ' + n.node + ' permanently?\nAlso clears pending CSR, renewal counters and node admins. The node cannot self-renew afterwards.')) return;
dl.disabled = true;
nauth({action:'delete_cert', node:n.node}).then(function(r) {
if (r && r.ok) loadEditNodeAuth(); else { dl.disabled = false; alert((r && (r.detail||r.error)) || 'Failed'); }
});
});
act.appendChild(dl);
}
}
if (n.password) {
var pwCell = tr.querySelector('.na-pw');
nauth({action:'getpw', node:n.node}).then(function(r) { pwCell.textContent = (r && r.ok) ? r.password : ((r && (r.detail||r.error)) || 'failed'); });
var rp = document.createElement('button');
rp.type = 'button'; rp.className = 'btn btn-sm btn-outline-secondary py-0'; rp.style.fontSize = '.75rem'; rp.textContent = 'Reset password';
rp.addEventListener('click', function() {
if (!confirm('Generate a NEW reflector password for ' + n.node + '?')) return;
rp.disabled = true; pwCell.textContent = 'Working…';
nauth({action:'resetpw', node:n.node}).then(function(r) { rp.disabled = false; pwCell.textContent = (r && r.ok) ? r.password : ((r && (r.detail||r.error)) || 'failed'); });
});
act.appendChild(rp);
}
});
pend.forEach(function(p) {
var tr = document.createElement('tr');
tr.innerHTML = '<td class="mono"><strong>' + esc(p.node) + '</strong></td>'
+ '<td><span class="node-badge" style="border-color:#ffc800;color:#ffc800;">cert request</span>' + (typeof p.renewals !== 'undefined' ? ' <span class="node-badge">renewals: ' + (p.renewals|0) + '</span>' : '') + '</td>'
+ '<td class="text-secondary small">—</td><td class="na-act" style="white-space:nowrap;"></td>';
tb.appendChild(tr);
var act = tr.querySelector('.na-act');
if (p.canSelfSign) {
var ac = document.createElement('button');
ac.type = 'button'; ac.className = 'btn btn-sm btn-success py-0 me-1'; ac.style.fontSize = '.75rem'; ac.textContent = 'Accept / issue';
ac.addEventListener('click', function() {
if (!confirm('Issue the certificate for ' + p.node + '?')) return;
ac.disabled = true;
nauth({action:'self_sign', node:p.node}).then(function(r) { if (r && r.ok) loadEditNodeAuth(); else { ac.disabled = false; alert((r && (r.detail||r.error)) || 'Failed'); } });
});
act.appendChild(ac);
}
if (p.canReject) {
var rj = document.createElement('button');
rj.type = 'button'; rj.className = 'btn btn-sm btn-outline-danger py-0'; rj.style.fontSize = '.75rem'; rj.textContent = 'Reject';
rj.addEventListener('click', function() {
if (!confirm('Reject (delete) the pending request for ' + p.node + '?')) return;
rj.disabled = true;
rapi({action:'rmpending', callsign:p.node}).then(function(r) { if (r && r.ok) loadEditNodeAuth(); else { rj.disabled = false; alert((r && (r.detail||r.error)) || 'Failed'); } });
});
act.appendChild(rj);
}
});
}
function renderEditNodes(selected) {
var list = $('editNodesList');
list.innerHTML = '';
var keys = Object.keys(editNodes);
if (keys.length === 0) {
list.innerHTML = '<div class="text-secondary small">No nodes (connected, password or certificate) found for this callsign.</div>';
return;
}
for (var i = 0; i < keys.length; i++) {
if (selected !== '__all__' && keys[i] !== selected) continue;
list.appendChild(buildNodeCard(keys[i], editNodes[keys[i]]));
}
}
// dmr peer "Edit admins" editor: Add on top, a row per admin with an editable
// callsign field + Delete, Save at the bottom. Each node keeps its own admin
// list; on Save the edited list is diffed against the server and add/remove
// calls are issued. Site admins (this page is admin-only) may edit any node.
function buildNodeAdminEditor(box, cs, currentAdmins) {
var server = (currentAdmins || []).map(function(c){ return String(c).toUpperCase(); });
var working = server.slice();
box.innerHTML =
'<div class="small text-secondary mb-1">Node admins — add a callsign to give them admin rights on this node. Each node has its own admins.</div>'
+ '<div class="d-flex gap-2 mb-2"><button type="button" class="btn btn-sm btn-outline-success ae-add">Add</button></div>'
+ '<div class="ae-wrap mb-2"></div>'
+ '<div class="d-flex gap-2 mt-2"><button type="button" class="btn btn-sm btn-primary ae-save">Save</button></div>'
+ '<div class="ae-msg small mt-2" style="display:none;"></div>';
var tw = box.querySelector('.ae-wrap'), msg = box.querySelector('.ae-msg');
function setErr(t, ok){ msg.style.display = t?'':'none'; msg.style.color = ok?'#00ff80':'#ff8080'; msg.textContent = t||''; }
function renderTable(){
var h = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:280px;"><thead><tr><th>Callsign</th><th style="width:120px;">Action</th></tr></thead><tbody>';
if (!working.length) h += '<tr><td colspan="2" class="small text-secondary">No admins — click Add to grant a callsign admin rights for this node. (The node’s own callsign always counts.)</td></tr>';
working.forEach(function(v, i){
h += '<tr><td><input type="text" class="form-control form-control-sm mono ae-cs" data-i="'+i+'" value="'+esc(v)+'" placeholder="SA7AUX" style="text-transform:uppercase;"></td>'
+ '<td><button type="button" class="btn btn-sm btn-outline-danger ae-del" data-i="'+i+'">Delete</button></td></tr>';
});
tw.innerHTML = h + '</tbody></table></div>';
tw.querySelectorAll('.ae-cs').forEach(function(inp){ inp.addEventListener('input', function(){ working[+inp.dataset.i] = inp.value; }); });
tw.querySelectorAll('.ae-del').forEach(function(b){ b.addEventListener('click', function(){ working.splice(+b.dataset.i, 1); renderTable(); }); });
}
renderTable();
box.querySelector('.ae-add').addEventListener('click', function(){ working.push(''); renderTable(); });
box.querySelector('.ae-save').addEventListener('click', async function(){
var seen = {}, norm = [];
working.forEach(function(c){ c = String(c).trim().toUpperCase(); if (c && !seen[c]){ seen[c] = 1; norm.push(c); } });
var toRemove = server.filter(function(c){ return norm.indexOf(c) < 0; });
var toAdd = norm.filter(function(c){ return server.indexOf(c) < 0; });
setErr('Saving…', true);
for (var i = 0; i < toRemove.length; i++){ var r = await api({action:'node_admin_remove', node:cs, admin_callsign:toRemove[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to remove '+toRemove[i]), false); return; } }
for (var i = 0; i < toAdd.length; i++){ var r = await api({action:'node_admin_add', node:cs, admin_callsign:toAdd[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to add '+toAdd[i]), false); return; } }
server = norm.slice();
setErr('✓ Saved', true);
});
}
function buildNodeCard(callsign, n) {
var card = document.createElement('div');
card.className = 'node-card';
var html = '<h6 class="mono"><strong>' + esc(callsign) + '</strong>';
if (n.nodeClass) html += ' <span class="node-badge">' + esc(n.nodeClass) + '</span>';
if (n.password) html += ' <span class="node-badge">password</span>';
if (n.cert) html += ' <span class="node-badge">cert</span>';
if (n.isTalker) html += ' <span class="node-badge" style="border-color:#ff4040;color:#ff4040;">TX</span>';
if (n.offline) html += ' <span class="node-badge" style="border-color:#888;color:#888;">offline</span>';
html += '</h6>';
html += '<dl class="node-grid">';
if (n.nodeLocation) html += '<dt>Location</dt><dd>' + esc(n.nodeLocation) + '</dd>';
if (n.sysop) html += '<dt>Sysop</dt><dd>' + esc(n.sysop) + '</dd>';
if (n.sw) html += '<dt>Software</dt><dd>' + esc(n.sw + ' ' + (n.swVer||'')) + '</dd>';
if (n.projVer) html += '<dt>Build</dt><dd>' + esc(n.projVer) + '</dd>';
if (n.machineArch) html += '<dt>Arch</dt><dd>' + esc(n.machineArch) + '</dd>';
if (n.protoVer) html += '<dt>Protocol</dt><dd>v' + n.protoVer.majorVer + '.' + n.protoVer.minorVer + '</dd>';
if (n.tg !== undefined) html += '<dt>Current TG</dt><dd>' + esc(String(n.tg || 'None')) + '</dd>';
html += '</dl>';
// Node admins — dmr peer "Edit admins" table editor, mounted after innerHTML.
html += '<div class="mt-2 na-adm-mount"></div>';
// Monitored TGs
if (n.monitoredTGs && n.monitoredTGs.length > 0) {
html += '<div class="mt-2"><span class="small text-secondary">Monitored TGs:</span><br>';
for (var i = 0; i < n.monitoredTGs.length; i++) html += '<span class="node-badge mono">' + n.monitoredTGs[i] + '</span>';
html += '</div>';
}
// CTCSS Tone Mapping
if (n.toneToTalkgroup && Object.keys(n.toneToTalkgroup).length > 0) {
html += '<div class="mt-2"><span class="small text-secondary">CTCSS Tone Mapping:</span>';
html += '<table style="font-size:.8rem;margin-top:4px;border-collapse:collapse;">';
var sorted = Object.entries(n.toneToTalkgroup).sort(function(a,b){ return parseFloat(a[0]) - parseFloat(b[0]); });
for (var i = 0; i < sorted.length; i++) {
html += '<tr><td class="mono" style="padding:1px 10px 1px 0;color:var(--fg2);">' + sorted[i][0] + ' Hz</td><td class="mono" style="padding:1px 0;">TG ' + sorted[i][1] + '</td></tr>';
}
html += '</table></div>';
}
// QTH
if (n.qth && n.qth.length > 0) {
for (var qi = 0; qi < n.qth.length; qi++) {
var q = n.qth[qi];
html += '<div class="mt-2" style="border-top:1px solid var(--line);padding-top:8px;">';
html += '<span class="small text-secondary">QTH: ' + esc(q.name || '') + '</span>';
html += '<dl class="node-grid mt-1">';
if (q.pos) {
if (q.pos.loc) html += '<dt>Locator</dt><dd>' + esc(q.pos.loc) + '</dd>';
if (q.pos.lat) html += '<dt>Lat/Long</dt><dd>' + esc(q.pos.lat + ', ' + q.pos.long) + '</dd>';
}
if (q.rx) {
for (var id in q.rx) {
var rx = q.rx[id];
html += '<dt>RX ' + esc(id) + '</dt><dd>' + esc((rx.name||'') + ' ' + (rx.freq ? rx.freq.toFixed(3)+' MHz':'') + (rx.sqlType ? ' ('+rx.sqlType+')':'')) + '</dd>';
html += '<dt>Status</dt><dd>' + esc((rx.enabled?'Enabled':'Disabled') + (rx.sql_open?', Squelch open':'')) + '</dd>';
}
}
if (q.tx) {
for (var id in q.tx) {
var tx = q.tx[id];
html += '<dt>TX ' + esc(id) + '</dt><dd>' + esc((tx.name||'') + ' ' + (tx.freq ? tx.freq.toFixed(3)+' MHz':'') + (tx.sqlType ? ' ('+tx.sqlType+')':'')) + '</dd>';
html += '<dt>Transmitting</dt><dd>' + esc(tx.transmit?'Yes':'No') + '</dd>';
}
}
html += '</dl>';
html += '</div>';
}
}
// Certificate & reflector login are shown in the "Reflector login & certificate"
// panel above (loadEditNodeAuth) — same layout as the profile page.
card.innerHTML = html;
// Mount the dmr-style multi-admin editor (Add / editable rows / Delete / Save).
var mount = card.querySelector('.na-adm-mount');
if (mount) buildNodeAdminEditor(mount, callsign, n.node_admins || []);
return card;
}
function renderPagination(total, totalPages) {
var wrap = $('paginationWrap');
if (!wrap) return;
if (totalPages <= 1) { wrap.innerHTML = '<span class="small text-secondary">' + total + ' accounts</span>'; return; }
var html = '<nav class="d-flex align-items-center gap-1 flex-wrap" style="font-size:.8rem;">';
html += '<span class="small text-secondary me-2">' + total + ' accounts, page ' + currentPage + '/' + totalPages + '</span>';
// First + Prev
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="1" ' + (currentPage===1?'disabled':'') + ' style="padding:2px 8px;">&laquo;</button>';
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + (currentPage-1) + '" ' + (currentPage===1?'disabled':'') + ' style="padding:2px 8px;">&lsaquo;</button>';
// Page numbers — show range around current
var rangeStart = Math.max(1, currentPage - 3);
var rangeEnd = Math.min(totalPages, currentPage + 3);
for (var p = rangeStart; p <= rangeEnd; p++) {
var active = p === currentPage ? 'background:rgba(0,255,255,.2);' : '';
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + p + '" style="padding:2px 8px;' + active + '">' + p + '</button>';
}
// Next + Last
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + (currentPage+1) + '" ' + (currentPage===totalPages?'disabled':'') + ' style="padding:2px 8px;">&rsaquo;</button>';
html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + totalPages + '" ' + (currentPage===totalPages?'disabled':'') + ' style="padding:2px 8px;">&raquo;</button>';
html += '</nav>';
wrap.innerHTML = html;
wrap.querySelectorAll('.pg-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var pg = parseInt(btn.dataset.pg, 10);
if (pg >= 1 && pg <= totalPages && pg !== currentPage) {
currentPage = pg;
renderAccounts($('acctSearch').value);
}
});
});
}
/* ======== INIT ======== */
document.addEventListener('DOMContentLoaded', function() {
// Load CSRF token
fetch('/auth/me.php',{credentials:'same-origin'}).then(function(r){return r.json();}).then(function(j){
if(j&&j.csrf_token) window._csrfToken=j.csrf_token;
if(j&&typeof j.flags!=='undefined') window._actingFlags=(j.flags|0);
}).catch(function(){});
loadAccounts();
$('acctSearch').addEventListener('input', function(e) { currentPage = 1; renderAccounts(e.target.value); });
$('btnBack').addEventListener('click', backToList);
// Tab switching
document.querySelectorAll('.profile-tab[data-ptab]').forEach(function(btn) {
btn.addEventListener('click', function() { switchTab(btn.dataset.ptab); });
});
// Node instance selector
$('editNodeSelect').addEventListener('change', function() { renderEditNodes(this.value); });
// Account actions
$('btnSave').addEventListener('click', async function() {
if (!currentUuid) return;
var body = {action:'update', uuid:currentUuid};
['callsign','firstname','lastname','shortname','email','city','state','country'].forEach(function(f) {
var el = $('aedit_'+f); if (el) body[f] = el.value;
});
var j = await api(body);
if (j && j.ok) { setMsg($('acctMsg'),'Saved!',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnBan').addEventListener('click', async function() {
if (!currentUuid) return;
var reason = prompt('Ban reason (optional):') || '';
var j = await api({action:'ban',uuid:currentUuid,reason:reason});
if (j&&j.ok) { setMsg($('acctMsg'),'Banned',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnUnban').addEventListener('click', async function() {
if (!currentUuid) return;
var j = await api({action:'unban',uuid:currentUuid});
if (j&&j.ok) { setMsg($('acctMsg'),'Unbanned',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnLock').addEventListener('click', async function() {
if (!currentUuid) return;
var reason = prompt('Lock reason (optional):') || '';
var j = await api({action:'lock',uuid:currentUuid,reason:reason});
if (j&&j.ok) { setMsg($('acctMsg'),'Locked',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnUnlock').addEventListener('click', async function() {
if (!currentUuid) return;
var j = await api({action:'unlock',uuid:currentUuid});
if (j&&j.ok) { setMsg($('acctMsg'),'Unlocked',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnVerifyEmail').addEventListener('click', async function() {
if (!currentUuid) return;
var j = await api({action:'set_verified', uuid:currentUuid, verified:true});
if (j&&j.ok) { currentAcct.email_verified='1'; setVerifiedStatus(); setMsg($('acctMsg'),'Email marked verified',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnUnverifyEmail').addEventListener('click', async function() {
if (!currentUuid) return;
var j = await api({action:'set_verified', uuid:currentUuid, verified:false});
if (j&&j.ok) { currentAcct.email_verified='0'; setVerifiedStatus(); setMsg($('acctMsg'),'Email set to unverified',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
// One-click approve/unapprove — same net effect as the flags modal, for when
// an account was missed in the bell. Approve sets reg(2)+normal(4); unapprove
// drops normal but keeps reg. Mirrors the dmr Register/Unregister buttons.
$('btnApprove').addEventListener('click', async function() {
if (!currentUuid || !currentAcct) return;
var flags = ((parseInt(currentAcct.flags, 10) || 0) | 2 | 4) & 0x3F;
var j = await api({action:'set_flags', uuid:currentUuid, flags:flags});
if (j&&j.ok) { currentAcct.flags = j.flags; $('acctFlagsText').textContent = flagsLabel(j.flags); setApprovalBadge(); setMsg($('acctMsg'),'Approved — user can log in',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnUnapprove').addEventListener('click', async function() {
if (!currentUuid || !currentAcct) return;
var flags = ((parseInt(currentAcct.flags, 10) || 0) & ~4) & 0x3F;
var j = await api({action:'set_flags', uuid:currentUuid, flags:flags});
if (j&&j.ok) { currentAcct.flags = j.flags; $('acctFlagsText').textContent = flagsLabel(j.flags); setApprovalBadge(); setMsg($('acctMsg'),'Approval revoked — user cannot log in',false); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnDelete').addEventListener('click', async function() {
if (!currentUuid) return;
if (!confirm('Permanently delete this account?')) return;
var j = await api({action:'delete',uuid:currentUuid});
if (j&&j.ok) { backToList(); loadAccounts(); }
else setMsg($('acctMsg'),(j&&j.error)||'Failed',true);
});
$('btnAcctLevel').addEventListener('click', function() {
if (!currentUuid || !currentAcct) return;
openFlagsModal(currentUuid, currentAcct.callsign || '', parseInt(currentAcct.flags, 10) || 0, String(currentAcct.email_verified || '0') === '1', function(nf) {
currentAcct.flags = nf;
$('acctFlagsText').textContent = flagsLabel(nf);
setApprovalBadge();
setMsg($('acctMsg'),'Saved',false);
loadAccounts();
});
});
$('btnFlagsSave').addEventListener('click', async function() {
var st = flagsModalState;
if (!st) return;
var j = await api({action:'set_flags', uuid:st.uuid, flags:st.pending});
if (j && j.ok) {
st.flags = j.flags;
st.pending = j.flags;
st.onChange(st.flags);
var m = window.bootstrap && bootstrap.Modal.getInstance($('acctFlagsModal'));
if (m) m.hide();
} else {
setMsg($('flagsModalMsg'), (j && j.error) || 'Failed', true);
}
});
$('btnResetPwd').addEventListener('click', async function() {
if (!currentUuid) return;
var p = $('newPwd').value;
if (!p || p.length < 6) { setMsg($('pwdMsg'),'Min 6 chars',true); return; }
var j = await api({action:'reset_password',uuid:currentUuid,new_password:p});
if (j&&j.ok) { setMsg($('pwdMsg'),'Password reset',false); $('newPwd').value=''; }
else setMsg($('pwdMsg'),(j&&j.error)||'Failed',true);
});
});
+154
View File
@@ -0,0 +1,154 @@
<?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);
$lvl = acct_admin_level($uuid);
if ($lvl < 2) { header('Location: /'); exit; }
$page_title = 'Admin · Accounts — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:800px;">
<!-- Account List -->
<div id="acctListPanel">
<div class="panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Accounts</h5>
<input type="text" class="form-control form-control-sm" id="acctSearch" placeholder="Search..." style="max-width:200px;">
</div>
<div style="max-height:70vh; overflow-y:auto; overflow-x:auto; -webkit-overflow-scrolling:touch;">
<table class="data-table" id="acctTable">
<thead><tr><th>Callsign</th><th>Name</th><th>Email</th><th>Level</th><th>Status</th><th>Last Login</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div id="loadMsg" class="text-secondary small mt-2">Loading...</div>
<div id="paginationWrap" class="mt-2"></div>
</div>
</div>
<!-- Edit Account -->
<div id="acctEditPanel" style="display:none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Edit: <span id="acctEditCs" class="mono"></span> <span id="acctApprovalBadge" class="badge ms-2"></span></h5>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btnBack">Back to list</button>
</div>
<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="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="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 id="acctFields"></div>
<div class="small mb-2">Email status: <span id="acctVerifiedStatus"></span></div>
<div class="d-flex gap-2 mt-3 flex-wrap">
<button type="button" class="btn btn-primary btn-sm" id="btnSave">Save</button>
<button type="button" class="btn btn-danger btn-sm" id="btnBan">Ban</button>
<button type="button" class="btn btn-success btn-sm" id="btnUnban">Unban</button>
<button type="button" class="btn btn-warning btn-sm" id="btnLock">Lock</button>
<button type="button" class="btn btn-success btn-sm" id="btnUnlock">Unlock</button>
<button type="button" class="btn btn-success btn-sm" id="btnVerifyEmail">Mark email verified</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btnUnverifyEmail">Unverify email</button>
<button type="button" class="btn btn-success btn-sm" id="btnApprove" title="Approve — sets reg+normal so the user can log in.">Approve</button>
<button type="button" class="btn btn-outline-warning btn-sm" id="btnUnapprove" title="Revoke approval — user can no longer log in.">Unapprove</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="btnDelete">Delete</button>
</div>
<div class="d-flex align-items-center gap-2 mt-2" id="acctLevelRow">
<label class="form-label small mb-0">Admin Level:</label>
<span id="acctFlagsText" class="small mono"></span>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btnAcctLevel">Admin</button>
</div>
<div id="acctMsg" class="small mt-2" style="display:none;"></div>
</div>
</div>
<div id="ptab-nodes" class="profile-tab-pane" style="display:none;">
<div class="panel">
<h5 class="mb-3">Reflector login &amp; certificate</h5>
<div id="editNodeAuthList"><span class="small text-secondary">Loading…</span></div>
</div>
<div class="panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Nodes</h5>
<div class="d-flex gap-2 align-items-center">
<div id="editNodeSelWrap" class="d-flex gap-2 align-items-center" style="display:none;">
<label class="small text-secondary mb-0">Instance:</label>
<select class="form-select form-select-sm" id="editNodeSelect" style="max-width:180px;"></select>
</div>
<span id="editNodesStatus" class="small text-secondary">Loading...</span>
</div>
</div>
<div id="editNodesList"></div>
</div>
</div>
<div id="ptab-calls" class="profile-tab-pane" style="display:none;">
<div class="panel">
<h5 class="mb-3">Callsigns this account is admin for</h5>
<div class="small text-secondary mb-2">Club calls or additional callsigns this account administers (the primary callsign always counts). Nodes whose name contains any of these belong to the account. The same callsign may sit on several accounts. Only site admins can change this list.</div>
<div id="acctCallsEditor"></div>
</div>
</div>
<div id="ptab-password" class="profile-tab-pane" style="display:none;">
<div class="panel">
<h6 class="mb-3">Reset Password</h6>
<div class="d-flex gap-2 align-items-end">
<div>
<label class="form-label small">New password (min 6 chars)</label>
<input type="password" class="form-control form-control-sm" id="newPwd" style="width:200px;">
</div>
<button type="button" class="btn btn-warning btn-sm" id="btnResetPwd">Reset</button>
</div>
<div id="pwdMsg" class="small mt-2" style="display:none;"></div>
</div>
</div>
</div>
</div>
<!-- Admin flags modal (same layout as the dmr site modals) -->
<div class="modal fade" id="acctFlagsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content border rounded" style="background: var(--bg);">
<div class="modal-header border-bottom">
<h5 class="modal-title">Admin: <span id="flagsModalCs" class="mono"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"
style="filter: invert(1);"></button>
</div>
<div class="modal-body">
<div id="flagsModalBtns" class="d-grid gap-2"></div>
<div id="flagsModalMsg" class="small mt-2" style="display:none;"></div>
</div>
<div class="modal-footer border-top">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="btnFlagsSave">Save</button>
</div>
</div>
</div>
</div>
<?php
$extra_scripts = '<script>var ADMIN_LEVEL = ' . $lvl . '; var ADMIN_FLAGS = ' . acct_flags($uuid) . ';</script><script src="/auth/admin.js?v=' . @filemtime(__DIR__ . '/admin.js') . '"></script>';
require __DIR__ . '/footer.php';
?>
+303
View File
@@ -0,0 +1,303 @@
<?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);
+65
View File
@@ -0,0 +1,65 @@
'use strict';
// Global Bans — callsign-only, styled like the DMR site's bans page.
var $ = function(id) { return document.getElementById(id); };
var bansCanWrite = false;
function bansEsc(s) { var d=document.createElement('div'); d.textContent=s==null?'':s; return d.innerHTML; }
function bansSetMsg(txt, err) {
var el = $('banMsg'); if (!el) return;
if (!txt) { el.style.display='none'; return; }
el.style.display='block'; el.textContent=txt; el.style.color=err?'#ff8080':'#00ff80';
}
async function bapi(body) {
var r = await fetch('/auth/admin_bans_api.php', { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''}, body:JSON.stringify(body) });
return r.json().catch(function(){ return null; });
}
function bansRenderList(items) {
var ul = $('banCsList'); if (!ul) return;
ul.innerHTML = '';
if (!items || items.length === 0) { ul.innerHTML = '<li class="text-secondary">— none —</li>'; return; }
items.forEach(function(v) {
var li = document.createElement('li');
li.className = 'd-flex justify-content-between align-items-center mb-1';
li.innerHTML = '<span>' + bansEsc(v) + '</span>'
+ (bansCanWrite
? '<button class="btn btn-outline-secondary btn-sm" data-ban-rm-val="' + bansEsc(v) + '" style="font-size:10px;padding:1px 6px;">unban</button>'
: '');
ul.appendChild(li);
});
}
async function bansLoad() {
var j = await bapi({action:'list'});
if (!j || !j.ok) return;
bansCanWrite = !!j.can_write;
var ro = $('banReadonlyBadge'); if (ro) ro.style.display = bansCanWrite ? 'none' : '';
document.querySelectorAll('.ban-add-row').forEach(function(el){ el.style.display = bansCanWrite ? 'flex' : 'none'; });
bansRenderList(j.callsigns || []);
}
async function bansAdd() {
var v = ($('banCsAdd').value || '').trim();
if (!v) return;
var res = await bapi({action:'add', kind:'callsign', value:v});
if (res && res.ok) { $('banCsAdd').value=''; bansSetMsg('Banned callsign: ' + v.toUpperCase(), false); await bansLoad(); }
else bansSetMsg((res && res.error) || 'Failed', true);
}
async function bansRemove(v) {
if (!confirm('Unban callsign ' + v + '?')) return;
var res = await bapi({action:'remove', kind:'callsign', value:v});
if (res && res.ok) { bansSetMsg('Removed.', false); await bansLoad(); }
else bansSetMsg((res && res.error) || 'Failed', true);
}
document.addEventListener('DOMContentLoaded', function() {
fetch('/auth/me.php',{credentials:'same-origin'}).then(function(r){return r.json();}).then(function(j){if(j&&j.csrf_token)window._csrfToken=j.csrf_token;}).catch(function(){});
$('btnBansRefresh').addEventListener('click', bansLoad);
document.querySelectorAll('button[data-ban-add]').forEach(function(b){ b.addEventListener('click', bansAdd); });
$('banCsAdd').addEventListener('keydown', function(e){ if (e.key==='Enter') bansAdd(); });
$('banCsList').addEventListener('click', function(e){
var btn = e.target.closest('button[data-ban-rm-val]');
if (btn) bansRemove(btn.getAttribute('data-ban-rm-val'));
});
bansLoad();
});
+43
View File
@@ -0,0 +1,43 @@
<?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);
$lvl = acct_admin_level($uuid);
if ($lvl < 2) { header('Location: /'); exit; }
$page_title = 'Admin · Global Bans — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:800px;">
<div id="page-admin-bans">
<div class="panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Global Bans <span id="banReadonlyBadge" class="badge bg-secondary ms-2 small" style="display:none;">read-only</span></h5>
<button class="btn btn-outline-secondary btn-sm" id="btnBansRefresh">Refresh</button>
</div>
<p class="small text-secondary mb-3">A global ban blocks the callsign on the portal — a banned callsign cannot register or sign in.</p>
<div class="row g-3">
<!-- Callsigns -->
<div class="col-12 col-md-6">
<h6>Callsigns</h6>
<div style="max-height:300px; overflow-y:auto; border:1px solid var(--line); padding:6px; border-radius:4px;">
<ul class="list-unstyled mb-0 small mono" id="banCsList"></ul>
</div>
<div class="d-flex gap-2 mt-2 ban-add-row">
<input type="text" class="form-control form-control-sm mono" id="banCsAdd" placeholder="CALLSIGN" maxlength="10" style="text-transform:uppercase;">
<button class="btn btn-outline-danger btn-sm" data-ban-add="callsign">Ban</button>
</div>
</div>
</div>
<div id="banMsg" class="small mt-3" style="display:none;"></div>
</div>
</div>
</div>
<?php
$extra_scripts = '<script>var ADMIN_LEVEL = ' . $lvl . ';</script><script src="/auth/admin_bans.js?v=' . @filemtime(__DIR__ . '/admin_bans.js') . '"></script>';
require __DIR__ . '/footer.php';
?>
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
// Global callsign bans (mirrors the DMR site's bans.php). A Redis SET of base
// callsigns; list is visible to admin (LVL2+), add/remove are LVL3-only.
$acting_uuid = require_login();
$acting_level = acct_admin_level($acting_uuid);
if ($acting_level < 2) json_out(['ok'=>false,'error'=>'forbidden'], 403);
$j = json_in();
$action = (string)($j['action'] ?? 'list');
$r = redis();
if ($action === 'list') {
$cs = $r->sMembers(global_bans_key());
if (!is_array($cs)) $cs = [];
sort($cs);
json_out(['ok'=>true, 'callsigns'=>$cs, 'can_write'=>$acting_level >= 3]);
}
csrf_check();
if ($acting_level < 3) json_out(['ok'=>false,'error'=>'lvl3_required'], 403);
if ($action === 'add') {
$cs = base_call((string)($j['value'] ?? ''));
if ($cs === '' || !preg_match('/^[A-Z0-9]+$/', $cs)) json_out(['ok'=>false,'error'=>'invalid_callsign'], 400);
$r->sAdd(global_bans_key(), $cs);
json_out(['ok'=>true]);
}
if ($action === 'remove') {
$cs = base_call((string)($j['value'] ?? ''));
if ($cs === '') json_out(['ok'=>false,'error'=>'invalid_callsign'], 400);
$r->sRem(global_bans_key(), $cs);
json_out(['ok'=>true]);
}
json_out(['ok'=>false,'error'=>'unknown_action'], 400);
+154
View File
@@ -0,0 +1,154 @@
'use strict';
var $ = function(id){ return document.getElementById(id); };
function setMsg(el, txt, err){
if (!el) return;
if (!txt){ el.style.display='none'; return; }
el.style.display='block'; el.textContent=txt; el.style.color=err?'#ff8080':'#00ff80';
}
function esc(s){ var d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; }
async function rapi(body){
var r = await fetch('/auth/admin_reflector_api.php', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''},
body:JSON.stringify(body)
});
return r.json().catch(function(){ return null; });
}
function timeAgo(epoch){
if (!epoch) return '';
var diff = Math.floor(Date.now()/1000) - epoch;
var fut = diff < 0; if (fut) diff = -diff;
var t;
if (diff < 60) t = 'just now';
else if (diff < 3600) t = Math.floor(diff/60)+'m';
else if (diff < 86400) t = Math.floor(diff/3600)+'h';
else t = Math.floor(diff/86400)+'d';
if (t === 'just now') return t;
return fut ? ('in '+t) : (t+' ago');
}
function fmtDate(epoch){ if (!epoch) return ''; return new Date(epoch*1000).toISOString().slice(0,16).replace('T',' ') + ' UTC'; }
/* ---- icon buttons ---- */
var ICONS = {
info: '<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>',
trash: '<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6Z"/><path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1ZM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118ZM2.5 3h11V2h-11v1Z"/>',
repend: '<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/><path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>',
sign: '<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>',
reject: '<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>'
};
function iconBtn(action, cs, name, title, btnCls){
return '<button class="btn btn-sm '+btnCls+' act-'+action+'" data-cs="'+esc(cs)+'" title="'+title+'" aria-label="'+title+'" style="padding:2px 6px; line-height:1; margin-left:4px;">'
+ '<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" style="vertical-align:-2px;">'+ICONS[name]+'</svg></button>';
}
async function loadCerts(){
var body = $('certsBody');
body.innerHTML = '<tr><td colspan="3" class="text-secondary">Loading…</td></tr>';
var j = await rapi({action:'list_certs'});
if (!j || !j.ok){ body.innerHTML = '<tr><td colspan="3" style="color:#ff8080;">'+esc((j&&j.error)||'Failed to load')+'</td></tr>'; return; }
var list = j.certs || [];
if (!list.length){ body.innerHTML = '<tr><td colspan="3" class="text-secondary">No issued certificates.</td></tr>'; return; }
body.innerHTML = list.map(function(it){
var cs = esc(it.callsign);
return '<tr><td class="mono"><strong>'+cs+'</strong></td>'
+ '<td class="text-secondary">'+esc(fmtDate(it.mtime))+'</td>'
+ '<td style="text-align:right; white-space:nowrap;">'
+ iconBtn('info', it.callsign, 'info', 'Certificate info', 'btn-outline-secondary')
+ iconBtn('repend', it.callsign, 'repend', 'Move back to pending', 'btn-outline-secondary')
+ iconBtn('del', it.callsign, 'trash', 'Delete / revoke', 'btn-outline-danger')
+ '</td></tr>';
}).join('');
body.querySelectorAll('.act-info').forEach(function(b){ b.addEventListener('click', function(){ certInfo(b.dataset.cs); }); });
body.querySelectorAll('.act-repend').forEach(function(b){ b.addEventListener('click', function(){ repend(b.dataset.cs); }); });
body.querySelectorAll('.act-del').forEach(function(b){ b.addEventListener('click', function(){ delCert(b.dataset.cs); }); });
}
async function loadPending(){
var body = $('pendingBody');
body.innerHTML = '<tr><td colspan="4" class="text-secondary">Loading…</td></tr>';
var j = await rapi({action:'pending'});
if (!j || !j.ok){ body.innerHTML = '<tr><td colspan="4" style="color:#ff8080;">'+esc((j&&j.error)||'Failed to load')+'</td></tr>'; return; }
var list = j.pending || [];
if (!list.length){ body.innerHTML = '<tr><td colspan="4" class="text-secondary">No pending CSRs.</td></tr>'; return; }
body.innerHTML = list.map(function(it){
var cs = esc(it.callsign);
return '<tr><td class="mono"><strong>'+cs+'</strong></td>'
+ '<td class="text-secondary">'+esc(timeAgo(it.mtime))+'</td>'
+ '<td class="mono small">'+esc(it.subject||'')+'</td>'
+ '<td style="text-align:right; white-space:nowrap;">'
+ iconBtn('sign', it.callsign, 'sign', 'Sign', 'btn-success')
+ iconBtn('reject', it.callsign, 'reject', 'Reject', 'btn-outline-danger')
+ '</td></tr>';
}).join('');
body.querySelectorAll('.act-sign').forEach(function(b){ b.addEventListener('click', function(){ signCsr(b.dataset.cs); }); });
body.querySelectorAll('.act-reject').forEach(function(b){ b.addEventListener('click', function(){ rejectCsr(b.dataset.cs); }); });
}
async function signCsr(cs){
if (!confirm('Sign certificate for '+cs+'?')) return;
setMsg($('certMsg'), null);
var j = await rapi({action:'sign', callsign:cs});
if (j && j.ok){ setMsg($('certMsg'), j.message||('Signed '+cs), false); setTimeout(loadPending, 1500); }
else setMsg($('certMsg'), (j&&j.error)||'Failed', true);
}
async function rejectCsr(cs){
if (!confirm('Reject (delete) pending CSR for '+cs+'?')) return;
setMsg($('certMsg'), null);
var j = await rapi({action:'rmpending', callsign:cs});
if (j && j.ok){ setMsg($('certMsg'), j.message||('Rejected '+cs), false); loadPending(); }
else setMsg($('certMsg'), (j&&j.error)||'Failed', true);
}
async function delCert(cs){
if (!confirm('Delete/revoke certificate for '+cs+'?\nThe node will no longer be able to connect.')) return;
setMsg($('certMsg'), null);
var j = await rapi({action:'revoke', callsign:cs});
if (j && j.ok){ setMsg($('certMsg'), j.message||('Revoked '+cs), false); loadCerts(); }
else setMsg($('certMsg'), (j&&j.error)||'Failed', true);
}
async function repend(cs){
if (!confirm('Move '+cs+' back to pending for re-signing?')) return;
setMsg($('certMsg'), null);
var j = await rapi({action:'repend', callsign:cs});
if (j && j.ok){ setMsg($('certMsg'), j.message||('Re-pended '+cs), false); }
else setMsg($('certMsg'), (j&&j.error)||'Failed', true);
}
async function certInfo(cs){
var j = await rapi({action:'cert_info', callsign:cs});
if (!j || !j.ok){ setMsg($('certMsg'), (j&&j.error)||'Failed', true); return; }
var i = j.info || {};
var expired = i.validTo && (i.validTo*1000 < Date.now());
var validTo = fmtDate(i.validTo) + (i.validTo ? ' (' + (expired ? 'EXPIRED' : 'expires '+timeAgo(i.validTo)) + ')' : '');
var rows = [
['Callsign', i.callsign],
['Subject CN', i.subjectCN],
['Issuer', i.issuer],
['Serial', i.serial],
['Valid from', fmtDate(i.validFrom)],
['Valid to', validTo],
['SHA-256', i.fingerprint]
];
$('certInfoTitle').textContent = 'Certificate · ' + (i.callsign || cs);
$('certInfoBody').innerHTML = rows.map(function(r){
var v = esc(r[1] || '—');
if (r[0] === 'Valid to' && expired) v = '<span style="color:#ff8080;">'+v+'</span>';
return '<div style="margin-bottom:6px;"><span class="text-secondary" style="display:inline-block;min-width:92px;">'+esc(r[0])+':</span> '+v+'</div>';
}).join('');
bootstrap.Modal.getOrCreateInstance($('certInfoModal')).show();
}
function switchView(v){
$('viewCerts').style.display = (v === 'certs') ? '' : 'none';
$('viewPending').style.display = (v === 'pending') ? '' : 'none';
if (v === 'pending') loadPending(); else loadCerts();
}
document.addEventListener('DOMContentLoaded', function(){
fetch('/auth/me.php', {credentials:'same-origin'})
.then(function(r){ return r.json(); })
.then(function(j){ if (j && j.csrf_token) window._csrfToken = j.csrf_token; loadCerts(); })
.catch(function(){ loadCerts(); });
$('certView').addEventListener('change', function(){ switchView(this.value); });
});
+62
View File
@@ -0,0 +1,62 @@
<?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);
$lvl = acct_admin_level($uuid);
if ($lvl < 2) { header('Location: /'); exit; }
$page_title = 'Admin · Certificates — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:900px;">
<div class="panel">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h5 class="mb-0">Certificate Admin</h5>
<select id="certView" class="form-select form-select-sm" style="width:auto;">
<option value="certs">Issued certificates</option>
<option value="pending">Pending CSRs</option>
</select>
</div>
<div id="certMsg" class="small mb-2" style="display:none;"></div>
<!-- Issued certs -->
<div id="viewCerts">
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>Callsign</th><th>Issued</th><th style="text-align:right;">Actions</th></tr></thead>
<tbody id="certsBody"><tr><td colspan="3" class="text-secondary">Loading…</td></tr></tbody>
</table>
</div>
</div>
<!-- Pending CSRs -->
<div id="viewPending" style="display:none;">
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>Callsign</th><th>Received</th><th>Subject</th><th style="text-align:right;">Actions</th></tr></thead>
<tbody id="pendingBody"><tr><td colspan="4" class="text-secondary">Loading…</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Cert info modal -->
<div class="modal fade" id="certInfoModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content" style="background:#111; border:1px solid var(--line);">
<div class="modal-header" style="border-color:var(--line);">
<h5 class="modal-title" id="certInfoTitle">Certificate</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body mono small" id="certInfoBody" style="word-break:break-all;"></div>
</div>
</div>
</div>
<?php
$extra_scripts = '<script>var ADMIN_LEVEL = ' . $lvl . ';</script><script src="/auth/admin_certs.js"></script>';
require __DIR__ . '/footer.php';
?>
+55
View File
@@ -0,0 +1,55 @@
'use strict';
var $ = function(id) { return document.getElementById(id); };
function setMsg(el, txt, err) {
if (!el) return;
if (!txt) { el.style.display='none'; return; }
el.style.display='block'; el.textContent=txt; el.style.color=err?'#ff8080':'#00ff80';
}
function esc(s) { var d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
async function rapi(body) {
var r = await fetch('/auth/admin_reflector_api.php', { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''}, body:JSON.stringify(body) });
return r.json().catch(function(){ return null; });
}
var configOriginal = '';
async function loadConfig() {
var j = await rapi({action:'config_read'});
if (!j || !j.ok) { setMsg($('cfgMsg'), (j&&j.error)||'Failed', true); return; }
configOriginal = j.raw || '';
$('cfgView').textContent = configOriginal;
// The backend decides who may edit: LVL2/LVL3 → true (LVL2 sees secrets masked
// and they're restored on save), LVL1 → false (read-only).
$('btnEdit').style.display = j.can_edit ? '' : 'none';
showView();
}
function showView() { $('viewMode').style.display=''; $('editMode').style.display='none'; }
function showEdit() { $('cfgEditor').value=configOriginal; $('viewMode').style.display='none'; $('editMode').style.display=''; setMsg($('cfgMsg'),null); }
async function saveConfig() {
var raw = $('cfgEditor').value;
if (!raw.trim()) { setMsg($('cfgMsg'),'Config is empty',true); return; }
var j = await rapi({action:'config_save', raw:raw});
if (j && j.ok) { setMsg($('cfgMsg'),'Config saved. Reload service to apply.',false); configOriginal=raw; loadConfig(); }
else setMsg($('cfgMsg'),(j&&j.error)||'Failed',true);
}
document.addEventListener('DOMContentLoaded', function() {
fetch('/auth/me.php',{credentials:'same-origin'}).then(function(r){return r.json();}).then(function(j){if(j&&j.csrf_token)window._csrfToken=j.csrf_token;}).catch(function(){});
loadConfig();
$('btnView').addEventListener('click', showView);
$('btnEdit').addEventListener('click', showEdit);
$('btnSave').addEventListener('click', saveConfig);
$('btnSaveReload').addEventListener('click', async function() {
var raw = $('cfgEditor').value;
if (!raw.trim()) { setMsg($('cfgMsg'),'Config is empty',true); return; }
var j = await rapi({action:'config_save', raw:raw});
if (!j || !j.ok) { setMsg($('cfgMsg'),(j&&j.error)||'Save failed',true); return; }
configOriginal = raw;
var r = await rapi({action:'service_reload'});
if (r && r.ok) { setMsg($('cfgMsg'),'Config saved and service reloaded.',false); loadConfig(); }
else { setMsg($('cfgMsg'),'Saved but reload failed: '+((r&&r.error)||''),true); }
});
$('btnDiscard').addEventListener('click', function() { $('cfgEditor').value=configOriginal; showView(); setMsg($('cfgMsg'),'Discarded',false); });
});
+40
View File
@@ -0,0 +1,40 @@
<?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);
$lvl = acct_admin_level($uuid);
if ($lvl < 2) { header('Location: /'); exit; }
$page_title = 'Admin · Config — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:800px;">
<div class="panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Configuration</h5>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="btnView">View</button>
<button type="button" class="btn btn-warning btn-sm" id="btnEdit">Edit</button>
</div>
</div>
<div id="viewMode">
<pre id="cfgView" class="mono" style="font-size:.8rem; white-space:pre-wrap; margin:0; color:var(--fg);"></pre>
</div>
<div id="editMode" style="display:none;">
<textarea class="form-control mono" id="cfgEditor" rows="25" style="font-size:.8rem; resize:vertical;"></textarea>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-primary btn-sm" id="btnSave">Save</button>
<button type="button" class="btn btn-warning btn-sm" id="btnSaveReload">Save &amp; Reload</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btnDiscard">Discard</button>
</div>
</div>
<div id="cfgMsg" class="small mt-2" style="display:none;"></div>
</div>
</div>
<?php
$extra_scripts = '<script>var ADMIN_LEVEL = ' . $lvl . ';</script><script src="/auth/admin_config.js"></script>';
require __DIR__ . '/footer.php';
?>
+38
View File
@@ -0,0 +1,38 @@
'use strict';
var $ = function(id) { return document.getElementById(id); };
function setMsg(el, txt, err) {
if (!el) return;
if (!txt) { el.style.display='none'; return; }
el.style.display='block'; el.textContent=txt; el.style.color=err?'#ff8080':'#00ff80';
}
function esc(s) { var d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
async function rapi(body) {
var r = await fetch('/auth/admin_reflector_api.php', { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''}, body:JSON.stringify(body) });
return r.json().catch(function(){ return null; });
}
async function loadStatus() {
var j = await rapi({action:'service_status'});
var el = $('svcStatus');
if (j && j.ok) {
var s = (j.message||'').trim();
el.innerHTML = 'Service: <strong style="color:'+(s==='active'?'#00c864':'#ff4040')+';">'+esc(s)+'</strong>';
} else { el.textContent = 'Cannot check status'; }
}
async function svcAction(action, msg) {
if (msg && !confirm(msg)) return;
setMsg($('svcMsg'), null);
var j = await rapi({action: action});
if (j && j.ok) { setMsg($('svcMsg'), j.message||'OK', false); setTimeout(loadStatus, 2000); }
else setMsg($('svcMsg'), (j&&j.error)||'Failed', true);
}
document.addEventListener('DOMContentLoaded', function() {
fetch('/auth/me.php',{credentials:'same-origin'}).then(function(r){return r.json();}).then(function(j){if(j&&j.csrf_token)window._csrfToken=j.csrf_token;}).catch(function(){});
loadStatus();
var isSuperAdmin = (typeof ADMIN_LEVEL !== 'undefined') && ADMIN_LEVEL >= 3;
if (!isSuperAdmin) {
$('btnStop').style.display = 'none';
}
$('btnReload').addEventListener('click', function(){ svcAction('service_reload'); });
$('btnRestart').addEventListener('click', function(){ svcAction('service_restart', 'Restart reflector? All connections will be dropped.'); });
$('btnStop').addEventListener('click', function(){ svcAction('service_stop', 'Shutdown reflector?'); });
});
+29
View File
@@ -0,0 +1,29 @@
<?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);
$lvl = acct_admin_level($uuid);
if ($lvl < 2) { header('Location: /'); exit; }
$page_title = 'Admin · Control — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:800px;">
<div class="panel">
<h5 class="mb-3">Service Control</h5>
<div id="svcStatus" class="small mb-3 text-secondary">Checking...</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-primary btn-sm" id="btnReload">Reload Config</button>
<button type="button" class="btn btn-warning btn-sm" id="btnRestart">Restart Service</button>
<button type="button" class="btn btn-danger btn-sm" id="btnStop">Shutdown</button>
</div>
<div id="svcMsg" class="small mt-2" style="display:none;"></div>
</div>
</div>
<?php
$extra_scripts = '<script>var ADMIN_LEVEL = ' . $lvl . ';</script><script src="/auth/admin_control.js"></script>';
require __DIR__ . '/footer.php';
?>
+64
View File
@@ -0,0 +1,64 @@
<?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 (acct_admin_level($uuid) < 2) { header('Location: /'); exit; }
$page_title = 'Admin · MQTT — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:640px;">
<div class="panel">
<h5 class="mb-3">MQTT publish settings</h5>
<div class="text-secondary small mb-3">Where the portal publishes node commands (the broker it connects to for <span class="mono">mosquitto_pub</span>). Nodes set their own broker host/port on the node dashboard.</div>
<div class="row g-2">
<div class="col-8"><label class="form-label">Broker host</label>
<input class="form-control form-control-sm mono" id="m_host" placeholder="127.0.0.1"></div>
<div class="col-4"><label class="form-label">Port</label>
<input class="form-control form-control-sm mono" id="m_port" placeholder="1883"></div>
<div class="col-6"><label class="form-label">Username</label>
<input class="form-control form-control-sm mono" id="m_user" placeholder="portal-pub"></div>
<div class="col-6"><label class="form-label">Password</label>
<input class="form-control form-control-sm mono" id="m_pass" type="password" placeholder="enter password"></div>
</div>
<div class="d-flex gap-2 mt-3 align-items-center">
<button class="btn btn-primary btn-sm" id="btnSave" type="button">Save</button>
<span id="msg" class="small text-secondary"></span>
</div>
</div>
</div>
<script>
var CSRF = '';
function api(body){
return fetch('/auth/node_cmd_api.php', {method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':CSRF},
body:JSON.stringify(body)}).then(function(r){return r.json();});
}
function load(){
api({action:'mqtt_get'}).then(function(j){
if(!j||!j.ok) return;
document.getElementById('m_host').value = j.host || '';
document.getElementById('m_port').value = j.port || '';
document.getElementById('m_user').value = j.user || '';
document.getElementById('m_pass').placeholder = j.pass_set ? 'unchanged — blank keeps it' : 'enter password';
});
}
document.getElementById('btnSave').addEventListener('click', function(){
var msg = document.getElementById('msg');
api({action:'mqtt_set',
host: document.getElementById('m_host').value.trim(),
port: parseInt(document.getElementById('m_port').value, 10) || 1883,
user: document.getElementById('m_user').value.trim(),
pass: document.getElementById('m_pass').value})
.then(function(j){
if(j && j.ok){ msg.textContent='Saved.'; msg.style.color='#00e676'; document.getElementById('m_pass').value=''; load(); }
else { msg.textContent = (j && j.error) || 'Save failed'; msg.style.color='#ff5252'; }
});
});
fetch('/auth/me.php',{credentials:'same-origin'}).then(function(r){return r.json();})
.then(function(j){ if(j&&j.csrf_token) CSRF=j.csrf_token; load(); }).catch(load);
</script>
<?php require __DIR__ . '/footer.php'; ?>
+322
View File
@@ -0,0 +1,322 @@
'use strict';
var $ = function(id){ return document.getElementById(id); };
function setMsg(el, txt, err){
if (!el) return;
if (!txt){ el.style.display='none'; return; }
el.style.display='block'; el.textContent=txt; el.style.color=err?'#ff8080':'#00ff80';
}
function esc(s){ var d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; }
function fmtDate(epoch){ if (!epoch) return ''; return new Date(epoch*1000).toISOString().slice(0,10); }
async function napi(body){
var r = await fetch('/auth/node_auth_api.php', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''},
body:JSON.stringify(body)
});
return r.json().catch(function(){ return null; });
}
async function naAdminsApi(body){
var r = await fetch('/auth/node_admins_api.php', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''},
body:JSON.stringify(body)
});
return r.json().catch(function(){ return null; });
}
function wireCopy(btn, getVal){
btn.addEventListener('click', function(){
navigator.clipboard.writeText(getVal()).then(function(){
var t = btn.textContent; btn.textContent = 'Copied!';
setTimeout(function(){ btn.textContent = t; }, 1200);
});
});
}
function showPassword(node, pw){
$('naPwTitle').textContent = 'Password · ' + node;
$('naPwValue').textContent = pw;
bootstrap.Modal.getOrCreateInstance($('naPwModal')).show();
}
// ── node list ──────────────────────────────────────────────────────────
var allNodes = [];
async function loadNodes(){
var j = await napi({action:'list'});
if (!j || !j.ok){
$('naTable').querySelector('tbody').innerHTML =
'<tr><td colspan="6" style="color:#ff8080;">'+esc((j&&j.error)||'Failed to load')+'</td></tr>';
return;
}
allNodes = j.nodes || [];
renderNodes('');
}
function renderNodes(filter){
var tbody = $('naTable').querySelector('tbody');
var f = (filter||'').toLowerCase();
var list = f ? allNodes.filter(function(n){
return n.node.toLowerCase().includes(f)
|| (n.email||'').toLowerCase().includes(f)
|| (n.admins||[]).some(function(a){ return a.toLowerCase().includes(f); });
}) : allNodes;
$('naStatus').textContent = list.length + ' of ' + allNodes.length + ' nodes';
if (!list.length){
tbody.innerHTML = '<tr><td colspan="6" class="text-secondary">No nodes.</td></tr>';
return;
}
tbody.innerHTML = list.map(function(n){
var auth = [];
if (n.cert) auth.push('<span class="node-badge">cert</span>');
if (n.password) auth.push('<span class="node-badge">password</span>');
var expired = n.validTo && (n.validTo*1000 < Date.now());
return '<tr><td class="mono"><strong>'+esc(n.node)+'</strong></td>'
+ '<td>'+auth.join(' ')+'</td>'
+ '<td class="mono small">'+esc((n.admins||[]).join(', '))+'</td>'
+ '<td class="mono small">'+esc(n.email||'')+'</td>'
+ '<td class="small"'+(expired?' style="color:#ff8080;"':'')+'>'+(n.cert ? esc(fmtDate(n.validTo))+(expired?' EXPIRED':'') : '')+'</td>'
+ '<td style="white-space:nowrap;">'
+ '<button class="btn btn-outline-secondary btn-sm na-edit" data-cs="'+esc(n.node)+'" title="Edit this node">Edit</button>'
+ ' <button class="btn btn-outline-secondary btn-sm na-adm" data-cs="'+esc(n.node)+'" title="Manage node admins">Admins</button>'
+ (n.password ? ' <button class="btn btn-outline-danger btn-sm na-del" data-cs="'+esc(n.node)+'" title="Delete this node">Delete</button>' : '')
+ '</td></tr>';
}).join('');
tbody.querySelectorAll('.na-edit').forEach(function(b){ b.addEventListener('click', function(){ showEdit(b.dataset.cs); }); });
tbody.querySelectorAll('.na-adm').forEach(function(b){ b.addEventListener('click', function(){ toggleAdminsRow(b); }); });
tbody.querySelectorAll('.na-del').forEach(function(b){ b.addEventListener('click', function(){ delNode(b.dataset.cs); }); });
}
// base62, 20 chars — same shape as the server's gen_password().
function genPassword(){
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var a = new Uint32Array(20); (window.crypto||window.msCrypto).getRandomValues(a);
var s = ''; for (var i=0;i<20;i++) s += chars[a[i] % 62];
return s;
}
// Local admins list for the Add container (before the node exists).
var addAdmins = [];
function renderAddAdmins(){
var wrap = $('naAddAdmWrap');
var h = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:280px;">'
+ '<thead><tr><th>Callsign</th><th style="width:120px;">Action</th></tr></thead><tbody>';
if (!addAdmins.length) h += '<tr><td colspan="2" class="small text-secondary">No extra admins — the node’s own callsign always counts.</td></tr>';
addAdmins.forEach(function(v, i){
h += '<tr><td><input type="text" class="form-control form-control-sm mono aa-cs" data-i="'+i+'" value="'+esc(v)+'" placeholder="SA7XYZ" style="text-transform:uppercase;"></td>'
+ '<td><button type="button" class="btn btn-sm btn-outline-danger aa-del" data-i="'+i+'">Delete</button></td></tr>';
});
wrap.innerHTML = h + '</tbody></table></div>';
wrap.querySelectorAll('.aa-cs').forEach(function(inp){ inp.addEventListener('input', function(){ addAdmins[+inp.dataset.i] = inp.value; }); });
wrap.querySelectorAll('.aa-del').forEach(function(b){ b.addEventListener('click', function(){ addAdmins.splice(+b.dataset.i, 1); renderAddAdmins(); }); });
}
function showAddPanel(){
$('naNewNode').value = '';
$('naNewPw').value = genPassword();
addAdmins = [];
renderAddAdmins();
setMsg($('naAddMsg'), null);
$('naListPanel').style.display = 'none';
$('naEditPanel').style.display = 'none';
$('naAddPanel').style.display = '';
$('naNewNode').focus();
}
function addBackToList(){
$('naAddPanel').style.display = 'none';
$('naListPanel').style.display = '';
loadNodes();
}
async function addNode(){
var node = ($('naNewNode').value || '').trim().toUpperCase();
var pw = ($('naNewPw').value || '').trim();
if (!node){ setMsg($('naAddMsg'), 'Enter a login (node callsign)', true); return; }
if (!pw){ setMsg($('naAddMsg'), 'Enter or generate a password', true); return; }
var seen = {}, admins = [];
addAdmins.forEach(function(c){ c = String(c).trim().toUpperCase(); if (c && !seen[c]){ seen[c] = 1; admins.push(c); } });
if (!confirm('Create node '+node+'?\nThe reflector will be restarted.')) return;
setMsg($('naAddMsg'), 'Creating…');
var j = await napi({action:'add', node:node, password:pw, admins:admins});
if (!j || !j.ok){ setMsg($('naAddMsg'), (j&&(j.detail||j.error))||'Failed', true); return; }
showPassword(j.node, j.password);
addBackToList();
}
async function delNode(cs){
if (!confirm('Delete login/password node '+cs+'?\nIt will no longer be able to connect. The reflector will be restarted.')) return;
setMsg($('naMsg'), null);
var j = await napi({action:'del', node:cs});
if (j && j.ok){ setMsg($('naMsg'), 'Removed '+cs, false); loadNodes(); }
else setMsg($('naMsg'), (j&&(j.detail||j.error))||'Failed', true);
}
// ── edit view ──────────────────────────────────────────────────────────
var currentCs = null;
async function showEdit(cs){
currentCs = cs;
var n = null;
for (var i = 0; i < allNodes.length; i++) if (allNodes[i].node === cs) n = allNodes[i];
if (!n) return;
$('naEditCs').textContent = cs;
var badges = [];
if (n.cert) badges.push('<span class="node-badge">cert</span>');
if (n.password) badges.push('<span class="node-badge">password</span>');
$('naEditType').innerHTML = badges.join(' ');
// Linked portal account — full user info, like the dmr account edit
$('naEditAcct').textContent = 'Loading…';
napi({action:'info', node:cs}).then(function(ij){
var box = $('naEditAcct');
if (!ij || !ij.ok){ box.innerHTML = '<span style="color:#ff8080;">'+esc((ij&&ij.error)||'Failed')+'</span>'; return; }
var a = ij.account;
if (!a){ box.innerHTML = '<span class="text-secondary">No portal account is linked to this node (no callsign or cert-email match).</span>'; return; }
var rows = [
['Callsign', '<span class="mono">'+esc(a.callsign)+'</span>'],
['Name', esc(a.name)],
['Email', '<span class="mono">'+esc(a.email)+'</span>'+(a.email_verified ? ' <span class="node-badge">verified</span>' : ' <span class="node-badge" style="border-color:#ff8080;color:#ff8080;">unverified</span>')],
['Admin level', String(a.admin_level)],
['Status', a.banned ? '<span style="color:#ff8080;">banned</span>' : 'active'],
['Last login', a.last_login_at ? new Date(a.last_login_at*1000).toISOString().replace('T',' ').slice(0,16) : ''],
['Location', esc([a.city, a.country].filter(Boolean).join(', '))],
['Linked via', ij.linked_via === 'cert_email' ? 'certificate email' : 'callsign']
];
box.innerHTML = '<table class="small" style="border-collapse:collapse;">'
+ rows.filter(function(r){ return r[1] !== ''; }).map(function(r){
return '<tr><td class="text-secondary" style="padding:1px 14px 1px 0;">'+r[0]+'</td><td>'+r[1]+'</td></tr>';
}).join('')
+ '</table>'
+ '<div class="mt-1"><a href="/auth/admin.php">edit account</a></div>';
});
// Password (login/password nodes)
setMsg($('naPwMsg'), null);
$('naEditPwWrap').style.display = n.password ? '' : 'none';
if (n.password){
$('naEditPw').value = '';
var pj = await napi({action:'getpw', node:cs});
if (pj && pj.ok) $('naEditPw').value = pj.password;
else setMsg($('naPwMsg'), (pj&&(pj.detail||pj.error))||'Could not read password', true);
}
// Certificate info (cert nodes)
$('naEditCertWrap').style.display = n.cert ? '' : 'none';
if (n.cert){
var expired = n.validTo && (n.validTo*1000 < Date.now());
$('naEditCert').innerHTML =
(n.email ? '<div>Email: <span class="mono">'+esc(n.email)+'</span></div>' : '')
+ '<div>Valid to: <span'+(expired?' style="color:#ff8080;"':'')+'>'+esc(fmtDate(n.validTo))+(expired?' (EXPIRED)':'')+'</span>'
+ ' · <a href="/auth/admin_certs.php">manage certificate</a></div>';
}
buildAdminEditor($('naEditAdmins'), cs, null);
$('naListPanel').style.display = 'none';
$('naEditPanel').style.display = '';
}
// dmr peer "Edit admins" editor: Add on top, a row per admin with an editable
// callsign field + Delete, Save/Back at the bottom. Each node keeps its own
// admin list (node_admins:<callsign>), so admins differ node by node. On Save
// we diff the edited list against the server and issue add/remove calls.
async function buildAdminEditor(box, cs, onBack){
box.innerHTML = '<span class="small text-secondary">Loading…</span>';
var j = await naAdminsApi({action:'list', node:cs});
if (!j || !j.ok){ box.innerHTML = '<span style="color:#ff8080;">'+esc((j&&j.error)||'Failed')+'</span>'; return; }
var server = (j.admins || []).map(function(c){ return String(c).toUpperCase(); });
var working = server.slice();
box.innerHTML =
'<div class="d-flex gap-2 mb-2"><button type="button" class="btn btn-sm btn-outline-success ae-add">Add</button></div>'
+ '<div class="ae-wrap mb-2"></div>'
+ '<div class="d-flex gap-2 mt-2"><button type="button" class="btn btn-sm btn-primary ae-save">Save</button>'
+ (onBack ? '<button type="button" class="btn btn-sm btn-outline-secondary ms-auto ae-back">Back</button>' : '')
+ '</div>'
+ '<div class="ae-msg small mt-2" style="display:none;"></div>';
var wrap = box.querySelector('.ae-wrap'), msg = box.querySelector('.ae-msg');
function setErr(t, ok){ msg.style.display = t?'':'none'; msg.style.color = ok?'#00ff80':'#ff8080'; msg.textContent = t||''; }
function renderTable(){
var html = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:280px;">'
+ '<thead><tr><th>Callsign</th><th style="width:120px;">Action</th></tr></thead><tbody>';
if (!working.length) html += '<tr><td colspan="2" class="small text-secondary">No admins — click Add to grant a callsign admin rights for this node. (The node’s own callsign always counts.)</td></tr>';
working.forEach(function(v, i){
html += '<tr><td><input type="text" class="form-control form-control-sm mono ae-cs" data-i="'+i+'" value="'+esc(v)+'" placeholder="SA7AUX" style="text-transform:uppercase;"></td>'
+ '<td><button type="button" class="btn btn-sm btn-outline-danger ae-del" data-i="'+i+'">Delete</button></td></tr>';
});
wrap.innerHTML = html + '</tbody></table></div>';
wrap.querySelectorAll('.ae-cs').forEach(function(inp){ inp.addEventListener('input', function(){ working[+inp.dataset.i] = inp.value; }); });
wrap.querySelectorAll('.ae-del').forEach(function(b){ b.addEventListener('click', function(){ working.splice(+b.dataset.i, 1); renderTable(); }); });
}
renderTable();
box.querySelector('.ae-add').addEventListener('click', function(){ working.push(''); renderTable(); });
box.querySelector('.ae-save').addEventListener('click', async function(){
var seen = {}, norm = [];
working.forEach(function(c){ c = String(c).trim().toUpperCase(); if (c && !seen[c]){ seen[c] = 1; norm.push(c); } });
var toRemove = server.filter(function(c){ return norm.indexOf(c) < 0; });
var toAdd = norm.filter(function(c){ return server.indexOf(c) < 0; });
setErr('Saving…', true);
for (var i = 0; i < toRemove.length; i++){ var r = await naAdminsApi({action:'remove', node:cs, admin:toRemove[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to remove '+toRemove[i]), false); return; } }
for (var i = 0; i < toAdd.length; i++){ var r = await naAdminsApi({action:'add', node:cs, admin:toAdd[i]}); if (!r || !r.ok){ setErr((r&&r.error)||('Failed to add '+toAdd[i]), false); return; } }
server = norm.slice();
setErr('✓ Saved', true);
});
if (onBack) box.querySelector('.ae-back').addEventListener('click', onBack);
}
// Inline admins editor under the node's row (Admins button in the list)
function toggleAdminsRow(btn){
var cs = btn.dataset.cs;
var tr = btn.closest('tr');
var next = tr.nextElementSibling;
if (next && next.classList.contains('na-adm-row')){ next.remove(); return; }
var open = tr.parentNode.querySelector('.na-adm-row');
if (open) open.remove();
var row = document.createElement('tr');
row.className = 'na-adm-row';
row.innerHTML = '<td colspan="6" style="background:rgba(0,255,255,.04);">'
+ '<div class="small text-secondary mb-2">Admins for <span class="mono">'+esc(cs)+'</span></div>'
+ '<div class="na-adm-box"></div></td>';
tr.after(row);
buildAdminEditor(row.querySelector('.na-adm-box'), cs, function(){ row.remove(); });
}
function backToList(){
$('naEditPanel').style.display = 'none';
$('naListPanel').style.display = '';
loadNodes();
}
async function savePw(pw){
if (!currentCs) return;
if (!confirm('Save password for '+currentCs+'?\nThe reflector will be restarted (nodes reconnect).')) return;
setMsg($('naPwMsg'), 'Saving…');
var j = await napi({action:'resetpw', node:currentCs, password:pw});
if (j && j.ok){ $('naEditPw').value = j.password; setMsg($('naPwMsg'), 'Saved'); }
else setMsg($('naPwMsg'), (j&&(j.detail||j.error))||'Failed', true);
}
document.addEventListener('DOMContentLoaded', function(){
fetch('/auth/me.php', {credentials:'same-origin'})
.then(function(r){ return r.json(); })
.then(function(j){ if (j && j.csrf_token) window._csrfToken = j.csrf_token; loadNodes(); })
.catch(function(){ loadNodes(); });
$('naBtnShowAdd').addEventListener('click', showAddPanel);
$('naBtnAddBack').addEventListener('click', addBackToList);
$('naBtnAdd').addEventListener('click', addNode);
$('naNewNode').addEventListener('keydown', function(e){ if (e.key === 'Enter') addNode(); });
$('naNewPwGen').addEventListener('click', function(){ $('naNewPw').value = genPassword(); });
wireCopy($('naNewPwCopy'), function(){ return $('naNewPw').value; });
$('naAddAdmBtn').addEventListener('click', function(){ addAdmins.push(''); renderAddAdmins(); });
$('naBtnBack').addEventListener('click', backToList);
$('naBtnPwSave').addEventListener('click', function(){
var pw = ($('naEditPw').value||'').trim();
if (!pw){ setMsg($('naPwMsg'), 'Empty password', true); return; }
savePw(pw);
});
$('naBtnPwGen').addEventListener('click', function(){ savePw(''); });
wireCopy($('naBtnPwCopy'), function(){ return $('naEditPw').value; });
wireCopy($('naPwCopy'), function(){ return $('naPwValue').textContent; });
});
+118
View File
@@ -0,0 +1,118 @@
<?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);
$lvl = acct_admin_level($uuid);
if ($lvl < 2) { header('Location: /'); exit; }
$page_title = 'Admin · Node Auth — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:900px;">
<!-- Node list -->
<div id="naListPanel">
<div class="panel">
<div class="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
<h5 class="mb-0">Node Authentication</h5>
<button type="button" class="btn btn-primary btn-sm" id="naBtnShowAdd">+ Add node</button>
</div>
<div id="naMsg" class="small mb-2" style="display:none;"></div>
<div style="overflow-x:auto; -webkit-overflow-scrolling:touch;">
<table class="data-table" id="naTable" style="min-width:640px;">
<thead><tr><th>Callsign</th><th>Auth</th><th>Admins</th><th>Cert email</th><th>Valid to</th><th>Actions</th></tr></thead>
<tbody><tr><td colspan="6" class="text-secondary">Loading…</td></tr></tbody>
</table>
</div>
<div id="naStatus" class="text-secondary small mt-2"></div>
</div>
</div>
<!-- Add node -->
<div id="naAddPanel" style="display:none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Add node</h5>
<button type="button" class="btn btn-outline-secondary btn-sm" id="naBtnAddBack">Back to list</button>
</div>
<div class="panel">
<div class="small text-secondary mb-3">Login/password node. Cert nodes are added under <a href="/auth/admin_certs.php">Certificates</a> (linked by the certificate's email); their admins are managed from the node list.</div>
<label class="form-label small text-secondary mb-1" for="naNewNode">Login (node callsign, with -SSID)</label>
<input type="text" class="form-control form-control-sm mono mb-3" id="naNewNode" placeholder="SA7ABC-1" style="width:180px; text-transform:uppercase;">
<label class="form-label small text-secondary mb-1" for="naNewPw">Password</label>
<div class="d-flex gap-2 align-items-center flex-wrap mb-1">
<input type="text" class="form-control form-control-sm mono" id="naNewPw" style="width:240px;">
<button type="button" class="btn btn-outline-secondary btn-sm" id="naNewPwGen">Generate</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="naNewPwCopy">Copy</button>
</div>
<div class="small text-secondary mb-3">Auto-generated. Goes in the node's <span class="mono">AUTH_KEY</span>; the node's admins see it in clear text under their profile → Nodes.</div>
<label class="form-label small text-secondary">Node admins</label>
<div class="small text-secondary mb-2">Callsigns that may administer this node and see its password. The node's own callsign always counts; add others here.</div>
<div class="d-flex gap-2 mb-2"><button type="button" class="btn btn-sm btn-outline-success" id="naAddAdmBtn">Add admin</button></div>
<div id="naAddAdmWrap" class="mb-3"></div>
<button type="button" class="btn btn-primary btn-sm" id="naBtnAdd">Create node</button>
<div id="naAddMsg" class="small mt-2" style="display:none;"></div>
</div>
</div>
<!-- Edit node -->
<div id="naEditPanel" style="display:none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Edit: <span id="naEditCs" class="mono"></span></h5>
<button type="button" class="btn btn-outline-secondary btn-sm" id="naBtnBack">Back to list</button>
</div>
<div class="panel">
<div class="small mb-3"><span class="text-secondary">Auth:</span> <span id="naEditType"></span></div>
<label class="form-label small text-secondary">Linked account</label>
<div id="naEditAcct" class="small mb-3">Loading…</div>
<div id="naEditPwWrap" style="display:none;">
<label class="form-label small text-secondary">Password (goes in the node's <span class="mono">AUTH_KEY</span>)</label>
<div class="d-flex gap-2 align-items-center flex-wrap mb-1">
<input type="text" class="form-control form-control-sm mono" id="naEditPw" style="width:240px;">
<button type="button" class="btn btn-primary btn-sm" id="naBtnPwSave">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="naBtnPwGen">Generate new</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="naBtnPwCopy">Copy</button>
</div>
<div id="naPwMsg" class="small mb-3" style="display:none;"></div>
</div>
<div id="naEditCertWrap" style="display:none;">
<label class="form-label small text-secondary">Certificate</label>
<div id="naEditCert" class="small mb-3"></div>
</div>
<label class="form-label small text-secondary">Node admins</label>
<div class="small text-secondary mb-2">Add a callsign to give them admin rights on this node. Each node has its own admins.</div>
<div id="naEditAdmins"></div>
</div>
</div>
</div>
<!-- One-time password modal (after Add) -->
<div class="modal fade" id="naPwModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content" style="background:#111; border:1px solid var(--line);">
<div class="modal-header" style="border-color:var(--line);">
<h5 class="modal-title" id="naPwTitle">Node password</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="small text-secondary mb-2">Copy this password into the node's <span class="mono">svxlink.conf</span> (<span class="mono">AUTH_KEY</span>). The node's owner and admins can also view it under profile → My Nodes.</div>
<div class="mono" id="naPwValue" style="font-size:1.1rem; word-break:break-all;"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="naPwCopy">Copy</button>
</div>
</div>
</div>
</div>
<?php
$extra_scripts = '<script>var ADMIN_LEVEL = ' . $lvl . ';</script><script src="/auth/admin_nodeauth.js?v=' . @filemtime(__DIR__ . '/admin_nodeauth.js') . '"></script>';
require __DIR__ . '/footer.php';
?>
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$uuid = require_admin(2);
// Preferred: the account uuid, so all of the account's callsigns (primary +
// admin-added extra/club calls) are matched. 'callsign' kept as fallback.
$acct_uuid = trim($_GET['uuid'] ?? '');
$callsign = strtoupper(trim($_GET['callsign'] ?? ''));
if ($acct_uuid !== '') {
$calls = acct_all_calls($acct_uuid);
} elseif ($callsign !== '') {
$u = redis()->get(call_index_key(base_call($callsign)));
$calls = (is_string($u) && $u !== '') ? acct_all_calls($u) : [$callsign];
} else {
json_out(['ok' => false, 'error' => 'missing_callsign'], 400);
}
if (!$calls) json_out(['ok' => true, 'nodes' => (object)[]]);
// Parse paths from svxreflector.conf
$conf_lines = @file('/etc/svxlink/svxreflector.conf', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$pki_dir = '/etc/svxlink/pki';
$pending_dir = '';
$certs_dir = '';
foreach ($conf_lines as $line) {
$line = trim($line);
if (str_starts_with($line, 'CERT_PKI_DIR=')) $pki_dir = substr($line, strlen('CERT_PKI_DIR='));
if (str_starts_with($line, 'CERT_CA_PENDING_CSRS_DIR=')) $pending_dir = substr($line, strlen('CERT_CA_PENDING_CSRS_DIR='));
if (str_starts_with($line, 'CERT_CA_CERTS_DIR=')) $certs_dir = substr($line, strlen('CERT_CA_CERTS_DIR='));
}
if ($pending_dir === '') $pending_dir = $pki_dir . '/pending_csrs';
if ($certs_dir === '') $certs_dir = $pki_dir . '/certs';
// Renewal count for a node, via the per-email counter (cert email, else CSR email).
function node_renewals(string $cs): int {
$e = node_cert_email($cs);
if ($e === '') $e = node_csr_email($cs);
return $e === '' ? 0 : cert_email_count($e);
}
// Fetch reflector status
$ch = curl_init('http://127.0.0.1:8098/api/status');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 2]);
$json = curl_exec($ch);
curl_close($ch);
$data = json_decode($json ?: '', true);
$all_nodes = (is_array($data) && isset($data['nodes'])) ? $data['nodes'] : [];
$r = redis();
$matched = [];
foreach ($all_nodes as $node_call => $node_info) {
// Only LOCAL client nodes — remote trunk nodes belong to other reflectors.
if (!empty($node_info['remoteTrunk'])) continue;
$sysop = strtoupper(trim($node_info['sysop'] ?? ''));
$match = ($sysop !== '' && in_array($sysop, $calls, true))
|| calls_match_node($calls, (string)$node_call);
// Also match if one of the account's callsigns is a node admin
if (!$match) {
$admins = $r->sMembers(rkey("node_admins:" . strtoupper($node_call)));
if (is_array($admins)) {
foreach ($calls as $c) {
if (in_array($c, $admins, true)) { $match = true; break; }
}
}
}
if ($match) {
// Node admins (sysop always included)
$stored = $r->sMembers(rkey("node_admins:" . strtoupper($node_call)));
if (!is_array($stored)) $stored = [];
$all_admins = $stored;
if ($sysop !== '' && !in_array($sysop, $all_admins, true)) array_unshift($all_admins, $sysop);
sort($all_admins);
$node_info['node_admins'] = $all_admins;
// Cert status
$node_info['has_cert'] = is_file(rtrim($certs_dir, '/') . '/' . $node_call . '.crt');
$node_info['has_pending_csr'] = is_file(rtrim($pending_dir, '/') . '/' . $node_call . '.csr');
$node_info['renewals'] = node_renewals($node_call);
$matched[$node_call] = $node_info;
}
}
// --- Also include OFFLINE nodes for this callsign (not currently connected) ---
// Password nodes from the reflector conf (via the scoped helper) and cert nodes
// from the PKI, matched by base callsign or node-admin membership. Mirrors the
// profile's node list so admins can manage admins even when a node is down.
$add_offline = function (string $nc, array $extra) use (&$matched, $r, $calls, $certs_dir, $pending_dir) {
$nc = strtoupper(trim($nc));
if ($nc === '' || isset($matched[$nc])) return;
$base = explode('-', $nc)[0];
$admins = $r->sMembers(rkey("node_admins:$nc"));
if (!is_array($admins)) $admins = [];
if (!calls_match_node($calls, $nc)) {
$isadm = false;
foreach ($calls as $c) {
if (in_array($c, $admins, true)) { $isadm = true; break; }
}
if (!$isadm) return;
}
$all_admins = $admins;
if ($base !== '' && !in_array($base, $all_admins, true)) array_unshift($all_admins, $base);
sort($all_admins);
$matched[$nc] = $extra + [
'offline' => true,
'sysop' => $base,
'node_admins' => $all_admins,
'has_cert' => is_file(rtrim($certs_dir, '/') . "/$nc.crt"),
'has_pending_csr' => is_file(rtrim($pending_dir, '/') . "/$nc.csr"),
'renewals' => node_renewals($nc),
];
};
// Password nodes (offline included) from the reflector conf via the scoped helper
$pw_out = [];
@exec('sudo /usr/local/sbin/svxreflector-users list 2>/dev/null', $pw_out);
foreach ($pw_out as $pn) $add_offline($pn, ['password' => true]);
// Cert nodes (offline included) from the PKI
foreach (glob(rtrim($certs_dir, '/') . '/*.crt') ?: [] as $f) {
$cn = basename($f, '.crt');
if (str_starts_with($cn, 'svxreflector_') || str_starts_with($cn, 'SvxReflector_')) continue;
$add_offline($cn, ['cert' => true]);
}
json_out(['ok' => true, 'nodes' => $matched]);
+349
View File
@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$uuid = require_admin(2);
$in = json_in();
$action = $in['action'] ?? '';
// These are the READ-ONLY actions. LVL1 is a read-only admin: it may run these
// but nothing else (LVL2/LVL3 have rank >= 2). The same set is csrf-exempt.
const REFLECTOR_RO_ACTIONS = ['pending', 'list_certs', 'cert_info', 'config_read', 'service_status'];
if (!in_array($action, REFLECTOR_RO_ACTIONS, true)) {
csrf_check();
if (acct_admin_rank($uuid) < 2) {
json_out(['ok' => false, 'error' => 'forbidden', 'reason' => 'read_only_admin'], 403);
}
}
// --- Parse svxreflector.conf for paths (same as svx-ca-admin.py) ---
define('REFLECTOR_CONF', '/etc/svxlink/svxreflector.conf');
function reflector_conf(): array {
static $cache = null;
if ($cache !== null) return $cache;
$cache = [];
$section = '';
foreach (file(REFLECTOR_CONF, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (preg_match('/^\[(.+)\]$/', $line, $m)) { $section = $m[1]; continue; }
if (str_contains($line, '=')) {
[$k, $v] = explode('=', $line, 2);
$cache[$section . '.' . trim($k)] = trim($v);
}
}
return $cache;
}
function conf_get(string $section, string $key, string $default = ''): string {
return reflector_conf()[$section . '.' . $key] ?? $default;
}
$pki_dir = conf_get('GLOBAL', 'CERT_PKI_DIR', '/etc/svxlink/pki');
$pending_dir = conf_get('GLOBAL', 'CERT_CA_PENDING_CSRS_DIR', $pki_dir . '/pending_csrs');
$csrs_dir = conf_get('GLOBAL', 'CERT_CA_CSRS_DIR', $pki_dir . '/csrs');
$certs_dir = conf_get('GLOBAL', 'CERT_CA_CERTS_DIR', $pki_dir . '/certs');
$pty_path = conf_get('GLOBAL', 'COMMAND_PTY', '/dev/shm/reflector_ctrl');
// Node callsigns are validated the way the reflector does it: against the
// ACCEPT_CALLSIGN/REJECT_CALLSIGN regexes in svxreflector.conf plus path
// safety (node_callsign_valid in lib.php).
// valid_callsign() comes from auth/lib.php.
// require_callsign() comes from auth/lib.php.
// Write command to reflector PTY via root-owned helper running as svxlink
// (the PTY slave is mode 0600 owned by svxlink; www-data cannot write it directly)
function pty_write(string $pty_path, string $command): true|string {
$parts = preg_split('/\s+/', trim($command));
if (!$parts || $parts[0] === '')
return 'Empty PTY command';
$cmd = 'sudo -u svxlink /usr/local/sbin/svxreflector-pty '
. implode(' ', array_map('escapeshellarg', $parts));
exec($cmd . ' 2>&1', $out, $rc);
return $rc === 0 ? true : (implode("\n", $out) ?: 'PTY write failed');
}
// Restore masked secrets before writing config back. When an operator below LVL3
// saves, config_read gave them every KEY/KEYFILE/PASSWORD/private value (and the
// whole [PASSWORDS] section) as `********`. Any line whose value is purely
// asterisks is put back from $old, matched by (section,key). A value the operator
// actually changed (not all-asterisks) is left as typed and written through.
function svxconf_restore_masked_secrets(string $new, string $old): string {
// Build (SECTION => key => value) from the live file.
$oldMap = [];
$sec = '';
foreach (preg_split('/\r?\n/', $old) as $ln) {
$t = trim($ln);
if ($t === '' || $t[0] === '#' || $t[0] === ';') continue;
if ($t[0] === '[' && substr($t, -1) === ']') { $sec = strtoupper(trim(substr($t, 1, -1))); continue; }
$eq = strpos($ln, '=');
if ($eq === false) continue;
$oldMap[$sec][trim(substr($ln, 0, $eq))] = trim(substr($ln, $eq + 1));
}
// Walk the incoming file; restore purely-masked values in place.
$sec = '';
$out = [];
foreach (preg_split('/\r?\n/', $new) as $ln) {
$t = trim($ln);
if ($t !== '' && $t[0] === '[' && substr($t, -1) === ']') {
$sec = strtoupper(trim(substr($t, 1, -1)));
$out[] = $ln;
continue;
}
$eq = strpos($ln, '=');
if ($eq !== false && $t !== '' && $t[0] !== '#' && $t[0] !== ';') {
$k = trim(substr($ln, 0, $eq));
$v = trim(substr($ln, $eq + 1));
if ($v !== '' && preg_match('/^\*+$/', $v) && isset($oldMap[$sec][$k])) {
$ln = rtrim(substr($ln, 0, $eq)) . '=' . $oldMap[$sec][$k];
}
}
$out[] = $ln;
}
return implode("\n", $out);
}
switch ($action) {
// ---- List pending CSRs ----
case 'pending':
$list = [];
$files = glob(rtrim($pending_dir, '/') . '/*.csr');
if ($files) {
foreach ($files as $f) {
$cs = basename($f, '.csr');
$csr = @openssl_csr_get_subject(file_get_contents($f));
$list[] = [
'callsign' => $cs,
'subject' => $csr ? ('CN=' . ($csr['CN'] ?? '?')) : '?',
'mtime' => filemtime($f) ?: 0,
];
}
usort($list, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
}
json_out(['ok' => true, 'pending' => $list]);
// ---- List signed client certs ----
case 'list_certs':
$list = [];
$files = glob(rtrim($certs_dir, '/') . '/*.crt');
if ($files) {
foreach ($files as $f) {
$cs = basename($f, '.crt');
if (str_starts_with($cs, 'svxreflector_') || str_starts_with($cs, 'SvxReflector_'))
continue;
$list[] = [
'callsign' => $cs,
'mtime' => filemtime($f) ?: 0,
];
}
usort($list, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
}
json_out(['ok' => true, 'certs' => $list]);
// ---- Certificate details ----
case 'cert_info':
$cs = require_callsign($in);
$crtFile = rtrim($certs_dir, '/') . "/{$cs}.crt";
if (!is_file($crtFile))
json_out(['ok' => false, 'error' => "No certificate for {$cs}"], 404);
$pem = file_get_contents($crtFile);
$x = @openssl_x509_parse($pem);
if (!$x)
json_out(['ok' => false, 'error' => 'Cannot parse certificate'], 500);
$fp = @openssl_x509_fingerprint($pem, 'sha256');
$issuer = '';
if (isset($x['issuer']) && is_array($x['issuer']))
$issuer = implode(', ', array_map(fn($k, $v) => "$k=$v", array_keys($x['issuer']), array_values($x['issuer'])));
json_out(['ok' => true, 'info' => [
'callsign' => $cs,
'subjectCN' => $x['subject']['CN'] ?? '',
'issuer' => $issuer,
'serial' => $x['serialNumberHex'] ?? (string)($x['serialNumber'] ?? ''),
'validFrom' => $x['validFrom_time_t'] ?? 0,
'validTo' => $x['validTo_time_t'] ?? 0,
'fingerprint' => $fp ? strtoupper(implode(':', str_split($fp, 2))) : '',
'hasCsr' => is_file(rtrim($csrs_dir, '/') . "/{$cs}.csr"),
]]);
// ---- Add a pending CSR (paste PEM) ----
case 'add_pending':
$pem = trim((string)($in['csr'] ?? ''));
if ($pem === '' || !str_contains($pem, 'BEGIN CERTIFICATE REQUEST'))
json_out(['ok' => false, 'error' => 'Not a PEM certificate request'], 400);
$subj = @openssl_csr_get_subject($pem);
if (!$subj || empty($subj['CN']))
json_out(['ok' => false, 'error' => 'Invalid CSR (no CN found)'], 400);
$cs = strtoupper(trim($subj['CN']));
if (!valid_callsign($cs))
json_out(['ok' => false, 'error' => "CN '{$cs}' is not a valid callsign"], 400);
if (is_file(rtrim($pending_dir, '/') . "/{$cs}.csr"))
json_out(['ok' => false, 'error' => "Pending CSR already exists for {$cs}"], 409);
if (is_file(rtrim($certs_dir, '/') . "/{$cs}.crt"))
json_out(['ok' => false, 'error' => "Certificate already issued for {$cs}"], 409);
$dest = rtrim($pending_dir, '/') . "/{$cs}.csr";
if (@file_put_contents($dest, $pem . "\n") === false)
json_out(['ok' => false, 'error' => 'Cannot write CSR file'], 500);
@chmod($dest, 0664);
json_out(['ok' => true, 'message' => "Added pending CSR for {$cs}", 'callsign' => $cs]);
// ---- Re-queue an issued cert's CSR back to pending (for re-signing) ----
case 'repend':
$cs = require_callsign($in);
$signedCsr = rtrim($csrs_dir, '/') . "/{$cs}.csr";
$pend = rtrim($pending_dir, '/') . "/{$cs}.csr";
if (!is_file($signedCsr))
json_out(['ok' => false, 'error' => "No CSR on file for {$cs}; cannot re-pend"], 404);
if (is_file($pend))
json_out(['ok' => false, 'error' => "{$cs} is already pending"], 409);
if (!@copy($signedCsr, $pend))
json_out(['ok' => false, 'error' => 'Cannot write pending CSR'], 500);
@chmod($pend, 0664);
json_out(['ok' => true, 'message' => "{$cs} moved back to pending"]);
// ---- Sign a pending CSR via PTY (CA SIGN) ----
case 'sign':
$cs = require_callsign($in);
if (!is_file(rtrim($pending_dir, '/') . "/{$cs}.csr"))
json_out(['ok' => false, 'error' => "No pending CSR for {$cs}"], 404);
$csr_email = node_csr_email($cs); // read before signing (CSR leaves pending)
$msg = pty_write($pty_path, "CA SIGN {$cs}");
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
// Bump the counters: the first admin sign takes them 0 -> 1, which
// unlocks self-service renewals for the node (per-node counter) and
// for the account email carried in the CSR (per-email counter).
cert_node_bump($cs);
if ($csr_email !== '') cert_email_bump($csr_email);
json_out(['ok' => true, 'message' => "Sent CA SIGN {$cs} to reflector"]);
// ---- Revoke — remove cert + CSR via PTY (CA RM) ----
case 'revoke':
$cs = require_callsign($in);
$crtFile = rtrim($certs_dir, '/') . "/{$cs}.crt";
if (!is_file($crtFile))
json_out(['ok' => false, 'error' => "No certificate for {$cs}"], 404);
$msg = pty_write($pty_path, "CA RM {$cs}");
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
json_out(['ok' => true, 'message' => "Removed certificate for {$cs}"]);
// ---- Remove (reject) a pending CSR ----
case 'rmpending':
$cs = require_callsign($in);
$csrFile = rtrim($pending_dir, '/') . "/{$cs}.csr";
if (!is_file($csrFile))
json_out(['ok' => false, 'error' => "No pending CSR for {$cs}"], 404);
@unlink($csrFile);
json_out(['ok' => true, 'message' => "Removed pending CSR for {$cs}"]);
// ---- Block a node via PTY ----
case 'block':
$cs = require_callsign($in);
$secs = (int)($in['seconds'] ?? 0);
if ($secs < 1)
json_out(['ok' => false, 'error' => 'invalid_seconds'], 400);
$msg = pty_write($pty_path, "NODE BLOCK {$cs} {$secs}");
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
json_out(['ok' => true, 'message' => "Blocked {$cs} for {$secs}s"]);
// ---- Unblock a node via PTY ----
case 'unblock':
$cs = require_callsign($in);
$msg = pty_write($pty_path, "NODE BLOCK {$cs} 0"); // GeuReflector: unblock = block 0s
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
json_out(['ok' => true, 'message' => "Unblocked {$cs}"]);
// ---- Service control (reload/restart/stop) ----
case 'service_reload':
case 'service_restart':
case 'service_stop':
case 'service_status':
if ($action === 'service_stop' && acct_admin_level($uuid) < 3) json_out(['ok'=>false,'error'=>'superadmin_required'], 403);
$svc_action = str_replace('service_', '', $action);
$cmd = 'sudo /usr/local/sbin/svxreflector-systemctl ' . escapeshellarg($svc_action);
$output = [];
$rc = 0;
exec($cmd . ' 2>&1', $output, $rc);
$out = implode("\n", $output);
if ($rc === 0) json_out(['ok' => true, 'message' => $out]);
else json_out(['ok' => false, 'error' => $out], 500);
// ---- Config: read all sections via PTY ----
case 'config_read':
$conf_content = @file_get_contents('/etc/svxlink/svxreflector.conf');
if ($conf_content === false)
json_out(['ok' => false, 'error' => 'Cannot read config file'], 500);
// Parse into sections
$sections = [];
$current_section = '';
foreach (explode("\n", $conf_content) as $line) {
$trimmed = trim($line);
if ($trimmed === '' || $trimmed[0] === '#') continue;
if (preg_match('/^\[(.+)\]$/', $trimmed, $m)) {
$current_section = $m[1];
if (!isset($sections[$current_section])) $sections[$current_section] = [];
continue;
}
if (str_contains($trimmed, '=') && $current_section !== '') {
[$k, $v] = explode('=', $trimmed, 2);
$sections[$current_section][trim($k)] = trim($v);
}
}
// Mask sensitive data for non-superadmin
$level = acct_admin_level($uuid);
if ($level < 3) {
// Mask raw content
$conf_content = preg_replace('/^(.*(?:KEY|KEYFILE|PASSWORD|private).*=\s*).+$/mi', '$1********', $conf_content);
// Mask sections
foreach ($sections as $sec => &$vals) {
if (strtoupper($sec) === 'PASSWORDS') {
foreach ($vals as $k => &$v) $v = '********';
}
foreach ($vals as $k => &$v) {
if (preg_match('/key|keyfile|private|password/i', $k)) $v = '********';
}
}
unset($vals, $v);
}
// LVL2 may edit config (secrets stay masked; restored on save), LVL3 fully.
json_out(['ok' => true, 'sections' => $sections, 'raw' => $conf_content, 'can_edit' => acct_admin_rank($uuid) >= 2]);
// ---- Config: write a value via PTY ----
case 'config_set':
$section = strtoupper(trim($in['section'] ?? ''));
$tag = trim($in['tag'] ?? '');
$value = trim($in['value'] ?? '');
if ($section === '' || $tag === '')
json_out(['ok' => false, 'error' => 'missing section or tag'], 400);
$msg = pty_write($pty_path, "CFG {$section} {$tag} {$value}");
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
json_out(['ok' => true, 'message' => "Set {$section}/{$tag}={$value}"]);
// ---- Config: save to file ----
// LVL1 is already blocked by the read-only guard at the top. LVL2 may save
// but never sees secrets (they arrive masked as ********); LVL3 sees the real
// values.
case 'config_save':
$raw = $in['raw'] ?? '';
if ($raw === '')
json_out(['ok' => false, 'error' => 'empty config'], 400);
$path = '/etc/svxlink/svxreflector.conf';
// Below LVL3 the editor showed masked secrets — restore any purely-masked
// value (^\*+$) from the live file so a blind save can't wipe a real
// secret. A value the operator actually typed is written through.
if (acct_admin_rank($uuid) < 3) {
$old = is_readable($path) ? (string)file_get_contents($path) : '';
$raw = svxconf_restore_masked_secrets((string)$raw, $old);
}
// Backup first
@copy($path, $path . '.bak');
if (file_put_contents($path, $raw) === false)
json_out(['ok' => false, 'error' => 'Cannot write config file'], 500);
json_out(['ok' => true, 'message' => 'Config saved']);
default:
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
}
+155
View File
@@ -0,0 +1,155 @@
'use strict';
// TG List editor — canonical TG -> name/system list (served publicly by
// /tglist.php). Mirrors the Global Bans admin pattern. Add/delete are admin-only;
// an owner (matching callsign) can edit their own TG. The server enforces this;
// the UI only reflects it.
var $ = function (id) { return document.getElementById(id); };
var tglData = [], tglCanAdmin = false, tglMyCalls = [], tglEditingTg = null;
function tglEsc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function tglMsg(t, err) {
var e = $('tglMsg'); if (!e) return;
if (!t) { e.style.display = 'none'; return; }
e.style.display = 'block'; e.textContent = t; e.style.color = err ? '#ff8080' : '#00ff80';
}
async function tapi(body) {
var r = await fetch('/auth/tglist_api.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' },
body: JSON.stringify(body)
});
return r.json().catch(function () { return null; });
}
function tglCanEdit(row) {
if (tglCanAdmin) return true;
var o = (row.owner || '').toUpperCase();
return o !== '' && tglMyCalls.indexOf(o) >= 0;
}
// Filter predicate from the search box: try regex, fall back to a literal
// substring if the pattern is invalid — it never throws.
function tglMatcher(q) {
q = (q || '').trim();
if (!q) return function () { return true; };
var re;
try { re = new RegExp(q, 'i'); }
catch (e) { var lit = q.toLowerCase(); return function (s) { return s.toLowerCase().indexOf(lit) >= 0; }; }
return function (s) { return re.test(s); };
}
function tglRender() {
var body = $('tglBody'); if (!body) return;
var match = tglMatcher($('tglSearch').value);
var key = $('tglSortKey').value;
var dir = $('tglSortDir').getAttribute('data-dir') === 'desc' ? -1 : 1;
var rows = tglData.filter(function (r) {
return match([r.tg, r.name, r.system, r.allstar, r.mode, r.owner].join(' '));
});
rows.sort(function (a, b) {
var x = a[key], y = b[key];
if (key === 'tg') return (x - y) * dir;
x = (x || '').toLowerCase(); y = (y || '').toLowerCase();
return (x < y ? -1 : x > y ? 1 : 0) * dir;
});
body.innerHTML = '';
rows.forEach(function (r) {
var tr = document.createElement('tr');
var canEdit = tglCanEdit(r);
tr.innerHTML =
'<td class="mono">' + tglEsc(r.tg) + '</td>' +
'<td>' + tglEsc(r.name) + '</td>' +
'<td>' + tglEsc(r.system) + '</td>' +
'<td class="mono">' + tglEsc(r.allstar) + '</td>' +
'<td>' + tglEsc(r.mode) + '</td>' +
'<td class="mono">' + tglEsc(r.owner) + '</td>' +
'<td class="text-end" style="white-space:nowrap;">' +
(canEdit ? '<button class="btn btn-outline-secondary btn-sm" data-tgl-edit="' + tglEsc(r.tg) + '" style="font-size:10px;padding:1px 6px;">edit</button>' : '') +
(tglCanAdmin ? ' <button class="btn btn-outline-danger btn-sm" data-tgl-del="' + tglEsc(r.tg) + '" style="font-size:10px;padding:1px 6px;">del</button>' : '') +
'</td>';
body.appendChild(tr);
});
$('tglCount').textContent = rows.length + ' / ' + tglData.length + ' TG';
}
function tglOpenForm(row) {
var isNew = !row;
tglEditingTg = isNew ? null : row.tg;
$('tglfTg').value = isNew ? '' : row.tg;
$('tglfName').value = isNew ? '' : row.name;
$('tglfSystem').value = isNew ? '' : row.system;
$('tglfAllstar').value = isNew ? '' : row.allstar;
$('tglfMode').value = isNew ? '' : row.mode;
$('tglfOwner').value = isNew ? '' : row.owner;
$('tglfTg').disabled = !isNew; // TG fixed on edit, set only when adding
$('tglfOwner').disabled = true; // owner is display-only (set via request approval)
$('tglForm').style.display = 'block';
$('tglfName').focus();
}
function tglCloseForm() { $('tglForm').style.display = 'none'; tglEditingTg = null; }
async function tglSave() {
var tg = parseInt($('tglfTg').value, 10);
if (!tg || tg <= 0) { tglMsg('TG must be a positive number', true); return; }
var body = {
action: 'save', tg: tg,
name: $('tglfName').value, system: $('tglfSystem').value,
allstar: $('tglfAllstar').value.trim(), mode: $('tglfMode').value
};
var res = await tapi(body);
if (res && res.ok) { tglMsg('Saved TG ' + tg, false); tglCloseForm(); await tglLoad(); }
else tglMsg((res && res.error) || 'Failed', true);
}
async function tglDelete(tg) {
if (!confirm('Delete TG ' + tg + '?')) return;
var res = await tapi({ action: 'delete', tg: parseInt(tg, 10) });
if (res && res.ok) { tglMsg('Deleted TG ' + tg, false); await tglLoad(); }
else tglMsg((res && res.error) || 'Failed', true);
}
async function tglLoad() {
var j = await tapi({ action: 'list' });
if (!j || !j.ok) return;
tglData = j.tgs || [];
tglCanAdmin = !!j.can_admin;
tglMyCalls = (j.my_calls || []).map(function (s) { return (s || '').toUpperCase(); });
$('btnTglAdd').style.display = tglCanAdmin ? '' : 'none';
var ro = $('tglRoBadge');
if (ro) ro.style.display = (tglCanAdmin || tglMyCalls.length) ? 'none' : '';
tglRender();
}
document.addEventListener('DOMContentLoaded', function () {
fetch('/auth/me.php', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) { if (j && j.csrf_token) window._csrfToken = j.csrf_token; })
.catch(function () {})
.finally(tglLoad);
$('btnTglRefresh').addEventListener('click', tglLoad);
$('btnTglAdd').addEventListener('click', function () { tglOpenForm(null); });
$('tglfSave').addEventListener('click', tglSave);
$('tglfCancel').addEventListener('click', tglCloseForm);
$('tglSearch').addEventListener('input', tglRender);
$('tglSortKey').addEventListener('change', tglRender);
$('tglSortDir').addEventListener('click', function () {
var b = $('tglSortDir');
var d = b.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
b.setAttribute('data-dir', d);
b.innerHTML = d === 'asc' ? '&#9650; asc' : '&#9660; desc';
tglRender();
});
$('tglBody').addEventListener('click', function (e) {
var ed = e.target.closest('button[data-tgl-edit]');
if (ed) {
var tg = parseInt(ed.getAttribute('data-tgl-edit'), 10);
var row = tglData.filter(function (r) { return r.tg === tg; })[0];
if (row) tglOpenForm(row);
return;
}
var de = e.target.closest('button[data-tgl-del]');
if (de) tglDelete(de.getAttribute('data-tgl-del'));
});
});
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
start_session();
$uuid = $_SESSION['uid'] ?? null;
if (!is_string($uuid) || $uuid === '') { header('Location: /'); exit; }
// Any logged-in user may open the page — add/edit/delete rights are enforced
// per-row by tglist_api.php (admin = all; owner = own TGs). Non-owners just see
// a read-only table.
$lvl = acct_admin_level($uuid);
$page_title = 'TG List — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:1000px;">
<div id="page-tglist">
<div class="panel">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h5 class="mb-0">TG List <span id="tglRoBadge" class="badge bg-secondary ms-2 small" style="display:none;">read-only</span></h5>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" id="btnTglRefresh">Refresh</button>
<button class="btn btn-outline-success btn-sm" id="btnTglAdd" style="display:none;">+ Add TG</button>
</div>
</div>
<p class="small text-secondary mb-3">Canonical TG&nbsp;&rarr;&nbsp;name list. Every box/program reads it from <span class="mono">/tglist.php</span>. Admins manage all entries and assign an owner; an owner (matching callsign) may edit their own TG's name / system / AllStar / mode.</p>
<div class="d-flex gap-2 mb-3 flex-wrap align-items-center">
<input type="text" class="form-control form-control-sm mono" id="tglSearch" placeholder="search tg / name / system … (regex ok)" style="max-width:320px;">
<select class="form-select form-select-sm" id="tglSortKey" style="width:auto;">
<option value="tg">Sort: TG</option>
<option value="name">Sort: Name</option>
<option value="system">Sort: System</option>
</select>
<button class="btn btn-outline-secondary btn-sm" id="tglSortDir" data-dir="asc" title="Toggle sort direction">&#9650; asc</button>
<span class="small text-secondary ms-auto" id="tglCount"></span>
</div>
<!-- Add / edit form (hidden until Add or Edit) -->
<div id="tglForm" class="mb-3 p-3" style="display:none; border:1px solid var(--line); border-radius:6px;">
<div class="row g-2">
<div class="col-6 col-md-2"><label class="small">TG</label><input id="tglfTg" type="number" class="form-control form-control-sm mono"></div>
<div class="col-6 col-md-3"><label class="small">Name</label><input id="tglfName" class="form-control form-control-sm" maxlength="64"></div>
<div class="col-6 col-md-3"><label class="small">System</label><input id="tglfSystem" class="form-control form-control-sm" maxlength="64"></div>
<div class="col-4 col-md-2"><label class="small">AllStar</label><input id="tglfAllstar" class="form-control form-control-sm mono" maxlength="10"></div>
<div class="col-4 col-md-2"><label class="small">Mode</label><input id="tglfMode" class="form-control form-control-sm" maxlength="16" placeholder="AllStar / DMR"></div>
<div class="col-4 col-md-3"><label class="small">Owner (callsign)</label><input id="tglfOwner" class="form-control form-control-sm mono" maxlength="12" style="text-transform:uppercase;"></div>
</div>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-success btn-sm" id="tglfSave">Save</button>
<button class="btn btn-outline-secondary btn-sm" id="tglfCancel">Cancel</button>
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr>
<th>TG</th><th>Name</th><th>System</th><th>AllStar</th><th>Mode</th><th>Owner</th><th>Actions</th>
</tr></thead>
<tbody id="tglBody"></tbody>
</table>
</div>
<div id="tglMsg" class="small mt-2" style="display:none;"></div>
</div>
</div>
</div>
<?php
$extra_scripts = '<script>var ADMIN_LEVEL = ' . $lvl . ';</script><script src="/auth/admin_tglist.js?v=' . @filemtime(__DIR__ . '/admin_tglist.js') . '"></script>';
require __DIR__ . '/footer.php';
?>
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
return [
'redis' => [
'unix_socket' => '/run/redis/redis.sock',
'password' => 'CHANGEME',
'host' => '127.0.0.1',
'port' => 6379,
'db' => 0,
'prefix' => 'registry:svxlink:',
],
'session' => [
'name' => 'svxportal_sess',
'cookie_secure' => true,
'cookie_samesite' => 'Lax',
],
'mail' => [ 'from' => 'no-reply@example.com' ],
'base_url' => 'https://svx.example.com',
// Canonical TG list JSON file (SVX source of truth; DMR uses Redis instead).
// Must be writable by the web user (www-data) and readable by svxdr.
'tglist_file' => '/etc/svxlink/tglist.json',
];
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$j = json_in();
$consent = (string)($j['consent'] ?? '');
if ($consent !== 'necessary' && $consent !== 'all') {
json_out(['ok'=>false,'error'=>'bad_consent'], 400);
}
$maxAge = 60 * 60 * 24 * 365;
setcookie('cookie_consent', $consent, [
'expires' => time() + $maxAge,
'path' => '/',
'secure' => (bool)(cfg()['session']['cookie_secure'] ?? false),
'httponly' => false,
'samesite' => 'Lax',
]);
json_out(['ok'=>true, 'consent'=>$consent]);
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$uuid = require_admin(2);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
set_time_limit(0);
ini_set('output_buffering', '0');
if (function_exists('apache_setenv')) {
apache_setenv('no-gzip', '1');
}
session_write_close();
// Parse pending_csrs path from svxreflector.conf
$conf_lines = @file('/etc/svxlink/svxreflector.conf', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$pki_dir = '/etc/svxlink/pki';
$pending_dir = '';
$certs_dir = '';
foreach ($conf_lines as $line) {
$line = trim($line);
if (str_starts_with($line, 'CERT_PKI_DIR=')) $pki_dir = substr($line, strlen('CERT_PKI_DIR='));
if (str_starts_with($line, 'CERT_CA_PENDING_CSRS_DIR=')) $pending_dir = substr($line, strlen('CERT_CA_PENDING_CSRS_DIR='));
if (str_starts_with($line, 'CERT_CA_CERTS_DIR=')) $certs_dir = substr($line, strlen('CERT_CA_CERTS_DIR='));
}
if ($pending_dir === '') $pending_dir = $pki_dir . '/pending_csrs';
if ($certs_dir === '') $certs_dir = $pki_dir . '/certs';
$last_hash = '';
function sse_send(string $event, $data): void {
echo "event: {$event}\n";
echo 'data: ' . json_encode($data) . "\n\n";
if (ob_get_level()) ob_flush();
flush();
}
function scan_pending(string $dir): array {
$list = [];
$files = glob(rtrim($dir, '/') . '/*.csr');
if ($files === false) return $list;
foreach ($files as $f) {
$list[] = ['callsign' => basename($f, '.csr'), 'mtime' => filemtime($f) ?: 0];
}
usort($list, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
return $list;
}
// Warn about infrastructure certs (root/issuing CA + server cert) nearing
// expiry. Node certs auto-renew at 2/3 lifetime, so they are NOT included.
function scan_ca_certs(string $certs_dir): array {
$list = [];
$files = glob(rtrim($certs_dir, '/') . '/*.crt');
if ($files === false) return $list;
$now = time();
$warn = 30 * 86400;
foreach ($files as $f) {
$base = basename($f, '.crt');
if (!str_starts_with($base, 'svxreflector_') && !str_starts_with($base, 'SvxReflector_')) continue;
$pem = @file_get_contents($f);
if ($pem === false) continue;
$x = @openssl_x509_parse($pem);
if (!$x || !isset($x['validTo_time_t'])) continue;
$vt = (int)$x['validTo_time_t'];
$left = $vt - $now;
if ($left > $warn) continue;
$list[] = [
'name' => $base,
'validTo' => $vt,
'days_left' => (int)floor($left / 86400),
'expired' => $left <= 0,
];
}
usort($list, fn($a, $b) => $a['validTo'] <=> $b['validTo']);
return $list;
}
function scan_unverified(): array {
$r = redis();
$pattern = call_index_key('*');
$cursor = null;
$list = [];
do {
$result = $r->scan($cursor, $pattern, 200);
if ($result === false) break;
foreach ($result as $key) {
$uuid = $r->get($key);
if (!is_string($uuid) || $uuid === '') continue;
$a = $r->hMGet(acct_key($uuid), ['callsign', 'email_verified', 'firstname', 'created_at']);
if ((int)($a['email_verified'] ?? 0) === 0) {
// Also check for accounts that are verified but not activated (no last_login)
continue;
}
// Check if account has never logged in (new registration, verified but pending admin review)
$last = $r->hGet(acct_key($uuid), 'last_login_at');
if ($last && (int)$last > 0) continue; // Already logged in, not new
// This is a verified account that has never logged in = pending activation
}
} while ($cursor > 0);
return $list;
}
function scan_new_accounts(): array {
$r = redis();
$pattern = call_index_key('*');
$cursor = null;
$list = [];
do {
$result = $r->scan($cursor, $pattern, 200);
if ($result === false) break;
foreach ($result as $key) {
$uuid = $r->get($key);
if (!is_string($uuid) || $uuid === '') continue;
$a = $r->hMGet(acct_key($uuid), ['callsign', 'email_verified', 'firstname', 'created_at', 'last_login_at', 'banned', 'acct_flags', 'admin_level']);
$verified = (int)($a['email_verified'] ?? 0);
$flags = acct_flags_from(is_array($a) ? $a : []);
$approved = ($flags & 4) !== 0; // normal bit — required to log in
$banned = (int)($a['banned'] ?? 0);
// Show accounts that still need an admin: unverified (awaiting email)
// OR verified but not yet approved (normal bit not set → can't log in).
if (!$banned && (!$verified || !$approved)) {
$list[] = [
'uuid' => $uuid,
'callsign' => $a['callsign'] ?? '',
'firstname' => $a['firstname'] ?? '',
'verified' => $verified,
'approved' => $approved ? 1 : 0,
'flags' => $flags,
'created' => (int)($a['created_at'] ?? 0),
];
}
}
} while ($cursor > 0);
usort($list, fn($a, $b) => $b['created'] <=> $a['created']);
return $list;
}
// Open TG requests awaiting admin approval (SVX TG list JSON file).
function scan_tg_requests(): array {
$p = cfg()['tglist_file'] ?? '/etc/svxlink/tglist.json';
if (!is_string($p) || !is_file($p)) return [];
$j = json_decode((string)@file_get_contents($p), true);
$out = [];
if (is_array($j) && isset($j['requests']) && is_array($j['requests'])) {
foreach ($j['requests'] as $rq) {
if (!is_array($rq)) continue;
$out[] = ['uuid' => (string)($rq['uuid'] ?? ''), 'tg' => (int)($rq['tg'] ?? 0),
'name' => (string)($rq['name'] ?? ''), 'callsign' => (string)($rq['callsign'] ?? ''), 'ts' => (int)($rq['ts'] ?? 0)];
}
}
usort($out, fn($a, $b) => $b['ts'] <=> $a['ts']);
return $out;
}
function notif_payload(array $st): array {
return [
'pending_count' => count($st['pending']),
'pending' => $st['pending'],
'new_accounts_count' => count($st['new_accounts']),
'new_accounts' => $st['new_accounts'],
'ca_certs_count' => count($st['ca_certs']),
'ca_certs' => $st['ca_certs'],
'tg_requests_count' => count($st['tg_requests']),
'tg_requests' => $st['tg_requests'],
'total' => count($st['pending']) + count($st['new_accounts']) + count($st['ca_certs']) + count($st['tg_requests']),
];
}
// Initial send
$pending = scan_pending($pending_dir);
$new_accounts = scan_new_accounts();
$ca_certs = scan_ca_certs($certs_dir);
$state = ['pending' => $pending, 'new_accounts' => $new_accounts, 'ca_certs' => $ca_certs, 'tg_requests' => scan_tg_requests()];
$last_hash = md5(json_encode($state));
sse_send('notifications', notif_payload($state));
$tick = 0;
while (true) {
sleep(3);
if (connection_aborted()) break;
clearstatcache(true, $pending_dir);
$pending = scan_pending($pending_dir);
$new_accounts = ($tick % 10 === 0) ? scan_new_accounts() : ($state['new_accounts'] ?? []);
$ca_certs = ($tick % 10 === 0) ? scan_ca_certs($certs_dir) : ($state['ca_certs'] ?? []);
$state = ['pending' => $pending, 'new_accounts' => $new_accounts, 'ca_certs' => $ca_certs, 'tg_requests' => scan_tg_requests()];
$hash = md5(json_encode($state));
if ($hash !== $last_hash) {
$last_hash = $hash;
sse_send('notifications', notif_payload($state));
}
if (++$tick % 5 === 0) {
echo ": keepalive\n\n";
if (ob_get_level()) ob_flush();
flush();
}
}
+9
View File
@@ -0,0 +1,9 @@
</main>
<footer style="text-align:center; padding:16px; color:rgba(0,255,255,.4); font-size:.75rem; border-top:1px solid rgba(0,255,255,.15);">
&copy; 2026 SA7AUX. All rights reserved.
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/auth_ui.js?v=<?= @filemtime(dirname(__DIR__) . '/auth_ui.js') ?>"></script>
<?= $extra_scripts ?? '' ?>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$j = json_in();
$callsign = base_call(trim((string)($j['callsign'] ?? '')));
$email = norm_email((string)($j['email'] ?? ''));
// Rate limit: 3 attempts per 15 minutes per IP
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
rate_limit("forgot:{$ip}", 3, 900);
if ($callsign === '' || $email === '') {
json_out(['ok' => false, 'error' => 'Callsign and email are required.'], 400);
}
// Find account by callsign
$uuid = redis()->get(call_index_key($callsign));
if (!is_string($uuid) || $uuid === '') {
// Don't reveal if account exists
json_out(['ok' => true, 'message' => 'If the account exists, a reset link has been sent.']);
}
$acct = acct_get($uuid);
$acct_email = norm_email($acct['email'] ?? '');
// Verify email matches
if ($acct_email !== $email) {
json_out(['ok' => true, 'message' => 'If the account exists, a reset link has been sent.']);
}
// Generate reset token
$tid = token_id();
$secret = token_secret();
$hash = token_hash($secret);
$k = rkey("token:reset:$tid");
redis()->hMSet($k, ['uid' => $uuid, 'hash' => $hash]);
redis()->expire($k, 60 * 60); // 1 hour
$base = rtrim(cfg()['base_url'], '/');
$link = $base . '/auth/reset_password.php?tid=' . urlencode($tid) . '&token=' . urlencode($secret);
$name = $acct['firstname'] ?? $callsign;
$body = "Hello {$name},\n\n";
$body .= "A password reset was requested for your SvxPortal account ({$callsign}).\n\n";
$body .= "Click the link below to set a new password:\n\n";
$body .= "{$link}\n\n";
$body .= "This link is valid for 1 hour.\n\n";
$body .= "If you did not request this, you can ignore this email.\n\n";
$body .= "73,\nSvxPortal";
mail_send($email, "Password reset - SvxPortal", $body);
json_out(['ok' => true, 'message' => 'If the account exists, a reset link has been sent.']);
+95
View File
@@ -0,0 +1,95 @@
<?php
// Shared header — include on all pages
if (!function_exists('cfg')) require __DIR__ . '/lib.php';
if (session_status() !== PHP_SESSION_ACTIVE) start_session();
// Never let browsers cache these pages: without this they guess freshness
// themselves and keep showing days-old UI after every deploy.
header('Cache-Control: no-store');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= $page_title ?? 'SvxPortal' ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root{ color-scheme:dark; --bg:#000; --fg:#00ffff; --fg2:rgba(0,255,255,.75); --line:rgba(0,255,255,.35); }
html, body{ height:100%; background:var(--bg); color:var(--fg); margin:0; }
body{ display:flex; flex-direction:column; min-height:100vh; }
*{ color:var(--fg); }
*::selection{ background: rgba(0,255,255,.2); }
.navbar{ background: var(--bg) !important; position:sticky; top:0; z-index:1030; }
a, .nav-link{ color:var(--fg) !important; text-decoration:none; }
.text-muted, .text-secondary{ color: var(--fg2) !important; }
.btn.btn-outline-secondary{ background: var(--bg) !important; color: var(--fg) !important; border-color: rgba(0,255,255,.7) !important; }
.btn.btn-outline-secondary:hover{ background: rgba(0,255,255,.1) !important; }
.btn.btn-primary{ background: rgba(0,255,255,.15) !important; color: var(--fg) !important; border-color: rgba(0,255,255,.7) !important; }
.btn.btn-primary:hover{ background: rgba(0,255,255,.25) !important; }
.btn.btn-danger{ background: rgba(255,80,80,.2) !important; color: #ff8080 !important; border-color: rgba(255,80,80,.6) !important; }
.btn.btn-warning{ background: rgba(255,200,0,.15) !important; color: #ffc800 !important; border-color: rgba(255,200,0,.5) !important; }
.btn.btn-success{ background: rgba(0,200,100,.15) !important; color: #00c864 !important; border-color: rgba(0,200,100,.5) !important; }
.btn.btn-outline-danger{ background: transparent !important; color: #ff8080 !important; border-color: rgba(255,80,80,.6) !important; }
.form-control, .form-select{ background: var(--bg) !important; color: var(--fg) !important; border-color: rgba(0,255,255,.5) !important; }
.form-control:focus, .form-select:focus{ box-shadow: 0 0 0 .2rem rgba(0,255,255,.25) !important; }
.form-label{ color: var(--fg2) !important; }
.panel{ border:1px solid var(--line); border-radius:10px; padding:16px; margin-bottom:16px; }
.mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.border, .border-bottom{ border-color: var(--line) !important; }
main.content{ flex:1; }
footer{ flex-shrink:0; position:sticky; bottom:0; background:var(--bg); }
table.data-table{ width:100%; min-width:500px; border-collapse:collapse; font-size:.85rem; }
table.data-table th, table.data-table td{ padding:6px 10px; border-bottom:1px solid var(--line); }
table.data-table th{ color: var(--fg2); font-weight:600; text-align:left; }
.profile-tabs{ display:flex; gap:4px; flex-wrap:wrap; border-bottom:1px solid var(--line); padding-bottom:8px; margin-bottom:16px; }
.profile-tab{ display:flex; align-items:center; gap:6px; padding:8px 14px; background:transparent; border:1px solid transparent; border-radius:6px 6px 0 0; color:var(--fg2); cursor:pointer; font-size:.85rem; transition:background .15s, color .15s; white-space:nowrap; }
.profile-tab:hover{ background:rgba(0,255,255,.08); color:var(--fg); }
.profile-tab.active{ background:rgba(0,255,255,.12); border-color:var(--line); border-bottom-color:var(--bg); color:var(--fg); }
.node-card{ border:1px solid var(--line); border-radius:8px; padding:12px; margin-bottom:12px; }
.node-badge{ display:inline-block; background:rgba(0,255,255,.1); border:1px solid var(--line); border-radius:4px; padding:2px 8px; font-size:.75rem; margin:2px; }
.node-grid{ display:grid; grid-template-columns:auto 1fr; gap:2px 12px; font-size:.85rem; }
.node-grid dt{ color:var(--fg2); font-weight:normal; }
.node-grid dd{ margin:0; }
#adminMenuItem{ position: relative; }
#adminSubMenu .dropdown-item{ padding: 6px 16px; white-space: nowrap; }
/* Dark dropdown menus (fixes white Bootstrap default on callsign/admin menu) */
.dropdown-menu{ background:#111 !important; border-color:var(--line) !important; }
.dropdown-item{ color:var(--fg) !important; }
.dropdown-item:hover, .dropdown-item:focus{ background:rgba(0,255,255,.1) !important; color:var(--fg) !important; }
.dropdown-divider{ border-color:var(--line) !important; }
#adminSubMenu{ position:absolute; right:100%; left:auto; top:0; min-width:170px; background:#111; border:1px solid var(--line); border-radius:6px; padding:4px 0; list-style:none; margin:0; z-index:1050; }
@media (max-width:576px){ .profile-tab span{ display:none; } .profile-tab{ padding:8px 10px; } }
@media (max-width: 768px) {
.container, .container-fluid { padding-left: 10px; padding-right: 10px; }
table.data-table { font-size: .75rem; }
table.data-table th, table.data-table td { padding: 4px 6px; }
.node-grid { grid-template-columns: 1fr; gap: 1px 0; }
.node-grid dt { margin-top: 6px; }
.panel { padding: 12px; }
.btn-sm { font-size: .75rem; padding: 3px 8px; }
.mono { font-size: .8rem; word-break: break-all; }
}
@media (max-width: 480px) {
table.data-table th:nth-child(3), table.data-table td:nth-child(3),
table.data-table th:nth-child(5), table.data-table td:nth-child(5),
table.data-table th:nth-child(6), table.data-table td:nth-child(6) { display: none; }
}
<?= $extra_css ?? '' ?>
</style>
</head>
<body>
<nav class="navbar navbar-expand border-bottom">
<div class="container-fluid">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="/#clients">Reflector clients</a></li>
<li class="nav-item"><a class="nav-link" href="/#monitor">Monitor</a></li>
<li class="nav-item"><a class="nav-link" href="/#lastheard">Lastheard</a></li>
<li class="nav-item"><a class="nav-link" href="/#ctcss">CTCSS Table</a></li>
<li class="nav-item"><a class="nav-link" href="/#map">Map</a></li>
<li class="nav-item"><a class="nav-link" href="/help.html">Help</a></li>
</ul>
<button class="btn btn-outline-secondary btn-sm me-2" id="btnRegister" type="button">Register</button>
<button class="btn btn-outline-secondary btn-sm" id="btnSignIn" type="button">Sign in</button>
</div>
</nav>
<main class="content">
+443
View File
@@ -0,0 +1,443 @@
<?php
declare(strict_types=1);
function cfg(): array {
static $cfg = null;
if ($cfg !== null) return $cfg;
$cfg = require __DIR__ . '/config.php';
return $cfg;
}
// redis() / redis_connect() / redis_scan_mget() come from auth/lib/redis.php
// (copy of callmgr2/www-mumien/auth/lib/redis.php).
require __DIR__ . '/lib/redis.php';
// json_in/json_out, CSRF and rate limiting come from auth/lib/http.php (copy of
// callmgr2's); the rate-limit keys keep this site's namespace.
function rate_limit_key_prefix(): string { return rkey('ratelimit:'); }
require __DIR__ . '/lib/http.php';
require __DIR__ . '/lib/acl.php';
require __DIR__ . '/lib/html.php';
require __DIR__ . '/lib/shell.php';
function rkey(string $suffix): string {
$p = cfg()['redis']['prefix'] ?? '';
return $p . ltrim($suffix, ':');
}
function base_call(string $cs): string {
$cs = trim($cs);
$cs = preg_split('/[-(]/', $cs)[0] ?? $cs;
return strtoupper(trim($cs));
}
function norm_email(string $email): string {
return strtolower(trim($email));
}
function uuidv4(): string {
$d = random_bytes(16);
$d[6] = chr((ord($d[6]) & 0x0f) | 0x40);
$d[8] = chr((ord($d[8]) & 0x3f) | 0x80);
$hex = bin2hex($d);
return sprintf('%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
function token_id(): string {
return rtrim(strtr(base64_encode(random_bytes(12)), '+/', '-_'), '=');
}
function token_secret(): string {
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
function token_hash(string $secret): string {
return hash('sha256', $secret, false);
}
function start_session(): void {
$s = cfg()['session'];
$params = session_get_cookie_params();
session_name($s['name'] ?? 'svxportal_sess');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => $params['domain'] ?? '',
'secure' => (bool)($s['cookie_secure'] ?? false),
'httponly' => true,
'samesite' => $s['cookie_samesite'] ?? 'Lax',
]);
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
}
function set_session_user(string $uuid): void {
start_session();
session_regenerate_id(true);
$_SESSION['uid'] = $uuid;
}
function session_user(): ?string {
start_session();
$u = $_SESSION['uid'] ?? null;
return is_string($u) && $u !== '' ? $u : null;
}
function require_login(): string {
$u = session_user();
if (!$u) json_out(['ok'=>false,'error'=>'not_logged_in'], 401);
check_session_timeout(7200); // 2 hour idle timeout
// Banned users are cut off completely — also mid-session, as on the dmr site
$acct = acct_get($u);
if ((int)($acct['banned'] ?? 0) === 1 || is_call_banned((string)($acct['callsign'] ?? ''))) {
session_destroy();
json_out(['ok'=>false,'error'=>'Your account has been banned.'], 403);
}
return $u;
}
function acct_key(string $uuid): string { return rkey("acct:$uuid"); }
function acct_calls_key(string $uuid): string { return rkey("acct_calls:$uuid"); }
// Reverse index for extra callsigns: SET of account uuids per callsign. Unlike
// the unique call:<CS> login index, the same callsign (e.g. a club call) may
// sit on several accounts at once.
function call_accts_key(string $callsign_upper): string { return rkey("call_accts:$callsign_upper"); }
function acct_nodes_key(string $uuid): string { return rkey("acct_nodes:$uuid"); }
function email_index_key(string $email_lower): string { return rkey("acct_by_email:$email_lower"); }
function call_index_key(string $callsign_upper): string { return rkey("call:$callsign_upper"); }
function node_index_key(string $node_id): string { return rkey("node:$node_id"); }
function find_acct_by_login(string $userOrEmail): ?string {
$v = trim($userOrEmail);
if ($v === '') return null;
if (str_contains($v, '@')) {
$uuid = redis()->get(email_index_key(norm_email($v)));
return is_string($uuid) && $uuid !== '' ? $uuid : null;
}
$uuid = redis()->get(call_index_key(base_call($v)));
return is_string($uuid) && $uuid !== '' ? $uuid : null;
}
function acct_get(string $uuid): array {
$h = redis()->hGetAll(acct_key($uuid));
return is_array($h) ? $h : [];
}
// Account flags (8-bit, as on the dmr site): 1=unreg 2=reg 4=normal 8=lvl1 16=lvl2 32=lvl3.
// The flags ARE the admin system. Accounts without the field derive a value
// from the legacy email_verified/admin_level fields.
function acct_flags_from(array $h): int {
$f = $h['acct_flags'] ?? null;
if ($f !== null && $f !== false && $f !== '') return ((int)$f) & 0x3F;
if ((string)($h['email_verified'] ?? '0') !== '1') return 1;
$flags = 2 | 4;
$legacy = (int)($h['admin_level'] ?? 1);
if ($legacy === 2) $flags |= 16;
if ($legacy >= 3) $flags |= 32;
return $flags;
}
function acct_flags(string $uuid): int { return acct_flags_from(acct_get($uuid)); }
function acct_level_from_flags(int $flags): int {
if ($flags & 32) return 3;
if ($flags & (8|16)) return 2; // lvl1 and lvl2 are both acting admins
return 1;
}
// Fine-grained admin rank for the level hierarchy: lvl1=1, lvl2=2, lvl3=3, none=0.
function acct_admin_rank_from(int $flags): int {
if ($flags & 32) return 3;
if ($flags & 16) return 2;
if ($flags & 8) return 1;
return 0;
}
function acct_admin_rank(string $uuid): int { return acct_admin_rank_from(acct_flags($uuid)); }
function acct_admin_level(string $uuid): int {
$lvl = acct_level_from_flags(acct_flags($uuid));
return $lvl;
}
function require_admin(int $minLevel): string {
$uuid = require_login();
if (acct_admin_level($uuid) < $minLevel) json_out(['ok'=>false,'error'=>'forbidden'], 403);
return $uuid;
}
// All callsigns tied to an account: the primary callsign field plus any extra
// callsigns (club calls etc.) an admin has added to the acct_calls set.
function acct_all_calls(string $uuid): array {
$calls = [];
$cs = strtoupper(trim((string)(acct_get($uuid)['callsign'] ?? '')));
if ($cs !== '') $calls[] = $cs;
foreach (redis()->sMembers(acct_calls_key($uuid)) ?: [] as $c) {
$c = strtoupper(trim((string)$c));
if ($c !== '' && !in_array($c, $calls, true)) $calls[] = $c;
}
return $calls;
}
// A node belongs to a callsign when the callsign appears anywhere in the node
// name: SA7AUX matches SA7AUX, SA7AUX-TEST, SA7AUX_TEST, SA7AUX/TEST,
// SA7AUXTEST and XXSA7AUXXX. The node name itself is never rewritten — the
// variants are distinct nodes.
function calls_match_node(array $calls, string $cs): bool {
$node = strtoupper(trim($cs));
if ($node === '') return false;
foreach ($calls as $c) {
if ($c !== '' && preg_match('/' . preg_quote($c, '/') . '/', $node)) return true;
}
return false;
}
function link_callsign(string $uuid, string $callsign): void {
$cs = base_call($callsign);
if ($cs === '') throw new RuntimeException('invalid_callsign');
$idx = call_index_key($cs);
$old = redis()->get($idx);
if (is_string($old) && $old !== '' && $old !== $uuid) throw new RuntimeException('callsign_taken');
redis()->set($idx, $uuid);
redis()->sAdd(acct_calls_key($uuid), $cs);
}
function link_node(string $uuid, string $nodeId): void {
$id = trim($nodeId);
if ($id === '') throw new RuntimeException('invalid_node');
$idx = node_index_key($id);
$old = redis()->get($idx);
if (is_string($old) && $old !== '' && $old !== $uuid) throw new RuntimeException('node_taken');
redis()->set($idx, $uuid);
redis()->sAdd(acct_nodes_key($uuid), $id);
}
// --- Node ownership ---
function pki_certs_dir(): string {
static $dir = null;
if ($dir !== null) return $dir;
$dir = '/etc/svxlink/pki/certs';
foreach (@file('/etc/svxlink/svxreflector.conf', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if (preg_match('/^CERT_CA_CERTS_DIR\s*=\s*(.+)$/', trim($line), $m)) { $dir = trim($m[1]); break; }
}
$dir = rtrim($dir, '/');
return $dir;
}
// Email carried in a node's client cert (subjectAltName, falling back to the
// subject's emailAddress), or '' if the node has no cert / no email in it.
function node_cert_email(string $cs): string {
$f = pki_certs_dir() . "/{$cs}.crt";
if (!is_file($f)) return '';
$x = @openssl_x509_parse((string)@file_get_contents($f));
if (!$x) return '';
$san = (string)($x['extensions']['subjectAltName'] ?? '');
if (preg_match('/email:([^,\s]+)/i', $san, $m)) return norm_email($m[1]);
$e = (string)($x['subject']['emailAddress'] ?? '');
return $e !== '' ? norm_email($e) : '';
}
// Pending-CSR and signed-CSR directories, resolved from svxreflector.conf the
// same way as pki_certs_dir().
function pki_conf_dir(string $key, string $fallback): string {
$dir = $fallback;
foreach (@file('/etc/svxlink/svxreflector.conf', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if (preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(.+)$/', trim($line), $m)) { $dir = trim($m[1]); break; }
}
return rtrim($dir, '/');
}
function pki_pending_dir(): string { return pki_conf_dir('CERT_CA_PENDING_CSRS_DIR', '/etc/svxlink/pki/pending_csrs'); }
// Path safety for node callsigns used as cert/CSR file names — same rule as the
// reflector's isCallsignPathSafe(): never '/', '..' or NUL.
function node_callsign_path_safe(string $cs): bool {
return $cs !== '' && strlen($cs) <= 64
&& !str_contains($cs, '/') && !str_contains($cs, '..') && !str_contains($cs, "\0");
}
// Validate a node callsign for portal operations: file-name safety only.
// Which names may exist on the network is the reflector's business — the live
// network has trunk/bridge nodes (SE-EASTCOAST, stream_test, SA7AUX-DMRBR)
// that no callsign-format regex covers, so the portal must not be stricter.
// Callsign check shared by the node/reflector APIs; a caller may pass its own
// stricter validator (node_cmd_api: amateur-format regex).
function valid_callsign(string $cs): bool { return node_callsign_valid($cs); }
function require_callsign(array $in, ?callable $validator = null): string {
$cs = strtoupper(trim((string)($in['callsign'] ?? '')));
$ok = $validator ? $validator($cs) : valid_callsign($cs);
if ($cs === '' || !$ok) json_out(['ok' => false, 'error' => 'invalid_callsign'], 400);
return $cs;
}
function node_callsign_valid(string $cs): bool {
return node_callsign_path_safe($cs)
&& preg_match('/^[A-Za-z0-9._-]{1,64}$/', $cs) === 1;
}
// Email carried in a pending CSR's subjectAltName (or subject emailAddress), '' if none.
function node_csr_email(string $cs): string {
$f = pki_pending_dir() . "/{$cs}.csr";
if (!is_file($f)) return '';
$out = [];
@exec('openssl req -in ' . escapeshellarg($f) . ' -noout -text 2>/dev/null', $out);
$text = implode("\n", $out);
if (preg_match('/email:([^,\s]+)/i', $text, $m)) return norm_email($m[1]);
if (preg_match('/emailAddress\s*=\s*([^,\s\/]+)/i', $text, $m)) return norm_email($m[1]);
return '';
}
// Run a CA command through the reflector PTY helper (svxlink-owned). true on
// success, or an error string.
function reflector_ca(string $sub, string $cs): true|string {
$sub = strtoupper($sub);
if (!in_array($sub, ['SIGN', 'RM'], true)) return 'bad CA subcommand';
if (!node_callsign_path_safe($cs) || preg_match('/\s/', $cs)) return 'bad callsign';
$cmd = 'sudo -u svxlink /usr/local/sbin/svxreflector-pty CA ' . escapeshellarg($sub) . ' ' . escapeshellarg($cs);
exec($cmd . ' 2>&1', $out, $rc);
return $rc === 0 ? true : (implode("\n", $out) ?: 'PTY write failed');
}
// --- Per-email certificate renewal counter ---
// 0 = new (never issued), 1 = admin issued the first cert, >=1 lets the account
// self-serve renewals. The count is keyed by the account/cert email.
function cert_email_key(string $email): string { return rkey('cert_email:' . norm_email($email)); }
function cert_email_ensure(string $email): void {
$e = norm_email($email);
if ($e === '') return;
$k = cert_email_key($e);
if (!redis()->exists($k)) redis()->hMSet($k, ['email' => $e, 'count' => '0']);
}
function cert_email_count(string $email): int {
$e = norm_email($email);
if ($e === '') return 0;
$c = redis()->hGet(cert_email_key($e), 'count');
return ($c === false || $c === null) ? 0 : (int)$c;
}
function cert_email_bump(string $email): int {
$e = norm_email($email);
if ($e === '') return 0;
$k = cert_email_key($e);
redis()->hSet($k, 'email', $e);
return (int)redis()->hIncrBy($k, 'count', 1);
}
// --- Per-node certificate issue counter ---
// Same semantics as the per-email counter, keyed by the full node callsign:
// 0 = an admin has never issued a cert for this node, >=1 = the owner may
// self-serve renewals. Bumped on every sign (admin or self), never decremented
// so the first-issue gate survives a remove->reissue cycle.
function cert_node_key(string $cs): string { return rkey('cert_node:' . strtoupper(trim($cs))); }
function cert_node_ensure(string $cs): void {
$c = strtoupper(trim($cs));
if ($c === '') return;
$k = cert_node_key($c);
if (!redis()->exists($k)) redis()->hMSet($k, ['call' => $c, 'count' => '0']);
}
function cert_node_count(string $cs): int {
$c = strtoupper(trim($cs));
if ($c === '') return 0;
$v = redis()->hGet(cert_node_key($c), 'count');
return ($v === false || $v === null) ? 0 : (int)$v;
}
function cert_node_bump(string $cs): int {
$c = strtoupper(trim($cs));
if ($c === '') return 0;
$k = cert_node_key($c);
redis()->hSet($k, 'call', $c);
return (int)redis()->hIncrBy($k, 'count', 1);
}
// A user "owns" a node if they're a site admin, one of their callsigns (primary
// or admin-added extra/club calls) appears in the node name, they're a listed
// node admin, or the node's client cert carries their verified account email.
function user_owns_node(string $uuid, string $cs): bool {
if (acct_admin_level($uuid) >= 2) return true;
$me = acct_get($uuid);
$mycalls = acct_all_calls($uuid);
if (calls_match_node($mycalls, $cs)) return true;
$admins = redis()->sMembers(rkey("node_admins:$cs")) ?: [];
foreach ($mycalls as $c) {
if (in_array($c, $admins, true)) return true;
}
if (!empty($me['email']) && !empty($me['email_verified'])) {
$ce = node_cert_email($cs);
if ($ce !== '' && $ce === norm_email((string)$me['email'])) return true;
}
return false;
}
// --- Global callsign bans ---
// A Redis SET of base callsigns (like the DMR site's banned_callsigns set).
// A banned callsign is blocked on the portal — it cannot register or sign in.
function global_bans_key(): string { return rkey('banned_callsigns'); }
function is_call_banned(string $callsign): bool {
$cs = base_call($callsign);
if ($cs === '') return false;
return (bool) redis()->sIsMember(global_bans_key(), $cs);
}
function mail_send(string $to, string $subject, string $body): bool {
$m = cfg()['mail'] ?? [];
$from = $m['from'] ?? 'no-reply@example.com';
// Direct SSL SMTP when a relay is configured (host+user) — needs no local MTA
// (/usr/sbin/sendmail), which many boxes don't have. Same approach as the
// callmgr portal. Falls back to PHP mail() where no SMTP is configured (e.g.
// a box with a working msmtp/sendmail).
$host = $m['host'] ?? '';
$user = $m['user'] ?? '';
if ($host !== '' && $user !== '') {
return smtp_send_ssl($host, (int)($m['port'] ?? 465), $user, (string)($m['pass'] ?? ''),
$from, (string)($m['from_name'] ?? 'SvxPortal'), $to, $subject, $body);
}
$headers = "From: $from\r\nContent-Type: text/plain; charset=UTF-8\r\n";
return @mail($to, $subject, $body, $headers);
}
// Minimal SSL SMTP client (AUTH LOGIN). Returns true only when the server
// accepts the message. No dependency on a local MTA.
function smtp_send_ssl(string $host, int $port, string $user, string $pass,
string $from, string $from_name, string $to,
string $subject, string $body): bool {
$ctx = stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
$fp = @stream_socket_client("ssl://{$host}:{$port}", $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $ctx);
if (!$fp) { error_log("smtp: connect failed $host:$port $errstr"); return false; }
$read = function() use ($fp): string { return fgets($fp, 4096) ?: ''; };
$write = function(string $c) use ($fp): void { fwrite($fp, $c . "\r\n"); };
$expect = function(string $code) use ($read): bool { return str_starts_with($read(), $code); };
if (!$expect('220')) { fclose($fp); return false; }
$write("EHLO svxportal");
do { $line = $read(); } while ($line !== '' && strlen($line) > 3 && $line[3] === '-');
$write("AUTH LOGIN"); if (!$expect('334')) { fclose($fp); return false; }
$write(base64_encode($user)); if (!$expect('334')) { fclose($fp); return false; }
$write(base64_encode($pass)); if (!$expect('235')) { fclose($fp); error_log("smtp: auth failed"); return false; }
$write("MAIL FROM:<{$from}>"); if (!$expect('250')) { fclose($fp); return false; }
$write("RCPT TO:<{$to}>"); if (!$expect('250')) { fclose($fp); return false; }
$write("DATA"); if (!$expect('354')) { fclose($fp); return false; }
$msg = "From: {$from_name} <{$from}>\r\n";
$msg .= "To: {$to}\r\n";
$msg .= "Subject: {$subject}\r\n";
$msg .= "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n";
$msg .= "Date: " . date('r') . "\r\n\r\n";
$msg .= $body . "\r\n.";
$write($msg); if (!$expect('250')) { fclose($fp); return false; }
$write("QUIT"); fclose($fp);
return true;
}
// --- Session timeout ---
function check_session_timeout(int $max_idle = 3600): void {
start_session();
if (isset($_SESSION['last_activity']) && (time() - (int)$_SESSION['last_activity']) > $max_idle) {
session_unset();
session_destroy();
json_out(['ok' => false, 'error' => 'session_expired'], 401);
}
$_SESSION['last_activity'] = time();
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
// acl.php — shared PHP helper for every web (dmr-web, svx-web).
// Canonical copy: callmgr2/www-mumien/auth/lib/acl.php; other repos carry a COPY.
// Admin-on-admin checks. Needs acct_admin_level() and json_out() from the site.
// An admin may change another account only when their own level is strictly
// higher (and may always change themselves).
function require_privilege(string $acting_uuid, int $acting_level, string $target_uuid): void {
if ($target_uuid === $acting_uuid) return;
$target_level = acct_admin_level($target_uuid);
if ($target_level >= $acting_level) {
json_out(['ok' => false, 'error' => 'insufficient_privilege'], 403);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
// html.php — shared PHP helper for every web (dmr-web, svx-web, svxlink-web).
// Canonical copy: callmgr2/www-mumien/auth/lib/html.php; other repos carry a COPY.
// HTML escaping for server-rendered pages.
// Escape for HTML text and attribute context (quotes included, invalid UTF-8 replaced).
function h(?string $s): string {
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
// http.php — shared PHP helper for every web (dmr-web, svx-web).
// Canonical copy: callmgr2/www-mumien/auth/lib/http.php; other repos carry a COPY.
// JSON request/response, CSRF check, Redis-backed rate limiting.
// Loaded by auth/lib.php; needs cfg() from the page's own lib.php.
function json_in(): array {
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') return [];
$j = json_decode($raw, true);
return is_array($j) ? $j : [];
}
function json_out(array $obj, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
// V5-14F63: prevent browser heuristic caching — every endpoint returns
// live data; stale-from-cache breaks SPA pages that re-fetch on nav.
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
echo json_encode($obj, JSON_UNESCAPED_SLASHES);
exit;
}
function csrf_token(): string {
start_session();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_check(): void {
start_session();
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$expected = $_SESSION['csrf_token'] ?? '';
session_write_close(); // release the lock immediately after reading
if ($token === '' || $expected === '' || !hash_equals($expected, $token)) {
json_out(['ok' => false, 'error' => 'invalid_csrf_token'], 403);
}
}
// Rate-limit key namespace per site: a site may define rate_limit_key_prefix()
// (svx-web uses its own rkey() namespace); the default is callmgr's.
function rate_limit_key_prefix_(): string {
return function_exists('rate_limit_key_prefix') ? rate_limit_key_prefix() : '/callmgr/ratelimit/';
}
function rate_limit(string $key, int $max_attempts, int $window_seconds): void {
$r = redis();
$rk = rate_limit_key_prefix_() . $key;
$count = (int)$r->get($rk);
if ($count >= $max_attempts) {
$ttl = $r->ttl($rk);
json_out(['ok' => false, 'error' => "Too many attempts. Try again in {$ttl} seconds."], 429);
}
$r->incr($rk);
if ($count === 0) {
$r->expire($rk, $window_seconds);
}
}
function rate_limit_clear(string $key): void {
redis()->del(rate_limit_key_prefix_() . $key);
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
// redis.php — shared PHP helper for every web (dmr-web, svx-web).
// Canonical copy: callmgr2/www-mumien/auth/lib/redis.php; other repos carry a COPY.
// Redis connections (main + aux) and the batched SCAN+MGET read — the only way to read many keys.
// Loaded by auth/lib.php; needs cfg() from the page's own lib.php.
// One connection from a config block {unix_socket|host,port, password, db}.
// $read_timeout > 0 keeps a blocking SUBSCRIBE alive (lastheard relay: 86400 s).
function redis_connect(array $c, float $read_timeout = 0.0): Redis {
$r = new Redis();
if (!empty($c['unix_socket'])) {
$r->connect($c['unix_socket'], 0, 0.0, null, 0, $read_timeout);
} else {
$r->connect($c['host'], (int)$c['port'], 0.0, null, 0, $read_timeout);
}
if (!empty($c['password'])) $r->auth($c['password']);
if (isset($c['db'])) $r->select((int)$c['db']);
return $r;
}
function redis(): Redis {
static $r = null;
if ($r instanceof Redis) return $r;
$c = cfg()['redis'];
$r = new Redis();
if (!empty($c['unix_socket'])) {
$r->connect($c['unix_socket']);
} else {
$r->connect($c['host'], (int)$c['port']);
}
if (!empty($c['password'])) $r->auth($c['password']);
if (isset($c['db'])) $r->select((int)$c['db']);
return $r;
}
// V5-14F64: aux redis instance — daemon's APRS inbox/outbox lives here
// (same place stats/events go). Falls back to main redis if no aux config
// is present (older installs without redis-aux).
function redis_aux(): Redis {
static $r = null;
if ($r instanceof Redis) return $r;
$c = cfg()['redis_aux'] ?? null;
if (!$c) return redis(); // fallback: main socket
$r = new Redis();
if (!empty($c['unix_socket'])) {
$r->connect($c['unix_socket']);
} else {
$r->connect($c['host'], (int)$c['port']);
}
if (!empty($c['password'])) $r->auth($c['password']);
if (isset($c['db'])) $r->select((int)$c['db']);
return $r;
}
// Every key matching $pattern in ONE SCAN sweep (the only way to enumerate
// keys: no KEYS, no per-page loops copied around). Returns [] on error.
function redis_scan_keys(Redis $r, string $pattern, int $count = 500): array {
$keys = [];
$it = null;
do {
$batch = $r->scan($it, $pattern, $count);
if (is_array($batch)) {
foreach ($batch as $k) $keys[] = $k;
}
} while ($it > 0);
return $keys;
}
// Batch-read every key under $scan_pattern: ONE SCAN sweep + one MGET, keyed
// by the part after $base_prefix. With ~700k keys in the instance this is the
// difference between 2 round-trips and one per key.
function redis_scan_mget(Redis $r, string $base_prefix, string $scan_pattern): array {
$keys = redis_scan_keys($r, $scan_pattern, 200);
$out = [];
if (!empty($keys)) {
$vals = $r->mGet($keys);
foreach ($keys as $i => $k) {
$rel = substr($k, strlen($base_prefix));
$v = $vals[$i] ?? false;
$out[$rel] = is_string($v) ? $v : '';
}
}
ksort($out);
return $out;
}
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
// shell.php — shared PHP helper for every web (dmr-web, svx-web, svxlink-web).
// Canonical copy: callmgr2/www-mumien/auth/lib/shell.php; other repos carry a COPY.
// Shell-argument quoting for the few pages that run local helpers.
function sh_arg($v): string { return escapeshellarg((string)$v); }
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$j = json_in();
$user = trim((string)($j['user'] ?? ''));
$pass = (string)($j['pass'] ?? '');
if ($user === '' || $pass === '') json_out(['ok'=>false,'error'=>'missing'], 400);
// Rate limit: 5 attempts per 5 minutes per IP
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
rate_limit("login:{$ip}", 5, 300);
$uuid = find_acct_by_login($user);
if (!$uuid) json_out(['ok'=>false,'error'=>'invalid_login'], 401);
$acct = acct_get($uuid);
if (!$acct) json_out(['ok'=>false,'error'=>'invalid_login'], 401);
// Check global callsign ban (blocks the callsign on the portal)
if (is_call_banned((string)($acct['callsign'] ?? ''))) {
json_out(['ok'=>false, 'error'=>'Your callsign has been banned.'], 403);
}
// Check banned
if ((int)($acct['banned'] ?? 0) === 1) {
$reason = $acct['ban_reason'] ?? '';
$msg = 'Your account has been banned.';
if ($reason !== '') $msg .= ' Reason: ' . $reason;
json_out(['ok'=>false, 'error'=>$msg], 403);
}
// Check locked
if ((int)($acct['locked'] ?? 0) === 1) {
$reason = $acct['lock_reason'] ?? '';
$msg = 'Your account is locked.';
if ($reason !== '') $msg .= ' Reason: ' . $reason;
json_out(['ok'=>false, 'error'=>$msg], 403);
}
// Check email verified
if (!isset($acct['email_verified']) || (int)$acct['email_verified'] !== 1) {
json_out(['ok'=>false,'code'=>'unverified','error'=>'Your email address has not been verified. Check your inbox for the verification link.'], 403);
}
// Check approved: accounts with an acct_flags field need the normal-bit (4) to get in.
// Accounts from before the flags system have no field and pass as before.
$flags = ($acct['acct_flags'] ?? '') === '' ? null : ((int)$acct['acct_flags'] & 0x3F);
if ($flags !== null && ($flags & 4) === 0) {
json_out(['ok'=>false,'code'=>'unapproved','error'=>'Your account is registered and waiting for an admin to approve it before you can log in.'], 403);
}
$hash = (string)($acct['password_hash'] ?? '');
if ($hash === '' || !password_verify($pass, $hash)) {
json_out(['ok'=>false,'error'=>'invalid_login'], 401);
}
rate_limit_clear("login:{$ip}");
set_session_user($uuid);
redis()->hSet(acct_key($uuid), 'last_login_at', (string)time());
json_out(['ok'=>true]);
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
start_session();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time()-3600, $p['path'] ?? '/', $p['domain'] ?? '', (bool)($p['secure'] ?? false), true);
}
session_destroy();
header('Location: /svxportal/');
exit;
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
start_session();
$uuid = $_SESSION['uid'] ?? null;
if (!is_string($uuid) || $uuid === '') {
json_out(['ok'=>false,'logged_in'=>false]);
}
$acct = acct_get($uuid);
if (!$acct) {
json_out(['ok'=>false,'logged_in'=>false]);
}
unset($acct['password_hash']);
$acct['ok'] = true;
$acct['logged_in'] = true;
$acct['uuid'] = $uuid;
$acct['flags'] = acct_flags_from($acct);
$acct['admin_level'] = acct_level_from_flags($acct['flags']);
// Fine-grained rank so the UI can tell LVL1 (read-only) from LVL2 — admin_level
// collapses both to 2. lvl1=1, lvl2=2, lvl3=3, none=0.
$acct['admin_rank'] = acct_admin_rank_from($acct['flags']);
$acct['csrf_token'] = csrf_token();
json_out($acct);
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$uuid = require_login();
$me = acct_get($uuid);
$my_calls = acct_all_calls($uuid); // primary + admin-added extra/club calls
$my_email = strtolower(trim($me['email'] ?? ''));
if (!$my_calls && $my_email === '') {
json_out(['ok' => true, 'nodes' => []]);
}
// Fetch reflector status via cURL
$ch = curl_init('http://127.0.0.1:8098/api/status');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 2,
]);
$json = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($json === false || $json === '') {
json_out(['ok' => false, 'error' => 'Cannot reach reflector API' . ($err ? ": $err" : '')], 502);
}
$data = json_decode($json, true);
if (!is_array($data) || !isset($data['nodes'])) {
json_out(['ok' => false, 'error' => 'Invalid reflector response'], 502);
}
$r = redis();
// Match nodes belonging to this user
$my_nodes = [];
foreach ($data['nodes'] as $node_call => $node_info) {
// Only LOCAL client nodes: skip remote trunk nodes (other reflectors'
// clients relayed over trunks) and bridge/gateway infrastructure.
if (!empty($node_info['remoteTrunk'])) continue;
if (stripos((string)($node_info['sw'] ?? ''), 'bridge') !== false) continue;
$sysop = strtoupper(trim($node_info['sysop'] ?? ''));
$match = false;
if ($my_calls && $sysop !== '' && in_array($sysop, $my_calls, true)) $match = true;
if (!$match && $my_calls && calls_match_node($my_calls, (string)$node_call)) $match = true;
// Also match if user is a node admin
if (!$match && $my_calls) {
$admins = $r->sMembers(rkey("node_admins:" . strtoupper($node_call)));
if (is_array($admins)) {
foreach ($my_calls as $c) {
if (in_array($c, $admins, true)) { $match = true; break; }
}
}
}
if ($match) {
// Add node admins info — sysop is always default admin
$stored_admins = $r->sMembers(rkey("node_admins:" . strtoupper($node_call)));
if (!is_array($stored_admins)) $stored_admins = [];
$all_admins = $stored_admins;
if ($sysop !== '' && !in_array($sysop, $all_admins, true)) {
array_unshift($all_admins, $sysop);
}
sort($all_admins);
$node_info['node_admins'] = $all_admins;
$my_nodes[$node_call] = $node_info;
}
}
json_out(['ok' => true, 'nodes' => $my_nodes]);
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
// Manage a node's admin list (Redis set node_admins:<CALL>). The node's sysop is
// always the implicit default admin; this stores ADDITIONAL admins.
//
// Owners (user_owns_node) may LIST the admins of their nodes; only site admins
// (admin_level >= 2) may add or remove entries — users must not grant
// themselves or others admin rights.
require __DIR__ . '/lib.php';
$uuid = require_login();
csrf_check();
$in = json_in();
$action = strtolower(trim((string)($in['action'] ?? 'list')));
function na_valid_call(string $cs): bool {
return (bool)preg_match('/^[A-Z0-9]{1,3}[0-9][A-Z0-9]{0,3}[A-Z](-[A-Z0-9]{1,10})?$/', $cs);
}
$node = strtoupper(trim((string)($in['node'] ?? '')));
$admin = strtoupper(trim((string)($in['admin'] ?? '')));
if ($node === '' || !node_callsign_valid($node)) {
json_out(['ok' => false, 'error' => 'invalid_node'], 400);
}
$r = redis();
$key = rkey("node_admins:$node");
// Listing requires ownership (callsign/email/node-admin — user_owns_node).
if (!user_owns_node($uuid, $node)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$stored = $r->sMembers($key) ?: [];
if ($action === 'add' || $action === 'remove') {
// Changing the list is site-admin only.
if (acct_admin_level($uuid) < 2) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
if ($admin === '' || !na_valid_call($admin)) {
json_out(['ok' => false, 'error' => 'invalid_admin'], 400);
}
if ($action === 'add') {
$r->sAdd($key, $admin);
} else {
$r->sRem($key, $admin);
}
$stored = $r->sMembers($key) ?: [];
} elseif ($action !== 'list') {
json_out(['ok' => false, 'error' => 'invalid_action'], 400);
}
sort($stored);
json_out(['ok' => true, 'node' => $node, 'admins' => $stored]);
+326
View File
@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
// Reflector node authentication: manage login/password nodes ([USERS]/[PASSWORDS]
// in svxreflector.conf, edited only via the scoped sudo helper svxreflector-users)
// and list them together with cert-authenticated nodes. Cert nodes are linked to
// portal accounts by callsign OR email — either one in the cert/CSR is enough:
// the CN (node name) matched against the account's callsigns (contains-match,
// calls_match_node in lib.php) or the subjectAltName email matched against the
// account's verified email.
//
// list -> unified node list; site admins see all, others their own
// add {node, owner?} -> site admin only; generates the password, returned once
// getpw {node} -> owner/node admin/site admin; show the current password
// (the conf stores it in plaintext — same trust model as
// the MQTT credentials, which are also shown on demand)
// resetpw {node} -> owner/node admin/site admin; generate a new password
// del {node} -> site admin only
require __DIR__ . '/lib.php';
$uuid = require_login();
csrf_check();
$in = json_in();
$action = strtolower(trim((string)($in['action'] ?? 'list')));
// Node callsigns are validated the way the reflector does it: against the
// ACCEPT_CALLSIGN/REJECT_CALLSIGN regexes in svxreflector.conf plus path
// safety (never '/', '..' or NUL — cert/CSR files are named <callsign>.<ext>).
// valid_callsign() comes from auth/lib.php.
// Run the gateway script. The password never touches argv: it's passed via a 0600
// temp file the helper reads and shreds (same pattern as svx-mqtt-provision).
function users_ctl(string $sub, string $cs = '', ?string $pw = null): array {
$cmd = 'sudo /usr/local/sbin/svxreflector-users ' . escapeshellarg($sub);
if ($cs !== '') $cmd .= ' ' . escapeshellarg($cs);
$tmp = '';
if ($pw !== null) {
$tmp = tempnam(sys_get_temp_dir(), 'sru');
file_put_contents($tmp, $pw);
chmod($tmp, 0600);
$cmd .= ' ' . escapeshellarg($tmp);
}
exec($cmd . ' 2>&1', $out, $rc);
if ($tmp !== '' && is_file($tmp)) @unlink($tmp); // helper shreds it; clean up if it failed early
return [$rc, trim(implode("\n", $out))];
}
function gen_password(): string {
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$pw = '';
for ($i = 0; $i < 20; $i++) $pw .= $chars[random_int(0, 61)];
return $pw;
}
function require_node(array $in): string {
$cs = strtoupper(trim((string)($in['node'] ?? '')));
if ($cs === '' || !valid_callsign($cs)) json_out(['ok' => false, 'error' => 'invalid_node'], 400);
return $cs;
}
$is_admin = acct_admin_level($uuid) >= 2;
// The caller's own verified account email (used to link certs/CSRs to them for
// self-service). Empty if the account email is not verified.
$me_acct = acct_get($uuid);
$my_email = (!empty($me_acct['email']) && (int)($me_acct['email_verified'] ?? 0) === 1)
? norm_email((string)$me_acct['email']) : '';
// Admins may view another account's reflector logins by passing its uuid as
// 'target' (used by the admin account-edit page to show the same section as the
// profile). Ownership matching is then scoped to that account.
$target_uuid = $is_admin ? trim((string)($in['target'] ?? '')) : '';
if ($target_uuid !== '') {
$tgt = acct_get($target_uuid);
$view_calls = acct_all_calls($target_uuid);
$view_email = (!empty($tgt['email']) && (int)($tgt['email_verified'] ?? 0) === 1)
? norm_email((string)$tgt['email']) : '';
} else {
$view_calls = acct_all_calls($uuid);
$view_email = $my_email;
}
// Ownership WITHOUT the site-admin-sees-all shortcut: one of the viewed
// account's callsigns appears in the node name, the account is a listed node
// admin, or the given cert/CSR email matches the account's verified email.
$owned_by_view = function (string $cs, string $node_email) use ($view_calls, $view_email): bool {
if (calls_match_node($view_calls, $cs)) return true;
$adm = redis()->sMembers(rkey("node_admins:$cs")) ?: [];
foreach ($view_calls as $c) {
if ($c !== '' && in_array($c, $adm, true)) return true;
}
return $view_email !== '' && $node_email !== '' && $node_email === $view_email;
};
// Whether the viewed account owns a node — callsign match, node-admin
// membership, or its verified email in the node's cert.
$view_owns = function (string $cs) use ($target_uuid, $is_admin, $uuid, $owned_by_view): bool {
// Own profile with no target: admins see everything, users see their own.
if ($target_uuid === '') {
if ($is_admin) return true;
return user_owns_node($uuid, $cs);
}
return $owned_by_view($cs, node_cert_email($cs));
};
switch ($action) {
// ---- Unified node list: password nodes from the conf + cert nodes from the PKI ----
case 'list':
[$rc, $out] = users_ctl('list');
if ($rc !== 0) json_out(['ok' => false, 'error' => 'list_failed', 'detail' => $out], 502);
$nodes = [];
foreach (array_filter(array_map('trim', explode("\n", $out))) as $cs) {
$nodes[$cs] = ['node' => $cs, 'password' => true, 'cert' => false];
}
foreach (glob(pki_certs_dir() . '/*.crt') ?: [] as $f) {
$cs = basename($f, '.crt');
if (str_starts_with($cs, 'svxreflector_') || str_starts_with($cs, 'SvxReflector_')) continue;
$nodes[$cs] ??= ['node' => $cs, 'password' => false, 'cert' => false];
$nodes[$cs]['cert'] = true;
$x = @openssl_x509_parse((string)@file_get_contents($f));
$ce = node_cert_email($cs);
$nodes[$cs]['email'] = $ce;
$nodes[$cs]['validTo'] = (int)($x['validTo_time_t'] ?? 0);
if ($ce !== '') cert_email_ensure($ce);
cert_node_ensure($cs);
$nodes[$cs]['renewals'] = max($ce !== '' ? cert_email_count($ce) : 0, cert_node_count($cs));
$nodes[$cs]['owned'] = $owned_by_view($cs, $ce);
}
$list = [];
foreach ($nodes as $cs => $n) {
if (!$view_owns($cs)) continue;
$admins = redis()->sMembers(rkey("node_admins:$cs")) ?: [];
sort($admins);
$n['admins'] = $admins;
$list[] = $n;
}
usort($list, fn($a, $b) => strcmp($a['node'], $b['node']));
// Pending CSRs the viewed account may act on: matched by callsign OR
// verified email (or node-admin membership). These appear as
// "certificate request" rows so the owner (or an admin) can
// accept/issue the renewal once the first cert was issued.
$pending = [];
foreach (glob(pki_pending_dir() . '/*.csr') ?: [] as $f) {
$cs = basename($f, '.csr');
if (str_starts_with($cs, 'svxreflector_') || str_starts_with($cs, 'SvxReflector_')) continue;
$ce = node_csr_email($cs);
$mine = $owned_by_view($cs, $ce);
if (!$view_owns($cs) && !$mine) continue;
if ($ce !== '') cert_email_ensure($ce);
cert_node_ensure($cs);
$cnt = max($ce !== '' ? cert_email_count($ce) : 0, cert_node_count($cs));
$pending[] = [
'node' => $cs,
'email' => $ce,
'renewals' => $cnt,
'mine' => $mine,
// The owner self-signs once count>=1; an admin viewing may always sign.
'canSelfSign' => ($mine && $cnt >= 1) || ($target_uuid !== '' && $is_admin),
'canReject' => ($target_uuid !== '' && $is_admin),
];
}
json_out(['ok' => true, 'nodes' => $list, 'pending' => $pending, 'is_admin' => $is_admin]);
// ---- Create a password node (site admin only); password shown once.
// Optional 'password' sets a chosen one (validated); omitted = generated.
// Optional 'admins' (array of callsigns) become node admins. ----
case 'add':
if (!$is_admin) json_out(['ok' => false, 'error' => 'forbidden'], 403);
$cs = require_node($in);
$pw = trim((string)($in['password'] ?? ''));
if ($pw !== '' && !preg_match('#^[A-Za-z0-9._!%&@\#*+=:/-]{8,64}$#', $pw))
json_out(['ok' => false, 'error' => 'bad_password', 'detail' => '8-64 chars: letters, digits, ._!%&@#*+=:/-'], 400);
if ($pw === '') $pw = gen_password();
[$rc, $out] = users_ctl('add', $cs, $pw);
if ($rc !== 0) json_out(['ok' => false, 'error' => 'add_failed', 'detail' => $out], 502);
// Optional admins — need not share the node's callsign; record explicitly.
foreach ((array)($in['admins'] ?? []) as $adm) {
$adm = strtoupper(trim((string)$adm));
if ($adm !== '' && valid_callsign($adm)) redis()->sAdd(rkey("node_admins:$cs"), $adm);
}
json_out(['ok' => true, 'node' => $cs, 'password' => $pw]);
// ---- Show the current password (owner/node admin/site admin) ----
case 'getpw':
$cs = require_node($in);
if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403);
[$rc, $out] = users_ctl('getpw', $cs);
if ($rc !== 0) json_out(['ok' => false, 'error' => 'getpw_failed', 'detail' => $out], 502);
json_out(['ok' => true, 'node' => $cs, 'password' => $out]);
// ---- Set/generate password for an existing node (owner/node admin/site admin).
// Optional 'password' sets a chosen one; omitted = generate. ----
case 'resetpw':
$cs = require_node($in);
if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403);
$pw = trim((string)($in['password'] ?? ''));
if ($pw !== '' && !preg_match('#^[A-Za-z0-9._!%&@\#*+=:/-]{8,64}$#', $pw))
json_out(['ok' => false, 'error' => 'bad_password', 'detail' => '8-64 chars: letters, digits, ._!%&@#*+=:/-'], 400);
if ($pw === '') $pw = gen_password();
[$rc, $out] = users_ctl('setpw', $cs, $pw);
if ($rc !== 0) json_out(['ok' => false, 'error' => 'resetpw_failed', 'detail' => $out], 502);
json_out(['ok' => true, 'node' => $cs, 'password' => $pw]);
// ---- Self-service: accept/issue a pending CSR for your own cert ----
// Allowed when the caller owns the CSR — its callsign matches one of the
// account's callsigns OR its email matches the verified account email (or
// the caller is a node admin) — AND an admin has already issued the first
// cert (either counter >= 1). Admins may sign any pending CSR. Signing
// goes through the reflector PTY.
case 'self_sign':
$cs = require_node($in);
if (!is_file(pki_pending_dir() . "/{$cs}.csr")) json_out(['ok' => false, 'error' => 'no_pending_csr'], 404);
$ce = node_csr_email($cs);
if (!$is_admin) {
if (!$owned_by_view($cs, $ce)) json_out(['ok' => false, 'error' => 'not_your_csr'], 403);
$cnt = max($ce !== '' ? cert_email_count($ce) : 0, cert_node_count($cs));
if ($cnt < 1) json_out(['ok' => false, 'error' => 'first_issue_needs_admin'], 403);
}
$r = reflector_ca('SIGN', $cs);
if ($r !== true) json_out(['ok' => false, 'error' => 'sign_failed', 'detail' => $r], 502);
$n = cert_node_bump($cs);
if ($ce !== '') $n = max($n, cert_email_bump($ce));
json_out(['ok' => true, 'node' => $cs, 'renewals' => $n]);
// ---- Revoke — remove the cert (CA RM) but KEEP the renewal counters, so
// the node can re-issue/renew. Allowed for a callsign-admin/owner of the
// node (callsign match, node-admin membership, verified email) OR any site
// admin (lvl >= 1). A plain user with no relation to the node cannot. ----
case 'self_revoke':
case 'revoke':
$cs = require_node($in);
if (!is_file(pki_certs_dir() . "/{$cs}.crt")) json_out(['ok' => false, 'error' => 'no_cert'], 404);
if (acct_admin_level($uuid) < 1 && !$owned_by_view($cs, node_cert_email($cs)))
json_out(['ok' => false, 'error' => 'not_your_cert'], 403);
$r = reflector_ca('RM', $cs);
if ($r !== true) json_out(['ok' => false, 'error' => 'revoke_failed', 'detail' => $r], 502);
json_out(['ok' => true, 'node' => $cs]);
// ---- Delete certificate — PERMANENT full removal (lvl2-3 site admins only,
// NOT callsign-admins). Removes the signed cert + any pending CSR, resets the
// renewal counters and clears node-admin membership so nothing can re-issue. ----
case 'delete_cert':
$cs = require_node($in);
if (acct_admin_level($uuid) < 2) json_out(['ok' => false, 'error' => 'admin2_required'], 403);
$ce = node_cert_email($cs);
if (is_file(pki_certs_dir() . "/{$cs}.crt")) {
$r = reflector_ca('RM', $cs);
if ($r !== true) json_out(['ok' => false, 'error' => 'delete_failed', 'detail' => $r], 502);
}
@unlink(pki_pending_dir() . "/{$cs}.csr"); // drop any pending CSR
redis()->del(cert_node_key($cs)); // reset per-node renewal counter
if ($ce !== '') redis()->del(cert_email_key($ce)); // reset per-email renewal counter
redis()->del(rkey("node_admins:$cs")); // clear callsign admins
json_out(['ok' => true, 'node' => $cs]);
// ---- Linked portal account for a node (owner/node admin/site admin) ----
// Scans the primary-callsign (call:) and extra-callsign (call_accts:)
// indexes for the longest callsign contained in the node name, falling
// back to the cert's subjectAltName email — same linking rules as
// user_owns_node().
case 'info':
$cs = require_node($in);
if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403);
$linked_via = null;
$acct_uuid = null;
$node_uc = strtoupper($cs);
$best_call = '';
$rds = redis();
foreach ([[call_index_key(''), 'callsign'], [rkey('call_accts:'), 'extra_callsign']] as [$prefix, $via]) {
$cursor = null;
do {
$result = $rds->scan($cursor, $prefix . '*', 200);
if ($result === false) break;
foreach ($result as $key) {
$call = substr($key, strlen($prefix));
if ($call === '' || strlen($call) <= strlen($best_call)) continue;
if (!calls_match_node([$call], $node_uc)) continue;
if ($via === 'callsign') {
$u = $rds->get($key);
} else {
$mem = $rds->sMembers($key) ?: [];
sort($mem);
$u = $mem[0] ?? null;
}
if (!is_string($u) || $u === '') continue;
$best_call = $call; $acct_uuid = $u; $linked_via = $via;
}
} while ($cursor > 0);
}
if ($acct_uuid === null) {
$email = node_cert_email($cs);
if ($email !== null && $email !== '') {
$u = redis()->get(email_index_key(norm_email($email)));
if (is_string($u) && $u !== '') { $acct_uuid = $u; $linked_via = 'cert_email'; }
}
}
$account = null;
if ($acct_uuid !== null) {
$a = acct_get($acct_uuid);
$account = [
'callsign' => $a['callsign'] ?? '',
'name' => trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? '')),
'email' => $a['email'] ?? '',
'admin_level' => acct_level_from_flags(acct_flags_from($a)),
'email_verified' => (int)($a['email_verified'] ?? 0),
'banned' => (int)($a['banned'] ?? 0),
'last_login_at' => (int)($a['last_login_at'] ?? 0),
'city' => $a['city'] ?? '',
'country' => $a['country'] ?? '',
];
}
json_out(['ok' => true, 'node' => $cs, 'linked_via' => $linked_via, 'account' => $account]);
// ---- Remove a password node (site admin only) ----
case 'del':
if (!$is_admin) json_out(['ok' => false, 'error' => 'forbidden'], 403);
$cs = require_node($in);
[$rc, $out] = users_ctl('del', $cs);
if ($rc !== 0) json_out(['ok' => false, 'error' => 'del_failed', 'detail' => $out], 502);
// Keep the admin list if the node still exists via a cert.
if (!is_file(pki_certs_dir() . "/{$cs}.crt")) redis()->del(rkey("node_admins:$cs"));
json_out(['ok' => true, 'node' => $cs]);
default:
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
}
+129
View File
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
// Remote-control a SvxLink node over MQTT, manage the portal's publish settings, and
// provision per-node broker credentials (stored in the node profile).
require __DIR__ . '/lib.php';
$uuid = require_login();
$in = json_in();
$action = $in['action'] ?? 'tg';
// Amateur-format callsign (stricter than the node-name rule in auth/lib.php);
// passed to the shared require_callsign() as its validator.
function valid_amateur_callsign(string $cs): bool {
return (bool)preg_match('/^[A-Z0-9]{1,3}[0-9][A-Z0-9]{0,3}[A-Z](-[A-Z0-9]{1,10})?$/', $cs);
}
function admin_level(string $uuid): int { return acct_admin_level($uuid); }
function require_admin2(string $uuid): void { if (admin_level($uuid) < 2) json_out(['ok' => false, 'error' => 'forbidden'], 403); }
// Node ownership: user_owns_node() in lib.php (base callsign / node admin /
// cert email match / site admin).
// Scoped provisioning helper on the server (writes /etc/mosquitto/passwd, reloads mosquitto).
// Uses exec() (proc_open is disabled in this FPM pool); the password is passed via a 0600
// temp file (no stdin, and kept out of the process argv). The helper reads + shreds it.
function provision_call(string $sub, string $cs, ?string $pw = null): array {
$arg = '';
$tmp = '';
if ($pw !== null) {
$tmp = tempnam(sys_get_temp_dir(), 'mqp');
file_put_contents($tmp, $pw);
chmod($tmp, 0600);
$arg = ' ' . escapeshellarg($tmp);
}
$cmd = 'sudo /usr/local/sbin/svx-mqtt-provision ' . escapeshellarg($sub) . ' ' . escapeshellarg($cs) . $arg;
exec($cmd . ' 2>&1', $out, $rc);
if ($tmp !== '' && is_file($tmp)) @unlink($tmp); // helper shreds it; clean up if it failed early
return [$rc, trim(implode("\n", $out))];
}
// ===== Node-owner actions: read / (re)generate this node's MQTT credentials =====
if ($action === 'mqtt_node_get' || $action === 'mqtt_node_provision') {
$cs = require_callsign($in, 'valid_amateur_callsign');
if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403);
$key = rkey('node_mqtt:' . $cs);
$user = strtolower($cs);
if ($action === 'mqtt_node_get') {
$rec = redis()->hGetAll($key) ?: [];
json_out(['ok' => true, 'provisioned' => !empty($rec['pass']),
'username' => $user, 'password' => $rec['pass'] ?? '',
'broker_host' => 'svx.sa7aux.se', 'broker_port' => 8883]);
}
// provision / regenerate
csrf_check();
$pw = bin2hex(random_bytes(12)); // 24 hex chars, no shell-special chars
[$rc, $err] = provision_call('provision', $cs, $pw);
if ($rc !== 0) json_out(['ok' => false, 'error' => 'provision_failed', 'detail' => $err], 502);
redis()->hSet($key, 'user', $user);
redis()->hSet($key, 'pass', $pw);
redis()->hSet($key, 'ts', (string)time());
redis()->hSet($key, 'by', $uuid);
json_out(['ok' => true, 'username' => $user, 'password' => $pw,
'broker_host' => 'svx.sa7aux.se', 'broker_port' => 8883]);
}
// ===== Portal MQTT publish settings (admin) =====
function mqtt_settings(): array {
$s = redis()->hGetAll(rkey('portal/mqtt')) ?: [];
$c = cfg()['mqtt'] ?? [];
return [
'host' => (string)($s['host'] ?? $c['pub_host'] ?? '127.0.0.1'),
'port' => (int) ($s['port'] ?? $c['pub_port'] ?? 1883),
'user' => (string)($s['user'] ?? $c['pub_user'] ?? ''),
'pass' => (string)($s['pass'] ?? $c['pub_pass'] ?? ''),
];
}
if ($action === 'mqtt_get') {
require_admin2($uuid);
$s = mqtt_settings();
json_out(['ok' => true, 'host' => $s['host'], 'port' => $s['port'], 'user' => $s['user'], 'pass_set' => $s['pass'] !== '']);
}
if ($action === 'mqtt_set') {
require_admin2($uuid); csrf_check();
// The MQTT broker password is a system secret. LVL1 is read-only; LVL2 may
// set it blind (blank keeps the current one), LVL3 fully.
if (acct_admin_rank($uuid) < 2) json_out(['ok' => false, 'error' => 'forbidden', 'reason' => 'read_only_admin'], 403);
$host = trim((string)($in['host'] ?? ''));
if ($host === '') json_out(['ok' => false, 'error' => 'host_required'], 400);
$port = (int)($in['port'] ?? 1883);
if ($port < 1 || $port > 65535) json_out(['ok' => false, 'error' => 'bad_port'], 400);
$k = rkey('portal/mqtt');
redis()->hSet($k, 'host', $host);
redis()->hSet($k, 'port', (string)$port);
redis()->hSet($k, 'user', trim((string)($in['user'] ?? '')));
if (isset($in['pass']) && $in['pass'] !== '') redis()->hSet($k, 'pass', (string)$in['pass']);
json_out(['ok' => true]);
}
// ===== Publish a command (node owner OR admin) =====
csrf_check();
$cs = require_callsign($in, 'valid_amateur_callsign');
if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403);
$call = strtolower($cs);
if ($action === 'tg') {
// Kept for programmatic use: activate/monitor a talkgroup by number (node wraps 91/94<tg>#).
$tg = trim((string)($in['tg'] ?? ''));
if (!preg_match('/^[0-9]{1,7}$/', $tg)) json_out(['ok' => false, 'error' => 'invalid_tg'], 400);
$mode = (($in['mode'] ?? 'active') === 'monitor') ? 'monitor' : 'active';
$topic = "svxnode/{$call}/cmd/tg";
$payload = json_encode(['tg' => $tg, 'mode' => $mode]);
} elseif ($action === 'dtmf') {
// Full SvxLink DTMF command sent literally, e.g. 91240# 92# 929#
$code = strtoupper(trim((string)($in['dtmf'] ?? '')));
if (!preg_match('/^[0-9A-D*#]{1,16}$/', $code)) json_out(['ok' => false, 'error' => 'invalid_dtmf'], 400);
$topic = "svxnode/{$call}/cmd/dtmf";
$payload = $code;
} else {
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
}
$s = mqtt_settings();
if ($s['user'] === '' || $s['pass'] === '') json_out(['ok' => false, 'error' => 'mqtt_not_configured'], 500);
$cmd = 'mosquitto_pub'
. ' -h ' . escapeshellarg($s['host']) . ' -p ' . escapeshellarg((string)$s['port'])
. ' -u ' . escapeshellarg($s['user']) . ' -P ' . escapeshellarg($s['pass'])
. ' -q 1 -t ' . escapeshellarg($topic) . ' -m ' . escapeshellarg((string)$payload);
exec($cmd . ' 2>&1', $out, $rc);
if ($rc !== 0) json_out(['ok' => false, 'error' => 'publish_failed', 'detail' => implode("\n", $out)], 502);
json_out(['ok' => true, 'topic' => $topic, 'payload' => $payload]);
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$uuid = require_login();
csrf_check();
$j = json_in();
$old = (string)($j['old_password'] ?? '');
$new = (string)($j['new_password'] ?? '');
if ($old === '' || $new === '') json_out(['ok'=>false,'error'=>'missing_fields'], 400);
if (strlen($new) < 6) json_out(['ok'=>false,'error'=>'too_short'], 400);
$acct = acct_get($uuid);
$hash = (string)($acct['password_hash'] ?? '');
if ($hash === '' || !password_verify($old, $hash)) {
json_out(['ok'=>false,'error'=>'invalid_old_password'], 401);
}
redis()->hSet(acct_key($uuid), 'password_hash', password_hash($new, PASSWORD_DEFAULT));
json_out(['ok'=>true]);
+134
View File
@@ -0,0 +1,134 @@
'use strict';
const $ = id => document.getElementById(id);
let ME = null;
const editableFields = [
{key:'firstname', label:'First Name'},
{key:'lastname', label:'Last Name'},
{key:'shortname', label:'Short Name'},
{key:'email', label:'Email'},
{key:'city', label:'City'},
{key:'state', label:'State'},
{key:'country', label:'Country'},
];
function setMsg(el, txt, err){
if (!el) return;
if (txt === null) { el.style.display='none'; el.textContent=''; return; }
el.style.display='block';
el.textContent = txt;
el.style.color = err ? '#ff8080' : '#00ff80';
}
async function loadMe(){
try {
const r = await fetch('/auth/me.php', {credentials:'same-origin'});
const j = await r.json();
if (j && j.logged_in) ME = j;
else { window.location.href = '/'; return; }
} catch(_) { window.location.href = '/'; return; }
renderProfileEditFields();
}
function renderProfileEditFields(){
if (!ME) return;
const container = $('profileEditFields');
container.innerHTML = '';
// Header info
const info = document.createElement('div');
info.className = 'mb-3 d-flex gap-3 align-items-center flex-wrap';
const FLAG_NAMES = [[1,'unreg'],[2,'reg'],[4,'normal'],[8,'lvl1'],[16,'lvl2'],[32,'lvl3']];
const lvlText = FLAG_NAMES.filter(x => (ME.flags||0) & x[0]).map(x => x[1]).join(', ') || '-';
const lvlColor = ME.admin_level >= 2 ? '#ffc800' : 'rgba(0,255,255,.75)';
const star = ME.admin_level >= 2 ? ' \u2605' : '';
info.innerHTML =
'<span class="small text-secondary">UUID: <span class="mono">' + ME.uuid + '</span></span>'
+ '<span class="small" style="color:' + lvlColor + ';">' + lvlText + star + '</span>'
+ '<span class="small text-secondary">Callsign: <span class="mono">' + (ME.callsign||'') + '</span></span>';
container.appendChild(info);
for (const f of editableFields) {
const lbl = document.createElement('label');
lbl.className = 'form-label small text-secondary';
lbl.textContent = f.label;
lbl.setAttribute('for', 'pedit_' + f.key);
const inp = document.createElement('input');
inp.className = 'form-control form-control-sm mb-2';
inp.id = 'pedit_' + f.key;
inp.value = ME[f.key] || '';
container.appendChild(lbl);
container.appendChild(inp);
}
}
async function saveProfile(){
const body = {};
for (const f of editableFields) {
const el = $('pedit_' + f.key);
if (el) body[f.key] = el.value;
}
const r = await fetch('/auth/profile_update.php', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
const j = await r.json().catch(()=>null);
if (j && j.ok) {
setMsg($('profileEditMsg'), 'Saved!', false);
await loadMe();
} else {
setMsg($('profileEditMsg'), (j && j.error) || 'Failed', true);
}
}
function switchProfileTab(tab){
document.querySelectorAll('.profile-tab').forEach(b => {
const active = b.dataset.ptab === tab;
b.classList.toggle('active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('.profile-tab-pane').forEach(p => {
p.style.display = p.id === 'ptab-' + tab ? '' : 'none';
});
}
document.addEventListener('DOMContentLoaded', () => {
loadMe();
document.querySelectorAll('.profile-tab[data-ptab]').forEach(btn => {
btn.addEventListener('click', () => switchProfileTab(btn.dataset.ptab));
});
$('btnSaveProfile').addEventListener('click', saveProfile);
// Reset web password modal
$('btnUserResetPwd').addEventListener('click', () => {
$('userPwdOld').value = '';
$('userPwdNew').value = '';
$('userPwdNew2').value = '';
setMsg($('userPwdMsg'), null);
new bootstrap.Modal($('userPwdModal')).show();
});
$('btnUserPwdSave').addEventListener('click', async () => {
const old_password = $('userPwdOld').value;
const new_password = $('userPwdNew').value;
const new_password2 = $('userPwdNew2').value;
if (!old_password || !new_password) { setMsg($('userPwdMsg'),'Fill all fields',true); return; }
if (new_password !== new_password2) { setMsg($('userPwdMsg'),'Passwords do not match',true); return; }
const r = await fetch('/auth/password_change.php', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({old_password, new_password})
});
const j = await r.json().catch(()=>null);
if (j && j.ok) {
setMsg($('userPwdMsg'),'Password changed!', false);
setTimeout(() => bootstrap.Modal.getInstance($('userPwdModal'))?.hide(), 800);
} else {
setMsg($('userPwdMsg'), ((j && j.error) || 'Failed').replace(/_/g,' '), true);
}
});
});
+275
View File
@@ -0,0 +1,275 @@
<?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 ? ' &#9733;' : '';
// 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;">&#10003; 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))?> &middot; 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 &mdash; 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';
?>
+507
View File
@@ -0,0 +1,507 @@
'use strict';
function switchProfileTab(tab) {
document.querySelectorAll('.profile-tab').forEach(function(b) {
var active = b.dataset.ptab === tab;
b.classList.toggle('active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('.profile-tab-pane').forEach(function(p) {
p.style.display = p.id === 'ptab-' + tab ? '' : 'none';
});
if (tab === 'nodes' && !nodesLoaded) loadNodes();
if (tab === 'mqtt' && !mqttLoaded) loadMqtt();
if (tab === 'nodesec' && !nodeSecLoaded) { nodeSecLoaded = true; loadNodeAuth(); }
}
var nodesLoaded = false;
var mqttLoaded = false;
var nodeSecLoaded = false;
var allNodes = {};
async function loadNodes() {
var status = document.getElementById('nodesStatus');
var list = document.getElementById('nodesList');
var selWrap = document.getElementById('nodeSelWrap');
var sel = document.getElementById('nodeSelect');
try {
var r = await fetch('/auth/my_nodes.php', {credentials:'same-origin'});
var j = await r.json();
if (!j || !j.ok) { status.textContent = j && j.error || 'Error'; return; }
nodesLoaded = true;
allNodes = j.nodes || {};
var keys = Object.keys(allNodes);
if (keys.length === 0) {
status.textContent = 'No nodes connected';
list.innerHTML = '<div class="text-secondary small">No nodes matching your callsign are currently connected to the reflector.</div>';
selWrap.style.display = 'none';
return;
}
status.textContent = keys.length + ' node' + (keys.length > 1 ? 's' : '');
// Populate instance selector
sel.innerHTML = '';
if (keys.length > 1) {
var optAll = document.createElement('option');
optAll.value = '__all__';
optAll.textContent = 'All nodes (' + keys.length + ')';
sel.appendChild(optAll);
}
for (var i = 0; i < keys.length; i++) {
var opt = document.createElement('option');
opt.value = keys[i];
opt.textContent = keys[i];
sel.appendChild(opt);
}
selWrap.style.display = (keys.length > 1) ? '' : 'none';
// Show first node or all
if (keys.length > 1) {
showNodes('__all__');
} else {
showNodes(keys[0]);
}
} catch(e) { status.textContent = 'Failed to load'; }
}
function showNodes(selected) {
var list = document.getElementById('nodesList');
list.innerHTML = '';
var keys = Object.keys(allNodes);
for (var i = 0; i < keys.length; i++) {
if (selected !== '__all__' && keys[i] !== selected) continue;
list.appendChild(renderNode(keys[i], allNodes[keys[i]]));
}
}
function renderNode(callsign, n) {
var card = document.createElement('div');
card.className = 'node-card';
var html = '<h6 class="mono"><strong>' + esc(callsign) + '</strong>';
if (n.nodeClass) html += ' <span class="node-badge">' + esc(n.nodeClass) + '</span>';
if (n.isTalker) html += ' <span class="node-badge" style="border-color:#ff4040;color:#ff4040;">TX</span>';
html += '</h6>';
html += '<dl class="node-grid">';
if (n.nodeLocation) html += dt('Location') + dd(n.nodeLocation);
if (n.sysop) html += dt('Sysop') + dd(n.sysop);
if (n.sw) html += dt('Software') + dd(n.sw + ' ' + (n.swVer || ''));
if (n.projVer) html += dt('Build') + dd(n.projVer);
if (n.machineArch) html += dt('Arch') + dd(n.machineArch);
if (n.protoVer) html += dt('Protocol') + dd('v' + n.protoVer.majorVer + '.' + n.protoVer.minorVer);
if (n.tg !== undefined) html += dt('Current TG') + dd(n.tg || 'None');
html += '</dl>';
// Node admins editor appended below (dmr-style table); sysop is the
// implicit owner, others editable by owner / node admin / site admin.
if (n.monitoredTGs && n.monitoredTGs.length > 0) {
html += '<div class="mt-2"><span class="small text-secondary">Monitored TGs:</span><br>';
for (var i = 0; i < n.monitoredTGs.length; i++) html += '<span class="node-badge mono">' + n.monitoredTGs[i] + '</span>';
html += '</div>';
}
if (n.toneToTalkgroup && Object.keys(n.toneToTalkgroup).length > 0) {
html += '<div class="mt-2"><span class="small text-secondary">CTCSS Tone Mapping:</span>';
html += '<table style="font-size:.8rem;margin-top:4px;border-collapse:collapse;">';
var sorted = Object.entries(n.toneToTalkgroup).sort(function(a,b){ return parseFloat(a[0]) - parseFloat(b[0]); });
for (var i = 0; i < sorted.length; i++) {
html += '<tr><td class="mono" style="padding:1px 10px 1px 0;color:var(--fg2);">' + sorted[i][0] + ' Hz</td><td class="mono" style="padding:1px 0;">TG ' + sorted[i][1] + '</td></tr>';
}
html += '</table></div>';
}
if (n.qth && n.qth.length > 0) {
for (var qi = 0; qi < n.qth.length; qi++) {
var q = n.qth[qi];
html += '<div class="mt-2" style="border-top:1px solid var(--line);padding-top:8px;">';
html += '<span class="small text-secondary">QTH: ' + esc(q.name || '') + '</span>';
html += '<dl class="node-grid mt-1">';
if (q.pos) {
if (q.pos.loc) html += dt('Locator') + dd(q.pos.loc);
if (q.pos.lat) html += dt('Lat/Long') + dd(q.pos.lat + ', ' + q.pos.long);
}
if (q.rx) {
for (var id in q.rx) {
var rx = q.rx[id];
html += dt('RX ' + id) + dd((rx.name||'') + ' ' + (rx.freq ? rx.freq.toFixed(3)+' MHz':'') + (rx.sqlType ? ' ('+rx.sqlType+')':''));
html += dt('Status') + dd((rx.enabled?'Enabled':'Disabled') + (rx.sql_open?', Squelch open':''));
}
}
if (q.tx) {
for (var id in q.tx) {
var tx = q.tx[id];
html += dt('TX ' + id) + dd((tx.name||'') + ' ' + (tx.freq ? tx.freq.toFixed(3)+' MHz':'') + (tx.sqlType ? ' ('+tx.sqlType+')':''));
html += dt('Transmitting') + dd(tx.transmit?'Yes':'No');
}
}
html += '</dl>';
html += '</div>';
}
}
card.innerHTML = html;
return card;
}
// ===== Remote (MQTT) tab: provision + show this node's broker credentials =====
function loadMqtt() {
mqttLoaded = true;
var pane = document.getElementById('mqttPane');
fetch('/auth/me.php', { credentials: 'same-origin' }).then(function(r) { return r.json(); }).then(function(me) {
if (me && me.csrf_token) window._csrfToken = me.csrf_token;
var call = ((me && me.callsign) || '').toUpperCase();
if (!call) { pane.innerHTML = '<span class="text-secondary small">Your account has no callsign set — add one on the My Account tab first.</span>'; return; }
pane.innerHTML = '';
var wrap = document.createElement('div');
var head = document.createElement('div');
head.className = 'mb-2';
head.innerHTML = 'Node callsign: <b class="mono">' + esc(call) + '</b>';
var btn = document.createElement('button');
btn.className = 'btn btn-primary btn-sm';
btn.textContent = 'Show / set up credentials';
var creds = document.createElement('div');
creds.className = 'mqtt-creds small mono mt-2';
btn.addEventListener('click', function() {
btn.disabled = true;
mqttApi({ action: 'mqtt_node_get', callsign: call }).then(function(j) {
btn.disabled = false;
if (j && j.ok) mqttCreds(call, creds, j);
else { creds.style.display = 'block'; creds.textContent = (j && j.error) || 'error'; }
}).catch(function() { btn.disabled = false; });
});
wrap.appendChild(head); wrap.appendChild(btn); wrap.appendChild(creds);
// Change talkgroup (owner can control their own node)
var tgWrap = document.createElement('div');
tgWrap.className = 'mt-3';
tgWrap.style.borderTop = '1px solid var(--line)';
tgWrap.style.paddingTop = '10px';
tgWrap.innerHTML = '<div class="small text-secondary mb-1">Send a SvxLink DTMF command (remote control must be ON). '
+ '<span class="mono">91&lt;TG&gt;#</span> talk · <span class="mono">910#</span> leave · '
+ '<span class="mono">92&lt;TG&gt;#</span> QSY (<span class="mono">929#</span>→TG9 if someone interferes) · '
+ '<span class="mono">93#</span> follow QSY · <span class="mono">94&lt;TG&gt;#</span> monitor · <span class="mono">9*#</span> status</div>';
var row = document.createElement('div');
row.className = 'd-flex gap-2 align-items-center';
var inp = document.createElement('input');
inp.className = 'form-control form-control-sm mono'; inp.style.maxWidth = '180px';
inp.type = 'text'; inp.placeholder = 'DTMF, e.g. 91240#';
var setb = document.createElement('button');
setb.className = 'btn btn-primary btn-sm'; setb.type = 'button'; setb.textContent = 'Send';
var st = document.createElement('span'); st.className = 'small';
setb.addEventListener('click', function() {
var tg = (inp.value || '').trim().toUpperCase();
if (!/^[0-9A-D*#]{1,16}$/.test(tg)) { st.style.color = '#ff5252'; st.textContent = 'Use 0-9, #, * and A-D'; return; }
setb.disabled = true; st.style.color = ''; st.textContent = 'Sending…';
mqttApi({ action: 'dtmf', callsign: call, dtmf: tg }).then(function(j) {
setb.disabled = false;
if (j && j.ok) { st.style.color = '#00e676'; st.textContent = 'Sent: ' + tg; }
else { st.style.color = '#ff5252'; st.textContent = (j && j.error) || 'failed'; }
}).catch(function() { setb.disabled = false; st.style.color = '#ff5252'; st.textContent = 'request failed'; });
});
row.appendChild(inp); row.appendChild(setb); row.appendChild(st);
tgWrap.appendChild(row);
wrap.appendChild(tgWrap);
pane.appendChild(wrap);
}).catch(function() { pane.innerHTML = '<span class="text-secondary small">Failed to load.</span>'; });
}
function mqttApi(body) {
return fetch('/auth/node_cmd_api.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' },
body: JSON.stringify(body)
}).then(function(r) { return r.json(); });
}
function mqttCopyBtn(label, value) {
return '<button type="button" class="btn btn-sm btn-outline-secondary ms-2 py-0" ' +
'data-copy="' + esc(value) + '" style="font-size:.72rem;">' + label + '</button>';
}
function mqttWireCopy(cont) {
cont.querySelectorAll('[data-copy]').forEach(function(b) {
b.addEventListener('click', function() {
var v = b.getAttribute('data-copy');
var done = function() { var t = b.textContent; b.textContent = 'Copied!'; setTimeout(function() { b.textContent = t; }, 1200); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(v).then(done).catch(function() { mqttCopyFallback(v, done); });
} else { mqttCopyFallback(v, done); }
});
});
}
function mqttCopyFallback(v, done) {
var ta = document.createElement('textarea');
ta.value = v; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) {}
document.body.removeChild(ta);
}
function mqttCreds(call, cont, d) {
cont.style.display = 'block';
if (d.password) {
cont.innerHTML =
'<div>Broker: <b>' + esc(d.broker_host) + ':' + d.broker_port + '</b></div>' +
'<div>Username: <b>' + esc(d.username) + '</b>' + mqttCopyBtn('Copy', d.username) + '</div>' +
'<div>Password: <b>' + esc(d.password) + '</b>' + mqttCopyBtn('Copy', d.password) + '</div>' +
'<div class="text-secondary" style="margin-top:4px;">Paste these into the node dashboard → Admin → Remote Ctrl, then Turn ON.</div>' +
'<button class="btn btn-sm btn-warning mt-1" data-regen="1">Regenerate</button>';
cont.querySelector('[data-regen]').addEventListener('click', function() { mqttProvision(call, cont); });
mqttWireCopy(cont);
} else {
cont.innerHTML = '<button class="btn btn-sm btn-primary" data-gen="1">Generate password</button>';
cont.querySelector('[data-gen]').addEventListener('click', function() { mqttProvision(call, cont); });
}
}
function mqttProvision(call, cont) {
if (!confirm('Generate a NEW MQTT password for ' + call + '? Any old one stops working.')) return;
cont.innerHTML = '<span class="text-secondary">Working…</span>';
mqttApi({ action: 'mqtt_node_provision', callsign: call }).then(function(j) {
if (j && j.ok) mqttCreds(call, cont, j);
else cont.textContent = (j && j.error) || 'provision failed';
}).catch(function() { cont.textContent = 'request failed'; });
}
function dt(l) { return '<dt>' + esc(l) + '</dt>'; }
function dd(v) { return '<dd>' + esc(String(v)) + '</dd>'; }
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.profile-tab[data-ptab]').forEach(function(btn) {
btn.addEventListener('click', function() { switchProfileTab(btn.dataset.ptab); });
});
var sel = document.getElementById('nodeSelect');
if (sel) {
sel.addEventListener('change', function() { showNodes(sel.value); });
}
var rv = document.getElementById('btnResendVerify');
if (rv) {
rv.addEventListener('click', async function() {
var msg = document.getElementById('resendMsg');
rv.disabled = true;
if (msg) { msg.style.color = ''; msg.textContent = 'Sending\u2026'; }
try {
var r = await fetch('/auth/resend_verification.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' }
});
var j = await r.json();
if (j && j.ok) {
if (msg) { msg.style.color = '#00c864'; msg.textContent = j.already ? 'Already verified.' : 'Verification email sent \u2014 check your inbox.'; }
} else {
if (msg) { msg.style.color = '#ff8080'; msg.textContent = (j && j.error) || 'Failed'; }
rv.disabled = false;
}
} catch (e) {
if (msg) { msg.style.color = '#ff8080'; msg.textContent = 'Failed'; }
rv.disabled = false;
}
});
}
});
// --- Reflector authentication (password/cert nodes linked to this account) ---
function nauthApi(body) {
var post = function () {
return fetch('/auth/node_auth_api.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' },
body: JSON.stringify(body)
}).then(function (r) { return r.json(); }).catch(function () { return null; });
};
if (window._csrfToken) return post();
return fetch('/auth/me.php', { credentials: 'same-origin' }).then(function (r) { return r.json(); })
.then(function (me) { if (me && me.csrf_token) window._csrfToken = me.csrf_token; return post(); })
.catch(function () { return post(); });
}
function loadNodeAuth() {
var cont = document.getElementById('nodeAuthList');
if (!cont) return;
nauthApi({ action: 'list' }).then(function (j) {
if (!j || !j.ok) { cont.innerHTML = '<span class="small text-secondary">' + esc((j && j.error) || 'Failed to load') + '</span>'; return; }
var list = j.nodes || [];
var pend = j.pending || [];
if (!list.length && !pend.length) { cont.innerHTML = '<span class="small text-secondary">No reflector logins or certificates are linked to your account.</span>'; return; }
cont.innerHTML = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:520px;">'
+ '<thead><tr><th>Callsign</th><th>Auth</th><th>Password</th><th>Actions</th></tr></thead><tbody></tbody></table></div>';
var tb = cont.querySelector('tbody');
list.forEach(function (n) {
var auth = [];
if (n.cert) {
var expired = n.validTo && (n.validTo * 1000 < Date.now());
auth.push('<span class="node-badge">cert' + (n.validTo ? ' · ' + new Date(n.validTo * 1000).toISOString().slice(0, 10) : '') + '</span>'
+ (expired ? ' <span class="node-badge" style="border-color:#ff4040;color:#ff4040;">EXPIRED</span>' : ''));
if (typeof n.renewals !== 'undefined') auth.push('<span class="node-badge" title="Times this certificate has been issued/renewed">renewals: ' + (n.renewals | 0) + '</span>');
}
if (n.password) auth.push('<span class="node-badge">password</span>');
var tr = document.createElement('tr');
tr.innerHTML = '<td class="mono"><strong>' + esc(n.node) + '</strong></td>'
+ '<td>' + auth.join(' ') + '</td>'
+ '<td class="na-pw mono small">' + (n.password ? 'Loading…' : '<span class="text-secondary">—</span>') + '</td>'
+ '<td class="na-act" style="white-space:nowrap;"></td>';
tb.appendChild(tr);
var actCell = tr.querySelector('.na-act');
// Remove-certificate (self-service) — for a cert linked to your account
// by callsign or verified email. Removal goes through the reflector (CA RM).
if (n.cert && n.owned) {
var rmBtn = document.createElement('button');
rmBtn.type = 'button'; rmBtn.className = 'btn btn-sm btn-outline-warning py-0 me-1';
rmBtn.style.fontSize = '.75rem'; rmBtn.textContent = 'Revoke certificate';
rmBtn.addEventListener('click', function () {
if (!confirm('Remove the certificate for ' + n.node + '?\nThe node can then reconnect and submit a new request, which you can accept here.')) return;
rmBtn.disabled = true;
nauthApi({ action: 'self_revoke', node: n.node }).then(function (r) {
if (r && r.ok) { nodeSecLoaded = false; loadNodeAuth(); }
else { rmBtn.disabled = false; alert((r && (r.detail || r.error)) || 'Failed'); }
});
});
actCell.appendChild(rmBtn);
}
// Admins — expand the dmr-style editor under the row (all node types).
var admBtn = document.createElement('button');
admBtn.type = 'button'; admBtn.className = 'btn btn-sm btn-outline-secondary py-0 me-1';
admBtn.style.fontSize = '.75rem'; admBtn.textContent = 'Admins';
admBtn.addEventListener('click', function () {
var nx = tr.nextElementSibling;
if (nx && nx.classList.contains('na-adm-sub')) { nx.remove(); return; }
var open = tb.querySelector('.na-adm-sub'); if (open) open.remove();
var sub = document.createElement('tr'); sub.className = 'na-adm-sub';
var td = document.createElement('td'); td.colSpan = 4; td.style.background = 'rgba(0,255,255,.04)';
// Node Security never edits admins — not even for site admins; that is
// done on the admin account-edit page. Read-only list only.
td.appendChild(nauthAdminsEditor(n.node, false));
sub.appendChild(td); tr.after(sub);
});
actCell.appendChild(admBtn);
if (n.password) {
var pwCell = tr.querySelector('.na-pw');
var showPw = function (pw) { pwCell.innerHTML = '<span>' + esc(pw) + '</span>' + mqttCopyBtn('Copy', pw); mqttWireCopy(pwCell); };
nauthApi({ action: 'getpw', node: n.node }).then(function (r) {
if (r && r.ok) showPw(r.password);
else pwCell.textContent = (r && (r.detail || r.error)) || 'failed';
}).catch(function () { pwCell.textContent = 'request failed'; });
var btn = document.createElement('button');
btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-secondary py-0';
btn.style.fontSize = '.75rem'; btn.textContent = 'Reset password';
btn.addEventListener('click', function () {
if (!confirm('Generate a NEW reflector password for ' + n.node + '?\nThe old one stops working and the reflector restarts (nodes reconnect).')) return;
btn.disabled = true; pwCell.textContent = 'Working…';
nauthApi({ action: 'resetpw', node: n.node }).then(function (r) {
btn.disabled = false;
if (r && r.ok) showPw(r.password);
else pwCell.textContent = (r && (r.detail || r.error)) || 'failed';
}).catch(function () { btn.disabled = false; pwCell.textContent = 'request failed'; });
});
actCell.appendChild(btn);
}
});
// Pending certificate requests — a node reconnecting after its cert was
// removed submits a fresh CSR that lands here. You can accept (issue) it
// yourself once an admin has issued the first certificate for the node or
// your account email (callsign or email match links it to you).
pend.forEach(function (p) {
var tr = document.createElement('tr');
tr.innerHTML = '<td class="mono"><strong>' + esc(p.node) + '</strong></td>'
+ '<td><span class="node-badge" style="border-color:#ffc800;color:#ffc800;">cert request</span>'
+ (typeof p.renewals !== 'undefined' ? ' <span class="node-badge">renewals: ' + (p.renewals | 0) + '</span>' : '') + '</td>'
+ '<td class="text-secondary small">—</td>'
+ '<td class="na-act" style="white-space:nowrap;"></td>';
tb.appendChild(tr);
var cell = tr.querySelector('.na-act');
if (p.canSelfSign) {
var acc = document.createElement('button');
acc.type = 'button'; acc.className = 'btn btn-sm btn-success py-0';
acc.style.fontSize = '.75rem'; acc.textContent = 'Accept / issue';
acc.addEventListener('click', function () {
if (!confirm('Issue the certificate for ' + p.node + '?')) return;
acc.disabled = true;
nauthApi({ action: 'self_sign', node: p.node }).then(function (r) {
if (r && r.ok) { nodeSecLoaded = false; loadNodeAuth(); }
else { acc.disabled = false; alert((r && (r.detail || r.error)) || 'Failed'); }
});
});
cell.appendChild(acc);
} else {
cell.innerHTML = '<span class="small text-secondary">Waiting for an admin to issue the first certificate.</span>';
}
});
});
}
// Per-node admins editor — exactly the dmr peer "Edit admins" table: Add on
// top, a row per admin with an editable callsign field + Delete, Save/Back at
// the bottom. Each node keeps its own admin list (node_admins:<callsign>), so
// admins differ node by node. On Save the edited list is diffed against the
// server and add/remove calls are issued. Editing is site-admin only —
// regular users get a read-only list (the server enforces the same rule).
function nauthAdminsEditor(cs, editable) {
var wrap = document.createElement('div');
wrap.className = 'mt-2';
wrap.innerHTML = '<div class="small text-secondary mb-2">'
+ (editable ? 'Node admins — add a callsign to give them admin rights on this node. Each node has its own admins.'
: 'Node admins for this node. Only site admins can change the list.')
+ '</div><div class="ae-box"></div>';
var box = wrap.querySelector('.ae-box');
box.innerHTML = '<span class="small text-secondary">Loading…</span>';
naApi({ action: 'list', node: cs }).then(function (j) {
if (!j || !j.ok) { box.innerHTML = '<span style="color:#ff8080;">' + esc((j && j.error) || 'failed') + '</span>'; return; }
var server = (j.admins || []).map(function (c) { return String(c).toUpperCase(); });
if (!editable) {
var ro = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:280px;"><thead><tr><th>Callsign</th></tr></thead><tbody>';
if (!server.length) ro += '<tr><td class="small text-secondary">No extra admins. (The node\'s own callsign always counts.)</td></tr>';
server.forEach(function (c) { ro += '<tr><td class="mono">' + esc(c) + '</td></tr>'; });
box.innerHTML = ro + '</tbody></table></div>';
return;
}
var working = server.slice();
box.innerHTML =
'<div class="d-flex gap-2 mb-2"><button type="button" class="btn btn-sm btn-outline-success ae-add">Add</button></div>'
+ '<div class="ae-wrap mb-2"></div>'
+ '<div class="d-flex gap-2 mt-2"><button type="button" class="btn btn-sm btn-primary ae-save">Save</button></div>'
+ '<div class="ae-msg small mt-2" style="display:none;"></div>';
var tw = box.querySelector('.ae-wrap'), msg = box.querySelector('.ae-msg');
var setErr = function (t, ok) { msg.style.display = t ? '' : 'none'; msg.style.color = ok ? '#00ff80' : '#ff8080'; msg.textContent = t || ''; };
var renderTable = function () {
var html = '<div style="overflow-x:auto;"><table class="data-table" style="min-width:280px;">'
+ '<thead><tr><th>Callsign</th><th style="width:120px;">Action</th></tr></thead><tbody>';
if (!working.length) html += '<tr><td colspan="2" class="small text-secondary">No admins — click Add to grant a callsign admin rights for this node. (Your own callsign always counts.)</td></tr>';
working.forEach(function (v, i) {
html += '<tr><td><input type="text" class="form-control form-control-sm mono ae-cs" data-i="' + i + '" value="' + esc(v) + '" placeholder="SA7AUX" style="text-transform:uppercase;"></td>'
+ '<td><button type="button" class="btn btn-sm btn-outline-danger ae-del" data-i="' + i + '">Delete</button></td></tr>';
});
tw.innerHTML = html + '</tbody></table></div>';
tw.querySelectorAll('.ae-cs').forEach(function (inp) { inp.addEventListener('input', function () { working[+inp.dataset.i] = inp.value; }); });
tw.querySelectorAll('.ae-del').forEach(function (b) { b.addEventListener('click', function () { working.splice(+b.dataset.i, 1); renderTable(); }); });
};
renderTable();
box.querySelector('.ae-add').addEventListener('click', function () { working.push(''); renderTable(); });
box.querySelector('.ae-save').addEventListener('click', function () {
var seen = {}, norm = [];
working.forEach(function (c) { c = String(c).trim().toUpperCase(); if (c && !seen[c]) { seen[c] = 1; norm.push(c); } });
var toRemove = server.filter(function (c) { return norm.indexOf(c) < 0; });
var toAdd = norm.filter(function (c) { return server.indexOf(c) < 0; });
setErr('Saving…', true);
var chain = Promise.resolve(true);
toRemove.forEach(function (c) { chain = chain.then(function (ok) { return ok && naApi({ action: 'remove', node: cs, admin: c }).then(function (r) { if (!r || !r.ok) { setErr((r && r.error) || ('Failed to remove ' + c), false); return false; } return true; }); }); });
toAdd.forEach(function (c) { chain = chain.then(function (ok) { return ok && naApi({ action: 'add', node: cs, admin: c }).then(function (r) { if (!r || !r.ok) { setErr((r && r.error) || ('Failed to add ' + c), false); return false; } return true; }); }); });
chain.then(function (ok) { if (ok) { server = norm.slice(); setErr('✓ Saved', true); } });
});
});
return wrap;
}
// --- Node admin management (add/remove) — owner / node admin / site admin ---
function naApi(body) {
var post = function () {
return fetch('/auth/node_admins_api.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' },
body: JSON.stringify(body)
}).then(function (r) { return r.json(); }).catch(function () { return null; });
};
// The CSRF token is only guaranteed once me.php has been fetched; ensure it's set.
if (window._csrfToken) return post();
return fetch('/auth/me.php', { credentials: 'same-origin' }).then(function (r) { return r.json(); })
.then(function (me) { if (me && me.csrf_token) window._csrfToken = me.csrf_token; return post(); })
.catch(function () { return post(); });
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$uuid = require_login();
csrf_check();
$j = json_in();
$allowed = ['firstname','lastname','shortname','email','city','state','country'];
$updates = [];
foreach ($allowed as $f) {
if (isset($j[$f]) && is_string($j[$f])) $updates[$f] = trim($j[$f]);
}
if (empty($updates)) json_out(['ok'=>false,'error'=>'no_fields'], 400);
// Email-uppdatering kräver index-update
if (isset($updates['email'])) {
$old = acct_get($uuid);
$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), $uuid);
}
}
redis()->hMSet(acct_key($uuid), $updates);
json_out(['ok'=>true]);
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
start_session();
$uuid = $_SESSION['uid'] ?? null;
if (is_string($uuid) && $uuid !== '') {
// Logged in (profile page)
csrf_check();
rate_limit("resendverify:$uuid", 3, 600);
} else {
// From the sign-in form: unverified users cannot log in, so they prove
// ownership with the same credentials they just typed.
$in = json_in();
$user = trim((string)($in['user'] ?? ''));
$pass = (string)($in['pass'] ?? '');
if ($user === '' || $pass === '') json_out(['ok'=>false,'error'=>'not_logged_in'], 401);
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
rate_limit("resendverify:ip:{$ip}", 3, 600);
$uuid = find_acct_by_login($user);
$acct = $uuid ? acct_get($uuid) : [];
$hash = (string)($acct['password_hash'] ?? '');
if ($hash === '' || !password_verify($pass, $hash)) json_out(['ok'=>false,'error'=>'invalid_login'], 401);
rate_limit("resendverify:$uuid", 3, 600);
}
$me = acct_get($uuid);
if (!$me) json_out(['ok' => false, 'error' => 'not_found'], 404);
if (($me['email_verified'] ?? '0') === '1') json_out(['ok' => true, 'already' => true]);
$email = (string)($me['email'] ?? '');
if ($email === '') json_out(['ok' => false, 'error' => 'no_email'], 400);
$r = redis();
$tid = token_id();
$secret = token_secret();
$hash = token_hash($secret);
$k = rkey("token:verify:$tid");
$r->hMSet($k, ['uid' => $uuid, 'hash' => $hash]);
$r->expire($k, 60 * 60 * 24);
$base = rtrim(cfg()['base_url'], '/');
$link = $base . '/auth/verify.php?tid=' . urlencode($tid) . '&token=' . urlencode($secret);
$first = (string)($me['firstname'] ?? '');
$call = (string)($me['callsign'] ?? '');
$body = "Hello {$first},\n\n";
$body .= "Here is a new email verification link for your SvxPortal account ({$call}):\n\n";
$body .= "{$link}\n\n";
$body .= "This link is valid for 24 hours.\n\n";
$body .= "73,\nSvxPortal";
$sent = mail_send($email, "Verify your email - SvxPortal", $body);
json_out(['ok' => true, 'email_sent' => $sent]);
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$tid = trim((string)($_GET['tid'] ?? ''));
$token = trim((string)($_GET['token'] ?? ''));
$error = '';
$done = false;
if ($tid === '' || $token === '') {
$error = 'Invalid or missing reset link.';
} else {
$k = rkey("token:reset:$tid");
$h = redis()->hGetAll($k);
if (!is_array($h) || empty($h['uid']) || empty($h['hash'])) {
$error = 'Reset link has expired or is invalid.';
} elseif (!hash_equals((string)$h['hash'], token_hash($token))) {
$error = 'Invalid reset token.';
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pass1 = (string)($_POST['password'] ?? '');
$pass2 = (string)($_POST['password2'] ?? '');
if ($pass1 === '' || strlen($pass1) < 6) {
$error = 'Password must be at least 6 characters.';
} elseif ($pass1 !== $pass2) {
$error = 'Passwords do not match.';
} else {
$uid = (string)$h['uid'];
redis()->hSet(acct_key($uid), 'password_hash', password_hash($pass1, PASSWORD_DEFAULT));
redis()->del($k);
$done = true;
}
}
}
function he(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reset Password — SvxPortal</title>
<style>
:root{ color-scheme:dark; --bg:#000; --fg:#00ffff; --fg2:rgba(0,255,255,.75); --line:rgba(0,255,255,.35); }
html, body{ height:100%; background:var(--bg); color:var(--fg); margin:0; font-family:system-ui,sans-serif; }
.box{ max-width:400px; margin:60px auto; padding:24px; border:1px solid var(--line); border-radius:10px; }
input{ background:var(--bg); color:var(--fg); border:1px solid rgba(0,255,255,.5); border-radius:4px; padding:6px 10px; width:100%; margin-bottom:10px; box-sizing:border-box; }
input:focus{ outline:none; box-shadow:0 0 0 2px rgba(0,255,255,.25); }
button{ background:rgba(0,255,255,.15); color:var(--fg); border:1px solid rgba(0,255,255,.7); border-radius:4px; padding:6px 16px; cursor:pointer; }
button:hover{ background:rgba(0,255,255,.25); }
.err{ color:#ff8080; font-size:.9rem; margin-bottom:10px; }
.ok{ color:#00ff80; font-size:.9rem; }
a{ color:var(--fg); }
label{ display:block; font-size:.85rem; color:var(--fg2); margin-bottom:4px; }
</style>
</head>
<body>
<div class="box">
<h3 style="margin-top:0;">Reset Password</h3>
<?php if ($done): ?>
<div class="ok">Password changed successfully!</div>
<p><a href="/">Back to SvxPortal</a></p>
<?php elseif ($error && ($_SERVER['REQUEST_METHOD'] !== 'POST' || $tid === '' || $token === '')): ?>
<div class="err"><?=he($error)?></div>
<p><a href="/">Back to SvxPortal</a></p>
<?php else: ?>
<?php if ($error): ?><div class="err"><?=he($error)?></div><?php endif; ?>
<form method="post">
<label>New password</label>
<input type="password" name="password" autocomplete="new-password" required minlength="6">
<label>Confirm password</label>
<input type="password" name="password2" autocomplete="new-password" required>
<button type="submit">Set New Password</button>
</form>
<?php endif; ?>
</div>
<footer style="text-align:center; padding:16px; color:rgba(0,255,255,.4); font-size:.75rem; border-top:1px solid rgba(0,255,255,.15);">
&copy; 2026 SA7AUX. All rights reserved.
</footer>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$j = json_in();
$callsign = base_call(trim((string)($j['callsign'] ?? '')));
$firstname = trim((string)($j['firstname'] ?? ''));
$lastname = trim((string)($j['lastname'] ?? ''));
$email = norm_email((string)($j['email'] ?? ''));
$city = trim((string)($j['city'] ?? ''));
$state = trim((string)($j['state'] ?? ''));
$country = trim((string)($j['country'] ?? ''));
$pass1 = (string)($j['password'] ?? '');
$pass2 = (string)($j['password2'] ?? '');
// Rate limit: 3 registrations per hour per IP
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
rate_limit("register:{$ip}", 3, 3600);
// Validation
if ($callsign === '' || $firstname === '' || $email === '' || $pass1 === '') {
json_out(['ok' => false, 'error' => 'Callsign, first name, email and password are required.'], 400);
}
if (strlen($callsign) < 3 || strlen($callsign) > 10) {
json_out(['ok' => false, 'error' => 'Callsign must be 3-10 characters.'], 400);
}
if (!preg_match('/^[A-Z0-9]+$/', $callsign)) {
json_out(['ok' => false, 'error' => 'Callsign must contain only letters and numbers.'], 400);
}
if ($pass1 !== $pass2) {
json_out(['ok' => false, 'error' => 'Passwords do not match.'], 400);
}
if (strlen($pass1) < 6) {
json_out(['ok' => false, 'error' => 'Password must be at least 6 characters.'], 400);
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
json_out(['ok' => false, 'error' => 'Invalid email address.'], 400);
}
// Global callsign ban blocks registration
if (is_call_banned($callsign)) {
json_out(['ok' => false, 'error' => 'This callsign is not permitted to register.'], 403);
}
// Check duplicates
$r = redis();
$existing_cs = $r->get(call_index_key($callsign));
if (is_string($existing_cs) && $existing_cs !== '') {
json_out(['ok' => false, 'error' => 'Callsign or email already registered.'], 409);
}
$existing_em = $r->get(email_index_key($email));
if (is_string($existing_em) && $existing_em !== '') {
json_out(['ok' => false, 'error' => 'Callsign or email already registered.'], 409);
}
// Create account
$uuid = uuidv4();
$now = (string)time();
$r->hMSet(acct_key($uuid), [
'callsign' => $callsign,
'firstname' => $firstname,
'lastname' => $lastname,
'shortname' => $firstname,
'email' => $email,
'city' => $city,
'state' => $state,
'country' => $country,
'password_hash' => password_hash($pass1, PASSWORD_DEFAULT),
'email_verified' => '0',
'acct_flags' => '1',
'created_at' => $now,
]);
// Create indexes
$r->set(call_index_key($callsign), $uuid);
$r->set(email_index_key($email), $uuid);
// Generate email verification token
$tid = token_id();
$secret = token_secret();
$hash = token_hash($secret);
$k = rkey("token:verify:$tid");
$r->hMSet($k, ['uid' => $uuid, 'hash' => $hash]);
$r->expire($k, 60 * 60 * 24);
// Send verification email
$base = rtrim(cfg()['base_url'], '/');
$link = $base . '/auth/verify.php?tid=' . urlencode($tid) . '&token=' . urlencode($secret);
$body = "Hello {$firstname},\n\n";
$body .= "Thank you for registering on SvxPortal with callsign {$callsign}.\n\n";
$body .= "Please verify your email address by clicking the link below:\n\n";
$body .= "{$link}\n\n";
$body .= "This link is valid for 24 hours.\n\n";
$body .= "After email verification, an administrator will review and activate your account.\n\n";
$body .= "73,\nSvxPortal";
$mail_sent = mail_send($email, "Verify your email - SvxPortal", $body);
json_out(['ok' => true, 'callsign' => $callsign, 'email_sent' => $mail_sent]);
+287
View File
@@ -0,0 +1,287 @@
'use strict';
// Admin TG editor (List B) — mirrors the DMR sysconfig SVX-satellite editor:
// list (data-table) -> per-row Edit -> grouped-field detail form -> an
// "Edit admins (N)" button that drills into an admins editor (owner shown
// first as always-admin, then co-admins with Add/Delete + Save/Back), exactly
// like renderSvxSatelliteEdit_ + renderAdminsEditor_. Backed by tglist_api.php.
var $ = function (id) { return document.getElementById(id); };
var tgaData = [], tgaPending = [], tgaSearch = '', tgaSortKey = 'tg', tgaSortDir = 'asc';
var tgaSelected = new Set(); // selected TG numbers for bulk actions
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
function tgaMsg(t, err) {
var e = $('tgadminMsg'); if (!e) return;
if (!t) { e.style.display = 'none'; return; }
e.style.display = 'block'; e.textContent = t; e.style.color = err ? '#ff8080' : '#0f0';
}
async function tgaApi(body) {
var r = await fetch('/auth/tglist_api.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' },
body: JSON.stringify(body)
});
return r.json().catch(function () { return null; });
}
// regex filter with literal fallback (never throws)
function tgaMatcher(q) {
q = (q || '').trim();
if (!q) return function () { return true; };
var re; try { re = new RegExp(q, 'i'); }
catch (e) { var lit = q.toLowerCase(); return function (s) { return s.toLowerCase().indexOf(lit) >= 0; }; }
return function (s) { return re.test(s); };
}
async function tgaLoad() {
var j = await tgaApi({ action: 'list' });
if (!j || !j.ok) { tgaMsg((j && j.error) || 'Load failed', true); return; }
tgaData = j.tgs || [];
var p = await tgaApi({ action: 'pending_list' });
tgaPending = (p && p.ok && p.pending) ? p.pending : [];
renderList();
}
function pendingHtml() {
if (!tgaPending.length) return '';
var h = '<div class="mb-4"><h6 class="mb-2" style="color:var(--fg2);">Pending requests (' + tgaPending.length + ')</h6>' +
'<div style="overflow-x:auto;"><table class="data-table"><thead><tr><th>TG</th><th>Name</th><th>System</th><th>AllStar</th><th>Mode</th><th>Requester</th><th>Actions</th></tr></thead><tbody>';
tgaPending.forEach(function (p) {
h += '<tr>' +
'<td class="mono">' + esc(p.tg) + '</td><td>' + esc(p.name) + '</td><td>' + esc(p.system) + '</td>' +
'<td class="mono">' + esc(p.allstar) + '</td><td>' + esc(p.mode) + '</td><td class="mono">' + esc(p.callsign) + '</td>' +
'<td style="white-space:nowrap;">' +
'<button class="btn btn-success btn-sm" data-tga-approve="' + esc(p.uuid) + '">Approve</button> ' +
'<button class="btn btn-outline-danger btn-sm" data-tga-deny="' + esc(p.uuid) + '">Deny</button>' +
'</td></tr>';
});
return h + '</tbody></table></div></div>';
}
async function tgaApprove(uuid) {
var res = await tgaApi({ action: 'approve', uuid: uuid });
if (res && res.ok) { tgaMsg('Approved — created TG ' + res.tg, false); await tgaLoad(); }
else tgaMsg((res && res.error) || 'Failed', true);
}
async function tgaDeny(uuid) {
if (!confirm('Deny this TG request?')) return;
var res = await tgaApi({ action: 'deny', uuid: uuid });
if (res && res.ok) { tgaMsg('Denied.', false); await tgaLoad(); }
else tgaMsg((res && res.error) || 'Failed', true);
}
function rowByTg(tg) { var n = parseInt(tg, 10); return tgaData.filter(function (r) { return r.tg === n; })[0]; }
// ── list view (data-table, like renderArrList) ───────────────────────────
function renderList() {
var app = $('tgadminApp'); if (!app) return;
var match = tgaMatcher(tgaSearch);
var rows = tgaData.filter(function (r) { return match([r.tg, r.name, r.system, r.allstar, r.mode, r.owner].join(' ')); });
var key = tgaSortKey, dir = tgaSortDir === 'desc' ? -1 : 1;
rows.sort(function (a, b) {
var x = a[key], y = b[key];
if (key === 'tg') return (x - y) * dir;
x = (x || '').toLowerCase(); y = (y || '').toLowerCase();
return (x < y ? -1 : x > y ? 1 : 0) * dir;
});
var h = pendingHtml() +
'<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">' +
'<h5 class="mb-0">TG List — admin <span class="text-secondary small">(' + rows.length + '/' + tgaData.length + ')</span></h5>' +
'<div class="d-flex gap-2"><button class="btn btn-outline-secondary btn-sm" id="tgaRefresh">Refresh</button>' +
'<button class="btn btn-outline-success btn-sm" id="tgaAdd">+ Add TG</button></div>' +
'</div>' +
'<div class="d-flex gap-2 mb-3 flex-wrap align-items-center">' +
'<input type="text" class="form-control form-control-sm mono" id="tgaSearch" placeholder="search … (regex ok)" style="max-width:300px;" value="' + esc(tgaSearch) + '">' +
'<select class="form-select form-select-sm" id="tgaSortKey" style="width:auto;"><option value="tg">Sort: TG</option><option value="name">Sort: Name</option><option value="system">Sort: System</option><option value="owner">Sort: Owner</option></select>' +
'<button class="btn btn-outline-secondary btn-sm" id="tgaSortDir">' + (tgaSortDir === 'asc' ? '&#9650; asc' : '&#9660; desc') + '</button>' +
'</div>' +
(tgaSelected.size
? '<div class="d-flex gap-2 mb-2 align-items-center"><button class="btn btn-danger btn-sm" id="tgaBulkDel">Delete selected</button><span class="small text-secondary">' + tgaSelected.size + ' selected</span></div>'
: '') +
'<div style="overflow-x:auto;"><table class="data-table">' +
'<thead><tr><th style="width:28px;"><input type="checkbox" id="tgaSelAll"></th><th>TG</th><th>Name</th><th>System</th><th>AllStar</th><th>Mode</th><th>Owner</th><th>Admins</th><th>Actions</th></tr></thead><tbody>';
rows.forEach(function (r) {
h += '<tr>' +
'<td><input type="checkbox" class="tga-sel" data-tga-sel="' + esc(r.tg) + '"' + (tgaSelected.has(r.tg) ? ' checked' : '') + '></td>' +
'<td class="mono">' + esc(r.tg) + '</td>' +
'<td>' + esc(r.name) + '</td>' +
'<td>' + esc(r.system) + '</td>' +
'<td class="mono">' + esc(r.allstar) + '</td>' +
'<td>' + esc(r.mode) + '</td>' +
'<td class="mono">' + (r.owner ? esc(r.owner) : '<span class="text-secondary">—</span>') + '</td>' +
'<td>' + ((r.admins && r.admins.length) ? r.admins.length : '<span class="text-secondary">0</span>') + '</td>' +
'<td style="white-space:nowrap;">' +
'<button class="btn btn-outline-secondary btn-sm" data-tga-edit="' + esc(r.tg) + '">Edit</button> ' +
'<button class="btn btn-outline-danger btn-sm" data-tga-del="' + esc(r.tg) + '">Delete</button>' +
'</td></tr>';
});
h += '</tbody></table></div>';
if (!tgaData.length) h += '<p class="small text-secondary mt-2">No talkgroups yet — click <b>+ Add TG</b>.</p>';
app.innerHTML = h;
$('tgaRefresh').addEventListener('click', tgaLoad);
$('tgaAdd').addEventListener('click', function () { renderDetail(null); });
var s = $('tgaSearch');
s.addEventListener('input', function () { tgaSearch = s.value; var pos = s.selectionStart; renderList(); var s2 = $('tgaSearch'); if (s2) { s2.focus(); try { s2.setSelectionRange(pos, pos); } catch (e) {} } });
$('tgaSortKey').value = tgaSortKey;
$('tgaSortKey').addEventListener('change', function () { tgaSortKey = this.value; renderList(); });
$('tgaSortDir').addEventListener('click', function () { tgaSortDir = tgaSortDir === 'asc' ? 'desc' : 'asc'; renderList(); });
app.querySelectorAll('button[data-tga-edit]').forEach(function (b) { b.addEventListener('click', function () { var r = rowByTg(b.getAttribute('data-tga-edit')); if (r) renderDetail(r); }); });
app.querySelectorAll('button[data-tga-del]').forEach(function (b) { b.addEventListener('click', function () { tgaDelete(b.getAttribute('data-tga-del')); }); });
app.querySelectorAll('button[data-tga-approve]').forEach(function (b) { b.addEventListener('click', function () { tgaApprove(b.getAttribute('data-tga-approve')); }); });
app.querySelectorAll('button[data-tga-deny]').forEach(function (b) { b.addEventListener('click', function () { tgaDeny(b.getAttribute('data-tga-deny')); }); });
// Multi-select + bulk delete (like the SVX-satellite TG-map editor).
var shownTgs = rows.map(function (r) { return r.tg; });
var selAll = $('tgaSelAll');
if (selAll) {
selAll.checked = shownTgs.length > 0 && shownTgs.every(function (tg) { return tgaSelected.has(tg); });
selAll.addEventListener('change', function () {
if (selAll.checked) shownTgs.forEach(function (tg) { tgaSelected.add(tg); });
else shownTgs.forEach(function (tg) { tgaSelected.delete(tg); });
renderList();
});
}
app.querySelectorAll('input.tga-sel').forEach(function (cb) {
cb.addEventListener('change', function () {
var tg = parseInt(cb.getAttribute('data-tga-sel'), 10);
if (cb.checked) tgaSelected.add(tg); else tgaSelected.delete(tg);
renderList();
});
});
var bulk = $('tgaBulkDel');
if (bulk) bulk.addEventListener('click', tgaBulkDelete);
}
async function tgaBulkDelete() {
var tgs = Array.from(tgaSelected);
if (!tgs.length) return;
if (!confirm('Delete ' + tgs.length + ' selected TG' + (tgs.length === 1 ? '' : 's') + '?')) return;
for (var i = 0; i < tgs.length; i++) { await tgaApi({ action: 'delete', tg: tgs[i] }); }
tgaSelected.clear();
tgaMsg('Deleted ' + tgs.length + ' TG' + (tgs.length === 1 ? '' : 's'), false);
await tgaLoad();
}
// ── detail view: grouped labeled fields (like renderSvxSatelliteEdit_) ────
function fieldRow(label, inputHtml) {
return '<div class="d-flex gap-2 align-items-center mb-1">' +
'<label class="small text-secondary" style="min-width:160px;text-align:right;">' + esc(label) + '</label>' +
inputHtml + '</div>';
}
function groupHead(t) { return '<h6 class="mt-3 mb-2" style="color:var(--fg2);">' + esc(t) + '</h6>'; }
function renderDetail(row) {
var app = $('tgadminApp'); if (!app) return;
var isNew = !row;
var r = row || { tg: '', name: '', system: '', allstar: '', mode: '', owner: '', admins: [] };
var inp = function (id, val, extra) { return '<input id="' + id + '" class="form-control form-control-sm mono" style="max-width:360px;" ' + (extra || '') + ' value="' + esc(val) + '">'; };
var h =
'<div class="d-flex justify-content-between align-items-center mb-3">' +
'<h5 class="mb-0">' + (isNew ? 'Add TG' : ('Edit: TG ' + esc(r.tg) + (r.name ? ' — ' + esc(r.name) : ''))) + '</h5>' +
'<button class="btn btn-outline-secondary btn-sm" id="tgaBack">Back</button>' +
'</div>' +
groupHead('Identity') +
fieldRow('tg', inp('tgaf_tg', r.tg, 'type="number"' + (isNew ? '' : ' disabled'))) +
fieldRow('name', inp('tgaf_name', r.name, 'maxlength="64"')) +
groupHead('Location') +
fieldRow('system', inp('tgaf_system', r.system, 'maxlength="64"')) +
fieldRow('allstar', inp('tgaf_allstar', r.allstar, 'maxlength="10"')) +
fieldRow('mode', inp('tgaf_mode', r.mode, 'maxlength="16" placeholder="AllStar / DMR / SVX"')) +
groupHead('Ownership') +
fieldRow('owner', '<span class="mono" style="align-self:center;">' + (r.owner ? esc(r.owner) : '<span class="text-secondary">— none (set when a TG request is approved) —</span>') + '</span>') +
fieldRow('admins', '<button type="button" class="btn btn-sm btn-outline-secondary" id="tgaEditAdmins"' + (isNew ? ' disabled title="Save the TG first"' : '') + '>Edit admins (' + ((r.admins && r.admins.length) || 0) + ')</button>') +
'<div class="d-flex gap-2 mt-3">' +
'<button class="btn btn-success btn-sm" id="tgaSave">Save</button>' +
(isNew ? '' : '<button class="btn btn-outline-danger btn-sm" id="tgaDel">Delete</button>') +
'<button class="btn btn-outline-secondary btn-sm" id="tgaCancel">Cancel</button>' +
'</div>';
app.innerHTML = h;
$('tgaBack').addEventListener('click', renderList);
$('tgaCancel').addEventListener('click', renderList);
$('tgaSave').addEventListener('click', tgaSave);
var del = $('tgaDel'); if (del) del.addEventListener('click', function () { tgaDelete(r.tg); });
var ea = $('tgaEditAdmins'); if (ea && !isNew) ea.addEventListener('click', function () { renderAdmins(r); });
}
async function tgaSave() {
var tg = parseInt($('tgaf_tg').value, 10);
if (!tg || tg <= 0) { tgaMsg('TG must be a positive number', true); return; }
var res = await tgaApi({
action: 'save', tg: tg,
name: $('tgaf_name').value, system: $('tgaf_system').value,
allstar: ($('tgaf_allstar').value || '').trim(), mode: $('tgaf_mode').value
});
if (res && res.ok) { tgaMsg('Saved TG ' + tg, false); await tgaLoad(); }
else tgaMsg((res && res.error) || 'Failed', true);
}
async function tgaDelete(tg) {
if (!confirm('Delete TG ' + tg + '?')) return;
var res = await tgaApi({ action: 'delete', tg: parseInt(tg, 10) });
if (res && res.ok) { tgaMsg('Deleted TG ' + tg, false); await tgaLoad(); }
else tgaMsg((res && res.error) || 'Failed', true);
}
// ── admins drill-down (like renderAdminsEditor_) ─────────────────────────
function renderAdmins(row) {
var app = $('tgadminApp'); if (!app) return;
var owner = (row.owner || '').toUpperCase();
var working = (row.admins || []).map(function (c) { return String(c); });
var draw = function () {
var body = '';
// Owner row: always an admin, shown first with a badge, not removable here.
if (owner) {
body += '<tr><td class="mono">' + esc(owner) + ' <span class="badge bg-info text-dark small">owner</span></td><td></td></tr>';
}
if (working.length === 0 && !owner) {
body += '<tr><td colspan="2" class="small text-secondary">No admins — click Add to grant a callsign admin rights for this TG. (Account admin level always has access.)</td></tr>';
}
working.forEach(function (cs, i) {
body += '<tr>' +
'<td><input type="text" class="form-control form-control-sm mono" data-adm-i="' + i + '" value="' + esc(cs) + '" placeholder="SA7AUX" style="text-transform:uppercase;max-width:220px;"></td>' +
'<td style="width:120px;"><button class="btn btn-sm btn-outline-danger" data-adm-del="' + i + '">Delete</button></td>' +
'</tr>';
});
var h =
'<div class="d-flex justify-content-between align-items-center mb-2">' +
'<h6 class="mb-0" style="color:var(--fg2);">Admins (callsigns) — TG ' + esc(row.tg) + '</h6>' +
'<button class="btn btn-outline-secondary btn-sm" id="admBackTop">Back</button>' +
'</div>' +
'<div class="mb-2"><button type="button" class="btn btn-sm btn-outline-success" id="admAdd">Add</button></div>' +
'<div style="overflow-x:auto;"><table class="data-table"><thead><tr><th>Callsign</th><th style="width:120px;">Action</th></tr></thead><tbody>' +
body + '</tbody></table></div>' +
'<div class="d-flex gap-2 mt-3">' +
'<button class="btn btn-primary btn-sm" id="admSave">Save</button>' +
'<button class="btn btn-outline-secondary btn-sm ms-auto" id="admBack">Back</button>' +
'</div>';
app.innerHTML = h;
var back = function () { renderDetail(row); };
$('admBackTop').addEventListener('click', back);
$('admBack').addEventListener('click', back);
$('admAdd').addEventListener('click', function () { working.push(''); draw(); });
app.querySelectorAll('input[data-adm-i]').forEach(function (el) {
el.addEventListener('input', function () { working[parseInt(el.getAttribute('data-adm-i'), 10)] = el.value; });
});
app.querySelectorAll('button[data-adm-del]').forEach(function (b) {
b.addEventListener('click', function () { working.splice(parseInt(b.getAttribute('data-adm-del'), 10), 1); draw(); });
});
$('admSave').addEventListener('click', async function () {
var seen = {}, list = [];
working.forEach(function (c) { c = String(c).trim().toUpperCase(); if (c && !seen[c]) { seen[c] = 1; list.push(c); } });
var res = await tgaApi({ action: 'admin_set', tg: row.tg, admins: list });
if (res && res.ok) { row.admins = res.admins || list; tgaMsg('Admins saved for TG ' + row.tg, false); await tgaLoad(); renderDetail(rowByTg(row.tg) || row); }
else tgaMsg((res && res.error) || 'Failed', true);
});
};
draw();
}
document.addEventListener('DOMContentLoaded', function () {
fetch('/auth/me.php', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) { if (j && j.csrf_token) window._csrfToken = j.csrf_token; })
.catch(function () {})
.finally(tgaLoad);
});
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
start_session();
$uuid = $_SESSION['uid'] ?? null;
if (!is_string($uuid) || $uuid === '') { header('Location: /'); exit; }
$lvl = acct_admin_level($uuid);
// Admin tool: LVL2+ (admin ranks lvl1/lvl2/lvl3 all collapse to level>=2). Owners
// who are NOT admins use the separate self-service page (admin_tglist.php).
if ($lvl < 2) { header('Location: /'); exit; }
$page_title = 'Admin · TG List — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:1000px;">
<div id="page-tgadmin">
<p class="small text-secondary mb-3">Admin TG manager — add / edit / delete any talkgroup and its full record. <b>Approve</b> assigns an owner callsign so that operator can then self-manage their own TG from the user TG List. This is the admin master; the user page is separate.</p>
<div id="tgadminApp"></div>
<div id="tgadminMsg" class="small mt-2" style="display:none;"></div>
</div>
</div>
<?php
$extra_scripts = '<script>var ADMIN_LEVEL = ' . $lvl . ';</script><script src="/auth/tgadmin.js?v=' . @filemtime(__DIR__ . '/tgadmin.js') . '"></script>';
require __DIR__ . '/footer.php';
?>
+217
View File
@@ -0,0 +1,217 @@
<?php
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);
+82
View File
@@ -0,0 +1,82 @@
'use strict';
// Request a TG — mirrors the DMR tgrequest flow. Submit -> pending -> an admin
// approves (in the admin TG editor) -> you become owner. One open request/user.
var $ = function (id) { return document.getElementById(id); };
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
function reqMsg(t, err) { var e = $('reqMsg'); if (!e) return; if (!t) { e.style.display = 'none'; return; } e.style.display = 'block'; e.textContent = t; e.style.color = err ? '#ff8080' : '#0f0'; }
async function reqApi(body) {
var r = await fetch('/auth/tglist_api.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window._csrfToken || '' },
body: JSON.stringify(body)
});
return r.json().catch(function () { return null; });
}
function showPending(p) {
var box = $('reqPending'), form = $('reqForm'), cancel = $('reqCancel');
if (p) {
box.style.display = 'block';
box.innerHTML = 'You have a pending request for <b>TG ' + esc(p.tg) + '</b> ("' + esc(p.name) + '"). An admin will review it.';
// lock the form fields to the pending values
$('reqTg').value = p.tg; $('reqName').value = p.name || ''; $('reqSystem').value = p.system || '';
$('reqAllstar').value = p.allstar || ''; $('reqMode').value = p.mode || '';
Array.prototype.forEach.call(form.querySelectorAll('input'), function (i) { i.disabled = true; });
$('reqSubmit').disabled = true;
cancel.style.display = '';
} else {
box.style.display = 'none';
Array.prototype.forEach.call(form.querySelectorAll('input'), function (i) { i.disabled = false; });
$('reqSubmit').disabled = false;
cancel.style.display = 'none';
}
}
async function loadPending() {
var j = await reqApi({ action: 'my_request' });
if (j && j.ok) showPending(j.pending || null);
}
var ERRS = {
no_callsign: 'Your account has no callsign — set one in your profile first.',
invalid_tg: 'Enter a valid TG number.',
tg_already_taken: 'That TG already exists.',
pending_request_exists: 'You already have a pending request.',
name_required: 'Name is required.',
invalid_allstar: 'AllStar node must be digits only.'
};
document.addEventListener('DOMContentLoaded', function () {
fetch('/auth/me.php', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (j && j.csrf_token) window._csrfToken = j.csrf_token;
var cs = (j && j.callsign) ? String(j.callsign).toUpperCase() : '';
var el = $('reqCallsign');
if (el) el.innerHTML = cs ? esc(cs) : '<span style="color:#ff8080;">No callsign on your account — set one in your profile first.</span>';
})
.catch(function () {})
.finally(loadPending);
$('reqForm').addEventListener('submit', async function (e) {
e.preventDefault();
var tg = parseInt($('reqTg').value, 10);
if (!tg || tg <= 0) { reqMsg('Enter a valid TG number', true); return; }
if (!($('reqName').value || '').trim()) { reqMsg('Name is required', true); return; }
var res = await reqApi({
action: 'request', tg: tg,
name: $('reqName').value, system: $('reqSystem').value,
allstar: ($('reqAllstar').value || '').trim(), mode: $('reqMode').value
});
if (res && res.ok) { reqMsg('Request submitted — awaiting admin approval.', false); await loadPending(); }
else reqMsg(ERRS[res && res.error] || (res && res.error) || 'Failed', true);
});
$('reqCancel').addEventListener('click', async function () {
if (!confirm('Cancel your pending TG request?')) return;
var res = await reqApi({ action: 'request_cancel' });
if (res && res.ok) { reqMsg('Request cancelled.', false); await loadPending(); }
else reqMsg((res && res.error) || 'Failed', true);
});
});
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
start_session();
$uuid = $_SESSION['uid'] ?? null;
if (!is_string($uuid) || $uuid === '') { header('Location: /'); exit; }
$page_title = 'Request a TG — SvxPortal';
require __DIR__ . '/header.php';
?>
<div class="container py-4" style="max-width:640px;">
<div class="panel">
<h5 class="mb-3">Request a Talkgroup</h5>
<p class="small text-secondary mb-3">Request a TG for your callsign. An admin reviews it; once approved you become the TG's owner and can manage its details (and co-admins) from your TG List. One open request at a time.</p>
<div id="reqPending" class="alert alert-info small" style="display:none;"></div>
<div class="mb-3">
<label class="small text-secondary">Callsign — becomes the TG owner on approval</label>
<div id="reqCallsign" class="mono" style="padding:6px 10px;border:1px solid var(--line);border-radius:4px;background:var(--bg);">—</div>
</div>
<form id="reqForm">
<div class="mb-2"><label class="small text-secondary">TG number</label>
<input id="reqTg" type="number" class="form-control form-control-sm mono" required></div>
<div class="mb-2"><label class="small text-secondary">Name</label>
<input id="reqName" class="form-control form-control-sm" maxlength="64" required></div>
<div class="mb-2"><label class="small text-secondary">System (reflector / network)</label>
<input id="reqSystem" class="form-control form-control-sm" maxlength="64"></div>
<div class="mb-2"><label class="small text-secondary">AllStar node</label>
<input id="reqAllstar" class="form-control form-control-sm mono" maxlength="10"></div>
<div class="mb-3"><label class="small text-secondary">Mode</label>
<input id="reqMode" class="form-control form-control-sm" maxlength="16" placeholder="AllStar / DMR / SVX"></div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success btn-sm" id="reqSubmit">Submit request</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="reqCancel" style="display:none;">Cancel my request</button>
</div>
</form>
<div id="reqMsg" class="small mt-3" style="display:none;"></div>
</div>
</div>
<?php
$extra_scripts = '<script src="/auth/tgrequest.js?v=' . @filemtime(__DIR__ . '/tgrequest.js') . '"></script>';
require __DIR__ . '/footer.php';
?>
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
// Per-account web theme preference (so a signed-in operator's theme follows them
// across devices/browsers). Stored on the account hash — me.php returns the whole
// hash, so 'web_theme' + 'web_theme_colors' surface there automatically.
// web_theme: 'dark' | 'light' | 'color'
// web_theme_colors: compact JSON { bg,panel,fg,fg2,line,accent } of #hex values
// Anonymous visitors never reach this endpoint — they use localStorage only.
$uuid = require_login();
csrf_check();
$j = json_in();
$updated = [];
if (array_key_exists('theme', $j)) {
$t = is_string($j['theme']) ? strtolower(trim($j['theme'])) : '';
if (!in_array($t, ['dark', 'light', 'color'], true)) {
json_out(['ok' => false, 'error' => 'invalid_theme', 'allowed' => ['dark', 'light', 'color']], 400);
}
redis()->hSet(acct_key($uuid), 'web_theme', $t);
$updated['web_theme'] = $t;
}
if (array_key_exists('colors', $j)) {
$c = $j['colors'];
$allow = ['bg', 'panel', 'fg', 'fg2', 'line', 'accent'];
$clean = [];
if (is_array($c)) {
foreach ($allow as $k) {
if (isset($c[$k]) && is_string($c[$k]) && preg_match('/^#[0-9a-fA-F]{3,8}$/', $c[$k])) {
$clean[$k] = strtolower($c[$k]);
}
}
}
// Cast to object so an empty selection serialises as '{}', never '[]'.
$json = json_encode((object)$clean, JSON_UNESCAPED_SLASHES);
if ($json === false || strlen($json) > 500) {
json_out(['ok' => false, 'error' => 'invalid_colors'], 400);
}
redis()->hSet(acct_key($uuid), 'web_theme_colors', $json);
$updated['web_theme_colors'] = $clean;
}
if (empty($updated)) {
json_out(['ok' => false, 'error' => 'nothing_to_update'], 400);
}
json_out(['ok' => true, 'updated' => $updated]);
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$uuid = require_login();
$r = redis();
$events = $r->lRange(rkey("user_events:$uuid"), 0, 49);
$list = [];
if (is_array($events)) {
foreach ($events as $e) {
$d = json_decode($e, true);
if (is_array($d)) $list[] = $d;
}
}
json_out(['ok' => true, 'events' => $list]);
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
$tid = trim((string)($_GET['tid'] ?? ''));
$token = trim((string)($_GET['token'] ?? ''));
if ($tid === '' || $token === '') {
http_response_code(400);
echo "Bad request";
exit;
}
$k = rkey("token:verify:$tid");
$h = redis()->hGetAll($k);
if (!is_array($h) || empty($h['uid']) || empty($h['hash'])) {
http_response_code(400);
echo "Invalid or expired token";
exit;
}
if (!hash_equals((string)$h['hash'], token_hash($token))) {
http_response_code(400);
echo "Invalid token";
exit;
}
$uid = (string)$h['uid'];
redis()->hSet(acct_key($uid), 'email_verified', '1');
// Flags: unreg -> reg. An admin must then set normal before the account gets in.
$f = redis()->hGet(acct_key($uid), 'acct_flags');
$f = ($f === false || $f === '') ? 1 : ((int)$f & 0x3F);
redis()->hSet(acct_key($uid), 'acct_flags', (string)((($f & ~1) | 2)));
redis()->del($k);
header('Content-Type: text/html; charset=utf-8');
echo "<!doctype html><meta charset='utf-8'><title>Verified</title><body style='font-family:system-ui;background:#000;color:#0ff;padding:20px;'>
<h2>OK</h2>
<div>Your e-mail is verified. Your account is now registered and waiting for an admin to approve it &mdash; once that is done you can sign in.</div>
<div style='margin-top:12px;'><a href='/svxportal/' style='color:#0ff;'>Back to SvxPortal</a></div>
</body>";