fix: a test button that cannot fail is worse than no button

TestClublog checked that three fields were non-empty and returned "Ready — CALL
via EMAIL". Nothing was ever sent to Club Log, so a wrong password produced the
identical green message. It now signs in, through getadif.php because that is
the one authenticated endpoint that cannot change anything: a test must never
put a record into someone's log. A future start year keeps it from downloading
130 000 QSOs to prove a password, and a rejected login answers 403 before any
body arrives.

LoTW is two credentials doing two jobs and the button reported only the first.
Uploads go through TQSL signed by the certificate — the website password is
never involved — so a wrong one breaks nothing until the day confirmations are
downloaded, by which time nobody connects the two events. Both are now checked
and, more importantly, reported separately.

Also: the detector still refused 12 and 10 m while the PSK Reporter feed was
already subscribed to them, so those decodes were fetched and thrown away. The
rule was never "HF is out", it is "is an opening here an event" — 20 m being
open is the normal state of the band, 10 m opening is not.
This commit is contained in:
2026-08-11 13:16:09 +02:00
parent 9c33feecfa
commit 0550ecdac3
4 changed files with 108 additions and 11 deletions
+67 -4
View File
@@ -27,6 +27,11 @@ const clublogBatchURL = "https://clublog.org/putlogs.php"
// must send a real, app-identifying User-Agent.
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
// clublogDownloadURL is Club Log's ADIF export. Used ONLY to verify credentials
// (see TestClublog): it is the one authenticated endpoint that cannot change
// anything in the operator's log, which is what a test button must never do.
const clublogDownloadURL = "https://clublog.org/getadif.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in
@@ -204,17 +209,75 @@ func stripHTMLBrief(s string) string {
// TestClublog validates the configured credentials by attempting a no-op
// style check. Club Log has no dedicated status endpoint, so we report the
// fields look complete; a real failure surfaces on the first upload.
// TestClublog checks the credentials against Club Log, not against themselves.
//
// It used to verify that the three fields were non-empty and then report
// "Ready — <call> via <email>". Nothing was sent anywhere, so a wrong password
// produced exactly the same green message as a right one. That is worse than
// having no button: it is confidence the test never earned, and it cost an
// operator the one moment they were actually looking for the problem.
//
// The download endpoint is used because it is READ-ONLY: testing a password
// must not put a record into someone's log. Club Log answers a rejected login
// with 403 before sending any body, so the answer arrives immediately; on
// success the body is a log, and we read a few bytes and hang up rather than
// pull it down to prove a point.
func TestClublog(ctx context.Context, cfg ServiceConfig) (string, error) {
_ = ctx
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case strings.TrimSpace(cfg.Email) == "":
case email == "":
return "", fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return "", fmt.Errorf("clublog: password not set")
case strings.TrimSpace(cfg.Callsign) == "":
case call == "":
return "", fmt.Errorf("clublog: logbook callsign not set")
}
return fmt.Sprintf("Ready — %s via %s", strings.ToUpper(strings.TrimSpace(cfg.Callsign)), strings.TrimSpace(cfg.Email)), nil
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
form := url.Values{}
form.Set("email", email)
form.Set("password", cfg.Password)
form.Set("call", call)
// A future start date: the credentials are what is being checked, not the
// log, and an operator with 130 000 QSOs should not download them to find
// out whether a password is right.
form.Set("startyear", "2099")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogDownloadURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", clublogUserAgent)
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return "", fmt.Errorf("clublog: could not reach Club Log: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := strings.TrimSpace(string(body))
switch resp.StatusCode {
case http.StatusOK:
return fmt.Sprintf("Ready — %s via %s (Club Log accepted the login)", call, email), nil
case http.StatusUnauthorized, http.StatusForbidden:
if msg == "" {
msg = "wrong e-mail, password or logbook callsign"
}
return "", fmt.Errorf("Club Log rejected the login: %s", msg)
default:
if len(msg) > 200 {
msg = msg[:200] + "…"
}
return "", fmt.Errorf("clublog: http %d %s", resp.StatusCode, msg)
}
}
// clublogPost performs the form POST and maps the HTTP status to a result.
+25 -2
View File
@@ -277,10 +277,33 @@ func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
if err != nil {
return "", fmt.Errorf("lotw: can't read station locations: %w", err)
}
found := ""
for _, l := range locs {
if strings.EqualFold(l.Name, loc) {
return fmt.Sprintf("Ready — TQSL found, location %q (%s)", l.Name, l.Call), nil
found = l.Call
break
}
}
return "", fmt.Errorf("lotw: station location %q not found in TQSL", loc)
if found == "" {
return "", fmt.Errorf("lotw: station location %q not found in TQSL", loc)
}
// LoTW is TWO credentials doing two jobs, and the button used to report only
// the first. Uploading goes through TQSL and is signed by the certificate —
// the website password is never involved, so a wrong one breaks nothing until
// the day confirmations are downloaded and nobody connects the two events.
//
// So the download login is tested separately, and said separately. A future
// "since" date makes LoTW return an empty report rather than the whole
// account: the credentials are what is being checked, not the log.
up := fmt.Sprintf("Ready — TQSL found, location %q (%s)", loc, found)
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
return up + ". Download login not set — confirmations cannot be fetched.", nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := DownloadLoTWConfirmations(ctx, nil, cfg, "2099-01-01", ""); err != nil {
return "", fmt.Errorf("%s — but the DOWNLOAD login failed: %w", up, err)
}
return up + ". Download login accepted.", nil
}