From 25b481e2aeaea70ad47e009abdf7e134f45c568f Mon Sep 17 00:00:00 2001 From: bjorne Date: Sat, 26 Sep 2026 23:07:05 +0200 Subject: [PATCH] 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 --- .gitignore | 11 + AUTH_README.txt | 16 + INSTALL.md | 251 +++++++ README.md | 18 + app.js | 233 ++++++ auth/admin.js | 847 +++++++++++++++++++++ auth/admin.php | 154 ++++ auth/admin_accounts.php | 303 ++++++++ auth/admin_bans.js | 65 ++ auth/admin_bans.php | 43 ++ auth/admin_bans_api.php | 39 + auth/admin_certs.js | 154 ++++ auth/admin_certs.php | 62 ++ auth/admin_config.js | 55 ++ auth/admin_config.php | 40 + auth/admin_control.js | 38 + auth/admin_control.php | 29 + auth/admin_mqtt.php | 64 ++ auth/admin_nodeauth.js | 322 ++++++++ auth/admin_nodeauth.php | 118 +++ auth/admin_nodes.php | 130 ++++ auth/admin_reflector_api.php | 349 +++++++++ auth/admin_tglist.js | 155 ++++ auth/admin_tglist.php | 69 ++ auth/config.php.example | 23 + auth/consent.php | 20 + auth/events.php | 204 ++++++ auth/footer.php | 9 + auth/forgot_password.php | 55 ++ auth/header.php | 95 +++ auth/lib.php | 443 +++++++++++ auth/lib/acl.php | 15 + auth/lib/html.php | 10 + auth/lib/http.php | 66 ++ auth/lib/redis.php | 89 +++ auth/lib/shell.php | 7 + auth/login.php | 63 ++ auth/logout.php | 14 + auth/me.php | 24 + auth/my_nodes.php | 76 ++ auth/node_admins_api.php | 54 ++ auth/node_auth_api.php | 326 +++++++++ auth/node_cmd_api.php | 129 ++++ auth/password_change.php | 21 + auth/profile.js | 134 ++++ auth/profile.php | 275 +++++++ auth/profile_nodes.js | 507 +++++++++++++ auth/profile_update.php | 30 + auth/resend_verification.php | 53 ++ auth/reset_password.php | 81 ++ auth/signup.php | 100 +++ auth/tgadmin.js | 287 ++++++++ auth/tgadmin.php | 25 + auth/tglist_api.php | 217 ++++++ auth/tgrequest.js | 82 +++ auth/tgrequest.php | 45 ++ auth/theme_prefs_update.php | 51 ++ auth/user_events.php | 17 + auth/verify.php | 41 ++ auth_ui.js | 664 +++++++++++++++++ changelog.json | 569 ++++++++++++++ clients_view.js | 172 +++++ common.js | 8 + ctcss_view.js | 978 +++++++++++++++++++++++++ help.html | 718 ++++++++++++++++++ help.js | 24 + hoverinfo.js | 82 +++ index.html | 707 ++++++++++++++++++ install/nginx-svxportal.conf | 101 +++ install/php-fpm-svxadmin.conf | 18 + install/redis-svxportal.conf | 21 + install/tmpfiles-svxportal-tglist.conf | 5 + lastheard_view.js | 98 +++ lib/api.js | 53 ++ lib/auth_ui.js | 53 ++ lib/changelog.js | 80 ++ lib/fmt.js | 95 +++ lib/pager.js | 84 +++ lib/theme.js | 241 ++++++ lib/ui_field.js | 67 ++ lib/ui_list.js | 264 +++++++ lib/ui_msg.js | 31 + list_view.js | 33 + monitor_view.js | 128 ++++ privacy | 1 + robots.txt | 3 + sitemap.xml | 13 + svx-mkadmin.php | 96 +++ talker_view.js | 37 + tglist.php | 31 + tgmon_view.js | 187 +++++ topology_view.js | 316 ++++++++ trunks_view.js | 126 ++++ 93 files changed, 13357 insertions(+) create mode 100644 .gitignore create mode 100644 AUTH_README.txt create mode 100644 INSTALL.md create mode 100644 README.md create mode 100644 app.js create mode 100644 auth/admin.js create mode 100644 auth/admin.php create mode 100644 auth/admin_accounts.php create mode 100644 auth/admin_bans.js create mode 100644 auth/admin_bans.php create mode 100644 auth/admin_bans_api.php create mode 100644 auth/admin_certs.js create mode 100644 auth/admin_certs.php create mode 100644 auth/admin_config.js create mode 100644 auth/admin_config.php create mode 100644 auth/admin_control.js create mode 100644 auth/admin_control.php create mode 100644 auth/admin_mqtt.php create mode 100644 auth/admin_nodeauth.js create mode 100644 auth/admin_nodeauth.php create mode 100644 auth/admin_nodes.php create mode 100644 auth/admin_reflector_api.php create mode 100644 auth/admin_tglist.js create mode 100644 auth/admin_tglist.php create mode 100644 auth/config.php.example create mode 100644 auth/consent.php create mode 100644 auth/events.php create mode 100644 auth/footer.php create mode 100644 auth/forgot_password.php create mode 100644 auth/header.php create mode 100644 auth/lib.php create mode 100644 auth/lib/acl.php create mode 100644 auth/lib/html.php create mode 100644 auth/lib/http.php create mode 100644 auth/lib/redis.php create mode 100644 auth/lib/shell.php create mode 100644 auth/login.php create mode 100644 auth/logout.php create mode 100644 auth/me.php create mode 100644 auth/my_nodes.php create mode 100644 auth/node_admins_api.php create mode 100644 auth/node_auth_api.php create mode 100644 auth/node_cmd_api.php create mode 100644 auth/password_change.php create mode 100644 auth/profile.js create mode 100644 auth/profile.php create mode 100644 auth/profile_nodes.js create mode 100644 auth/profile_update.php create mode 100644 auth/resend_verification.php create mode 100644 auth/reset_password.php create mode 100644 auth/signup.php create mode 100644 auth/tgadmin.js create mode 100644 auth/tgadmin.php create mode 100644 auth/tglist_api.php create mode 100644 auth/tgrequest.js create mode 100644 auth/tgrequest.php create mode 100644 auth/theme_prefs_update.php create mode 100644 auth/user_events.php create mode 100644 auth/verify.php create mode 100644 auth_ui.js create mode 100644 changelog.json create mode 100644 clients_view.js create mode 100644 common.js create mode 100644 ctcss_view.js create mode 100644 help.html create mode 100644 help.js create mode 100644 hoverinfo.js create mode 100644 index.html create mode 100644 install/nginx-svxportal.conf create mode 100644 install/php-fpm-svxadmin.conf create mode 100644 install/redis-svxportal.conf create mode 100644 install/tmpfiles-svxportal-tglist.conf create mode 100644 lastheard_view.js create mode 100644 lib/api.js create mode 100644 lib/auth_ui.js create mode 100644 lib/changelog.js create mode 100644 lib/fmt.js create mode 100644 lib/pager.js create mode 100644 lib/theme.js create mode 100644 lib/ui_field.js create mode 100644 lib/ui_list.js create mode 100644 lib/ui_msg.js create mode 100644 list_view.js create mode 100644 monitor_view.js create mode 100644 privacy create mode 100644 robots.txt create mode 100644 sitemap.xml create mode 100644 svx-mkadmin.php create mode 100644 talker_view.js create mode 100644 tglist.php create mode 100644 tgmon_view.js create mode 100644 topology_view.js create mode 100644 trunks_view.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad3b8e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Secrets — never commit +auth/config.php + +# Backups / editor cruft +*.bak +*.bak.* +*~ +*.orig +*.save +*.swp +*.bak-* diff --git a/AUTH_README.txt b/AUTH_README.txt new file mode 100644 index 0000000..4208ed5 --- /dev/null +++ b/AUTH_README.txt @@ -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: (HASH) includes admin_level 1..3 +- registry:svxlink:acct_by_email: -> uuid +- registry:svxlink:call: -> uuid +- registry:svxlink:acct_calls: (SET) callsigns +- registry:svxlink:node: -> uuid +- registry:svxlink:acct_nodes: (SET) nodes diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..65bd4ff --- /dev/null +++ b/INSTALL.md @@ -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 ``/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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1688714 --- /dev/null +++ b/README.md @@ -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. diff --git a/app.js b/app.js new file mode 100644 index 0000000..c84a118 --- /dev/null +++ b/app.js @@ -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'); + diff --git a/auth/admin.js b/auth/admin.js new file mode 100644 index 0000000..1556773 --- /dev/null +++ b/auth/admin.js @@ -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;">«</button>'; + html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + (currentPage-1) + '" ' + (currentPage===1?'disabled':'') + ' style="padding:2px 8px;">‹</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;">›</button>'; + html += '<button class="btn btn-outline-secondary btn-sm pg-btn" data-pg="' + totalPages + '" ' + (currentPage===totalPages?'disabled':'') + ' style="padding:2px 8px;">»</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); + }); + +}); diff --git a/auth/admin.php b/auth/admin.php new file mode 100644 index 0000000..ba667e4 --- /dev/null +++ b/auth/admin.php @@ -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 & 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'; +?> diff --git a/auth/admin_accounts.php b/auth/admin_accounts.php new file mode 100644 index 0000000..dfdcac6 --- /dev/null +++ b/auth/admin_accounts.php @@ -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); diff --git a/auth/admin_bans.js b/auth/admin_bans.js new file mode 100644 index 0000000..a6cf0a3 --- /dev/null +++ b/auth/admin_bans.js @@ -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(); +}); diff --git a/auth/admin_bans.php b/auth/admin_bans.php new file mode 100644 index 0000000..b0eca64 --- /dev/null +++ b/auth/admin_bans.php @@ -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'; +?> diff --git a/auth/admin_bans_api.php b/auth/admin_bans_api.php new file mode 100644 index 0000000..1f58b0f --- /dev/null +++ b/auth/admin_bans_api.php @@ -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); diff --git a/auth/admin_certs.js b/auth/admin_certs.js new file mode 100644 index 0000000..17443bb --- /dev/null +++ b/auth/admin_certs.js @@ -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); }); +}); diff --git a/auth/admin_certs.php b/auth/admin_certs.php new file mode 100644 index 0000000..51b4d88 --- /dev/null +++ b/auth/admin_certs.php @@ -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'; +?> diff --git a/auth/admin_config.js b/auth/admin_config.js new file mode 100644 index 0000000..fd3c096 --- /dev/null +++ b/auth/admin_config.js @@ -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); }); +}); diff --git a/auth/admin_config.php b/auth/admin_config.php new file mode 100644 index 0000000..3aaa900 --- /dev/null +++ b/auth/admin_config.php @@ -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 & 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'; +?> diff --git a/auth/admin_control.js b/auth/admin_control.js new file mode 100644 index 0000000..aa74a67 --- /dev/null +++ b/auth/admin_control.js @@ -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?'); }); +}); diff --git a/auth/admin_control.php b/auth/admin_control.php new file mode 100644 index 0000000..49ff19d --- /dev/null +++ b/auth/admin_control.php @@ -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'; +?> diff --git a/auth/admin_mqtt.php b/auth/admin_mqtt.php new file mode 100644 index 0000000..26504a5 --- /dev/null +++ b/auth/admin_mqtt.php @@ -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'; ?> diff --git a/auth/admin_nodeauth.js b/auth/admin_nodeauth.js new file mode 100644 index 0000000..6990ba5 --- /dev/null +++ b/auth/admin_nodeauth.js @@ -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; }); +}); diff --git a/auth/admin_nodeauth.php b/auth/admin_nodeauth.php new file mode 100644 index 0000000..d5bf156 --- /dev/null +++ b/auth/admin_nodeauth.php @@ -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'; +?> diff --git a/auth/admin_nodes.php b/auth/admin_nodes.php new file mode 100644 index 0000000..c4aa463 --- /dev/null +++ b/auth/admin_nodes.php @@ -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]); diff --git a/auth/admin_reflector_api.php b/auth/admin_reflector_api.php new file mode 100644 index 0000000..2cbf766 --- /dev/null +++ b/auth/admin_reflector_api.php @@ -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); +} diff --git a/auth/admin_tglist.js b/auth/admin_tglist.js new file mode 100644 index 0000000..04950d9 --- /dev/null +++ b/auth/admin_tglist.js @@ -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' ? '▲ asc' : '▼ 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')); + }); +}); diff --git a/auth/admin_tglist.php b/auth/admin_tglist.php new file mode 100644 index 0000000..3b43a1e --- /dev/null +++ b/auth/admin_tglist.php @@ -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 → 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">▲ 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'; +?> diff --git a/auth/config.php.example b/auth/config.php.example new file mode 100644 index 0000000..0e6d4b6 --- /dev/null +++ b/auth/config.php.example @@ -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', +]; diff --git a/auth/consent.php b/auth/consent.php new file mode 100644 index 0000000..5bcc103 --- /dev/null +++ b/auth/consent.php @@ -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]); diff --git a/auth/events.php b/auth/events.php new file mode 100644 index 0000000..240ed73 --- /dev/null +++ b/auth/events.php @@ -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(); + } +} diff --git a/auth/footer.php b/auth/footer.php new file mode 100644 index 0000000..8fd4149 --- /dev/null +++ b/auth/footer.php @@ -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);"> + © 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> diff --git a/auth/forgot_password.php b/auth/forgot_password.php new file mode 100644 index 0000000..ff7bb5c --- /dev/null +++ b/auth/forgot_password.php @@ -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.']); diff --git a/auth/header.php b/auth/header.php new file mode 100644 index 0000000..339d540 --- /dev/null +++ b/auth/header.php @@ -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' ?> + + + + + +
diff --git a/auth/lib.php b/auth/lib.php new file mode 100644 index 0000000..d2cf70e --- /dev/null +++ b/auth/lib.php @@ -0,0 +1,443 @@ + 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: 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(); +} diff --git a/auth/lib/acl.php b/auth/lib/acl.php new file mode 100644 index 0000000..d8d9b60 --- /dev/null +++ b/auth/lib/acl.php @@ -0,0 +1,15 @@ += $acting_level) { + json_out(['ok' => false, 'error' => 'insufficient_privilege'], 403); + } +} diff --git a/auth/lib/html.php b/auth/lib/html.php new file mode 100644 index 0000000..063581a --- /dev/null +++ b/auth/lib/html.php @@ -0,0 +1,10 @@ + 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); +} diff --git a/auth/lib/redis.php b/auth/lib/redis.php new file mode 100644 index 0000000..ca1e494 --- /dev/null +++ b/auth/lib/redis.php @@ -0,0 +1,89 @@ + 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; +} diff --git a/auth/lib/shell.php b/auth/lib/shell.php new file mode 100644 index 0000000..8344959 --- /dev/null +++ b/auth/lib/shell.php @@ -0,0 +1,7 @@ +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]); diff --git a/auth/logout.php b/auth/logout.php new file mode 100644 index 0000000..4c26fce --- /dev/null +++ b/auth/logout.php @@ -0,0 +1,14 @@ +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); diff --git a/auth/my_nodes.php b/auth/my_nodes.php new file mode 100644 index 0000000..1b7a3f2 --- /dev/null +++ b/auth/my_nodes.php @@ -0,0 +1,76 @@ + 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]); diff --git a/auth/node_admins_api.php b/auth/node_admins_api.php new file mode 100644 index 0000000..ea1216a --- /dev/null +++ b/auth/node_admins_api.php @@ -0,0 +1,54 @@ +). 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]); diff --git a/auth/node_auth_api.php b/auth/node_auth_api.php new file mode 100644 index 0000000..5440f29 --- /dev/null +++ b/auth/node_auth_api.php @@ -0,0 +1,326 @@ + 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 .). +// 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); +} diff --git a/auth/node_cmd_api.php b/auth/node_cmd_api.php new file mode 100644 index 0000000..88f7880 --- /dev/null +++ b/auth/node_cmd_api.php @@ -0,0 +1,129 @@ + 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 = 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]); diff --git a/auth/password_change.php b/auth/password_change.php new file mode 100644 index 0000000..6a74806 --- /dev/null +++ b/auth/password_change.php @@ -0,0 +1,21 @@ +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]); diff --git a/auth/profile.js b/auth/profile.js new file mode 100644 index 0000000..222951e --- /dev/null +++ b/auth/profile.js @@ -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 = + 'UUID: ' + ME.uuid + '' + + '' + lvlText + star + '' + + 'Callsign: ' + (ME.callsign||'') + ''; + 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); + } + }); +}); diff --git a/auth/profile.php b/auth/profile.php new file mode 100644 index 0000000..dcc9492 --- /dev/null +++ b/auth/profile.php @@ -0,0 +1,275 @@ +del(call_index_key($old_cs)); + redis()->set(call_index_key($new_cs), $uuid); + } + } + if (isset($updates['email'])) { + $old_em = strtolower(trim($me['email'] ?? '')); + $new_em = norm_email($updates['email']); + $updates['email'] = $new_em; + if ($old_em !== $new_em) { + if ($old_em !== '') redis()->del(email_index_key($old_em)); + redis()->set(email_index_key($new_em), $uuid); + } + } + if (!empty($updates)) { + redis()->hMSet(acct_key($uuid), $updates); + $me = acct_get($uuid); + $msg = 'Saved!'; + } + } elseif ($action === 'change_password') { + $old = (string)($_POST['old_password'] ?? ''); + $new = (string)($_POST['new_password'] ?? ''); + $new2= (string)($_POST['new_password2'] ?? ''); + $hash = (string)($me['password_hash'] ?? ''); + if ($old === '' || $new === '') { $msg = 'Fill all fields'; $err = true; } + elseif ($new !== $new2) { $msg = 'Passwords do not match'; $err = true; } + elseif (strlen($new) < 6) { $msg = 'Password too short (min 6 chars)'; $err = true; } + elseif (!password_verify($old, $hash)) { $msg = 'Invalid current password'; $err = true; } + else { + redis()->hSet(acct_key($uuid), 'password_hash', password_hash($new, PASSWORD_DEFAULT)); + $msg = 'Password changed!'; + } + } +} + +// h() comes from auth/lib/html.php. + +$call = (string)($me['callsign'] ?? ''); +$first = (string)($me['firstname'] ?? ''); +$last = (string)($me['lastname'] ?? ''); +$short = (string)($me['shortname'] ?? ''); +$email = (string)($me['email'] ?? ''); +$city = (string)($me['city'] ?? ''); +$state = (string)($me['state'] ?? ''); +$country = (string)($me['country'] ?? ''); +$flags = acct_flags_from($me); +$admin = acct_level_from_flags($flags); +$email_verified = ((($me['email_verified'] ?? '0')) === '1'); +$parts = []; +foreach ([1=>'unreg',2=>'reg',4=>'normal',8=>'lvl1',16=>'lvl2',32=>'lvl3'] as $bit => $name) { + if ($flags & $bit) $parts[] = $name; +} +$lvlText = $parts ? implode(', ', $parts) : '-'; +$lvlColor = $admin >= 2 ? '#ffc800' : 'rgba(0,255,255,.75)'; +$star = $admin >= 2 ? ' ★' : ''; + +// Callsigns tab (read-only): the account's callsigns (primary + admin-added +// extras/club calls) and the nodes where one of them is a listed node admin. +// Only site admins can change these lists. +$my_calls = acct_all_calls($uuid); +$extra_calls = array_values(array_filter(array_map( + fn($c) => strtoupper(trim((string)$c)), + redis()->sMembers(acct_calls_key($uuid)) ?: [] +))); +sort($extra_calls); +$admin_nodes = []; +$rds = redis(); +$cursor = null; +do { + $res = $rds->scan($cursor, rkey('node_admins:*'), 200); + if ($res === false) break; + foreach ($res as $key) { + $node = substr($key, strlen(rkey('node_admins:'))); + $members = $rds->sMembers($key) ?: []; + foreach ($my_calls as $c) { + if (in_array($c, $members, true)) { $admin_nodes[] = $node; break; } + } + } +} while ($cursor > 0); +$admin_nodes = array_values(array_unique($admin_nodes)); +sort($admin_nodes); + +$page_title = 'Profile — SvxPortal'; +require __DIR__ . '/header.php'; +?> + +
+ + +
+ +
+ + + + +
+
+
+ UUID: + +
+
+ +
Identity
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Email status + + + ✓ Verified + + Not verified + + + + +
+
Location
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + + + + + + + + +
+ +'; +require __DIR__ . '/footer.php'; +?> \ No newline at end of file diff --git a/auth/profile_nodes.js b/auth/profile_nodes.js new file mode 100644 index 0000000..065ca25 --- /dev/null +++ b/auth/profile_nodes.js @@ -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 = '
No nodes matching your callsign are currently connected to the reflector.
'; + 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 = '
' + esc(callsign) + ''; + if (n.nodeClass) html += ' ' + esc(n.nodeClass) + ''; + if (n.isTalker) html += ' TX'; + html += '
'; + html += '
'; + 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 += '
'; + // 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 += '
Monitored TGs:
'; + for (var i = 0; i < n.monitoredTGs.length; i++) html += '' + n.monitoredTGs[i] + ''; + html += '
'; + } + if (n.toneToTalkgroup && Object.keys(n.toneToTalkgroup).length > 0) { + html += '
CTCSS Tone Mapping:'; + html += ''; + 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 += ''; + } + html += '
' + sorted[i][0] + ' HzTG ' + sorted[i][1] + '
'; + } + if (n.qth && n.qth.length > 0) { + for (var qi = 0; qi < n.qth.length; qi++) { + var q = n.qth[qi]; + html += '
'; + html += 'QTH: ' + esc(q.name || '') + ''; + html += '
'; + 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 += '
'; + html += '
'; + } + } + 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 = 'Your account has no callsign set — add one on the My Account tab first.'; return; } + pane.innerHTML = ''; + var wrap = document.createElement('div'); + var head = document.createElement('div'); + head.className = 'mb-2'; + head.innerHTML = 'Node callsign: ' + esc(call) + ''; + 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 = '
Send a SvxLink DTMF command (remote control must be ON). ' + + '91<TG># talk · 910# leave · ' + + '92<TG># QSY (929#→TG9 if someone interferes) · ' + + '93# follow QSY · 94<TG># monitor · 9*# status
'; + 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 = 'Failed to load.'; }); +} + +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 ''; +} +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 = + '
Broker: ' + esc(d.broker_host) + ':' + d.broker_port + '
' + + '
Username: ' + esc(d.username) + '' + mqttCopyBtn('Copy', d.username) + '
' + + '
Password: ' + esc(d.password) + '' + mqttCopyBtn('Copy', d.password) + '
' + + '
Paste these into the node dashboard → Admin → Remote Ctrl, then Turn ON.
' + + ''; + cont.querySelector('[data-regen]').addEventListener('click', function() { mqttProvision(call, cont); }); + mqttWireCopy(cont); + } else { + cont.innerHTML = ''; + 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 = 'Working…'; + 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 '
' + esc(l) + '
'; } +function dd(v) { return '
' + esc(String(v)) + '
'; } +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 = '' + esc((j && j.error) || 'Failed to load') + ''; return; } + var list = j.nodes || []; + var pend = j.pending || []; + if (!list.length && !pend.length) { cont.innerHTML = 'No reflector logins or certificates are linked to your account.'; return; } + cont.innerHTML = '
' + + '
CallsignAuthPasswordActions
'; + 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('cert' + (n.validTo ? ' · ' + new Date(n.validTo * 1000).toISOString().slice(0, 10) : '') + '' + + (expired ? ' EXPIRED' : '')); + if (typeof n.renewals !== 'undefined') auth.push('renewals: ' + (n.renewals | 0) + ''); + } + if (n.password) auth.push('password'); + var tr = document.createElement('tr'); + tr.innerHTML = '' + esc(n.node) + '' + + '' + auth.join(' ') + '' + + '' + (n.password ? 'Loading…' : '—') + '' + + ''; + 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 = '' + esc(pw) + '' + 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 = '' + esc(p.node) + '' + + 'cert request' + + (typeof p.renewals !== 'undefined' ? ' renewals: ' + (p.renewals | 0) + '' : '') + '' + + '—' + + ''; + 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 = 'Waiting for an admin to issue the first certificate.'; + } + }); + }); +} + +// 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:), 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 = '
' + + (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.') + + '
'; + var box = wrap.querySelector('.ae-box'); + box.innerHTML = 'Loading…'; + naApi({ action: 'list', node: cs }).then(function (j) { + if (!j || !j.ok) { box.innerHTML = '' + esc((j && j.error) || 'failed') + ''; return; } + var server = (j.admins || []).map(function (c) { return String(c).toUpperCase(); }); + if (!editable) { + var ro = '
'; + if (!server.length) ro += ''; + server.forEach(function (c) { ro += ''; }); + box.innerHTML = ro + '
Callsign
No extra admins. (The node\'s own callsign always counts.)
' + esc(c) + '
'; + return; + } + var working = server.slice(); + box.innerHTML = + '
' + + '
' + + '
' + + ''; + 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 = '
' + + ''; + if (!working.length) html += ''; + working.forEach(function (v, i) { + html += '' + + ''; + }); + tw.innerHTML = html + '
CallsignAction
No admins — click Add to grant a callsign admin rights for this node. (Your own callsign always counts.)
'; + 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(); }); +} diff --git a/auth/profile_update.php b/auth/profile_update.php new file mode 100644 index 0000000..68a5dc8 --- /dev/null +++ b/auth/profile_update.php @@ -0,0 +1,30 @@ +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]); diff --git a/auth/resend_verification.php b/auth/resend_verification.php new file mode 100644 index 0000000..b940662 --- /dev/null +++ b/auth/resend_verification.php @@ -0,0 +1,53 @@ +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]); diff --git a/auth/reset_password.php b/auth/reset_password.php new file mode 100644 index 0000000..d08a612 --- /dev/null +++ b/auth/reset_password.php @@ -0,0 +1,81 @@ +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'); } +?> + + + + + + Reset Password — SvxPortal + + + +
+

