Compare commits

...
Author SHA1 Message Date
sjgandClaude Opus 4.8 571ef17295 [fix](trx-wefax): stop fragmenting one transmission into many images
CI / lint (push) Failing after 3s
CI / frontend (push) Successful in 5m23s
CI / reuse (push) Successful in 19s
CI / test (push) Successful in 9m9s
The WEFAX decoder decoded content but chopped a single chart into many
short PNG "chunks". Two heuristics fought each other: in State::Receiving
the carrier-loss watchdog finalized the image after only 30 low-correlation
scan lines (~15 s at 120 LPM), and the Idle variance auto-start then
re-triggered on the still-present carrier ~3 s later, starting a fresh
image. Ordinary HF fading (QSB) of 5-20 s therefore split every chart into
a stream of tiny images.

Reference decoders (fldigi) keep one continuous image per APT cycle up to a
large line cap and only stop on the APT stop tone or genuine signal loss.
Align with that model:

- Raise the end-of-transmission watchdog to 120 low-correlation lines
  (~60 s at 120 LPM) so normal fades ride through within one image.
- Gate the variance-based auto-start to fire at most once per session
  (auto_start_used). After the first image a new one starts only on an APT
  start tone or an explicit reset, so trailing carrier/noise can no longer
  spawn a second image. reset() re-arms it.
- Cap a single image at 3000 lines to bound memory on an open carrier.
- Require a 2 s (was 1 s) APT tone sustain, cutting false Stop detections on
  busy image content that momentarily hits ~450 transitions/s.

Also make line slicing drift-free: 120 LPM at 11025 Hz is 5512.5 samples per
line, and slicing on the rounded integer accumulated a fractional-sample
error every line (slow horizontal slant). Boundaries are now derived from the
exact fractional line length (samples_per_line_f64) so the error never
accumulates.

Adds regression tests for the single-auto-start invariant and zero slicer
drift over 1000 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-08-15 23:27:32 +02:00
sjg 1829e3d17b [fix](trx-frontend-http): make Guest role composable
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m16s
CI / frontend (pull_request) Successful in 5m22s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m11s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 4s
2026-08-13 02:16:59 +02:00
sjg b037225a05 [fix](trx-frontend-http): block Guest password changes
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m5s
CI / frontend (pull_request) Successful in 5m23s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 9m11s
CI / frontend (push) Successful in 5m22s
CI / reuse (push) Successful in 5s
2026-08-11 22:47:55 +02:00
sjg e978cf8a84 [feat](trx-frontend-http): separate transmit permission
CI / frontend (pull_request) Successful in 5m21s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m11s
CI / test (push) Successful in 8m16s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 5s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m25s
Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-11 18:36:33 +02:00
sjg 44870bc941 [feat](trx-frontend-http): add restricted Guest role
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m16s
CI / frontend (pull_request) Successful in 4m17s
CI / reuse (pull_request) Successful in 5s
Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-11 16:04:51 +02:00
sjg 5e9dae02c7 Complete managed account lifecycle
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m6s
CI / frontend (pull_request) Successful in 5m17s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m26s
2026-08-11 07:49:53 +02:00
sjg 34507ffa17 Add composable HTTP access roles
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m24s
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
2026-08-11 01:06:57 +02:00
sjg 36c1e56efa Protect the final administrator 2026-08-11 00:39:25 +02:00
sjg e4cce9a004 Add Users settings tab 2026-08-11 00:37:59 +02:00
sjg 3c3fc69542 Refactor HTTP account system
CI / frontend (pull_request) Successful in 5m13s
CI / reuse (pull_request) Successful in 29s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m24s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
2026-08-10 23:47:51 +02:00
sjgandClaude Opus 5 d539ff96e5 [docs](trx-rs): write down how a contest and a confirmation are worked
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 5s
CI / test (push) Successful in 8m0s
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m57s
CI / lint (push) Successful in 2m32s
CI / frontend (push) Successful in 4m13s
CI / reuse (push) Successful in 6s
The manual gains the contest exchange, the Cabrillo entry and the
confirmations.  The plan marks all five phases done, corrects the API paths
to the /api/logbook they were built under -- /logbook is the page, and the
bookmarks API already shadows its own page that way -- and records the two
decisions phase 5 settled: that the Cabrillo header comes from the operator
because no log can derive it, and that a confirmation counts from whichever
bureau answered.

The plan itself travels with this branch: it was written on a branch of its
own that was never proposed for merge, and the earlier attempts to update it
from the implementation branches were silent no-ops, because the edits did
not assert that they had found what they were replacing.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:31:06 +02:00
sjgandClaude Opus 5 ca5cd65c85 [docs](trx-rs): settle the logbook's remaining questions
The clock is the server's, as asked: it is the machine at the radio, where
the browser may be a phone in another timezone with a clock nobody checked.
When the two disagree by more than a second the panel says so, rather than
logging a time the operator did not expect.

The rest, decided against how logging is actually done:

One station log, not one per rig.  DXCC, WAS and LoTW count the callsign, not
the radio, and a station worked on the second rig is still worked.  The rig
goes on the QSO as MY_RIG.  Station location does follow the rig, though —
these rigs can be in different places, so MY_GRIDSQUARE comes from the one
that made the contact, which is what LoTW's station locations expect too.

The operator is a per-QSO field set once per session.  ADIF separates the
callsign used on the air from the person at the key, and multi-operator
stations rotate people through one station callsign.  It defaults from the
configured callsign, so a single operator never touches it.  It cannot come
from the session: the auth roles are control and rx, with nobody's name on
them.

The log file is configurable, defaulting to the user's data directory.
Bookmarks sit in the config directory because they are settings and decode
logs in the cache directory because they are disposable; a QSO log is
irreplaceable, and cache directories get swept.

Import collisions match on callsign, band, mode and a two-minute window.
Loggers rarely agree to the second on the same QSO — one stamps the contact,
the other the entry — so an exact-minute key duplicates half of what it is
asked to merge.  Two minutes absorbs that without swallowing a legitimate
re-work, since contest rules forbid a second contact on the same band and
mode.  Times compare as instants so midnight matches, and modes are
normalised or an imported SSB would miss our USB.

That normalisation is now written down: a rig mode is not an ADIF mode.  DIG
is the one the rig cannot answer — a rig in DIG is in FT8 or FT4 depending on
what is decoding — and WSPR never opens an entry at all, because hearing a
beacon is not a contact.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:30:10 +02:00
sjgandClaude Opus 5 a9e1e86fdc [docs](trx-rs): settle the logbook's panel, its prefill, and its formats
Three answers from the issue, folded into the proposal.

The logbook is a panel of its own rather than a strip on the radio page, and
it stands in every layout: a log can be kept without adopting the ham layout,
and read while another is selected.  The ham layout is then the arrangement
that starts from it, with the radio controls around it.

Prefill is exactly six fields — frequency, mode, rig name, time, callsign and
locator — and nothing else.  A signal report in particular stays empty: an
FT8 SNR is not what was sent, and prefilling one would put a number in the
log that nobody exchanged.  The station's own callsign and locator are not
per-entry fields at all; they are station identity, shown once at the top of
the panel and written into the QSO from configuration.

The file format was left to me.  ADIF stays, because it is not one option
among several: LoTW, eQSL, Club Log, QRZ and every other logger read it and
nothing else, so a log that cannot write it cannot be uploaded, confirmed or
moved.  Nothing on disk is ADI regardless — the store is JSON Lines.  The
second format is Cabrillo 3.0, which ADIF cannot replace: contest logs are
submitted in it and rejected in anything else.  It lands with the contest
exchange fields, since without a serial or a zone it has nothing to write.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:30:10 +02:00
sjgandClaude Opus 5 76e33a91bb [docs](trx-rs): propose the logbook and the ham radio layout
Issue #54 asks for a ham radio layout and an ADIF logbook, with no detail
behind either.  This is what they would be: what the logbook has to hold, how
it is stored, where ADIF is read and written, and which of the existing parts
each half hangs off.

Two things it settles before any code is written.  The log is append-only
JSON Lines rather than the whole-file dump the bookmarks use, because a
station with forty thousand QSOs would rewrite megabytes to log one contact
and lose the lot if the power went mid-dump.  And a decode is not a QSO: the
decoders only ever heard something, so a decoded callsign pre-fills an entry
and never writes one.

The layout is a fifth entry in the operator layouts that already exist,
gated on the rig being able to transmit.

Refs #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:30:10 +02:00
sjgandClaude Opus 5 ee185b98bc [feat](trx-logbook): work a contest, and record what came back
Phase 5 of the logbook: the exchange, the entry sponsors take, and the
confirmations an award counts.

Contest fields go on the contact — the contest, the serials both ways as
numbers and as words, and the zones — because an exchange is not always a
number: a zone, a section or a name goes in as written.  The serial sent and
the contest stay between contacts, since they belong to the session and not
to the contact just logged, and the serial counts on by itself rather than
being retyped forty times an hour.

Cabrillo 3.0 is written because ADIF cannot do this job: sponsors take
Cabrillo and reject everything else.  Its shape is not ADIF's either — the
frequency is kilohertz below 30 MHz and a band designator above it, the modes
are CW, PH, FM, RY and DG, and the contacts go oldest first, as a contest log
is read.  The header cannot be derived from a log — how many operators, how
much power, what the score is claimed to be — so it comes from the operator,
with single-op, low power, all bands and mixed behind it.

QSL, LoTW and eQSL states are held as ADIF's single letters, and anything
else is refused rather than written: a log that grew states of its own would
be one no other program could read.  A contact is confirmed when any one of
the three says so — an award wants a card or an electronic match, not one of
each, and counting them separately would tell the operator they were short of
what they have.

