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.
322 lines
12 KiB
Go
322 lines
12 KiB
Go
package extsvc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint, used
|
|
// when a QSO is logged. Bulk/manual uploads go to clublogBatchURL instead.
|
|
const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
|
|
|
// clublogBatchURL is Club Log's batch ADIF endpoint: it accepts a whole ADIF
|
|
// file in one multipart request and dedupes server-side, so a manual upload of
|
|
// N QSOs is one HTTP request instead of N realtime.php calls.
|
|
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
|
|
|
// clublogUserAgent identifies OpsLog to Club Log. Go's default
|
|
// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx
|
|
// returns 403 Forbidden before the request reaches the app), so every request
|
|
// 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
|
|
// rather than asking each user for one. It's an application identifier, not
|
|
// a user secret, but note it is visible in the source and the binary.
|
|
const clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
|
|
|
|
// UploadClublog pushes one ADIF record to Club Log in real time. The user
|
|
// supplies the account email + password and the logbook callsign; the
|
|
// application API key is embedded (clublogAppAPIKey), so users never need
|
|
// one — same UX as Log4OM.
|
|
//
|
|
// Form params:
|
|
//
|
|
// email, password, callsign, adif, clientident, api
|
|
//
|
|
// Club Log replies with HTTP 200 on success; on failure it returns a 4xx/5xx
|
|
// status with a plain-text reason in the body.
|
|
func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
|
email := strings.TrimSpace(cfg.Email)
|
|
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
|
switch {
|
|
case email == "":
|
|
return UploadResult{}, fmt.Errorf("clublog: account email not set")
|
|
case cfg.Password == "":
|
|
return UploadResult{}, fmt.Errorf("clublog: password not set")
|
|
case call == "":
|
|
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
|
|
case strings.TrimSpace(adifRecord) == "":
|
|
return UploadResult{}, fmt.Errorf("clublog: empty adif record")
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("email", email)
|
|
form.Set("password", cfg.Password)
|
|
form.Set("callsign", call)
|
|
form.Set("adif", adifRecord)
|
|
form.Set("clientident", "OpsLog")
|
|
// Club Log requires the application API key. Use OpsLog's embedded key;
|
|
// a per-user override (cfg.APIKey) wins if one is ever configured.
|
|
api := strings.TrimSpace(cfg.APIKey)
|
|
if api == "" {
|
|
api = clublogAppAPIKey
|
|
}
|
|
form.Set("api", api)
|
|
|
|
res, err := clublogPost(ctx, client, clublogRealtimeURL, form)
|
|
if err != nil {
|
|
return UploadResult{}, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// UploadClublogADIF pushes a whole ADIF document (header + many records) to
|
|
// Club Log's batch endpoint (putlogs.php) in a single multipart request. Use
|
|
// this for manual/bulk uploads instead of calling UploadClublog per QSO. Club
|
|
// Log dedupes server-side, so re-uploading QSOs it already holds is harmless.
|
|
//
|
|
// Multipart form fields: email, password, callsign, api, clientident, and the
|
|
// ADIF as a "file" upload. Returns HTTP 200 on success with a summary body.
|
|
func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConfig, adifDoc string) (UploadResult, error) {
|
|
email := strings.TrimSpace(cfg.Email)
|
|
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
|
switch {
|
|
case email == "":
|
|
return UploadResult{}, fmt.Errorf("clublog: account email not set")
|
|
case cfg.Password == "":
|
|
return UploadResult{}, fmt.Errorf("clublog: password not set")
|
|
case call == "":
|
|
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
|
|
case strings.TrimSpace(adifDoc) == "":
|
|
return UploadResult{}, fmt.Errorf("clublog: empty adif document")
|
|
}
|
|
api := strings.TrimSpace(cfg.APIKey)
|
|
if api == "" {
|
|
api = clublogAppAPIKey
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
_ = mw.WriteField("email", email)
|
|
_ = mw.WriteField("password", cfg.Password)
|
|
_ = mw.WriteField("callsign", call)
|
|
_ = mw.WriteField("api", api)
|
|
_ = mw.WriteField("clientident", "OpsLog")
|
|
fw, err := mw.CreateFormFile("file", "opslog.adi")
|
|
if err != nil {
|
|
return UploadResult{}, fmt.Errorf("clublog: build form: %w", err)
|
|
}
|
|
if _, err := io.WriteString(fw, adifDoc); err != nil {
|
|
return UploadResult{}, fmt.Errorf("clublog: write adif: %w", err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return UploadResult{}, fmt.Errorf("clublog: finalise form: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogBatchURL, &buf)
|
|
if err != nil {
|
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.Header.Set("User-Agent", clublogUserAgent)
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 120 * time.Second}
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
|
msg := strings.TrimSpace(string(body))
|
|
if resp.StatusCode == http.StatusOK {
|
|
return UploadResult{OK: true, Message: msg}, nil
|
|
}
|
|
diag := clublogFailureDiag(resp, msg)
|
|
return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag)
|
|
}
|
|
|
|
// clublogFailureDiag turns a non-200 response into a readable one-liner that
|
|
// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare,
|
|
// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx"
|
|
// page means the request never reached the app; the fingerprint headers below
|
|
// tell the operator whether it's Club Log's own WAF or something on their side.
|
|
func clublogFailureDiag(resp *http.Response, body string) string {
|
|
server := strings.TrimSpace(resp.Header.Get("Server"))
|
|
// Notable intermediary fingerprints — presence points at the culprit.
|
|
fp := []string{}
|
|
for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} {
|
|
if v := strings.TrimSpace(resp.Header.Get(h)); v != "" {
|
|
fp = append(fp, h+"="+v)
|
|
}
|
|
}
|
|
// Collapse the HTML error page to something short.
|
|
summary := stripHTMLBrief(body)
|
|
if summary == "" {
|
|
summary = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
|
}
|
|
out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary)
|
|
if server != "" {
|
|
out += " [server=" + server + "]"
|
|
}
|
|
if len(fp) > 0 {
|
|
out += " [" + strings.Join(fp, " ") + "]"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160
|
|
// chars of visible text — enough to read "403 Forbidden" without the markup.
|
|
func stripHTMLBrief(s string) string {
|
|
var b strings.Builder
|
|
depth := 0
|
|
for _, r := range s {
|
|
switch r {
|
|
case '<':
|
|
depth++
|
|
case '>':
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
default:
|
|
if depth == 0 {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
}
|
|
out := strings.Join(strings.Fields(b.String()), " ")
|
|
if len(out) > 160 {
|
|
out = out[:160] + "…"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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) {
|
|
email := strings.TrimSpace(cfg.Email)
|
|
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
|
switch {
|
|
case email == "":
|
|
return "", fmt.Errorf("clublog: account email not set")
|
|
case cfg.Password == "":
|
|
return "", fmt.Errorf("clublog: password not set")
|
|
case call == "":
|
|
return "", fmt.Errorf("clublog: logbook callsign not set")
|
|
}
|
|
|
|
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.
|
|
func clublogPost(ctx context.Context, client *http.Client, endpoint string, form url.Values) (UploadResult, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("User-Agent", clublogUserAgent)
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 20 * time.Second}
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
|
msg := strings.TrimSpace(string(body))
|
|
|
|
switch {
|
|
case resp.StatusCode == http.StatusOK:
|
|
return UploadResult{OK: true, Message: msg}, nil
|
|
case isClublogDuplicate(resp.StatusCode, msg):
|
|
// Club Log rejects an exact duplicate; treat as already-logged.
|
|
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
|
default:
|
|
if msg == "" {
|
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
|
}
|
|
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: upload failed: %s", msg)
|
|
}
|
|
}
|
|
|
|
// isClublogDuplicate recognises Club Log's "already have this QSO" rejection
|
|
// so repeated uploads stay idempotent.
|
|
func isClublogDuplicate(status int, msg string) bool {
|
|
m := strings.ToLower(msg)
|
|
return strings.Contains(m, "duplicate") || strings.Contains(m, "already")
|
|
}
|