Reset Password

+ +
Password changed successfully!
+

Back to SvxPortal

+ +
+

Back to SvxPortal

+ +
+
+ + + + + +
+ +
+
+ © 2026 SA7AUX. All rights reserved. +
+ + diff --git a/auth/signup.php b/auth/signup.php new file mode 100644 index 0000000..5758ddc --- /dev/null +++ b/auth/signup.php @@ -0,0 +1,100 @@ + 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]); diff --git a/auth/tgadmin.js b/auth/tgadmin.js new file mode 100644 index 0000000..e3cc4e4 --- /dev/null +++ b/auth/tgadmin.js @@ -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 = '
Pending requests (' + tgaPending.length + ')
' + + '
'; + tgaPending.forEach(function (p) { + h += '' + + '' + + '' + + ''; + }); + return h + '
TGNameSystemAllStarModeRequesterActions
' + esc(p.tg) + '' + esc(p.name) + '' + esc(p.system) + '' + esc(p.allstar) + '' + esc(p.mode) + '' + esc(p.callsign) + '' + + ' ' + + '' + + '
'; +} + +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() + + '
' + + '
TG List — admin (' + rows.length + '/' + tgaData.length + ')
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + (tgaSelected.size + ? '
' + tgaSelected.size + ' selected
' + : '') + + '
' + + ''; + rows.forEach(function (r) { + h += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + h += '
TGNameSystemAllStarModeOwnerAdminsActions
' + esc(r.tg) + '' + esc(r.name) + '' + esc(r.system) + '' + esc(r.allstar) + '' + esc(r.mode) + '' + (r.owner ? esc(r.owner) : '—') + '' + ((r.admins && r.admins.length) ? r.admins.length : '0') + '' + + ' ' + + '' + + '
'; + if (!tgaData.length) h += '

No talkgroups yet — click + Add TG.

'; + 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 '
' + + '' + + inputHtml + '
'; +} +function groupHead(t) { return '
' + esc(t) + '
'; } + +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 ''; }; + + var h = + '
' + + '
' + (isNew ? 'Add TG' : ('Edit: TG ' + esc(r.tg) + (r.name ? ' — ' + esc(r.name) : ''))) + '
' + + '' + + '
' + + 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', '' + (r.owner ? esc(r.owner) : '— none (set when a TG request is approved) —') + '') + + fieldRow('admins', '') + + '
' + + '' + + (isNew ? '' : '') + + '' + + '
'; + 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 += '' + esc(owner) + ' owner'; + } + if (working.length === 0 && !owner) { + body += 'No admins — click Add to grant a callsign admin rights for this TG. (Account admin level always has access.)'; + } + working.forEach(function (cs, i) { + body += '' + + '' + + '' + + ''; + }); + var h = + '
' + + '
Admins (callsigns) — TG ' + esc(row.tg) + '
' + + '' + + '
' + + '
' + + '
' + + body + '
CallsignAction
' + + '
' + + '' + + '' + + '
'; + 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); +}); diff --git a/auth/tgadmin.php b/auth/tgadmin.php new file mode 100644 index 0000000..8574c93 --- /dev/null +++ b/auth/tgadmin.php @@ -0,0 +1,25 @@ +=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'; +?> +
+
+