The bands report counts contacts, distinct stations and confirmations per
band, ordered by wavelength as a band plan reads.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:28:40 +02:00
54 changed files with 4778 additions and 721 deletions
Generated
+93 -5
View File
@@ -316,6 +316,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -345,6 +357,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bindgen"
version = "0.66.1"
@@ -395,6 +413,24 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.0"
@@ -525,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
@@ -721,6 +757,15 @@ dependencies = [
"windows",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -739,6 +784,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.1"
@@ -799,15 +854,26 @@ dependencies = [
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [
"block-buffer",
"block-buffer 0.12.0",
"const-oid",
"crypto-common",
"crypto-common 0.2.1",
]
[[package]]
@@ -1019,6 +1085,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1890,6 +1966,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "peeking_take_while"
version = "0.1.2"
@@ -2492,8 +2579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.3.0",
"digest 0.11.2",
]
[[package]]
@@ -3215,6 +3302,7 @@ version = "0.1.0"
dependencies = [
"actix-web",
"actix-ws",
"argon2",
"base64",
"brotli 7.0.0",
"bytes",
+9 -4
View File
@@ -925,9 +925,14 @@ main
### HTTP Frontend Auth
- Optional token or HTTP Basic Auth middleware
- Configured in `[frontends.http.auth]`
- Rate limiting supported
- Optional Argon2id-backed managed accounts with HttpOnly session cookies
- An exclusive Guest role plus composable Read, Control, Transmit, Write, and Administrator roles, with policy shared by middleware and handlers
- Guest sessions receive read-only station access but no account-control endpoints or panels
- Transmit separately gates PTT, TX audio frames, and TX power-limit changes
- Atomic JSON persistence with migration from the legacy single-role schema
- Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes
- A database invariant always preserves at least one enabled administrator
- Per-IP login rate limiting; configured in `[frontends.http.auth]`
### Transport Security
@@ -1043,7 +1048,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i
|-----------|---------|------------|
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
| `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `rx_passphrase`, `session_ttl_secs`, `tokens` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin/read accounts, `session_ttl_secs`, `tokens` |
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
| `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
+266
View File
@@ -322,3 +322,269 @@ trx-configurator
| `trx-app` | Config types and validation | Yes |
| `serialport` | Serial port enumeration | Yes (transitive) |
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
---
## Logbook and Ham Radio Layout
Two halves of one feature ([#54](https://git.haxx.space/sjg/trx-rs/issues/54)): a station
logbook in a panel of its own, and an operator layout that puts a transceiver's controls
around it.
Nothing in the application records a QSO today. The map's "QSO summary" cards describe
contacts *between other stations*, reconstructed from decoded traffic; bookmarks are
frequencies, not contacts. Neither is the operator's own log.
### Requirements
| ID | Description |
|----|-------------|
| REQ-LOG-001 | The system shall record QSOs the operator makes, each holding at minimum callsign, date, time, band, frequency, mode and both signal reports. |
| REQ-LOG-002 | When starting a log entry, the system shall pre-fill exactly six fields: frequency, mode, rig name, time, callsign and locator. |
| REQ-LOG-003 | The system shall leave every other field of a log entry empty for the operator to fill. |
| REQ-LOG-004 | The system shall allow a logged QSO to be edited and deleted. |
| REQ-LOG-005 | The system shall survive a crash without losing a QSO that was recorded before it. |
| REQ-LOG-006 | The system shall list, search and filter the log by callsign, band, mode and date range. |
| REQ-LOG-007 | Where a decoded station is on screen, the system shall offer to start a log entry from it, pre-filled, without logging it unattended. |
| REQ-LOG-008 | The system shall show whether a callsign has been worked before, and on which bands. |
| REQ-FMT-001 | The system shall export the log as an ADIF 3.1.x `.adi` file. |
| REQ-FMT-002 | The system shall import ADIF `.adi` files produced by other logging software, preserving fields it does not itself use. |
| REQ-FMT-003 | When importing, the system shall identify QSOs already held and shall not duplicate them. |
| REQ-FMT-004 | The system shall export a filtered selection of the log as a Cabrillo 3.0 file for contest submission. |
| REQ-LOG-009 | The system shall stamp QSO times from the server's clock in UTC, and shall tell the operator when the browser's clock disagrees with it by more than one second. |
| REQ-LAY-001 | The system shall present the logbook in a panel of its own, reachable whatever layout is selected. |
| REQ-LAY-002 | The system shall offer a "Ham radio" operator layout presenting the transceiver controls and that panel together. |
| REQ-LAY-003 | Where the selected rig cannot transmit, the system shall not offer the ham layout. |
### A decode is not a QSO
The decoders are receive-only: FT8, CW, APRS and the rest report what was *heard*. A heard
callsign is the beginning of a log entry, not a contact, and the logbook must never write one
by itself — REQ-LOG-007 says offer and pre-fill, never auto-log. Digital QSOs made in
WSJT-X or similar arrive the way every other logger takes them: through ADIF import.
### What is pre-filled, and what is not
Six fields, and no more (REQ-LOG-002, REQ-LOG-003):
| Field | From | ADIF |
|-------|------|------|
| Frequency | the selected rig's dial | `FREQ`, with `BAND` derived from it |
| Mode | the selected rig | `MODE`, and `SUBMODE` where the mode implies one |
| Rig name | the rig's display name | `MY_RIG` |
| Time | the server's clock, UTC, when the entry opens | `QSO_DATE`, `TIME_ON` |
| Callsign | the decode row or map station the entry was started from, else empty | `CALL` |
| Locator | that station's grid, where the decode carried one, else empty | `GRIDSQUARE` |
Signal reports, power, name, QTH and the rest stay empty. A report in particular is the
operator's to give: an FT8 SNR is not what was sent, and pre-filling one would put a number in
the log that nobody exchanged.
The station's own callsign and locator are not pre-filled per entry either — they are station
identity, taken from configuration when the QSO is written (`STATION_CALLSIGN`, `OPERATOR`,
`MY_GRIDSQUARE`), and shown once at the top of the panel rather than typed into every row.
### Architecture
#### New crate: `trx-logbook`
```
src/trx-client/
trx-logbook/
src/
lib.rs # LogbookHandle: open, add, edit, delete, query, import, export
qso.rs # Qso: the record, its ADIF field mapping, band/mode helpers
adif.rs # ADI reader and writer (pure Rust, no new dependencies)
store.rs # Append-only JSON Lines file, in-memory index, compaction
dedupe.rs # Worked-before and import-collision rules
```
A library crate under `src/trx-client/`, beside `trx-frontend`, consumed by
`trx-frontend-http`. The log belongs to the station rather than to a rig or a frontend, so it
sits where any frontend can reach it.
#### Storage: append-only, not a rewritten blob
Bookmarks use `PickleDb` with `AutoDump`, which rewrites the whole file on every write. That
is right for a few dozen bookmarks and wrong for a log: a station with 40 000 QSOs would
rewrite several megabytes to log one contact, and lose the lot if the power went during the
dump.
The log is instead a JSON Lines file — the shape `trx-decode-log` already uses — appended one
record per write and read into an in-memory index at startup. An edit or a delete appends a new revision of that record's id; the load
keeps the last one, and a compaction pass rewrites the file when superseded records exceed a
threshold. Appending is O(1) and atomic per line, so a crash costs at most the line being
written.
#### Two formats, for the two things a log is asked for
The file formats were left open ("pick a well-known ham format"), so: **ADIF for interchange,
Cabrillo for contest submission.** Both are implemented in-repo, in the way this project
already implements its decoders, and neither adds a dependency.
**ADIF has to stay.** It is not one option among several — it is the only thing the ecosystem
reads. LoTW, eQSL, Club Log, QRZ.com and every other logger take ADIF and nothing else, so a
log that cannot write `.adi` cannot be uploaded, confirmed, or moved to another program. That
is a one-way door, and the interoperability is most of the point of keeping a log at all.
Nothing on disk is ADI regardless: the store is JSON Lines, and ADIF is what comes out of an
export.
ADI is a tagged text format — `<FIELD:length>value`, records ended by `<EOR>`, a header ended
by `<EOH>`, everything outside a tag ignored — small enough to implement exactly. The reader
must be lenient in the ways real files are irregular (lowercase tags, CRLF, missing header,
unknown fields, type indicators) and the writer strict. Unknown fields are carried through
import to export unchanged, so a round trip through trx-rs does not quietly strip what another
logger wrote. ADX, the XML serialisation of the same data model, is out of scope: it is part
of the standard but almost nothing reads it.
**Cabrillo is the second format, because ADIF cannot do its job.** Contest logs are submitted
to sponsors in Cabrillo 3.0 and are rejected in anything else — a header of `CALLSIGN:`,
`CONTEST:`, `CATEGORY-*` and `CLAIMED-SCORE:` lines, then one fixed-column `QSO:` line per
contact carrying frequency in kHz, a mode code (`CW`, `PH`, `FM`, `RY`, `DG`), the UTC date and
time, and both stations' calls, reports and exchanges. It is export-only and drops everything
outside the contest's exchange, which is why it complements ADIF rather than replacing it.
It arrives with the contest exchange fields in phase 5, since without a serial or a zone to
put in the exchange there is nothing for it to write.
#### Integration points
| Source | What it gives the log | How |
|--------|----------------------|-----|
| `RigState` | `FREQ`, `BAND`, `MODE`/`SUBMODE`, and the rig id a QSO was made on | watch channel already in the frontend context |
| Client config `general.callsign` | `STATION_CALLSIGN`, and the default `OPERATOR` | already surfaced as `owner_callsign` in frontend meta |
| The QSO's own rig, and its position | `MY_GRIDSQUARE` | per-rig latitude and longitude already carried in the rig list |
| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /api/logbook/now`, which also feeds the browser-clock check |
| Decoder panels and map | a pre-filled entry: callsign, grid, and the report to offer | existing decode history; no new plumbing |
| `bandForHz` | `BAND` from a frequency | exists in `map-core.ts`; move to a shared module |
#### HTTP API
Under `/api/logbook`, as the recorder's endpoints are, because `/logbook` itself is the page:
the `/bookmarks` API and the bookmarks page already share a path, and whichever is registered
first wins.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/logbook` | Query: filters, paging |
| `POST` | `/api/logbook` | Add a QSO |
| `PUT` | `/api/logbook/{id}` | Edit |
| `DELETE` | `/api/logbook/{id}` | Delete |
| `GET` | `/api/logbook/export.adi` | ADIF export, honouring the current filter |
| `GET` | `/api/logbook/export.cbr` | Cabrillo export of a contest selection |
| `POST` | `/api/logbook/import` | Import, answering with counts: added, duplicate, rejected |
| `GET` | `/api/logbook/worked/{call}` | Worked-before: bands and modes |
| `GET` | `/api/logbook/statistics` | Contacts, stations and confirmations, per band |
| `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's |
| `GET` | `/api/logbook/prefill` | The six fields an entry opens with |
Writes require the admin role, as the rig endpoints do.
### Frontend
The logbook is **its own panel**, not a strip bolted to the radio page: a `logbook` entry in
the tab order beside Bookmarks, holding the entry form, the table with the filters of
REQ-LOG-006, and import and export. It stands on its own in every layout, so a log can be kept
without adopting the ham layout, and read while another layout is selected (REQ-LAY-001).
The panel is three parts: the station line at the top (own callsign, locator, the rig a QSO
would be logged against), the entry form beneath it opening with the six pre-filled fields,
and the log itself under that, filtered as REQ-LOG-006 asks. Worked-before shows against
the callsign as it is typed.
The **ham layout** is a fifth entry in the operator layouts (`compact`, `broadcast`, `digital`,
`full`), which already gate on capability, seed the disclosure sections and persist per rig:
```ts
ham: {
label: "Ham radio",
unavailable: "Ham radio needs a rig that can transmit",
advanced: true, audio: true, scheduler: false,
preferredTab: "logbook", capability: "ham",
}
```
with the `ham` capability set from `RigCapabilities.tx`. It keeps frequency, VFO, mode, filter,
PTT, power and the meters, and hides the broadcast furniture. What it adds over `full` is where
it starts: the logbook panel, with the radio controls a keystroke away rather than the other
way round — the layout an operator working the bands wants, where logging the contact is the
task and the rig is the instrument.
### Phases
All five are implemented.
| Phase | Lands |
|-------|-------|
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
| 2 | Store, dedupe, and the HTTP API behind the admin role |
| 3 | Logbook tab: entry, table, filters, import, export |
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
Two decisions phase 5 settled. The Cabrillo header cannot be derived from a log — how many
operators, how much power, what the score is claimed to be — so it comes from the operator,
with `SINGLE-OP`, `LOW`, `ALL` and `MIXED` behind it. And a confirmation counts from whichever
bureau answered: an award wants one card or one electronic match, not one of each, so a log
that counted them separately would tell the operator they were short of what they have.
### Decisions
**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS
and LoTW all count the callsign, not the radio — and a station worked on the second rig is
still worked. The rig is recorded on the QSO (`MY_RIG`) rather than dividing the log by it.
The station *location* fields do follow the rig, though: trx-rs rigs can be in different
places, so `MY_GRIDSQUARE` is taken from the rig that made the QSO rather than from one global
setting, which is also what LoTW's station locations expect.
**The operator is a per-QSO field, set once per session.** ADIF separates `STATION_CALLSIGN`
(the call used on the air) from `OPERATOR` (the person at the key); multi-operator stations
rotate operators through one station callsign, which is why contest loggers record it per QSO.
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
it, and changed on the station line at the top of the panel where it sticks for the session.
It cannot be inferred from the session's identity: an account username need not be an operator
callsign, and operational accounts may be shared.
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
server, and when a browser's clock disagrees by more than a second the panel says so rather
than silently logging a time the operator did not expect.
**The log file is configurable, and defaults to the user's data directory.** Bookmarks live in
the config directory because they are settings; decode logs live in the cache directory because
they are disposable. A QSO log is neither — it is irreplaceable, and cache directories are
swept by cleaners. `[logbook].path` in the client config, defaulting to
`dirs::data_dir()/trx-rs/logbook.jsonl`, so a station that keeps its log on a synced or
backed-up volume can say so.
**Import collisions: callsign, band, mode and a two-minute window.** Two loggers rarely agree
to the second on the same QSO — one records the time the contact started, another the time it
was entered — so an exact-minute key duplicates half of what it is asked to merge. Two minutes
absorbs that. It does not swallow legitimate re-works: contest rules forbid a second contact
with the same station on the same band and mode, so a repeat inside two minutes is the same
QSO. Times are compared as instants rather than date and time strings, so a QSO either side of
midnight matches. Modes are normalised before comparison, or a log that stored `SSB` would fail
to match ours that stored `USB`.
### Rig modes to ADIF modes
The rig reports what it is demodulating; ADIF wants what the contact was made on, which is not
always the same word:
| Rig mode | ADIF `MODE` | ADIF `SUBMODE` |
|----------|-------------|----------------|
| `USB`, `LSB` | `SSB` | `USB` / `LSB` |
| `CW`, `CWR` | `CW` | — |
| `AM`, `SAM` | `AM` | — |
| `FM`, `WFM` | `FM` | — |
| `PKT` | `PKT` | — |
| `DIG` | decided by the decoder in use, not by the rig | |
| `AIS`, `VDES` | none — not amateur modes, and these rigs do not log | |
| `Other(..)` | passed through when it names an ADIF mode, else left for the operator | |
`DIG` is the one that cannot come from the rig: a rig in `DIG` is in FT8, FT4 or something else
depending on which decoder is running, and an entry started from an FT8 row logs `FT8` rather
than the rig's word for it. WSPR never opens an entry at all — it is a beacon mode, and hearing
a beacon is not a contact.
The table is data in `qso.rs`, checked against the ADIF enumeration when it is written, with
anything unrecognised left to the operator rather than guessed into the log.
+19 -12
View File
@@ -121,13 +121,14 @@ The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers
When auth is enabled, an **auth gate** blocks the UI with:
- Title: "Access Required"
- Subtitle: "Enter passphrase to continue"
- Password input + Login button (green accent, full-width)
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
- Subtitle: "Sign in to continue"
- Username and password inputs + Login button (green accent, full-width)
- Error message area (red `#ff6b6b`)
- Role badge display
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
**Guest** provides read-only station access and is exclusive. Non-Guest accounts
may combine **Read**, **Control**, **Transmit**, **Write**, and **Administrator** roles.
Administrator implies all permissions.
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
@@ -336,25 +337,31 @@ Logo and favicon are embedded at compile time via `include_bytes!`. The logo ima
### 7.1 Route Access Classification
Routes are classified into three tiers:
Routes are classified into access tiers:
| Tier | Examples | Requirement |
|---|---|---|
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, Transmit, or Administrator role |
| **Control** | `/set_freq`, `/set_mode`, `/toggle_power`, receive-side radio-control POST routes | Control or Administrator role |
| **Transmit** | `/set_ptt`, `/set_tx_limit`, outbound `/audio` frames | Transmit or Administrator role |
| **Write** | Logbook access and bookmark mutations | Write or Administrator role |
### 7.2 Session Management
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
- Configurable TTL (default from TOML config)
- Expired sessions auto-pruned on access
- Constant-time passphrase comparison to mitigate timing attacks
- Passwords are verified against salted Argon2id hashes
### 7.3 TX Access Control
### 7.3 User Management
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
for Control-role users, providing an extra safety layer.
Every authenticated non-Guest account gets a Settings > Account tab for changing
its own password. Guest sees neither Account nor Users and both account-control
APIs deny Guest sessions. Only administrators get Settings > Users, where accounts can be
created, enabled/disabled, assigned multiple roles, given a new password, or
removed. The final enabled administrator cannot be disabled, removed, or
demoted. Account security changes revoke every active session for that account.
---
+70 -25
View File
@@ -66,8 +66,7 @@ both:
|------------|----------|----------|
| `[listen.auth].tokens` | `tokens_file` | one token per line |
| `[[remotes]].auth.token` | `token_file` | the token |
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
| `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
Blank lines and `#` comments are ignored in the list files. A config that holds
@@ -350,17 +349,20 @@ A name in any of those maps that no remote answers to is a config error.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Require a passphrase |
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
| `rx_passphrase_file` | string | — | Read it from this file instead |
| `control_passphrase` | string | — | Passphrase granting full control |
| `control_passphrase_file` | string | — | Read it from this file instead |
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
| `enabled` | bool | `false` | Enable the user/password ACL |
| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
| `bootstrap_admin_password` | string | — | First administrator password |
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
| `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent |
| `bootstrap_read_username` | string | `"guest"` | Initial Guest username |
| `bootstrap_read_password` | string | `"guest"` | Initial Guest password |
| `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
With `enabled = true`, at least one passphrase must be set.
When enabling ACL for the first time, configure both bootstrap fields. After
the database exists, remove the bootstrap credentials from configuration.
#### `[frontends.rigctl]`
@@ -519,6 +521,30 @@ most the contact being written. It lives in your data directory by default;
`[trx-client.logbook].path` moves it, for a station that keeps its log on a
backed-up volume.
### Contests
The **Contest exchange** block on the entry holds the contest's name and the
serials — sent and received. Both stay between contacts, because they belong to
the session rather than to the contact just logged, and the serial sent counts
on by itself so it is not retyped forty times an hour. An exchange that is a
zone, a section or a name rather than a number is kept as written.
**Contest entry (Cabrillo)** exports the entry sponsors accept. Only the
contacts of the contest named there are included. The header — operator
category, power, claimed score — cannot be worked out from a log, so it is
yours to fill in; the defaults are single operator, low power, all bands, mixed
mode.
### Confirmations
The **QSL** column shows a tick when the other station has confirmed, and the
**Confirm** button on a row records a card that has arrived. A confirmation
counts from wherever it came: a paper card, LoTW or eQSL. An award wants one of
them, not all three, so the log does not ask for all three.
**Bands worked** counts, per band, the contacts made, the distinct stations
worked, and how many of those contacts are confirmed.
### Ham radio layout
The **Ham radio** operator layout opens on the logbook with the transceiver
@@ -554,7 +580,7 @@ The link button in the top bar copies the current link to the clipboard. The
address bar itself is updated as you tune, using `replaceState`, so sweeping
the dial does not fill the browser's history.
Applying a link changes the radio, so it needs the `control` role; an `rx`
Applying a link changes the radio, so it needs the `Control` role; a `Read`
session opens the page and says the link was not applied. Links describe the
rig's own dial — while a tab is listening to a virtual channel the address is
left as it was, rather than publishing a frequency the rig is not on.
@@ -563,53 +589,72 @@ left as it was, rather than publishing a frequency the rig is not on.
## Authentication
The HTTP frontend supports optional passphrase-based authentication with two
roles:
The HTTP frontend supports an optional user/password ACL:
- **rx** — read-only access (monitoring, audio, decode streams)
- **control** — full access (frequency, mode, PTT, and all settings)
- **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role
- **Read** — monitoring, audio, decode streams, and bookmark reads
- **Control** — tuning, mode, power, and receive-side radio controls
- **Transmit** — PTT, transmitted audio, and TX power-limit controls
- **Write** — logbook access and bookmark changes
- **Administrator** — user management and all other permissions
### Configuration
```toml
[frontends.http.auth]
enabled = false
rx_passphrase = "rx-only-passphrase"
control_passphrase = "full-control-passphrase"
tx_access_control_enabled = true
users_file = "trx-http-users.json"
bootstrap_admin_username = "admin"
bootstrap_admin_password = "change-this-password"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480
cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None
```
When `enabled = false` (the default), all auth is bypassed and the UI behaves
as before. When enabled, at least one passphrase must be set.
as before. When enabling it for the first time, bootstrap credentials create
the initial administrator (with every non-Guest role), the default `guest`/`guest` Guest
account, and the Argon2id-hashed user database. Change or disable the guest
credentials in configuration before first startup on an exposed deployment.
### Behaviour
- On login, the server issues an `HttpOnly` session cookie.
- Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts.
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
rejected for unauthenticated or `rx`-role users.
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
- Non-Guest roles are independent; for example, an account may have Read and Write without Control.
- Guest accounts have no account-control panels and cannot call account-control endpoints.
- Every non-Guest signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
- Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
- At least one enabled administrator must always remain and cannot be disabled, removed, or demoted.
- Disabling/removing an account or changing its password/roles revokes all of its sessions.
- Existing account files migrate automatically: legacy accounts are enabled by default and legacy `user`/`admin` roles become Read/all roles.
### Routes
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session |
| `/auth/session` | GET | Check current session/role |
| `/auth/session` | GET | Check current session/roles |
| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password |
| `/auth/users` | GET/POST | List or add users (admin only) |
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
Protected routes require at least `rx` role. Control routes (set frequency,
mode, PTT, etc.) require `control` role.
Read routes accept Guest or require Read. Tuning and receive-side radio mutations
require Control. PTT, transmitted audio, and TX limit changes require Transmit.
Logbook access and bookmark mutations require Write. Administrator grants every permission.
### Frontend Flow
1. On load, the UI calls `/auth/session`.
2. If unauthenticated, a login screen is shown.
3. On successful login, the normal UI loads.
4. `rx` users see a read-only interface; `control` users get full controls.
4. The interface enables controls according to the account's roles.
5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security
+13 -1
View File
@@ -45,8 +45,20 @@ impl WefaxConfig {
60.0 / lpm as f32
}
/// Samples per line at the internal sample rate.
/// Samples per line at the internal sample rate (rounded to an integer;
/// use [`Self::samples_per_line_f64`] for drift-free line slicing).
pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
(Self::line_duration_s(lpm) * sample_rate as f32).round() as usize
}
/// Exact (fractional) samples per line at the internal sample rate.
///
/// The line period rarely lands on an integer number of samples
/// (e.g. 120 LPM at 11 025 Hz is 5512.5 samples). Slicing on the rounded
/// integer accumulates a fractional-sample error every line, which shows
/// up as a slow horizontal slant over a tall image. Line boundaries are
/// instead computed from this exact value so the error never accumulates.
pub fn samples_per_line_f64(lpm: u16, sample_rate: u32) -> f64 {
60.0 / f64::from(lpm) * f64::from(sample_rate)
}
}
+86 -5
View File
@@ -38,10 +38,24 @@ const SIGNAL_DETECT_WINDOWS: u32 = 6;
/// Real WEFAX content typically shows r > 0.5 between adjacent lines.
const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
/// Number of consecutive uncorrelated scan lines that trigger auto-finalize
/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on
/// fldigi's line-to-line correlation check for automatic stop.
const LINE_CORR_NOISE_LINES: u32 = 30;
/// Number of consecutive uncorrelated scan lines that mark the end of a
/// transmission (carrier truly gone) and trigger auto-finalize. This must be
/// long enough to ride through ordinary HF fading (QSB), which routinely
/// decorrelates adjacent lines for several seconds without the transmission
/// having ended. At 120 LPM this is ~60 s; at 60 LPM ~120 s.
///
/// A short window here is what previously chopped a single chart into many
/// PNG "chunks": a 15 s fade tripped the watchdog, the image was finalized,
/// and the still-present carrier immediately re-started a fresh image. Real
/// WEFAX decoders (fldigi) keep one continuous image per APT cycle up to a
/// large line cap and only stop on the APT stop tone or genuine signal loss.
const LINE_CORR_NOISE_LINES: u32 = 120;
/// Hard cap on lines in a single image. A 120 LPM chart runs ~10 min
/// (~1200 lines); this cap (≈25 min at 120 LPM) only bounds memory if a
/// session is left running on an open carrier. On reaching it the image is
/// finalized and the decoder waits for a fresh APT start.
const MAX_IMAGE_LINES: u32 = 3000;
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
/// lock before falling through to Receiving. Typical WEFAX phasing lasts
@@ -107,6 +121,12 @@ pub struct WefaxDecoder {
/// the decoder falls through to Receiving so a noisy or partial
/// phasing signal doesn't wedge the state machine.
phasing_samples: u64,
/// Whether a reception has already been auto-started from bare signal
/// variance during this session. After the first image, a new one is only
/// started by an APT start tone — this stops the trailing noise / carrier
/// that follows one chart from immediately auto-starting another image
/// (the mechanism that fragmented a transmission into many chunks).
auto_start_used: bool,
/// Current rig dial frequency in Hz (for image filenames).
freq_hz: u64,
/// Current rig mode name (for image filenames).
@@ -135,6 +155,7 @@ impl WefaxDecoder {
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
low_corr_lines: 0,
phasing_samples: 0,
auto_start_used: false,
freq_hz: 0,
mode: String::new(),
}
@@ -204,7 +225,13 @@ impl WefaxDecoder {
// Fallback: detect active WEFAX signal by luminance variance.
// Like fldigi's "strong image signal" detection — if we see
// sustained modulated signal, auto-start receiving with defaults.
if self.state == State::Idle {
//
// Only ever taken once per session: it exists to catch a user
// tuning in mid-image with no APT start. After the first image,
// a new reception requires an APT start tone, so the residual
// carrier / noise that trails a finished chart cannot silently
// spawn a second image (which is what produced many chunks).
if self.state == State::Idle && !self.auto_start_used {
self.signal_detect_buf.extend_from_slice(&luminance);
let window_size = INTERNAL_RATE as usize / 2;
while self.signal_detect_buf.len() >= window_size {
@@ -340,6 +367,14 @@ impl WefaxDecoder {
break;
}
// Bound memory on an open carrier: finalize and wait
// for a fresh APT start rather than growing forever.
if count >= MAX_IMAGE_LINES {
debug!(lines = count, "WEFAX: max image lines — finalizing");
carrier_lost = true;
break;
}
// Emit progress event.
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
let line_data =
@@ -402,6 +437,7 @@ impl WefaxDecoder {
self.signal_detect_buf.clear();
self.low_corr_lines = 0;
self.phasing_samples = 0;
self.auto_start_used = false;
events
}
@@ -434,6 +470,7 @@ impl WefaxDecoder {
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
let ioc = self.config.ioc.unwrap_or(ioc);
debug!(ioc, "WEFAX: APT start detected");
self.auto_start_used = true;
self.state = State::StartDetected { ioc };
self.reception_start_ms = Some(
std::time::SystemTime::now()
@@ -463,6 +500,7 @@ impl WefaxDecoder {
self.image = Some(ImageAssembler::new(ppl));
self.tone_detector.reset();
self.low_corr_lines = 0;
self.auto_start_used = true;
self.state = State::Receiving { ioc, lpm };
self.state_event("Receiving", ioc, lpm)
}
@@ -596,4 +634,47 @@ mod tests {
dec.reset();
assert_eq!(dec.state, State::Idle);
}
/// Regression test for the over-chunking bug: once a session has produced
/// an image, the trailing carrier / noise must not silently auto-start a
/// second image. Only an APT start tone (or an explicit reset) may begin a
/// new reception after the first.
#[test]
fn variance_auto_start_only_fires_once_per_session() {
let mut dec = WefaxDecoder::new(11025, WefaxConfig::default());
// A transition rate that matches no APT tone → drives the variance
// based "strong signal" auto-start rather than a tone detection.
let strong = generate_apt_start(100.0, 11025, 4.0);
dec.process_samples(&strong);
assert!(
matches!(dec.state, State::Receiving { .. }),
"strong signal should auto-start one image, got {:?}",
dec.state
);
assert!(dec.auto_start_used);
// Simulate the image ending on carrier loss / stop (finalize → idle)
// WITHOUT an operator reset.
dec.transition_to_idle();
assert_eq!(dec.state, State::Idle);
// The still-present carrier must NOT spawn a second image.
dec.process_samples(&generate_apt_start(100.0, 11025, 4.0));
assert_eq!(
dec.state,
State::Idle,
"trailing signal must not re-auto-start a fresh image"
);
// An explicit reset re-arms mid-image auto-start.
dec.reset();
assert!(!dec.auto_start_used);
dec.process_samples(&generate_apt_start(100.0, 11025, 4.0));
assert!(
matches!(dec.state, State::Receiving { .. }),
"reset should re-arm variance auto-start, got {:?}",
dec.state
);
}
}
+54 -9
View File
@@ -12,8 +12,8 @@ use crate::config::WefaxConfig;
/// Line slicer for WEFAX image assembly.
pub struct LineSlicer {
/// Samples per line at the internal sample rate.
samples_per_line: usize,
/// Exact (fractional) samples per line at the internal sample rate.
samples_per_line: f64,
/// Pixels per line (IOC × π).
pixels_per_line: usize,
/// Phase offset in samples from the phasing detector.
@@ -22,22 +22,37 @@ pub struct LineSlicer {
buffer: Vec<f32>,
/// Whether we have aligned to the phase offset yet.
aligned: bool,
/// Index of the next line to emit. Boundaries are derived from this
/// against the exact fractional line length so rounding never accumulates.
line_index: u64,
}
impl LineSlicer {
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
let samples_per_line = WefaxConfig::samples_per_line_f64(lpm, sample_rate);
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
Self {
samples_per_line,
pixels_per_line,
phase_offset,
buffer: Vec::with_capacity(samples_per_line * 2),
buffer: Vec::with_capacity(samples_per_line as usize * 2),
aligned: false,
line_index: 0,
}
}
/// Number of samples in line `n`, from the exact fractional line length.
///
/// Boundaries are `round(n · spl)`; the per-line length is the difference
/// of successive boundaries, so lengths alternate (e.g. 5513/5512 for
/// 120 LPM at 11 025 Hz) with no cumulative drift.
fn line_len(&self, n: u64) -> usize {
let start = (n as f64 * self.samples_per_line).round() as u64;
let end = ((n + 1) as f64 * self.samples_per_line).round() as u64;
(end - start) as usize
}
/// Feed luminance samples and extract complete image lines.
///
/// Returns a vector of completed lines, each as a `Vec<u8>` of
@@ -56,12 +71,18 @@ impl LineSlicer {
}
// Extract complete lines (single drain at the end to avoid O(n²)).
// Line boundaries follow the exact fractional line length so the
// sample clock stays locked over a tall image (no accumulating slant).
let mut offset = 0;
while offset + self.samples_per_line <= self.buffer.len() {
let line_samples = &self.buffer[offset..offset + self.samples_per_line];
let pixels = self.resample_line(line_samples);
lines.push(pixels);
offset += self.samples_per_line;
loop {
let len = self.line_len(self.line_index);
if offset + len > self.buffer.len() {
break;
}
let line_samples = &self.buffer[offset..offset + len];
lines.push(self.resample_line(line_samples));
offset += len;
self.line_index += 1;
}
if offset > 0 {
self.buffer.drain(..offset);
@@ -77,6 +98,7 @@ impl LineSlicer {
pub fn reset(&mut self) {
self.buffer.clear();
self.aligned = false;
self.line_index = 0;
}
/// Resample a line's worth of luminance samples to the target pixel count
@@ -129,6 +151,29 @@ mod tests {
assert!(lines[0].iter().all(|&p| p == 255));
}
#[test]
fn slicer_no_cumulative_drift() {
// 120 LPM at 11 025 Hz is 5512.5 samples/line — not an integer. Slicing
// on the rounded value (5513) would lose a line every ~11 000 lines and
// slant the image; the fractional boundaries must not accumulate error.
let lpm = 120;
let ioc = 576;
let sr = 11025;
let spl = WefaxConfig::samples_per_line_f64(lpm, sr);
assert_ne!(spl.fract(), 0.0, "test premise: spl is fractional");
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
let total = (spl * 1000.0).round() as usize;
let samples = vec![1.0f32; total];
let lines = slicer.process(&samples);
assert_eq!(
lines.len(),
1000,
"exactly 1000 lines should fit in {} samples with no drift",
total
);
}
#[test]
fn slicer_linear_interpolation() {
let lpm = 120;
+5 -1
View File
@@ -78,7 +78,11 @@ pub struct ToneDetector {
impl ToneDetector {
pub fn new(sample_rate: u32) -> Self {
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
// APT start/stop tones are transmitted for ~5 s (WMO), so requiring a
// 2 s sustain costs no real detection latency while sharply cutting
// false positives from busy image content that momentarily produces a
// 300/450/675-transitions-per-second rate.
let min_sustain_s = 2.0;
let window_duration_s = window_size as f32 / sample_rate as f32;
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
+11 -5
View File
@@ -252,11 +252,17 @@ async fn async_init() -> DynResult<AppState> {
// Set HTTP frontend authentication config
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled;
frontend_runtime.http_auth.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
frontend_runtime.http_auth.control_passphrase =
cfg.frontends.http.auth.control_passphrase.clone();
frontend_runtime.http_auth.tx_access_control_enabled =
cfg.frontends.http.auth.tx_access_control_enabled;
frontend_runtime.http_auth.users_file = cfg.frontends.http.auth.users_file.clone();
frontend_runtime.http_auth.bootstrap_admin_username =
cfg.frontends.http.auth.bootstrap_admin_username.clone();
frontend_runtime.http_auth.bootstrap_admin_password =
cfg.frontends.http.auth.bootstrap_admin_password.clone();
frontend_runtime.http_auth.bootstrap_read_enabled =
cfg.frontends.http.auth.bootstrap_read_enabled;
frontend_runtime.http_auth.bootstrap_read_username =
cfg.frontends.http.auth.bootstrap_read_username.clone();
frontend_runtime.http_auth.bootstrap_read_password =
cfg.frontends.http.auth.bootstrap_read_password.clone();
frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs();
frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
+12 -6
View File
@@ -257,9 +257,12 @@ impl Default for DecodeHistoryContext {
/// HTTP authentication configuration.
pub struct HttpAuthConfig {
pub enabled: bool,
pub rx_passphrase: Option<String>,
pub control_passphrase: Option<String>,
pub tx_access_control_enabled: bool,
pub users_file: String,
pub bootstrap_admin_username: Option<String>,
pub bootstrap_admin_password: Option<String>,
pub bootstrap_read_enabled: bool,
pub bootstrap_read_username: String,
pub bootstrap_read_password: Option<String>,
pub session_ttl_secs: u64,
pub cookie_secure: bool,
pub cookie_same_site: String,
@@ -271,9 +274,12 @@ impl Default for HttpAuthConfig {
fn default() -> Self {
Self {
enabled: false,
rx_passphrase: None,
control_passphrase: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: None,
bootstrap_admin_password: None,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_secs: 480 * 60,
cookie_secure: false,
cookie_same_site: "Lax".to_string(),
@@ -28,6 +28,7 @@ flate2 = { workspace = true }
brotli = "7"
rand = "0.8"
hex = "0.4"
argon2 = "0.5"
pickledb = "0.5"
dirs = "6"
uuid = { workspace = true }
@@ -1,3 +1,20 @@
import {
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
changeOwnPassword,
createUser,
deleteUser,
fetchAuthSession,
hasAccountControls,
hasAuthRole,
listUsers,
login,
logout,
normalizeAuthRoles,
updateUser
} from "./chunk-PISLBJGN.js";
// src/webgl-renderer.ts
(function initTrxWebGl(global) {
"use strict";
@@ -1314,60 +1331,6 @@ async function loadDecoderRegistry(onLoaded) {
bridge.decoderRegistry = decoderRegistry;
bridge.onDecoderRegistryReady = onDecoderRegistryReady;
// src/api/auth.ts
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== void 0 && session.role !== "rx" && session.role !== "control") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = { authenticated: session.authenticated };
if (session.role !== void 0) decoded.role = session.role;
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
role: "control",
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false };
}
}
async function login(passphrase) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
return decodeAuthSession(await response.json());
}
async function logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
// src/core/format.ts
function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1e3);
@@ -1862,33 +1825,67 @@ function isVchanRdsEntry(value) {
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
}
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
var authRoles = [];
var authUsername = null;
var authEnabled = true;
function setAuthRoles(roles) {
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole2(role) {
return hasAuthRole(authRoles, role);
}
function buildRoleChoices(selected) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
const administrator = inputs.find((choice) => choice.value === "administrator");
if (administrator) administrator.input.checked = false;
} else if (value === "administrator") {
const guest = inputs.find((choice) => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
async function checkAuthStatus() {
return fetchAuthSession();
}
async function authLogin(passphrase) {
return login(passphrase);
async function authLogin(username, password) {
return login(username, password);
}
async function authLogout() {
try {
await logout();
authRole = null;
setAuthRoles([]);
authUsername = null;
disconnect();
setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none";
requiredElement("auth-passphrase").value = "";
requiredElement("auth-password").value = "";
updateAuthUI();
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
} catch (e) {
console.error("Logout failed:", e);
showAuthError("Logout failed");
}
}
function showAuthGate(allowGuest = false) {
function showAuthGate() {
if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none";
@@ -1905,10 +1902,6 @@ function showAuthGate(allowGuest = false) {
document.querySelectorAll(".tab-panel").forEach((panel) => {
panel.style.display = "none";
});
const guestBtn2 = document.getElementById("auth-guest-btn");
if (guestBtn2) {
guestBtn2.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main");
});
@@ -1944,20 +1937,30 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn2 = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRole) {
if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Logout";
headerAuthBtn2.style.display = "block";
}
} else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Login";
@@ -1967,31 +1970,34 @@ function updateAuthUI() {
syncTopBarAccess();
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRole === "rx") {
if (authRoles.length === 0) return;
if (!hasAuthRole2("transmit")) {
const pttBtn2 = document.getElementById("ptt-btn");
const txLimitInput2 = document.getElementById("tx-limit");
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
if (pttBtn2) pttBtn2.disabled = true;
if (txAudioBtn2) txAudioBtn2.disabled = true;
if (txLimitBtn2) txLimitBtn2.disabled = true;
if (txLimitInput2) txLimitInput2.disabled = true;
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
}
if (!hasAuthRole2("control")) {
const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn");
const freqInput = document.getElementById("freq");
const centerFreqInput = document.getElementById("center-freq");
const modeSelect = document.getElementById("mode");
const txLimitInput2 = document.getElementById("tx-limit");
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
const jogUp = document.getElementById("jog-up");
const jogDown = document.getElementById("jog-down");
const jogButtons = document.querySelectorAll(".jog-step button");
const vfoButtons = document.querySelectorAll("#vfo-picker button");
if (pttBtn2) pttBtn2.disabled = true;
if (powerBtn2) powerBtn2.disabled = true;
if (lockBtn2) lockBtn2.disabled = true;
if (txAudioBtn2) txAudioBtn2.disabled = true;
if (txLimitBtn2) txLimitBtn2.disabled = true;
if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true;
if (txLimitInput2) txLimitInput2.disabled = true;
vfoButtons.forEach((btn) => btn.disabled = true);
const jogWheel2 = document.getElementById("jog-wheel");
if (jogUp) jogUp.disabled = true;
@@ -2028,7 +2034,6 @@ function applyAuthRestrictions() {
btn.disabled = true;
}
});
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
}
}
function applyCapabilities(caps) {
@@ -2258,20 +2263,21 @@ window.applyDecodeHistoryRetention = function() {
}
};
function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole;
const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector(".header-rig-switch");
if (tabBar) tabBar.style.display = "";
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main";
btn.style.display = !loggedOut || isMain ? "" : "none";
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false;
});
if (rigSwitch) {
rigSwitch.style.display = loggedOut ? "none" : "";
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
}
}
var overviewDrawPending = false;
@@ -2907,7 +2913,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole2("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -4245,9 +4251,16 @@ function formatSignal(sUnits) {
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
}
function setDisabled(disabled) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
const controlDisabled = disabled || authEnabled && !hasAuthRole2("control");
const transmitDisabled = disabled || authEnabled && !hasAuthRole2("transmit");
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
if (el) el.disabled = controlDisabled;
});
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
if (el) el.disabled = transmitDisabled;
});
const transmitAudio = document.getElementById("tx-audio-btn");
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
syncModePicker();
}
var serverVersion = null;
@@ -4510,7 +4523,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "rx") {
if (!hasAuthRole2("control")) {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -5228,7 +5241,7 @@ async function postPath(path, options = {}) {
}
const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) {
authRole = null;
setAuthRoles([]);
if (es) es.close();
showAuthGate();
throw new Error("Authentication required");
@@ -5253,7 +5266,7 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
if (!hasAuthRole2("control")) {
showHint("Control role required", 1500);
return;
}
@@ -5867,8 +5880,13 @@ function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
}
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5981,11 +5999,11 @@ window.addEventListener("resize", () => {
scheduleSpectrumLayout();
});
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -5996,7 +6014,8 @@ async function initializeApp() {
return;
}
if (authStatus.authenticated) {
authRole = authStatus.role ?? null;
setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -6006,32 +6025,191 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
var settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole2("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list");
try {
const users = await listUsers();
const enabledAdminCount = users.filter((user) => user.enabled && hasAuthRole(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
const guestInput = roleInputs.find((item) => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input");
password.type = "password";
password.placeholder = "New password (8+ characters)";
password.autocomplete = "new-password";
password.className = "auth-input";
password.minLength = 8;
password.maxLength = 1024;
const syncPasswordAccess = () => {
const guestActive = guestInput?.checked === true;
password.disabled = guestActive;
password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : "";
if (guestActive) password.value = "";
};
roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess));
syncPasswordAccess();
const save = document.createElement("button");
save.type = "button";
save.textContent = "Save";
save.addEventListener("click", async () => {
const changes = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked
};
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Remove";
remove.className = "danger";
remove.disabled = user.username === authUsername || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
showUserManagementError(error);
}
}
function showUserManagementError(error) {
const element = document.getElementById("user-management-error");
if (!element) return;
element.textContent = error instanceof Error ? error.message : String(error);
element.style.display = "block";
}
async function runUserOperation(operation) {
try {
await operation();
const error = document.getElementById("user-management-error");
if (error) error.style.display = "none";
await refreshUserManagement();
} catch (reason) {
showUserManagementError(reason);
}
}
var createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password");
const enabled = requiredElement("user-create-enabled");
const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value), enabled.checked);
username.value = "";
password.value = "";
enabled.checked = true;
roles.forEach((input) => {
input.checked = input.value === "read";
});
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const currentPassword = requiredElement("account-current-password");
const newPassword = requiredElement("account-new-password");
const confirmPassword = requiredElement("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value).then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3e3);
}).catch((reason) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
}).finally(() => {
if (submit) submit.disabled = false;
});
});
requiredElement("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphraseEl = requiredElement("auth-passphrase");
const passphrase = passphraseEl.value;
const usernameEl = requiredElement("auth-username");
const passwordEl = requiredElement("auth-password");
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
if (!btn) return;
btn.disabled = true;
btn.textContent = "Logging in...";
try {
const result = await authLogin(passphrase);
authRole = result.role ?? null;
passphraseEl.value = "";
const result = await authLogin(usernameEl.value, passwordEl.value);
setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -6041,37 +6219,22 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
showAuthError("Invalid username or password");
console.error("Login error:", err);
} finally {
btn.disabled = false;
btn.textContent = "Login";
}
});
var guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
var headerAuthBtn = document.getElementById("header-auth-btn");
if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
if (authRole) {
if (authRoles.length > 0) {
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
}
} else {
showAuthGate(false);
showAuthGate();
}
});
}
@@ -6109,8 +6272,8 @@ Object.defineProperties(trxState, {
authEnabled: { get() {
return authEnabled;
} },
authRole: { get() {
return authRole;
authRoles: { get() {
return authRoles;
} },
decoderRegistry: { get() {
return decoderRegistry;
@@ -7182,6 +7345,10 @@ function startTxAudio() {
void stopTxAudio();
return;
}
if (authEnabled && !hasAuthRole2("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -13,7 +16,7 @@ var bgdWindow = window;
return d.id;
});
}
let backgroundDecodeRole = null;
let backgroundDecodeRoles = [];
let currentRigId = null;
let currentConfig = null;
let bookmarkList = [];
@@ -21,8 +24,8 @@ var bgdWindow = window;
let bgdDirty = false;
let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null;
function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role;
function initBackgroundDecode(rigId, roles) {
backgroundDecodeRoles = roles;
currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
@@ -358,7 +361,7 @@ var bgdWindow = window;
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole() {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostCore,
hostState
@@ -42,7 +45,7 @@ function bmEsc(str) {
return d.innerHTML;
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function bmSyncAccess() {
const canCtrl = bmCanControl();
@@ -0,0 +1,136 @@
// src/api/auth.ts
var AUTH_ROLES = ["guest", "read", "control", "transmit", "write", "administrator"];
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
var AUTH_ROLE_LABELS = {
guest: "Guest",
read: "Read",
control: "Control",
transmit: "Transmit",
write: "Write",
administrator: "Administrator"
};
function isAuthRole(value) {
return typeof value === "string" && AUTH_ROLES.includes(value);
}
function normalizeAuthRoles(roles) {
return AUTH_ROLES.filter((role) => roles.includes(role));
}
function hasAuthRole(roles, required) {
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && (roles.includes("control") || roles.includes("transmit"));
}
function hasAccountControls(roles) {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value, context) {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response")
};
if (session.username !== void 0) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false, roles: [] };
}
}
async function login(username, password) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
return decodeAuthSession(await response.json());
}
async function userRequest(path, init) {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
async function listUsers() {
const value = await userRequest("/auth/users").then((response) => response.json());
if (!Array.isArray(value) || !value.every((user) => {
if (typeof user !== "object" || user === null) return false;
const record = user;
return typeof record.username === "string" && typeof record.enabled === "boolean" && Array.isArray(record.roles) && record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return value.map((user) => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
async function createUser(username, password, roles, enabled = true) {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
async function updateUser(username, changes) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
async function changeOwnPassword(currentPassword, newPassword) {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
});
}
async function deleteUser(username) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
async function logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
export {
AUTH_ROLES,
AUTH_ADMIN_ROLES,
AUTH_ROLE_LABELS,
normalizeAuthRoles,
hasAuthRole,
hasAccountControls,
fetchAuthSession,
login,
listUsers,
createUser,
updateUser,
changeOwnPassword,
deleteUser,
logout
};
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostCore,
hostState
@@ -31,12 +34,25 @@ var importFile = el("log-import-file");
var exportLink = el("log-export-btn");
var clearBtn = el("log-clear-btn");
var saveBtn = el("log-save-btn");
var contestIdInput = el("log-contest-id");
var stxInput = el("log-stx");
var srxInput = el("log-srx");
var statisticsRows = el("log-statistics-rows");
var cabrilloContest = el("log-cbr-contest");
var cabrilloCallsign = el("log-cbr-callsign");
var cabrilloOperator = el("log-cbr-operator");
var cabrilloPower = el("log-cbr-power");
var cabrilloScore = el("log-cbr-score");
var cabrilloExport = el("log-cbr-export");
var entryStartedAt = null;
var entryRigId = null;
var entryRigName = null;
var entryGrid = null;
var qsos = [];
var workedRequest = 0;
function canWriteLogbook() {
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
else hostCore.showHint(message, 2e3);
@@ -55,6 +71,12 @@ function parseFreq(text) {
if (!Number.isFinite(value) || value <= 0) return null;
return value < 1e5 ? Math.round(value * 1e6) : Math.round(value);
}
function numberOrNull(text) {
const trimmed = (text ?? "").trim();
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
const value = Number(trimmed);
return Number.isSafeInteger(value) ? value : null;
}
function utcDate(iso) {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
@@ -96,7 +118,7 @@ function showClock(prefill) {
clockEl.classList.toggle("is-adrift", drift > 1e3);
}
function resetEntry() {
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) {
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
if (input) input.value = "";
}
if (workedEl) workedEl.textContent = "";
@@ -131,6 +153,13 @@ async function submitEntry(event) {
gridsquare: gridInput?.value ?? null,
name: nameInput?.value ?? null,
comment: commentInput?.value ?? null,
contest_id: contestIdInput?.value ?? null,
// The exchange is a number when it is a serial and a word when it is a
// zone or a section; both are kept, and the log writes whichever it has.
stx: numberOrNull(stxInput?.value),
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
srx: numberOrNull(srxInput?.value),
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
station_callsign: stationCallEl?.textContent?.trim() ?? null,
operator: operatorInput?.value ?? null,
my_gridsquare: entryGrid,
@@ -149,7 +178,9 @@ async function submitEntry(event) {
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
notify(`${call} logged`);
const sent = numberOrNull(stxInput?.value);
resetEntry();
if (stxInput && sent != null) stxInput.value = String(sent + 1);
await refreshLog();
} catch (error) {
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -201,10 +232,51 @@ async function refreshLog() {
summaryEl.textContent = query ? `${String(qsos.length)} of ${String(answer.total)} contacts` : `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
}
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
await refreshStatistics();
} catch (error) {
console.error("logbook read failed", error);
}
}
async function refreshStatistics() {
if (!statisticsRows) return;
try {
const answer = await getJson("/api/logbook/statistics");
if (answer.bands.length === 0) {
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
return;
}
const fragment = document.createDocumentFragment();
for (const band of answer.bands) {
const row = document.createElement("tr");
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
const cell = document.createElement("td");
cell.textContent = String(value);
row.appendChild(cell);
}
fragment.appendChild(row);
}
statisticsRows.replaceChildren(fragment);
} catch (error) {
console.error("logbook statistics failed", error);
}
}
function syncCabrilloLink() {
if (!cabrilloExport) return;
const params = new URLSearchParams();
const contest = cabrilloContest?.value.trim();
if (contest) {
params.set("contest", contest);
}
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
if (callsign) params.set("callsign", callsign);
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
const score = numberOrNull(cabrilloScore?.value);
if (score != null) params.set("claimed_score", String(score));
const operator = operatorInput?.value.trim();
if (operator) params.set("operators", operator);
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
}
function renderRows() {
if (!rowsBody) return;
if (qsos.length === 0) {
@@ -224,7 +296,8 @@ function renderRows() {
qso.rst_sent ?? "",
qso.rst_rcvd ?? "",
qso.gridsquare ?? "",
qso.my_rig ?? ""
qso.my_rig ?? "",
qso.confirmed ? "✓" : ""
];
for (const [index, value] of cells.entries()) {
const cell = document.createElement("td");
@@ -233,6 +306,20 @@ function renderRows() {
row.appendChild(cell);
}
const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "log-row-btn";
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
confirm.title = qso.confirmed ? "Mark this contact as not confirmed" : "Mark this contact confirmed by QSL";
confirm.addEventListener("click", () => {
void setConfirmed(qso, !qso.confirmed);
});
actions.appendChild(confirm);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "log-row-btn";
@@ -262,6 +349,24 @@ function renderFilterOptions() {
select.value = chosen;
}
}
async function setConfirmed(qso, confirmed) {
try {
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...qso,
// A card is a card: this is the paper one, and an electronic
// confirmation the log already holds is left where it is.
qsl_rcvd: confirmed ? "Y" : "N"
})
});
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
await refreshLog();
} catch (error) {
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function deleteQso(qso) {
const confirmed = await bridge.trxUi.confirm({
title: "Delete this contact?",
@@ -314,6 +419,10 @@ for (const control of [filterCall, filterBand, filterMode]) {
void refreshLog();
});
}
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
control?.addEventListener("input", syncCabrilloLink);
control?.addEventListener("change", syncCabrilloLink);
}
importBtn?.addEventListener("click", () => {
importFile?.click();
});
@@ -322,10 +431,19 @@ importFile?.addEventListener("change", () => {
if (file) void importAdif(file);
importFile.value = "";
});
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation();
void openEntry();
if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
}
syncCabrilloLink();
if (canWriteLogbook()) void openEntry();
void refreshLog();
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -15,7 +18,7 @@ function schedulerOptionalEl(id) {
}
(function() {
"use strict";
let schedulerRole = null;
let schedulerRoles = [];
let currentRigId = null;
let currentConfig = null;
let currentSchedulerStatus = null;
@@ -25,8 +28,8 @@ function schedulerOptionalEl(id) {
let schedulerStepPending = false;
let schEntryEditIdx = null;
let schedulerDirty = false;
function initScheduler(rigId, role) {
schedulerRole = role;
function initScheduler(rigId, roles) {
schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -272,7 +275,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
const enabled = hasAuthRole(schedulerRoles, "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
prevBtn.disabled = !enabled;
nextBtn.disabled = !enabled;
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
@@ -354,7 +357,7 @@ function schedulerOptionalEl(id) {
const panel = schedulerEl("scheduler-panel");
if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "control";
const isControl = hasAuthRole(schedulerRoles, "control");
setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row");
@@ -1220,8 +1223,8 @@ function schedulerOptionalEl(id) {
markDirty: markSchedulerDirty
};
schedulerWindow.trx.modules.scheduler = schedulerService;
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
@@ -123,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="auth-gate" class="auth-gate" style="display:none;">
<div class="auth-gate-head">
<div class="auth-gate-title">Access Required</div>
<div class="auth-gate-sub">Enter passphrase to continue</div>
<div class="auth-gate-sub">Sign in to continue</div>
</div>
<form id="auth-form" class="auth-form">
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
<input type="text" id="auth-username" class="auth-input" placeholder="Username" autocomplete="username" required />
<input type="password" id="auth-password" class="auth-input" placeholder="Password" autocomplete="current-password" maxlength="1024" required />
<button type="submit" class="auth-submit">Login</button>
</form>
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
<div id="auth-error" class="auth-error" style="display: none;"></div>
<div id="auth-role" class="auth-role" style="display: none;"></div>
</div>
@@ -543,6 +543,24 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input type="text" id="log-comment" class="status-input" />
</label>
</div>
<details class="log-contest" id="log-contest-block">
<summary>Contest exchange</summary>
<div class="log-entry-grid">
<label class="log-field">
<span>Contest</span>
<input type="text" id="log-contest-id" class="status-input" spellcheck="false"
placeholder="CQ-WW-SSB" />
</label>
<label class="log-field log-field-narrow">
<span>Serial sent</span>
<input type="text" id="log-stx" class="status-input" inputmode="numeric" />
</label>
<label class="log-field log-field-narrow">
<span>Received</span>
<input type="text" id="log-srx" class="status-input" />
</label>
</div>
</details>
<div class="log-entry-actions">
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
<span class="log-entry-buttons">
@@ -581,6 +599,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<th scope="col">Rcvd</th>
<th scope="col">Locator</th>
<th scope="col">Rig</th>
<th scope="col">QSL</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
@@ -588,6 +607,68 @@ SPDX-License-Identifier: GPL-2.0-or-later
</table>
</div>
<div id="log-summary" class="log-summary" aria-live="polite"></div>
<details class="log-report" id="log-statistics-block">
<summary>Bands worked</summary>
<div class="log-table-wrap">
<table class="log-table">
<thead>
<tr>
<th scope="col">Band</th>
<th scope="col">Contacts</th>
<th scope="col">Stations</th>
<th scope="col">Confirmed</th>
</tr>
</thead>
<tbody id="log-statistics-rows"></tbody>
</table>
</div>
</details>
<details class="log-report" id="log-contest-export-block">
<summary>Contest entry (Cabrillo)</summary>
<p class="log-report-note">
Contest logs are submitted in Cabrillo and rejected in anything else.
Only the contacts of the contest named here go into the entry.
</p>
<div class="log-entry-grid">
<label class="log-field">
<span>Contest</span>
<input type="text" id="log-cbr-contest" class="status-input" spellcheck="false"
placeholder="CQ-WW-SSB" />
</label>
<label class="log-field">
<span>Callsign</span>
<input type="text" id="log-cbr-callsign" class="status-input" spellcheck="false" />
</label>
<label class="log-field">
<span>Operators</span>
<select id="log-cbr-operator" class="status-input">
<option value="SINGLE-OP">Single operator</option>
<option value="MULTI-OP">Multi operator</option>
<option value="CHECKLOG">Checklog</option>
</select>
</label>
<label class="log-field">
<span>Power</span>
<select id="log-cbr-power" class="status-input">
<option value="LOW">Low</option>
<option value="HIGH">High</option>
<option value="QRP">QRP</option>
</select>
</label>
<label class="log-field log-field-narrow">
<span>Claimed score</span>
<input type="text" id="log-cbr-score" class="status-input" inputmode="numeric" />
</label>
</div>
<div class="log-entry-actions">
<span class="log-entry-buttons">
<a id="log-cbr-export" class="log-secondary-btn" href="/api/logbook/export.cbr"
download>Export Cabrillo</a>
</span>
</div>
</details>
</div>
</div>
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
@@ -1380,6 +1461,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
<button class="sub-tab" data-subtab="settings-history">History</button>
<button id="settings-account-tab" class="sub-tab" data-subtab="settings-account" style="display:none;">Account</button>
<button id="settings-users-tab" class="sub-tab" data-subtab="settings-users" style="display:none;">Users</button>
</div>
<div id="subtab-settings-scheduler" class="sub-tab-panel">
<div id="scheduler-panel" class="sch-panel">
@@ -1669,6 +1752,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<div id="subtab-settings-account" class="sub-tab-panel" style="display:none;">
<div class="settings-card">
<h3>Change password</h3>
<form id="account-password-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="account-current-password" class="auth-input" type="password" placeholder="Current password" autocomplete="current-password" maxlength="1024" required />
<input id="account-new-password" class="auth-input" type="password" placeholder="New password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<input id="account-confirm-password" class="auth-input" type="password" placeholder="Confirm new password" autocomplete="new-password" minlength="8" maxlength="1024" required />
<button type="submit" class="auth-submit">Change password</button>
</form>
<div id="account-password-error" class="auth-error" role="alert" aria-live="polite" style="display:none;"></div>
<p class="settings-note">Changing your password signs out every session for this account.</p>
</div>
</div>
<div id="subtab-settings-users" class="sub-tab-panel" style="display:none;">
<div id="user-management">
<div class="settings-card">
<form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="user-create-username" class="auth-input" placeholder="Username" autocomplete="off" required />
<input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<span id="user-create-roles" class="auth-role-choices"></span>
<label class="auth-role-choice"><input id="user-create-enabled" type="checkbox" checked /> Enabled</label>
<button type="submit" class="auth-submit">Add user</button>
</form>
<div id="user-management-error" class="auth-error" style="display:none;"></div>
<div id="user-list" style="margin-top:.75rem;"></div>
</div>
</div>
</div>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2>
@@ -196,8 +196,7 @@ body {
font-size: var(--fs-base);
box-sizing: border-box;
}
.auth-submit,
.auth-guest {
.auth-submit {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-md);
@@ -213,16 +212,14 @@ body {
font-weight: 700;
}
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
.auth-guest {
background: var(--btn-bg);
color: var(--text);
border: 1px solid var(--border-light);
font-weight: 600;
margin-top: var(--space-4);
}
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
#user-management .auth-input { width: auto; min-width: 9rem; flex: 1 1 10rem; margin-bottom: 0; }
#user-management .auth-submit { width: auto; }
#user-management .auth-role-choices { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
#user-management .auth-role-choice { display: inline-flex; align-items: center; gap: .2rem; white-space: nowrap; }
.settings-note { color: var(--text-muted); font-size: .85rem; margin: .75rem 0 0; }
#user-management button { padding: 0.55rem 0.75rem; }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
#tab-main .label > span {
@@ -6633,3 +6630,35 @@ body[data-operator-layout="ham"] #rds-panel { display: none !important; }
flex: 1 1 8rem;
}
}
/* Contest exchange and the reports below the log: folded away, because most
operating is not a contest and most sessions do not export one. */
.log-contest,
.log-report {
border: 1px solid var(--border-light);
border-radius: 0.5rem;
background: var(--bg-secondary);
padding: 0.5rem 0.85rem;
}
.log-contest {
margin-top: 0.75rem;
}
.log-contest > summary,
.log-report > summary {
cursor: pointer;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.log-contest[open] > summary,
.log-report[open] > summary {
margin-bottom: 0.65rem;
}
.log-report-note {
margin: 0 0 0.65rem;
max-width: 62ch;
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.45;
}
@@ -15,6 +15,7 @@ use trx_core::rig::{
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta;
use trx_frontend_http::server::auth::AuthRole;
use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS};
@@ -56,6 +57,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigListItem);
export!(RigListResponse);
export!(FrontendMeta);
export!(AuthRole);
export!(DecoderActivation);
export!(DecoderDescriptor);
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs && node tests/transmit-role.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -2,11 +2,51 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "rx" | "control";
import type { AuthRole } from "./generated.js";
export type { AuthRole };
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "transmit", "write", "administrator"];
export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest");
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
guest: "Guest",
read: "Read",
control: "Control",
transmit: "Transmit",
write: "Write",
administrator: "Administrator",
};
export function isAuthRole(value: unknown): value is AuthRole {
return typeof value === "string" && (AUTH_ROLES as readonly string[]).includes(value);
}
export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
return AUTH_ROLES.filter(role => roles.includes(role));
}
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
return roles.includes("administrator")
|| roles.includes(required)
|| required === "read" && roles.includes("guest")
|| required === "read" && (roles.includes("control") || roles.includes("transmit"));
}
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value: unknown, context: string): AuthRole[] {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
export interface AuthSession {
authenticated: boolean;
role?: AuthRole;
roles: AuthRole[];
username?: string;
auth_disabled?: boolean;
}
@@ -18,21 +58,24 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== undefined && session.role !== "rx" && session.role !== "control") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded: AuthSession = { authenticated: session.authenticated };
if (session.role !== undefined) decoded.role = session.role;
const decoded: AuthSession = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response"),
};
if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
const authDisabledSession: AuthSession = {
authenticated: true,
role: "control",
roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true,
};
@@ -40,19 +83,19 @@ export async function fetchAuthSession(): Promise<AuthSession> {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false };
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error: unknown) {
console.error("Auth check failed:", error);
return { authenticated: false };
return { authenticated: false, roles: [] };
}
}
export async function login(passphrase: string): Promise<AuthSession> {
export async function login(username: string, password: string): Promise<AuthSession> {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
body: JSON.stringify({ username, password }),
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
@@ -62,6 +105,52 @@ export async function login(passphrase: string): Promise<AuthSession> {
return decodeAuthSession(await response.json());
}
export interface ManagedUser { username: string; roles: AuthRole[]; enabled: boolean }
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string };
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
export async function listUsers(): Promise<ManagedUser[]> {
const value: unknown = await userRequest("/auth/users").then(response => response.json());
if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false;
const record = user as Record<string, unknown>;
return typeof record.username === "string"
&& typeof record.enabled === "boolean"
&& Array.isArray(record.roles)
&& record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return (value as ManagedUser[]).map(user => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
export async function createUser(username: string, password: string, roles: AuthRole[], enabled = true): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[]; enabled?: boolean }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
export async function changeOwnPassword(currentPassword: string, newPassword: string): Promise<void> {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
}
export async function deleteUser(username: string): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
export async function logout(): Promise<void> {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
@@ -123,6 +123,8 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
export type AuthRole = "guest" | "read" | "control" | "transmit" | "write" | "administrator";
export type DecoderActivation = "mode_bound" | "toggle";
export type DecoderDescriptor = {
@@ -21,6 +21,17 @@ import {
fetchAuthSession,
login,
logout,
listUsers,
createUser,
updateUser,
deleteUser,
changeOwnPassword,
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
hasAccountControls,
hasAuthRole as rolesInclude,
normalizeAuthRoles,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
@@ -189,8 +200,8 @@ interface TrxModules {
reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
bandForHz(frequencyHz: number): unknown;
};
scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
scheduler?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
bookmarks?: {
readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number;
@@ -226,7 +237,7 @@ interface TrxState {
readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean;
readonly authRole: AuthRole | null;
readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null;
readonly primaryRds: RdsData | null;
@@ -406,40 +417,76 @@ declare global {
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication ---
let authRole: AuthRole | null = null;
let authRoles: AuthRole[] = [];
let authUsername: string | null = null;
let authEnabled = true;
function setAuthRoles(roles: readonly AuthRole[]) {
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole(role: AuthRole) {
return rolesInclude(authRoles, role);
}
function buildRoleChoices(selected: readonly AuthRole[]) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
const administrator = inputs.find(choice => choice.value === "administrator");
if (administrator) administrator.input.checked = false;
} else if (value === "administrator") {
const guest = inputs.find(choice => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
async function checkAuthStatus() {
return fetchAuthSession();
}
async function authLogin(passphrase: string) {
return login(passphrase);
async function authLogin(username: string, password: string) {
return login(username, password);
}
async function authLogout() {
try {
await logout();
authRole = null;
setAuthRoles([]);
authUsername = null;
// Disconnect and show auth gate without page reload
disconnect();
setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
requiredElement<HTMLInputElement>("auth-password").value = "";
updateAuthUI();
// Check if guest mode is available after logout
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
} catch (e) {
console.error("Logout failed:", e);
showAuthError("Logout failed");
}
}
function showAuthGate(allowGuest = false) {
function showAuthGate() {
if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none";
@@ -459,12 +506,6 @@ function showAuthGate(allowGuest = false) {
panel.style.display = "none";
});
// Show guest button if guest mode is available
const guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main");
});
@@ -506,22 +547,32 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn) headerAuthBtn.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRole) {
if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn) {
headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block";
}
} else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn) {
headerAuthBtn.textContent = "Login";
@@ -532,37 +583,40 @@ function updateAuthUI() {
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRoles.length === 0) return;
// Disable TX/PTT/frequency/mode/VFO controls for rx role
if (authRole === "rx") {
if (!hasAuthRole("transmit")) {
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
if (pttBtn) pttBtn.disabled = true;
if (txAudioBtn) txAudioBtn.disabled = true;
if (txLimitBtn) txLimitBtn.disabled = true;
if (txLimitInput) txLimitInput.disabled = true;
if (txLimitRow) txLimitRow.style.opacity = "0.5";
}
// General tuning and receive-side controls require Control.
if (!hasAuthRole("control")) {
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
const modeSelect = document.getElementById("mode") as HTMLSelectElement | null;
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
// Disable TX buttons
if (pttBtn) pttBtn.disabled = true;
if (powerBtn) powerBtn.disabled = true;
if (lockBtn) lockBtn.disabled = true;
if (txAudioBtn) txAudioBtn.disabled = true;
if (txLimitBtn) txLimitBtn.disabled = true;
// Disable frequency/mode inputs
if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true;
if (txLimitInput) txLimitInput.disabled = true;
// Disable VFO selector
vfoButtons.forEach(btn => btn.disabled = true);
@@ -606,9 +660,6 @@ function applyAuthRestrictions() {
btn.disabled = true;
}
});
// Hide TX-specific UI but keep controls visible (disabled)
if (txLimitRow) txLimitRow.style.opacity = "0.5";
}
}
@@ -867,14 +918,15 @@ window.applyDecodeHistoryRetention = function() {
};
function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole;
const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
if (tabBar) tabBar.style.display = "";
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main";
btn.style.display = !loggedOut || isMain ? "" : "none";
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false;
});
@@ -883,7 +935,7 @@ function syncTopBarAccess() {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
}
}
@@ -1464,7 +1516,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -3029,9 +3081,16 @@ function formatSignal(sUnits: number) {
}
function setDisabled(disabled: boolean) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit"));
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
if (el) el.disabled = controlDisabled;
});
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
if (el) el.disabled = transmitDisabled;
});
const transmitAudio = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
syncModePicker();
}
@@ -3352,7 +3411,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link: TuneLink) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "rx") {
if (!hasAuthRole("control")) {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -4162,7 +4221,7 @@ async function postPath(path: string, options: PostOptions = {}) {
const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) {
// Not authenticated - return to login
authRole = null;
setAuthRoles([]);
if (es) es.close();
showAuthGate();
throw new Error("Authentication required");
@@ -4191,7 +4250,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
if (!hasAuthRole("control")) {
showHint("Control role required", 1500);
return;
}
@@ -4874,8 +4933,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
}
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5008,12 +5072,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence ---
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -5026,7 +5090,8 @@ async function initializeApp() {
if (authStatus.authenticated) {
// User has valid session
authRole = authStatus.role ?? null;
setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5036,10 +5101,7 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
// No valid session - show auth gate
// Guest button is shown if guest mode is available (role granted without auth)
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
@@ -5049,28 +5111,186 @@ let settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list");
try {
const users = await listUsers();
const enabledAdminCount = users.filter(user => user.enabled && rolesInclude(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled
&& rolesInclude(user.roles, "administrator")
&& enabledAdminCount === 1;
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
const guestInput = roleInputs.find(item => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input");
password.type = "password"; password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; password.maxLength = 1024;
const syncPasswordAccess = () => {
const guestActive = guestInput?.checked === true;
password.disabled = guestActive;
password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : "";
if (guestActive) password.value = "";
};
roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess));
syncPasswordAccess();
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
save.addEventListener("click", async () => {
const changes: { roles?: AuthRole[]; password?: string; enabled?: boolean } = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked,
};
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
remove.disabled = user.username === authUsername || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
showUserManagementError(error);
}
}
function showUserManagementError(error: unknown) {
const element = document.getElementById("user-management-error");
if (!element) return;
element.textContent = error instanceof Error ? error.message : String(error);
element.style.display = "block";
}
async function runUserOperation(operation: () => Promise<void>) {
try {
await operation();
const error = document.getElementById("user-management-error");
if (error) error.style.display = "none";
await refreshUserManagement();
} catch (reason) {
showUserManagementError(reason);
}
}
const createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement<HTMLInputElement>("user-create-username");
const password = requiredElement<HTMLInputElement>("user-create-password");
const enabled = requiredElement<HTMLInputElement>("user-create-enabled");
const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole), enabled.checked);
username.value = ""; password.value = "";
enabled.checked = true;
roles.forEach(input => { input.checked = input.value === "read"; });
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const currentPassword = requiredElement<HTMLInputElement>("account-current-password");
const newPassword = requiredElement<HTMLInputElement>("account-new-password");
const confirmPassword = requiredElement<HTMLInputElement>("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value)
.then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3000);
})
.catch((reason: unknown) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
})
.finally(() => {
if (submit) submit.disabled = false;
});
});
// Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase");
const passphrase = passphraseEl.value;
const usernameEl = requiredElement<HTMLInputElement>("auth-username");
const passwordEl = requiredElement<HTMLInputElement>("auth-password");
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]");
if (!btn) return;
btn.disabled = true;
btn.textContent = "Logging in...";
try {
const result = await authLogin(passphrase);
authRole = result.role ?? null;
passphraseEl.value = "";
const result = await authLogin(usernameEl.value, passwordEl.value);
setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5080,7 +5300,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
showAuthError("Invalid username or password");
console.error("Login error:", err);
} finally {
btn.disabled = false;
@@ -5088,35 +5308,18 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
}
});
// Setup guest button
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
// Setup header auth button (Login/Logout)
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
if (authRole) {
if (authRoles.length > 0) {
// Logged in - show logout confirmation
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
}
} else {
// Not logged in - show auth gate
showAuthGate(false);
showAuthGate();
}
});
}
@@ -5138,7 +5341,7 @@ Object.defineProperties(trxState, {
initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } },
authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } },
primaryRds: { get() { return primaryRds; } },
@@ -6183,6 +6386,10 @@ function stopRxAudio() {
function startTxAudio() {
if (txActive) { void stopTxAudio(); return; }
if (authEnabled && !hasAuthRole("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
export {};
@@ -44,7 +45,7 @@ interface BackgroundBridge {
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
}
interface BackgroundDecodeService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
wireEvents(): void;
setRig(rigId: string | null): void;
}
@@ -60,7 +61,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
.map(function (d) { return d.id; });
}
let backgroundDecodeRole: string | null = null;
let backgroundDecodeRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList: Bookmark[] = [];
@@ -70,8 +71,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role;
function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
backgroundDecodeRoles = roles;
// The panel used to take whatever rig it was handed at load and wait to be
// told again. Loading before the rig list arrives handed it null, and the
// next telling only came when the operator switched rigs, so the panel sat
@@ -468,7 +469,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
@@ -100,7 +101,8 @@ function bmEsc(str: unknown): string {
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -11,6 +11,8 @@
// feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes.
import type { AuthRole } from "../api/auth.js";
export interface HostDecoderDescriptor {
id: string;
label: string;
@@ -25,7 +27,7 @@ export interface HostState {
/** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null;
readonly authEnabled: boolean;
readonly authRole: string | null;
readonly authRoles: readonly AuthRole[];
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>;
@@ -10,6 +10,7 @@
// keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
@@ -32,6 +33,20 @@ interface Qso {
my_gridsquare?: string | null;
my_rig?: string | null;
rig_id?: string | null;
contest_id?: string | null;
stx?: number | null;
srx?: number | null;
srx_string?: string | null;
qsl_rcvd?: string | null;
lotw_qsl_rcvd?: string | null;
confirmed?: boolean;
}
interface BandStatistics {
band: string;
contacts: number;
stations: number;
confirmed: number;
}
interface Prefill {
@@ -86,6 +101,16 @@ const importFile = el<HTMLInputElement>("log-import-file");
const exportLink = el<HTMLAnchorElement>("log-export-btn");
const clearBtn = el<HTMLButtonElement>("log-clear-btn");
const saveBtn = el<HTMLButtonElement>("log-save-btn");
const contestIdInput = el<HTMLInputElement>("log-contest-id");
const stxInput = el<HTMLInputElement>("log-stx");
const srxInput = el<HTMLInputElement>("log-srx");
const statisticsRows = el<HTMLTableSectionElement>("log-statistics-rows");
const cabrilloContest = el<HTMLInputElement>("log-cbr-contest");
const cabrilloCallsign = el<HTMLInputElement>("log-cbr-callsign");
const cabrilloOperator = el<HTMLSelectElement>("log-cbr-operator");
const cabrilloPower = el<HTMLSelectElement>("log-cbr-power");
const cabrilloScore = el<HTMLInputElement>("log-cbr-score");
const cabrilloExport = el<HTMLAnchorElement>("log-cbr-export");
/** The time the open entry was started, as the server gave it. */
let entryStartedAt: string | null = null;
@@ -96,6 +121,11 @@ let entryGrid: string | null = null;
let qsos: Qso[] = [];
let workedRequest = 0;
function canWriteLogbook(): boolean {
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
function notify(message: string, kind?: string): void {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
else hostCore.showHint(message, 2000);
@@ -121,6 +151,14 @@ function parseFreq(text: string): number | null {
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
}
/** A serial, or null when the exchange is a word rather than a number. */
function numberOrNull(text: string | undefined): number | null {
const trimmed = (text ?? "").trim();
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
const value = Number(trimmed);
return Number.isSafeInteger(value) ? value : null;
}
function utcDate(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
@@ -176,7 +214,9 @@ function showClock(prefill: Prefill): void {
/** Clear the entry and open a fresh one. */
function resetEntry(): void {
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput]) {
// The contest and the serial sent stay: they belong to the session, not to
// the contact just logged.
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
if (input) input.value = "";
}
if (workedEl) workedEl.textContent = "";
@@ -214,6 +254,13 @@ async function submitEntry(event: Event): Promise<void> {
gridsquare: gridInput?.value ?? null,
name: nameInput?.value ?? null,
comment: commentInput?.value ?? null,
contest_id: contestIdInput?.value ?? null,
// The exchange is a number when it is a serial and a word when it is a
// zone or a section; both are kept, and the log writes whichever it has.
stx: numberOrNull(stxInput?.value),
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
srx: numberOrNull(srxInput?.value),
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
station_callsign: stationCallEl?.textContent?.trim() ?? null,
operator: operatorInput?.value ?? null,
my_gridsquare: entryGrid,
@@ -232,7 +279,11 @@ async function submitEntry(event: Event): Promise<void> {
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
}
notify(`${call} logged`);
// A contest runs on serials: the next one is this one plus one, so it is
// not retyped forty times an hour.
const sent = numberOrNull(stxInput?.value);
resetEntry();
if (stxInput && sent != null) stxInput.value = String(sent + 1);
await refreshLog();
} catch (error: unknown) {
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -292,11 +343,57 @@ async function refreshLog(): Promise<void> {
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
}
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
await refreshStatistics();
} catch (error: unknown) {
console.error("logbook read failed", error);
}
}
/** What has been worked and confirmed, band by band. */
async function refreshStatistics(): Promise<void> {
if (!statisticsRows) return;
try {
const answer = await getJson<{ bands: BandStatistics[] }>("/api/logbook/statistics");
if (answer.bands.length === 0) {
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
return;
}
const fragment = document.createDocumentFragment();
for (const band of answer.bands) {
const row = document.createElement("tr");
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
const cell = document.createElement("td");
cell.textContent = String(value);
row.appendChild(cell);
}
fragment.appendChild(row);
}
statisticsRows.replaceChildren(fragment);
} catch (error: unknown) {
console.error("logbook statistics failed", error);
}
}
/** The Cabrillo link, carrying the header the operator filled in. */
function syncCabrilloLink(): void {
if (!cabrilloExport) return;
const params = new URLSearchParams();
const contest = cabrilloContest?.value.trim();
if (contest) {
// Only that contest's contacts belong in the entry.
params.set("contest", contest);
}
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
if (callsign) params.set("callsign", callsign);
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
const score = numberOrNull(cabrilloScore?.value);
if (score != null) params.set("claimed_score", String(score));
const operator = operatorInput?.value.trim();
if (operator) params.set("operators", operator);
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
}
function renderRows(): void {
if (!rowsBody) return;
if (qsos.length === 0) {
@@ -319,6 +416,7 @@ function renderRows(): void {
qso.rst_rcvd ?? "",
qso.gridsquare ?? "",
qso.my_rig ?? "",
qso.confirmed ? "✓" : "",
];
for (const [index, value] of cells.entries()) {
const cell = document.createElement("td");
@@ -327,6 +425,22 @@ function renderRows(): void {
row.appendChild(cell);
}
const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
// Confirming is the commonest edit a log gets, so it is a button rather
// than a form: a card arrives, and the contact counts towards an award.
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "log-row-btn";
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
confirm.title = qso.confirmed
? "Mark this contact as not confirmed"
: "Mark this contact confirmed by QSL";
confirm.addEventListener("click", () => { void setConfirmed(qso, !qso.confirmed); });
actions.appendChild(confirm);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "log-row-btn";
@@ -357,6 +471,26 @@ function renderFilterOptions(): void {
}
}
/** Record, or withdraw, the other station's confirmation. */
async function setConfirmed(qso: Qso, confirmed: boolean): Promise<void> {
try {
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...qso,
// A card is a card: this is the paper one, and an electronic
// confirmation the log already holds is left where it is.
qsl_rcvd: confirmed ? "Y" : "N",
}),
});
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
await refreshLog();
} catch (error: unknown) {
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
}
}
async function deleteQso(qso: Qso): Promise<void> {
const confirmed = await bridge.trxUi.confirm({
title: "Delete this contact?",
@@ -412,6 +546,10 @@ for (const control of [filterCall, filterBand, filterMode]) {
control?.addEventListener("input", () => { void refreshLog(); });
control?.addEventListener("change", () => { void refreshLog(); });
}
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
control?.addEventListener("input", syncCabrilloLink);
control?.addEventListener("change", syncCabrilloLink);
}
importBtn?.addEventListener("click", () => { importFile?.click(); });
importFile?.addEventListener("change", () => {
const file = importFile.files?.[0];
@@ -420,11 +558,20 @@ importFile?.addEventListener("change", () => {
});
/** Start an entry from a decode, and show the operator where it went. */
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation();
void openEntry();
if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
}
syncCabrilloLink();
if (canWriteLogbook()) void openEntry();
void refreshLog();
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
import type { AuthRole } from "../api/auth.js";
export type SchedulerMode = "disabled" | "grayline" | "time_span";
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
}
export interface SchedulerService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
destroy(): void;
setRig(rigId: string | null): void;
wireEvents(): void;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
import type {
ScheduleEntry,
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// State
// -------------------------------------------------------------------------
let schedulerRole: string | null = null;
let schedulerRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: SchedulerConfig | null = null;
let currentSchedulerStatus: SchedulerStatus | null = null;
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function initScheduler(rigId: string | null, role: string | null): void {
schedulerRole = role;
function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled =
schedulerRole === "control" &&
hasAuthRole(schedulerRoles, "control") &&
!!currentRigId &&
!schedulerStepPending &&
state.activeEntries.length > 1;
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "control";
const isControl = hasAuthRole(schedulerRoles, "control");
// Mode selector
setSelected("scheduler-mode-select", mode);
@@ -1574,8 +1575,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here.
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"];
const fixture = await startWebFixture({
authSession: {
authenticated: true,
username: "admin",
roles: ALL_ROLES,
auth_disabled: false,
},
users: [
{ username: "admin", roles: ALL_ROLES, enabled: true },
{ username: "guest", roles: ["guest"], enabled: true },
{ username: "listener", roles: ["read"], enabled: false },
],
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").waitFor({ state: "visible" });
await page.locator("#settings-users-tab").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").click();
assert.equal(await page.locator("#account-password-form").isVisible(), true);
assert.equal(await page.locator("#subtab-settings-users").isVisible(), false);
await page.locator("#settings-users-tab").click();
await page.locator("#user-list").getByText("listener (disabled)").waitFor();
assert.equal(await page.locator("#user-create-form").isVisible(), true);
const state = await page.evaluate(() => {
const rows = [...document.querySelectorAll("#user-list > .sch-row")];
const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username));
const admin = rowFor("admin");
const guest = rowFor("guest");
const listener = rowFor("listener");
const role = (row, value) => row?.querySelector(`input[value="${value}"]`);
return {
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
adminRoleLocked: role(admin, "administrator")?.disabled,
adminGuestLocked: role(admin, "guest")?.disabled,
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
guestPasswordLocked: guest?.querySelector('input[type="password"]')?.disabled,
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
listenerRead: role(listener, "read")?.checked,
};
});
assert.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]);
assert.equal(state.adminEnabledLocked, true);
assert.equal(state.adminRoleLocked, true);
assert.equal(state.adminGuestLocked, true);
assert.equal(state.adminRemoveLocked, true);
assert.equal(state.guestPasswordLocked, true);
assert.equal(state.listenerEnabled, false);
assert.equal(state.listenerRead, true);
const listenerRow = page.locator("#user-list > .sch-row").filter({ hasText: "listener" });
const listenerPassword = listenerRow.locator('input[type="password"]');
await listenerRow.locator('input[value="guest"]').check();
assert.equal(await listenerPassword.isDisabled(), true);
assert.equal(await listenerRow.locator('input[value="read"]').isChecked(), true);
await listenerRow.locator('input[value="control"]').check();
assert.equal(await listenerRow.locator('input[value="guest"]').isChecked(), true);
assert.equal(await listenerPassword.isDisabled(), true);
await listenerRow.locator('input[value="guest"]').uncheck();
assert.equal(await listenerPassword.isDisabled(), false);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
const guestFixture = await startWebFixture({
authSession: {
authenticated: true,
username: "guest",
roles: ["guest"],
auth_disabled: false,
},
});
const guestBrowser = await startBrowser(chromium);
try {
await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" });
assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false);
assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false);
assert.deepEqual(guestBrowser.runtimeErrors, []);
} finally {
await guestBrowser.browser.close();
await guestFixture.close();
}
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
const source = await bundleEntry(new URL("../src/api/auth.ts", import.meta.url), "AuthApi");
function loadAuth(fetch) {
const context = vm.createContext({ fetch, console });
new vm.Script(source).runInContext(context);
return context.AuthApi;
}
test("role policy centralizes Guest and separates Control from Transmit", () => {
const auth = loadAuth(async () => { throw new Error("unused"); });
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "write", "administrator"]);
assert.equal(auth.hasAuthRole(["guest"], "read"), true);
assert.equal(auth.hasAccountControls(["guest"]), false);
assert.equal(auth.hasAccountControls(["read"]), true);
assert.equal(auth.hasAuthRole(["control"], "read"), true);
assert.equal(auth.hasAuthRole(["transmit"], "read"), true);
assert.equal(auth.hasAuthRole(["control"], "transmit"), false);
assert.equal(auth.hasAuthRole(["control"], "write"), false);
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
});
test("auth responses normalize roles and require the managed-account lifecycle state", async () => {
const replies = new Map([
["/auth/session", { authenticated: true, roles: ["write", "read", "write"], username: "alice" }],
["/auth/users", [{ username: "alice", roles: ["write", "read"], enabled: false }]],
]);
const auth = loadAuth(async (url) => ({
ok: true,
status: 200,
json: async () => replies.get(String(url)),
}));
assert.deepEqual(Array.from((await auth.fetchAuthSession()).roles), ["read", "write"]);
assert.deepEqual(Array.from((await auth.listUsers())[0].roles), ["read", "write"]);
assert.equal((await auth.listUsers())[0].enabled, false);
});
test("changing a password sends current and replacement credentials", async () => {
let request;
const auth = loadAuth(async (url, init) => {
request = { url, init };
return { ok: true, status: 200, json: async () => ({}) };
});
await auth.changeOwnPassword("old-password", "new-password");
assert.equal(request.url, "/auth/account/password");
assert.equal(request.init.method, "PATCH");
assert.deepEqual(JSON.parse(request.init.body), {
current_password: "old-password",
new_password: "new-password",
});
});
@@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "control");
window.trx.modules.backgroundDecode.initialize("rig/a", ["administrator"]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "control",
authRoles: ["read", "control", "transmit", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -157,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
const { window } = hostFixture({ authEnabled: true, authRoles: ["read"] });
const context = vm.createContext({
window,
document: documentFixture(element),
@@ -171,7 +171,7 @@ test("bookmark controls follow the host authentication state", async () => {
assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "control";
window.trx.state.authRoles = ["read", "write"];
await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, "");
});
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null,
serverLon: null,
authEnabled: false,
authRole: "control",
authRoles: ["read", "control", "transmit", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -137,6 +137,69 @@ try {
assert.equal(inHamLayout.layout, "ham");
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
// ── Contest working ─────────────────────────────────────────────────────
// The exchange belongs to the session: the contest and the serial sent stay
// between contacts, and the serial counts on by itself.
await page.evaluate(() => { window.navigateToTab("logbook"); });
await page.waitForTimeout(600);
await page.locator("#log-contest-block > summary").click();
await page.locator("#log-contest-id").fill("CQ-WW-SSB");
await page.locator("#log-stx").fill("1");
await page.locator("#log-call").fill("DL9CON");
await page.locator("#log-srx").fill("014");
await page.locator("#log-rst-sent").fill("59");
await page.locator("#log-rst-rcvd").fill("59");
await page.locator("#log-save-btn").click();
await page.waitForTimeout(900);
const afterContest = await page.evaluate(() => ({
contest: document.getElementById("log-contest-id")?.value ?? "",
stx: document.getElementById("log-stx")?.value ?? "",
srx: document.getElementById("log-srx")?.value ?? "",
call: document.getElementById("log-call")?.value ?? "",
}));
assert.equal(afterContest.contest, "CQ-WW-SSB", "the contest was cleared with the contact");
assert.equal(afterContest.stx, "2", `the serial sent went to "${afterContest.stx}"`);
assert.equal(afterContest.srx, "", "the received exchange was kept for the next station");
assert.equal(afterContest.call, "");
// The Cabrillo link carries the header, and names the contest so that only
// its contacts are entered.
await page.locator("#log-contest-export-block > summary").click();
await page.locator("#log-cbr-contest").fill("CQ-WW-SSB");
await page.locator("#log-cbr-score").fill("4242");
await page.waitForTimeout(300);
const cabrillo = await page.evaluate(() =>
document.getElementById("log-cbr-export")?.getAttribute("href") ?? "");
assert.match(cabrillo, /contest=CQ-WW-SSB/, `the Cabrillo link reads "${cabrillo}"`);
assert.match(cabrillo, /claimed_score=4242/, `the Cabrillo link reads "${cabrillo}"`);
const entry = await page.evaluate(async (href) => {
const response = await fetch(href);
return await response.text();
}, cabrillo);
assert.match(entry, /^START-OF-LOG: 3\.0/, entry);
assert.match(entry, /DL9CON/, "the contest contact is not in the entry");
assert.ok(!entry.includes("OZ1NEW"), "a contact outside the contest was entered");
// ── Confirmations ───────────────────────────────────────────────────────
// A card arrives: the contact is marked confirmed, and the per-band report
// counts it.
const confirmRow = page.locator("#log-rows tr", { hasText: "DL9CON" }).first();
await confirmRow.getByRole("button", { name: "Confirm" }).click();
await page.waitForTimeout(800);
await page.locator("#log-statistics-block > summary").click();
await page.waitForTimeout(400);
const confirmed = await page.evaluate(() => ({
marks: [...document.querySelectorAll("#log-rows tr")]
.map((row) => [...row.querySelectorAll("td")].at(-2)?.textContent ?? ""),
bands: [...document.querySelectorAll("#log-statistics-rows tr")]
.map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent)),
}));
assert.equal(confirmed.marks.filter((mark) => mark === "✓").length, 1,
`the QSL column reads ${JSON.stringify(confirmed.marks)}`);
const twenty = confirmed.bands.find((band) => band[0] === "20m");
assert.ok(twenty, `the bands are ${JSON.stringify(confirmed.bands)}`);
assert.equal(twenty[3], "1", `20m shows ${twenty[3]} confirmed`);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => {
// No role known yet: the entry registers its service and waits for the
// application to drive initialization.
const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } };
const window = { ...createHost({ state: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
const context = vm.createContext({
window,
document: {
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id);
};
const window = {
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }),
...createHost({ state: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
@@ -9,6 +9,7 @@ import { readFile } from "node:fs/promises";
const indexPath = new URL("../../assets/web/index.html", import.meta.url);
const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url);
const mapCorePath = new URL("../src/map-core.ts", import.meta.url);
const appPath = new URL("../src/app.ts", import.meta.url);
test("index loads one first-party application bootstrap", async () => {
const html = await readFile(indexPath, "utf8");
@@ -30,6 +31,30 @@ test("startup has no remote script or stylesheet dependencies", async () => {
);
});
test("administrator user management is a dedicated Settings sub-tab", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-users"/);
assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/);
assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/);
assert.match(app, /settings-users-tab/);
});
test("account lifecycle controls include self-service passwords and enable state", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-account"/);
assert.match(html, /id="account-password-form"/);
assert.match(html, /id="user-create-enabled"/);
assert.match(app, /changeOwnPassword/);
assert.match(app, /hasAccountControls\(authRoles\)/);
assert.match(app, /enabledAdminCount/);
});
test("lazy frontend features use modules and local map symbols", async () => {
const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"),
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
async function controlState(page) {
return {
frequency: await page.locator("#freq").isDisabled(),
ptt: await page.locator("#ptt-btn").isDisabled(),
txAudio: await page.locator("#tx-audio-btn").isDisabled(),
txLimit: await page.locator("#tx-limit-btn").isDisabled(),
};
}
async function inspectRole(roles) {
const fixture = await startWebFixture({
tx: true,
authSession: {
authenticated: true,
username: "operator",
roles,
auth_disabled: false,
},
});
const session = await startBrowser(chromium);
try {
await session.page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await session.page.locator("#content").waitFor({ state: "visible" });
await session.page.waitForTimeout(1200);
assert.deepEqual(session.runtimeErrors, []);
return await controlState(session.page);
} finally {
await session.browser.close();
await fixture.close();
}
}
assert.deepEqual(await inspectRole(["control"]), {
frequency: false,
ptt: true,
txAudio: true,
txLimit: true,
});
assert.deepEqual(await inspectRole(["transmit"]), {
frequency: true,
ptt: false,
txAudio: false,
txLimit: false,
});
@@ -145,6 +145,8 @@ export async function startWebFixture({
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true },
users = [],
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -232,7 +234,8 @@ export async function startWebFixture({
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/auth/session", authSession],
["/auth/users", users],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
@@ -309,6 +312,49 @@ export async function startWebFixture({
response.end(JSON.stringify({ call, worked }));
return;
}
if (tail === "/statistics") {
const bands = new Map();
for (const qso of logbook) {
const entry = bands.get(qso.band) ?? { band: qso.band, contacts: 0, stations: 0, confirmed: 0, calls: new Set() };
entry.contacts += 1;
entry.calls.add(qso.call);
if (qso.confirmed || qso.qsl_rcvd === "Y" || qso.lotw_qsl_rcvd === "Y") entry.confirmed += 1;
bands.set(qso.band, entry);
}
const list = [...bands.values()].map(({ calls, ...rest }) => ({ ...rest, stations: calls.size }));
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
contacts: logbook.length,
confirmed: list.reduce((total, band) => total + band.confirmed, 0),
bands: list,
}));
return;
}
if (tail === "/export.cbr") {
const contest = url.searchParams.get("contest");
const entered = contest ? logbook.filter((qso) => qso.contest_id === contest) : logbook;
response.writeHead(200, { "content-type": "text/plain" });
response.end(`START-OF-LOG: 3.0\nCONTEST: ${contest ?? ""}\n`
+ entered.map((qso) => `QSO: 14200 PH ${qso.call}\n`).join("")
+ "END-OF-LOG:\n");
return;
}
if (request.method === "PUT") {
const raw = await new Promise((resolve) => {
let text = "";
request.on("data", (chunk) => { text += chunk; });
request.on("end", () => resolve(text));
});
const input = JSON.parse(raw);
const id = tail.replace("/", "");
const held = logbook.find((qso) => qso.id === id);
if (held) {
Object.assign(held, input, { id, confirmed: input.qsl_rcvd === "Y" });
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(held ?? {}));
return;
}
if (tail === "/export.adi") {
const body = logbook
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
@@ -337,6 +383,10 @@ export async function startWebFixture({
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
my_rig: input.my_rig,
operator: input.operator,
contest_id: input.contest_id ?? null,
stx: input.stx ?? null,
srx: input.srx ?? null,
confirmed: input.qsl_rcvd === "Y" || input.lotw_qsl_rcvd === "Y",
};
logbook.unshift(qso);
response.writeHead(200, { "content-type": "application/json" });
@@ -9,7 +9,7 @@ use std::sync::Arc;
use actix_web::Error;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
use super::{no_cache_response, request_accepts_html, require_control};
use super::{no_cache_response, request_accepts_html, require_write};
use crate::server::status;
// ============================================================================
@@ -165,7 +165,7 @@ pub async fn create_bookmark(
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
if store.freq_taken(body.freq_hz, None) {
return Err(actix_web::error::ErrorConflict(
@@ -201,7 +201,7 @@ pub async fn update_bookmark(
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.freq_taken(body.freq_hz, Some(&id)) {
@@ -235,7 +235,7 @@ pub async fn delete_bookmark(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.remove(&id) {
@@ -253,7 +253,7 @@ pub async fn batch_delete_bookmarks(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let mut deleted = 0usize;
for id in &body.ids {
@@ -272,7 +272,7 @@ pub async fn batch_move_bookmarks(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
let mut moved = 0usize;
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
use trx_logbook::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder};
use trx_logbook::{LogQuery, Logbook, Qso};
use super::{active_rig_id_from_context, require_control};
use super::{active_rig_id_from_context, require_write};
use crate::server::auth::AuthState;
/// What a contact looks like on the wire.
@@ -27,12 +27,15 @@ struct QsoView {
#[serde(flatten)]
qso: Qso,
band: Option<&'static str>,
/// Whether the other station has confirmed, from whichever bureau answered.
confirmed: bool,
}
impl From<Qso> for QsoView {
fn from(qso: Qso) -> Self {
Self {
band: qso.band(),
confirmed: qso.is_confirmed(),
qso,
}
}
@@ -79,6 +82,39 @@ struct QsoInput {
my_rig: Option<String>,
#[serde(default)]
rig_id: Option<String>,
#[serde(default)]
contest_id: Option<String>,
#[serde(default)]
stx: Option<u32>,
#[serde(default)]
stx_string: Option<String>,
#[serde(default)]
srx: Option<u32>,
#[serde(default)]
srx_string: Option<String>,
#[serde(default)]
cqz: Option<u8>,
#[serde(default)]
ituz: Option<u8>,
#[serde(default)]
qsl_sent: Option<String>,
#[serde(default)]
qsl_rcvd: Option<String>,
#[serde(default)]
lotw_qsl_sent: Option<String>,
#[serde(default)]
lotw_qsl_rcvd: Option<String>,
#[serde(default)]
eqsl_qsl_sent: Option<String>,
#[serde(default)]
eqsl_qsl_rcvd: Option<String>,
}
/// ADIF's QSL states are single letters. Anything else is refused rather than
/// written, or a log would grow states no other program can read.
fn qsl_state(value: Option<String>) -> Option<String> {
let state = blank_to_none(value)?.to_uppercase();
matches!(state.as_str(), "Y" | "N" | "R" | "I" | "Q" | "V").then_some(state)
}
fn blank_to_none(value: Option<String>) -> Option<String> {
@@ -123,6 +159,19 @@ impl QsoInput {
qso.my_gridsquare = blank_to_none(self.my_gridsquare).map(|g| g.to_uppercase());
qso.my_rig = blank_to_none(self.my_rig);
qso.rig_id = blank_to_none(self.rig_id);
qso.contest_id = blank_to_none(self.contest_id).map(|c| c.to_uppercase());
qso.stx = self.stx;
qso.stx_string = blank_to_none(self.stx_string);
qso.srx = self.srx;
qso.srx_string = blank_to_none(self.srx_string);
qso.cqz = self.cqz;
qso.ituz = self.ituz;
qso.qsl_sent = qsl_state(self.qsl_sent);
qso.qsl_rcvd = qsl_state(self.qsl_rcvd);
qso.lotw_qsl_sent = qsl_state(self.lotw_qsl_sent);
qso.lotw_qsl_rcvd = qsl_state(self.lotw_qsl_rcvd);
qso.eqsl_qsl_sent = qsl_state(self.eqsl_qsl_sent);
qso.eqsl_qsl_rcvd = qsl_state(self.eqsl_qsl_rcvd);
// Whatever an imported contact carried that this application does not
// model stays with it through an edit.
if let Some(existing) = existing {
@@ -176,7 +225,7 @@ pub async fn add_qso(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
let qso = match input.into_inner().into_qso(None) {
Ok(qso) => qso,
Err(reason) => {
@@ -199,7 +248,7 @@ pub async fn edit_qso(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
let id = path.into_inner();
let Some(existing) = book(&logbook).get(&id) else {
return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })));
@@ -227,7 +276,7 @@ pub async fn delete_qso(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
match book(&logbook).delete(&path.into_inner()) {
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
Ok(false) => {
@@ -263,7 +312,7 @@ pub async fn import_adi(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
match book(&logbook).import_adi(&body) {
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
Err(err) => Ok(HttpResponse::InternalServerError()
@@ -271,6 +320,41 @@ pub async fn import_adi(
}
}
/// `GET /api/logbook/export.cbr` — a contest entry, in the only format sponsors
/// take.
///
/// The header cannot be worked out from the log — how many operators, how much
/// power — so it comes as query parameters from the operator.
#[get("/api/logbook/export.cbr")]
pub async fn export_cabrillo(
query: web::Query<LogQuery>,
header: web::Query<trx_logbook::CabrilloHeader>,
logbook: web::Data<Arc<Logbook>>,
) -> impl Responder {
let text = book(&logbook).export_cabrillo(&query.into_inner(), &header.into_inner());
let filename = format!("trx-rs-contest-{}.cbr", Utc::now().format("%Y%m%d"));
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/plain; charset=utf-8"))
.insert_header((
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
))
.body(text)
}
/// `GET /api/logbook/statistics` — what has been worked and confirmed, per band.
#[get("/api/logbook/statistics")]
pub async fn statistics(logbook: web::Data<Arc<Logbook>>) -> impl Responder {
let bands = book(&logbook).band_statistics();
let contacts: usize = bands.iter().map(|band| band.contacts).sum();
let confirmed: usize = bands.iter().map(|band| band.confirmed).sum();
HttpResponse::Ok().json(serde_json::json!({
"contacts": contacts,
"confirmed": confirmed,
"bands": bands,
}))
}
/// `GET /api/logbook/worked/{call}` — which bands and modes this station has been
/// worked on.
#[get("/api/logbook/worked/{call}")]
@@ -391,16 +391,20 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
GzCacheEntry { gz, br, etag }
}
fn require_control(
fn require_write(
req: &HttpRequest,
auth_state: &crate::server::auth::AuthState,
) -> Result<(), actix_web::Error> {
if !auth_state.config.enabled {
return Ok(());
}
match crate::server::auth::get_session_role(req, auth_state) {
Some(crate::server::auth::AuthRole::Control) => Ok(()),
_ => Err(actix_web::error::ErrorForbidden("control role required")),
if !auth_state.config.enabled
|| crate::server::auth::session_grants(
req,
auth_state,
crate::server::auth::AuthRole::Write,
)
{
Ok(())
} else {
Err(actix_web::error::ErrorForbidden("write role required"))
}
}
@@ -709,12 +713,19 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::auth::login)
.service(crate::server::auth::logout)
.service(crate::server::auth::session_status)
.service(crate::server::auth::change_own_password)
.service(crate::server::auth::list_users)
.service(crate::server::auth::create_user)
.service(crate::server::auth::update_user)
.service(crate::server::auth::delete_user)
// Logbook
.service(logbook::list_qsos)
.service(logbook::add_qso)
.service(logbook::edit_qso)
.service(logbook::delete_qso)
.service(logbook::export_adi)
.service(logbook::export_cabrillo)
.service(logbook::statistics)
.service(logbook::import_adi)
.service(logbook::worked_before)
.service(logbook::server_now)
@@ -947,30 +958,36 @@ mod tests {
);
}
/// Auth off: every session may write, as a station with no passphrase set.
/// Auth off: every session may write without an account.
fn auth_state_disabled() -> crate::server::auth::AuthState {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
false,
std::path::PathBuf::from("unused-users.json"),
None,
None,
false,
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
))
.unwrap()
}
/// Auth on with no session presented, which is what a listener is.
fn auth_state_locked() -> crate::server::auth::AuthState {
let directory = tempfile::tempdir().unwrap();
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
true,
Some("listen".to_string()),
Some("control".to_string()),
false,
directory.path().join("users.json"),
Some(crate::server::auth::BootstrapAccount::new(
"admin".to_string(),
"password123".to_string(),
)),
None,
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
))
.unwrap()
}
/// A contact written over HTTP comes back out of the log, and out of an
@@ -1045,9 +1062,88 @@ mod tests {
assert!(text.contains("<EOR>"), "{text}");
}
/// A read-only session may read the log and may not write to it.
/// A contest entry comes out in Cabrillo, holding that contest's contacts
/// and no others, with the header the operator gave.
#[actix_web::test]
async fn a_read_only_session_cannot_write_to_the_log() {
async fn a_contest_entry_is_exported_as_cabrillo() {
let dir = tempfile::tempdir().expect("tempdir");
let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(logbook.clone()))
.app_data(web::Data::new(auth_state_disabled()))
.service(logbook::add_qso)
.service(logbook::export_cabrillo)
.service(logbook::statistics),
)
.await;
for (call, contest, serial, confirmed) in [
("DL1AB", Some("CQ-WW-SSB"), 1, true),
("OZ2CD", Some("CQ-WW-SSB"), 2, false),
("SP3EF", None, 0, false),
] {
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.set_json(serde_json::json!({
"call": call,
"freq_hz": 14_200_000_u64,
"mode": "SSB",
"rst_sent": "59",
"rst_rcvd": "59",
"station_callsign": "SP0TRX",
"contest_id": contest,
"stx": serial,
"srx": serial,
"lotw_qsl_rcvd": if confirmed { "Y" } else { "N" },
}))
.to_request(),
)
.await;
assert_eq!(response.status(), 200);
}
let entry = actix_test::call_and_read_body(
&app,
actix_test::TestRequest::get()
.uri("/api/logbook/export.cbr?contest=CQ-WW-SSB&callsign=SP0TRX&claimed_score=42")
.to_request(),
)
.await;
let text = String::from_utf8_lossy(&entry);
assert!(text.starts_with("START-OF-LOG: 3.0"), "{text}");
assert!(text.contains("CONTEST: CQ-WW-SSB"), "{text}");
assert!(text.contains("CLAIMED-SCORE: 42"), "{text}");
assert_eq!(
text.lines().filter(|line| line.starts_with("QSO:")).count(),
2,
"the entry holds contacts from outside the contest: {text}"
);
assert!(!text.contains("SP3EF"), "{text}");
// And the statistics count the station once per band, with the
// confirmation counted wherever it came from.
let stats: serde_json::Value = actix_test::call_and_read_body_json(
&app,
actix_test::TestRequest::get()
.uri("/api/logbook/statistics")
.to_request(),
)
.await;
assert_eq!(stats["contacts"], 3);
assert_eq!(stats["confirmed"], 1);
assert_eq!(stats["bands"][0]["band"], "20m");
assert_eq!(stats["bands"][0]["stations"], 3);
}
/// A QSL state that is not one of ADIF's letters is dropped rather than
/// written, or the log grows states nothing else can read.
#[actix_web::test]
async fn a_qsl_state_outside_the_enumeration_is_not_written() {
let dir = tempfile::tempdir().expect("tempdir");
let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
@@ -1055,7 +1151,44 @@ mod tests {
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(logbook))
.app_data(web::Data::new(auth_state_locked()))
.app_data(web::Data::new(auth_state_disabled()))
.service(logbook::add_qso),
)
.await;
let written: serde_json::Value = actix_test::call_and_read_body_json(
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.set_json(serde_json::json!({
"call": "SP1AA", "freq_hz": 14_074_000_u64, "mode": "FT8",
"qsl_rcvd": "maybe", "lotw_qsl_rcvd": "y",
}))
.to_request(),
)
.await;
assert!(written.get("qsl_rcvd").is_none(), "{written}");
// ...but a lowercase letter that is in the enumeration is taken.
assert_eq!(written["lotw_qsl_rcvd"], "Y");
assert_eq!(written["confirmed"], true);
}
/// A read-only session may read the log and may not write to it.
#[actix_web::test]
async fn a_read_only_session_cannot_write_to_the_log() {
let dir = tempfile::tempdir().expect("tempdir");
let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
);
let auth_state = auth_state_locked();
let session_id = auth_state.store.create(
"reader".to_string(),
[crate::server::auth::AuthRole::Read].into_iter().collect(),
std::time::Duration::from_secs(3600),
);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(logbook))
.app_data(web::Data::new(auth_state))
.service(logbook::add_qso)
.service(logbook::list_qsos),
)
@@ -1065,6 +1198,10 @@ mod tests {
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new(
"trx_http_sid",
session_id.clone(),
))
.set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
}))
@@ -1080,12 +1217,47 @@ mod tests {
&app,
actix_test::TestRequest::get()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.to_request(),
)
.await;
assert_eq!(listed["total"], 0);
}
#[actix_web::test]
async fn a_write_session_can_write_to_the_log() {
let dir = tempfile::tempdir().expect("tempdir");
let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
);
let auth_state = auth_state_locked();
let session_id = auth_state.store.create(
"writer".to_string(),
[crate::server::auth::AuthRole::Write].into_iter().collect(),
std::time::Duration::from_secs(3600),
);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(logbook))
.app_data(web::Data::new(auth_state))
.service(logbook::add_qso),
)
.await;
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
}))
.to_request(),
)
.await;
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
}
/// A contact needs a callsign; the panel is not the only thing that has to
/// insist on it.
#[actix_web::test]
@@ -737,6 +737,7 @@ pub async fn audio_ws(
body: web::Payload,
query: web::Query<AudioQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
@@ -746,6 +747,7 @@ pub async fn audio_ws(
if !req.headers().contains_key("upgrade") {
return Ok(HttpResponse::NoContent().finish());
}
let tx_session_id = crate::server::auth::extract_session_id(&req);
// If a channel_id is specified, subscribe to the per-channel broadcaster.
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
@@ -880,6 +882,16 @@ pub async fn audio_ws(
msg = msg_stream.recv() => {
match msg {
Some(Ok(Message::Binary(data))) => {
let can_transmit = !auth_state.config.enabled
|| crate::server::auth::session_id_grants(
tx_session_id.as_ref(),
&auth_state,
crate::server::auth::AuthRole::Transmit,
);
if !can_transmit {
warn!("Audio WS: closing after unauthorized TX frame");
break;
}
let _ = tx_sender.send(Bytes::from(data.to_vec())).await;
}
Some(Ok(Message::Close(_))) => break,
File diff suppressed because it is too large Load Diff
@@ -252,11 +252,31 @@ fn build_server(
"None" => SameSite::None,
_ => SameSite::Lax, // default
};
let bootstrap_admin = auth::BootstrapAccount::from_parts(
context.http_auth.bootstrap_admin_username.clone(),
context.http_auth.bootstrap_admin_password.clone(),
);
let bootstrap_read = context
.http_auth
.bootstrap_read_enabled
.then(|| {
context
.http_auth
.bootstrap_read_password
.clone()
.map(|password| {
auth::BootstrapAccount::new(
context.http_auth.bootstrap_read_username.clone(),
password,
)
})
})
.flatten();
let auth_config = AuthConfig::new(
context.http_auth.enabled,
context.http_auth.rx_passphrase.clone(),
context.http_auth.control_passphrase.clone(),
context.http_auth.tx_access_control_enabled,
context.http_auth.users_file.clone().into(),
bootstrap_admin,
bootstrap_read,
Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure,
same_site,
@@ -273,7 +293,9 @@ fn build_server(
}
let context_data = web::Data::new(context);
let auth_state = web::Data::new(AuthState::new(auth_config.clone()));
let auth_state = web::Data::new(
AuthState::new(auth_config.clone()).map_err(actix_web::error::ErrorInternalServerError)?,
);
// Spawn session cleanup task if auth is enabled
if auth_config.enabled {
+52
View File
@@ -47,8 +47,28 @@ const MODELLED: &[&str] = &[
"OPERATOR",
"MY_GRIDSQUARE",
"MY_RIG",
"CONTEST_ID",
"STX",
"STX_STRING",
"SRX",
"SRX_STRING",
"CQZ",
"ITUZ",
"QSL_SENT",
"QSL_RCVD",
"LOTW_QSL_SENT",
"LOTW_QSL_RCVD",
"EQSL_QSL_SENT",
"EQSL_QSL_RCVD",
];
/// ADIF's QSL states are single letters; anything else in that field is a
/// misunderstanding on the writer's part and is not carried into ours.
fn qsl_state(value: Option<&str>) -> Option<String> {
let state = value?.trim().to_uppercase();
matches!(state.as_str(), "Y" | "N" | "R" | "I" | "Q" | "V").then_some(state)
}
/// What a file gave up, and what it could not.
#[derive(Debug, Default, Clone)]
pub struct ParseReport {
@@ -215,6 +235,19 @@ fn qso_from_fields(fields: &[Field]) -> Result<Qso, String> {
my_gridsquare: find(fields, "MY_GRIDSQUARE").map(|g| g.to_uppercase()),
my_rig: find(fields, "MY_RIG").map(str::to_string),
rig_id: None,
contest_id: find(fields, "CONTEST_ID").map(|c| c.to_uppercase()),
stx: find(fields, "STX").and_then(|value| value.parse().ok()),
stx_string: find(fields, "STX_STRING").map(str::to_string),
srx: find(fields, "SRX").and_then(|value| value.parse().ok()),
srx_string: find(fields, "SRX_STRING").map(str::to_string),
cqz: find(fields, "CQZ").and_then(|value| value.parse().ok()),
ituz: find(fields, "ITUZ").and_then(|value| value.parse().ok()),
qsl_sent: qsl_state(find(fields, "QSL_SENT")),
qsl_rcvd: qsl_state(find(fields, "QSL_RCVD")),
lotw_qsl_sent: qsl_state(find(fields, "LOTW_QSL_SENT")),
lotw_qsl_rcvd: qsl_state(find(fields, "LOTW_QSL_RCVD")),
eqsl_qsl_sent: qsl_state(find(fields, "EQSL_QSL_SENT")),
eqsl_qsl_rcvd: qsl_state(find(fields, "EQSL_QSL_RCVD")),
extra,
})
}
@@ -273,11 +306,30 @@ pub fn write_adi(qsos: &[Qso], program_version: &str) -> String {
("OPERATOR", &qso.operator),
("MY_GRIDSQUARE", &qso.my_gridsquare),
("MY_RIG", &qso.my_rig),
("CONTEST_ID", &qso.contest_id),
("STX_STRING", &qso.stx_string),
("SRX_STRING", &qso.srx_string),
("QSL_SENT", &qso.qsl_sent),
("QSL_RCVD", &qso.qsl_rcvd),
("LOTW_QSL_SENT", &qso.lotw_qsl_sent),
("LOTW_QSL_RCVD", &qso.lotw_qsl_rcvd),
("EQSL_QSL_SENT", &qso.eqsl_qsl_sent),
("EQSL_QSL_RCVD", &qso.eqsl_qsl_rcvd),
] {
if let Some(value) = value {
write_field(&mut out, name, value);
}
}
for (name, value) in [
("STX", qso.stx),
("SRX", qso.srx),
("CQZ", qso.cqz.map(u32::from)),
("ITUZ", qso.ituz.map(u32::from)),
] {
if let Some(value) = value {
write_field(&mut out, name, &value.to_string());
}
}
if let Some(power) = qso.tx_pwr_w {
write_field(&mut out, "TX_PWR", &format!("{power}"));
}
+304
View File
@@ -0,0 +1,304 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Cabrillo 3.0, the format contest logs are submitted in.
//!
//! ADIF cannot do this job: sponsors take Cabrillo and reject everything else.
//! It is export-only and narrower on purpose — a header naming the entrant and
//! the category, then one fixed-shape `QSO:` line per contact carrying the
//! frequency, the mode, the time, and both stations' calls, reports and
//! exchanges. Everything a log holds beyond that is left behind, which is why
//! this complements ADIF rather than replacing it.
use serde::Deserialize;
use crate::qso::Qso;
/// The header of a contest entry.
///
/// Sponsors want the entrant's own answers here — how many operators, how much
/// power, which bands — and no log can work them out for itself, so they come
/// from the operator with sane defaults behind them.
#[derive(Debug, Clone, Deserialize)]
pub struct CabrilloHeader {
/// The contest's Cabrillo name, e.g. `CQ-WW-SSB`.
#[serde(default)]
pub contest: Option<String>,
/// The callsign the entry is submitted under.
#[serde(default)]
pub callsign: Option<String>,
#[serde(default)]
pub category_operator: Option<String>,
#[serde(default)]
pub category_power: Option<String>,
#[serde(default)]
pub category_band: Option<String>,
#[serde(default)]
pub category_mode: Option<String>,
#[serde(default)]
pub claimed_score: Option<u64>,
#[serde(default)]
pub operators: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub soapbox: Option<String>,
}
impl Default for CabrilloHeader {
fn default() -> Self {
Self {
contest: None,
callsign: None,
category_operator: Some("SINGLE-OP".to_string()),
category_power: Some("LOW".to_string()),
category_band: Some("ALL".to_string()),
category_mode: Some("MIXED".to_string()),
claimed_score: None,
operators: None,
name: None,
email: None,
soapbox: None,
}
}
}
/// The mode letters Cabrillo uses, which are not ADIF's names.
fn cabrillo_mode(qso: &Qso) -> &'static str {
match qso.mode.trim().to_uppercase().as_str() {
"CW" | "CWR" => "CW",
"SSB" | "USB" | "LSB" | "AM" | "FM" | "SAM" | "WFM" | "PHONE" => {
// FM has a letter of its own; the rest of phone is PH.
if matches!(qso.mode.trim().to_uppercase().as_str(), "FM" | "WFM") {
"FM"
} else {
"PH"
}
}
"RTTY" => "RY",
_ => "DG",
}
}
/// Band designators above 30 MHz, where Cabrillo names the band rather than
/// the frequency.
const VHF_DESIGNATORS: &[(u64, u64, &str)] = &[
(50_000_000, 54_000_000, "50"),
(70_000_000, 71_000_000, "70"),
(144_000_000, 148_000_000, "144"),
(222_000_000, 225_000_000, "222"),
(420_000_000, 450_000_000, "432"),
(902_000_000, 928_000_000, "902"),
(1_240_000_000, 1_300_000_000, "1.2G"),
(2_300_000_000, 2_450_000_000, "2.3G"),
(3_300_000_000, 3_500_000_000, "3.4G"),
(5_650_000_000, 5_925_000_000, "5.7G"),
(10_000_000_000, 10_500_000_000, "10G"),
];
/// The frequency field: kilohertz below 30 MHz, a band designator above it.
fn cabrillo_frequency(hz: u64) -> String {
if hz < 30_000_000 {
return (hz / 1000).to_string();
}
VHF_DESIGNATORS
.iter()
.find(|(low, high, _)| hz >= *low && hz <= *high)
.map_or_else(
|| (hz / 1000).to_string(),
|(_, _, name)| (*name).to_string(),
)
}
fn header_line(out: &mut String, key: &str, value: Option<&str>) {
if let Some(value) = value {
let value = value.trim();
if !value.is_empty() {
out.push_str(&format!("{key}: {value}\n"));
}
}
}
/// Write a Cabrillo 3.0 entry for `qsos`.
///
/// The caller decides which contacts belong to the entry; this writes what it
/// is given, in the order it is given, oldest first as sponsors expect.
pub fn write_cabrillo(qsos: &[Qso], header: &CabrilloHeader) -> String {
let mut out = String::with_capacity(qsos.len() * 96 + 512);
out.push_str("START-OF-LOG: 3.0\n");
header_line(&mut out, "CREATED-BY", Some("trx-rs"));
header_line(&mut out, "CONTEST", header.contest.as_deref());
header_line(&mut out, "CALLSIGN", header.callsign.as_deref());
header_line(
&mut out,
"CATEGORY-OPERATOR",
header.category_operator.as_deref(),
);
header_line(&mut out, "CATEGORY-BAND", header.category_band.as_deref());
header_line(&mut out, "CATEGORY-MODE", header.category_mode.as_deref());
header_line(&mut out, "CATEGORY-POWER", header.category_power.as_deref());
if let Some(score) = header.claimed_score {
header_line(&mut out, "CLAIMED-SCORE", Some(&score.to_string()));
}
header_line(&mut out, "OPERATORS", header.operators.as_deref());
header_line(&mut out, "NAME", header.name.as_deref());
header_line(&mut out, "EMAIL", header.email.as_deref());
header_line(&mut out, "SOAPBOX", header.soapbox.as_deref());
// Oldest first: a contest log is read as it was worked.
let mut ordered: Vec<&Qso> = qsos.iter().collect();
ordered.sort_by_key(|qso| qso.started_at);
let station = header
.callsign
.clone()
.or_else(|| qsos.first().and_then(|qso| qso.station_callsign.clone()))
.unwrap_or_else(|| "UNKNOWN".to_string());
for qso in ordered {
let sent = qso.exchange_sent().unwrap_or_default();
let received = qso.exchange_received().unwrap_or_default();
out.push_str(&format!(
"QSO: {freq:>5} {mode:2} {date} {time} {mine:<13} {rst_sent:>3} {exch_sent:<6} {theirs:<13} {rst_rcvd:>3} {exch_rcvd:<6}\n",
freq = cabrillo_frequency(qso.freq_hz),
mode = cabrillo_mode(qso),
date = qso.started_at.format("%Y-%m-%d"),
time = qso.started_at.format("%H%M"),
mine = qso.station_callsign.as_deref().unwrap_or(&station),
rst_sent = qso.rst_sent.as_deref().unwrap_or(""),
exch_sent = sent,
theirs = qso.call,
rst_rcvd = qso.rst_rcvd.as_deref().unwrap_or(""),
exch_rcvd = received,
));
}
out.push_str("END-OF-LOG:\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::qso::parse_adif_datetime;
fn contest_qso(call: &str, hz: u64, mode: &str, time: &str, sent: u32, rcvd: u32) -> Qso {
let mut qso = Qso::new(
format!("{call}-{time}"),
parse_adif_datetime("20260807", Some(time)).expect("time"),
call,
hz,
mode,
);
qso.station_callsign = Some("SP0TRX".into());
qso.rst_sent = Some("59".into());
qso.rst_rcvd = Some("59".into());
qso.stx = Some(sent);
qso.srx = Some(rcvd);
qso.contest_id = Some("CQ-WW-SSB".into());
qso
}
#[test]
fn an_entry_has_the_header_a_sponsor_expects() {
let header = CabrilloHeader {
contest: Some("CQ-WW-SSB".into()),
callsign: Some("SP0TRX".into()),
claimed_score: Some(1234),
..CabrilloHeader::default()
};
let text = write_cabrillo(
&[contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 42)],
&header,
);
assert!(text.starts_with("START-OF-LOG: 3.0\n"), "{text}");
assert!(text.contains("CONTEST: CQ-WW-SSB\n"), "{text}");
assert!(text.contains("CALLSIGN: SP0TRX\n"), "{text}");
assert!(text.contains("CATEGORY-OPERATOR: SINGLE-OP\n"), "{text}");
assert!(text.contains("CLAIMED-SCORE: 1234\n"), "{text}");
assert!(text.trim_end().ends_with("END-OF-LOG:"), "{text}");
// A header field nobody filled in is left out, not written empty.
assert!(!text.contains("SOAPBOX:"), "{text}");
}
#[test]
fn a_qso_line_carries_both_sides_of_the_exchange() {
let text = write_cabrillo(
&[contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 42)],
&CabrilloHeader::default(),
);
let line = text
.lines()
.find(|line| line.starts_with("QSO:"))
.expect("a QSO line");
// Frequency in kHz on HF, the phone mode letter, the date and UTC time,
// then mine, my report and my serial, then theirs.
assert!(line.contains("14200"), "{line}");
assert!(line.contains(" PH "), "{line}");
assert!(line.contains("2026-08-07 1200"), "{line}");
assert!(line.contains("SP0TRX"), "{line}");
assert!(line.contains("DL1AB"), "{line}");
assert!(line.contains("001"), "{line}");
assert!(line.contains("042"), "{line}");
}
/// Above 30 MHz Cabrillo names the band rather than the frequency.
#[test]
fn vhf_and_up_are_written_as_band_designators() {
assert_eq!(cabrillo_frequency(14_200_000), "14200");
assert_eq!(cabrillo_frequency(7_030_000), "7030");
assert_eq!(cabrillo_frequency(144_300_000), "144");
assert_eq!(cabrillo_frequency(432_100_000), "432");
assert_eq!(cabrillo_frequency(1_296_000_000), "1.2G");
assert_eq!(cabrillo_frequency(50_313_000), "50");
}
#[test]
fn the_mode_letters_are_cabrillos_not_adifs() {
let cw = contest_qso("DL1AB", 7_030_000, "CW", "120000", 1, 1);
let phone = contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 1);
let fm = contest_qso("DL1AB", 145_500_000, "FM", "120000", 1, 1);
let digital = contest_qso("DL1AB", 14_074_000, "FT8", "120000", 1, 1);
assert_eq!(cabrillo_mode(&cw), "CW");
assert_eq!(cabrillo_mode(&phone), "PH");
assert_eq!(cabrillo_mode(&fm), "FM");
assert_eq!(cabrillo_mode(&digital), "DG");
}
#[test]
fn contacts_are_written_oldest_first_however_they_arrive() {
let text = write_cabrillo(
&[
contest_qso("LATER", 14_200_000, "SSB", "130000", 2, 2),
contest_qso("EARLIER", 14_200_000, "SSB", "120000", 1, 1),
],
&CabrilloHeader::default(),
);
let calls: Vec<&str> = text
.lines()
.filter(|line| line.starts_with("QSO:"))
.map(|line| {
if line.contains("EARLIER") {
"EARLIER"
} else {
"LATER"
}
})
.collect();
assert_eq!(calls, vec!["EARLIER", "LATER"]);
}
/// A word exchange -- a zone, a section, a name -- rather than a serial.
#[test]
fn an_exchange_that_is_not_a_number_is_written_as_it_was() {
let mut qso = contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 1);
qso.stx_string = Some("14".into());
qso.srx_string = Some("BAVARIA".into());
let text = write_cabrillo(&[qso], &CabrilloHeader::default());
let line = text.lines().find(|l| l.starts_with("QSO:")).expect("line");
assert!(line.contains("BAVARIA"), "{line}");
assert!(!line.contains("001"), "{line}");
}
}
+200
View File
@@ -11,6 +11,7 @@
//! from the rig that made it, because rigs can be in different places.
pub mod adif;
pub mod cabrillo;
pub mod dedupe;
pub mod qso;
pub mod store;
@@ -21,6 +22,7 @@ use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use cabrillo::{write_cabrillo, CabrilloHeader};
pub use dedupe::{worked_before, DuplicateIndex, MATCH_WINDOW_MINUTES};
pub use qso::Qso;
pub use store::LogStore;
@@ -47,6 +49,13 @@ pub struct LogQuery {
/// Substring of the callsign, case-insensitive.
#[serde(default)]
pub call: Option<String>,
/// Only contacts in this contest.
#[serde(default)]
pub contest: Option<String>,
/// Only contacts the other station has confirmed, or only those they have
/// not.
#[serde(default)]
pub confirmed: Option<bool>,
#[serde(default)]
pub band: Option<String>,
#[serde(default)]
@@ -62,6 +71,16 @@ pub struct LogQuery {
pub offset: Option<usize>,
}
/// One band's worth of the log.
#[derive(Debug, Clone, Serialize)]
pub struct BandStatistics {
pub band: String,
pub contacts: usize,
/// Distinct callsigns, which is the number an award counts.
pub stations: usize,
pub confirmed: usize,
}
/// What an import did.
#[derive(Debug, Default, Clone, Serialize)]
pub struct ImportOutcome {
@@ -106,6 +125,7 @@ impl Logbook {
let call = query.call.as_deref().map(str::to_uppercase);
let band = query.band.as_deref().map(str::to_lowercase);
let mode = query.mode.as_deref().map(str::to_uppercase);
let contest = query.contest.clone();
let matching: Vec<Qso> = store
.all()
.into_iter()
@@ -117,6 +137,14 @@ impl Logbook {
&& mode.as_ref().is_none_or(|m| qso.mode == *m)
&& query.from.is_none_or(|from| qso.started_at >= from)
&& query.to.is_none_or(|to| qso.started_at <= to)
&& contest.as_ref().is_none_or(|wanted| {
qso.contest_id
.as_deref()
.is_some_and(|held| held.eq_ignore_ascii_case(wanted))
})
&& query
.confirmed
.is_none_or(|wanted| qso.is_confirmed() == wanted)
})
.collect();
let offset = query.offset.unwrap_or(0).min(matching.len());
@@ -146,6 +174,52 @@ impl Logbook {
self.with_store(|store| dedupe::worked_before(&store.all(), call))
}
/// Export as Cabrillo, for submitting a contest entry.
pub fn export_cabrillo(&self, query: &LogQuery, header: &CabrilloHeader) -> String {
cabrillo::write_cabrillo(&self.query(query), header)
}
/// What has been worked, and what has been confirmed, band by band.
///
/// One confirmation counts wherever it came from: an award wants a card or
/// an electronic match, not one of each.
pub fn band_statistics(&self) -> Vec<BandStatistics> {
self.with_store(|store| {
let mut by_band: std::collections::HashMap<String, BandStatistics> =
std::collections::HashMap::new();
let mut stations: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
for qso in store.all() {
let band = qso.band().unwrap_or("other").to_string();
let entry = by_band
.entry(band.clone())
.or_insert_with(|| BandStatistics {
band: band.clone(),
contacts: 0,
stations: 0,
confirmed: 0,
});
entry.contacts += 1;
if qso.is_confirmed() {
entry.confirmed += 1;
}
stations.entry(band).or_default().insert(qso.call.clone());
}
let mut all: Vec<BandStatistics> = by_band
.into_values()
.map(|mut entry| {
entry.stations = stations
.get(&entry.band)
.map_or(0, std::collections::HashSet::len);
entry
})
.collect();
// Ordered by wavelength, longest first, as a band plan reads.
all.sort_by_key(|entry| qso::band_order(&entry.band));
all
})
}
/// Export as ADIF, honouring the same filters as a read.
pub fn export_adi(&self, query: &LogQuery, program_version: &str) -> String {
adif::write_adi(&self.query(query), program_version)
@@ -294,6 +368,132 @@ mod tests {
assert!(book.worked_before("SP9ZZ").is_empty());
}
#[test]
fn statistics_count_stations_and_confirmations_band_by_band() {
let (_dir, book) = new_book();
let mut first = qso("SP1AA", 14_074_000, "FT8", "120000");
first.lotw_qsl_rcvd = Some("Y".into());
book.put(first).expect("put");
// The same station again on the same band: one station, two contacts.
book.put(qso("SP1AA", 14_100_000, "SSB", "121000"))
.expect("put");
let mut third = qso("DL2BB", 7_030_000, "CW", "130000");
third.qsl_rcvd = Some("Y".into());
book.put(third).expect("put");
book.put(qso("OZ3CC", 7_040_000, "CW", "131000"))
.expect("put");
let stats = book.band_statistics();
// Longest wavelength first, as a band plan reads.
assert_eq!(
stats.iter().map(|s| s.band.as_str()).collect::<Vec<_>>(),
vec!["40m", "20m"]
);
let forty = &stats[0];
assert_eq!((forty.contacts, forty.stations, forty.confirmed), (2, 2, 1));
let twenty = &stats[1];
assert_eq!(
(twenty.contacts, twenty.stations, twenty.confirmed),
(2, 1, 1)
);
}
#[test]
fn a_contest_entry_holds_only_that_contest() {
let (_dir, book) = new_book();
for (call, contest, serial) in [
("SP1AA", Some("CQ-WW-SSB"), 1),
("DL2BB", Some("CQ-WW-SSB"), 2),
("OZ3CC", None, 0),
] {
let mut entry = qso(call, 14_200_000, "SSB", "120000");
entry.contest_id = contest.map(str::to_string);
entry.stx = Some(serial);
entry.station_callsign = Some("SP0TRX".into());
entry.rst_sent = Some("59".into());
entry.rst_rcvd = Some("59".into());
book.put(entry).expect("put");
}
let query = LogQuery {
contest: Some("cq-ww-ssb".into()),
..LogQuery::default()
};
assert_eq!(
book.query(&query).len(),
2,
"the contest filter is case-sensitive"
);
let text = book.export_cabrillo(
&query,
&CabrilloHeader {
contest: Some("CQ-WW-SSB".into()),
callsign: Some("SP0TRX".into()),
..CabrilloHeader::default()
},
);
let lines: Vec<&str> = text
.lines()
.filter(|line| line.starts_with("QSO:"))
.collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(
!text.contains("OZ3CC"),
"a contact outside the contest was submitted"
);
}
#[test]
fn confirmations_come_from_whichever_bureau_answered() {
let (_dir, book) = new_book();
let mut card = qso("SP1AA", 14_074_000, "FT8", "120000");
card.qsl_rcvd = Some("Y".into());
let mut lotw = qso("DL2BB", 14_074_000, "FT8", "121000");
lotw.lotw_qsl_rcvd = Some("Y".into());
let mut requested = qso("OZ3CC", 14_074_000, "FT8", "122000");
requested.qsl_rcvd = Some("R".into());
for entry in [card, lotw, requested] {
book.put(entry).expect("put");
}
let confirmed = LogQuery {
confirmed: Some(true),
..LogQuery::default()
};
let calls: Vec<String> = book.query(&confirmed).into_iter().map(|q| q.call).collect();
assert_eq!(calls.len(), 2, "{calls:?}");
assert!(calls.contains(&"SP1AA".to_string()) && calls.contains(&"DL2BB".to_string()));
// Requested is not confirmed.
let outstanding = LogQuery {
confirmed: Some(false),
..LogQuery::default()
};
assert_eq!(book.query(&outstanding)[0].call, "OZ3CC");
}
#[test]
fn the_contest_and_qsl_fields_survive_a_trip_through_adif() {
let (_dir, book) = new_book();
let mut entry = qso("SP1AA", 14_200_000, "SSB", "120000");
entry.contest_id = Some("CQ-WW-SSB".into());
entry.stx = Some(7);
entry.srx_string = Some("BAVARIA".into());
entry.cqz = Some(15);
entry.qsl_sent = Some("Y".into());
entry.lotw_qsl_rcvd = Some("Y".into());
book.put(entry).expect("put");
let exported = book.export_adi(&LogQuery::default(), "0.1.0");
let (_other_dir, other) = new_book();
other.import_adi(exported.as_bytes()).expect("import");
let back = &other.query(&LogQuery::default())[0];
assert_eq!(back.contest_id.as_deref(), Some("CQ-WW-SSB"));
assert_eq!(back.stx, Some(7));
assert_eq!(back.srx_string.as_deref(), Some("BAVARIA"));
assert_eq!(back.cqz, Some(15));
assert_eq!(back.qsl_sent.as_deref(), Some("Y"));
assert!(back.is_confirmed());
}
#[test]
fn a_contact_can_be_changed_and_forgotten() {
let (_dir, book) = new_book();
+85
View File
@@ -64,6 +64,39 @@ pub struct Qso {
/// Which rig of this client, by id — ours, not ADIF's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// The contest this contact counts towards, by its Cabrillo name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contest_id: Option<String>,
/// Serial sent. Kept as text as well as a number, because plenty of
/// exchanges are neither — a zone, a section, a name, a power.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stx: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stx_string: Option<String>,
/// Serial received, likewise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub srx: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub srx_string: Option<String>,
/// CQ and ITU zones of the station worked.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cqz: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ituz: Option<u8>,
/// Paper QSL, and the two card-less bureaux, as ADIF's single letters:
/// `Y` yes, `N` no, `R` requested, `I` ignore, `Q` queued, `V` verified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qsl_sent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qsl_rcvd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lotw_qsl_sent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lotw_qsl_rcvd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eqsl_qsl_sent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eqsl_qsl_rcvd: Option<String>,
/// ADIF fields this application does not model, kept verbatim.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, String>,
@@ -98,6 +131,19 @@ impl Qso {
my_gridsquare: None,
my_rig: None,
rig_id: None,
contest_id: None,
stx: None,
stx_string: None,
srx: None,
srx_string: None,
cqz: None,
ituz: None,
qsl_sent: None,
qsl_rcvd: None,
lotw_qsl_sent: None,
lotw_qsl_rcvd: None,
eqsl_qsl_sent: None,
eqsl_qsl_rcvd: None,
extra: BTreeMap::new(),
}
}
@@ -107,6 +153,34 @@ impl Qso {
band_for_hz(self.freq_hz)
}
/// Whether the other station has confirmed this contact, anywhere.
///
/// One confirmation is a confirmation: an award needs one card or one
/// electronic match, not all three, and a log that counted them separately
/// would tell the operator they were three contacts short of what they have.
pub fn is_confirmed(&self) -> bool {
[&self.qsl_rcvd, &self.lotw_qsl_rcvd, &self.eqsl_qsl_rcvd]
.into_iter()
.flatten()
.any(|state| {
let state = state.trim().to_uppercase();
state == "Y" || state == "V"
})
}
/// The exchange as it goes into a contest log: what was sent, or received.
pub fn exchange_sent(&self) -> Option<String> {
self.stx_string
.clone()
.or_else(|| self.stx.map(|serial| format!("{serial:03}")))
}
pub fn exchange_received(&self) -> Option<String> {
self.srx_string
.clone()
.or_else(|| self.srx.map(|serial| format!("{serial:03}")))
}
/// What two logs have to agree on to be describing the same contact.
///
/// Not the time: that is compared as a window by [`crate::dedupe`], because
@@ -183,6 +257,17 @@ pub fn band_midpoint_for_name(band: &str) -> Option<u64> {
.map(|(_, low, high)| low + (high - low) / 2)
}
/// Where a band sits in the table, for ordering a report by wavelength.
///
/// Anything not in the table sorts last, which is where "other" belongs.
pub fn band_order(band: &str) -> usize {
let wanted = band.trim().to_lowercase();
BANDS
.iter()
.position(|(name, _, _)| *name == wanted)
.unwrap_or(BANDS.len())
}
/// The frequency in MHz, as ADIF writes it.
pub fn freq_mhz_string(hz: u64) -> String {
// Six decimals is 1 Hz, which is finer than any dial reports and is what
+121 -88
View File
@@ -268,18 +268,21 @@ impl AsRef<str> for CookieSameSite {
pub struct HttpAuthConfig {
/// Enable HTTP frontend authentication
pub enabled: bool,
/// Passphrase for read-only access (rx role)
pub rx_passphrase: Option<String>,
/// Read the rx passphrase from this file instead.
/// JSON file containing the managed user database.
pub users_file: String,
/// Username used to create the first administrator when the database is absent.
pub bootstrap_admin_username: Option<String>,
/// Password used to create the first administrator when the database is absent.
pub bootstrap_admin_password: Option<String>,
/// Read the bootstrap administrator password from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rx_passphrase_file: Option<String>,
/// Passphrase for full control access (control role)
pub control_passphrase: Option<String>,
/// Read the control passphrase from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_passphrase_file: Option<String>,
/// Enforce TX/PTT access control (hide from unauthenticated/rx users)
pub tx_access_control_enabled: bool,
pub bootstrap_admin_password_file: Option<String>,
/// Create a Guest account when bootstrapping a new database.
pub bootstrap_read_enabled: bool,
/// Username for the Guest bootstrap account.
pub bootstrap_read_username: String,
/// Password for the Guest bootstrap account.
pub bootstrap_read_password: Option<String>,
/// Session time-to-live in minutes
pub session_ttl_min: u64,
/// Set Secure flag on session cookie (required for HTTPS)
@@ -292,11 +295,13 @@ impl Default for HttpAuthConfig {
fn default() -> Self {
Self {
enabled: false,
rx_passphrase: None,
rx_passphrase_file: None,
control_passphrase: None,
control_passphrase_file: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: None,
bootstrap_admin_password: None,
bootstrap_admin_password_file: None,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -802,14 +807,9 @@ impl ClientConfig {
)?;
}
resolve_secret(
&mut self.frontends.http.auth.rx_passphrase,
&self.frontends.http.auth.rx_passphrase_file,
"[frontends.http.auth].rx_passphrase",
)?;
resolve_secret(
&mut self.frontends.http.auth.control_passphrase,
&self.frontends.http.auth.control_passphrase_file,
"[frontends.http.auth].control_passphrase",
&mut self.frontends.http.auth.bootstrap_admin_password,
&self.frontends.http.auth.bootstrap_admin_password_file,
"[frontends.http.auth].bootstrap_admin_password",
)?;
resolve_secret_list(
&mut self.frontends.http_json.auth.tokens,
@@ -819,7 +819,7 @@ impl ClientConfig {
if let Some(path) = config_path {
if self.has_inline_secrets() {
crate::secrets::warn_if_group_readable(path, "tokens/passphrases");
crate::secrets::warn_if_group_readable(path, "tokens/passwords");
}
}
Ok(())
@@ -832,10 +832,22 @@ impl ClientConfig {
.remotes
.iter()
.any(|r| r.auth.token_file.is_none() && r.auth.token.is_some())
|| self.frontends.http.auth.rx_passphrase_file.is_none()
&& self.frontends.http.auth.rx_passphrase.is_some()
|| self.frontends.http.auth.control_passphrase_file.is_none()
&& self.frontends.http.auth.control_passphrase.is_some()
|| self
.frontends
.http
.auth
.bootstrap_admin_password_file
.is_none()
&& self.frontends.http.auth.bootstrap_admin_password.is_some()
|| self.frontends.http.auth.enabled
&& self.frontends.http.auth.bootstrap_read_enabled
&& self
.frontends
.http
.auth
.bootstrap_read_password
.as_deref()
.is_some_and(|password| password != "guest")
|| self.frontends.http_json.auth.tokens_file.is_none()
&& !self.frontends.http_json.auth.tokens.is_empty()
}
@@ -905,11 +917,13 @@ impl ClientConfig {
decode_history_retention_min_by_rig: HashMap::new(),
auth: HttpAuthConfig {
enabled: false,
rx_passphrase: Some("rx-passphrase-example".to_string()),
rx_passphrase_file: None,
control_passphrase: Some("control-passphrase-example".to_string()),
control_passphrase_file: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: Some("admin".to_string()),
bootstrap_admin_password: Some("change-this-password".to_string()),
bootstrap_admin_password_file: None,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -948,27 +962,34 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
return Ok(());
}
// If enabled, require at least one passphrase
if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() {
if auth.users_file.trim().is_empty() {
return Err("[frontends.http.auth].users_file must not be empty".to_string());
}
if auth.bootstrap_admin_username.is_some() != auth.bootstrap_admin_password.is_some() {
return Err("[frontends.http.auth] bootstrap_admin_username and bootstrap_admin_password must be set together".to_string());
}
if auth
.bootstrap_admin_username
.as_deref()
.is_some_and(|v| v.trim().is_empty())
|| auth
.bootstrap_admin_password
.as_deref()
.is_some_and(|v| v.is_empty())
{
return Err(
"[frontends.http.auth] enabled=true requires at least one passphrase \
(rx_passphrase and/or control_passphrase)"
"[frontends.http.auth] bootstrap administrator credentials must not be empty"
.to_string(),
);
}
// Validate passphrases are not empty strings
if let Some(rx) = &auth.rx_passphrase {
if rx.trim().is_empty() {
return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string());
}
}
if let Some(ctrl) = &auth.control_passphrase {
if ctrl.trim().is_empty() {
return Err(
"[frontends.http.auth].control_passphrase must not be empty if set".to_string(),
);
}
if auth.bootstrap_read_enabled
&& (auth.bootstrap_read_username.trim().is_empty()
|| auth
.bootstrap_read_password
.as_deref()
.is_none_or(str::is_empty))
{
return Err("[frontends.http.auth] enabled bootstrap Guest account requires a non-empty username and password".to_string());
}
// Session TTL must be > 0
@@ -1238,42 +1259,34 @@ home-hf = "audio://10.0.0.5:4600"
}
#[test]
fn test_validate_rejects_http_auth_enabled_without_passphrases() {
fn test_validate_accepts_http_auth_with_user_database() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_bootstrap_admin_pair() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password = Some("secret-password".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_incomplete_bootstrap_admin_pair() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
assert!(config.validate().is_err());
}
#[test]
fn test_validate_accepts_http_auth_with_rx_passphrase() {
fn test_validate_rejects_empty_users_file() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_http_auth_with_control_passphrase() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_http_auth_with_both_passphrases() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_empty_rx_passphrase() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("".to_string());
config.frontends.http.auth.users_file.clear();
assert!(config.validate().is_err());
}
@@ -1281,16 +1294,27 @@ home-hf = "audio://10.0.0.5:4600"
fn test_validate_rejects_zero_session_ttl() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
config.frontends.http.auth.session_ttl_min = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_validate_auth_disabled_ignores_passphrases() {
fn test_validate_allows_disabling_read_bootstrap_credentials() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password = Some("secret-password".to_string());
config.frontends.http.auth.bootstrap_read_enabled = false;
config.frontends.http.auth.bootstrap_read_username.clear();
config.frontends.http.auth.bootstrap_read_password = None;
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_auth_disabled_ignores_user_settings() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = false;
config.frontends.http.auth.rx_passphrase = Some("".to_string());
config.frontends.http.auth.users_file.clear();
assert!(config.validate().is_ok());
}
@@ -1298,9 +1322,12 @@ home-hf = "audio://10.0.0.5:4600"
fn test_http_auth_config_default() {
let auth = HttpAuthConfig::default();
assert!(!auth.enabled);
assert!(auth.rx_passphrase.is_none());
assert!(auth.control_passphrase.is_none());
assert!(auth.tx_access_control_enabled);
assert_eq!(auth.users_file, "trx-http-users.json");
assert!(auth.bootstrap_admin_username.is_none());
assert!(auth.bootstrap_admin_password.is_none());
assert!(auth.bootstrap_read_enabled);
assert_eq!(auth.bootstrap_read_username, "guest");
assert_eq!(auth.bootstrap_read_password.as_deref(), Some("guest"));
assert_eq!(auth.session_ttl_min, 480);
assert!(!auth.cookie_secure);
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
@@ -1602,15 +1629,21 @@ spectrum_interval_ms = 0
}
#[test]
fn test_passphrase_file_fills_passphrase() {
fn test_bootstrap_password_file_fills_password() {
let f = secret_file("hunter2\n");
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.control_passphrase_file =
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password_file =
Some(f.path().to_str().unwrap().to_string());
config.resolve_secrets(None).unwrap();
assert_eq!(
config.frontends.http.auth.control_passphrase.as_deref(),
config
.frontends
.http
.auth
.bootstrap_admin_password
.as_deref(),
Some("hunter2")
);
assert!(config.validate().is_ok());
+2 -2
View File
@@ -111,8 +111,8 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
),
(
"trx-client.frontends.http.auth",
"Passphrase login for the web UI. rx_passphrase_file and\n\
control_passphrase_file keep the secrets out of this file.",
"Optional user/password ACL for the web UI. Administrators manage\n\
accounts stored in users_file.",
),
(
"trx-client.frontends.rigctl",
+8 -5
View File
@@ -203,13 +203,16 @@ decode_history_retention_min = 1440
[trx-client.frontends.http.decode_history_retention_min_by_rig]
# Passphrase login for the web UI. rx_passphrase_file and
# control_passphrase_file keep the secrets out of this file.
# Optional user/password ACL for the web UI. Administrators manage
# accounts stored in users_file.
[trx-client.frontends.http.auth]
enabled = false
rx_passphrase = "rx-passphrase-example"
control_passphrase = "control-passphrase-example"
tx_access_control_enabled = true
users_file = "trx-http-users.json"
bootstrap_admin_username = "admin"
bootstrap_admin_password = "change-this-password"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480
cookie_secure = false
cookie_same_site = "Lax"