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:
@@ -66,9 +66,16 @@ func DefaultConfig() Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Bands watched. HF is deliberately absent: an "opening" on 20 m is the normal
|
||||
// state of the band and announcing it would be noise.
|
||||
var watched = map[string]bool{"6m": true, "4m": true, "2m": true}
|
||||
// Bands watched.
|
||||
//
|
||||
// 10 and 12 m are in, and they are HF. The line is not "HF versus VHF" but
|
||||
// "is an opening here an event": 20 m being open is the normal state of the
|
||||
// band and saying so is noise, while 10 and 12 m spend most of a solar cycle
|
||||
// shut and open sharply when they go — which is exactly what an operator wants
|
||||
// interrupting them for. They also carry the Es that reaches 6 m, often first.
|
||||
//
|
||||
// Anything below 12 m stays out for the reason above.
|
||||
var watched = map[string]bool{"12m": true, "10m": true, "6m": true, "4m": true, "2m": true}
|
||||
|
||||
// Watched reports whether a band is one the detector looks at.
|
||||
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user