Admin TG manager — add / edit / delete any talkgroup and its full record. Approve 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.

+
+ +
+
+var ADMIN_LEVEL = ' . $lvl . ';'; +require __DIR__ . '/footer.php'; +?> diff --git a/auth/tglist_api.php b/auth/tglist_api.php new file mode 100644 index 0000000..d637ede --- /dev/null +++ b/auth/tglist_api.php @@ -0,0 +1,217 @@ += 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); diff --git a/auth/tgrequest.js b/auth/tgrequest.js new file mode 100644 index 0000000..315d58e --- /dev/null +++ b/auth/tgrequest.js @@ -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 TG ' + esc(p.tg) + ' ("' + 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) : 'No callsign on your account — set one in your profile first.'; + }) + .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); + }); +}); diff --git a/auth/tgrequest.php b/auth/tgrequest.php new file mode 100644 index 0000000..9342044 --- /dev/null +++ b/auth/tgrequest.php @@ -0,0 +1,45 @@ + +
+
+
Request a Talkgroup
+

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.

+ + + +
+ +
—
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
+'; +require __DIR__ . '/footer.php'; +?> diff --git a/auth/theme_prefs_update.php b/auth/theme_prefs_update.php new file mode 100644 index 0000000..c6a5a7e --- /dev/null +++ b/auth/theme_prefs_update.php @@ -0,0 +1,51 @@ + 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]); diff --git a/auth/user_events.php b/auth/user_events.php new file mode 100644 index 0000000..c96082c --- /dev/null +++ b/auth/user_events.php @@ -0,0 +1,17 @@ +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]); diff --git a/auth/verify.php b/auth/verify.php new file mode 100644 index 0000000..5d30387 --- /dev/null +++ b/auth/verify.php @@ -0,0 +1,41 @@ +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 "Verified +

