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
+11
View File
@@ -0,0 +1,11 @@
# Secrets — never commit
auth/config.php
# Backups / editor cruft
*.bak
*.bak.*
*~
*.orig
*.save
*.swp
*.bak-*
+16
View File
@@ -0,0 +1,16 @@
SvxPortal auth drop-in
Endpoints:
- /svxportal/auth/login.php (POST JSON {user, pass})
- /svxportal/auth/signup.php (POST JSON {email, callsign?, pass})
- /svxportal/auth/verify.php (GET tid, token)
- /svxportal/auth/consent.php (POST JSON {consent:'necessary'|'all'})
- /svxportal/auth/logout.php
Redis schema:
- registry:svxlink:acct:<uuid> (HASH) includes admin_level 1..3
- registry:svxlink:acct_by_email:<email_lower> -> uuid
- registry:svxlink:call:<CALLSIGN_UPPER> -> uuid
- registry:svxlink:acct_calls:<uuid> (SET) callsigns
- registry:svxlink:node:<node_id> -> uuid
- registry:svxlink:acct_nodes:<uuid> (SET) nodes
+251
View File
@@ -0,0 +1,251 @@
# SvxPortal web — installation HOWTO
This is the **web front-end only** for a SvxReflector: node map, monitor,
last-heard, talkgroup list, user accounts and the reflector admin pages.
It is plain HTML/JavaScript plus a small PHP backend that stores accounts in
Redis. No build step, no framework, no composer.
What you need on the server:
| Component | Why |
|-----------|-----|
| nginx | serves the static files and passes `*.php` to PHP-FPM |
| PHP 8.2+ with FPM | the `auth/` backend (login, accounts, admin API) |
| PHP extensions: `redis`, `mbstring`, `openssl`, `json`, `session` | used by `auth/lib.php` |
| Redis 6+ | stores accounts, sessions, bans, talkgroup requests |
| A way to send e-mail | signup verification and password reset (local MTA **or** an SMTP relay, see step 5) |
| TLS certificate | the session cookie is `Secure`; login does not work over plain HTTP |
Everything below assumes Debian 12/13 or Ubuntu 24.04, PHP 8.4 and that the
site will live at `https://svxportal.example.com`. Replace the host name
everywhere you see it.
---
## 1. Packages
```bash
sudo apt install nginx php8.4-fpm php8.4-redis php8.4-mbstring php8.4-cli redis-server certbot
```
Check the PHP version you actually got (`php -v`) and use that number instead
of `8.4` in the file names below.
## 2. Copy the web files
```bash
sudo mkdir -p /var/www/html/svxportal
sudo rsync -a --exclude .git --exclude install --exclude INSTALL.md ./ /var/www/html/svxportal/
sudo chown -R www-data:www-data /var/www/html/svxportal
sudo find /var/www/html/svxportal -type d -exec chmod 2775 {} +
sudo find /var/www/html/svxportal -type f -exec chmod 0644 {} +
```
## 3. Redis (dedicated instance, unix socket)
```bash
sudo cp install/redis-svxportal.conf /etc/redis/redis-svxportal.conf
sudo mkdir -p /var/lib/redis-svxportal
sudo chown redis:redis /var/lib/redis-svxportal /etc/redis/redis-svxportal.conf
sudo chmod 640 /etc/redis/redis-svxportal.conf
```
Generate a password and put it in the config:
```bash
openssl rand -hex 32
sudo sed -i "s/^requirepass .*/requirepass PASTE-THE-VALUE-HERE/" /etc/redis/redis-svxportal.conf
sudo systemctl enable --now redis-server@svxportal
```
The socket is created with mode 770 and group `redis`, so the web user must be
in that group:
```bash
sudo usermod -aG redis www-data
```
Test:
```bash
redis-cli -s /run/redis-svxportal/redis.sock -a 'PASTE-THE-VALUE-HERE' ping # -> PONG
```
## 4. PHP-FPM
Two pools are used. The default pool (`www`) handles all normal pages. A
second small pool handles only `auth/admin_reflector_api.php`, because that
script calls `exec()` to reach the sudo helpers (step 9) and you do not want
`exec()` enabled for the whole site.
```bash
sudo cp install/php-fpm-svxadmin.conf /etc/php/8.4/fpm/pool.d/svxadmin.conf
```
In the default pool hardening is recommended. Edit
`/etc/php/8.4/fpm/pool.d/www.conf` and add at the end:
```ini
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,popen,proc_open,proc_close,pcntl_exec,dl,phpinfo,show_source
php_admin_value[expose_php] = Off
```
Then:
```bash
sudo systemctl restart php8.4-fpm
ls -l /run/php/ # both php8.4-fpm.sock and php8.4-fpm-svxadmin.sock must exist
```
## 5. Web app config (`auth/config.php`)
```bash
cd /var/www/html/svxportal
sudo -u www-data cp auth/config.php.example auth/config.php
sudo chmod 640 auth/config.php
sudoedit auth/config.php
```
Set:
- `redis.unix_socket` -> `/run/redis-svxportal/redis.sock`
- `redis.password` -> the value from step 3
- `base_url` -> `https://svxportal.example.com` (used in the e-mail links)
- `mail.from` -> the sender address for verification / reset mails
E-mail: with only `mail.from` set, PHP's `mail()` is used, which needs a
working local MTA (`/usr/sbin/sendmail`, e.g. `msmtp-mta` or `postfix`).
If you do not have one, add an SMTP relay instead and no MTA is needed:
```php
'mail' => [
'from' => 'no-reply@example.com',
'from_name' => 'SvxPortal',
'host' => 'smtp.example.com', // SSL SMTP (implicit TLS)
'port' => 465,
'user' => 'smtp-user',
'pass' => 'smtp-password',
],
```
`config.php` is listed in `.gitignore` and is never served by nginx (all
`*.php` go to PHP-FPM, which just executes `return [...]`). Keep it mode 640.
## 6. Talkgroup list file
The talkgroup list is a JSON file the portal reads and writes:
```bash
sudo cp install/tmpfiles-svxportal-tglist.conf /etc/tmpfiles.d/svxportal-tglist.conf
sudo systemd-tmpfiles --create /etc/tmpfiles.d/svxportal-tglist.conf
```
This creates `/etc/svxlink/tglist.json` owned by `www-data`. The path is
`tglist_file` in `config.php`; change both if you want it elsewhere.
## 7. nginx + TLS
```bash
sudo mkdir -p /var/www/letsencrypt
sudo cp install/nginx-svxportal.conf /etc/nginx/sites-available/svxportal
sudo sed -i 's/svxportal\.example\.com/YOUR.HOST.NAME/g' /etc/nginx/sites-available/svxportal
```
Get a certificate first (the config references it):
```bash
sudo certbot certonly --webroot -w /var/www/letsencrypt -d YOUR.HOST.NAME
```
If certbot has not created `/etc/letsencrypt/options-ssl-nginx.conf` and
`ssl-dhparams.pem`, run `sudo certbot --nginx` once or remove those two lines.
```bash
sudo ln -s /etc/nginx/sites-available/svxportal /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
Open `https://YOUR.HOST.NAME/` — the page should render. Click **Sign in** and
you should get a proper "wrong password" style error, not a 500. A 500 here
almost always means Redis (wrong socket/password, or `www-data` not in group
`redis`).
Also edit these files in the web root for your own site name: `robots.txt`,
`sitemap.xml` and the `<title>`/meta tags at the top of `index.html`.
## 8. First admin account
Accounts normally self-register and verify by e-mail. The **first** admin is
created from the command line with the bundled script; it reuses the
portal's own library, so it writes exactly what `signup.php` would:
```bash
cd /var/www/html/svxportal
sudo -u www-data php svx-mkadmin.php
```
Answer the prompts (callsign, e-mail, password, admin level). Levels:
| Level | Meaning |
|-------|---------|
| 1 | normal user |
| 2 | admin: accounts, nodes, certificates, talkgroups |
| 3 | super admin: may also see reflector/MQTT secrets and manage other admins |
Log in on the web page with that callsign. The **Admin** menu appears.
## 9. Live data API — what this repository does NOT contain
The dashboard (node list, map, monitor, last-heard, trunks) fetches JSON and
Server-Sent Events from `/svxportal/api/...`, which nginx proxies to
`127.0.0.1:8098`. That backend service is **not included** here. Without it
the account/admin/talkgroup pages work, but the live node views stay empty
(the page shows a connection error banner).
Endpoints the UI expects:
| Path | Used by |
|------|---------|
| `/svxportal/api/status` | node table, topology |
| `/svxportal/api/stream` | live updates (SSE) |
| `/svxportal/api/lastheard` | last-heard list |
| `/svxportal/api/monitor-nodes` | monitor view |
| `/svxportal/api/local-trunks` | trunks view |
You need to provide something that serves these, for example a small service
that reads your svxreflector's `HTTP_SRV_PORT` status endpoint and republishes
it. The static reflector status page in `help.html` describes the fields.
## 10. Reflector admin features (optional, need sudo helpers)
The pages **Admin → Reflector**, **Certificates**, **Node control** and **MQTT**
talk to the running svxreflector on the same machine. They do it through
small root-owned helper scripts called with `sudo` from
`auth/admin_reflector_api.php`, `auth/node_cmd_api.php` and
`auth/node_auth_api.php`:
| Helper (expected in `/usr/local/sbin/`) | Purpose |
|------------------------------------------|---------|
| `svxreflector-pty` | writes `NODE BLOCK/UNBLOCK` and CA commands to the reflector's `COMMAND_PTY` |
| `svxreflector-systemctl` | restart/reload the `svxreflector` service |
| `svxreflector-users` | add/remove `[USERS]`/`[PASSWORDS]` entries in `svxreflector.conf` |
| `svx-mqtt-provision` | create/remove per-node MQTT accounts |
Those helpers and their `/etc/sudoers.d/` rules are **not part of this
repository**; they are specific to how each reflector box is set up. Write
your own for your environment (validate the callsign argument strictly, allow
`www-data` to run only that one script with `NOPASSWD`), or skip this: all
other pages work without them and the admin API returns a clear error when a
helper is missing.
The certificate pages additionally read `/etc/svxlink/svxreflector.conf` to
find `CERT_PKI_DIR` and watch `pending_csrs/` in it, so `www-data` needs read
access to the conf and the PKI directories (never to the private keys).
## 11. Checklist when something does not work
- **500 on any `auth/*.php`**: `sudo tail -f /var/log/nginx/svxportal-error.log` and `journalctl -u php8.4-fpm`. Usually Redis auth or missing `php-redis`.
- **"Not connected" banner, empty node list**: no backend on `127.0.0.1:8098` (step 9).
- **No verification e-mail**: `mail()` without an MTA silently fails. Configure the SMTP relay (step 5) and check `journalctl -u php8.4-fpm` for `smtp:` lines.
- **Login works but is lost on next page**: site not on HTTPS (cookie is `Secure`) or `base_url` wrong.
- **Admin API says "exec disabled"**: `admin_reflector_api.php` was served by the default pool. Check the nginx `location =` block and that `php8.4-fpm-svxadmin.sock` exists.
+18
View File
@@ -0,0 +1,18 @@
# SvxPortal web
Web front-end for a [SvxReflector](https://github.com/sm0svx/svxlink): live node
list and map, monitor, last-heard, talkgroup list, user accounts with e-mail
verification, and admin pages for accounts, node certificates, node control and
talkgroup requests.
Static HTML/JS (Bootstrap from jsdelivr) plus a small PHP backend that keeps
its data in Redis. No build step.
- **Install:** see [INSTALL.md](INSTALL.md) — nginx, PHP-FPM, Redis, first admin.
- **Config templates:** [`install/`](install/)
- **User help:** `help.html` (served at `/help.html`)
This repository contains the web application only. The live-data API it
consumes (`/svxportal/api/...`) and the sudo helper scripts used by the
reflector admin pages are site-specific and not included; INSTALL.md
sections 9 and 10 describe what they must provide.
+233
View File
@@ -0,0 +1,233 @@
'use strict';
let lastNodes = {};
let lastUpdated = 0;
let lastStatus = {};
function setNavActive(which){
const ids = ["navList","navClients","navMonitor","navTgmon","navLastheard","navCTCSS","navMap","navChangelog"];
for(const id of ids){
const el = document.getElementById(id);
if(!el) continue;
el.classList.toggle("active", id === which);
}
}
function setActive(panel){
const listP = document.getElementById('listPanel');
const cliP = document.getElementById('clientsPanel');
const monP = document.getElementById('monitorPanel');
const trkP = document.getElementById('trunksPanel');
const topP = document.getElementById('topologyPanel');
const lhP = document.getElementById('lastheardPanel');
const ctcP = document.getElementById('ctcssPanel');
const chgP = document.getElementById('changelogPanel');
const tgmP = document.getElementById('tgmonPanel');
if(listP) listP.style.display = (panel === 'list') ? 'block' : 'none';
if(cliP) cliP.style.display = (panel === 'clients') ? 'block' : 'none';
if(monP) monP.style.display = (panel === 'monitor') ? 'block' : 'none';
if(trkP) trkP.style.display = (panel === 'trunks') ? 'block' : 'none';
if(topP) topP.style.display = (panel === 'topology') ? 'block' : 'none';
if(lhP) lhP.style.display = (panel === 'lastheard') ? 'block' : 'none';
if(ctcP) ctcP.style.display = (panel === 'ctcss') ? 'block' : 'none';
if(chgP) chgP.style.display = (panel === 'changelog') ? 'block' : 'none';
if(tgmP) tgmP.style.display = (panel === 'tgmon') ? 'block' : 'none';
}
function render(status){
const nodes = status.nodes || {};
lastNodes = nodes;
lastStatus = status;
window.__svxportal_lastStatus = status;
window.__svxportal_lastNodes = nodes;
updateTalkerPopup(nodes, status.talker_meta);
// Header date/time/age
const now = new Date();
document.getElementById('dateTxt').textContent =
now.toLocaleDateString('en-GB', {weekday:'short', month:'short', day:'numeric'});
document.getElementById('timeTxt').textContent =
fmt2(now.getHours()) + ":" + fmt2(now.getMinutes()) + ":" + fmt2(now.getSeconds());
const age = lastUpdated ? Math.max(0, Math.floor((Date.now()-lastUpdated)/1000)) : 0;
document.getElementById('ageTxt').textContent = lastUpdated ? (age + "s") : "-";
// Render visible panels
if (document.getElementById('listPanel')?.style.display !== 'none') renderList(nodes);
if (document.getElementById('clientsPanel')?.style.display !== 'none') renderClients(nodes);
if (document.getElementById('monitorPanel')?.style.display !== 'none') renderMonitor(nodes, status);
if (document.getElementById('trunksPanel')?.style.display !== 'none') window.renderTrunks?.(lastStatus);
if (document.getElementById('topologyPanel')?.style.display !== 'none') window.renderTopology?.(lastStatus);
if (document.getElementById('lastheardPanel')?.style.display !== 'none') window.refreshLastheard?.();
if (document.getElementById('ctcssPanel')?.style.display !== 'none') window.renderCTCSS?.(lastStatus);
if (document.getElementById('tgmonPanel')?.style.display !== 'none') window.renderTgMon?.();
}
async function refresh(){
const j = await apiGet('/svxportal/api/status'); // lib/api.js
if(!j) return;
lastUpdated = Date.now();
render(j);
applyFederationExtra(j);
}
// On the AlabamaLink box (aldmr) fold the AlabamaLink line INTO the existing
// federation marquee so both scroll together in the one bar — same style, no
// second banner. Every other box keeps just the federation line. Gated on the
// local reflector identity from the status (KT4ROY / alabama.reflector-network.com),
// so it's a no-op everywhere else.
let _fedExtraDone = false;
function applyFederationExtra(status){
if(_fedExtraDone) return;
const track = document.querySelector('#federationBanner .fed-track');
if(!track) return;
const refs = (status && status.network && Array.isArray(status.network.reflectors)) ? status.network.reflectors : [];
const isAlabama = refs.some(r => r && r.self && (
String(r.name || '').toUpperCase() === 'KT4ROY' ||
/alabama\.reflector-network\.com/i.test(String(r.hostname || '') + ' ' + String(r.name || ''))
));
if(!isAlabama) return;
_fedExtraDone = true;
const fed = 'Part of the reflector-network.com federation — a growing mesh of independent networks.';
const ala = 'Powered by Executor — a multi-mode digital voice server, part of the AlabamaLink network.';
const sep = '  •  ';
const unit = fed + sep + ala;
const inner = document.createElement('span');
inner.textContent = unit + sep + unit; // doubled so the loop stays seamless
track.replaceChildren(inner);
}
/* ===== Monitor submenu ===== */
function closeMonitorSubMenu(){
const m = document.getElementById('monitorSubMenu');
if(m) m.style.display = 'none';
}
function toggleMonitorSubMenu(){
const m = document.getElementById('monitorSubMenu');
if(!m) return;
m.style.display = (m.style.display === 'none' || !m.style.display) ? 'block' : 'none';
}
document.addEventListener('click', (e) => {
const item = document.getElementById('monitorMenuItem');
if(item && !item.contains(e.target)) closeMonitorSubMenu();
});
/* ===== Wire UI ===== */
document.getElementById('navList')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navList');
setActive('list');
render({nodes:lastNodes});
});
document.getElementById('navClients')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navClients');
setActive('clients');
renderClients(lastNodes);
});
document.getElementById('navMonitor')?.addEventListener('click', (e) => {
e.preventDefault();
toggleMonitorSubMenu();
});
document.getElementById('navMonitorNodes')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navMonitor');
setActive('monitor');
renderMonitor(lastNodes, lastStatus);
});
document.getElementById('navMonitorTrunks')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navMonitor');
setActive('trunks');
window.renderTrunks?.(lastStatus);
});
document.getElementById('navMonitorTopology')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navMonitor');
setActive('topology');
window.renderTopology?.(lastStatus);
});
document.getElementById('navLastheard')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navLastheard');
setActive('lastheard');
window.refreshLastheard?.();
});
document.getElementById('navCTCSS')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navCTCSS');
setActive('ctcss');
window.renderCTCSS?.(lastStatus);
});
document.getElementById('navChangelog')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navChangelog');
setActive('changelog');
chLoad();
});
document.getElementById('navTgmon')?.addEventListener('click', (e) => {
e.preventDefault();
closeMonitorSubMenu();
setNavActive('navTgmon');
setActive('tgmon');
window.renderTgMon?.();
});
/* ===== Remember the last view across reloads (Ctrl+Shift+R) ===== */
const RESTORE_NAVS = ['navList','navClients','navMonitorNodes','navMonitorTrunks','navMonitorTopology','navTgmon','navLastheard','navCTCSS','navChangelog'];
for (const id of RESTORE_NAVS) {
document.getElementById(id)?.addEventListener('click', () => { try { localStorage.setItem('svxLastNav', id); } catch (_) {} });
}
(function restoreLastNav(){
let id = null; try { id = localStorage.getItem('svxLastNav'); } catch (_) {}
if (id && RESTORE_NAVS.includes(id)) { const el = document.getElementById(id); if (el) el.click(); }
})();
// Changelog: lib/changelog.js, fed by changelog.json.
const _chViewer = changelogViewer({ body: 'chBody', pager: 'chPager', fetchAt: 'chFetchAt' });
async function chLoad() { return _chViewer.load(); }
document.getElementById('monitorMode')?.addEventListener('change', () => {
renderMonitor(lastNodes, lastStatus);
});
/* ===== Start + SSE ===== */
refresh();
setInterval(() => { // keep Age ticking even without updates
const ageEl = document.getElementById('ageTxt');
if(lastUpdated) ageEl.textContent = Math.floor((Date.now()-lastUpdated)/1000) + "s";
}, 1000);
const sseTxt = document.getElementById('sseTxt');
function setSSE(state){
if(!sseTxt) return;
sseTxt.textContent = "SSE: " + state;
}
const es = new EventSource('/svxportal/api/stream');
es.addEventListener('update', () => refresh());
es.addEventListener('ping', () => {});
es.onopen = () => setSSE('ok');
es.onerror = () => setSSE('down');
setSSE('connecting');
+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>";
+664
View File
@@ -0,0 +1,664 @@
'use strict';
(function(){
function getCookie(name){
const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.$?*|{}()[\]\\\/\+^]/g, '\\$&') + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
async function setConsent(v){
try{
await fetch('/auth/consent.php', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({consent: v})
});
}catch(_){}
document.cookie = 'cookie_consent=' + encodeURIComponent(v) + '; Path=/; Max-Age=' + (60*60*24*365) + '; SameSite=Lax';
}
function showCookieModalIfNeeded(){
const v = getCookie('cookie_consent');
if(v === 'necessary' || v === 'all') return;
const el = document.getElementById('cookieModal');
if(!el || !window.bootstrap) return;
const m = new bootstrap.Modal(el, {backdrop:'static', keyboard:false});
m.show();
document.getElementById('btnCookiesNecessary')?.addEventListener('click', async () => {
await setConsent('necessary');
m.hide();
}, {once:true});
document.getElementById('btnCookiesAll')?.addEventListener('click', async () => {
await setConsent('all');
m.hide();
}, {once:true});
}
function showSignin(){
const el = document.getElementById('signinModal');
if(!el || !window.bootstrap) return;
const m = new bootstrap.Modal(el);
m.show();
document.getElementById('signinUser')?.focus();
}
function setResend(show, user, pass){
let btn = document.getElementById('signinResend');
if(!show){ if(btn) btn.remove(); return; }
if(!btn){
const err = document.getElementById('signinErr');
if(!err || !err.parentNode) return;
btn = document.createElement('button');
btn.id = 'signinResend';
btn.type = 'button';
btn.className = 'btn btn-outline-secondary btn-sm mt-2';
btn.textContent = 'Resend verification email';
err.parentNode.insertBefore(btn, err.nextSibling);
}
btn.disabled = false;
btn.onclick = async function(){
btn.disabled = true;
let j = null;
try{
const r = await fetch('/auth/resend_verification.php', {
method:'POST',
headers:{'Content-Type':'application/json', 'X-CSRF-Token': window._csrfToken || ''},
body: JSON.stringify({user, pass})
});
j = await r.json();
}catch(_){}
if(j && j.ok){
showErr('signinErr', 'Verification email sent - check your inbox.');
btn.remove();
}else{
btn.disabled = false;
showErr('signinErr', (j && j.error) ? j.error : 'Could not send verification email.');
}
};
}
async function doLogin(){
showErr('signinErr', '');
setResend(false);
const user = (document.getElementById('signinUser')?.value || '').trim();
const pass = (document.getElementById('signinPass')?.value || '');
if(!user || !pass){
showErr('signinErr', 'Missing username/email or password.');
return;
}
const ok = await authLogin({ // lib/auth_ui.js
endpoint: '/auth/login.php', body: {user, pass},
onFail: j => {
showErr('signinErr', (j && j.error) ? j.error : 'Login failed.');
if(j && j.code === 'unverified') setResend(true, user, pass);
},
});
if(!ok) return;
setTimeout(function() { window.location.reload(); }, 100);
}
/* ---- Notification Bell (SSE) ---- */
let bellEvtSource = null;
function setupBell(isAdmin) {
const container = document.querySelector('.container-fluid');
if (!container) return;
// Insert bell before the user dropdown
const userDropdown = container.querySelector('.dropdown');
if (!userDropdown) return;
const bellWrap = document.createElement('div');
bellWrap.id = 'notifBellWrap';
bellWrap.style.cssText = 'position:relative; display:inline-block; margin-right:10px;';
bellWrap.innerHTML = ''
+ '<button id="notifBellBtn" style="background:none; border:none; color:var(--fg); cursor:pointer; font-size:1.2rem; position:relative; padding:4px 6px;" title="Notifications">'
+ '<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor"><path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zM8 1.918l-.797.161A4.002 4.002 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 0 0-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5.002 5.002 0 0 1 13 6c0 .88.32 4.2 1.22 6z"/></svg>'
+ '<span id="notifBadge" style="display:none; position:absolute; top:0; right:0; background:#ff4040; color:#fff; border-radius:50%; font-size:.65rem; min-width:16px; height:16px; line-height:16px; text-align:center; font-weight:bold;">0</span>'
+ '</button>'
+ '<div id="notifDropdown" style="display:none; position:absolute; right:0; top:100%; min-width:280px; max-width:360px; background:#111; border:1px solid rgba(0,255,255,.35); border-radius:8px; padding:8px 0; z-index:1050; max-height:400px; overflow-y:auto;">'
+ '<div style="display:flex; justify-content:space-between; align-items:center; padding:0 12px 6px; border-bottom:1px solid rgba(0,255,255,.2); margin-bottom:6px;">'
+ '<span style="color:rgba(0,255,255,.6); font-weight:600; font-size:.8rem;">Notifications</span>'
+ '<span style="display:flex; gap:4px;">'
+ '<button id="notifFilterBtn" class="btn btn-outline-secondary btn-sm" style="font-size:.7rem; padding:1px 6px;" title="Show/hide categories">Filter</button>'
+ '<button id="notifClearBtn" class="btn btn-outline-secondary btn-sm" style="font-size:.7rem; padding:1px 6px;" title="Dismiss everything shown">Clear all</button>'
+ '</span>'
+ '</div>'
+ '<div id="notifFilterPane" style="display:none; padding:2px 12px 6px; margin-bottom:6px; border-bottom:1px solid rgba(0,255,255,.1);"></div>'
+ '<div id="notifList" class="small" style="padding:0 12px;"></div>'
+ '</div>';
userDropdown.parentNode.insertBefore(bellWrap, userDropdown);
// Toggle dropdown
document.getElementById('notifBellBtn').addEventListener('click', (e) => {
e.stopPropagation();
const dd = document.getElementById('notifDropdown');
dd.style.display = dd.style.display === 'block' ? 'none' : 'block';
});
// Clicks inside the panel must not close it (Filter/Clear/dismiss live here).
document.getElementById('notifDropdown').addEventListener('click', (e) => e.stopPropagation());
document.getElementById('notifFilterBtn').addEventListener('click', () => {
const p = document.getElementById('notifFilterPane');
if (p) p.style.display = p.style.display === 'none' ? 'block' : 'none';
});
document.getElementById('notifClearBtn').addEventListener('click', () => {
// Clear all = dismiss every currently-visible item until its state changes.
for (const k of currentBellKeys()) notifDismissAdd(k);
renderBell();
});
// Close on outside click
document.addEventListener('click', () => {
const dd = document.getElementById('notifDropdown');
if (dd) dd.style.display = 'none';
});
if (isAdmin) {
// Admins get SSE for pending CSRs + new registrations
startSSE();
}
// All users: load their own events
loadUserEvents();
}
// ── Bell state + filter/dismiss (V5-14K: DMR-style controls) ────────────
const BELL_CATS = [
{key:'csr', label:'Pending CSRs'},
{key:'reg', label:'New registrations'},
{key:'certs', label:'Certificate expiry'},
{key:'tgreq', label:'TG requests'},
{key:'events', label:'Account events'},
];
function bEsc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
let bellData = {pending:[], new_accounts:[], ca_certs:[], tg_requests:[]};
let bellEvents = [];
function notifFilterGet() {
try { const r = localStorage.getItem('svxNotifFilter'); const a = r ? JSON.parse(r) : null;
return Array.isArray(a) ? new Set(a) : new Set(BELL_CATS.map(c => c.key)); }
catch(_) { return new Set(BELL_CATS.map(c => c.key)); }
}
function notifFilterSet(set) { try { localStorage.setItem('svxNotifFilter', JSON.stringify([...set])); } catch(_) {} }
function notifDismissedGet() {
try { const r = localStorage.getItem('svxNotifDismissed'); const a = r ? JSON.parse(r) : []; return new Set(Array.isArray(a) ? a : []); }
catch(_) { return new Set(); }
}
function notifDismissAdd(k) { const s = notifDismissedGet(); s.add(k); try { localStorage.setItem('svxNotifDismissed', JSON.stringify([...s].slice(-500))); } catch(_) {} }
// Keys of every item currently visible (filter-on, not dismissed) — Clear all.
function currentBellKeys() {
const en = notifFilterGet(), dis = notifDismissedGet(), keys = [];
const add = (cat, k) => { if (en.has(cat) && !dis.has(k)) keys.push(k); };
if (en.has('csr')) for (const p of (bellData.pending || [])) add('csr', 'csr:' + p.callsign);
if (en.has('reg')) for (const a of (bellData.new_accounts || [])) add('reg', 'reg:' + (a.uuid || a.callsign));
if (en.has('certs')) for (const c of (bellData.ca_certs || [])) add('certs', 'cert:' + c.name);
if (en.has('tgreq')) for (const p of (bellData.tg_requests || [])) add('tgreq', 'tgreq:' + p.uuid);
if (en.has('events')) for (const e of (bellEvents || [])) add('events', 'evt:' + e.ts);
return keys;
}
async function loadUserEvents() {
try {
const r = await fetch('/auth/user_events.php', {credentials:'same-origin'});
const j = await r.json();
bellEvents = (j && j.ok && Array.isArray(j.events)) ? j.events : [];
} catch(_) { bellEvents = []; }
renderBell();
}
function startSSE() {
if (bellEvtSource) bellEvtSource.close();
bellEvtSource = new EventSource('/auth/events.php');
bellEvtSource.addEventListener('notifications', (e) => {
try {
const d = JSON.parse(e.data);
updateBell(d);
} catch(_) {}
});
// Legacy support for old 'pending' event
bellEvtSource.addEventListener('pending', (e) => {
try {
const d = JSON.parse(e.data);
updateBell({pending_count: d.count, pending: d.items, new_accounts_count: 0, new_accounts: [], total: d.count});
} catch(_) {}
});
bellEvtSource.onerror = () => {
bellEvtSource.close();
setTimeout(startSSE, 10000);
};
}
// SSE pushes new data → store it and re-render (filter/dismiss applied there).
function updateBell(data) {
bellData = {
pending: data.pending || [],
new_accounts: data.new_accounts || [],
ca_certs: data.ca_certs || [],
tg_requests: data.tg_requests || [],
};
renderBell();
}
// Single render path for the whole bell — applies the category filter and the
// per-item dismiss set, wires all actions. Mirrors the dmr bell's Filter +
// Clear all + per-row dismiss.
function renderBell() {
const badge = document.getElementById('notifBadge');
const list = document.getElementById('notifList');
const pane = document.getElementById('notifFilterPane');
if (!list) return;
const en = notifFilterGet();
const dis = notifDismissedGet();
// Filter pane checkboxes
if (pane) {
pane.innerHTML = '';
for (const c of BELL_CATS) {
const wrap = document.createElement('label');
wrap.style.cssText = 'display:inline-flex; align-items:center; gap:4px; margin-right:10px; font-size:.75rem; color:rgba(0,255,255,.7); cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = en.has(c.key);
cb.addEventListener('change', () => {
const s = notifFilterGet();
if (cb.checked) s.add(c.key); else s.delete(c.key);
notifFilterSet(s); renderBell();
});
wrap.appendChild(cb);
wrap.appendChild(document.createTextNode(c.label));
pane.appendChild(wrap);
}
}
const hdr = (t) => '<div style="padding:4px 0; color:rgba(0,255,255,.6); font-weight:600; border-bottom:1px solid rgba(0,255,255,.2); margin:8px 0 4px;">' + t + '</div>';
const dz = (k) => '<button class="notif-dismiss" data-key="' + k + '" title="Dismiss" style="background:none; border:none; color:rgba(0,255,255,.5); cursor:pointer; font-size:1rem; line-height:1; padding:0 2px;">&times;</button>';
let html = '', visible = 0;
// Pending CSRs
if (en.has('csr')) {
const items = (bellData.pending || []).filter(p => !dis.has('csr:' + p.callsign));
if (items.length) {
html += hdr('Pending CSRs');
for (const item of items) {
const key = 'csr:' + item.callsign; visible++;
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:center; gap:6px;">'
+ '<div><strong style="font-family:monospace;">' + item.callsign + '</strong><br><span style="color:rgba(0,255,255,.4); font-size:.75rem;">' + timeAgo(item.mtime) + '</span></div>'
+ '<div style="display:flex; gap:4px; align-items:center;">'
+ '<button class="btn btn-success btn-sm notif-sign" data-cs="' + item.callsign + '" style="font-size:.7rem; padding:2px 8px;">Sign</button>'
+ '<button class="btn btn-danger btn-sm notif-reject" data-cs="' + item.callsign + '" style="font-size:.7rem; padding:2px 8px;">Reject</button>'
+ dz(key) + '</div></div>';
}
}
}
// New registrations
if (en.has('reg')) {
const items = (bellData.new_accounts || []).filter(a => !dis.has('reg:' + (a.uuid || a.callsign)));
if (items.length) {
html += hdr('New Registrations');
for (const a of items) {
const key = 'reg:' + (a.uuid || a.callsign); visible++;
const status = !a.verified ? 'Awaiting email verification' : (a.approved ? 'Approved' : 'Pending approval — cannot log in');
const statusColor = !a.verified ? '#ffc800' : (a.approved ? '#00c864' : '#ff9800');
const canApprove = a.verified && !a.approved && a.uuid;
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:center; gap:6px; flex-wrap:wrap;">'
+ '<div style="flex:1 1 auto;"><strong style="font-family:monospace;">' + a.callsign + '</strong> <span style="color:rgba(0,255,255,.5);">' + a.firstname + '</span>'
+ '<br><span style="color:' + statusColor + '; font-size:.75rem;">' + status + '</span>'
+ ' <span style="color:rgba(0,255,255,.4); font-size:.75rem;">' + timeAgo(a.created) + '</span></div>'
+ '<div style="display:flex; gap:4px; align-items:center;">'
+ (canApprove ? '<button class="btn btn-success btn-sm notif-approve" data-uuid="' + a.uuid + '" data-cs="' + a.callsign + '" data-flags="' + (a.flags || 0) + '" style="font-size:.7rem; padding:2px 8px;">Approve</button>' : '')
+ '<a href="/auth/admin.php" class="btn btn-outline-secondary btn-sm" style="font-size:.7rem; padding:2px 8px;">Review</a>'
+ dz(key) + '</div>'
+ '<div class="notif-approve-err" style="flex:1 1 100%; color:#ff8080; font-size:.7rem;"></div>'
+ '</div>';
}
}
}
// Certificate expiry
if (en.has('certs')) {
const items = (bellData.ca_certs || []).filter(c => !dis.has('cert:' + c.name));
if (items.length) {
html += hdr('Certificate expiry');
for (const c of items) {
const key = 'cert:' + c.name; visible++;
const color = c.expired ? '#ff4040' : '#ffc800';
const label = c.expired ? 'EXPIRED' : (c.days_left + ' day' + (c.days_left === 1 ? '' : 's') + ' left');
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:center; gap:6px;">'
+ '<div><strong style="font-family:monospace;">' + c.name + '</strong><br><span style="color:' + color + '; font-size:.8rem;">' + label + '</span></div>'
+ dz(key) + '</div>';
}
}
}
// TG requests (awaiting admin approval)
if (en.has('tgreq')) {
const items = (bellData.tg_requests || []).filter(p => !dis.has('tgreq:' + p.uuid));
if (items.length) {
html += hdr('TG Requests');
for (const p of items) {
const key = 'tgreq:' + p.uuid; visible++;
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:center; gap:6px; flex-wrap:wrap;">'
+ '<div style="flex:1 1 auto;"><strong style="font-family:monospace;">TG ' + bEsc(p.tg) + '</strong> <span style="color:rgba(0,255,255,.5);">' + bEsc(p.name) + '</span>'
+ '<br><span style="font-family:monospace; color:rgba(0,255,255,.6); font-size:.75rem;">' + bEsc(p.callsign) + '</span>'
+ ' <span style="color:rgba(0,255,255,.4); font-size:.75rem;">' + timeAgo(p.ts) + '</span></div>'
+ '<div style="display:flex; gap:4px; align-items:center;">'
+ '<button class="btn btn-success btn-sm notif-tgapprove" data-uuid="' + bEsc(p.uuid) + '" style="font-size:.7rem; padding:2px 8px;">Approve</button>'
+ '<button class="btn btn-danger btn-sm notif-tgdeny" data-uuid="' + bEsc(p.uuid) + '" style="font-size:.7rem; padding:2px 8px;">Deny</button>'
+ '<a href="/auth/tgadmin.php" class="btn btn-outline-secondary btn-sm" style="font-size:.7rem; padding:2px 8px;">Open</a>'
+ dz(key) + '</div></div>';
}
}
}
// Account events
if (en.has('events')) {
const items = (bellEvents || []).filter(e => !dis.has('evt:' + e.ts));
if (items.length) {
html += hdr('Account Events');
for (const evt of items.slice(0, 10)) {
const key = 'evt:' + evt.ts; visible++;
let label = evt.type || '?', color = '#00ffff';
if (evt.type === 'ban') { label = 'Account banned'; color = '#ff4040'; }
else if (evt.type === 'unban') { label = 'Account unbanned'; color = '#00c864'; }
else if (evt.type === 'lock') { label = 'Account locked'; color = '#ffc800'; }
else if (evt.type === 'unlock') { label = 'Account unlocked'; color = '#00c864'; }
html += '<div class="notif-row" data-key="' + key + '" style="padding:6px 0; border-bottom:1px solid rgba(0,255,255,.1); display:flex; justify-content:space-between; align-items:flex-start; gap:6px;">'
+ '<div><strong style="color:' + color + ';">' + label + '</strong>'
+ (evt.reason ? '<br><span style="color:rgba(0,255,255,.5); font-size:.8rem;">Reason: ' + evt.reason + '</span>' : '')
+ '<br><span style="color:rgba(0,255,255,.4); font-size:.75rem;">' + timeAgo(evt.ts) + (evt.by ? ' by ' + evt.by : '') + '</span></div>'
+ dz(key) + '</div>';
}
}
}
list.innerHTML = html || '<div style="padding:8px 0; color:rgba(0,255,255,.5);">No notifications</div>';
if (badge) {
if (visible > 0) { badge.textContent = String(visible); badge.style.display = ''; }
else badge.style.display = 'none';
}
// Dismiss (× per row) — hides the item until its state changes.
list.querySelectorAll('.notif-dismiss').forEach(btn => {
btn.addEventListener('click', (e) => { e.stopPropagation(); notifDismissAdd(btn.dataset.key); renderBell(); });
});
// TG request approve / deny (SSE re-pushes on the next tick; dismiss now for snappiness).
const tgReq = async (action, uuid) => {
const 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({action, uuid}) });
return r.json().catch(() => null);
};
list.querySelectorAll('.notif-tgapprove').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation(); btn.disabled = true;
const j = await tgReq('approve', btn.dataset.uuid);
if (j && j.ok) { notifDismissAdd('tgreq:' + btn.dataset.uuid); renderBell(); }
else { alert((j && j.error) || 'Failed'); btn.disabled = false; }
});
});
list.querySelectorAll('.notif-tgdeny').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm('Deny this TG request?')) return;
btn.disabled = true;
const j = await tgReq('deny', btn.dataset.uuid);
if (j && j.ok) { notifDismissAdd('tgreq:' + btn.dataset.uuid); renderBell(); }
else { alert((j && j.error) || 'Failed'); btn.disabled = false; }
});
});
// Sign CSR
list.querySelectorAll('.notif-sign').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm('Sign certificate for ' + btn.dataset.cs + '?')) return;
btn.disabled = true;
const 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({action:'sign', callsign: btn.dataset.cs})
});
const j = await r.json().catch(() => null);
if (!j || !j.ok) alert((j && j.error) || 'Failed');
});
});
// Reject CSR
list.querySelectorAll('.notif-reject').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm('Reject CSR for ' + btn.dataset.cs + '?')) return;
btn.disabled = true;
const 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({action:'rmpending', callsign: btn.dataset.cs})
});
const j = await r.json().catch(() => null);
if (!j || !j.ok) alert((j && j.error) || 'Failed');
});
});
// Approve a new registration inline — set reg(2)+normal(4) so the user can
// log in. Errors surface next to the row.
list.querySelectorAll('.notif-approve').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const row = btn.closest('.notif-row');
const errEl = row ? row.querySelector('.notif-approve-err') : null;
btn.disabled = true; btn.textContent = '…';
const flags = ((parseInt(btn.dataset.flags, 10) || 0) | 2 | 4) & 0x3F;
const r = await fetch('/auth/admin_accounts.php', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''},
body: JSON.stringify({action:'set_flags', uuid: btn.dataset.uuid, flags})
});
const j = await r.json().catch(() => null);
if (j && j.ok) { if (row) row.remove(); }
else {
btn.disabled = false; btn.textContent = 'Approve';
if (errEl) errEl.textContent = 'Approve failed: ' + ((j && j.error) || 'Failed');
}
});
});
}
function timeAgo(epoch) {
const diff = Math.floor(Date.now() / 1000) - epoch;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
/* ---- Session check & navbar ---- */
function showForgot() {
const el = document.getElementById('forgotModal');
if (!el || !window.bootstrap) return;
const m = new bootstrap.Modal(el);
m.show();
}
async function doForgot(e) {
if (e) e.preventDefault();
const msg = document.getElementById('forgotMsg');
if (msg) { msg.style.display = 'none'; }
const callsign = (document.getElementById('forgotCallsign')?.value || '').trim().toUpperCase();
const email = (document.getElementById('forgotEmail')?.value || '').trim();
if (!callsign || !email) {
if (msg) { msg.textContent = 'Fill in both fields.'; msg.style.color = '#ff8080'; msg.style.display = 'block'; }
return;
}
try {
const r = await fetch('/auth/forgot_password.php', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({callsign, email})
});
const j = await r.json();
if (msg) {
msg.textContent = (j && j.message) || 'Check your email for the reset link.';
msg.style.color = '#00ff80';
msg.style.display = 'block';
}
} catch(_) {
if (msg) { msg.textContent = 'Request failed.'; msg.style.color = '#ff8080'; msg.style.display = 'block'; }
}
}
function showRegister() {
const el = document.getElementById('registerModal');
if (!el || !window.bootstrap) return;
const m = new bootstrap.Modal(el);
m.show();
}
function setRegMsg(txt, err) {
const el = document.getElementById('regMsg');
if (!el) return;
if (!txt) { el.style.display = 'none'; return; }
el.textContent = txt;
el.style.color = err ? '#ff8080' : '#00ff80';
el.style.display = 'block';
}
async function doRegister(e) { // lib/auth_ui.js
if (e) e.preventDefault();
await authRegister({ endpoint: '/auth/signup.php', msg: setRegMsg });
}
async function checkSession(){
try{
const r = await fetch('/auth/me.php', {credentials:'same-origin'});
const j = await r.json();
if (!j || !j.logged_in) return;
const btn = document.getElementById('btnSignIn');
if (!btn) return;
const label = (j.callsign || j.email || 'User');
window._csrfToken = j.csrf_token || '';
// Theme on login: the account (uuid/redis) profile is authoritative. When
// signed in, ALWAYS apply the saved account theme + colours — that is the
// "real" pick and it must win over whatever the pre-paint boot read from the
// local cookie (you may have set a different one while logged out). A change
// made later in the session mirrors back to the account (theme.js
// saveToServer); the _adopted guard keeps a second load from re-adopting over
// it. An account that never saved a theme has empty web_theme/web_theme_colors
// → adopt skipped, the cookie/dark boot stands.
if (window.svxTheme && !window.svxTheme._adopted &&
(j.web_theme || j.web_theme_colors)) {
window.svxTheme._adopted = true;
window.svxTheme.adoptServer(j.web_theme || '', j.web_theme_colors || '');
}
const isAdmin = j.admin_level >= 2;
window._isAdmin = isAdmin;
const adminBlock = isAdmin
? '<li id="adminMenuItem" style="position:relative;">'
+ '<a class="dropdown-item" href="#" id="adminSubToggle">Admin &#9656;</a>'
+ '<ul id="adminSubMenu" style="display:none;">'
+ '<li><a class="dropdown-item" href="/auth/admin.php">Accounts</a></li>'
+ '<li><a class="dropdown-item" href="/auth/admin_control.php">Control</a></li>'
+ '<li><a class="dropdown-item" href="/auth/admin_mqtt.php">MQTT</a></li>'
+ '<li><a class="dropdown-item" href="/auth/admin_certs.php">Certificates</a></li>'
+ '<li><a class="dropdown-item" href="/auth/admin_nodeauth.php">Node Auth</a></li>'
+ '<li><a class="dropdown-item" href="/auth/admin_bans.php">Global Bans</a></li>'
+ '<li><a class="dropdown-item" href="/auth/tgadmin.php">TG List (admin)</a></li>'
+ (j.admin_level >= 3 ? '<li><a class="dropdown-item" href="/auth/admin_config.php">Config</a></li>' : '')
+ '</ul>'
+ '</li>'
+ '<li><hr class="dropdown-divider"></li>'
: '';
const html = ''
+ '<div class="dropdown">'
+ '<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="userMenuBtn" data-bs-toggle="dropdown" aria-expanded="false">'
+ label
+ '</button>'
+ '<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userMenuBtn">'
+ '<li><a class="dropdown-item" href="/auth/profile.php">Profile</a></li>'
+ '<li><a class="dropdown-item" href="/auth/admin_tglist.php">TG List</a></li>'
+ '<li><a class="dropdown-item" href="/auth/tgrequest.php">Request TG</a></li>'
+ adminBlock
+ '<li><a class="dropdown-item" href="#" id="menuSignOut">Sign out</a></li>'
+ '</ul>'
+ '</div>';
btn.outerHTML = html;
// Remove Register button when logged in
const regBtn = document.getElementById('btnRegister');
if (regBtn) regBtn.remove();
// Sign out
document.getElementById('menuSignOut')?.addEventListener('click', (e) => {
e.preventDefault();
doLogout();
});
// Admin submenu toggle (click)
document.getElementById('adminSubToggle')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const m = document.getElementById('adminSubMenu');
if (m) m.style.display = m.style.display === 'block' ? 'none' : 'block';
});
// Hover open
document.getElementById('adminMenuItem')?.addEventListener('mouseenter', () => {
const m = document.getElementById('adminSubMenu');
if (m) m.style.display = 'block';
});
document.getElementById('adminMenuItem')?.addEventListener('mouseleave', () => {
const m = document.getElementById('adminSubMenu');
if (m) m.style.display = 'none';
});
// Setup notification bell for all users
setupBell(isAdmin);
} catch(_) {}
}
async function doLogout(){ // lib/auth_ui.js
await authLogout({ endpoint: '/auth/logout.php', method: 'POST', after: () => {
if (bellEvtSource) bellEvtSource.close();
window.location.reload();
} });
}
document.addEventListener('DOMContentLoaded', () => {
showCookieModalIfNeeded();
checkSession();
document.getElementById('btnRegister')?.addEventListener('click', (e) => {
e.preventDefault();
showRegister();
});
document.getElementById('btnSignIn')?.addEventListener('click', (e) => {
e.preventDefault();
showSignin();
});
document.getElementById('linkSignup')?.addEventListener('click', (e) => {
e.preventDefault();
// Close signin modal if open
const sm = document.getElementById('signinModal');
if (sm) { const inst = bootstrap.Modal.getInstance(sm); if (inst) inst.hide(); }
showRegister();
});
document.getElementById('linkForgot')?.addEventListener('click', (e) => {
e.preventDefault();
const sm = document.getElementById('signinModal');
if (sm) { const inst = bootstrap.Modal.getInstance(sm); if (inst) inst.hide(); }
showForgot();
});
document.getElementById('forgotForm')?.addEventListener('submit', doForgot);
document.getElementById('registerForm')?.addEventListener('submit', doRegister);
document.getElementById('signinForm')?.addEventListener('submit', (e) => {
e.preventDefault();
doLogin();
});
});
})();
+569
View File
@@ -0,0 +1,569 @@
[
{
"date": "2026-09-22",
"repo": "svx-web",
"hash": "shared-libs-round-3",
"type": "change",
"summary": "Full shared lib set from callmgr2 (lib/*.js incl. ui_field, auth/lib/*.php incl. html.php); monitor and hover escapers on lib/fmt.js; valid_callsign/require_callsign once in auth/lib.php (node_cmd_api keeps its stricter amateur-format check as the validator argument). Not deployed yet."
},
{
"date": "2026-09-22",
"repo": "svx-web",
"hash": "shared-libs-round-2",
"type": "change",
"summary": "Shared libraries from callmgr2: changelog viewer, pager (monitor/TG monitor/trunks), message helpers, sign-in/register/sign-out transport, one theme.js (lib/theme.js), and PHP auth/lib/http.php + acl.php (JSON, CSRF, rate limit with this site's key namespace, require_privilege). No behaviour change; not deployed yet."
},
{
"date": "2026-09-22",
"repo": "svx-web",
"hash": "shared-libs",
"type": "change",
"summary": "Shared libraries from callmgr2 (copies under lib/ and auth/lib/): lib/api.js (status, lastheard and monitor fetches), lib/fmt.js (fmt2, callsign sort, frequency helpers in the CTCSS view), lib/ui_list.js (list editors, for coming editors), auth/lib/redis.php (Redis connection + batched read). No behaviour change."
},
{
"date": "2026-08-27",
"repo": "svxdr",
"hash": "tglist-sole-source",
"type": "change",
"summary": "FROZEN: the tglist is now the SOLE source of TG name/Location in Lastheard — all fallback removed (locForTG/systemForTG/bridgeIdentity no longer read live astmeta gateway location or nodes[].system). Every talker on a TG shows exactly the tglist name for that TG (blank if absent), so Lastheard is identical on every box. The master list is edited on speed and every box syncs it."
},
{
"date": "2026-08-27",
"repo": "svx-web",
"hash": "theme-focus-guard",
"type": "fix",
"summary": "Mirror of the DMR fix: the morning 'color' theme jump is gone. A theme <select>/color-swatch change is honored only when that control is focused (document.activeElement), so browser session-restore events (which never focus the control) can no longer flip the theme; added a pageshow re-sync."
},
{
"date": "2026-08-26",
"repo": "svx-web",
"hash": "tglist-canonical-admin-edit",
"type": "feature",
"summary": "New canonical TG list: one source, many readers. A single editable TG -> {name, system, allstar, mode, owner} list stored in Redis, edited from a new admin page (nav: user menu -> TG List) and served publicly at /tglist.php for every box/program to fetch (each consumer gets a 'tglist_url' config key, default this URL — no hardcoding). The editor has add/edit/delete, TG/name/system sort with an asc/desc toggle, and a search box with regex (invalid pattern falls back to literal substring, never throws). Delegated ownership: admins (LVL2+) manage all entries and assign an owner callsign; an owner may edit only their own TG's name/system/allstar/mode — enforced server-side, not just in the UI. This replaces the scattered per-box TG-name config so Last Heard / Location render identically everywhere. Consumers (svxdr, tgmonitor, proxy) are rewired to read this list in following steps."
},
{
"date": "2026-08-26",
"repo": "svx-web",
"hash": "theme-session-restore-guard",
"type": "fix",
"summary": "Theme no longer jumps to 'color' on the first browser start of the day. Root cause: browser session-restore ('continue where you left off') restores the previous session's form-control values on startup and fires synthetic change/input events on the theme <select> and the color swatches — the swatch handler then forced 'color' with no user action, overwriting the cookie theme read at boot. Fix: autocomplete=off on the theme select and swatches, plus a user-gesture gate so a restore-fired event before any real interaction is ignored (swatches also require the Color modal to be open). Logged-out cookie behavior unchanged: no cookie -> dark default, saved cookie -> that theme."
},
{
"date": "2026-08-24",
"repo": "svx-web",
"hash": "theme-adopt-on-login",
"type": "fix",
"summary": "Theme now follows your account on login. The account (uuid) profile is the source of truth: signing in applies your saved theme + colours, overriding whatever the pre-paint boot used from the local cookie. Previously the profile was adopted only on a device with no theme cookie yet, so once you had ever picked a theme, logging in on that browser never restored your account theme. Adoption still runs once per page load (a change made later in the session is not clobbered), an account that never saved a theme keeps the cookie/dark boot, anonymous changes save to the cookie only, and logged-in changes save to both the cookie and the profile."
},
{
"date": "2026-08-22",
"repo": "svx-web",
"hash": "admin-secret-boundary",
"type": "security",
"summary": "Reflector/MQTT system secrets are now visible only to Admin L3 — the reflector config already masked KEY/PASSWORD/private values below L3, and that stays. Admin L1 is now strictly read-only: every mutating admin action (cert sign/revoke, node block, service reload/restart, config set/save, MQTT settings) is rejected with 'read_only_admin'. Admin L2 can now edit the reflector config and set secrets blind: secrets show as ******** and, on save, any purely-masked value is restored from the live file so a blind save can never overwrite a real secret with the mask, while a value L2 actually types is written through. A new admin_rank in me.php lets the UI tell L1 from L2 (admin_level collapsed both to 2)."
},
{
"date": "2026-08-22",
"repo": "svx-web",
"hash": "theme-colors-array-poison",
"type": "fix",
"summary": "Theme colour handling hardened (mirror of the DMR fix): colours could be stored as an empty array '[]' instead of an object; all read/write/adopt now funnels through one normaliser that collapses '[]', malformed cookies and stray values to {}, so a bad value can never throw or paint a broken palette, and the save endpoint can no longer emit '[]'."
},
{
"date": "2026-08-11",
"repo": "svx-web",
"hash": "theme-color-editor-restored",
"type": "feat",
"summary": "Color editor restored (🎨 next to the theme selector), now on the fixed CSP-safe engine so it actually works: opens a dialog to pick each part of the Color theme with labelled swatches (Background, Text, Secondary text, Borders, Panel, Accent) + accent quick-picks + reset. Colours are stored in a cookie (svxcolors) for everyone and synced to the account profile when signed in."
},
{
"date": "2026-08-11",
"repo": "svx-web",
"hash": "theme-csp-fix-cookie",
"type": "fix",
"summary": "Theme switch now actually works: the no-flash engine was an inline <script>, which the site CSP (script-src 'self') blocks — so window.svxTheme never loaded and switching did nothing. Moved the engine to an external theme.js loaded in <head> ('self', CSP-allowed). Anonymous preference now stored in a first-party cookie (svxtheme) as intended, not localStorage; signed-in accounts still sync to the profile. Removed the color-edit button + dialog (not wanted) — just the Dark/Light/Color switch remains."
},
{
"date": "2026-08-11",
"repo": "svx-web",
"hash": "theme-system-dark-light-color",
"type": "feat",
"summary": "Theme switch in the navbar — Dark (default) / Light / Color — remembered per device (localStorage) and, for signed-in accounts, per account (synced to the profile so it follows you across devices). A 'Color edit' 🎨 dialog lets you pick each part of the Color theme with labelled colour swatches (Background, Text, Secondary text, Borders, Panel, Accent) plus accent quick-picks — no hex typing. No-flash: the saved theme applies before first paint. Dark stays byte-identical; all the existing cyan is now driven off theme tokens."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-monitor-node-key-consistency",
"type": "fix",
"summary": "Monitor->Nodes now shows the SAME name as Reflector clients. It uses the node KEY (the reflector /status map key, which Reflector clients displays), not the callsign field — so a node like key SA7AUX-AC (callsign SA7AUX) is SA7AUX-AC in both views, not two different names. Top-level (Local) names are never renamed/suffixed since they are the canonical keys; only trunk/satellite array nodes (no key, absent from Reflector clients) get a -N suffix to stay unique, and only enough to clear names already taken."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-monitor-unique-names-backend",
"type": "fix",
"summary": "Monitor->Nodes uniqueness done right: make each node NAME itself unique in the BACKEND (before the web), not a web-side badge. Any callsign that repeats across the whole view gets a number appended (SA7AUX -> SA7AUX1, SA7AUX2, … globally); a unique callsign is untouched. Reverted the web-side #index rendering — the web just displays the names it receives, no special logic."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-monitor-node-unique-index",
"type": "feat",
"summary": "Monitor->Nodes: every row is now unique. Nodes that share a callsign within a section get a 1-based #index (separate field, the callsign is never modified or lengthened). Data-driven; a unique callsign gets no index. Frontend shows it as a small #N after the callsign. Real stable identity still needs a source-provided id (DMR-ID); the index is the display disambiguator."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-monitor-node-system-field",
"type": "feat",
"summary": "svxdr Monitor->Nodes now shows each node type/link from the DATA per-node system field (DMR, DMR/YSF, DMR/Hytera), data-driven. Priority: config nodes[].mode override, else the node system field, else the sw-derived type. No hardcoding."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-nodes-groups-modes-config",
"type": "feat",
"summary": "svxdr node display is fully config-driven for BOTH shapes and free-text type. (1) statusnorm now rewrites top-level nodes[] too (by the node key/login), not just trunks/satellites — so a group that shows as a plain node (e.g. a DMR gateway) is renamed the same as a trunk group. (2) per-(group,TG) via: two different nodes (ASL + DMR) can share a TG and each keeps its own group name; talkerMetaFor matches against the set of configured vias for a TG. (3) new nodes[].mode = free-text type (AllStar/DMR/YSF/anything) shown per node, config wins over the data-derived type. (4) a configured node is always shown (never hidden by the bridge/infra filter). No config on a node = untouched. Zero hardcoding/per-type/per-box special."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-node-groups-config-driven",
"type": "feat",
"summary": "svxdr per-TG node groups are now fully config-driven, no hardcoding. Added a group field to nodes[]; each entry names the shared reflector login it belongs to (e.g. KT4ROY-ASL, KT4ROY-DMR). satLogins + satTrunkTGs are derived from that group field alone — removed the ASL-only lock (Trunk.SatelliteID) and the strip-digits heuristic. Any group renders identically; adding a new one (DMR, etc) is pure config, zero web changes."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-monitor-nodes-uniform",
"type": "fix",
"summary": "svxdr Monitor->Nodes: make it behave IDENTICALLY on every box, no per-conf-dialect special case. Bug: the Local-vs-trunk split only happened when the master trunk name could be derived from svxreflector.conf ([ReflectorTrunk] Peers), which only speed s dialect has — so aldmr (dialect [TRUNK_*]) never split and dumped the whole SE hub s 18 nodes into LOCAL. Now the split is decided purely by is the monitored master remote (reflectorURL != monitor_status_url), and the master-section label is derived from the DATA (the Reflector field the master s nodes report, masterReflectorName) instead of the local conf. Both boxes now show LOCAL = only their own nodes and the master s nodes under one identical, data-driven section. Removed dead/special code: the whole trunk_status.go topology-feed dead end, masterTrunkName and uplinkTrunkName (conf-dialect helpers), and the trunk_status config field."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-topology-crawl-restored",
"type": "fix",
"summary": "svxdr topology: restore the network crawl (go externalPuller) so the graph shows the connection LINES again, not just nodes. An aggregated hub status (sm2ampr/geu) carries empty trunk host fields, so it can never draw the server-to-server edges; only crawling each reflector s OWN /status yields the real peer hosts topology_view.js resolves into links. The crawl seeds from the local reflector s trunk hosts + the network topology file and BFS-reads each reflector; interval bumped to 60s (EXTERNAL_PULL_INTERVAL) with last-good retention so it is spread out and gentle. aldmr 17 reflectors/34 trunk-links, speed 18/42, local reflector self=true anchored at centre. The per-trunk trunk_status feed added earlier stays dormant (could not produce edges)."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-topology-full-net",
"type": "fix",
"summary": "svxdr topology: show the WHOLE network again, local reflector centred. readTrunkStatuses now reads ALL uplink trunks from svxreflector.conf (default address = the trunk s conf Host; nodelist.trunk_status json is only the exception, e.g. SE hub -> sm2ampr), and unfolds each hub response: every sub-trunk that carries nodes becomes its own reflector entity, so one hub read yields the full aggregated net (no crawl, no recursion beyond parsing the single response). aldmr 19 reflectors/1357 nodes, speed 9/661, each with type=local self=true so topology_view.js anchors it at centre. Read cadence stays local-fast / remote-1-per-min."
},
{
"date": "2026-08-07",
"repo": "svx-web",
"hash": "svxdr-trunk-status-topology",
"type": "fix",
"summary": "svxdr nodelist: rebuild network topology from OUR OWN trunks (svxreflector.conf) instead of a crawl. Each trunk s /status address is configured per trunk in svxdr.json nodelist.trunk_status, because a trunk s HTTP status address is not its trunk-protocol Host in the conf (SE hub trunk reaches reflector.reflector-network.se:5302 but publishes status at reflector.sm2ampr.net/geu/status/). readTrunkStatuses reads only the configured addresses, one level deep (no recursion), and fills externalState.sources so buildNetworkDoc reflectors[] is whole again. Read cadence split local vs remote: a loopback status URL refreshes fast (5s), a remote master/trunk URL is throttled to nodelist.monitor_interval (default 60s) so a shared hub is never hammered. No crawl, no hardcoded addresses."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "svxdr-merge-code-complete",
"type": "feat",
"summary": "svxdr: merge code-complete — proxy, astmeta and nodelist (+ xlink inside nodelist) all run as subsystem goroutines in one binary, toggled per box by modules.* in /etc/svxlink/svxdr.json. proxy/astmeta ported to instance-scoped ctx-aware modules; nodelist folded by copying its sources and surgically rewiring config (applyConfig overrides env-derived globals from svxdr.json; applyRenderFromConfig builds the Location render from lastheard+nodes[]) with all crawl/lastheard/federation logic preserved. Builds, vets, runs (all modules start, graceful stop). Old standalone binaries stay until cutover. Remaining: shared in-memory talker hand-off (drop the localhost HTTP hop), svxdr.service + deploy, and the box-by-box cutover."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "svxdr-merge-phase1-scaffold",
"type": "feat",
"summary": "svxdr: start merging astmeta + nodelist + proxy + xlink into ONE process (fewer daemons to track). Phase 1 scaffold: one config /etc/svxlink/svxdr.json (sectioned per subsystem + shared redis/nodes/trunk/lastheard), per-box module toggles so the same binary runs all subsystems on speed/aldmr and only astmeta on the allstar box, and a start/SIGHUP-reload/graceful-stop lifecycle. Subsystems are stubs for now and are folded in one at a time; the existing standalone binaries keep running until cutover (no regression). Builds + vets; verified it parses the example and toggles modules."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "aldmr-nodes-json-deploy",
"type": "fix",
"summary": "aldmr: deployed the nodes.json bridge map (deploy-aldmr/etc/nodes.json) and the new astmeta/nodelist. Fixes the Location regression on TG 3149050: it was mapped in astmeta to the dead node 403872 while the live carrier 1971 was unwatched (also ended a per-second 'no RPT_ALINKS for 403872' reconnect storm). 1971 is a fan-out endpoint whose only keyed alink is our own 48305, so the allstar remap reports the real far node 45225. Verified live: TG 3109050 and 3149050 now pair on node 45225 / East Coast Reflector. callsign KT4ROY-ASL uniform for all; reflector name lives in nodeLocation."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "astmeta-nodes-allstar-remap",
"type": "fix",
"summary": "astmeta: a fan-out endpoint (e.g. a local 19xx node) whose only keyed alink is one of our own upstream bridge nodes now reports the configured far AllStar node from nodes.json (allstar field) instead of the local node -- so the Location column gets the right reflector name AND the exact 'via <node>' on the fanned-out TG. Strictly guarded: only remaps when the seen node is one of our own bridge nodes; a node that sees a real external AllStar node directly is unchanged. No-op when nodes.json is absent."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "nodes-json-single-source",
"type": "feat",
"summary": "nodes.json: one source-of-truth bridge map that astmeta and nodelist both read directly (svx_node/tg/allstar/system/via/callsign per node). astmeta derives watch_nodes/[node.*]/[names] from it; nodelist derives the Location column's reflector_names + format from it -- ending the multi-file drift that broke the Location column. Additive: absent file = old config, no regression. proxy is a pure relay and stays untouched. Tooling: deploy-aldmr/import-live.py bootstraps nodes.json from the live config (derives the AllStar link from rpt startup_macro, flags what it cannot derive instead of guessing); deploy-aldmr/gen-asterisk.py renders the svx_sat.conf node blocks as a managed #include."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "monitor-underline-visible",
"type": "fix",
"summary": "Monitor -> Nodes: make the section line actually visible -- node rows are now fully borderless (table-borderless) and each section name (Local / trunk) carries an explicit 2px grey line under it, so the header stands apart in both light and dark themes instead of blending into Bootstrap's faint default row borders."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "monitor-section-underline",
"type": "change",
"summary": "Monitor -> Nodes: each section name (Local, and every trunk) now has a thin line under it so the sections read apart at a glance. The line is under the header only -- node rows stay borderless."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "monitor-local-vs-trunk",
"type": "fix",
"summary": "Monitor -> Nodes: Local now means ONLY this server's own reflector nodes. When a box monitors a foreign master (speed reads the HUB SE master status), the master's own nodes used to land under Local -- they are a different server reached over a trunk, not local. They are now grouped under that trunk's section, named from config (the svxreflector.conf trunk whose host matches the monitored master, e.g. SE-AUX -- never hardcoded). Local is read from our OWN reflector (REFLECTOR_STATUS_URL) and self nodes are de-duplicated out of the master section. A box that monitors its own reflector (aldmr) is unaffected: same URL -> no split, top-level stays Local."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "trunks-collapsible-tgs",
"type": "fix",
"summary": "Monitor -> Trunks: a satellite trunk with talkgroups is now collapsible -- it shows a chevron and '(N TG)', and the TG list expands/collapses on click (accordion, same feel as the CTCSS list) instead of always spilling every TG. Expanded state persists across the 5 s status refresh."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "trunks-satellite-grouped",
"type": "fix",
"summary": "Monitor -> Trunks shows ONE trunk per satellite, named by the satellite id (e.g. KT4ROY-ASL), with all its talkgroups listed underneath by their own per-TG label + number. Before, several chan_svx_sat entries sharing a satellite_id collapsed to whichever callsign was parsed last (only 'ASL-dodropin' showed). Now they are grouped as the single trunk they actually are. Fully dynamic: the satellite set is the union of the [SATELLITE] ids in svxreflector.conf and the live-authenticated satellites in /status, so new satellites (Chicago, ...) appear automatically and offline ones still show. Secrets are never read or exposed -- only the id."
},
{
"date": "2026-08-06",
"repo": "svx-web",
"hash": "topo-strip-status-url",
"type": "fix",
"summary": "Monitor -> Topology never shows a raw http(s)://host:port/status URL. Federation-seed reflectors/trunks arrive with their status URL as the name; every label (node, edge, leaf, popup) is now stripped to the hostname only -- host:port/status is an attack surface. Popup also shows the name only (no IP/port/hostname/aliases/version)."
},
{
"date": "2026-08-05",
"repo": "callmgr",
"hash": "limiter-dbfs",
"type": "change",
"summary": "The analog limiter ceiling is now configured in dBFS instead of a raw linear PCM number, on every analog bridge (SVX peer, SVX satellite, USRP link). 0 dBFS is full scale (32767) and the default is -0.8 dBFS; the DSP still works on the linear peak internally, the value is just converted at the config boundary. Fully back-compatible -- any config still carrying the old linear 'lim_ceil' key keeps working (read and converted on load), and the sibling C drivers (chan-svx, chan-svx-sat, pbx-sat) plus the svxmixer TUI and the FreePBX/DMR web editors all moved to the same dBFS scale so a trunk sounds identical everywhere."
},
{
"date": "2026-08-05",
"repo": "callmgr",
"hash": "svx-dmr-audio",
"type": "fix",
"summary": "SVX<->DMR audio is fixed end-to-end on the analog bridges (SVX peer, SVX satellite and USRP link, plus the same voice-sync marker on NXDN->DMR). Three faults, all in the paths that build the DMR voice burst themselves: (1) SVX->DMR was completely silent because every synthesized voice burst was tagged as a plain voice frame with no A-F voice-sync marker, so the far DMR side never locked onto the stream; (2) the audio that did get through was harsh/overmodulated because the burst payload left the sync region empty -- the 48-bit MS voice-sync pattern that YSF/M17 already write was never added; (3) DMR->SVX crackled and started sluggishly because the three Opus frames produced from each 60 ms DMR burst were sent back-to-back instead of paced. Now every burst carries the correct A-F frame-sync marker and the MS voice-sync pattern, and audio is released one frame per 20 ms -- the first frame fires instantly for a snappy key-up, a 5 ms drain poll keeps the cadence tight, and the SVX-satellite trunk sets TCP_NODELAY so nothing is coalesced. Verified clean on the wire (steady 19-20 ms spacing)."
},
{
"date": "2026-08-04",
"repo": "web",
"hash": "1a0b8ab",
"type": "feature",
"summary": "help.html PBX <-> SVX tab gained an 'Audio tuning' section: the per-trunk preamp -> 7-band EQ -> AGC -> limiter chain (per direction, off by default = bit-identical passthrough), where to set it in the FreePBX module, the live-preview workflow (Start/Stop/Discard/Save, heard mid-call, auto-reverts after 30 s) and the equivalent pbx_sat.conf keys with a 'Voice' EQ preset. Matches the chan-pbx-sat 0.2.0 packages."
},
{
"date": "2026-08-04",
"repo": "web",
"hash": "d71315f",
"type": "feature",
"summary": "help.html PBX <-> SVX tab gained a complete 'Full FreePBX setup' walk-through (0-8): prerequisites, install, loading chan_pbx_sat, creating the trunk in the GUI (now with per-field default/example/range/help), the global lists (callsigns/talkgroups/routing/settings), the PBX -> SVX dial-plan wiring (extensions_custom.conf context off the trunk's Group number, ptt/cmd_select_tg), the optional SVX -> PBX ring path (standing + monitor + TG->SIP), the access list, and apply/verify via the status panel, dashboard widget and 'pbxsat status'."
},
{
"date": "2026-08-03",
"repo": "web",
"hash": "168c9d8",
"type": "feature",
"summary": "help.html gained a 'PBX <-> SVX' Downloads tab (mirroring the AllStar tab): what chan_pbx_sat is, the two packages (chan-pbx-sat driver + pbxsat-freepbx FreePBX GUI), apt/dnf install snippets for Debian 12/13 and CentOS/RHEL 7/8/9, and a link to the public packages repo (git.sa7aux.se/bjorne/pbx-sat-deb). Downloads live on the repo page so the tab needs no per-file update."
},
{
"date": "2026-08-01",
"repo": "proxy",
"hash": "fa69ae8",
"type": "feature",
"summary": "svx-web/proxy gained a meta-sync conduit so a box with NO local astmeta (speed) still shows the real talker on Lastheard. It sets UPSTREAM_STATUS_URL to the proxy of the box that HAS the meta (aldmr) and FORWARD_TALKER_URL to its own nodelist: every 2s it fetches the upstream /status ('hämta status'), keeps the talker_meta (so its own /status carries it too) and POSTs each entry to the local nodelist /api/talker-meta ('api') with network left empty. The frozen Lastheard code enriches the existing trunk row IN PLACE — callsign, via, AllStar node, system — while the backend stays 'trunk' (it arrived over the trunk). Only trunk/status/API cross-box; no bridge, no polling of anyone else, no code change to nodelist/web, no dupes. No-op unless UPSTREAM_STATUS_URL is set (aldmr stays fed by its local astmeta)."
},
{
"date": "2026-07-31",
"repo": "astmeta",
"hash": "33607dc",
"type": "feature",
"summary": "astmeta can now POST talker-meta to SEVERAL reflectors, not just the local one: [nodelist] peers = url|token, url|token. This lets a partner reflector that receives the same talker over the trunk (e.g. speed hearing an East Coast talker relayed from aldmr) enrich its Lastheard IN PLACE with the real source — 'East Coast Reflector via KT4ROY-SAT1', AllStar node, backend AllStar — instead of the bare trunk name 'KT4ROY-SAT1 / trunk'. No dupes: the receiving nodelist already applies talker-meta to trunk-delivered rows (via-gated), so it enriches the existing single row. Backward compatible; no peers = unchanged."
},
{
"date": "2026-07-31",
"repo": "web",
"hash": "4f5a617",
"type": "feature",
"summary": "On the AlabamaLink box (aldmr) the AlabamaLink line is now folded into the SAME federation marquee — 'Part of the reflector-network.com federation … • Powered by Executor — a multi-mode digital voice server, part of the AlabamaLink network.' scrolling together in one bar, matching the DMR site's notice. No second banner. Gated on the local reflector identity (KT4ROY) from the status the page already fetches, so every other box keeps just the federation line."
},
{
"date": "2026-07-31",
"repo": "web",
"hash": "8b021da",
"type": "fix",
"summary": "Monitor — Topology now draws the local reflector's OWN satellite trunks (chan_svx_sat, e.g. KT4ROY-SAT1/SAT2) as leaves off the centre node. They were silently dropped: local-node trunks that don't resolve to a known reflector were all suppressed to avoid drawing the dangling uplink trunk as a leaf, but that filter also swallowed the local satellites. Satellites are now exempt from that suppression while the uplink stays hidden, so your satellite trunks finally appear in Topology like they already do in Monitor — Trunks and Monitor — Nodes."
},
{
"date": "2026-07-31",
"repo": "nodelist",
"hash": "ee4714c",
"type": "fix",
"summary": "Satellite trunks (chan_svx_sat) now appear in EVERY trunk view, not just some. The Monitor — Trunks table (/api/local-trunks) read only svxreflector.conf and so omitted satellites entirely; it now also reads svx_sat.conf and lists the enabled satellite trunks under their real callsign (e.g. KT4ROY-SAT1) with live authenticated/node counts from the reflector status — matching Topology and Monitor — Nodes, which already carried them. Names are resolved per-box from svx_sat.conf so this is generic for anyone running the web."
},
{
"date": "2026-07-30",
"repo": "astmeta",
"hash": "32e216d",
"type": "fix",
"summary": "A talker heard through a named hub (a node with a [names] entry, e.g. an AllStar reflector) now keeps its REAL callsign: the [names] value becomes the system name and the astdb still resolves the operator callsign, returned separately. Previously a named node reported the system name as the callsign and threw the real one away. So an East Coast talker now shows 'W2ECR via KT4ROY-SAT1' with system 'East Coast Reflector' — and the DMR side can resolve the DMR id from the real callsign."
},
{
"date": "2026-07-29",
"repo": "nodelist",
"hash": "6bd338e",
"type": "feature",
"summary": "Last Heard now shows a Backend column — how a talker enters the system (AllStar, SVXLink, trunk, and DMR/DMR-YSF/DMR-OB… later) — in place of the free-text System name, matching the DMR side where Backend already means the ingress path (there Mode is the DV protocol). The Node column is relabelled \"AllStar node\" and only fills for AllStar ingress. The value is carried end-to-end: a bridge reports it via /api/talker-meta (astmeta sends AllStar), otherwise a row defaults to trunk or SVXLink by source."
},
{
"date": "2026-07-28",
"repo": "web",
"hash": "66c7279",
"type": "fix",
"summary": "Admin accounts flags modal: each admin level (L1/L2/L3) is now gated behind 'normal' with a cascade (dropping normal drops admin), mirroring the dmr site where admin requires 'verified'. Enforced in both the modal (admin.js) and the backend (admin_accounts.php), so svx and dmr share the same verified-email -> reg -> normal -> admin progression instead of admin being settable ungated."
},
{
"date": "2026-07-28",
"repo": "web",
"hash": "ad1f081",
"type": "feature",
"summary": "Scrolling federation banner under the nav, shown on every page — \"Part of the reflector-network.com federation — a growing mesh of independent networks\" in amber with a soft glow so it stands out against the cyan theme; the marquee pauses on hover and honours prefers-reduced-motion."
},
{
"date": "2026-07-28",
"repo": "deploy",
"hash": "8ee1f46",
"type": "feature",
"summary": "deploy-remote.sh --conf deploys the per-box system config under /etc (deploy/etc for speed, deploy-aldmr/etc for aldmr). Secret-safe: each @@PLACEHOLDER@@ is filled from the CURRENT live file (section+key aware) so real passwords/tokens are never overwritten; a placeholder that can't be resolved skips the file (never writes @@ into a live config). Per-file diff + y/N confirm (or -y), -n dry-run, preserves target owner/mode. Runs before --bin so a following --bin restart picks up the new config. Also reconciled the astmeta.conf template with the live file (added the [nodelist] section) so its diff is clean."
},
{
"date": "2026-07-28",
"repo": "astmeta",
"hash": "39f4594",
"type": "feature",
"summary": "astmeta gains per-watch-node TG/via via [node.<N>] sections, so ONE instance can cover several bridge nodes that each report a different talk group and via callsign (e.g. 48305 -> 3109050/KT4ROY-A and 403872 -> 3109051/SA7AUX). Talker state is now keyed per (watch-node, remote-node) so the same remote heard through two bridges no longer clobbers the other. Fixes Lastheard/meta being bare on the second bridge's TG. Global [usrp] tg / [nodelist] via / [mqtt] via remain the fallback — existing single-node configs unchanged."
},
{
"date": "2026-07-28",
"repo": "nodelist",
"hash": "6ded491",
"type": "fix",
"summary": "Lastheard now captures trunk/satellite talkers, not only node talkers. The reflector-log tailer regex only matched 'Talker start/stop'; a satellite/trunk-delivered talker is logged as 'Trunk talker start/stop' (e.g. sat403872 on TG 3109051) and was silently dropped, so those TGs never appeared in Lastheard. The regex now also matches the optional 'Trunk ' form and tags the event source=trunk."
},
{
"date": "2026-07-28",
"repo": "web",
"hash": "b127801",
"type": "fix",
"summary": "Topology centre node now shows the local SERVER's name (self hostname, e.g. alabama.reflector-network.com, else the reflector name) instead of its first trunk name — the trunk name pointed at a remote hub and read as 'the hub is in the centre'. The local server is now clearly the centre."
},
{
"date": "2026-07-28",
"repo": "web",
"hash": "ef7878e",
"type": "change",
"summary": "Monitor Topology is always anchored on THIS reflector — the local server sits in the centre and every other reflector rings around it, instead of centring on the network hub/uplink. Legend simplified (the centre is 'you')."
},
{
"date": "2026-07-28",
"repo": "nodelist",
"hash": "3ac5dd1",
"type": "fix",
"summary": "Monitor Trunks showed a configured trunk as offline when the reflector reports it under trunks_UDP (named like TrunkPeer#SE-AUX) instead of the trunks dict. /api/local-trunks now also reads trunks_UDP and matches the conf name against the TrunkPeer#-prefixed name, so an online trunk shows online."
},
{
"date": "2026-07-28",
"repo": "nodelist",
"hash": "ccc516d",
"type": "change",
"summary": "Monitor Trunks now lists ONLY the trunks this reflector is configured to connect to, read straight from the local /etc/svxlink/svxreflector.conf (new /api/local-trunks). Handles both config dialects — [ReflectorTrunk]+[TrunkPeer#name] and [TRUNK_xxx] HOST/PORT/REMOTE_PREFIX. Each trunk is matched to the local reflector's /status for online + node count; the host/IP is used only for matching and never sent to the client. The whole-network view stays on Monitor → Topology."
},
{
"date": "2026-07-28",
"repo": "web",
"hash": "ad32bd9",
"type": "change",
"summary": "Monitor Trunks reworked: Local at the top, everything else under Network; dropped the Owner and Host columns (host/IP was never meant to be exposed); the Network list gets the same footer pagination as the node page (first/prev/next/last, rows/page fit the screen) when it grows; search box is now a case-insensitive regex. Monitor Nodes trunk headers no longer print the host either."
},
{
"date": "2026-07-28",
"repo": "nodelist",
"hash": "50d5732",
"type": "change",
"summary": "Monitor Nodes shows only real users — every bridge is dropped: sw *_Bridge / Janus Bridge / chan_svx (chan_svx_sat kept as a real AllStar node), nodeClass bridge/xlx/dmr/ysf/mumble/stream/sip/echolink/zello, and gateway callsigns (SIP*, DCS*, XLX*, *Mumble*). On the SE hub that is 10 real users instead of 28 mixed rows."
},
{
"date": "2026-07-28",
"repo": "web",
"hash": "4255924",
"type": "fix",
"summary": "Monitor Nodes gets its footer pagination back (first « · ‹ · page numbers · › · last »), rows/page fit to the screen so it never scrolls — same pager as TG Monitor. Local + per-trunk sections, search and the All nodes / All active toggle all kept."
},
{
"date": "2026-07-28",
"repo": "nodelist",
"hash": "174b09d",
"type": "change",
"summary": "Monitor Nodes rebuilt to read ONE reflector /status (new /api/monitor-nodes) instead of the recursive crawl+aggregate: shows a Local section then one section per trunk with its nodes. Per-box source via MONITOR_STATUS_URL (speed=sm2ampr /geu/status/, aldmr=its own /status). Search + mode toggle kept, borderless."
},
{
"date": "2026-07-28",
"repo": "web",
"hash": "a703cfb",
"type": "feature",
"summary": "SvxPortal remembers the last view across reloads (Ctrl+Shift+R lands where you were), and TG Monitor gets a search box — type a TG number to see who is on it, or a callsign/node to see which talkgroups it is on"
},
{
"date": "2026-07-28",
"repo": "nodelist",
"hash": "a09a9ed",
"type": "feature",
"summary": "All Nodes now covers the WHOLE network — folds the federation aggregate (reflector-network.com, ~627 nodes) into the flat list with real reflector names; crawl still wins for live TG, aggregate fills the rest and replaces any bare-IP ref with the real reflector name (or a hostname alias)"
},
{
"date": "2026-07-28",
"repo": "nodelist",
"hash": "bf19e22",
"type": "fix",
"summary": "All Nodes ref column never exposes a raw http(s)://host:port/status URL — federation-seed sources are shown by hostname only"
},
{
"date": "2026-07-27",
"repo": "web",
"hash": "3b87ec6",
"type": "change",
"summary": "Monitor → Nodes is now the total deduped list itself — one row per callsign with the REAL reflector it's connected to (no per-reflector boxes, no divider lines), sorted by callsign, regex filter + pagination + live last-heard. Dropped the separate All Nodes page; nodelist attributes the local reflector by its real name, never 127.0.0.1"
},
{
"date": "2026-07-27",
"repo": "nodelist",
"hash": "85e875e",
"type": "feature",
"summary": "All Nodes now streams true per-node deltas: nodelist maintains a deduped flat map (federation crawl + fresh local statusKey overlay, local reflector owns its calls) and serves /api/nodes (full) + /api/nodes-stream (SSE nodes-full then nodes-delta added/changed/removed). Rebuild fires instantly on pubsub + a 3s safety tick; the wire only carries what changed"
},
{
"date": "2026-07-27",
"repo": "web",
"hash": "e1515bd",
"type": "feature",
"summary": "All Nodes page — one total, deduped list across every reflector/trunk (mesh dupes collapsed to the owning ref): Callsign · Reflector · TG · live Last-heard (NOW highlight when transmitting). Regex search top-right, first/prev/pages/next/last pagination, built once then re-synced event-driven off the SSE (no polling)"
},
{
"date": "2026-07-27",
"repo": "web",
"hash": "0a017b2",
"type": "feature",
"summary": "Notification bell now matches the dmr bell: a Filter pane (show/hide categories, saved in localStorage), a Clear all button and a per-row dismiss (×) across Pending CSRs / New registrations / Certificate expiry / Account events, with the badge counting only what's visible"
},
{
"date": "2026-07-27",
"repo": "web",
"hash": "3a18be1",
"type": "feature",
"summary": "Admin account editor: one-click Approve / Unapprove buttons (shown by state) so an account missed in the bell can be approved directly — on par with the dmr Register/Unregister"
},
{
"date": "2026-07-27",
"repo": "web",
"hash": "7e56b7c",
"type": "feature",
"summary": "Admin: approval-status badge on the account editor (Approved / Pending approval / Not verified / Banned) and a matching account-list status; the notification bell now has an inline Approve button on new registrations (sets reg+normal so the user can log in) with the error shown inline; sign-in label clarified to Callsign or email (email login already supported)"
},
{
"date": "2026-07-27",
"repo": "nodelist",
"hash": "1a7b699",
"type": "feature",
"summary": "Federation gap detector (/api/gaps) — diffs the network-wide aggregate against our crawl coverage; names reflectors to add to FEDERATION_SEEDS"
},
{
"date": "2026-07-27",
"repo": "nodelist",
"hash": "6e721f3",
"type": "feature",
"summary": "Federation seeds — recursive crawl now reaches the Swedish Reflector-network core (FEDERATION_SEEDS) and branches via their trunks"
},
{
"date": "2026-07-26",
"repo": "web",
"hash": "cf75e28",
"type": "docs",
"summary": "Help → AllStar tab rewritten for chan_svx (native SVXReflector driver for ASL3, replaces USRP bridge)"
},
{
"date": "2026-07-26",
"repo": "web",
"hash": "9f32a6c",
"type": "fix",
"summary": "TG Monitor hides bridge nodes (chan_svx asterisk etc) — only real radio clients"
},
{
"date": "2026-07-26",
"repo": "web",
"hash": "49548b3",
"type": "feature",
"summary": "TG Monitor view — clients per talkgroup, per mode, both sides (SVX + DMR via trunk), deduped"
},
{
"date": "2026-07-26",
"repo": "nodelist",
"hash": "077f7e0",
"type": "feature",
"summary": "Talker-meta ingest accepts localhost without a token (zero-config bridge)"
},
{
"date": "2026-07-26",
"repo": "nodelist",
"hash": "38734d1",
"type": "change",
"summary": "One shared nodelist source — removed the separate nodelist-al build"
},
{
"date": "2026-07-26",
"repo": "web",
"hash": "26126d6",
"type": "change",
"summary": "Enable/Disable toggle buttons in admin (matches the DMR side)"
},
{
"date": "2026-07-25",
"repo": "web",
"hash": "56202f7",
"type": "feature",
"summary": "Lastheard fills node/system + \"system via bridge\" from the bridge directory"
},
{
"date": "2026-07-25",
"repo": "auth",
"hash": "898cfe6",
"type": "feature",
"summary": "Mail send via direct SSL-SMTP when a relay is configured (no local MTA needed)"
},
{
"date": "2026-07-25",
"repo": "web",
"hash": "89b8719",
"type": "feature",
"summary": "\"Talking now\" identical local/remote — trunk talkers in the popup"
},
{
"date": "2026-07-25",
"repo": "web",
"hash": "cb1d59a",
"type": "feature",
"summary": "Enrich lastheard + talker popup from the bridge directory (trunk bridges)"
},
{
"date": "2026-07-25",
"repo": "nodelist",
"hash": "354a82a",
"type": "fix",
"summary": "Bridge crawl uses the reflector IP (URL often empty) + runs in parallel"
}
]
+172
View File
@@ -0,0 +1,172 @@
'use strict';
// Search matcher: glob wildcards * and ? → anchored regex; else substring.
function clientMatcher(term){
term = String(term == null ? '' : term).trim().toLowerCase();
if(!term) return null;
if(term.indexOf('*') !== -1 || term.indexOf('?') !== -1){
const re = new RegExp('^' + term.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
return (v) => re.test(String(v == null ? '' : v));
}
return (v) => String(v == null ? '' : v).toLowerCase().indexOf(term) !== -1;
}
let CLIENTS_NODES = null; // last nodes, so the search box can re-render
function renderClients(nodes){
const body = document.getElementById('clientsBody');
if(!body) return;
CLIENTS_NODES = nodes || {};
const isAdmin = !!window._isAdmin;
// Reflector clients = only nodes connected to THIS local reflector. Trunk-side
// nodes (tagged remoteTrunk) live on other reflectors — not our clients.
const m = clientMatcher((document.getElementById('clientsSearch') || {}).value);
let calls = Object.keys(nodes || {}).filter(cs => {
const n = nodes[cs]; return n && !n.remoteTrunk;
}).sort();
if(m){
calls = calls.filter(cs => m(cs) || m(String((nodes[cs] || {}).tg == null ? '' : (nodes[cs] || {}).tg)));
}
if(calls.length === 0){
body.innerHTML = `<div class="text-secondary">${m ? 'No matching nodes' : 'No connected nodes'}</div>`;
return;
}
const rows = calls.map(cs => {
const n = nodes[cs] || {};
const cls = n.nodeClass || '';
const tgs = Array.isArray(n.monitoredTGs) ? n.monitoredTGs.slice() : [];
tgs.sort((a,b)=>Number(a)-Number(b));
const loc = clientLocation(n);
const fr = clientFreqs(n);
const curTg = Number(n.tg || 0);
const sysop = (n.sysop && n.sysop !== cs) ? ` <span class="text-secondary">(${escClient(n.sysop)})</span>` : '';
const freq = (fr.rx || fr.tx) ? `${fr.rx || '-'} / ${fr.tx || '-'}` : '';
const sw = [n.sw, n.swVer].filter(Boolean).join(' ');
const trAttr = n.isTalker ? ' style="color:#ff5252;font-weight:bold;"' : '';
const remote = n.remoteTrunk ? ` title="via ${escClient(n.remoteTrunk)}"` : '';
let txCtcss = '';
const t2t = n.toneToTalkgroup;
if (curTg > 0 && t2t && typeof t2t === 'object'){
for (const tone in t2t){ if (Number(t2t[tone]) === curTg){ txCtcss = tone; break; } }
}
const rxCtcss = (n.reportCtcss == null || n.reportCtcss === '') ? '' : String(n.reportCtcss);
const info = window.buildRowInfo ? window.buildRowInfo([
['Callsign', cs], ['Location', loc], ['TG', curTg > 0 ? curTg : ''],
['RX freq', fr.rx], ['TX freq', fr.tx],
['RX subtone', rxCtcss], ['TX subtone', txCtcss],
['Class', cls], ['SW', sw], ['Monitored TGs', tgs.length ? tgs.join(', ') : ''],
['Node admin', n.sysop], ['Via trunk', n.remoteTrunk]
], { talking: !!n.isTalker }) : '';
let actions = '';
if (isAdmin) {
if (n.remoteTrunk) {
actions = '<td class="text-secondary nowrap" title="Remote (trunk) node — cannot block">&mdash;</td>';
} else {
actions = `<td class="nowrap">`
+ `<button type="button" class="btn btn-sm btn-warning node-blk" data-blk="${escClient(cs)}" title="Enter 0 minutes to unblock" style="min-width:86px;">Block</button></td>`;
}
}
return `<tr${trAttr}${info ? ` data-info="${info}"` : ''}>`
+ `<td class="mono nowrap"${remote}><b>${escClient(cs)}</b>${sysop}</td>`
+ `<td class="nowrap">${escClient(cls)}</td>`
+ `<td class="mono nowrap">${curTg > 0 ? curTg : ''}</td>`
+ `<td class="wrap">${escClient(loc)}</td>`
+ `<td class="mono nowrap">${freq}</td>`
+ `<td class="nowrap">${escClient(sw)}</td>`
+ `<td class="mono wrap">${tgs.length ? tgs.join(', ') : ''}</td>`
+ actions
+ `</tr>`;
}).join('');
body.innerHTML = '<div class="table-responsive"><table class="table table-sm mb-0 data-table">'
+ '<thead><tr><th>Callsign</th><th>Class</th><th>TG</th><th>Location</th><th>RX / TX</th><th>SW</th><th>Monitored TGs</th>'
+ (isAdmin ? '<th>Actions</th>' : '')
+ '</tr></thead>'
+ '<tbody>' + rows + '</tbody></table></div>';
}
// CSP-safe click delegation, wired ONCE at load on document (inline onclick is blocked by the
// page CSP). Wiring here — not inside renderClients — means it works on the very first click,
// regardless of the poll re-rendering the table.
document.addEventListener('click', function(e) {
var t = e.target;
if (!t || !t.closest) return;
var blk = t.closest('[data-blk]');
if (blk) { toggleBlock(blk.getAttribute('data-blk')); return; }
});
// Live re-render as the callsign/TG filter is typed.
document.addEventListener('input', function(e) {
if (e.target && e.target.id === 'clientsSearch') renderClients(CLIENTS_NODES || {});
});
/* ---- Admin: block / unblock a locally connected node (runtime, via PTY) ---- */
function _blockApi(body){
return 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)
}).then(r => r.json()).catch(() => null);
}
function toggleBlock(cs){
var v = prompt('Block ' + cs + ' \u2014 minutes (0 = unblock):', '5');
if (v === null) return;
var mins = parseInt(v, 10);
if (isNaN(mins) || mins < 0){ alert('Enter 0 (unblock) or a positive number of minutes.'); return; }
if (mins === 0){
_blockApi({action:'unblock', callsign:cs}).then(function(j){
if (j && j.ok) alert(cs + ' unblocked');
else alert((j && j.error) || 'Unblock failed');
});
} else {
_blockApi({action:'block', callsign:cs, seconds:mins*60}).then(function(j){
if (j && j.ok) alert('Blocked ' + cs + ' for ' + mins + ' min');
else alert((j && j.error) || 'Block failed');
});
}
}
window.toggleBlock = toggleBlock;
function clientLocation(n){
if (n.nodeLocation) return String(n.nodeLocation);
if (Array.isArray(n.qth)){
for (const q of n.qth){
if (q && q.name) return String(q.name);
}
}
return '';
}
function clientFreqs(n){
let rx = 0, tx = 0;
if (Array.isArray(n.qth)){
for (const q of n.qth){
if (!rx && q && q.rx){
for (const id in q.rx){
const r = q.rx[id];
if (r && Number(r.freq) > 0){ rx = Number(r.freq); break; }
}
}
if (!tx && q && q.tx){
for (const id in q.tx){
const t = q.tx[id];
if (t && Number(t.freq) > 0){ tx = Number(t.freq); break; }
}
}
if (rx && tx) break;
}
}
return { rx: rx ? rx.toFixed(4) : '', tx: tx ? tx.toFixed(4) : '' };
}
function escClient(s){
return String(s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
+8
View File
@@ -0,0 +1,8 @@
'use strict';
// fmt2() comes from lib/fmt.js (loaded before this file).
function includesTG(n, tg){
return Array.isArray(n?.monitoredTGs) && n.monitoredTGs.includes(tg);
}
+978
View File
@@ -0,0 +1,978 @@
/* CTCSS Table view + export (beta)
* - One row per node (export can be multiple rows per node)
* - TX CTCSS = the access/mapping tone (CTCSS_TO_TG) the radio sends to pick a TG
* - RX CTCSS = the node's REPORT_CTCSS (the tone SvxLink transmits, TX_CTCSS),
* filled in from node.reportCtcss when the node publishes it
* - 2m: 144-148 MHz forced NFM + 12.5 kHz
* - UHF: 432-480 MHz user selectable (default NFM + 12.5 kHz)
*/
(function(){
'use strict';
const SELECTED_CALLSIGNS = new Set();
let HAS_USER_SELECTION = false;
// baseCallsign / callsignCompare / parseMHz / fmtFreq come from lib/fmt.js.
function normalizeTone(v){
if(v == null) return '';
const s = String(v).replace(',', '.');
const m = s.match(/(\d+(?:\.\d+)?)/);
return m ? m[1] : '';
}
// The node's REPORT_CTCSS: the tone SvxLink transmits (TX_CTCSS) and that the
// radio should tone-squelch on (RX CTCSS). REPORT_CTCSS=77 in svxlink.conf
// arrives here as reportCtcss:77, normalized to "77.0". Returns "" when the
// node does not publish it (older SvxLink, or no reportCtcss in the node info).
function getReportTone(n){
const v = (n == null) ? null : (n.reportCtcss ?? n.report_ctcss ?? n.reportCTCSS);
if(v == null || v === '') return '';
const num = parseFloat(String(v).replace(',', '.'));
if(!Number.isFinite(num) || num <= 0) return '';
return num.toFixed(1);
}
function syncSelectionFromDOM(){
SELECTED_CALLSIGNS.clear();
document.querySelectorAll('#ctcssRows input[type=checkbox]')?.forEach(cb => {
const cs = cb.dataset.callsign;
if(cb.checked && cs) SELECTED_CALLSIGNS.add(cs);
});
}
const LS_KEY = 'svxportal_ctcss_anytone_defaults_v1';
const DEFAULTS = {
uhfMode: 'NFM', // 'NFM' | 'FM'
uhfBandwidth: '12.5', // '12.5' | '25'
power: 'Low', // AnyTone CPS: Low/Mid/High
startNo: 1, // AnyTone CSV "No." start value
contact: 'Sweden',
contactCallType: 'Group Call',
contactTg: '240',
radioId: '' // if empty: base callsign is used
};
function clampStr(v){ return (v == null) ? '' : String(v); }
function loadDefaults(){
try{
const raw = localStorage.getItem(LS_KEY);
if(!raw) return {...DEFAULTS};
const j = JSON.parse(raw);
return {...DEFAULTS, ...(j||{})};
}catch(_){
return {...DEFAULTS};
}
}
function saveDefaults(d){
try{ localStorage.setItem(LS_KEY, JSON.stringify(d)); }catch(_){}
}
function inRange(x, a, b){
return Number.isFinite(x) && x >= a && x <= b;
}
function bandModeBw(rxMHz, d){
if(inRange(rxMHz, 144.0, 148.0)) return {mode:'NFM', bw:'12.5'};
if(inRange(rxMHz, 432.0, 480.0)) return {mode:d.uhfMode, bw:d.uhfBandwidth};
return {mode:d.uhfMode, bw:d.uhfBandwidth};
}
function pickFreqFromMap(m){
if(!m || typeof m !== 'object') return null;
for(const v of Object.values(m)){
if(v == null) continue;
const f = (typeof v === 'object') ? (v.freq ?? v.frequency) : v;
const mhz = parseMHz(f);
if(Number.isFinite(mhz) && mhz > 0) return f;
}
return null;
}
function getRxTx(n){
const q = (n && Array.isArray(n.qth) && n.qth.length) ? (n.qth[0] || {}) : (n || {});
let rx = (n && (n.rxFreq ?? n.rx ?? n.rx_frequency)) ?? null;
let tx = (n && (n.txFreq ?? n.tx ?? n.tx_frequency)) ?? null;
if(rx == null){
const qrx = q.rx ?? q.rxFreq ?? q.rx_frequency ?? q.frequency ?? q.freq;
rx = (qrx && typeof qrx === 'object') ? pickFreqFromMap(qrx) : qrx;
}
if(tx == null){
const qtx = q.tx ?? q.txFreq ?? q.tx_frequency;
tx = (qtx && typeof qtx === 'object') ? pickFreqFromMap(qtx) : qtx;
}
const rxF = fmtFreq(rx);
const txF = fmtFreq((tx == null || tx === '') ? rx : tx);
return {rx: rxF, tx: txF, rxMHz: parseMHz(rxF)};
}
function getLocation(n){
const q = (n && Array.isArray(n.qth) && n.qth.length) ? (n.qth[0] || {}) : {};
return clampStr(q.name || n?.location || '');
}
function normalizeMappings(m){
const out = [];
if(!m) return out;
if(Array.isArray(m)){
for(const it of m){
if(!it) continue;
const tone = it.tone ?? it.ctcss ?? it.pl ?? it.hz ?? it.freq;
const tg = it.tg ?? it.talkgroup ?? it.dest;
if(tone == null || tg == null) continue;
out.push({tone: clampStr(tone), tg: clampStr(tg)});
}
return out;
}
if(typeof m === 'object'){
for(const [k,v] of Object.entries(m)){
if(v == null) continue;
out.push({tone: clampStr(k), tg: clampStr(v)});
}
return out;
}
return out;
}
function getNodeMappings(cs, n, status){
const m = n?.toneToTalkgroup;
if(!m || typeof m !== 'object' || Array.isArray(m)) return [];
const out = [];
for(const [k,v] of Object.entries(m)){
const tone = normalizeTone(k);
const tg = (v == null) ? '' : clampStr(v);
if(!tone || !tg) continue;
out.push({tone, tg});
}
out.sort((a,b) => parseMHz(a.tone) - parseMHz(b.tone));
return out;
}
function ensureSkeleton(){
const panel = document.getElementById('ctcssPanel');
if(!panel) return null;
if(panel.dataset.inited === '1') return panel;
// Normalize/replace format options (remove legacy generic/chirp; AnyTone split into 878/890; add FTM-400, IC 5100, IC 7100)
const fmtSel = document.getElementById('ctcssFormat');
if(fmtSel){
const wanted = [
{value:'anytone878', label:'AnyTone 878'},
{value:'anytone890', label:'AnyTone 890'},
{value:'ftm400', label:'FTM-400'},
{value:'ic5100', label:'IC 5100'},
{value:'ic7100', label:'IC 7100'}
];
// Migrate legacy single 'anytone' selection to 'anytone878'.
let cur = String(fmtSel.value || '').trim();
if(cur === 'anytone') cur = 'anytone878';
fmtSel.innerHTML = wanted.map(o => `<option value="${o.value}">${o.label}</option>`).join('');
fmtSel.value = wanted.some(o => o.value === cur) ? cur : 'anytone878';
}
const anyBox = document.getElementById('ctcssAnyToneDefaults');
if(anyBox){
const d = loadDefaults();
anyBox.innerHTML = `
<div class="fw-bold mb-2">AnyTone defaults</div>
<div class="d-flex flex-wrap gap-2 align-items-end">
<div>
<div class="small text-secondary">UHF (432–480) mode</div>
<select id="ctcssAnyUhfMode" class="form-select form-select-sm">
<option value="NFM">NFM</option>
<option value="FM">FM</option>
</select>
</div>
<div>
<div class="small text-secondary">UHF (432–480) bandwidth</div>
<select id="ctcssAnyUhfBw" class="form-select form-select-sm">
<option value="12.5">12.5 kHz</option>
<option value="25">25 kHz</option>
</select>
</div>
<div>
<div class="small text-secondary">Power</div>
<select id="ctcssAnyPower" class="form-select form-select-sm">
<option value="Low">Low</option>
<option value="Mid">Mid</option>
<option value="High">High</option>
</select>
</div>
<div>
<div class="small text-secondary">Start No</div>
<input id="ctcssAnyStartNo" type="number" min="1" step="1" class="form-control form-control-sm" style="max-width: 6rem;" value="${Number.isFinite(d.startNo)?d.startNo:1}">
</div>
<div class="ms-auto small text-secondary">
RX CTCSS = node REPORT_CTCSS (Squelch Mode &rarr; CTCSS/DCS). TX = access tone.
</div>
</div>
<div class="small text-secondary mt-2">
2m (144–148): forced NFM + 12.5 kHz.
</div>
`;
const uhfModeEl = document.getElementById('ctcssAnyUhfMode');
const uhfBwEl = document.getElementById('ctcssAnyUhfBw');
const pwrEl = document.getElementById('ctcssAnyPower');
const startNoEl = document.getElementById('ctcssAnyStartNo');
if(uhfModeEl) uhfModeEl.value = d.uhfMode;
if(uhfBwEl) uhfBwEl.value = d.uhfBandwidth;
if(pwrEl) pwrEl.value = d.power;
if(startNoEl) startNoEl.value = String(Number.isFinite(d.startNo)?d.startNo:1);
const save = () => {
const nd = loadDefaults();
if(uhfModeEl) nd.uhfMode = uhfModeEl.value;
if(uhfBwEl) nd.uhfBandwidth = uhfBwEl.value;
if(pwrEl) nd.power = pwrEl.value;
if(startNoEl){
const n = parseInt(startNoEl.value, 10);
nd.startNo = Number.isFinite(n) && n > 0 ? n : 1;
}
saveDefaults(nd);
};
uhfModeEl?.addEventListener('change', () => { save(); window.renderCTCSS?.(window.__svxportal_lastStatus || {}); });
uhfBwEl?.addEventListener('change', () => { save(); window.renderCTCSS?.(window.__svxportal_lastStatus || {}); });
pwrEl?.addEventListener('change', () => { save(); });
startNoEl?.addEventListener('change', () => { save(); });
}
document.getElementById('ctcssSelectAll')?.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('#ctcssRows input[type=checkbox]')?.forEach(cb => cb.checked = true);
HAS_USER_SELECTION = true;
syncSelectionFromDOM();
updateNote();
});
document.getElementById('ctcssSelectNone')?.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('#ctcssRows input[type=checkbox]')?.forEach(cb => cb.checked = false);
HAS_USER_SELECTION = true;
syncSelectionFromDOM();
updateNote();
});
document.getElementById('ctcssFormat')?.addEventListener('change', () => {
const fmt = document.getElementById('ctcssFormat')?.value || 'anytone878';
const anyBox = document.getElementById('ctcssAnyToneDefaults');
if(anyBox) anyBox.style.display = fmt.startsWith('anytone') ? 'block' : 'none';
});
document.getElementById('ctcssExport')?.addEventListener('click', (e) => {
e.preventDefault();
exportSelected();
});
document.getElementById('ctcssExpandAll')?.addEventListener('click', (e) => {
e.preventDefault();
setAllExpanded(true);
});
document.getElementById('ctcssCollapseAll')?.addEventListener('click', (e) => {
e.preventDefault();
setAllExpanded(false);
});
panel.dataset.inited = '1';
return panel;
}
function updateNote(msg){
const el = document.getElementById('ctcssNote');
if(!el) return;
if(typeof msg === 'string'){
el.textContent = msg;
return;
}
const total = document.querySelectorAll('#ctcssRows input[type=checkbox]').length;
const sel = Array.from(document.querySelectorAll('#ctcssRows input[type=checkbox]')).filter(cb => cb.checked).length;
el.textContent = `Nodes: ${total} — selected: ${sel}. Export: one row per tone, or one row without CTCSS if the node has none.`;
}
function buildData(status){
const nodes = status?.nodes || {};
// Local reflector's own nodes only — skip trunk/remote nodes (tagged
// remoteTrunk), same rule as clients_view.js.
const calls = Object.keys(nodes)
.filter(cs => nodes[cs] && !nodes[cs].remoteTrunk)
.sort(callsignCompare);
const rows = [];
for(const cs of calls){
const n = nodes[cs] || {};
const maps = getNodeMappings(cs, n, status);
const {rx, tx, rxMHz} = getRxTx(n);
rows.push({ cs, location: getLocation(n), rx, tx, rxMHz, maps, reportCtcss: getReportTone(n) });
}
return rows;
}
const EXPANDED = new Set();
function renderTable(data){
const tbody = document.getElementById('ctcssRows');
if(!tbody) return;
tbody.innerHTML = '';
for(const row of data){
const tr = document.createElement('tr');
tr.className = 'ctcssMain';
tr.dataset.callsign = row.cs;
const tdChevron = document.createElement('td');
tdChevron.className = 'ctcssChevron';
tdChevron.innerHTML = EXPANDED.has(row.cs) ? '&#9662;' : '&#9656;';
const tdCb = document.createElement('td');
tdCb.className = 'ctcssCb';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'form-check-input';
cb.dataset.callsign = row.cs;
cb.checked = SELECTED_CALLSIGNS.has(row.cs);
cb.addEventListener('click', (e) => { e.stopPropagation(); });
cb.addEventListener('change', (e) => {
e.stopPropagation();
HAS_USER_SELECTION = true;
if(cb.checked) SELECTED_CALLSIGNS.add(row.cs);
else SELECTED_CALLSIGNS.delete(row.cs);
updateNote();
});
tdCb.appendChild(cb);
const tdCs = document.createElement('td');
tdCs.textContent = row.cs;
const tdLoc = document.createElement('td');
tdLoc.textContent = row.location;
const tdRx = document.createElement('td');
tdRx.className = 'nowrap';
tdRx.textContent = row.rx || '-';
const tdTx = document.createElement('td');
tdTx.className = 'nowrap';
tdTx.textContent = row.tx || '-';
const tdMap = document.createElement('td');
tdMap.className = 'nowrap';
tdMap.textContent = row.maps.length.toString();
tr.appendChild(tdChevron);
tr.appendChild(tdCb);
tr.appendChild(tdCs);
tr.appendChild(tdLoc);
tr.appendChild(tdRx);
tr.appendChild(tdTx);
tr.appendChild(tdMap);
tbody.appendChild(tr);
const detail = document.createElement('tr');
detail.className = 'ctcssDetail';
detail.dataset.callsign = row.cs;
detail.style.display = EXPANDED.has(row.cs) ? '' : 'none';
const detailTd = document.createElement('td');
detailTd.colSpan = 7;
detailTd.innerHTML = renderDetailHTML(row);
detail.appendChild(detailTd);
tbody.appendChild(detail);
tr.addEventListener('click', () => {
const isOpen = EXPANDED.has(row.cs);
if(isOpen){
EXPANDED.delete(row.cs);
detail.style.display = 'none';
tdChevron.innerHTML = '&#9656;';
}else{
EXPANDED.add(row.cs);
detail.style.display = '';
tdChevron.innerHTML = '&#9662;';
}
});
}
updateNote();
}
function renderDetailHTML(row){
const rxCtcss = row.reportCtcss || '';
if(!row.maps || !row.maps.length){
const rxNote = rxCtcss ? ` Node transmits RX CTCSS ${rxCtcss} Hz (REPORT_CTCSS).` : '';
return `<div class="ctcssEmpty">No CTCSS tone mappings configured for this node.${rxNote}</div>`;
}
const rxF = row.rx || '-';
const txF = row.tx || '-';
const rxCtcssCell = rxCtcss ? `${rxCtcss}` : '<span class="text-secondary">-</span>';
const rows = row.maps.map(m => {
const tone = clampStr(m.tone);
const tg = clampStr(m.tg);
return `<tr>
<td>${tone} Hz</td>
<td>TG ${tg}</td>
<td>${rxF}</td>
<td>${txF}</td>
<td>${tone}</td>
<td>${rxCtcssCell}</td>
</tr>`;
}).join('');
return `<table class="ctcssSub">
<thead><tr>
<th>CTCSS</th><th>&rarr; TG</th><th>RX</th><th>TX</th><th>TX CTCSS</th><th>RX CTCSS</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
function setAllExpanded(open){
document.querySelectorAll('#ctcssRows tr.ctcssMain').forEach(tr => {
const cs = tr.dataset.callsign;
if(!cs) return;
const detail = tr.nextElementSibling;
const chev = tr.querySelector('.ctcssChevron');
if(open){
EXPANDED.add(cs);
if(detail) detail.style.display = '';
if(chev) chev.innerHTML = '&#9662;';
}else{
EXPANDED.delete(cs);
if(detail) detail.style.display = 'none';
if(chev) chev.innerHTML = '&#9656;';
}
});
}
function selectedCallsigns(){
return Array.from(SELECTED_CALLSIGNS);
}
function mkName(cs, tg){
const n = Number(tg);
if(!Number.isFinite(n) || n <= 0) return String(cs);
return `${cs} TG${n}`;
}
function csvEscape(v){
const s = clampStr(v);
if(/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
// AnyTone CPS CSV exports quote every field (including headers).
function csvQuoteAll(v){
const s = clampStr(v);
return `"${s.replace(/"/g, '""')}"`;
}
function downloadText(filename, text){
const blob = new Blob([text], {type:'text/plain;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportSelected(){
const status = window.__svxportal_lastStatus || {};
const all = buildData(status);
const selected = new Set(selectedCallsigns());
const flat = [];
for(const row of all){
if(!selected.has(row.cs)) continue;
if(!row.maps.length){
flat.push({
callsign: row.cs,
tg: '',
tone: '',
rx: row.rx,
tx: row.tx,
reportCtcss: row.reportCtcss
});
continue;
}
for(const m of row.maps){
flat.push({
callsign: row.cs,
tg: m.tg,
tone: m.tone,
rx: row.rx,
tx: row.tx,
reportCtcss: row.reportCtcss
});
}
}
if(!flat.length){
updateNote('Nothing selected to export.');
return;
}
const fmt = document.getElementById('ctcssFormat')?.value || 'anytone878';
if(fmt === 'anytone890'){
downloadText('svxportal-ctcss-anytone-890.csv', exportAnyTone890(flat));
return;
}
if(fmt === 'anytone878' || fmt === 'anytone'){
downloadText('svxportal-ctcss-anytone-878.csv', exportAnyTone878(flat));
return;
}
if(fmt === 'ftm400'){
downloadText('svxportal-ctcss-400.csv', exportFTM400(flat));
return;
}
if(fmt === 'ic5100'){
downloadText('svxportal-ctcss-5100.csv', exportIC5100(flat));
return;
}
if(fmt === 'ic7100'){
downloadText('svxportal-ctcss-7100.csv', exportIC7100(flat));
return;
}
// Fallback
downloadText('svxportal-ctcss-anytone-878.csv', exportAnyTone878(flat));
}
function fmtMHzFixed(v, decimals){
const mhz = parseMHz(v);
if(!Number.isFinite(mhz)) return clampStr(v);
return mhz.toFixed(decimals);
}
function fmtToneSpace(v){
const n = parseFloat(String(v).replace(/[^0-9.]/g,''));
if(!Number.isFinite(n)) return clampStr(v);
return n.toFixed(1) + ' Hz';
}
function fmtToneNoSpace(v){
const n = parseFloat(String(v).replace(/[^0-9.]/g,''));
if(!Number.isFinite(n)) return clampStr(v);
return n.toFixed(1) + 'Hz';
}
function fmtOffsetHuman(rxMHz, txMHz){
if(!Number.isFinite(rxMHz) || !Number.isFinite(txMHz) || rxMHz === txMHz) return ' ';
const diff = Math.abs(txMHz - rxMHz);
if(diff >= 1.0) return diff.toFixed(2) + ' MHz';
return String(Math.round(diff * 1000)) + ' kHz';
}
function fmtOffsetKHz(rxMHz, txMHz){
if(!Number.isFinite(rxMHz) || !Number.isFinite(txMHz) || rxMHz === txMHz) return ' ';
return String(Math.round(Math.abs(txMHz - rxMHz) * 1000)) + ' kHz';
}
function exportFTM400(rows){
const d = loadDefaults();
const header = [
'Channel Number','Receive Frequency','Transmit Frequency','Offset Frequency','Offset Direction',
'Operating Mode','Name','Show Name','Tone Mode','CTCSS','DCS','Tx Power','Skip','Step','Clock Shift','Comment','User CTCSS',''
];
const out = [header.join(',')];
let ch = 1;
for(const r of rows){
const rxMHz = parseMHz(r.rx);
const txMHz = parseMHz(r.tx || r.rx);
const mb = bandModeBw(rxMHz, d);
const isSimplex = (Number.isFinite(rxMHz) && Number.isFinite(txMHz) && rxMHz === txMHz);
const dir = isSimplex ? 'Simplex' : (txMHz > rxMHz ? 'Plus' : 'Minus');
const off = isSimplex ? ' ' : fmtOffsetHuman(rxMHz, txMHz);
const opMode = (String(mb.mode).toUpperCase() === 'NFM') ? 'FM Narrow' : 'FM';
const hasTone = !!(r.tone && String(r.tone).trim());
const toneMode = hasTone ? 'Tone' : 'None';
const ctcss = hasTone ? fmtToneSpace(r.tone) : '';
out.push([
csvEscape(String(ch++)),
csvEscape(fmtMHzFixed(r.rx, 5)),
csvEscape(fmtMHzFixed(r.tx || r.rx, 5)),
csvEscape(off),
csvEscape(dir),
csvEscape(opMode),
csvEscape(mkName(r.callsign, r.tg)),
csvEscape('Small'),
csvEscape(toneMode),
csvEscape(ctcss),
csvEscape('023'),
csvEscape('Low'),
csvEscape('Off'),
csvEscape('12.5 kHz'),
csvEscape('Off'),
csvEscape(''),
csvEscape('300 Hz'),
csvEscape('')
].join(','));
}
return out.join('\n') + '\n';
}
function exportIC7100(rows){
const header = [
'Channel Number','Receive Frequency','Transmit Frequency','Offset Frequency','Offset Direction',
'Operating Mode','Name','Tone Mode','CTCSS','Rx CTCSS','DCS','DCS Polarity','Select Scan','Comment',
'Digital Squelch','Digital Code','Your Callsign','Rpt-1 CallSign','Rpt-2 CallSign','Filter','Tx Data Mode',
'Data Mode','Split Tone Mode','Split CTCSS','Split Rx CTCSS','Split DCS','Split DCS Polarity','Tx Filter',
'Tx Operating Mode',''
];
const out = [header.join(',')];
let ch = 1;
for(const r of rows){
const rxMHz = parseMHz(r.rx);
const txMHz = parseMHz(r.tx || r.rx);
const isSimplex = (Number.isFinite(rxMHz) && Number.isFinite(txMHz) && rxMHz === txMHz);
const dir = isSimplex ? 'Simplex' : (txMHz > rxMHz ? 'DUP+' : 'DUP-');
const off = isSimplex ? ' ' : fmtOffsetKHz(rxMHz, txMHz);
// Icom (7100/5100) has no separate TX-encode / RX-decode CTCSS on a single
// analog channel — TSQL uses one tone both ways — so REPORT_CTCSS is NOT
// forced onto RX here (it would break access or be inactive). TX/access
// tone only; see the web table for the node's RX CTCSS.
const hasTone = !!(r.tone && String(r.tone).trim());
const toneMode = hasTone ? 'Tone' : 'None';
const ctcss = hasTone ? fmtToneSpace(r.tone) : '';
out.push([
csvEscape(String(ch++)),
csvEscape(fmtMHzFixed(r.rx, 6)),
csvEscape(fmtMHzFixed(r.tx || r.rx, 6)),
csvEscape(off),
csvEscape(dir),
csvEscape('FM'),
csvEscape(mkName(r.callsign, r.tg)),
csvEscape(toneMode),
csvEscape(ctcss),
csvEscape(ctcss),
csvEscape('023'),
csvEscape('Both N'),
csvEscape('Off'),
csvEscape(''),
csvEscape(''),
csvEscape(''),
csvEscape(''),
csvEscape(''),
csvEscape(''),
csvEscape('Filter 1'),
csvEscape(' '),
csvEscape(' '),
csvEscape('None'),
csvEscape(''),
csvEscape(''),
csvEscape('023'),
csvEscape('Both N'),
csvEscape('Filter 1'),
csvEscape('FM'),
csvEscape('')
].join(','));
}
return out.join('\n') + '\n';
}
function exportIC5100(rows){
const header = [
'CH No','Frequency','Dup','Offset','TS','Mode','Name','SKIP','TONE','Repeater Tone',
'TSQL Frequency','DTCS Code','DTCS Polarity','DV SQL','DV CSQL Code','Your Call Sign','RPT1 Call Sign','RPT2 Call Sign'
];
const out = [header.join(';')];
let ch = 0;
for(const r of rows){
const rxMHz = parseMHz(r.rx);
const txMHz = parseMHz(r.tx || r.rx);
const isSimplex = (Number.isFinite(rxMHz) && Number.isFinite(txMHz) && rxMHz === txMHz);
const dup = isSimplex ? 'OFF' : (txMHz > rxMHz ? 'DUP+' : 'DUP-');
// Typical analog offsets (used even when duplex is OFF in CS-5100 exports)
let off = '0.600000';
if(Number.isFinite(rxMHz) && rxMHz >= 200.0) off = '5.000000';
if(!isSimplex && Number.isFinite(rxMHz) && Number.isFinite(txMHz)){
off = Math.abs(txMHz - rxMHz).toFixed(6);
}
let ts = '12.5kHz';
if(Number.isFinite(rxMHz) && rxMHz < 200.0) ts = '6.25kHz';
if(Number.isFinite(rxMHz) && rxMHz >= 400.0) ts = '25kHz';
const hasTone = !!(r.tone && String(r.tone).trim());
const toneMode = hasTone ? 'TONE' : '';
const tone = hasTone ? fmtToneNoSpace(r.tone) : '';
out.push([
csvEscape(String(ch++).padStart(2,'0')),
csvEscape(fmtMHzFixed(r.rx, 6)),
csvEscape(dup),
csvEscape(off),
csvEscape(ts),
csvEscape('FM'),
csvEscape(mkName(r.callsign, r.tg)),
csvEscape('OFF'),
csvEscape(toneMode),
csvEscape(tone),
csvEscape(tone),
csvEscape('023'),
csvEscape('BOTH N'),
csvEscape('OFF'),
csvEscape('0'),
csvEscape('CQCQCQ'),
csvEscape(''),
csvEscape('')
].join(';'));
}
return out.join('\n') + '\n';
}
// ----- AnyTone export -----------------------------------------------------
// The D578UV (878) and D578UVII Plus (890) CPS use almost the same channel
// layout. There are two model-specific functions below — exportAnyTone878()
// and exportAnyTone890() — so it is obvious which one to change for each radio.
// Both share buildAnyTone878Rows() for the common per-channel logic.
//
// 878 = 56 columns. 890 = 77 columns: the first 55 are positionally identical
// (a few are only renamed), then 21 extra NXDN/misc columns, then "txcc"
// (which is the 878 "TxCC" column moved to the end).
const ANYTONE_HEADER_878 = [
'No.','Channel Name','Receive Frequency','Transmit Frequency','Channel Type','Transmit Power','Band Width',
'CTCSS/DCS Decode','CTCSS/DCS Encode','Contact','Contact Call Type','Contact TG/DMR ID','Radio ID',
'Busy Lock/TX Permit','Squelch Mode','Optional Signal','DTMF ID','2Tone ID','5Tone ID','PTT ID',
'RX Color Code','Slot','Scan List','Receive Group List','PTT Prohibit','Reverse','Simplex TDMA',
'Slot Suit','AES Digital Encryption','Digital Encryption','Call Confirmation','Talk Around(Simplex)',
'Work Alone','Custom CTCSS','2TONE Decode','Ranging','Through Mode','APRS RX','Analog APRS PTT Mode',
'Digital APRS PTT Mode','APRS Report Type','Digital APRS Report Channel','Correct Frequency[Hz]',
'SMS Confirmation','Exclude channel from roaming','DMR MODE','DataACK Disable','R5toneBot','R5ToneEot',
'Auto Scan','Ana Aprs Mute','Send Talker Alias','AnaAprsTxPath','ARC4','ex_emg_kind','TxCC'
];
const ANYTONE_HEADER_890 = [
'No.','Channel Name','Receive Frequency','Transmit Frequency','Channel Type','Transmit Power','Band Width',
'CTCSS/DCS Decode','CTCSS/DCS Encode','Contact/Talk Group','Contact/Talk Group Call Type',
'Contact/Talk Group TG/DMR ID','Radio ID','Busy Lock/TX Permit','Squelch Mode','Optional Signal',
'DTMF ID','2Tone ID','5Tone ID','PTT ID','RX Color Code','Slot','Scan List','Receive Group List',
'PTT Prohibit','Reverse','Digital Duplex','Slot Suit','AES Digital Encryption','Digital Encryption',
'Call Confirmation','Talk Around(Simplex)','Work Alone','Custom CTCSS','2TONE Decode','Ranging',
'Idle TX','APRS RX','Analog APRS PTT Mode','Digital APRS PTT Mode','APRS Report Type',
'Digital APRS Report Channel','Correct Frequency[Hz]','SMS Confirmation','Exclude channel from roaming',
'DMR MODE','DataACK Disable','R5toneBot','R5ToneEot','Auto Scan','Ana APRS Mute',
'Send Talker Alias DMR/NX','AnaAprsTxPath','ARC4','ex_emg_kind','Rpga_Mdc','DisturEn','DisturFreq',
'dmr_crc_ignore','compand','tx_talkalaes','dup_call','tx_int','BtRxState','idle_tx','nxdn_wn',
'NxdnRpga','nxdnSqCon','NxdnTxBusy','NxDnPttId','EnRan','DeRan','NxdnEncry','NxdnGroupId',
'NxdnIdNum','NxdnStateNum','txcc'
];
// Remap one 56-column 878 row to the 77-column 890 layout.
// Columns 0..54 are positionally identical; the 21 inserted columns (55..75)
// default to '0' for analog channels (matches a real 890 CPS export); the 878
// "TxCC" column (index 55) becomes the 890 "txcc" column (index 76).
function convert878RowTo890(row878){
const out = new Array(ANYTONE_HEADER_890.length).fill('0');
for(let i = 0; i <= 54; i++) out[i] = row878[i];
out[76] = row878[55];
return out;
}
// Shared per-channel builder. Returns an array of 56-element rows (878 layout),
// already sorted. Both 878 and 890 exports start from this.
function buildAnyTone878Rows(rows){
const d = loadDefaults();
const startNo = (() => {
const n = parseInt(d.startNo, 10);
return Number.isFinite(n) && n > 0 ? n : 1;
})();
const template = [
'1',
'Home SVX',
'434.40000',
'434.40000',
'A-Analog',
'Low',
'12.5K',
'79.7',
'136.5',
'Sweden',
'Group Call',
'240',
'SA4AGM',
'Off',
'Carrier',
'DTMF',
'2',
'1',
'1',
'Off',
'1',
'1',
'None',
'None',
'Off',
'Off',
'Off',
'Off',
'Normal Encryption',
'Off',
'Off',
'Off',
'Off',
'251.1',
'1',
'Off',
'Off',
'Off',
'Off',
'Off',
'Digital',
'1',
'0',
'Off',
'0',
'1',
'0',
'0',
'0',
'0',
'0',
'0',
'0',
'0',
'0',
'1'
];
const tgSortVal = (tg) => {
const n = parseInt(tg, 10);
return Number.isFinite(n) && n > 0 ? n : Number.POSITIVE_INFINITY;
};
const toneSortVal = (t) => {
const n = parseFloat(String(t || '').replace(',', '.'));
return Number.isFinite(n) ? n : Number.POSITIVE_INFINITY;
};
const sorted = (rows || []).slice().sort((a,b) => {
const ta = tgSortVal(a.tg);
const tb = tgSortVal(b.tg);
if(ta !== tb) return ta - tb;
const c = callsignCompare(a.callsign, b.callsign);
if(c) return c;
return toneSortVal(a.tone) - toneSortVal(b.tone);
});
const built = [];
let idx = 0;
for(const r of sorted){
const mb = bandModeBw(parseMHz(r.rx), d);
const bw = (mb.bw === '25') ? '25K' : '12.5K';
const row = template.slice();
// No.
row[0] = String(startNo + idx++);
// Channel Name
row[1] = mkName(r.callsign, r.tg);
// RX/TX
row[2] = r.rx;
row[3] = r.tx;
// Channel Type
row[4] = 'A-Analog';
// Power / Bandwidth
row[5] = d.power || row[5];
row[6] = bw;
// CTCSS/DCS Decode (RX) = node REPORT_CTCSS; Encode (TX) = access/mapping tone.
// When the node publishes a report tone, switch Squelch Mode to CTCSS/DCS so
// the radio only opens on this node (SvxLink sends it always, TX_CTCSS=ALWAYS).
const rxTone = r.reportCtcss ? String(r.reportCtcss) : '';
row[7] = rxTone || 'Off';
row[8] = r.tone ? r.tone : 'Off';
if(rxTone) row[14] = 'CTCSS/DCS';
// Contact defaults (kept for CPS compatibility)
row[9] = d.contact || row[9];
row[10] = d.contactCallType || row[10];
row[11] = d.contactTg || row[11];
// Radio ID (default: base callsign)
row[12] = (d.radioId && String(d.radioId).trim()) ? String(d.radioId).trim() : baseCallsign(r.callsign);
// If no CTCSS/tg mapping: name is only the callsign and there is no TX tone.
// The RX decode tone (row[7]/Squelch Mode) is kept — the node still sends it.
if(!r.tone || !r.tg){
row[1] = String(r.callsign);
row[8] = 'Off';
}
built.push(row);
}
return built;
}
// AnyTone CPS CSV files use CRLF line endings (every line, including a trailing
// one) and no BOM. 890 CPS rejects/mis-imports plain-LF files; 878 is lenient.
const ANYTONE_EOL = '\r\n';
// AnyTone D578UV (878) export.
function exportAnyTone878(rows){
const built = buildAnyTone878Rows(rows);
const out = [ANYTONE_HEADER_878.map(csvQuoteAll).join(',')];
for(const row of built) out.push(row.map(csvQuoteAll).join(','));
return out.join(ANYTONE_EOL) + ANYTONE_EOL;
}
// AnyTone D578UVII Plus (890) export — same channels, wider 77-column layout.
function exportAnyTone890(rows){
const built = buildAnyTone878Rows(rows);
const out = [ANYTONE_HEADER_890.map(csvQuoteAll).join(',')];
for(const row of built) out.push(convert878RowTo890(row).map(csvQuoteAll).join(','));
return out.join(ANYTONE_EOL) + ANYTONE_EOL;
}
window.renderCTCSS = function renderCTCSS(status){
const panel = ensureSkeleton();
if(!panel) return;
window.__svxportal_lastStatus = status || {};
const fmt = document.getElementById('ctcssFormat')?.value || 'anytone878';
const anyBox = document.getElementById('ctcssAnyToneDefaults');
if(anyBox) anyBox.style.display = fmt.startsWith('anytone') ? 'block' : 'none';
const data = buildData(status);
if(!HAS_USER_SELECTION){
for(const row of data) SELECTED_CALLSIGNS.add(row.cs);
}
renderTable(data);
if(!data.length){
updateNote('No nodes found in /svxportal/api/status.');
}else{
updateNote();
}
};
})();
+718
View File
@@ -0,0 +1,718 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Help &amp; Guides — SvxPortal | SvxLink nodes, reflector, build from source</title>
<meta name="description" content="SvxPortal help &amp; guides: register your SvxLink node, certificates, talkgroups and trunks — plus how to compile SvxLink from source on Debian, Ubuntu and Raspberry Pi, and how to find the right deb packages.">
<link rel="canonical" href="https://svxportal.sa7aux.se/help.html">
<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); }
body{ margin:0; }
*{ color:var(--fg); }
.navbar{ background: var(--bg) !important; }
a{ color:var(--fg) !important; }
.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; }
.panel{ border:1px solid var(--line); border-radius:10px; padding:16px; margin-bottom:16px; }
h5{ margin-bottom:12px; }
dt{ color:var(--fg2); font-weight:normal; margin-top:8px; }
dd{ margin-left:0; margin-bottom:4px; }
pre.code{ border:1px solid var(--line); border-radius:8px; padding:12px; overflow-x:auto;
font-size:.8rem; background:rgba(0,255,255,.05); margin:8px 0; }
.help-tabs{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:16px; }
.help-tabs button{ background:var(--bg); color:var(--fg); border:1px solid var(--line);
border-radius:8px; padding:6px 14px; cursor:pointer; }
.help-tabs button.active{ background:rgba(0,255,255,.15); border-color:var(--fg); }
/* Mobile responsive */
@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; }
.d-flex.gap-2 { gap: 4px !important; }
.btn-sm { font-size: .75rem; padding: 3px 8px; }
.mono { font-size: .8rem; word-break: break-all; }
.navbar-text { font-size: .75rem; }
#paginationWrap .btn { padding: 2px 6px !important; font-size: .7rem; }
}
@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; }
.profile-tabs { gap: 2px; }
.profile-tab { padding: 6px 8px; font-size: .8rem; }
}
</style>
</head>
<body>
<nav class="navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">SvxPortal</a>
<span class="navbar-text small me-auto ms-3">Help</span>
<a href="/" class="btn btn-sm btn-outline-secondary" onclick="if(history.length>1){history.back();return false;}">Back</a>
</div>
</nav>
<div class="container py-3" style="max-width:800px;">
<div class="help-tabs" id="helpTabs">
<button data-tab="portal" class="active">Portal</button>
<button data-tab="svxlink">SvxLink</button>
<button data-tab="svxref">SvxReflector</button>
<button data-tab="geu">GeuReflector</button>
<button data-tab="usrp">SvxLink-USRP</button>
<button data-tab="opusfec">Opus FEC</button>
<button data-tab="allstar">AllStar</button>
<button data-tab="pbxsvx">PBX &harr; SVX</button>
<button data-tab="admin">Admin</button>
</div>
<div class="tab-section" id="tab-portal">
<div class="panel">
<h5>Getting Started</h5>
<dl>
<dt>Register</dt>
<dd>Click <strong>Register</strong> in the top bar. Fill in your callsign, name, email and password. A verification email will be sent. After verifying your email, an administrator will activate your account.</dd>
<dt>Sign in</dt>
<dd>Click <strong>Sign in</strong> and enter your callsign (or email) and password.</dd>
<dt>Profile</dt>
<dd>After signing in, click your callsign in the top right and select <strong>Profile</strong>. Here you can edit your details and see your connected nodes.</dd>
</dl>
</div>
<div class="panel">
<h5>Your Nodes</h5>
<dl>
<dt>My Nodes tab</dt>
<dd>In your Profile, the <strong>My Nodes</strong> tab shows all nodes connected to the reflector that match your callsign. You can see node info, monitored talkgroups, CTCSS mappings, QTH, and RX/TX status.</dd>
<dt>Node Admins</dt>
<dd>Each node can have multiple administrators. The node owner (sysop) is always an admin. Additional admins can be added by a site administrator.</dd>
<dt>Certificates</dt>
<dd>Nodes need a signed certificate to connect. When your node first connects, it sends a Certificate Signing Request (CSR). An administrator will sign it, and the reflector sends the certificate to your node automatically.</dd>
</dl>
</div>
<div class="panel">
<h5>Account Status</h5>
<dl>
<dt>Banned</dt>
<dd>Your account has been suspended by an administrator. You cannot log in. The reason (if provided) will be shown at login. Contact an administrator to resolve this.</dd>
<dt>Locked</dt>
<dd>Your account has been temporarily locked. You cannot log in. The reason (if provided) will be shown at login. Contact an administrator.</dd>
<dt>Unverified</dt>
<dd>Your email has not been verified yet. Check your inbox for the verification link. If you did not receive it, contact an administrator.</dd>
</dl>
</div>
<div class="panel">
<h5>Notifications</h5>
<dl>
<dt>Bell icon</dt>
<dd>The bell icon in the top bar shows notifications. For regular users it shows account events (ban, lock, unlock). For administrators it also shows pending certificate requests and new registrations.</dd>
</dl>
</div>
<div class="panel">
<h5>Navigation</h5>
<dl>
<dt>Reflector clients</dt>
<dd>All connected nodes with real-time status, <strong>including remote nodes that arrive via a trunk</strong>. Shown as a table: Callsign (with sysop), Class, TG, Location, RX/TX, software/version and monitored talkgroups. The node currently transmitting is highlighted.</dd>
<dt>Monitor</dt>
<dd>Live activity monitor with <strong>Nodes</strong> and <strong>Trunks</strong> sub-views. Trunks shows each trunk link's status (Name, Status, heartbeat rx/tx, active talkgroups, Type, Qual).</dd>
<dt>Lastheard</dt>
<dd>Recent transmissions with callsign, talkgroup and duration &mdash; including traffic that arrives over a trunk (shown as <em>callsign via &lt;trunk&gt;</em>, with source local/trunk).</dd>
<dt>CTCSS Table</dt>
<dd>Reference table for CTCSS tone frequencies and talkgroup mappings.</dd>
<dt>Map</dt>
<dd>Geographic map showing node locations.</dd>
</dl>
</div>
<div class="panel">
<h5>Trunks &amp; Remote Nodes</h5>
<dl>
<dt>What is a trunk?</dt>
<dd>A trunk is a link between this reflector and another reflector network (for example reflector-network.se). Talkgroup audio is exchanged across the link.</dd>
<dt>Remote nodes</dt>
<dd>Nodes on the other side of a trunk are relayed in and appear in the client list, Monitor and Lastheard just like local nodes. They do <strong>not</strong> log in to this reflector &mdash; only the trunk link itself is authenticated (a shared key).</dd>
<dt>Identity</dt>
<dd>A remote node's identity is the name it advertises over the trunk (e.g. a node name like <span class="mono">SE-EASTCOAST</span>). There is no separate, locally verified operator callsign for trunk traffic &mdash; the advertised name is the identity.</dd>
</dl>
</div>
</div>
<div class="tab-section d-none" id="tab-svxlink">
<div class="panel">
<h5>Build SvxLink from source (Debian / Ubuntu / Raspberry Pi)</h5>
<dl>
<dt>What this covers</dt>
<dd>Compiling stock <a href="https://github.com/sm0svx/svxlink">SvxLink</a> (svxlink node, svxreflector, RemoteTrx) on Debian 12/13 (bookworm/trixie) or Ubuntu 22.04/24.04 &mdash; PC or Raspberry&nbsp;Pi (64-bit). The same procedure works for forks: just clone a different repository.</dd>
</dl>
<pre class="code">#!/bin/bash
set -e
# --- Build dependencies ------------------------------------------------
# Package names drift between releases. If apt says
# "Unable to locate package X": apt search &lt;part-of-name&gt;
# (e.g. apt search libgpiod) and pick the closest -dev package.
sudo apt update
sudo apt install -y \
build-essential cmake git wget curl sox alsa-utils vorbis-tools \
libgpiod-dev `# pulls the right runtime: libgpiod2 (bookworm/ubuntu) or libgpiod3 (trixie)` \
libsigc++-2.0-dev libgsm1-dev libpopt-dev \
tcl8.6-dev `# NOT tcl9.0-dev - svxlink wants 8.6 (apt list -a 'tcl*-dev' shows yours)` \
libgcrypt20-dev libssl-dev libspeex-dev libasound2-dev libopus-dev \
librtlsdr-dev libjsoncpp-dev libcurl4-gnutls-dev libogg-dev
# Optional, only for the Qtel EchoLink GUI (skip on headless/RPi):
# sudo apt install -y qtbase5-dev qttools5-dev qttools5-dev-tools
# Optional, only for "make doc": doxygen groff
# --- svxlink system user -----------------------------------------------
id -u svxlink &gt;/dev/null 2&gt;&amp;1 || sudo useradd -r -G audio,plugdev,dialout svxlink
getent group gpio &gt;/dev/null &amp;&amp; sudo usermod -aG gpio svxlink # RPi: GPIO PTT
# --- Fetch and build ---------------------------------------------------
mkdir -p ~/projects &amp;&amp; cd ~/projects
[ -d svxlink ] || git clone https://github.com/sm0svx/svxlink.git
mkdir -p svxlink/src/build &amp;&amp; cd svxlink/src/build
cmake -DCMAKE_INSTALL_PREFIX=/usr -DSYSCONF_INSTALL_DIR=/etc \
-DLOCAL_STATE_DIR=/var -DWITH_SYSTEMD=yes ..
make -j"$(nproc)"
sudo make install
sudo ldconfig
# --- Sound files (English + Swedish) -----------------------------------
cd ~/projects
[ -d svxlink-sounds-en_US-heather ] || git clone https://github.com/sm0svx/svxlink-sounds-en_US-heather.git
[ -d svxlink-sounds-sv_SE-elin ] || git clone https://github.com/sm0svx/svxlink-sounds-sv_SE-elin.git
cp svxlink/src/svxlink/scripts/filter_sounds.sh .
sudo ./filter_sounds.sh svxlink-sounds-en_US-heather en_US
sudo ./filter_sounds.sh svxlink-sounds-sv_SE-elin sv_SE
sudo tar xjf svxlink-sounds-en_US.tar.bz2 -C /usr/share/svxlink/sounds
sudo tar xjf svxlink-sounds-sv_SE.tar.bz2 -C /usr/share/svxlink/sounds
# --- Enable what this machine should run -------------------------------
# Node: sudo systemctl enable --now svxlink
# Reflector: sudo systemctl enable --now svxreflector</pre>
<dl>
<dt>&quot;Unable to locate package &hellip;&quot;</dt>
<dd>Find the real name on your release: <span class="mono">apt search libgpiod</span>, <span class="mono">apt list -a 'tcl*-dev'</span> or <span class="mono">apt-cache policy &lt;package&gt;</span>. Rule of thumb: install the <span class="mono">-dev</span> package &mdash; its name is stable across releases, while runtime libraries carry version suffixes that differ (libgpiod2 vs libgpiod3).</dd>
<dt>&quot;fatal error: some/header.h: No such file&quot;</dt>
<dd>Find which package ships the header: <span class="mono">sudo apt install apt-file &amp;&amp; sudo apt-file update</span>, then <span class="mono">apt-file search header.h</span> &mdash; install the -dev package it points to and re-run cmake.</dd>
<dt>Raspberry Pi notes</dt>
<dd>64-bit Raspberry Pi OS builds fine with the same script. The <span class="mono">gpio</span> group above is what allows GPIO PTT. On a Pi&nbsp;3, use <span class="mono">make -j2</span> to avoid running out of RAM.</dd>
</dl>
</div>
<div class="panel">
<h5>Configure the node &mdash; radio side (svxlink.conf)</h5>
<dl>
<dt>Skeleton</dt>
<dd>A minimal simplex node: one logic, one receiver, one transmitter. Everything lives in <span class="mono">/etc/svxlink/svxlink.conf</span>:</dd>
</dl>
<pre class="code">[GLOBAL]
LOGICS=SimplexLogic,ReflectorLogic
CFG_DIR=svxlink.d
CARD_SAMPLE_RATE=48000
[SimplexLogic]
TYPE=Simplex
RX=Rx1
TX=Tx1
CALLSIGN=SM0ABC
MODULES=ModuleHelp,ModuleParrot
REPORT_CTCSS=136.5 ; subtone your node transmits
DEFAULT_LANG=en_US
[Rx1]
TYPE=Local
AUDIO_DEV=alsa:plughw:0
AUDIO_CHANNEL=0
SQL_DET=CTCSS ; VOX works too, CTCSS is cleaner
CTCSS_FQ=136.5
SQL_HANGTIME=2000
[Tx1]
TYPE=Local
AUDIO_DEV=alsa:plughw:0
AUDIO_CHANNEL=0
PTT_TYPE=SerialPin ; on a Raspberry Pi instead:
PTT_PORT=/dev/ttyUSB0 ; PTT_TYPE=GPIOD
PTT_PIN=DTRRTS ; PTT_GPIOD_CHIP=gpiochip0 PTT_GPIOD_LINE=17</pre>
<dl>
<dt>CTCSS &rarr; talkgroup</dt>
<dd>With <span class="mono">CTCSS_TO_TG=</span> in the logic section, different subtones select different talkgroups &mdash; that is what populates the tone table in this portal.</dd>
</dl>
</div>
<div class="panel">
<h5>Set up a SvxLink node (connect to this network)</h5>
<dl>
<dt>1. Register</dt>
<dd>Create an account on this portal first (see <em>Getting Started</em>) so the administrator knows who the node belongs to.</dd>
<dt>2. Configure ReflectorLogic</dt>
<dd>In <span class="mono">/etc/svxlink/svxlink.conf</span> on your node:</dd>
</dl>
<pre class="code">[ReflectorLogic]
TYPE=Reflector
HOSTS=svx.sa7aux.se
CALLSIGN="SM0ABC-1" ; your node callsign (with -suffix if you run several)
CERT_EMAIL=you@example.com ; used in your node certificate
CERT_DOWNLOAD_CA_BUNDLE=1
CERT_CAFILE=/var/lib/svxlink/pki/ca-bundle.pem
DEFAULT_TG=240
MONITOR_TGS=240,2400
EVENT_HANDLER=/usr/share/svxlink/events.tcl
NODE_INFO_FILE=/etc/svxlink/node_info.json</pre>
<dl>
<dt>3. First connect &mdash; certificate</dt>
<dd>Add the logic to <span class="mono">LOGICS=</span> in <span class="mono">[GLOBAL]</span> and start svxlink. On first connect the node automatically sends a Certificate Signing Request. When the administrator signs it, the certificate is delivered to your node automatically and it comes online &mdash; nothing more to do on your side.</dd>
<dt>Fill in node_info.json</dt>
<dd>QTH, locator, frequencies and CTCSS in <span class="mono">/etc/svxlink/node_info.json</span> is what makes your node show up properly in the portal (map, frequencies, tone table).</dd>
</dl>
</div>
</div>
<div class="tab-section d-none" id="tab-usrp">
<div class="panel">
<h5>Build the USRP fork (svxlink-usrp)</h5>
<dl>
<dt>What it is</dt>
<dd>DL1HRC's SvxLink fork with <span class="mono">UsrpLogic</span> &mdash; bridges USRP audio (AllStar <span class="mono">chan_usrp</span>, DVSwitch Analog_Bridge) into SvxLink. UsrpLogic lives in contrib and is built automatically.</dd>
</dl>
<pre class="code"># Same build dependencies as the stock guide above, then:
cd ~/projects
git clone https://github.com/dl1hrc/svxlink.git svxlink-usrp
cd svxlink-usrp
git checkout svxlink-usrp
mkdir -p src/build &amp;&amp; cd src/build
cmake -DCMAKE_INSTALL_PREFIX=/usr -DSYSCONF_INSTALL_DIR=/etc \
-DLOCAL_STATE_DIR=/var -DWITH_SYSTEMD=yes ..
make -j"$(nproc)"
sudo make install &amp;&amp; sudo ldconfig</pre>
<dl>
<dt>Configuration</dt>
<dd>Add a <span class="mono">[UsrpLogic]</span> section to svxlink.conf &mdash; see the <span class="mono">UsrpLogic.conf</span> man page shipped with the fork for the USRP host/port settings.</dd>
</dl>
</div>
<div class="panel">
<h5>Configure UsrpLogic (svxlink.conf)</h5>
<pre class="code">[UsrpLogic]
TYPE=Usrp
USRP_HOST=127.0.0.1
USRP_TX_PORT=4002 ; svxlink sends here (Asterisk listens)
USRP_RX_PORT=4001 ; svxlink listens here (Asterisk sends)
CALL=N0CALL
DV_USER_INFOFILE=/etc/svxlink/dv_users.json
JITTER_BUFFER_DELAY=100
SHARE_USERINFO=1</pre>
<dl>
<dt>Wiring it up</dt>
<dd>Add <span class="mono">UsrpLogic</span> to <span class="mono">LOGICS=</span> and connect it to your <span class="mono">ReflectorLogic</span> with a LinkManager section (see the svxlink.conf man page) &mdash; the reflector TG the bridge lands on is the logic's <span class="mono">DEFAULT_TG</span>. The other end of the UDP port pair is the AllStar side &mdash; see the <strong>AllStar</strong> tab.</dd>
</dl>
</div>
</div>
<div class="tab-section d-none" id="tab-opusfec">
<div class="panel">
<h5>Opus in-band FEC patch (lossy long-haul links)</h5>
<dl>
<dt>What it fixes</dt>
<dd>Over a long-haul reflector hop with real packet loss (for example a transatlantic link) Opus audio breaks up more and more the longer a QSO runs. SvxLink's Opus encoder ships with <strong>in-band FEC off</strong> and <strong>expected packet loss 0%</strong> &mdash; and there was no config key to change it: the encoder had the setters but they were never wired to <span class="mono">setOption</span>. This patch exposes them so the config can turn on forward error correction. (Confirmed identical in stock <span class="mono">sm0svx</span> and the <span class="mono">dl1hrc</span> USRP fork.)</dd>
<dt>Where it belongs</dt>
<dd>On the <strong>SvxLink node that encodes</strong> audio into the lossy hop &mdash; <em>not</em> on SvxReflector, which only relays the already-encoded Opus stream. FEC must be added by the encoder at the sending end; the decoder at the far end uses it automatically.</dd>
</dl>
</div>
<div class="panel">
<h5>Get the patched fork and build it</h5>
<dl>
<dt>Clone the public fork</dt>
<dd>The patch sits on top of DL1HRC's <span class="mono">svxlink-usrp</span> in a public Gitea fork. Clone it and check out the tagged, patched version:</dd>
</dl>
<pre class="code"># Same build dependencies as the SvxLink tab, then:
cd ~/projects
git clone https://git.sa7aux.se/bjorne/svxlink-fork-usrp.git svxlink-usrp
cd svxlink-usrp
git checkout opus-fec-260723 # tagged patched version
# or: git checkout opus-inband-fec # the branch it lives on
mkdir -p src/build &amp;&amp; cd src/build
cmake -DCMAKE_INSTALL_PREFIX=/usr -DSYSCONF_INSTALL_DIR=/etc \
-DLOCAL_STATE_DIR=/var -DWITH_SYSTEMD=yes -DUSE_QT=OFF ..
make -j"$(nproc)"
sudo make install &amp;&amp; sudo ldconfig</pre>
<dl>
<dt><span class="mono">-DUSE_QT=OFF</span></dt>
<dd>Skips the Qtel EchoLink GUI &mdash; needed unless the full Qt6 stack (including Qt6Core5Compat) is installed. We only build the node, reflector and audio libraries.</dd>
</dl>
</div>
<div class="panel">
<h5>Enable FEC (svxlink.conf &mdash; ReflectorLogic)</h5>
<dl>
<dt>Config keys</dt>
<dd>Add these to the <span class="mono">[ReflectorLogic]</span> section. Any <span class="mono">OPUS_ENC_&lt;OPTION&gt;</span> key is passed straight through to the Opus encoder:</dd>
</dl>
<pre class="code">[ReflectorLogic]
; ... existing settings ...
OPUS_ENC_INBAND_FEC=1 ; enable Opus in-band forward error correction
OPUS_ENC_PACKET_LOSS=10 ; expected packet loss %, drives FEC redundancy</pre>
<dl>
<dt>OPUS_ENC_INBAND_FEC</dt>
<dd><span class="mono">1</span> = on, <span class="mono">0</span> = off (default). With FEC on, the encoder embeds a low-bitrate copy of the previous frame inside the next packet, so the decoder can rebuild a single lost packet instead of dropping audio.</dd>
<dt>OPUS_ENC_PACKET_LOSS</dt>
<dd>The loss percentage the encoder should plan for (for example <span class="mono">10</span>). It controls how much FEC redundancy is added: higher = more robust to loss but slightly less bitrate left for the main audio. Match it to the real loss on your path &mdash; 5&ndash;15 is sensible for a transatlantic hop.</dd>
<dt>Verify</dt>
<dd>On start SvxLink logs its Opus parameters. You should now see <span class="mono">Inband FEC = YES</span> and <span class="mono">Expected Packet Loss = 10%</span> instead of <span class="mono">NO</span> / <span class="mono">0%</span>.</dd>
</dl>
</div>
</div>
<div class="tab-section d-none" id="tab-geu">
<div class="panel">
<h5>Build GeuReflector (trunked reflector)</h5>
<dl>
<dt>What it is</dt>
<dd>A fork of SvxReflector (trunk extension by IW1GEU) that links multiple reflectors into a federated mesh &mdash; talkgroups are routed to their owning server by numeric prefix. This network runs SA2BLV's fork of it.</dd>
</dl>
<pre class="code"># Same build dependencies as the stock guide above, plus:
sudo apt install -y libhiredis-dev `# or build with -DWITH_REDIS=OFF`
cd ~/projects
git clone https://github.com/sa2blv/GeuReflector.git
cd GeuReflector
git checkout SwedenReflectors
mkdir -p src/build &amp;&amp; cd src/build
cmake -DCMAKE_INSTALL_PREFIX=/usr -DSYSCONF_INSTALL_DIR=/etc \
-DLOCAL_STATE_DIR=/var -DWITH_SYSTEMD=yes ..
make -j"$(nproc)"
sudo make install &amp;&amp; sudo ldconfig
sudo systemctl enable --now svxreflector</pre>
</div>
<div class="panel">
<h5>Configure trunks (svxreflector.conf)</h5>
<dl>
<dt>How routing works</dt>
<dd>Each reflector owns all talkgroups whose number starts with its prefix (like telephone country codes). Trunk peers exchange audio over UDP and talkgroups are routed to the owning server automatically.</dd>
</dl>
<pre class="code">[GLOBAL]
LOCAL_PREFIX=240 ; this reflector owns TG 240*, 2400*, ...
[ReflectorTrunk]
GatewayId=SE-MYREFLECTOR ; this server's identity on the trunk network
Port=5500 ; UDP trunk port
Peers=SE-AUX ; comma-separated [TrunkPeer#...] names
[TrunkPeer#SE-AUX]
Host=reflector.example.com
Port=5500
TGRule=^.*$ ; regex: which TGs may cross this trunk
Retransmit=0
ActiveFilter=0
Key=CHANGEME ; shared secret, same on both sides</pre>
<dl>
<dt>Joining an existing network</dt>
<dd>Both sides must agree on peer name, port and key. To peer with this network, contact the administrator.</dd>
</dl>
</div>
</div>
<div class="tab-section d-none" id="tab-svxref">
<div class="panel">
<h5>Run your own reflector: basics &amp; password login</h5>
<dl>
<dt>Basics</dt>
<dd>In <span class="mono">/etc/svxlink/svxreflector.conf</span>: <span class="mono">LISTEN_PORT=5300</span> is what nodes connect to, <span class="mono">HTTP_SRV_PORT=8090</span> serves the <span class="mono">/status</span> JSON that dashboards read.</dd>
<dt>Password login (legacy v1/v2 clients)</dt>
<dd><span class="mono">[USERS]</span> maps a callsign to a key group and <span class="mono">[PASSWORDS]</span> holds each group's secret &mdash; several nodes can share one group:</dd>
</dl>
<pre class="code">[USERS]
SM0ABC-1=MyNodes
SM0ABC-2=MyNodes
SM3XYZ=SM3XYZ
[PASSWORDS]
MyNodes="a strong shared key"
SM3XYZ="another strong password"</pre>
</div>
<div class="panel">
<h5>Certificates (PKI)</h5>
<dl>
<dt>The reflector is its own CA</dt>
<dd>On first start the reflector creates a complete PKI from the <span class="mono">[ROOT_CA]</span>, <span class="mono">[ISSUING_CA]</span> and <span class="mono">[SERVER_CERT]</span> sections &mdash; fill in your own organisation and set <span class="mono">COMMON_NAME</span>/<span class="mono">SUBJECT_ALT_NAME</span> in <span class="mono">[SERVER_CERT]</span> to your reflector's public hostname:</dd>
</dl>
<pre class="code">[GLOBAL]
CERT_PKI_DIR=/etc/svxlink/pki
CERT_CA_BUNDLE=/etc/svxlink/pki/ca-bundle.crt
CERT_CA_KEYS_DIR=/etc/svxlink/pki/private/
CERT_CA_PENDING_CSRS_DIR=/etc/svxlink/pki/pending_csrs/
CERT_CA_CSRS_DIR=/etc/svxlink/pki/csrs/
CERT_CA_CERTS_DIR=/etc/svxlink/pki/certs/
#ACCEPT_CALLSIGN="[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?"
[SERVER_CERT]
COMMON_NAME=reflector.example.com
SUBJECT_ALT_NAME=DNS:reflector.example.com
ORG=MYCLUB
COUNTRY=SE
EMAIL_ADDRESS=you@example.com</pre>
<dl>
<dt>Signing node certificates</dt>
<dd>Incoming node CSRs land in <span class="mono">CERT_CA_PENDING_CSRS_DIR</span> until an administrator signs them; the signed certificate is then pushed to the node automatically over the reflector connection. Use the <span class="mono">ACCEPT_CALLSIGN=</span> regex to restrict who may even submit one.</dd>
</dl>
</div>
</div>
<div class="tab-section d-none" id="tab-allstar">
<div class="panel">
<h5>AllStar &rarr; SvxReflector &mdash; native (chan_svx)</h5>
<dl>
<dt>What it is</dt>
<dd><span class="mono">chan_svx</span> is an Asterisk channel driver for <strong>AllStarLink&nbsp;3</strong> that connects directly to a SvxLink <strong>SVXReflector</strong>. Unlike the USRP bridge (see the <strong>SvxLink-USRP</strong> tab), it speaks the reflector protocol natively &mdash; lower latency, simpler architecture, a single Opus transcoding step, and reduced CPU/memory. SVXReflector talkgroups can be used straight from AllStarLink with no intermediate USRP hop.</dd>
<dt>Features</dt>
<dd>Native SVXReflector connectivity &middot; direct <span class="mono">app_rpt</span> integration &middot; SVXReflector v2 protocol (HMAC-SHA1) &middot; configurable jitter buffer &middot; adjustable RX/TX gain &middot; optional RX/TX AGC &middot; x86-64 and ARM64 (Raspberry&nbsp;Pi). Source code is private.</dd>
<dt>Requirements</dt>
<dd>Debian&nbsp;12 (Bookworm) or Debian&nbsp;13 (Trixie) &middot; AllStarLink&nbsp;3 (<span class="mono">asl3-asterisk</span>) already installed &middot; access credentials for an SVXReflector.</dd>
</dl>
<dl>
<dt>1. Choose the correct package</dt>
<dd>Browse and download from the repository: <a href="https://git.sa7aux.se/bjorne/chan-svx-deb" target="_blank" rel="noopener">git.sa7aux.se/bjorne/chan-svx-deb</a></dd>
<dd>Check your Debian release and CPU architecture, then pick the matching <span class="mono">.deb</span> (64-bit only):</dd>
</dl>
<pre class="code">. /etc/os-release; echo $VERSION_CODENAME # trixie / bookworm
dpkg --print-architecture # amd64 / arm64</pre>
<dl>
<dd>
<span class="mono">asl3-chan-svx_0.2.4~trixie_amd64.deb</span> &mdash; Debian&nbsp;13, x86-64<br>
<span class="mono">asl3-chan-svx_0.2.4~trixie_arm64.deb</span> &mdash; Debian&nbsp;13, ARM64 (Raspberry Pi OS Trixie)<br>
<span class="mono">asl3-chan-svx_0.2.4~bookworm_amd64.deb</span> &mdash; Debian&nbsp;12, x86-64<br>
<span class="mono">asl3-chan-svx_0.2.4~bookworm_arm64.deb</span> &mdash; Debian&nbsp;12, ARM64 (Raspberry Pi OS Bookworm)
</dd>
</dl>
<dl>
<dt>2. Install</dt>
<dd>Dependencies (<span class="mono">asl3-asterisk</span>, <span class="mono">libopus0</span>, <span class="mono">libssl3</span>) are pulled in automatically:</dd>
</dl>
<pre class="code">sudo apt install ./asl3-chan-svx_0.2.4~&lt;os&gt;_&lt;arch&gt;.deb</pre>
<dl>
<dt>3. Create /etc/asterisk/svx.conf</dt>
<dd>One section per reflector login / talkgroup. Required: <span class="mono">host</span>, <span class="mono">port</span>, <span class="mono">callsign</span>, <span class="mono">password</span>, <span class="mono">tg</span>. Optional: <span class="mono">tx_hold</span>, <span class="mono">jitter</span>, <span class="mono">rx_gain</span>, <span class="mono">tx_gain</span>, <span class="mono">rx_agc</span>, <span class="mono">tx_agc</span>.</dd>
<dt>4. Enable the module</dt>
<dd>In <span class="mono">/etc/asterisk/modules.conf</span> (remove the leading semicolon if the line is commented out):</dd>
</dl>
<pre class="code">load = chan_svx.so</pre>
<dl>
<dt>5. Point app_rpt at it</dt>
<dd>In <span class="mono">/etc/asterisk/rpt.conf</span>, where <span class="mono">&lt;section&gt;</span> matches the <span class="mono">svx.conf</span> section name:</dd>
</dl>
<pre class="code">rxchannel = SVX/&lt;section&gt;</pre>
<dl>
<dt>6. Restart &amp; verify</dt>
<dd>A successful connection prints reflector and talkgroup information:</dd>
</dl>
<pre class="code">sudo asterisk -rx "core restart now"
sudo asterisk -rx "svx show"</pre>
<dl>
<dt>Compatibility</dt>
<dd>Implements the SVXReflector v2 protocol (HMAC-SHA1). Reflectors that provide v2 compatibility can be used even if their SvxLink nodes normally operate on v3/TLS.</dd>
<dt>Notes</dt>
<dd>Reflector logins must use an <strong>upper-case callsign</strong> &mdash; SVXReflector validates callsigns case-sensitively. Match the package version to your installed AllStarLink&nbsp;3 release. A full guide ships in <span class="mono">/usr/share/doc/asl3-chan-svx/INSTALL.md</span>, and the post-install script prints the required steps.</dd>
</dl>
</div>
</div>
<div class="tab-section d-none" id="tab-pbxsvx">
<div class="panel">
<h5>PBX &rarr; SvxReflector &mdash; native (chan_pbx_sat)</h5>
<dl>
<dt>What it is</dt>
<dd><span class="mono">chan_pbx_sat</span> is an Asterisk channel driver that connects a <strong>PBX</strong> (FreePBX or standard Asterisk) directly to SvxLink <strong>SVXReflector</strong> talkgroups &mdash; no AllStar / <span class="mono">app_rpt</span> in the path. A phone dials a talkgroup (PBX&nbsp;&rarr;&nbsp;SVX); a talkgroup can ring a phone or ring group (SVX&nbsp;&rarr;&nbsp;PBX). A single Opus transcoding step. Source code is private.</dd>
<dt>Two packages</dt>
<dd><span class="mono">chan-pbx-sat</span> &mdash; the driver (required, any Asterisk box).<br>
<span class="mono">pbxsat-freepbx</span> &mdash; the FreePBX web GUI (<em>Connectivity &rarr; PBX &harr; SVX</em>): trunks, routing, callsigns, talkgroups, access lists, plus a live status panel and a dashboard widget. Install it too on a FreePBX box.</dd>
<dt>Requirements</dt>
<dd>Debian&nbsp;12/13 or CentOS/RHEL&nbsp;7/8/9 &middot; x86-64 &middot; Asterisk (driver) / FreePBX (GUI) &middot; access credentials for an SVXReflector.</dd>
</dl>
<dl>
<dt>1. Get the packages</dt>
<dd>All prebuilt <span class="mono">.deb</span> / <span class="mono">.rpm</span> packages live in the repository &mdash; browse and download them there:</dd>
<dd><a href="https://git.sa7aux.se/bjorne/pbx-sat-deb" target="_blank" rel="noopener">git.sa7aux.se/bjorne/pbx-sat-deb</a></dd>
</dl>
<dl>
<dt>2. Install &mdash; Debian 12 / 13</dt>
</dl>
<pre class="code">. /etc/os-release; echo $VERSION_CODENAME # bookworm (12) / trixie (13)
sudo apt install ./chan-pbx-sat_&lt;ver&gt;~&lt;suite&gt;_amd64.deb
# on a FreePBX box, add the GUI:
sudo apt install ./pbxsat-freepbx_&lt;ver&gt;~&lt;suite&gt;_all.deb</pre>
<dl>
<dt>2. Install &mdash; CentOS / RHEL 7 / 8 / 9</dt>
</dl>
<pre class="code">sudo dnf install ./chan-pbx-sat-&lt;ver&gt;.&lt;asttag&gt;.&lt;dist&gt;.x86_64.rpm
# on a FreePBX box, add the GUI:
sudo dnf install ./pbxsat-freepbx-&lt;ver&gt;.&lt;asttag&gt;.&lt;dist&gt;.noarch.rpm</pre>
<dl>
<dt>3. Configure</dt>
<dd>Copy <span class="mono">/etc/asterisk/pbx_sat.conf.sample</span> to <span class="mono">pbx_sat.conf</span> and edit it &mdash; or, with <span class="mono">pbxsat-freepbx</span> installed, use the GUI under <em>Connectivity &rarr; PBX &harr; SVX</em> (no database; it writes the same file).</dd>
<dt>4. Enable &amp; dial</dt>
<dd>Uncomment <span class="mono">load = chan_pbx_sat.so</span> in <span class="mono">/etc/asterisk/modules.conf</span>, then point a dial-plan route at <span class="mono">PBXSAT/&lt;trunk&gt;/&lt;tg&gt;</span>. Full guide: <span class="mono">/usr/share/doc/chan-pbx-sat/HOWTO.md</span>, or follow the complete walk-through below.</dd>
</dl>
</div>
<div class="panel">
<h5>Full FreePBX setup &mdash; everything, step by step</h5>
<p>The module only writes <span class="mono">pbx_sat.conf</span>. A working bridge also needs a few standard FreePBX pieces (an extension, the module loaded, a dial-plan entry). This is the whole chain, in order. Replace <span class="mono">mytrunk</span>, <span class="mono">91</span> and the talkgroup / extension numbers with your own.</p>
<dl>
<dt>0. Prerequisites</dt>
<dd>A running FreePBX&nbsp;17 (or standard Asterisk) on x86-64; at least one <strong>extension</strong> a phone can register to (<em>Applications &rarr; Extensions</em>); and credentials for an SVXReflector satellite trunk &mdash; its host, port, your <span class="mono">satellite_id</span> and the matching <span class="mono">SECRET_&lt;id&gt;</span> from the reflector's <span class="mono">svxreflector.conf</span>.</dd>
</dl>
<dl>
<dt>1. Install the packages</dt>
<dd>Install <span class="mono">chan-pbx-sat</span> (the driver) and, on FreePBX, <span class="mono">pbxsat-freepbx</span> (the GUI) &mdash; see the box above. The GUI appears under <em>Connectivity &rarr; PBX &harr; SVX</em>. The <span class="mono">.deb</span>/<span class="mono">.rpm</span> also drops the sample config and the group-dial voice prompt (English + Swedish).</dd>
</dl>
<dl>
<dt>2. Load the driver</dt>
<dd>Tell Asterisk to load the module, then confirm it is up:</dd>
</dl>
<pre class="code"># /etc/asterisk/modules.conf (under [modules])
load = chan_pbx_sat.so
sudo fwconsole restart # or: asterisk -rx "module load chan_pbx_sat.so"
asterisk -rx "pbxsat status" # should print JSON, not an error</pre>
<dl>
<dt>3. Create the trunk (GUI)</dt>
<dd><em>Connectivity &rarr; PBX &harr; SVX &rarr; Add trunk</em>. Every field shows its default, an example and the allowed range to the right. The essentials:
<ul>
<li><span class="mono">host</span> / <span class="mono">port</span> &mdash; the reflector (port defaults to 5300).</li>
<li><span class="mono">satellite_id</span> + <span class="mono">secret</span> &mdash; your trunk login and its shared HMAC secret.</li>
<li><span class="mono">callsign</span> + <span class="mono">node_location</span> &mdash; how you appear in lastheard (&ldquo;<em>&lt;callsign&gt; via &lt;label&gt;</em>&rdquo;).</li>
<li><span class="mono">tg</span> (default talkgroup) &mdash; joined at connect and used for a bare dial.</li>
<li><span class="mono">Group number</span> &mdash; the prefix a phone dials to reach <em>this</em> trunk with a dynamic TG (used in step&nbsp;5). One per trunk, so each reflector gets its own prefix.</li>
</ul>
Leave everything else on its default unless you have a reason. Save &mdash; the module writes the section to <span class="mono">pbx_sat.conf</span> and reloads the driver.</dd>
</dl>
<dl>
<dt>4. Global lists (once, shared by all trunks)</dt>
<dd>Still in the module, the other tabs are site-wide because one reflector is shared:
<ul>
<li><strong>Callsigns</strong> &mdash; <span class="mono">extension = callsign</span>, so a caller shows as their own callsign in lastheard.</li>
<li><strong>Talkgroups</strong> &mdash; a <span class="mono">number = label</span> catalogue for readability.</li>
<li><strong>Routing</strong> &mdash; <span class="mono">SIP&nbsp;&rarr;&nbsp;TG</span> (a bare dial from extension X joins TG&nbsp;Y) and <span class="mono">TG&nbsp;&rarr;&nbsp;SIP</span> (an active TG rings extension/ring-group Z; used in step&nbsp;6).</li>
<li><strong>Settings</strong> &mdash; one <span class="mono">max_dialout</span> cap on concurrent PBX&nbsp;&rarr;&nbsp;SVX calls (blank = unlimited).</li>
</ul>
</dd>
</dl>
<dl>
<dt>5. Dial-plan: let a phone dial a talkgroup (PBX &rarr; SVX)</dt>
<dd>The driver is dialled as <span class="mono">PBXSAT/&lt;trunk&gt;/&lt;tg&gt;</span>. Wire your <em>Group number</em> to it with a small custom context. Add this to <span class="mono">/etc/asterisk/extensions_custom.conf</span> (survives FreePBX reloads):</dd>
</dl>
<pre class="code">; dial 91&lt;tg&gt; to key up talkgroup &lt;tg&gt; on "mytrunk"
; (91 = this trunk's Group number; mytrunk = its name)
[pbxsat-mytrunk](+)
exten => _91.,1,Answer()
same => n,Dial(PBXSAT/mytrunk/${EXTEN:2}) ; :2 strips the "91" prefix
same => n,Hangup()</pre>
<dd>Include that context for your phones: add <span class="mono">include = pbxsat-mytrunk</span> to <span class="mono">[from-internal-custom]</span> in the same file, then <span class="mono">sudo fwconsole reload</span>. A softphone dialling <span class="mono">9124016</span> now keys TG&nbsp;24016. Mid-call, <span class="mono">*99</span>/<span class="mono">#99</span> key up/down and <span class="mono">91&lt;tg&gt;#</span> switches TG (the <span class="mono">ptt_up</span>/<span class="mono">ptt_dn</span>/<span class="mono">cmd_select_tg</span> fields).</dd>
<dl>
<dt>6. Optional: let a talkgroup ring a phone (SVX &rarr; PBX)</dt>
<dd>On the trunk, set <span class="mono">standing = yes</span> and list the talkgroups to watch in the <strong>TG list (monitor)</strong>. Then in <em>Routing</em> add a <span class="mono">TG&nbsp;&rarr;&nbsp;SIP</span> entry (e.g. <span class="mono">24016 = 200</span>, or a ring-group number). When that TG goes active the trunk originates a call into <span class="mono">pbx_context</span> (default <span class="mono">from-internal</span>) and rings the number. The target extension / ring group must exist in FreePBX.</dd>
</dl>
<dl>
<dt>7. Access control (optional, per trunk)</dt>
<dd>The trunk's <strong>Access list</strong> gates who may pass, in either direction: pick a direction (SIP&nbsp;&rarr;&nbsp;TG, TG&nbsp;&rarr;&nbsp;SIP, or both), a SIP extension (single, <span class="mono">100-199</span> range, or <span class="mono">any</span>), a TG (same forms), and allow/deny. Rules match top-down, first match wins; no rules = open.</dd>
</dl>
<dl>
<dt>8. Apply &amp; verify</dt>
<dd><span class="mono">sudo fwconsole reload</span> to apply dial-plan changes (the module already reloads the driver on save). Check the <strong>status panel</strong> at the top of the module page and the <strong>dashboard widget</strong>: each trunk shows Online/Offline and Idle/In&nbsp;use, plus any live talkers. From the CLI, <span class="mono">asterisk -rx "pbxsat status"</span> prints the same as JSON.</dd>
<dt>Secrets</dt>
<dd><span class="mono">pbx_sat.conf</span> holds the reflector secret &mdash; keep it <span class="mono">asterisk:asterisk</span> and mode <span class="mono">640</span>. The module writes it that way; if you edit by hand, re-check the permissions.</dd>
</dl>
</div>
<div class="panel">
<h5>Audio tuning (optional) &mdash; per-trunk EQ / AGC / limiter, live</h5>
<p>Each trunk carries a full audio chain <b>per direction</b> (rx = reflector&rarr;phone, tx = phone&rarr;reflector): preamp gain &rarr; 7-band EQ &rarr; AGC &rarr; limiter. It is <b>off by default</b> &mdash; a trunk that sets nothing passes audio through bit-for-bit, so this can never colour a link you didn't ask it to. The most useful piece on a phone path is the <b>limiter</b> (stops a hot caller clipping into the reflector) and <b>AGC</b> (evens out wildly different phone levels); the EQ is there for a low-cut / presence lift if you want it.</p>
<dl>
<dt>Where</dt>
<dd>Open a trunk in <em>Connectivity &rarr; PBX &harr; SVX</em>. The audio fields (<span class="mono">rx_gain</span>/<span class="mono">tx_gain</span>, <span class="mono">rx_agc</span>/<span class="mono">tx_agc</span> + target, <span class="mono">rx_lim_ceil</span>/<span class="mono">tx_lim_ceil</span>, <span class="mono">rx_eq_on</span>/<span class="mono">tx_eq_on</span>, <span class="mono">rx_eq</span>/<span class="mono">tx_eq</span>) each show their default, an example and the range. The EQ is 7 dB values low&rarr;high @ 250 500 900 1.4k 2k 2.8k 3.3k&nbsp;Hz; a good starting &ldquo;Voice&rdquo; preset is <span class="mono">0,0,2,3,2,0,-2</span>.</dd>
<dt>Tune it live</dt>
<dd>With a call up on the trunk, press <b>Start live preview</b> in the <em>Live audio preview</em> panel and adjust the fields &mdash; you hear each change <b>on the active call</b> as you type. <b>Stop</b> reverts the audio to the saved config; <b>Discard</b> also undoes your edits back to the saved values; <b>Save</b> writes them to the config for good (the next call reads them). An unattended preview auto-reverts after 30&nbsp;s, so you can never leave a link stuck on a half-tuned setting.</dd>
<dt>By hand</dt>
<dd>The same keys work directly in <span class="mono">pbx_sat.conf</span>, e.g. <span class="mono">tx_agc = yes</span>, <span class="mono">tx_lim_ceil = 30000</span>, <span class="mono">tx_eq_on = yes</span>, <span class="mono">tx_eq = 0,0,2,3,2,0,-2</span>. Changes take effect on the next call &mdash; no reload needed.</dd>
</dl>
</div>
</div>
<div class="tab-section d-none" id="tab-admin">
<div class="panel">
<h5>For Administrators</h5>
<dl>
<dt>Accounts</dt>
<dd>Manage user accounts — edit details, ban/lock/unlock, set admin levels, reset passwords, delete accounts. Click Edit on an account to manage that user's nodes and certificates.</dd>
<dt>Certificates</dt>
<dd>Manage node certificates. List pending CSRs (sign or reject), and list issued certificates with details, delete/revoke, or move a certificate back to pending for re-signing.</dd>
<dt>Control</dt>
<dd>Reload configuration, restart or shut down the reflector service.</dd>
<dt>Config</dt>
<dd>View and edit the reflector configuration file (svxreflector.conf). Save changes and reload the service to apply.</dd>
</dl>
</div>
</div>
<div class="panel">
<h5>Contact</h5>
<dd>For help or questions, contact the reflector administrator at <strong>bjorne@sa7aux.se</strong></dd>
</div>
</div>
<script src="help.js?v=20260720a" defer></script>
<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>
+24
View File
@@ -0,0 +1,24 @@
'use strict';
// Tab switching for help.html. Deep-linkable: #build, #setup, #admin, #portal.
(function(){
const tabs = document.getElementById('helpTabs');
if (!tabs) return;
function show(name){
document.querySelectorAll('.tab-section').forEach(s =>
s.classList.toggle('d-none', s.id !== 'tab-' + name));
tabs.querySelectorAll('button').forEach(b =>
b.classList.toggle('active', b.dataset.tab === name));
}
tabs.addEventListener('click', e => {
const b = e.target.closest('button[data-tab]');
if (!b) return;
show(b.dataset.tab);
history.replaceState(null, '', '#' + b.dataset.tab);
});
const h = location.hash.replace('#', '');
if (h && document.getElementById('tab-' + h)) show(h);
})();
+82
View File
@@ -0,0 +1,82 @@
'use strict';
// Shared hover info box: any element carrying a data-info attribute (URI-encoded
// HTML) shows a floating details box while the mouse is over it. Wired once via
// delegation, so it works for rows rendered at any time. CSP-safe (no inline JS).
(function(){
let box = null;
function ensureBox(){
if (box) return box;
box = document.createElement('div');
box.id = 'rowInfo';
box.style.cssText =
'position:fixed;z-index:2000;display:none;max-width:340px;padding:8px 11px;'
+ 'background:#0a1628;border:1px solid #2d5a9e;border-radius:8px;font-size:12px;'
+ 'line-height:1.5;color:#e2e8f0;box-shadow:0 6px 20px rgba(0,0,0,.6);'
+ 'pointer-events:none;white-space:normal;';
document.body.appendChild(box);
return box;
}
function place(x, y){
if (!box) return;
const pad = 14, w = box.offsetWidth, h = box.offsetHeight;
let left = x + pad, top = y + pad;
if (left + w > window.innerWidth - 8) left = x - w - pad;
if (top + h > window.innerHeight - 8) top = y - h - pad;
if (left < 4) left = 4;
if (top < 4) top = 4;
box.style.left = left + 'px';
box.style.top = top + 'px';
}
function show(html, x, y){
const b = ensureBox();
b.innerHTML = html;
b.style.display = 'block';
place(x, y);
}
function hide(){ if (box) box.style.display = 'none'; }
function infoEl(t){ return (t && t.closest) ? t.closest('[data-info]') : null; }
document.addEventListener('mouseover', function(e){
const el = infoEl(e.target);
if (!el) return;
const raw = el.getAttribute('data-info');
if (!raw) { hide(); return; }
let html = '';
try { html = decodeURIComponent(raw); } catch (_) { html = raw; }
show(html, e.clientX, e.clientY);
});
document.addEventListener('mousemove', function(e){
if (!box || box.style.display !== 'block') return;
if (infoEl(e.target)) place(e.clientX, e.clientY);
else hide();
});
document.addEventListener('mouseout', function(e){
if (!infoEl(e.target)) return;
const to = e.relatedTarget;
if (!to || !to.closest || !to.closest('[data-info]')) hide();
});
// Build a URI-encoded data-info payload from label/value pairs.
// rows: array of [label, value]; a value that is empty/null/undefined is skipped.
// Pass {talking:true} to prepend a red "Talking" line.
window.buildRowInfo = function(rows, opts){
opts = opts || {};
const parts = [];
if (opts.talking) parts.push('<div style="color:#ff5252;font-weight:bold">&#9679; Talking</div>');
for (const r of (rows || [])){
if (!r) continue;
const v = r[1];
if (v === undefined || v === null || v === '') continue;
parts.push('<div><span style="color:#7dd3fc">' + escHtml(r[0]) + ':</span> ' + escHtml(v) + '</div>');
}
if (!parts.length) return '';
return encodeURIComponent(parts.join(''));
};
})();
+707
View File
@@ -0,0 +1,707 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Always revalidate the HTML so bumped ?v= asset versions are picked up (no Cache-Control header on the server yet). -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>SvxPortal — SvxLink Reflector | Nodes, Lastheard, CTCSS &amp; Map</title>
<meta name="description" content="SvxPortal — live dashboard for the SA7AUX SvxLink reflector: connected nodes, reflector clients, talkgroup monitor, lastheard, CTCSS tone table and node map for amateur radio / ham radio." />
<link rel="canonical" href="https://svxportal.sa7aux.se/" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://svxportal.sa7aux.se/" />
<meta property="og:title" content="SvxPortal — SvxLink Reflector" />
<meta property="og:description" content="Live nodes, lastheard, talkgroup monitor, CTCSS table and map for the SA7AUX SvxLink reflector." />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "SvxPortal",
"url": "https://svxportal.sa7aux.se/",
"description": "Live dashboard for the SA7AUX SvxLink amateur radio reflector",
"publisher": { "@type": "Organization", "name": "SA7AUX", "url": "https://sa7aux.se/" }
}
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Theme engine (Dark/Light/Color). External + in <head> (NOT deferred) so it
runs before first paint AND satisfies the site CSP (script-src 'self' — inline
scripts are blocked). Anonymous visitors are remembered in a cookie; signed-in
accounts also sync to the profile. -->
<script src="lib/theme.js?v=20260922a"></script>
<style>
/* Theme system — Dark (default) / Light / Color (user-defined).
All the cyan hardcodes below are driven off these vars: solid accents use
var(--fg)/var(--accent), translucent ones use rgba(var(--accent-rgb),a),
so switching a theme only swaps the tokens here — dark stays byte-identical. */
:root{
color-scheme: dark;
--accent-rgb: 0,255,255;
--bg: #000;
--panel: #111;
--accent: #00ffff;
--fg: #00ffff;
--fg2: rgba(var(--accent-rgb),.75);
--line: rgba(var(--accent-rgb),.35);
}
:root[data-theme="light"]{
color-scheme: light;
--accent-rgb: 10,102,194;
--bg: #f6f8fa;
--panel: #ffffff;
--accent: #0a66c2;
--fg: #14202b;
--fg2: #5a6b7b;
--line: rgba(20,32,43,.18);
}
:root[data-theme="color"]{
color-scheme: dark;
--accent-rgb: var(--uc-accent-rgb, 255,93,177);
--bg: var(--uc-bg, #000);
--panel: var(--uc-panel, #111);
--accent: var(--uc-accent, #ff5db1);
--fg: var(--uc-fg, #ff5db1);
--fg2: var(--uc-fg2, rgba(var(--accent-rgb),.78));
--line: var(--uc-line, rgba(var(--accent-rgb),.35));
}
html, body{ height:100%; background:var(--bg); color:var(--fg); margin:0; }
body{ display:flex; flex-direction:column; min-height:100vh; }
.navbar{ position:sticky; top:0; z-index:1030; }
main, .container-fluid.py-3{ flex:1; }
footer{ flex-shrink:0; position:sticky; bottom:0; background:var(--bg); }
body{ margin:0; }
/* Global "black + cyan" */
*{ color:var(--fg); }
*::selection{ background: rgba(var(--accent-rgb),.2); }
.border, .border-bottom, .border-top, .border-start, .border-end, .rounded { border-color: var(--line) !important; }
.navbar{ background: var(--bg) !important; }
/* Federation-banner: rullande (marquee) + tydlig accent-färg som sticker ut mot cyan/svart */
#federationBanner{ --fed: #ffb000; overflow:hidden; white-space:nowrap; box-sizing:border-box;
color:var(--fed) !important; font-weight:600; letter-spacing:.03em;
text-shadow:0 0 6px rgba(255,176,0,.35); }
#federationBanner .fed-track{ display:inline-block; padding-left:100%; will-change:transform;
animation: fedscroll 26s linear infinite; }
#federationBanner:hover .fed-track{ animation-play-state: paused; }
#federationBanner .fed-track span{ color:var(--fed) !important; }
@keyframes fedscroll{ 0%{ transform:translateX(0); } 100%{ transform:translateX(-100%); } }
@media (prefers-reduced-motion: reduce){ #federationBanner .fed-track{ animation:none; padding-left:0; text-align:center; display:block; } }
a, .nav-link{ color:var(--fg) !important; text-decoration:none; }
.nav-link.active{ text-decoration: underline; text-underline-offset: 3px; }
.text-muted, .text-secondary{ color: var(--fg2) !important; }
.btn.btn-outline-secondary{
background: var(--bg) !important;
color: var(--fg) !important;
border-color: rgba(var(--accent-rgb),.7) !important;
}
select, option, .form-select{
background: var(--bg) !important;
color: var(--fg) !important;
border-color: rgba(var(--accent-rgb),.7) !important;
}
/* Panels */
.panel{
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
}
/* Talking now — compact strip on the menu row (left of the sign-in buttons) */
#talkerPopup{
display: none; /* toggled to inline-flex by JS via style.display */
align-items: baseline;
gap: 8px;
padding: 2px 10px;
border: 1px solid var(--line);
border-radius: 8px;
max-width: min(55vw, 720px);
overflow: hidden;
white-space: nowrap;
}
#talkerPopup #talkerBody{ overflow: hidden; text-overflow: ellipsis; }
#talkerPopup #talkerBody > div{ display: inline; margin-right: 12px; }
@media (max-width: 992px){ #talkerPopup{ display: none !important; } } /* hide on narrow screens where the row has no room */
/* Lists */
.lineRow{
display:flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid rgba(var(--accent-rgb),.18);
flex-wrap: wrap; /* mobile-safe */
}
.lineRow:last-child{ border-bottom: 0; }
.mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.nowrap{ white-space: nowrap; }
.wrap{ overflow-wrap: anywhere; }
/* TG groups (All active) */
.tgGroup{
border: 1px solid rgba(var(--accent-rgb),.25);
border-radius: 10px;
padding: 8px 10px;
margin: 8px 0;
}
.tgHeader{
display:flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(var(--accent-rgb),.18);
margin-bottom: 6px;
}
.csGrid{
display:grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 4px 10px;
}
.csItem{
padding: 2px 0;
}
/* Table */
.table, .table > :not(caption) > * > * { background: var(--bg) !important; color: var(--fg) !important; }
/* Borderless, uniform-bg tables for Lastheard + CTCSS + Monitor */
#lastheardPanel .table, #ctcssPanel .table, #monitorPanel .table, #clientsPanel .table, #trunksPanel .table, #topologyPanel .table {
--bs-table-bg: var(--bg);
--bs-table-color: var(--fg);
--bs-table-striped-bg: var(--bg);
--bs-table-striped-color: var(--fg);
--bs-table-hover-bg: var(--bg);
--bs-table-hover-color: var(--fg);
--bs-table-active-bg: var(--bg);
--bs-table-active-color: var(--fg);
--bs-table-border-color: transparent;
}
#lastheardPanel .table > :not(caption) > * > *,
#ctcssPanel .table > :not(caption) > * > *,
#monitorPanel .table > :not(caption) > * > *,
#clientsPanel .table > :not(caption) > * > *,
#trunksPanel .table > :not(caption) > * > *,
#topologyPanel .table > :not(caption) > * > * {
border-top-width: 0 !important;
border-bottom-width: 0 !important;
box-shadow: none !important;
background: var(--bg) !important;
color: var(--fg) !important;
}
#lastheardPanel .table-responsive, #ctcssPanel .table-responsive, #monitorPanel .table-responsive, #clientsPanel .table-responsive, #trunksPanel .table-responsive, #topologyPanel .table-responsive,
#lastheardPanel .border, #ctcssPanel .border, #trunksPanel .border, #topologyPanel .border,
#lastheardPanel .border.rounded, #ctcssPanel .border.rounded, #trunksPanel .border.rounded, #topologyPanel .border.rounded { border-color: transparent !important; }
#lastheardPanel thead tr, #ctcssPanel thead tr, #clientsPanel thead tr, #topologyPanel thead tr { border-bottom: 1px solid var(--line) !important; }
/* Monitor → Trunks: no line under the column header either (no lines at all on this page). */
#trunksPanel thead tr { border-bottom: 0 !important; }
/* Monitor → Nodes + TG Monitor: completely borderless — NO lines between anything. */
#monitorPanel .table, #monitorPanel .table th, #monitorPanel .table td, #monitorPanel thead tr, #monitorPanel tbody tr,
#tgmonPanel .table, #tgmonPanel .table th, #tgmonPanel .table td, #tgmonPanel thead tr, #tgmonPanel tbody tr { border: 0 !important; box-shadow: none !important; }
/* ...except the section-name rows (Local / each trunk) get a line UNDER the name so the sections read apart. */
#monitorPanel tr.mon-head td { border-bottom: 2px solid var(--line) !important; }
#tgmonPanel .table { --bs-table-bg: var(--bg); --bs-table-color: var(--fg); --bs-table-border-color: transparent; }
/* CTCSS expandable rows */
#ctcssPanel tr.ctcssMain { cursor: pointer; }
#ctcssPanel tr.ctcssMain:hover { background: rgba(var(--accent-rgb),.05) !important; }
#ctcssPanel tr.ctcssMain td.ctcssChevron { width: 1.5em; text-align: center; user-select: none; color: var(--fg2); }
#ctcssPanel tr.ctcssMain td.ctcssCb { width: 1.5em; }
#ctcssPanel tr.ctcssDetail > td { padding: 8px 10px !important; }
#ctcssPanel .ctcssSub {
width: 100%;
border-collapse: collapse;
background: var(--bg);
}
#ctcssPanel .ctcssSub th, #ctcssPanel .ctcssSub td {
padding: 3px 8px;
border: none !important;
color: var(--fg);
background: var(--bg);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
#ctcssPanel .ctcssSub th { color: var(--fg2); font-weight: normal; text-align: left; border-bottom: 1px solid rgba(var(--accent-rgb),.18) !important; }
#ctcssPanel .ctcssEmpty { color: var(--fg2); font-style: italic; padding: 4px 0; }
/* Mobile responsive */
@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; }
.d-flex.gap-2 { gap: 4px !important; }
.btn-sm { font-size: .75rem; padding: 3px 8px; }
.mono { font-size: .8rem; word-break: break-all; }
.navbar-text { font-size: .75rem; }
#paginationWrap .btn { padding: 2px 6px !important; font-size: .7rem; }
}
@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; }
.profile-tabs { gap: 2px; }
.profile-tab { padding: 6px 8px; font-size: .8rem; }
}
</style>
<style>
.dropdown-menu{
background: var(--panel) !important;
border-color: rgba(var(--accent-rgb),.35) !important;
}
.dropdown-item{
color: var(--fg) !important;
}
.dropdown-item:hover{
background: rgba(var(--accent-rgb),.1) !important;
}
.dropdown-divider{
border-color: rgba(var(--accent-rgb),.35) !important;
}
#adminSubMenu{
position: absolute;
right: 100%;
left: auto;
top: 0;
min-width: 160px;
background: var(--panel);
border: 1px solid rgba(var(--accent-rgb),.35);
border-radius: 6px;
padding: 4px 0;
list-style: none;
margin: 0;
z-index: 2000;
}
#adminMenuItem{ position: relative; }
#adminSubMenu .dropdown-item{ padding: 6px 16px; white-space: nowrap; }
#monitorMenuItem{ position: relative; }
#monitorSubMenu{
position: absolute;
left: 0;
top: 100%;
min-width: 140px;
background: var(--panel);
border: 1px solid rgba(var(--accent-rgb),.35);
border-radius: 6px;
padding: 4px 0;
list-style: none;
margin: 0;
z-index: 2000;
}
#monitorSubMenu .dropdown-item{ padding: 6px 16px; white-space: nowrap; }
/* Mobile responsive */
@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; }
.d-flex.gap-2 { gap: 4px !important; }
.btn-sm { font-size: .75rem; padding: 3px 8px; }
.mono { font-size: .8rem; word-break: break-all; }
.navbar-text { font-size: .75rem; }
#paginationWrap .btn { padding: 2px 6px !important; font-size: .7rem; }
}
@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; }
.profile-tabs { gap: 2px; }
.profile-tab { padding: 6px 8px; font-size: .8rem; }
}
</style>
</head>
<body>
<nav class="navbar navbar-expand border-bottom">
<div class="container-fluid">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link active" href="#" id="navClients">Reflector clients</a></li>
<li class="nav-item" id="monitorMenuItem">
<a class="nav-link" href="#" id="navMonitor">Monitor &#9662;</a>
<ul id="monitorSubMenu" style="display:none;">
<li><a class="dropdown-item" href="#" id="navMonitorNodes">Nodes</a></li>
<li><a class="dropdown-item" href="#" id="navMonitorTrunks">Trunks</a></li>
<li><a class="dropdown-item" href="#" id="navMonitorTopology">Topology</a></li>
</ul>
</li>
<li class="nav-item"><a class="nav-link" href="#" id="navTgmon">TG Monitor</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="navLastheard">Lastheard</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="navCTCSS">CTCSS Table</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="navMap">Map</a></li>
<li class="nav-item"><a class="nav-link" href="#" id="navChangelog">Changelog</a></li>
<li class="nav-item"><a class="nav-link" href="/help.html">Help</a></li>
</ul>
<!-- Talking now: lives on the menu row, to the left (after the links) -->
<div id="talkerPopup" class="ms-3" style="display:none;">
<span class="fw-bold" id="talkerTitle">Talking now</span>
<span id="talkerBody" class="small"></span>
</div>
<div style="margin-left:auto;"></div>
<select id="svxThemeSel" class="form-select form-select-sm me-2" style="width:auto;" title="Theme" aria-label="Theme" autocomplete="off">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="color">Color</option>
</select>
<button class="btn btn-outline-secondary btn-sm me-2" id="btnColorEdit" type="button" title="Edit the Color theme" aria-label="Edit colors">🎨</button>
<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="container-fluid py-3">
<h1 class="h4 mb-3">Nodes</h1>
<div class="d-flex flex-wrap gap-3 small text-secondary mb-3">
<div><strong id="nodeCount">-</strong></div>
<div>Date <span id="dateTxt">-</span></div>
<div>Time <span id="timeTxt">-</span></div>
<div>Age <span id="ageTxt">-</span></div>
<div id="sseTxt">SSE: -</div>
</div>
<!-- Reflector clients -->
<div id="clientsPanel" class="panel mb-3">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="fw-bold me-auto">Reflector clients</div>
<input id="clientsSearch" type="search" class="form-control form-control-sm" style="width:auto;max-width:220px;" placeholder="Filter callsign or TG…" autocomplete="off">
</div>
<div id="clientsBody" class="small"></div>
</div>
<!-- Monitor / Nodes -->
<div id="monitorPanel" class="panel mb-3" style="display:none;">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="fw-bold me-auto">Monitor — Nodes</div>
<input id="monitorSearch" type="search" class="form-control form-control-sm" style="width:auto;max-width:240px;" placeholder="Filter callsign / reflector (regex)…" autocomplete="off">
<select id="monitorMode" class="form-select form-select-sm" style="width:auto;">
<option value="all">All nodes</option>
<option value="active">All active</option>
</select>
</div>
<div id="monitorBody" class="small"></div>
</div>
<!-- Monitor / Trunks -->
<div id="trunksPanel" class="panel mb-3" style="display:none;">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="fw-bold me-auto">Monitor — Trunks</div>
<input id="trunksSearch" type="search" class="form-control form-control-sm" style="width:auto;max-width:240px;" placeholder="Filter trunk / callsign / TG (regex)…" autocomplete="off">
</div>
<div id="trunksBody" class="small"></div>
</div>
<!-- Monitor / Topology -->
<div id="topologyPanel" class="panel mb-3" style="display:none;">
<div class="fw-bold mb-2">Monitor — Topology</div>
<div id="topologyBody" class="small"></div>
</div>
<!-- Lastheard -->
<div id="lastheardPanel" class="panel mb-3" style="display:none;">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="fw-bold me-auto">Last Heard (24h)</div>
<span id="lastheardCount" class="small text-secondary"></span>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0 data-table">
<thead>
<tr>
<th class="nowrap">Time</th>
<th>Callsign</th>
<th class="nowrap">Backend</th>
<th class="nowrap">TG</th>
<th>Location</th>
<th>RX</th>
<th class="nowrap">Dur</th>
</tr>
</thead>
<tbody id="lastheardRows"></tbody>
</table>
</div>
</div>
<!-- CTCSS Table -->
<div id="ctcssPanel" class="panel mb-3" style="display:none;">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="fw-bold me-auto">CTCSS Table</div>
<button id="ctcssSelectAll" class="btn btn-outline-secondary btn-sm">Select all</button>
<button id="ctcssSelectNone" class="btn btn-outline-secondary btn-sm">None</button>
<button id="ctcssExpandAll" class="btn btn-outline-secondary btn-sm" title="Show tones for all nodes">&#9656; All</button>
<button id="ctcssCollapseAll" class="btn btn-outline-secondary btn-sm" title="Hide all tone details">&#9662; None</button>
<select id="ctcssFormat" class="form-select form-select-sm" style="width:auto;">
<option value="anytone878">AnyTone 878</option>
<option value="anytone890">AnyTone 890</option>
<option value="ftm400">FTM-400</option>
<option value="ic5100">IC 5100</option>
<option value="ic7100">IC 7100</option>
</select>
<button id="ctcssExport" class="btn btn-primary btn-sm">Export</button>
</div>
<div id="ctcssNote" class="small text-secondary mb-2"></div>
<div id="ctcssAnyToneDefaults" class="border rounded p-2 mb-2" style="display:none;"></div>
<div class="table-responsive border rounded">
<table class="table table-sm mb-0">
<thead>
<tr>
<th></th>
<th></th>
<th>Callsign</th>
<th>Location</th>
<th class="nowrap">RX</th>
<th class="nowrap">TX</th>
<th class="nowrap">CTCSS&rarr;TG</th>
</tr>
</thead>
<tbody id="ctcssRows"></tbody>
</table>
</div>
</div>
<!-- Changelog: recent updates, fed by changelog.json, paginated client-side. -->
<div id="changelogPanel" class="panel mb-3" style="display:none;">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="fw-bold me-auto">Recent updates</div>
<span id="chFetchAt" class="small text-secondary"></span>
</div>
<div id="chBody" class="small"><div class="text-secondary">Loading…</div></div>
<div id="chPager" class="d-flex justify-content-center align-items-center gap-1 mt-2 flex-wrap"></div>
</div>
<div id="tgmonPanel" class="panel mb-3" style="display:none;">
<div class="d-flex align-items-center gap-2 mb-1">
<div class="fw-bold me-auto">TG Monitor <span class="text-secondary small">— clients per talkgroup, per mode</span></div>
<input id="tgmonSearch" type="search" class="form-control form-control-sm" style="width:auto;max-width:260px;" placeholder="Search TG or callsign/node…" autocomplete="off">
</div>
<div class="small text-secondary mb-2">Only talkgroups with at least one client. Both sides shown together. Search a TG number to see who's on it, or a callsign/node to see which TGs it's on. Click a talkgroup to expand.</div>
<div id="tgmonBody"><div class="text-secondary">Loading…</div></div>
</div>
<!-- List (legacy, hidden — Reflector clients is now the default landing view) -->
<div id="listPanel" style="display:none;">
<div class="table-responsive border rounded">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Callsign</th>
<th class="nowrap">TG#</th>
<th>Location</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
</main>
<script src="lib/api.js?v=20260922a" defer></script>
<script src="lib/ui_msg.js?v=20260922a" defer></script>
<script src="lib/pager.js?v=20260922a" defer></script>
<script src="lib/changelog.js?v=20260922a" defer></script>
<script src="lib/auth_ui.js?v=20260922a" defer></script>
<script src="lib/fmt.js?v=20260922a" defer></script>
<script src="lib/ui_list.js?v=20260922a" defer></script>
<script src="lib/ui_field.js?v=20260922a" defer></script>
<script src="common.js?v=20260702a" defer></script>
<script src="hoverinfo.js?v=20260704a" defer></script>
<script src="list_view.js?v=20260702a" defer></script>
<script src="clients_view.js?v=20260727a" defer></script>
<script src="monitor_view.js?v=20260806e" defer></script>
<script src="trunks_view.js?v=20260806c" defer></script>
<script src="topology_view.js?v=20260806b" defer></script>
<script src="lastheard_view.js?v=20260803a" defer></script>
<script src="talker_view.js?v=20260720a" defer></script>
<script src="ctcss_view.js?v=20260704e" defer></script>
<script src="tgmon_view.js?v=20260826a" defer></script>
<script src="auth_ui.js?v=20260717a" defer></script>
<script src="app.js?v=20260728a" defer></script>
<!-- Color edit — pick each part of the "Color" theme with a colour swatch.
Applies live; remembered in a cookie (and on the account when signed in). -->
<div class="modal fade" id="colorEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border rounded" style="background: var(--bg);">
<div class="modal-header border-bottom">
<h5 class="modal-title">Color edit</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 class="small text-secondary mb-2">Pick each colour for the <b>Color</b> theme. Changes apply live.</div>
<div id="colorPickers" class="d-flex flex-column gap-2"></div>
<div class="small text-secondary mt-3 mb-1">Accent quick-picks</div>
<div id="colorPresets" class="d-flex flex-wrap gap-2"></div>
<div class="small text-secondary mt-3 mb-1">Preview</div>
<div class="panel d-flex gap-2 flex-wrap align-items-center">
<button class="btn btn-sm btn-primary" type="button">Primary</button>
<button class="btn btn-sm btn-outline-secondary" type="button">Outline</button>
<span class="mono">mono text</span>
<span class="text-secondary">secondary</span>
</div>
</div>
<div class="modal-footer border-top">
<button class="btn btn-outline-secondary" id="btnColorReset" type="button">Reset to defaults</button>
<button class="btn btn-primary" data-bs-dismiss="modal" type="button">Done</button>
</div>
</div>
</div>
</div>
<!-- Cookie consent -->
<div class="modal fade" id="cookieModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border rounded" style="background: var(--bg);">
<div class="modal-header border-bottom">
<h5 class="modal-title">Cookies</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 class="small text-secondary">
Vi använder nödvändiga cookies för inloggning (session). Valfria cookies används bara om du accepterar alla.
</div>
<div class="small mt-2">
<a href="/privacy" class="text-decoration-underline">Cookie/Privacy policy</a>
</div>
</div>
<div class="modal-footer border-top">
<button class="btn btn-outline-secondary" id="btnCookiesNecessary" type="button">Endast nödvändiga</button>
<button class="btn btn-primary" id="btnCookiesAll" type="button">Acceptera alla</button>
</div>
</div>
</div>
</div>
<!-- Sign in modal -->
<div class="modal fade" id="signinModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border rounded" style="background: var(--bg);">
<div class="modal-header border-bottom">
<h5 class="modal-title">Sign in</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"
style="filter: invert(1);"></button>
</div>
<div class="modal-body"><form id="signinForm" autocomplete="on">
<div id="signinErr" class="small mb-2" style="display:none; color:#ff8080;"></div>
<label class="form-label small text-secondary">Callsign or email</label>
<input id="signinUser" class="form-control mb-2" autocomplete="username" placeholder="SA7AUX or you@example.com" />
<label class="form-label small text-secondary">Password</label>
<input id="signinPass" type="password" class="form-control mb-3" autocomplete="current-password" />
<div class="d-flex gap-2 align-items-center">
<button id="signinSubmit" class="btn btn-primary" type="submit">Login</button>
<div class="ms-auto small">
<a href="#" id="linkSignup" class="text-decoration-underline">Register</a>
<span class="text-secondary">·</span>
<a href="#" id="linkForgot" class="text-decoration-underline">Forgot password?</a>
</div>
</div>
<div class="small text-secondary mt-3">
Om du har 2FA (Authenticator) aktiverat kan du bli ombedd om en 6‑siffrig kod efter lösenordet.
</div>
</form></div>
</div>
</div>
</div>
<!-- Forgot password modal -->
<div class="modal fade" id="forgotModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border rounded" style="background: var(--bg);">
<div class="modal-header border-bottom">
<h5 class="modal-title">Forgot Password</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" style="filter: invert(1);"></button>
</div>
<div class="modal-body">
<form id="forgotForm">
<div id="forgotMsg" class="small mb-2" style="display:none;"></div>
<label class="form-label small text-secondary">Callsign</label>
<input id="forgotCallsign" class="form-control form-control-sm mb-2" placeholder="SA7AUX" autocomplete="off" />
<label class="form-label small text-secondary">Email</label>
<input id="forgotEmail" type="email" class="form-control form-control-sm mb-3" autocomplete="email" />
<button id="forgotSubmit" class="btn btn-primary btn-sm" type="submit">Send Reset Link</button>
<p class="small text-secondary mt-2 mb-0">A password reset link will be sent to your email address.</p>
</form>
</div>
</div>
</div>
</div>
<!-- Register modal -->
<div class="modal fade" id="registerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border rounded" style="background: var(--bg);">
<div class="modal-header border-bottom">
<h5 class="modal-title">Create Account</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" style="filter: invert(1);"></button>
</div>
<div class="modal-body">
<form id="registerForm" autocomplete="on">
<div id="regMsg" class="small mb-2" style="display:none;"></div>
<label class="form-label small text-secondary">Callsign <span style="color:#ff4040;">*</span></label>
<input id="regCallsign" class="form-control form-control-sm mb-2" placeholder="SA7AUX" autocomplete="off" />
<div class="row">
<div class="col-6">
<label class="form-label small text-secondary">First name <span style="color:#ff4040;">*</span></label>
<input id="regFirstname" class="form-control form-control-sm mb-2" autocomplete="given-name" />
</div>
<div class="col-6">
<label class="form-label small text-secondary">Last name</label>
<input id="regLastname" class="form-control form-control-sm mb-2" autocomplete="family-name" />
</div>
</div>
<label class="form-label small text-secondary">Email <span style="color:#ff4040;">*</span></label>
<input id="regEmail" type="email" class="form-control form-control-sm mb-2" autocomplete="email" />
<label class="form-label small text-secondary">Password <span style="color:#ff4040;">*</span></label>
<input id="regPass1" type="password" class="form-control form-control-sm mb-2" autocomplete="new-password" />
<label class="form-label small text-secondary">Confirm password <span style="color:#ff4040;">*</span></label>
<input id="regPass2" type="password" class="form-control form-control-sm mb-2" autocomplete="new-password" />
<div class="row">
<div class="col-4">
<label class="form-label small text-secondary">City</label>
<input id="regCity" class="form-control form-control-sm mb-2" autocomplete="address-level2" />
</div>
<div class="col-4">
<label class="form-label small text-secondary">State</label>
<input id="regState" class="form-control form-control-sm mb-2" autocomplete="address-level1" />
</div>
<div class="col-4">
<label class="form-label small text-secondary">Country</label>
<input id="regCountry" class="form-control form-control-sm mb-2" autocomplete="country-name" />
</div>
</div>
<button id="regSubmit" class="btn btn-primary btn-sm mt-2" type="submit">Create Account</button>
<p class="small text-secondary mt-2 mb-0">After email verification, an administrator will activate your account.</p>
</form>
</div>
</div>
</div>
</div>
<!-- Federation banner — moved from the top to just above the copyright. -->
<div id="federationBanner" class="py-1" style="font-size:1.02rem;" title="Hover to pause">
<span class="fed-track"><span>Part of the reflector-network.com federation — a growing mesh of independent networks. &nbsp;&nbsp;•&nbsp;&nbsp; Part of the reflector-network.com federation — a growing mesh of independent networks.</span></span>
</div>
<footer style="text-align:center; padding:16px; color:rgba(var(--accent-rgb),.4); font-size:.75rem; border-top:1px solid rgba(var(--accent-rgb),.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>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
# SvxPortal web — nginx server block.
# Install: sudo cp install/nginx-svxportal.conf /etc/nginx/sites-available/svxportal
# sudo ln -s /etc/nginx/sites-available/svxportal /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
# Replace: svxportal.example.com, the certificate paths, and the PHP version in the socket names.
server {
listen 80;
listen [::]:80;
server_name svxportal.example.com;
# Let's Encrypt HTTP challenge (certbot --webroot -w /var/www/letsencrypt)
location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
root /var/www/letsencrypt;
allow all;
}
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name svxportal.example.com;
root /var/www/html/svxportal;
index index.html index.php;
ssl_certificate /etc/letsencrypt/live/svxportal.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/svxportal.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
access_log /var/log/nginx/svxportal-access.log;
error_log /var/log/nginx/svxportal-error.log;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# The UI loads Bootstrap/icons from cdn.jsdelivr.net; everything else is same-origin.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'" always;
location = /robots.txt { default_type text/plain; alias /var/www/html/svxportal/robots.txt; }
location = /sitemap.xml { default_type application/xml; alias /var/www/html/svxportal/sitemap.xml; }
location = /privacy { default_type text/plain; alias /var/www/html/svxportal/privacy; }
# Reflector admin API runs in its own PHP-FPM pool (see install/php-fpm-svxadmin.conf).
# It needs exec() for the sudo helpers; the normal pool should keep exec disabled.
location = /auth/admin_reflector_api.php {
include /etc/nginx/fastcgi.conf;
try_files $uri =404;
fastcgi_hide_header X-Powered-By;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.4-fpm-svxadmin.sock;
fastcgi_index index.php;
fastcgi_read_timeout 30;
}
# Never serve dotfiles (.git, .gitignore ...) or editor/backup files.
location ~ /\.(?!well-known) { deny all; access_log off; log_not_found off; }
location ~* \.(bak|orig|save|old|swp|inc|sql|dist|tpl)(\.[0-9]+)?$ { deny all; access_log off; log_not_found off; }
# Server-Sent Events endpoint: must not be buffered.
location = /auth/events.php {
include /etc/nginx/fastcgi.conf;
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_buffering off;
fastcgi_read_timeout 3600;
gzip off;
}
location ~ \.php$ {
include /etc/nginx/fastcgi.conf;
try_files $uri =404;
fastcgi_hide_header X-Powered-By;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
fastcgi_read_timeout 25;
}
location = / { try_files /index.html =404; }
location / { try_files $uri $uri/ =404; }
# Live data API (/svxportal/api/status, /stream, /lastheard, /monitor-nodes,
# /local-trunks). The web UI expects a JSON/SSE backend here on 127.0.0.1:8098.
# That backend is NOT part of this repository — see INSTALL.md, section 8.
location ^~ /svxportal/api/ {
proxy_pass http://127.0.0.1:8098/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
gzip off;
add_header X-Accel-Buffering no;
}
}
+18
View File
@@ -0,0 +1,18 @@
; Separate PHP-FPM pool for /auth/admin_reflector_api.php.
; Install: sudo cp install/php-fpm-svxadmin.conf /etc/php/8.4/fpm/pool.d/svxadmin.conf
; sudo systemctl restart php8.4-fpm
; Adjust "8.4" to your PHP version (also in the nginx config).
[svxadmin]
user = www-data
group = www-data
listen = /run/php/php8.4-fpm-svxadmin.sock
listen.owner = www-data
listen.group = www-data
pm = ondemand
pm.max_children = 4
pm.process_idle_timeout = 30s
request_terminate_timeout = 300
; exec() stays available for the scoped sudo helpers used by admin_reflector_api.php;
; everything else that spawns processes is disabled.
php_admin_value[disable_functions] = passthru,shell_exec,system,popen,proc_open,proc_close,pcntl_exec,dl,phpinfo,show_source
+21
View File
@@ -0,0 +1,21 @@
# Dedicated Redis instance for SvxPortal (unix socket only, no TCP port).
# Install: sudo cp install/redis-svxportal.conf /etc/redis/redis-svxportal.conf
# sudo mkdir -p /var/lib/redis-svxportal && sudo chown redis:redis /var/lib/redis-svxportal
# sudo systemctl enable --now redis-server@svxportal
# Debian/Ubuntu ship the redis-server@.service template; the instance name maps to
# /etc/redis/redis-<name>.conf. Replace CHANGEME with a long random password and put
# the same value in auth/config.php (redis.password).
port 0
unixsocket /run/redis-svxportal/redis.sock
unixsocketperm 770
daemonize no
supervised systemd
pidfile /run/redis-svxportal/redis-server.pid
logfile /var/log/redis/redis-svxportal.log
dir /var/lib/redis-svxportal
requirepass CHANGEME
maxmemory 512mb
maxmemory-policy noeviction
save 900 1
save 300 10
save 60 10000
+5
View File
@@ -0,0 +1,5 @@
# Canonical talkgroup list JSON, owned by the web user so the portal can save it.
# Install: sudo cp install/tmpfiles-svxportal-tglist.conf /etc/tmpfiles.d/svxportal-tglist.conf
# sudo systemd-tmpfiles --create /etc/tmpfiles.d/svxportal-tglist.conf
# 'f' only writes the seed when the file is missing; existing data is kept.
f /etc/svxlink/tglist.json 0664 www-data www-data - {"tgs":[],"requests":[]}
+98
View File
@@ -0,0 +1,98 @@
'use strict';
let __lhInflight = false;
async function refreshLastheard(){
if (__lhInflight) return;
__lhInflight = true;
try {
const r = await apiFetch('/svxportal/api/lastheard?limit=200'); // lib/api.js
if(!r.ok || !r.json){
renderLastheard({events:[], count:0, _err:`http ${r.status}`});
return;
}
const j = r.json;
renderLastheard(j);
} catch(e){
renderLastheard({events:[], count:0, _err:String(e)});
} finally {
__lhInflight = false;
}
}
function renderLastheard(payload){
const tbody = document.getElementById('lastheardRows');
const cnt = document.getElementById('lastheardCount');
if(!tbody) return;
const evs = (payload?.events || []).map(e => {
if (typeof e === 'string'){ try{ return JSON.parse(e); }catch{ return null; } }
return e;
}).filter(Boolean);
if (cnt) cnt.textContent = evs.length ? `${evs.length} event${evs.length===1?'':'s'} / last 24h` :
(payload?._err ? `error: ${payload._err}` : 'no events yet');
if (evs.length === 0){
tbody.innerHTML = `<tr><td colspan="8" class="text-secondary">No keyups recorded in last 24h.</td></tr>`;
return;
}
// Network bridge directory (crawled from every reflector): so a bridge that
// arrives bare over a trunk (speed: node/system empty) renders IDENTICALLY to
// where it is a local client (aldmr: astmeta filled node/system). Same code,
// same result on both boxes.
const bn = (window.__svxportal_lastStatus && window.__svxportal_lastStatus.bridge_nodes) || {};
const now = Math.floor(Date.now()/1000);
tbody.innerHTML = evs.map(e => {
const ts = Number(e.ts || 0);
const age = now - ts;
const ageStr = formatAge(age);
const absTs = new Date(ts*1000).toLocaleString();
const dur = Number(e.duration_s || 0);
const durStr = dur >= 60 ? `${Math.floor(dur/60)}m${dur%60}s` : `${dur}s`;
const sig = (e.siglev !== undefined && e.siglev !== null && e.siglev !== 0)
? ` <span class="text-secondary mono">${Number(e.siglev).toFixed(0)}</span>` : '';
const rx = e.rx_name ? `${escHtml(e.rx_name)}${sig}` : '<span class="text-secondary">-</span>';
const bi = bn[String(e.callsign || '').toUpperCase()];
const eSystem = e.system || (bi && bi.system) || '';
// Backend = HOW the talker enters (AllStar / SVXLink / trunk / DMR / PBX …),
// the ingress path — same concept the DMR side calls "Backend" (there "Mode"
// means the DV protocol, a different fact). Carried in the network field.
const eBackend = e.network || (bi && bi.network) || '';
// Callsign column = the callsign field as-is (e.g. the trunk callsign
// KT4ROY-ASL). The gateway/reflector descriptor ("East Coast Reflector via
// 45225 / W2ECR") lives in the Location column. Uniform on every box: an
// AllStar row (aldmr) and the same talker arriving over the trunk (speed)
// render identically — the backend already fills callsign + location, the
// web just shows them, it never re-derives the name.
const csCell = `<b>${escHtml(e.callsign || eSystem || '')}</b>`;
return `<tr>
<td class="mono nowrap" title="${escHtml(absTs)}">${ageStr}</td>
<td class="mono">${csCell}</td>
<td class="wrap">${escHtml(eBackend)}</td>
<td class="mono nowrap">${Number(e.tg||0)}</td>
<td class="wrap">${escHtml(e.location || '')}</td>
<td class="wrap">${rx}</td>
<td class="mono nowrap">${durStr}</td>
</tr>`;
}).join('');
}
function formatAge(sec){
if (sec < 0) sec = 0;
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec/60)}m ago`;
if (sec < 86400) return `${Math.floor(sec/3600)}h ago`;
const d = Math.floor(sec/86400);
return `${d}d ago`;
}
function escHtml(s){
return String(s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
window.refreshLastheard = refreshLastheard;
window.renderLastheard = renderLastheard;
+53
View File
@@ -0,0 +1,53 @@
// api.js — shared HTTP helpers for every web (dmr-web, svx-web, svxlink-web).
// Canonical copy: callmgr2/www-mumien/lib/api.js; other repos carry a COPY.
//
// apiFetch(url, {method, body, form, csrf, headers, quiet}) → {ok, status, json}
// apiGet(url, opts) → json | null GET, cache: no-store
// apiJson(url, body, opts) → json | null POST application/json (always POST, CSRF from window._csrfToken)
// apiForm(url, fields, opts) → json | null POST x-www-form-urlencoded
//
// Every call is same-origin, never cached, and returns null (or json:null)
// when the response is not JSON, so callers test `j && j.ok`. A response
// carrying persist_error (a save that reached Redis but not callmgr.json) is
// surfaced with an alert unless opts.quiet — no save path can hide it.
(function () {
'use strict';
async function apiFetch(url, opts) {
opts = opts || {};
const init = {
method: opts.method || (opts.body !== undefined ? 'POST' : 'GET'),
cache: 'no-store',
credentials: opts.credentials || 'same-origin',
headers: Object.assign({}, opts.headers || {}),
};
if (opts.body !== undefined) {
if (opts.form) {
init.headers['Content-Type'] = 'application/x-www-form-urlencoded';
init.body = new URLSearchParams(opts.body);
} else {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(opts.body);
}
}
const csrf = opts.csrf !== undefined ? opts.csrf : (window._csrfToken || '');
if (csrf) init.headers['X-CSRF-Token'] = csrf;
let r;
try { r = await fetch(url, init); }
catch (e) { return { ok: false, status: 0, json: null, error: String(e) }; }
const json = await r.json().catch(function () { return null; });
if (json && json.ok && json.persist_error && !opts.quiet) {
alert('Saved to Redis but NOT persisted to callmgr.json: ' + json.persist_error +
'\n\nThe change reverts on the next restart.');
}
return { ok: r.ok, status: r.status, json: json };
}
async function apiGet(url, opts) { return (await apiFetch(url, opts)).json; }
async function apiJson(url, body, opts) { return (await apiFetch(url, Object.assign({ method: 'POST', body: body }, opts || {}))).json; }
async function apiForm(url, fields, opts) { return (await apiFetch(url, Object.assign({ body: fields, form: true }, opts || {}))).json; }
window.apiFetch = apiFetch;
window.apiGet = apiGet;
window.apiJson = apiJson;
window.apiForm = apiForm;
})();
+53
View File
@@ -0,0 +1,53 @@
// auth_ui.js — shared sign-in / register / sign-out transport (dmr-web, svx-web).
// Canonical copy: callmgr2/www-mumien/lib/auth_ui.js; other repos carry a COPY.
// Needs lib/api.js. Pages keep their own field validation and messages; this
// owns the request/response contract so both webs behave the same.
//
// authLogin({ endpoint, body, csrf, onFail(j, r) }) → true on {ok:true}
// authRegister({ endpoint, csrf, msg(text, isErr) }) reads the reg* form fields
// authLogout({ endpoint, method, after })
(function () {
'use strict';
const REG_IDS = ['regCallsign', 'regFirstname', 'regLastname', 'regEmail', 'regPass1', 'regPass2', 'regCity', 'regState', 'regCountry'];
async function authLogin(o) {
const r = await apiFetch(o.endpoint, { body: o.body, csrf: o.csrf || window._csrfToken || '' });
if (!r.ok || !r.json || !r.json.ok) {
if (typeof o.onFail === 'function') o.onFail(r.json, r);
return false;
}
return true;
}
async function authRegister(o) {
const g = id => document.getElementById(id);
const v = id => (g(id) ? g(id).value : '').trim();
const msg = typeof o.msg === 'function' ? o.msg : function () {};
msg(null);
const callsign = v('regCallsign').toUpperCase(), firstname = v('regFirstname'), lastname = v('regLastname'),
email = v('regEmail'), pass1 = g('regPass1') ? g('regPass1').value : '', pass2 = g('regPass2') ? g('regPass2').value : '',
city = v('regCity'), state = v('regState'), country = v('regCountry');
if (!callsign || !firstname || !email || !pass1) { msg('Fill in all required fields.', true); return false; }
if (pass1 !== pass2) { msg('Passwords do not match.', true); return false; }
if (pass1.length < 6) { msg('Password must be at least 6 characters.', true); return false; }
const j = await apiJson(o.endpoint, { callsign, firstname, lastname, email, password: pass1, password2: pass2, city, state, country },
{ csrf: o.csrf || window._csrfToken || '' });
if (j && j.ok) {
const emailNote = j.email_sent ? ' A verification email has been sent.' : '';
msg('Account created!' + emailNote + ' An administrator will activate your account after email verification.', false);
REG_IDS.forEach(id => { if (g(id)) g(id).value = ''; });
return true;
}
msg((j && j.error) || 'Registration failed.', true);
return false;
}
async function authLogout(o) {
try { await apiFetch(o.endpoint, { method: o.method || 'GET' }); } catch (_) {}
if (typeof o.after === 'function') o.after();
}
window.authLogin = authLogin;
window.authRegister = authRegister;
window.authLogout = authLogout;
})();
+80
View File
@@ -0,0 +1,80 @@
// changelog.js — shared "recent updates" viewer (dmr-web, svx-web).
// Canonical copy: callmgr2/www-mumien/lib/changelog.js; other repos carry a COPY.
// Reads changelog.json (newest first, {date, repo, hash, type, summary}) and
// renders a client-side paginated list. Needs lib/fmt.js (escHtml).
//
// const ch = changelogViewer({ body: 'chBody', pager: 'chPager', fetchAt: 'chFetchAt',
// url: 'changelog.json', perPage: 6 });
// ch.load(); // fetch once, render; later calls just re-render
(function () {
'use strict';
function changelogViewer(opts) {
opts = opts || {};
const ids = { body: opts.body || 'chBody', pager: opts.pager || 'chPager', fetchAt: opts.fetchAt || 'chFetchAt' };
const url = opts.url || 'changelog.json';
const perPage = opts.perPage || 6;
const $ = id => document.getElementById(id);
let entries = null, page = 0, wired = false;
function typeBadge(t) {
const m = { feature: ['Added', 'bg-success'], fix: ['Fix', 'bg-danger'],
change: ['Change', 'bg-info text-dark'] };
const x = m[String(t || '').toLowerCase()] || [t || '—', 'bg-secondary'];
return `<span class="badge ${x[1]}" style="font-size:9px;">${escHtml(x[0])}</span>`;
}
function render() {
const body = $(ids.body), pager = $(ids.pager);
if (!body || !entries) return;
const n = entries.length, pages = Math.max(1, Math.ceil(n / perPage));
page = Math.min(Math.max(0, page), pages - 1);
const start = page * perPage;
const rows = entries.slice(start, start + perPage).map(e => `
<div class="d-flex align-items-start gap-2 py-1" style="border-bottom:1px solid rgba(128,128,128,.15);">
<span class="text-secondary mono" style="min-width:78px;">${escHtml(e.date || '')}</span>
${typeBadge(e.type)}
<span class="flex-grow-1">${escHtml(e.summary || '')}</span>
<span class="text-secondary mono" style="font-size:10px;" title="${escHtml(e.repo || '')} ${escHtml(e.hash || '')}">${escHtml(e.repo || '')} ${escHtml(e.hash || '')}</span>
</div>`).join('');
body.innerHTML = rows || '<div class="text-secondary">No entries.</div>';
if (!pager) return;
if (pages <= 1) { pager.innerHTML = ''; return; }
const btn = (label, p, dis, act) =>
`<button class="btn btn-sm ${act ? 'btn-primary' : 'btn-outline-secondary'}" style="padding:1px 8px;" ${dis ? 'disabled' : ''} data-chpage="${p}">${label}</button>`;
let h = btn('«', 0, page === 0) + btn('‹', page - 1, page === 0);
const lo = Math.max(0, page - 2), hi = Math.min(pages - 1, page + 2);
for (let p = lo; p <= hi; p++) h += btn(String(p + 1), p, false, p === page);
h += btn('›', page + 1, page === pages - 1) + btn('»', pages - 1, page === pages - 1);
h += `<select class="form-select form-select-sm" style="width:auto;padding:1px 6px;" id="chPageSel" title="Jump to page">` +
Array.from({ length: pages }, (_, p) => `<option value="${p}" ${p === page ? 'selected' : ''}>${p + 1}/${pages}</option>`).join('') + `</select>`;
pager.innerHTML = h;
}
async function load() {
if (!wired) {
wired = true;
const pager = $(ids.pager);
if (pager) {
pager.addEventListener('click', ev => {
const b = ev.target.closest('[data-chpage]'); if (!b) return;
page = parseInt(b.dataset.chpage, 10) || 0; render();
});
pager.addEventListener('change', ev => {
if (ev.target && ev.target.id === 'chPageSel') { page = parseInt(ev.target.value, 10) || 0; render(); }
});
}
}
if (entries) { render(); return; }
try {
const r = await fetch(url + '?_=' + Date.now(), { credentials: 'omit' });
const j = await r.json();
entries = Array.isArray(j) ? j : (j.entries || []);
const fa = $(ids.fetchAt);
if (fa && entries[0]) fa.textContent = 'latest ' + (entries[0].date || '');
render();
} catch (_) {
const b = $(ids.body); if (b) b.innerHTML = '<div class="text-secondary">Changelog unavailable.</div>';
}
}
return { load, render };
}
window.changelogViewer = changelogViewer;
})();
+95
View File
@@ -0,0 +1,95 @@
// fmt.js — shared formatting helpers for every web (dmr-web, svx-web, svxlink-web).
// Canonical copy: callmgr2/www-mumien/lib/fmt.js; other repos carry a COPY.
// Every function here replaced an identical local copy; outputs are unchanged.
(function () {
'use strict';
// HTML-escape for innerHTML/attribute use.
function escHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Two-digit zero pad (clock fields).
function fmt2(n) { return String(n).padStart(2, '0'); }
// Age in seconds → "12s" / "5m" / "3h" / "2d"; "—" when unknown.
function fmtAge(s) {
if (s == null || s < 0) return '—';
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h';
return Math.floor(s / 86400) + 'd';
}
// Duration in seconds → "42s" / "3m 5s" / "1h 07m".
function fmtDur(s) {
s = Number(s) || 0;
if (s < 60) return s + 's';
const m = Math.floor(s / 60), r = s % 60;
if (s < 3600) return m + 'm ' + r + 's';
const h = Math.floor(s / 3600), mm = Math.floor((s % 3600) / 60);
return h + 'h ' + mm + 'm';
}
// Unix seconds → local date/time string; "—" when missing.
function fmtTs(ts) {
if (!ts) return '—';
try { return new Date(ts * 1000).toLocaleString(); } catch (_) { return '—'; }
}
// Callsigns: "SA7AUX-R" / "SA7AUX (x)" → "SA7AUX"; natural sort by prefix,
// number, suffix so SM7A < SM7B < SM70A.
function baseCallsign(cs) {
return String(cs || '').split(/[-(]/)[0].trim();
}
function splitForSort(cs) {
const b = baseCallsign(cs).toUpperCase();
const m = b.match(/^([A-Z]+)(\d+)([A-Z0-9]*)$/);
if (!m) return { pre: b, num: Number.POSITIVE_INFINITY, post: '' };
return { pre: m[1], num: Number(m[2]), post: (m[3] || '') };
}
function callsignCompare(a, b) {
const A = splitForSort(a);
const B = splitForSort(b);
if (A.pre !== B.pre) return A.pre < B.pre ? -1 : 1;
if (A.num !== B.num) return A.num - B.num;
if (A.post !== B.post) return A.post < B.post ? -1 : 1;
const ba = baseCallsign(a).toUpperCase();
const bb = baseCallsign(b).toUpperCase();
if (ba !== bb) return ba < bb ? -1 : 1;
const fa = String(a).toUpperCase();
const fb = String(b).toUpperCase();
if (fa !== fb) return fa < fb ? -1 : 1;
return 0;
}
// Frequencies: "145,600 MHz" → 145.6; fmtFreq → "145.60000" (5 decimals).
function parseMHz(v) {
if (v == null) return NaN;
if (typeof v === 'number') return v;
const s = String(v).replace(',', '.').trim();
const m = s.match(/(\d+(?:\.\d+)?)/);
return m ? Number(m[1]) : NaN;
}
function fmtFreq(v) {
if (v == null) return '';
if (typeof v === 'number') {
if (!Number.isFinite(v)) return '';
return v.toFixed(5);
}
const s = String(v).trim();
if (!s) return '';
const mhz = parseMHz(s);
if (Number.isFinite(mhz) && mhz > 0) {
return mhz.toFixed(5);
}
return s;
}
window.escHtml = escHtml;
window.fmt2 = fmt2;
window.fmtAge = fmtAge;
window.fmtDur = fmtDur;
window.fmtTs = fmtTs;
window.baseCallsign = baseCallsign;
window.callsignCompare = callsignCompare;
window.parseMHz = parseMHz;
window.fmtFreq = fmtFreq;
})();
+84
View File
@@ -0,0 +1,84 @@
// pager.js — shared pagination (dmr-web, svx-web).
// Canonical copy: callmgr2/www-mumien/lib/pager.js; other repos carry a COPY.
//
// pageSizeFor('monitorBody', 32, 160) rows that fit below the element (min 5)
// pagerHtml({page, pages, total, noun, cls}) footer « ‹ 1..n › » as HTML;
// buttons carry class `cls` + data-pg, the page wires the click.
// paginate.slice / totalPages / render(container, total, pageSize, page, onChange)
// the container-driven pager (dmr-web lists), 1-based pages.
(function () {
'use strict';
function pageSizeFor(bodyId, rowPx, reservePx) {
const body = document.getElementById(bodyId);
const top = body ? body.getBoundingClientRect().top : 220;
const avail = window.innerHeight - top - (reservePx == null ? 160 : reservePx);
return Math.max(5, Math.floor(avail / (rowPx || 32)));
}
function pagerHtml(o) {
const page = o.page, pages = o.pages, total = o.total, cls = o.cls || 'pg', noun = o.noun || 'rows';
if (pages <= 1) return '';
const span = 3;
let from = Math.max(1, page - span), to = Math.min(pages, page + span);
while (to - from < 6 && (from > 1 || to < pages)) { if (from > 1) from--; else if (to < pages) to++; }
const btn = (label, target, dis, cur) =>
`<button class="btn btn-sm ${cls} ${cur ? 'btn-primary' : 'btn-outline-secondary'} mx-1"${dis ? ' disabled' : ''}${target != null ? ` data-pg="${target}"` : ''} style="padding:1px 8px;">${label}</button>`;
let h = `<div class="d-flex align-items-center flex-wrap justify-content-center mt-2" style="gap:2px;">`;
h += `<span class="small text-secondary me-2">${total} ${noun} · page ${page}/${pages}</span>`;
h += btn('&laquo;', 1, page === 1) + btn('&lsaquo;', page - 1, page === 1);
for (let p = from; p <= to; p++) h += btn(String(p), p, false, p === page);
h += btn('&rsaquo;', page + 1, page === pages) + btn('&raquo;', pages, page === pages);
return h + `</div>`;
}
const paginate = {
slice(items, pageSize, page) {
if (!Array.isArray(items)) return [];
if (pageSize <= 0) return items;
const start = Math.max(0, (page - 1) * pageSize);
return items.slice(start, start + pageSize);
},
totalPages(totalItems, pageSize) {
if (pageSize <= 0 || totalItems <= 0) return 1;
return Math.max(1, Math.ceil(totalItems / pageSize));
},
render(container, totalItems, pageSize, page, onChange) {
if (!container) return;
const total = this.totalPages(totalItems, pageSize);
if (total <= 1) { container.innerHTML = ''; return; }
const cur = Math.min(Math.max(1, page), total);
const btn = (label, target, disabled, isCurrent) =>
`<button class="btn btn-sm ${isCurrent ? 'btn-primary' : 'btn-outline-secondary'} mx-1"`
+ (disabled ? ' disabled' : '')
+ (target != null ? ` data-pg="${target}"` : '')
+ `>${label}</button>`;
const span = 2;
let from = Math.max(1, cur - span);
let to = Math.min(total, cur + span);
while (to - from < 4 && (from > 1 || to < total)) {
if (from > 1) from--;
else if (to < total) to++;
}
let nums = '';
for (let p = from; p <= to; p++) nums += btn(String(p), p, false, p === cur);
container.innerHTML =
`<div class="d-flex justify-content-center align-items-center mt-2 small">` +
btn('« First', 1, cur === 1, false) +
btn('‹ Prev', cur - 1, cur === 1, false) +
nums +
btn('Next ›', cur + 1, cur === total, false) +
btn('Last »', total, cur === total, false) +
`<span class="text-secondary ms-2">${cur} / ${total} (${totalItems} rows)</span>` +
`</div>`;
container.onclick = (e) => {
const b = e.target.closest('button[data-pg]');
if (!b) return;
const p = parseInt(b.dataset.pg, 10);
if (!isNaN(p) && p >= 1 && p <= total && typeof onChange === 'function') {
onChange(p);
}
};
},
};
window.pageSizeFor = pageSizeFor;
window.pagerHtml = pagerHtml;
window.paginate = paginate;
})();
+241
View File
@@ -0,0 +1,241 @@
/* Theme engine — Dark / Light / Color, with a Color editor. Shared by every web
* (canonical copy: callmgr2/www-mumien/lib/theme.js; other repos carry a COPY).
* Page settings: window.THEME_AUTH_BASE (default '/auth/'); window.THEME_SERVER_SYNC = false
* disables the per-account sync (svxlink-web has no account backend).
*
* Loaded from <head> WITHOUT defer so the saved theme applies before the first
* paint, and as an EXTERNAL file because the site CSP is script-src 'self'
* (inline <script> is blocked — an inline engine here simply never runs).
*
* Storage:
* - everyone: first-party cookies `svxtheme` + `svxcolors` (~1 year)
* - signed in: also the account profile — changes POST to
* auth/theme_prefs_update.php; app.js loadMe() adopts the stored value on
* login (svxTheme.adoptServer), and those adopt-time events are not echoed.
*/
(function () {
var THEMES = ['dark', 'light', 'color'];
var KEYS = ['bg', 'panel', 'fg', 'fg2', 'line', 'accent'];
var AUTH_BASE = (window.THEME_AUTH_BASE || '/auth/');
var AUTH = AUTH_BASE + 'theme_prefs_update.php';
function readCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function writeCookie(name, val) {
document.cookie = name + '=' + encodeURIComponent(val) + ';path=/;max-age=31536000;SameSite=Lax';
}
function rgb(h) {
h = (h || '').replace('#', '');
if (h.length === 3) h = h.replace(/(.)/g, '$1$1');
var n = parseInt(h, 16) || 0;
return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
}
// Storage: cookie (so PHP can read it too). localStorage is read as a
// fallback for browsers that saved with the old svxlink-web engine.
function lsGet(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } }
function currentTheme() {
var t = readCookie('svxtheme') || lsGet('svxTheme');
return THEMES.indexOf(t) >= 0 ? t : 'dark';
}
// Colours are ALWAYS a plain {key:#hex} object. Anything else — an empty array
// '[]' from an older PHP json_encode([]), a malformed cookie, a null, a string
// — collapses to {}. This is the single choke point every read/write/adopt goes
// through, so a poisoned value can never throw or paint a broken palette.
function normColors(v) {
var o = v;
if (typeof o === 'string') { try { o = JSON.parse(o); } catch (e) { o = null; } }
if (!o || typeof o !== 'object' || Array.isArray(o)) return {};
var out = {};
KEYS.forEach(function (k) { if (typeof o[k] === 'string' && o[k]) out[k] = o[k]; });
return out;
}
function currentColors() {
var c = readCookie('svxcolors');
if (c) return normColors(c);
var o = normColors(lsGet('svxColors'));
if (!o.accent && lsGet('svxColor')) o.accent = lsGet('svxColor'); // oldest single-colour form
return normColors(o);
}
function applyColors(o) {
o = normColors(o);
var s = document.documentElement.style;
KEYS.forEach(function (k) { if (o[k]) s.setProperty('--uc-' + k, o[k]); else s.removeProperty('--uc-' + k); });
if (o.accent) s.setProperty('--uc-accent-rgb', rgb(o.accent)); else s.removeProperty('--uc-accent-rgb');
// Accent-derived tints (svxlink-web's CSS reads these; harmless elsewhere).
var a = o.accent || '#ff5db1', r = rgb(a);
s.setProperty('--u-accent', a);
s.setProperty('--u-fg2', 'rgba(' + r + ',.78)');
s.setProperty('--u-line', 'rgba(' + r + ',.35)');
s.setProperty('--u-bg', 'rgba(' + r + ',.12)');
s.setProperty('--u-bg2', 'rgba(' + r + ',.24)');
s.setProperty('--u-line2', 'rgba(' + r + ',.7)');
s.setProperty('--u-focus', 'rgba(' + r + ',.22)');
}
window.svxTheme = {
THEMES: THEMES, KEYS: KEYS,
getTheme: currentTheme,
getColors: currentColors,
applyColors: applyColors,
setTheme: function (t) {
if (THEMES.indexOf(t) < 0) t = 'dark';
document.documentElement.setAttribute('data-theme', t);
writeCookie('svxtheme', t);
window.dispatchEvent(new CustomEvent('svx-theme', { detail: t }));
},
setColors: function (o) {
o = normColors(o);
writeCookie('svxcolors', JSON.stringify(o));
applyColors(o);
window.dispatchEvent(new CustomEvent('svx-color', { detail: o }));
},
// Adopt a signed-in account's stored theme+colours without echoing them back
// to the server (the save listeners skip while _fromServer is set).
adoptServer: function (theme, colors) {
this._fromServer = true;
try {
this.setColors(normColors(colors)); // normalises '[]'/garbage → {}, never throws
if (THEMES.indexOf(theme) >= 0) this.setTheme(theme);
} catch (e) {}
this._fromServer = false;
}
};
// Apply immediately — before first paint.
applyColors(currentColors());
document.documentElement.setAttribute('data-theme', currentTheme());
// Persist to the account when signed in. The cookie is already written by
// setTheme/setColors before this runs, so a change ALWAYS survives locally;
// this only mirrors it to the account so it follows across devices.
//
// Earlier this bailed on `!window._csrfToken` and dropped the POST silently —
// if you changed the theme before app.js loadMe() had populated the token
// (or on a page that never calls loadMe), nothing ever reached the server, so
// the account value stayed stale/seeded and every reload "reverted" your pick.
// Now, if the token isn't loaded yet, we fetch it once from me.php and then
// POST. Anonymous visitors' POSTs are simply rejected by require_login and do
// no harm (they use the cookie only).
function postPrefs(tok) {
try {
fetch(AUTH, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': tok || '' },
body: JSON.stringify({ theme: window.svxTheme.getTheme(), colors: window.svxTheme.getColors() })
}).catch(function () {});
} catch (e) {}
}
function saveToServer() {
if (window.THEME_SERVER_SYNC === false) return; // no account backend (svxlink-web)
if (window.svxTheme._fromServer) return; // adopt-time, never echo back
if (window._csrfToken) { postPrefs(window._csrfToken); return; }
// Token not primed yet — grab it once, then persist.
try {
fetch(AUTH_BASE + 'me.php?t=' + Date.now(), { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (j && j.csrf_token) { window._csrfToken = j.csrf_token; postPrefs(j.csrf_token); }
})
.catch(function () {});
} catch (e) {}
}
window.addEventListener('svx-theme', saveToServer);
window.addEventListener('svx-color', saveToServer);
// ---- UI: navbar selector + Color-edit dialog ----
var FIELDS = [
['bg', 'Background', '#000000'],
['fg', 'Text', '#ff5db1'],
['fg2', 'Secondary text', '#c98fb3'],
['line', 'Borders / lines', '#5c2a45'],
['panel', 'Panel background', '#111111'],
['accent', 'Accent (buttons, links)', '#ff5db1']
];
var PRESETS = ['#00ffff', '#ff5db1', '#00e676', '#ffb703', '#7c4dff', '#ff5252', '#5aa0ff', '#b070ff', '#ff8c00', '#00c8a0'];
document.addEventListener('DOMContentLoaded', function () {
var T = window.svxTheme;
// Guard against browser session-restore ("continue where you left off"):
// on the first startup of the day the browser restores form-control values
// from the previous session and fires a synthetic `change`/`input` on them.
// Those events flipped the theme (typically to 'color', via the swatch/select
// handlers) even though the user never touched the control.
//
// The deterministic discriminator is FOCUS: a genuine pick focuses the control
// (`document.activeElement === el` during the event), while session-restore
// sets the value and fires the event WITHOUT focusing it. Gate on that — not
// on a page-wide "user gesture" flag, which armed on any click/keypress and
// then let a deferred restore event through (the real morning-jump bug).
var sel = document.getElementById('svxThemeSel');
if (sel) {
sel.setAttribute('autocomplete', 'off');
sel.value = T.getTheme();
sel.addEventListener('change', function () {
if (document.activeElement !== sel) { sel.value = T.getTheme(); return; } // restore echo
T.setTheme(sel.value);
});
window.addEventListener('svx-theme', function (e) { sel.value = e.detail; });
// bfcache / session restore: re-sync the control to the source of truth so
// a restored value can never sit stale on screen.
window.addEventListener('pageshow', function () { sel.value = T.getTheme(); });
}
var pickBox = document.getElementById('colorPickers');
var inputs = {};
if (pickBox) {
var cols = T.getColors();
FIELDS.forEach(function (f) {
var key = f[0], label = f[1], def = f[2];
var row = document.createElement('label');
row.className = 'd-flex align-items-center gap-3';
var sw = document.createElement('input');
sw.type = 'color'; sw.value = cols[key] || def;
sw.setAttribute('autocomplete', 'off');
sw.style.cssText = 'width:48px;height:34px;padding:0;border:1px solid var(--line);background:var(--bg);border-radius:8px;cursor:pointer;flex:none;';
var txt = document.createElement('span'); txt.className = 'mono small'; txt.textContent = label;
sw.addEventListener('input', function () {
// A real edit only happens while the Color modal is open AND this swatch
// is the focused control. Session-restore fires input without focus.
var m = document.getElementById('colorEditModal');
if (m && !m.classList.contains('show')) return;
if (document.activeElement !== sw) return; // restore echo — ignore
var c = T.getColors(); c[key] = sw.value; T.setColors(c);
if (T.getTheme() !== 'color') T.setTheme('color');
});
inputs[key] = sw; row.appendChild(sw); row.appendChild(txt); pickBox.appendChild(row);
});
}
var presetBox = document.getElementById('colorPresets');
if (presetBox) {
PRESETS.forEach(function (c) {
var b = document.createElement('button');
b.type = 'button'; b.title = c;
b.style.cssText = 'width:26px;height:26px;border-radius:6px;border:1px solid var(--line);background:' + c + ';cursor:pointer;';
b.addEventListener('click', function () {
var cc = T.getColors(); cc.accent = c;
if (inputs.accent) inputs.accent.value = c;
if (!cc.fg && inputs.fg) inputs.fg.value = c; // sensible first pick
T.setColors(cc);
if (T.getTheme() !== 'color') T.setTheme('color');
});
presetBox.appendChild(b);
});
}
var resetBtn = document.getElementById('btnColorReset');
if (resetBtn) resetBtn.addEventListener('click', function () {
FIELDS.forEach(function (f) { if (inputs[f[0]]) inputs[f[0]].value = f[2]; });
T.setColors({});
});
var editBtn = document.getElementById('btnColorEdit');
if (editBtn && window.bootstrap) editBtn.addEventListener('click', function () {
var m = document.getElementById('colorEditModal');
if (m) bootstrap.Modal.getOrCreateInstance(m).show();
});
});
})();
+67
View File
@@ -0,0 +1,67 @@
// ui_field.js — shared form-field builders (dmr-web, svx-web, svxlink-web).
// Canonical copy: callmgr2/www-mumien/lib/ui_field.js; other repos carry a COPY.
//
// makeToggleBtn(isOn, onFlip(next, btn), extraCls) Enable/Disable button; .value 'true'/'false', .setOn()
// makeBoolSelect(cls, val, disabled) same button, fires 'change' — drop-in for a true/false <select>
// isBoolVal(v) only 'true'/'false'/true/false count (never 0/1: those are numbers)
// initTooltips(container) / cleanupTooltips() Bootstrap tooltips for [data-bs-toggle="tooltip"]
(function () {
'use strict';
function makeToggleBtn(isOn, onFlip, extraCls) {
const b = document.createElement('button');
b.type = 'button';
const paint = (state) => {
b.className = 'btn btn-sm ' + (state ? 'btn-warning' : 'btn-success') + (extraCls ? ' ' + extraCls : '');
b.textContent = state ? 'Disable' : 'Enable';
b.value = state ? 'true' : 'false'; // callers read .value === 'true'
b.dataset.on = state ? '1' : '0';
};
paint(!!isOn);
b.setOn = (state) => paint(!!state); // extern styrning (t.ex. mutual-exclusion)
b.addEventListener('click', () => {
if (b.disabled) return;
const next = b.dataset.on !== '1';
paint(next);
if (onFlip) onFlip(next, b);
});
return b;
}
function makeBoolSelect(cls, val, disabled) {
const on = (val === true || val === 'true' || val === '1' || val === 1);
const b = makeToggleBtn(on, (_next, btn) => {
btn.dispatchEvent(new Event('change', { bubbles: true })); // drives existing change handlers
}, cls);
if (disabled) b.disabled = true;
return b;
}
function isBoolVal(v) {
// Only unambiguous 'true'/'false' count as bool. '0'/'1' are REMOVED here —
// they collide with numeric fields (spacing_ms=0, burst_count=1, timeouts/
// intervals of 0/1) that would otherwise be wrongly rendered as a true/false
// toggle. The daemon stores bools as 'true'/'false'; any legacy '1'/'0' bool
// becomes a number field but is still type-corrected server-side on save
// (coerce_to_template_type in sysconfig.php).
return v === 'true' || v === 'false';
}
function cleanupTooltips() {
document.querySelectorAll('.tooltip').forEach(el => el.remove());
}
function initTooltips(container) {
cleanupTooltips();
if (!window.bootstrap || !window.bootstrap.Tooltip) return;
const root = container || document;
root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
try { bootstrap.Tooltip.getOrCreateInstance(el, {delay: {show: 400, hide: 100}}); } catch(_) {}
});
}
window.makeToggleBtn = makeToggleBtn;
window.makeBoolSelect = makeBoolSelect;
window.isBoolVal = isBoolVal;
window.initTooltips = initTooltips;
window.cleanupTooltips = cleanupTooltips;
})();
+264
View File
@@ -0,0 +1,264 @@
// ui_list.js — shared list editor for every web (dmr-web, svx-web, svxlink-web).
//
// Canonical copy: callmgr2/www-mumien/lib/ui_list.js. Other repos carry a
// plain COPY (never a symlink); deploy copies it in. Plain script, no deps
// beyond the shared CSS classes (btn, form-control, data-table, mono).
//
// One call renders an "Add + table + Delete" editor and hands the typed value
// back. The component owns look and validation; the page owns the storage
// format (JSON array, "9,240", "77.0:999,...") and the API key.
//
// const f = listField({
// kind: 'text' | 'int' | 'pair',
// value: [...], // text: string[] int: (number|string)[] pair: [a,b][]
// labels: ['Talk group'], // column header(s); pair takes two
// placeholder: '24016', // pair takes two: ['77.0', '999']
// addLabel: 'Add TG', // default 'Add'
// emptyText: 'No talk groups — click "Add TG".',
// upper: true, // text only: uppercase (callsigns)
// lower: true, // text only: lowercase (emails)
// validate: (s) => bool, // text only: extra per-row check (IPs)
// canEdit: true,
// onChange: (value) => {}, // called on every edit with get()
// });
// parent.appendChild(f.el); f.get(); f.set(newValue);
//
// get() returns the CLEAN value: trimmed, empty rows dropped, duplicates
// dropped, int rows parsed to positive integers, pairs with both halves.
// Rows that do not pass are shown with .is-invalid but left in place so the
// user can finish typing.
(function () {
'use strict';
function listField(opts) {
const kind = opts.kind || 'text';
const isPair = kind === 'pair';
const isInt = kind === 'int';
const canEdit = opts.canEdit !== false;
const labels = Array.isArray(opts.labels) ? opts.labels : [opts.labels || (isInt ? 'Value' : 'Text')];
const ph = Array.isArray(opts.placeholder) ? opts.placeholder : [opts.placeholder || '', ''];
const addLabel = opts.addLabel || 'Add';
const emptyText = opts.emptyText || ('None — click "' + addLabel + '".');
const onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
const validate = typeof opts.validate === 'function' ? opts.validate : null; // text: extra row check
// Working rows, always strings while editing.
let rows = normalize(opts.value);
function normalize(v) {
if (!Array.isArray(v)) v = (v == null || v === '') ? [] : [v];
return v.map(function (x) {
if (isPair) return Array.isArray(x) ? [str(x[0]), str(x[1])] : [str(x), ''];
return str(x);
});
}
function str(x) { return x == null ? '' : String(x).trim(); }
function cleanRow(r) { // -> typed value or null when not valid
if (isPair) {
const a = str(r[0]), b = str(r[1]);
return (a !== '' && b !== '') ? [a, b] : null;
}
let s = str(r);
if (s === '') return null;
if (isInt) {
if (!/^\d+$/.test(s)) return null;
const n = parseInt(s, 10);
return n > 0 ? n : null;
}
if (opts.upper) s = s.toUpperCase();
if (opts.lower) s = s.toLowerCase();
if (validate && !validate(s)) return null;
return s;
}
function get() {
const out = [], seen = new Set();
rows.forEach(function (r) {
const v = cleanRow(r);
if (v === null) return;
const key = isPair ? v[0] + ':' + v[1] : String(v);
if (seen.has(key)) return;
seen.add(key);
out.push(v);
});
return out;
}
const el = document.createElement('div');
el.className = 'ui-list';
const bar = document.createElement('div');
bar.className = 'd-flex gap-2 mb-2';
const btnAdd = document.createElement('button');
btnAdd.type = 'button';
btnAdd.className = 'btn btn-sm btn-outline-success';
btnAdd.textContent = addLabel;
btnAdd.disabled = !canEdit;
bar.appendChild(btnAdd);
el.appendChild(bar);
const tblWrap = document.createElement('div');
tblWrap.style.overflowX = 'auto';
el.appendChild(tblWrap);
function changed() { if (onChange) onChange(get()); }
function mkInput(value, placeholder, onInput) {
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm mono' + (opts.upper && !isPair && !isInt ? ' cs-upper' : '');
if (isInt) { inp.inputMode = 'numeric'; inp.pattern = '[0-9]*'; }
inp.value = value;
inp.placeholder = placeholder || '';
inp.disabled = !canEdit;
inp.addEventListener('input', function () { onInput(inp.value); });
return inp;
}
function draw() {
const tbl = document.createElement('table');
tbl.className = 'data-table';
tbl.style.minWidth = isPair ? '360px' : '280px';
const thead = document.createElement('thead');
const hr = document.createElement('tr');
labels.slice(0, isPair ? 2 : 1).forEach(function (l) {
const th = document.createElement('th'); th.textContent = l; hr.appendChild(th);
});
const thA = document.createElement('th'); thA.style.width = '120px'; thA.textContent = 'Action'; hr.appendChild(thA);
thead.appendChild(hr); tbl.appendChild(thead);
const tb = document.createElement('tbody');
if (!rows.length) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = isPair ? 3 : 2; td.className = 'small text-secondary'; td.textContent = emptyText;
tr.appendChild(td); tb.appendChild(tr);
}
rows.forEach(function (r, i) {
const tr = document.createElement('tr');
const mark = function () {
const ok = cleanRow(rows[i]) !== null;
tr.querySelectorAll('input').forEach(function (x) { x.classList.toggle('is-invalid', !ok); });
};
if (isPair) {
const tdA = document.createElement('td'), tdB = document.createElement('td');
tdA.appendChild(mkInput(r[0], ph[0], function (v) { rows[i][0] = v; mark(); changed(); }));
tdB.appendChild(mkInput(r[1], ph[1], function (v) { rows[i][1] = v; mark(); changed(); }));
tr.appendChild(tdA); tr.appendChild(tdB);
} else {
const td = document.createElement('td');
td.appendChild(mkInput(r, ph[0], function (v) { rows[i] = v; mark(); changed(); }));
tr.appendChild(td);
}
const tdX = document.createElement('td');
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-sm btn-outline-danger';
del.textContent = 'Delete';
del.disabled = !canEdit;
del.addEventListener('click', function () { rows.splice(i, 1); draw(); changed(); });
tdX.appendChild(del); tr.appendChild(tdX);
tb.appendChild(tr);
});
tbl.appendChild(tb);
tblWrap.innerHTML = '';
tblWrap.appendChild(tbl);
}
btnAdd.addEventListener('click', function () {
rows.push(isPair ? ['', ''] : '');
draw();
const ins = tblWrap.querySelectorAll('input');
if (ins.length) ins[isPair ? ins.length - 2 : ins.length - 1].focus();
});
draw();
return {
el: el,
get: get,
set: function (v) { rows = normalize(v); draw(); },
};
}
// listPopup: the compact form pattern — the page keeps a one-line text
// field, an "Edit…" button opens an overlay with the listField inside, Done
// (or a click outside the box) commits. This is the svxlink-web
// CTCSS_TO_TG shape; MONITOR_TGS and the dmr-web row lists use it too so
// every list edits the same way.
//
// listPopup({ title: 'CTCSS_TO_TG', list: {kind:'pair', ...}, onDone: (value) => {} })
function listPopup(opts) {
const old = document.getElementById('uiListPopup');
if (old) old.remove();
const ov = document.createElement('div');
ov.id = 'uiListPopup';
ov.style.cssText = 'position:fixed; inset:0; z-index:4000; background:rgba(0,0,0,.6); display:flex; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 12px;';
const box = document.createElement('div');
box.style.cssText = 'background:var(--bg, #0a0a0a); color:var(--fg, #eee); border:1px solid var(--line, #333); border-radius:10px; padding:16px; min-width:360px; max-width:560px; width:100%;';
const head = document.createElement('div');
head.className = 'd-flex justify-content-between align-items-center mb-2';
const title = document.createElement('b');
title.textContent = opts.title || '';
const btnDone = document.createElement('button');
btnDone.type = 'button';
btnDone.className = 'btn btn-sm btn-outline-secondary';
btnDone.textContent = 'Done';
head.appendChild(title); head.appendChild(btnDone);
box.appendChild(head);
const f = listField(opts.list || {});
box.appendChild(f.el);
ov.appendChild(box);
const commit = function () {
ov.remove();
if (typeof opts.onDone === 'function') opts.onDone(f.get());
};
btnDone.addEventListener('click', commit);
ov.addEventListener('click', function (e) { if (e.target === ov) commit(); });
document.body.appendChild(ov);
const first = box.querySelector('input');
if (first) first.focus();
return { close: commit, field: f };
}
// listControl: the compact row control — a read-only summary field plus
// "Edit…" that opens listPopup. The page keeps the typed value; the
// control keeps the summary in sync and calls onDone(value) on Done.
//
// const c = listControl({ title, list: {kind, value, ...}, format: v => v.join(', '),
// canEdit: true, onDone: v => {...} });
// parent.appendChild(c.el); c.set(newValue);
function listControl(opts) {
const canEdit = opts.canEdit !== false;
const format = typeof opts.format === 'function' ? opts.format : function (v) { return v.join(', '); };
let current = Array.isArray(opts.list && opts.list.value) ? opts.list.value : [];
const el = document.createElement('div');
el.className = 'ui-list-control d-flex gap-2 align-items-center';
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm mono';
inp.readOnly = true;
inp.value = format(current);
inp.placeholder = opts.placeholder || '(none)';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-info';
btn.style.flex = 'none';
btn.textContent = 'Edit\u2026';
btn.disabled = !canEdit;
btn.addEventListener('click', function () {
listPopup({
title: opts.title || '',
list: Object.assign({}, opts.list || {}, { value: current, canEdit: canEdit }),
onDone: function (v) {
current = v;
inp.value = format(v);
if (typeof opts.onDone === 'function') opts.onDone(v);
},
});
});
el.appendChild(inp); el.appendChild(btn);
return { el: el, get: function () { return current; }, set: function (v) { current = Array.isArray(v) ? v : []; inp.value = format(current); } };
}
window.listField = listField;
window.listPopup = listPopup;
window.listControl = listControl;
})();
+31
View File
@@ -0,0 +1,31 @@
// ui_msg.js — shared status/error message helpers (dmr-web, svx-web, svxlink-web).
// Canonical copy: callmgr2/www-mumien/lib/ui_msg.js; other repos carry a COPY.
//
// setMsg(elOrId, text, isErr) inline text line: hidden when empty, green/red
// showErr(elOrId, text) show/hide an error line, keeps its own colour
// flashMsg(selOrEl, html, isErr, ttlMs) panel-style box (svxlink-web); ttl 0 = stays
(function () {
'use strict';
const el = x => (typeof x === 'string' ? (document.getElementById(x) || document.querySelector(x)) : x);
function setMsg(target, msg, isErr) {
const e = el(target); if (!e) return;
if (!msg) { e.style.display = 'none'; e.textContent = ''; return; }
e.textContent = msg;
e.style.color = isErr ? '#ff8080' : '#0f0';
e.style.display = 'block';
}
function showErr(target, msg) {
const e = el(target); if (!e) return;
if (!msg) { e.style.display = 'none'; e.textContent = ''; return; }
e.textContent = msg;
e.style.display = 'block';
}
function flashMsg(target, html, isErr, ttlMs) {
const e = el(target); if (!e) return;
e.innerHTML = '<div class="panel" style="color:' + (isErr ? '#ff8080' : '#00ff9c') + ';">' + html + '</div>';
if (ttlMs > 0) setTimeout(() => { if (e.firstChild) e.innerHTML = ''; }, ttlMs);
}
window.setMsg = setMsg;
window.showErr = showErr;
window.flashMsg = flashMsg;
})();
+33
View File
@@ -0,0 +1,33 @@
'use strict';
function renderList(nodes){
const tbody = document.getElementById('rows');
if(!tbody) return;
tbody.innerHTML = "";
const calls = Object.keys(nodes || {}).sort();
for(const cs of calls){
const n = nodes[cs] || {};
const tr = document.createElement('tr');
const tdCS = document.createElement('td');
tdCS.textContent = cs;
const tdTG = document.createElement('td');
tdTG.textContent = (n.restrictedTG ? "*" : "") + (n.tg ?? "");
const tdLoc = document.createElement('td');
let loc = "";
if(Array.isArray(n.qth) && n.qth.length && n.qth[0].name) loc = n.qth[0].name;
tdLoc.textContent = loc;
tr.appendChild(tdCS);
tr.appendChild(tdTG);
tr.appendChild(tdLoc);
tbody.appendChild(tr);
}
document.getElementById('nodeCount').textContent = calls.length;
}
+128
View File
@@ -0,0 +1,128 @@
'use strict';
/*
* Monitor → Nodes. Read ONE reflector /status (via nodelist /api/monitor-nodes)
* that already has the whole picture, and show it grouped: a "Local" section
* first, then one section per trunk with its nodes underneath. No recursive
* crawl. Borderless. Search filters callsign/node or TG across every section.
* Footer pager (first « · ‹ · page numbers · › · last ») pages through the whole
* thing, rows/page fit to the screen so it never scrolls — same as TG Monitor.
*/
(function () {
let data = { local: [], trunks: [] };
let timer = null;
let started = false;
let _page = 1;
const $ = (id) => document.getElementById(id);
function modeBadge(m) {
if (!m) return '';
const map = { SVX: 'bg-info text-dark', AllStar: 'bg-warning text-dark', DMR: 'bg-secondary', YSF: 'bg-success', NXDN: 'bg-primary', M17: 'bg-danger' };
return `<span class="badge ${map[m] || 'bg-info text-dark'}">${escHtml(m)}</span>`;
}
async function fetchData() {
try {
const j = await apiGet('/svxportal/api/monitor-nodes'); // lib/api.js
if (j) { data = j; render(); }
} catch (_) {}
}
function filterNodes(nodes, q, mode) {
let ns = nodes || [];
// "All active" = only nodes on a real (non-zero) talkgroup; "All nodes" = all.
if (mode === 'active') ns = ns.filter(n => (Number(String(n.tg || '').replace('*', '')) || 0) > 0);
if (q) ns = ns.filter(n =>
String(n.callsign || '').toLowerCase().includes(q) ||
String(n.tg || '').toLowerCase().includes(q));
return ns;
}
// Rows/page fit ACTUAL space under the table (measure where it starts) so it
// fits without scrolling; leave room for the footer pager + heading.
function nodeRow(n) {
return `<tr>`
+ `<td class="mono fw-bold">${escHtml(n.callsign)}</td>`
+ `<td>${modeBadge(n.mode)}</td>`
+ `<td class="mono nowrap">${escHtml(n.tg || '')}</td>`
+ `<td class="text-secondary small">${escHtml(n.nodeClass || '')}</td>`
+ `</tr>`;
}
function headRow(title, sub) {
// A thin theme-aware line UNDER the section name (Local / trunk) only, so the
// sections read apart at a glance. Nothing else gets a border.
return `<tr class="mon-head"><td colspan="4" class="fw-bold pt-3 pb-1">${escHtml(title)}`
+ (sub ? ` <span class="text-secondary small">${escHtml(sub)}</span>` : '')
+ `</td></tr>`;
}
function render() {
const body = $('monitorBody');
if (!body) return;
const q = (($('monitorSearch') || {}).value || '').trim().toLowerCase();
const mode = (($('monitorMode') || {}).value || 'all');
const filt = (q || mode === 'active'); // a filter is active
// Build one flat row list (section headers + node rows) so a single footer
// pager pages through Local + every trunk and it fits the screen.
const rows = []; // {kind:'head'|'node'|'empty', ...}
let nodeCount = 0;
const loc = filterNodes(data.local, q, mode);
if (loc.length || !filt) {
rows.push({ kind: 'head', title: 'Local', sub: `${(data.local || []).length} nodes` });
if (loc.length) { loc.forEach(n => rows.push({ kind: 'node', n })); nodeCount += loc.length; }
else rows.push({ kind: 'empty' });
}
for (const t of (data.trunks || [])) {
const tn = filterNodes(t.nodes, q, mode);
if (filt && !tn.length) continue; // hide empty sections while filtering
const sub = [`${(t.nodes || []).length} nodes`, t.connected ? '' : 'offline'].filter(Boolean).join(' · ');
rows.push({ kind: 'head', title: t.name, sub });
if (tn.length) { tn.forEach(n => rows.push({ kind: 'node', n })); nodeCount += tn.length; }
else rows.push({ kind: 'empty' });
}
if (!rows.length) { body.innerHTML = '<div class="text-secondary">Loading…</div>'; return; }
if (filt && nodeCount === 0) { body.innerHTML = '<div class="text-secondary">No node or TG matches the filter.</div>'; return; }
const ps = pageSizeFor('monitorBody');
const pages = Math.max(1, Math.ceil(rows.length / ps));
if (_page > pages) _page = pages;
if (_page < 1) _page = 1;
const slice = rows.slice((_page - 1) * ps, (_page - 1) * ps + ps);
let html = `<table class="table table-sm table-borderless mb-0"><colgroup><col style="width:34%"><col style="width:14%"><col style="width:16%"><col style="width:36%"></colgroup><tbody>`;
for (const r of slice) {
if (r.kind === 'head') html += headRow(r.title, r.sub);
else if (r.kind === 'empty') html += `<tr><td colspan="4" class="text-secondary small">— no nodes —</td></tr>`;
else html += nodeRow(r.n);
}
html += `</tbody></table>` + pagerHtml({ page: _page, pages, total: nodeCount, noun: 'nodes', cls: 'mon-pg' });
body.innerHTML = html;
body.querySelectorAll('.mon-pg[data-pg]').forEach(b => b.addEventListener('click', () => {
const p = parseInt(b.dataset.pg, 10);
if (p >= 1 && p <= pages && p !== _page) { _page = p; render(); }
}));
}
// Called by app.js (nav click + every status poll while the panel is visible).
window.renderMonitor = function () {
if (!started) {
started = true;
fetchData();
if (!timer) timer = setInterval(() => { if ($('monitorPanel')?.style.display !== 'none') fetchData(); }, 5000);
}
render();
};
// Live search (callsign/node or TG number) + mode toggle (All nodes / All active).
document.addEventListener('input', (e) => { if (e.target && e.target.id === 'monitorSearch') { _page = 1; render(); } });
document.addEventListener('change', (e) => { if (e.target && e.target.id === 'monitorMode') { _page = 1; render(); } });
// Re-fit rows-per-page on resize.
window.addEventListener('resize', () => { if ($('monitorPanel')?.style.display !== 'none') render(); });
})();
+1
View File
@@ -0,0 +1 @@
Privacy policy
+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://svxportal.sa7aux.se/sitemap.xml
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://svxportal.sa7aux.se/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://svxportal.sa7aux.se/help.html</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/*
* svx-mkadmin.php — interaktiv provisionering av ett SvxPortal-konto (t.ex. admin).
*
* Återanvänder portalens EGEN auth/lib.php, så redis-anslutning (socket/lösen/db),
* nyckelprefix, uuid-generering, callsign-normalisering och lösenordshashning
* (password_hash PASSWORD_DEFAULT) blir identiska med signup.php. Inget hårdkodas.
*
* Kör på servern, från prompten:
* php svx-mkadmin.php
*
* Lib-sökväg (default = deployad portal) kan överstyras:
* SVXPORTAL_LIB=/annan/väg/lib.php php svx-mkadmin.php
* php svx-mkadmin.php /annan/väg/lib.php
*/
if (PHP_SAPI !== 'cli') { fwrite(STDERR, "Kör från kommandoraden.\n"); exit(1); }
if (!extension_loaded('redis')) { fwrite(STDERR, "PHP-tillägget 'redis' saknas.\n"); exit(1); }
$LIB = getenv('SVXPORTAL_LIB') ?: ($argv[1] ?? '/var/www/html/svxportal/auth/lib.php');
if (!is_file($LIB)) { fwrite(STDERR, "Hittar inte lib.php: $LIB\n(ange via SVXPORTAL_LIB eller som argument)\n"); exit(1); }
require $LIB; // ger cfg(), redis(), rkey(), uuidv4(), base_call(), norm_email(), *_key()
function ask(string $label, string $def = ''): string {
fwrite(STDOUT, $label . ($def !== '' ? " [$def]" : '') . ': ');
$v = rtrim((string)fgets(STDIN), "\n");
return $v !== '' ? $v : $def;
}
function ask_secret(string $label): string {
fwrite(STDOUT, $label . ': ');
shell_exec('stty -echo 2>/dev/null');
$v = rtrim((string)fgets(STDIN), "\n");
shell_exec('stty echo 2>/dev/null');
fwrite(STDOUT, "\n");
return $v;
}
function fail(string $msg): void { fwrite(STDERR, "FEL: $msg\n"); exit(1); }
$c = cfg()['redis'] ?? [];
fwrite(STDOUT, "Redis: " . ($c['unix_socket'] ?? ($c['host'] ?? '?')) .
" prefix='" . ($c['prefix'] ?? '') . "' (från " . $LIB . ")\n\n");
$r = redis();
/* --- Callsign (samma regler som signup.php) --- */
$cs = base_call(ask('Callsign'));
if (strlen($cs) < 3 || strlen($cs) > 10 || !preg_match('/^[A-Z0-9]+$/', $cs)) {
fail('Callsign måste vara 3-10 tecken A-Z/0-9.');
}
if ((string)$r->get(call_index_key($cs)) !== '') fail("Callsign $cs finns redan.");
/* --- Profil --- */
$first = ask('Förnamn');
$last = ask('Efternamn');
$email = norm_email(ask('E-post'));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) fail('Ogiltig e-postadress.');
if ((string)$r->get(email_index_key($email)) !== '') fail("E-posten $email finns redan.");
$city = ask('Stad', '');
$state = ask('Region/State', '');
$country = ask('Land', '');
/* --- Adminnivå (1=vanlig, 2=admin, 3=super; jfr acct_admin_level/require_admin) --- */
$level = (int)ask('admin_level (1-3)', '3');
if ($level < 1 || $level > 3) fail('admin_level måste vara 1-3.');
/* --- Lösenord (dolt, dubbelt) --- */
$p1 = ask_secret('Lösenord');
$p2 = ask_secret('Lösenord igen');
if ($p1 !== $p2) fail('Lösenorden matchar inte.');
if (strlen($p1) < 6) fail('Lösenord måste vara minst 6 tecken.');
/* --- Skapa konto + index (samma layout som signup.php) --- */
$uuid = uuidv4();
$r->hMSet(acct_key($uuid), [
'callsign' => $cs,
'firstname' => $first,
'lastname' => $last,
'shortname' => $first,
'email' => $email,
'city' => $city,
'state' => $state,
'country' => $country,
'password_hash' => password_hash($p1, PASSWORD_DEFAULT),
'email_verified' => '1', // admin-provisionering: hoppa över mejlverifiering
'admin_level' => (string)$level,
'created_at' => (string)time(),
]);
$r->set(call_index_key($cs), $uuid);
$r->set(email_index_key($email), $uuid);
fwrite(STDOUT, "\nOK: konto '$cs' skapat (admin_level $level)\n");
fwrite(STDOUT, " uuid = $uuid\n");
fwrite(STDOUT, " logga in med callsign eller e-post + lösenordet ovan.\n");
+37
View File
@@ -0,0 +1,37 @@
'use strict';
function updateTalkerPopup(nodes, talkerMeta){
const popup = document.getElementById('talkerPopup');
const title = document.getElementById('talkerTitle');
const body = document.getElementById('talkerBody');
const calls = Object.keys(nodes || {});
const talkers = calls
.map(cs => ({ cs, n: nodes[cs] || {} }))
.filter(x => x.n.isTalker === true && Number(x.n.tg || 0) !== 0);
if (talkers.length === 0) {
popup.style.display = 'none';
body.innerHTML = '';
return;
}
title.textContent = `Talking now: ${talkers.length}`;
const lines = talkers.map(t => {
const tg = Number(t.n.tg || 0);
const nodeType = t.n.nodeClass || 'node';
const listeners = calls.filter(cs2 => includesTG(nodes[cs2], tg)).length;
const sysop = (t.n.sysop && t.n.sysop !== t.cs) ? ` <span class="text-secondary">(${t.n.sysop})</span>` : '';
// Bridge talker meta (via-gated): show the real talker behind the node
const m = (talkerMeta || {})[String(tg)];
const who = (m && m.callsign && (!m.via || m.via === t.cs))
? `<b>${m.callsign}</b> <span class="text-secondary">via</span> <b>${t.cs}</b>`
: `<b>${t.cs}</b>${sysop}`;
return `<div>• ${who} <span class="text-secondary">(${nodeType})</span> — TG <b>${tg}</b> — listeners <b>${listeners}</b></div>`;
});
body.innerHTML = lines.join('');
popup.style.display = 'inline-flex';
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
require __DIR__ . '/auth/lib.php';
// Public canonical TG list. Reads the SVX JSON file (source of truth; DMR is
// Redis, not this). No auth — the URL every box/program fetches. The internal
// 'owner'/'admins' fields are NOT exposed here (edit-authority, not display).
$p = cfg()['tglist_file'] ?? '/etc/svxlink/tglist.json';
$tgs = [];
if (is_string($p) && is_file($p)) {
$raw = @file_get_contents($p);
$j = json_decode((string)$raw, true);
if (is_array($j) && isset($j['tgs']) && is_array($j['tgs'])) {
foreach ($j['tgs'] as $o) {
if (!is_array($o)) continue;
$tgs[] = [
'tg' => (int)($o['tg'] ?? 0),
'name' => (string)($o['name'] ?? ''),
'system' => (string)($o['system'] ?? ''),
'allstar' => (string)($o['allstar'] ?? ''),
'mode' => (string)($o['mode'] ?? ''),
];
}
}
}
usort($tgs, fn($a, $b) => $a['tg'] <=> $b['tg']);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: public, max-age=60');
echo json_encode(['ok' => true, 'count' => count($tgs), 'tgs' => $tgs], JSON_UNESCAPED_SLASHES);
+187
View File
@@ -0,0 +1,187 @@
'use strict';
// TG Monitor — vilka klienter finns på varje TG, per mode + sida (SVX/DMR).
// Symmetrisk motsvarighet till DMR-webbens TG Monitor. Läser status-noderna
// (network.reflectors[].nodes + network.trunks[].nodes): reflektor-/lokala noder
// = SVX-sidan, trunk-noder = DMR-sidan (kom in via satellite-trunken). Dedupar
// per (tg, side, callsign) så en callsign kan finnas på flera TG och båda sidor
// utan att kollapsa (den globala callsign-keyingen i nodelist gör annars det).
// Bara TG med >=1 nod. Klick på en TG öppnar en egen detaljvy. Uppdateras live
// av status-refreshen (render() anropar renderTgMon när panelen är synlig).
(function () {
let _sel = null; // vald TG (detaljvy) eller null (lista)
let _page = 1; // sida i TG-listan
let _tgNames = {}; // tg -> name, from the canonical /tglist.php
function tgName(tg) { return _tgNames[tg] || ''; }
// "TG 3149050 — East Coast Reflector" when the list names the TG, else "TG n".
function tgLabel(tg) { const n = tgName(tg); return 'TG ' + tg + (n ? ' — ' + escHtml(n) : ''); }
// Rader/sida anpassas till FAKTISKT utrymme under tabellen (mät var den börjar)
// så den får plats utan att scrolla; ta i lite (footer-pager + rubrik).
// En bryggnod (chan_svx-asterisk, DMR/trunk-brygga …) är ingen riktig klient —
// den kopplar bara ihop system och gör listan missvisande. Känns igen på sw
// (chan_svx) eller nod-namnets brygg-suffix (-AC/-DMR/-T). Gäller ALLA sådana,
// både lokala och fjärran (t.ex. KT4ROY:s asterisk) — vi tillskriver dem bara
// aldrig fel ägare.
const BRIDGE_SUFFIX = /-(AC|DMR|T)$/i;
function isBridge(n) {
if (!n) return true;
const sw = String(n.sw || '').toLowerCase();
// chan_svx_sat = a real AllStar node over the satellite trunk → KEEP it.
if (sw.indexOf('chan_svx_sat') !== -1) return false;
if (sw.indexOf('chan_svx') !== -1) return true; // plain chan_svx = reflector bridge
const nm = String(n.name || n.node || n.id || '');
if (nm && BRIDGE_SUFFIX.test(nm)) return true;
return false;
}
// The real mode/type of a node, derived from its software (svx nodes have no
// "mode" field like the dmr side does). SVX / AllStar / DMR / YSF / …
function modeOf(n) {
const sw = String((n && n.sw) || '').toLowerCase();
if (sw.indexOf('chan_svx_sat') !== -1) return 'AllStar';
if (sw.indexOf('chan_svx') !== -1) return 'AllStar';
if (sw.indexOf('svxlink') !== -1) return 'SVX';
if (sw.indexOf('mmdvm') !== -1 || sw.indexOf('brandmeister') !== -1 || sw.indexOf('dmr') !== -1) return 'DMR';
if (sw.indexOf('ysf') !== -1) return 'YSF';
if (sw.indexOf('nxdn') !== -1) return 'NXDN';
if (sw.indexOf('m17') !== -1) return 'M17';
return (n && n.sw) ? String(n.sw) : 'SVX';
}
// Samla alla noder → {tg, callsign, mode, side, location}. En nod läggs under
// sin nuvarande TG OCH varje monitored TG (så "lyssnar på TG x" räknas med).
// Bryggnoder hoppas över (bara riktiga radioklienter).
function collect() {
const st = window.__svxportal_lastStatus || {};
const net = st.network || {};
const out = [];
const push = (n) => {
if (!n || isBridge(n)) return;
const cs = n.callsign || '';
if (!cs) return;
const m = modeOf(n); // real type from sw: SVX / AllStar / DMR / …
const tgs = new Set();
if (Number(n.tg) > 0) tgs.add(Number(n.tg));
if (Array.isArray(n.monitoredTGs)) n.monitoredTGs.forEach(t => { if (Number(t) > 0) tgs.add(Number(t)); });
tgs.forEach(tg => out.push({ tg, callsign: cs, mode: m, side: m, location: n.nodeLocation || n.location || '' }));
};
(Array.isArray(net.reflectors) ? net.reflectors : []).forEach(r => {
if (!r || !Array.isArray(r.nodes)) return;
if (r.type === 'local' || (r.type === 'reflector' && r.online && !r.firewalled))
r.nodes.forEach(n => push(n));
});
(Array.isArray(net.trunks) ? net.trunks : []).forEach(t => {
if (!t || !Array.isArray(t.nodes)) return;
t.nodes.forEach(n => push(n));
});
return out;
}
// Gruppera per TG, dedupa per (tg, side, callsign). Bara TG med noder.
function byTg() {
const map = new Map();
for (const n of collect()) {
if (!map.has(n.tg)) map.set(n.tg, new Map());
const key = n.side + '|' + n.callsign;
if (!map.get(n.tg).has(key)) map.get(n.tg).set(key, n);
}
const arr = [...map.entries()].map(([tg, m]) => ({ tg, nodes: [...m.values()] }))
.filter(e => e.nodes.length > 0);
arr.sort((a, b) => a.tg - b.tg);
return arr;
}
function sideBadge(m) {
const map = { SVX:'bg-info text-dark', AllStar:'bg-warning text-dark', DMR:'bg-secondary', YSF:'bg-success', NXDN:'bg-primary', M17:'bg-danger' };
return `<span class="badge ${map[m] || 'bg-info text-dark'}">${escHtml(m)}</span>`;
}
function renderTgMon() {
const body = document.getElementById('tgmonBody');
if (!body) return;
const entries = byTg();
if (_sel != null) {
const t = entries.find(e => e.tg === _sel);
const nodes = t ? t.nodes : [];
let inner;
if (!nodes.length) {
inner = '<div class="text-secondary">No clients on this talkgroup right now.</div>';
} else {
const byMode = {};
nodes.forEach(n => { (byMode[n.mode || '?'] = byMode[n.mode || '?'] || []).push(n); });
const rows = Object.keys(byMode).sort().map(m =>
byMode[m].slice().sort((a, b) => String(a.callsign).localeCompare(String(b.callsign)))
.map(n => `<tr><td class="mono"><b>${escHtml(n.callsign)}</b></td><td class="mono">${escHtml(m)}</td>` +
`<td>${sideBadge(n.side)}</td><td>${escHtml(n.location)}</td></tr>`).join('')
).join('');
inner = `<table class="table table-sm"><thead><tr><th>Callsign</th><th>Mode</th><th>Side</th><th>Location</th></tr></thead><tbody>${rows}</tbody></table>`;
}
body.innerHTML =
`<div class="d-flex justify-content-between align-items-center mb-2">` +
`<h5 class="mb-0">${tgLabel(_sel)} <span class="text-secondary">— ${nodes.length} node${nodes.length === 1 ? '' : 's'}</span></h5>` +
`<button class="btn btn-sm btn-outline-secondary" id="tgmonBack">&larr; Back</button></div>${inner}`;
const back = document.getElementById('tgmonBack');
if (back) back.addEventListener('click', () => { _sel = null; renderTgMon(); });
return;
}
// Search: a TG number (who's on TG x) OR a callsign/node (which TG(s) it's on).
const q = ((document.getElementById('tgmonSearch') || {}).value || '').trim().toLowerCase();
let list = entries;
if (q) list = entries.filter(e => String(e.tg).includes(q)
|| tgName(e.tg).toLowerCase().includes(q)
|| e.nodes.some(n => String(n.callsign || '').toLowerCase().includes(q)));
if (!list.length) {
body.innerHTML = '<div class="text-secondary">' +
(q ? 'No match — no such TG, and no online callsign/node like that right now.'
: 'No talkgroups with active clients.') + '</div>';
return;
}
const ps = pageSizeFor('tgmonBody');
const pages = Math.max(1, Math.ceil(list.length / ps));
if (_page > pages) _page = pages;
if (_page < 1) _page = 1;
const slice = list.slice((_page - 1) * ps, (_page - 1) * ps + ps);
body.innerHTML =
'<table class="table table-sm"><thead><tr><th>Talkgroup</th><th>Nodes total</th></tr></thead><tbody>' +
slice.map(e =>
`<tr class="tgmon-row" data-tg="${e.tg}" style="cursor:pointer;"><td><b>${tgLabel(e.tg)}</b></td><td>${e.nodes.length}</td></tr>`
).join('') + '</tbody></table>' + pagerHtml({ page: _page, pages, total: list.length, noun: 'TGs', cls: 'tgm-pg' });
body.querySelectorAll('.tgmon-row').forEach(tr =>
tr.addEventListener('click', () => { _sel = Number(tr.dataset.tg) || null; renderTgMon(); }));
body.querySelectorAll('.tgm-pg[data-pg]').forEach(b => b.addEventListener('click', () => {
const p = parseInt(b.dataset.pg, 10);
if (p >= 1 && p <= pages && p !== _page) { _page = p; renderTgMon(); }
}));
}
window.renderTgMon = renderTgMon;
// Canonical TG names from the shared list (/tglist.php). Best-effort: if it
// fails the monitor still works, just with bare TG numbers. Refreshed on load;
// names change rarely so a per-page-load fetch is enough.
(function loadTgNames() {
fetch('/tglist.php', { cache: 'no-store' })
.then(r => r.ok ? r.json() : null)
.then(j => {
if (!j || !Array.isArray(j.tgs)) return;
const m = {};
j.tgs.forEach(t => { if (t && t.tg) m[t.tg] = t.name || t.system || ''; });
_tgNames = m;
if (document.getElementById('tgmonPanel')?.style.display !== 'none') renderTgMon();
})
.catch(() => {});
})();
// Re-fit rows-per-page on resize.
window.addEventListener('resize', () => { if (document.getElementById('tgmonPanel')?.style.display !== 'none') renderTgMon(); });
// Live search (TG number or callsign/node).
document.addEventListener('input', (e) => { if (e.target && e.target.id === 'tgmonSearch') { _page = 1; renderTgMon(); } });
})();
+316
View File
@@ -0,0 +1,316 @@
'use strict';
// Monitor → Topology. Reads the complete structure nodelist collected
// (status.network) and draws it: the local reflector (self) in the centre, every
// other reflector in a ring (online + offline), trunks drawn between the servers
// they actually connect, plus the master→uplink link. Guarded throughout.
function escG(s){
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function normHost(h){
h = String(h == null ? '' : h).trim().toLowerCase();
if(!h) return '';
return h.replace(/^[a-z]+:\/\//,'').replace(/\/.*$/,'').replace(/:\d+$/,'').replace(/^\[|\]$/g,'');
}
// A label may arrive as a raw federation-seed status URL (http[s]://host:port/
// status). NEVER show host:port/path in the UI — it is an attack surface. Strip
// any URL-shaped label down to the hostname only; leave normal names untouched.
function displayName(s){
s = String(s == null ? '' : s);
return /^[a-z]+:\/\//i.test(s.trim()) ? normHost(s) : s;
}
const TOPO = { tx:0, ty:0, scale:1, drag:null };
const TOPO_W = 1500, TOPO_H = 1040;
function renderTopology(status){
const holder = document.getElementById('topologyBody');
if(!holder) return;
status = status || window.__svxportal_lastStatus || {};
const net = status.network || {};
const reflectors = Array.isArray(net.reflectors) ? net.reflectors : [];
const localRef = reflectors.find(r => r && r.type === 'local')
|| { name:'Local', nodes:[], trunks:[], type:'local', online:true };
const others = reflectors.filter(r => r && r.type === 'reflector' && !r.self);
const graphRefs = [localRef].concat(others);
// Always anchor the map on THIS reflector — the local server is the centre,
// every other reflector sits in the ring around it.
const centerRef = localRef;
const ringRefs = graphRefs.filter(r => r !== centerRef);
// Identities of known nodes — a trunk to one becomes an edge; else a leaf.
const idSet = new Set();
const addIds = (r) => {
[r.name, r.hostname, r.ip].forEach(k => { k = normHost(k); if(k) idSet.add(k); });
(Array.isArray(r.aliases) ? r.aliases : []).forEach(a => { const k = normHost(a); if(k) idSet.add(k); });
};
graphRefs.forEach(addIds);
reflectors.filter(r => r && r.self).forEach(addIds); // self IPs resolve to the local node
const isLeafTrunk = (t) => t && !idSet.has(normHost(t.host));
const leafCount = ringRefs.map(r => (Array.isArray(r.trunks) ? r.trunks : []).filter(isLeafTrunk).length);
// Layout DERIVED from the data: each ring node gets an arc for its (long) label
// + its leaves; the radius/canvas follow so nothing overlaps.
const maxLeaf = Math.max.apply(null, leafCount.concat([0]));
const PER_LEAF = 80;
const arcNeed = ringRefs.map((r, i) => Math.max(150, String(r.name || '').length * 8 + 40) + leafCount[i] * PER_LEAF);
const totalArc = arcNeed.reduce((a, b) => a + b, 0) || 1;
const R = Math.round(Math.max(360, totalArc / (2 * Math.PI)) / 50) * 50;
const leafReach = Math.round((130 + maxLeaf * 16) / 25) * 25;
const W = 2 * (R + leafReach + 100), H = W, cx = W/2, cy = H/2;
if(TOPO.w !== W || TOPO.h !== H){ TOPO.tx = 0; TOPO.ty = 0; TOPO.scale = 1; }
TOPO.w = W; TOPO.h = H;
const center = { id:'__center', ref:centerRef, central:true, isLocal:(centerRef === localRef), online:centerRef.online !== false, x:cx, y:cy };
let acc = 0;
const svr = ringRefs.map((r, i) => {
const a = -Math.PI/2 + (2*Math.PI) * ((acc + arcNeed[i]/2) / totalArc);
acc += arcNeed[i];
return { id:'s'+i, ref:r, isLocal:(r === localRef), online:r.online !== false, x:cx + Math.cos(a)*R, y:cy + Math.sin(a)*R };
});
const allNodes = [center].concat(svr);
const localNode = allNodes.find(n => n.isLocal) || center;
// Label the local (home) node with the SERVER's own name — the self hostname
// the crawl found (e.g. alabama.reflector-network.com), else the reflector
// name. NOT a trunk name (that pointed at a remote hub and read as "the hub is
// in the centre").
const selfHost = (reflectors.find(r => r && r.self && r.type !== 'local'
&& /[a-z].*\./i.test(String(r.name || ''))) || {}).name;
const localTrunkName = selfHost || localRef.name || 'Local';
if(localNode) localNode.label = localTrunkName;
// Resolve a trunk host -> a node, by name / hostname / ip / alias.
const index = {};
const put = (k, node) => { k = normHost(k); if(k) index[k] = node; };
for(const node of allNodes){
const r = node.ref || {};
put(r.name, node); put(r.hostname, node); put(r.ip, node);
(Array.isArray(r.aliases) ? r.aliases : []).forEach(a => put(a, node));
}
// Any trunk pointing at a self identity (the local reflector's public IP/hostname)
// → the local node, so its uplink connects as an edge instead of a dangling leaf.
reflectors.filter(r => r && r.self).forEach(r => {
put(r.name, localNode); put(r.hostname, localNode); put(r.ip, localNode);
(Array.isArray(r.aliases) ? r.aliases : []).forEach(a => put(a, localNode));
});
const resolve = (h) => index[normHost(h)] || null;
// Edges.
const edges = [], edgeByKey = {};
const addEdge = (a, b, ok, name) => {
if(!a || !b || a === b) return;
const k = [a.id, b.id].sort().join('|');
if(edgeByKey[k]){
if(!edgeByKey[k].name && name) edgeByKey[k].name = name; // keep the trunk name
if(ok) edgeByKey[k].ok = true;
return;
}
const e = { a, b, ok:!!ok, name: name || '' };
edgeByKey[k] = e; edges.push(e);
};
// node-count per trunk name (for remote-leaf labels).
const trunkCount = {};
(Array.isArray(net.trunks) ? net.trunks : []).forEach(t => { if(t) trunkCount[t.name] = t.node_count || 0; });
// Every node's trunks -> edge to the resolved peer, or a remote leaf. The local
// reflector's dangling uplink trunk isn't drawn as a leaf (the hub's trunk back
// to us already provides that named edge).
const leaves = [], leafSeen = new Set();
for(const node of allNodes){
const tks = Array.isArray(node.ref && node.ref.trunks) ? node.ref.trunks : [];
for(const t of tks){
if(!t) continue;
const other = resolve(t.host);
if(other){ addEdge(node, other, !!t.connected, t.name); continue; }
// The local reflector's dangling uplink trunk isn't drawn as a leaf (the
// hub's trunk back already gives that edge) — but its OWN satellite trunks
// (chan_svx_sat, e.g. KT4ROY-SAT1) ARE leaves off us and must show.
if(node.isLocal && t.kind !== 'satellite') continue;
if(leafSeen.has(t.name)) continue;
leafSeen.add(t.name);
leaves.push({ owner: node, t: t });
}
}
const curve = (a, b) => {
const mx=(a.x+b.x)/2, my=(a.y+b.y)/2, dx=b.x-a.x, dy=b.y-a.y;
const nx=-dy, ny=dx, len=Math.hypot(nx,ny)||1;
return `M${a.x.toFixed(1)},${a.y.toFixed(1)} Q${(mx+nx/len*26).toFixed(1)},${(my+ny/len*26).toFixed(1)} ${b.x.toFixed(1)},${b.y.toFixed(1)}`;
};
let edgeSvg = '';
for(const e of edges){
const stroke = e.ok ? '#22c55e' : '#ef4444';
edgeSvg += `<path d="${curve(e.a,e.b)}" fill="none" stroke="${stroke}" stroke-width="1.6" opacity="${e.ok?0.8:0.5}"`
+ (e.ok?'':' stroke-dasharray="5 4"') + ` marker-end="${e.ok?'url(#tArrowOn)':'url(#tArrowOff)'}"/>`;
if(e.name){
const mx=(e.a.x+e.b.x)/2, my=(e.a.y+e.b.y)/2, dx=e.b.x-e.a.x, dy=e.b.y-e.a.y, len=Math.hypot(dx,dy)||1;
const lx=mx + (-dy/len)*15, ly=my + (dx/len)*15;
edgeSvg += `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" fill="#7dd3fc" font-size="10" style="pointer-events:none">${escG(displayName(e.name))}</text>`;
}
}
const drawNode = (node) => {
const r = node.ref || {};
const fw = !!r.firewalled;
const online = node.online !== false;
const rad = node.central ? 38 : (node.isLocal ? 30 : 26);
const count = Array.isArray(r.nodes) ? r.nodes.length : 0;
let ring = fw ? '#f59e0b' : (online ? '#22c55e' : '#ef4444');
let fill = node.central ? '#12325c' : (fw ? '#2a220d' : (online ? '#0d1e35' : '#2a0f12'));
if(node.central) ring = '#93c5fd'; // hub
if(node.isLocal){ ring = '#7dd3fc'; fill = '#0a2540'; } // YOUR reflector
void online;
const sub = node.isLocal ? ('you · ' + count + ' nodes')
: (fw ? 'online (fw)' : (online ? (count + ' nodes') : 'offline'));
return `<g class="topo-node" data-id="${escG(node.id)}" transform="translate(${node.x.toFixed(1)},${node.y.toFixed(1)})" style="cursor:pointer">`
+ `<circle r="${rad}" fill="${fill}" stroke="${ring}" stroke-width="${node.isLocal ? 3 : 2}"/>`
+ `<text text-anchor="middle" y="-1" fill="#e2e8f0" font-size="13" font-weight="700" style="pointer-events:none">${escG(displayName(node.label || r.name || '?'))}</text>`
+ `<text text-anchor="middle" y="14" fill="#94a3b8" font-size="10" style="pointer-events:none">${escG(sub)}</text>`
+ `</g>`;
};
let nodeSvg = '';
for(const node of allNodes) nodeSvg += drawNode(node);
// Remote leaves: every trunk whose far end isn't a known reflector (incl. big
// trunk networks like TRUNK_DE_IT and satellites) is drawn as a leaf off its
// owner, labelled name + carried-node count. So all trunks are visible.
let leafSvg = '';
const leavesByOwner = new Map();
for(const lf of leaves){
if(!leavesByOwner.has(lf.owner.id)) leavesByOwner.set(lf.owner.id, []);
leavesByOwner.get(lf.owner.id).push(lf);
}
for(const arr of leavesByOwner.values()){
const owner = arr[0].owner;
const away = Math.atan2(owner.y - cy, owner.x - cx) || -Math.PI/2;
const nLeaf = arr.length;
const fan = Math.min(Math.PI*1.4, nLeaf * 0.5); // wide fan when there are many
arr.forEach((lf, i) => {
const t = lf.t;
const isSat = t.kind === 'satellite';
const ang = away + (nLeaf === 1 ? 0 : (-fan/2 + fan * i/(nLeaf-1)));
const rr = leafReach + (i % 2) * 36; // stagger radius so adjacent labels don't overlap
const x = owner.x + Math.cos(ang)*rr, y = owner.y + Math.sin(ang)*rr;
const col = isSat ? '#a78bfa' : (t.connected ? '#22c55e' : '#ef4444');
const cnt = trunkCount[t.name] || 0;
const label = escG(displayName(t.name)) + (isSat ? ' (sat)' : (cnt ? ' (' + cnt + ')' : ''));
leafSvg += `<line x1="${owner.x.toFixed(1)}" y1="${owner.y.toFixed(1)}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${col}" stroke-width="1.3" stroke-dasharray="4 3" opacity="0.75"/>`;
leafSvg += `<g transform="translate(${x.toFixed(1)},${y.toFixed(1)})"><circle r="7" fill="${isSat?'#221a33':'#0d1e35'}" stroke="${col}" stroke-width="1.5"/>`
+ `<text x="11" y="3" fill="#cbd5e1" font-size="10" style="pointer-events:none">${label}</text></g>`;
});
}
const onlineCount = svr.filter(n => n.online).length;
const legend = `<div style="display:flex;gap:14px;flex-wrap:wrap;align-items:center;font-size:11px;color:#94a3b8">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#0a2540;border:2px solid #7dd3fc;vertical-align:-1px"></span> you (${escG(localTrunkName)}) — centre</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#0d1e35;border:2px solid #22c55e;vertical-align:-1px"></span> online</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2a220d;border:2px solid #f59e0b;vertical-align:-1px"></span> online (firewalled)</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2a0f12;border:2px solid #ef4444;vertical-align:-1px"></span> offline</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#221a33;border:2px solid #a78bfa;vertical-align:-1px"></span> satellite</span>
<span style="color:#64748b">click a reflector for details</span>
<button id="topoReset" type="button" style="font-size:11px;padding:2px 9px;border:1px solid #2d5a9e;border-radius:4px;background:#0a1628;color:#7dd3fc;cursor:pointer">&#8635; Reset view</button>
<span style="margin-left:auto">Reflectors: <b style="color:#7dd3fc">${svr.length}</b> (${onlineCount} online) · Links: <b style="color:#7dd3fc">${edges.length}</b></span>
</div>`;
holder.innerHTML = `${legend}
<div style="position:relative;border:1px solid #1e3a5f;border-radius:8px;overflow:hidden;background:#060d1a;margin-top:8px">
<svg id="topoSvg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:72vh;touch-action:none;user-select:none">
<defs>
<marker id="tArrowOn" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#22c55e"/></marker>
<marker id="tArrowOff" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#ef4444"/></marker>
</defs>
<g id="topoRoot" transform="translate(${TOPO.tx},${TOPO.ty}) scale(${TOPO.scale})">
<g>${edgeSvg}</g><g>${leafSvg}</g><g>${nodeSvg}</g>
</g>
</svg>
<div id="topoPopup" style="display:none;position:absolute;max-width:320px;max-height:60%;overflow:auto;background:#0a1628;border:1px solid #2d5a9e;border-radius:8px;padding:10px 12px;font-size:12px;color:#e2e8f0;box-shadow:0 4px 16px rgba(0,0,0,.6);z-index:5"></div>
</div>`;
wireTopology(allNodes);
}
function reflectorPopupHtml(r){
r = r || {};
const online = r.online !== false;
const fw = !!r.firewalled;
const stColor = fw ? '#f59e0b' : (online ? '#22c55e' : '#ef4444');
const stText = fw ? 'online (firewalled)' : (online ? 'online' : 'offline');
// NAME ONLY — never expose host connection details (IP/port/hostname/alias/
// status URL). Exposing them makes the reflector a DDoS/attack target.
let h = `<div style="font-weight:700;margin-bottom:4px">${escG(displayName(r.name || '?'))} <span style="color:${stColor}">(${stText})</span></div>`;
const nodes = Array.isArray(r.nodes) ? r.nodes : [];
h += `<div style="font-size:11px;margin-bottom:2px">Nodes: <b>${nodes.length}</b></div>`;
if(nodes.length){
const rows = nodes.slice().sort((a,b)=> String(a&&a.callsign).localeCompare(String(b&&b.callsign)))
.map(n => `<div class="mono" style="font-size:11px">${escG(n.callsign)}${(n.tg? ' · TG'+escG(n.tg):'')}${(n.location? ' · '+escG(n.location):'')}</div>`).join('');
h += `<div style="margin-top:2px">${rows}</div>`;
}
return h;
}
function wireTopology(allNodes){
const svg = document.getElementById('topoSvg');
const root = document.getElementById('topoRoot');
const popup = document.getElementById('topoPopup');
if(!svg || !root) return;
const apply = () => root.setAttribute('transform', `translate(${TOPO.tx},${TOPO.ty}) scale(${TOPO.scale})`);
svg.addEventListener('wheel', (e) => {
e.preventDefault();
const f = e.deltaY < 0 ? 1.12 : 1/1.12;
const rect = svg.getBoundingClientRect();
const mx = (e.clientX-rect.left)/rect.width*(TOPO.w||1500), my = (e.clientY-rect.top)/rect.height*(TOPO.h||1500);
const ns = Math.min(4, Math.max(0.3, TOPO.scale*f));
TOPO.tx = mx - (mx-TOPO.tx)*(ns/TOPO.scale);
TOPO.ty = my - (my-TOPO.ty)*(ns/TOPO.scale);
TOPO.scale = ns; apply();
}, { passive:false });
svg.addEventListener('pointerdown', (e) => {
if(e.target.closest('.topo-node')) return;
TOPO.drag = { x:e.clientX, y:e.clientY, tx:TOPO.tx, ty:TOPO.ty };
svg.setPointerCapture(e.pointerId);
if(popup) popup.style.display='none';
});
svg.addEventListener('pointermove', (e) => {
if(!TOPO.drag) return;
const rect = svg.getBoundingClientRect();
TOPO.tx = TOPO.drag.tx + (e.clientX-TOPO.drag.x)*((TOPO.w||1500)/rect.width);
TOPO.ty = TOPO.drag.ty + (e.clientY-TOPO.drag.y)*((TOPO.h||1500)/rect.height);
apply();
});
svg.addEventListener('pointerup', () => { TOPO.drag = null; });
const rb = document.getElementById('topoReset');
if(rb) rb.addEventListener('click', (e) => {
e.stopPropagation();
TOPO.tx = 0; TOPO.ty = 0; TOPO.scale = 1;
renderTopology();
});
const byId = {}; for(const n of allNodes) byId[n.id] = n;
svg.querySelectorAll('.topo-node').forEach(g => {
g.addEventListener('click', (e) => {
e.stopPropagation();
const node = byId[g.getAttribute('data-id')];
if(!node) return;
showTopoPopup(g, reflectorPopupHtml(node.ref));
});
});
if(popup) popup.addEventListener('click', () => popup.style.display='none');
}
function showTopoPopup(g, html){
const popup = document.getElementById('topoPopup'), svg = document.getElementById('topoSvg');
if(!popup || !svg) return;
const rect = svg.getBoundingClientRect(), gb = g.getBoundingClientRect();
popup.innerHTML = html;
popup.style.display = 'block';
popup.style.left = Math.max(4, Math.min(gb.left-rect.left+10, rect.width-280)) + 'px';
popup.style.top = Math.max(4, gb.top-rect.top+10) + 'px';
}
window.renderTopology = renderTopology;
+126
View File
@@ -0,0 +1,126 @@
'use strict';
// Monitor → Trunks: ONLY the trunks THIS reflector is configured to trunk against,
// read from the local svxreflector.conf (nodelist /api/local-trunks). The whole
// network is on Monitor → Topology instead. No host/IP is ever shown. Footer
// pager (first/prev/next/last, rows/page fit the screen) + regex search.
(function () {
let data = { trunks: [] };
let started = false, timer = null, _page = 1;
const EXPANDED = new Set(); // trunk names whose TG list is expanded (persists across polls)
function trunkDot(ok){
return ok
? '<span style="color:#00ff00;">&#9679;</span>'
: '<span style="color:#ff4040;">&#9679;</span>';
}
// Rows/page fit the space under the table so it never scrolls (same as the
// node / TG pages).
// Search matcher: the term is a case-insensitive regex (^se|us$, sm[0-9]).
// Invalid regex falls back to a plain substring match. Empty → null.
function trunkMatcher(term){
term = String(term == null ? '' : term).trim();
if(!term) return null;
try {
const re = new RegExp(term, 'i');
return (v) => re.test(String(v == null ? '' : v));
} catch(_) {
const lc = term.toLowerCase();
return (v) => String(v == null ? '' : v).toLowerCase().indexOf(lc) !== -1;
}
}
async function fetchData(){
try {
const r = await fetch('/svxportal/api/local-trunks', { cache: 'no-store' });
if(r.ok){ data = await r.json(); render(); }
} catch(_){}
}
function render(){
const body = document.getElementById('trunksBody');
if(!body) return;
let trunks = Array.isArray(data.trunks) ? data.trunks : [];
const m = trunkMatcher((document.getElementById('trunksSearch') || {}).value);
if(m) trunks = trunks.filter(t => t && m(t.name));
trunks = trunks.slice().sort((a,b)=> String(a && a.name).localeCompare(String(b && b.name)));
if(!trunks.length){
body.innerHTML = '<div class="text-secondary">' +
(m ? 'No matching trunks.' : 'No trunks configured in svxreflector.conf.') + '</div>';
return;
}
const ps = pageSizeFor('trunksBody');
const pages = Math.max(1, Math.ceil(trunks.length / ps));
if(_page > pages) _page = pages;
if(_page < 1) _page = 1;
const slice = trunks.slice((_page - 1) * ps, (_page - 1) * ps + ps);
let html = `<div class="table-responsive mb-2"><table class="table table-sm mb-0 data-table" style="table-layout:fixed;width:100%">
<colgroup><col style="width:56%"><col style="width:26%"><col style="width:18%"></colgroup>
<thead><tr><th>Name</th><th>Status</th><th class="nowrap">Nodes</th></tr></thead><tbody>`;
for(const t of slice){
if(!t) continue;
const ok = !!t.connected;
// Satellite trunks carry N talkgroups under the SAME trunk name. The row is a
// collapsible header: a chevron + "(N TG)"; click to expand/collapse the TGs.
const tgs = Array.isArray(t.tgs) ? t.tgs : [];
const hasTgs = tgs.length > 0;
const open = hasTgs && EXPANDED.has(t.name);
const chev = hasTgs
? `<span style="display:inline-block;width:1em;color:#7dd3fc">${open ? '&#9662;' : '&#9656;'}</span> `
: '';
html += `<tr${hasTgs ? ` class="trk-row" data-trunk="${escHtml(t.name)}" style="cursor:pointer"` : ''}>
<td class="mono wrap">${chev}${escHtml(t.name)}${hasTgs ? ` <span class="small text-secondary">(${tgs.length} TG)</span>` : ''}</td>
<td class="nowrap">${trunkDot(ok)} ${ok ? 'online' : 'offline'}</td>
<td class="mono">${Number(t.nodes || 0)}</td>
</tr>`;
if(open){
for(const g of tgs){
html += `<tr class="trk-tg"><td class="small text-secondary" style="padding-left:2em">`
+ escHtml(g.name || ('TG ' + (g.tg || ''))) + (g.tg ? ' · TG ' + escHtml(g.tg) : '')
+ `</td><td></td><td></td></tr>`;
}
}
}
html += `</tbody></table></div>` + pagerHtml({ page: _page, pages, total: trunks.length, noun: 'trunks', cls: 'trk-pg' });
body.innerHTML = html;
body.querySelectorAll('.trk-pg[data-pg]').forEach(b => b.addEventListener('click', () => {
const p = parseInt(b.dataset.pg, 10);
if(p >= 1 && p <= pages && p !== _page){ _page = p; render(); }
}));
// Expand/collapse a satellite trunk's talkgroups on click.
body.querySelectorAll('.trk-row[data-trunk]').forEach(r => r.addEventListener('click', () => {
const n = r.dataset.trunk;
if(EXPANDED.has(n)) EXPANDED.delete(n); else EXPANDED.add(n);
render();
}));
}
// Called by app.js (nav click + every status poll while the panel is visible).
window.renderTrunks = function(){
if(!started){
started = true;
fetchData();
if(!timer) timer = setInterval(() => {
if(document.getElementById('trunksPanel')?.style.display !== 'none') fetchData();
}, 5000);
}
render();
};
// Live regex search (reset to page 1) + re-fit on resize.
document.addEventListener('input', function(e){
if(e.target && e.target.id === 'trunksSearch'){ _page = 1; render(); }
});
window.addEventListener('resize', function(){
if(document.getElementById('trunksPanel')?.style.display !== 'none') render();
});
})();