OK

+
Your e-mail is verified. Your account is now registered and waiting for an admin to approve it — once that is done you can sign in.
+ +"; diff --git a/auth_ui.js b/auth_ui.js new file mode 100644 index 0000000..a58a06c --- /dev/null +++ b/auth_ui.js @@ -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 = '' + + '' + + ''; + + 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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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) => '
' + t + '
'; + const dz = (k) => ''; + 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 += '
' + + '
' + item.callsign + '
' + timeAgo(item.mtime) + '
' + + '
' + + '' + + '' + + dz(key) + '
'; + } + } + } + + // 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 += '
' + + '
' + a.callsign + ' ' + a.firstname + '' + + '
' + status + '' + + ' ' + timeAgo(a.created) + '
' + + '
' + + (canApprove ? '' : '') + + 'Review' + + dz(key) + '
' + + '
' + + '
'; + } + } + } + + // 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 += '
' + + '
' + c.name + '
' + label + '
' + + dz(key) + '
'; + } + } + } + + // 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 += '
' + + '
TG ' + bEsc(p.tg) + ' ' + bEsc(p.name) + '' + + '
' + bEsc(p.callsign) + '' + + ' ' + timeAgo(p.ts) + '
' + + '
' + + '' + + '' + + 'Open' + + dz(key) + '
'; + } + } + } + + // 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 += '
' + + '
' + label + '' + + (evt.reason ? '
Reason: ' + evt.reason + '' : '') + + '
' + timeAgo(evt.ts) + (evt.by ? ' by ' + evt.by : '') + '
' + + dz(key) + '
'; + } + } + } + + list.innerHTML = html || '
No notifications
'; + + 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 + ? '
  • ' + + 'Admin ▸' + + '' + + '
  • ' + + '
  • ' + : ''; + const html = '' + + ''; + 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(); + }); + }); +})(); diff --git a/changelog.json b/changelog.json new file mode 100644 index 0000000..80eb261 --- /dev/null +++ b/changelog.json @@ -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 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 +
    + © 2026 SA7AUX. All rights reserved. +
    + + diff --git a/help.js b/help.js new file mode 100644 index 0000000..3ee2a9c --- /dev/null +++ b/help.js @@ -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); +})(); diff --git a/hoverinfo.js b/hoverinfo.js new file mode 100644 index 0000000..a78f626 --- /dev/null +++ b/hoverinfo.js @@ -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('
    ● Talking
    '); + for (const r of (rows || [])){ + if (!r) continue; + const v = r[1]; + if (v === undefined || v === null || v === '') continue; + parts.push('
    ' + escHtml(r[0]) + ': ' + escHtml(v) + '
    '); + } + if (!parts.length) return ''; + return encodeURIComponent(parts.join('')); + }; +})(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..d034ae3 --- /dev/null +++ b/index.html @@ -0,0 +1,707 @@ + + + + + + + + + + SvxPortal — SvxLink Reflector | Nodes, Lastheard, CTCSS & Map + + + + + + + + + + + + + + + + + + + + +
    +

    Nodes

    + +
    +
    -
    +
    Date -
    +
    Time -
    +
    Age -
    +
    SSE: -
    +
    + + +
    +
    +
    Reflector clients
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Part of the reflector-network.com federation — a growing mesh of independent networks.   •   Part of the reflector-network.com federation — a growing mesh of independent networks. +
    +
    + © 2026 SA7AUX. All rights reserved. +
    + + + \ No newline at end of file diff --git a/install/nginx-svxportal.conf b/install/nginx-svxportal.conf new file mode 100644 index 0000000..796a70f --- /dev/null +++ b/install/nginx-svxportal.conf @@ -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; + } +} diff --git a/install/php-fpm-svxadmin.conf b/install/php-fpm-svxadmin.conf new file mode 100644 index 0000000..fbb7991 --- /dev/null +++ b/install/php-fpm-svxadmin.conf @@ -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 diff --git a/install/redis-svxportal.conf b/install/redis-svxportal.conf new file mode 100644 index 0000000..616c93e --- /dev/null +++ b/install/redis-svxportal.conf @@ -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-.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 diff --git a/install/tmpfiles-svxportal-tglist.conf b/install/tmpfiles-svxportal-tglist.conf new file mode 100644 index 0000000..c298b1d --- /dev/null +++ b/install/tmpfiles-svxportal-tglist.conf @@ -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":[]} diff --git a/lastheard_view.js b/lastheard_view.js new file mode 100644 index 0000000..d74a04f --- /dev/null +++ b/lastheard_view.js @@ -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 = `No keyups recorded in last 24h.`; + 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) + ? ` ${Number(e.siglev).toFixed(0)}` : ''; + const rx = e.rx_name ? `${escHtml(e.rx_name)}${sig}` : '-'; + 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 = `${escHtml(e.callsign || eSystem || '')}`; + return ` + ${ageStr} + ${csCell} + ${escHtml(eBackend)} + ${Number(e.tg||0)} + ${escHtml(e.location || '')} + ${rx} + ${durStr} + `; + }).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 => ({ + '&':'&','<':'<','>':'>','"':'"',"'":''' + }[c])); +} + +window.refreshLastheard = refreshLastheard; +window.renderLastheard = renderLastheard; diff --git a/lib/api.js b/lib/api.js new file mode 100644 index 0000000..8400934 --- /dev/null +++ b/lib/api.js @@ -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; +})(); diff --git a/lib/auth_ui.js b/lib/auth_ui.js new file mode 100644 index 0000000..b895dd4 --- /dev/null +++ b/lib/auth_ui.js @@ -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; +})(); diff --git a/lib/changelog.js b/lib/changelog.js new file mode 100644 index 0000000..9825f44 --- /dev/null +++ b/lib/changelog.js @@ -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 `${escHtml(x[0])}`; + } + 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 => ` +
    + ${escHtml(e.date || '')} + ${typeBadge(e.type)} + ${escHtml(e.summary || '')} + ${escHtml(e.repo || '')} ${escHtml(e.hash || '')} +
    `).join(''); + body.innerHTML = rows || '
    No entries.
    '; + if (!pager) return; + if (pages <= 1) { pager.innerHTML = ''; return; } + const btn = (label, p, dis, act) => + ``; + 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 += ``; + 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 = '
    Changelog unavailable.
    '; + } + } + return { load, render }; + } + window.changelogViewer = changelogViewer; +})(); diff --git a/lib/fmt.js b/lib/fmt.js new file mode 100644 index 0000000..8293f6b --- /dev/null +++ b/lib/fmt.js @@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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; +})(); diff --git a/lib/pager.js b/lib/pager.js new file mode 100644 index 0000000..28b284e --- /dev/null +++ b/lib/pager.js @@ -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) => + ``; + let h = `
    `; + h += `${total} ${noun} · page ${page}/${pages}`; + h += btn('«', 1, page === 1) + btn('‹', page - 1, page === 1); + for (let p = from; p <= to; p++) h += btn(String(p), p, false, p === page); + h += btn('›', page + 1, page === pages) + btn('»', pages, page === pages); + return h + `
    `; + } + 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) => + ``; + 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 = + `
    ` + + 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) + + `${cur} / ${total} (${totalItems} rows)` + + `
    `; + 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; +})(); diff --git a/lib/theme.js b/lib/theme.js new file mode 100644 index 0000000..dbb9881 --- /dev/null +++ b/lib/theme.js @@ -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 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