package extsvc import ( "context" "fmt" "html" "io" "net/http" "net/url" "strings" "time" ) // qrzAPIURL is the QRZ.com Logbook API endpoint. Note this is the LOGBOOK // API (key from the logbook's settings page), NOT the XML lookup // subscription used elsewhere for callsign data — they're different keys. const qrzAPIURL = "https://logbook.qrz.com/api" // UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by // apiKey. It returns OK when the QSO is inserted or already present // (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we // treat as success so retries are idempotent). // // API shape (form-encoded POST): // // KEY=&ACTION=INSERT&ADIF=&OPTION= // // Response is URL-encoded key/values, e.g.: // // STATUS=OK&LOGID=123456&COUNT=1&... // STATUS=FAIL&REASON=Unable+to+add+QSO+...&... // STATUS=AUTH&REASON=invalid+api+key&... func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord string) (UploadResult, error) { apiKey = strings.TrimSpace(apiKey) if apiKey == "" { return UploadResult{}, fmt.Errorf("qrz: api key not set") } if strings.TrimSpace(adifRecord) == "" { return UploadResult{}, fmt.Errorf("qrz: empty adif record") } form := url.Values{} form.Set("KEY", apiKey) form.Set("ACTION", "INSERT") // OPTION=REPLACE would overwrite an existing matching QSO; we leave it // empty so QRZ rejects duplicates (which we map to OK below). form.Set("ADIF", adifRecord) req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode())) if err != nil { return UploadResult{}, fmt.Errorf("qrz: build request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if client == nil { client = &http.Client{Timeout: 20 * time.Second} } resp, err := client.Do(req) if err != nil { return UploadResult{}, fmt.Errorf("qrz: request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if err != nil { return UploadResult{}, fmt.Errorf("qrz: read response: %w", err) } if resp.StatusCode != http.StatusOK { return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } return parseQRZResponse(string(body)) } // QRZFetchResult is the parsed outcome of a QRZ FETCH. type QRZFetchResult struct { ADIF string // raw ADIF document Result string // RESULT field (OK / FAIL / AUTH) Count string // COUNT field reported by QRZ } // FetchQRZ pulls logbook records as ADIF via the QRZ FETCH action. option is // the QRZ OPTION string (e.g. "ALL"). The ADIF document is returned in the // response's ADIF field. func FetchQRZ(ctx context.Context, client *http.Client, apiKey, option string) (QRZFetchResult, error) { var out QRZFetchResult apiKey = strings.TrimSpace(apiKey) if apiKey == "" { return out, fmt.Errorf("qrz: api key not set") } form := url.Values{} form.Set("KEY", apiKey) form.Set("ACTION", "FETCH") if option != "" { form.Set("OPTION", option) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode())) if err != nil { return out, fmt.Errorf("qrz: build request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if client == nil { client = &http.Client{Timeout: 120 * time.Second} } resp, err := client.Do(req) if err != nil { return out, fmt.Errorf("qrz: request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024)) if err != nil { return out, fmt.Errorf("qrz: read response: %w", err) } // The response is "RESULT=OK&COUNT=N&ADIF=". The ADIF blob can // contain '&' and ';', so we can't url.ParseQuery the whole body (Go // caps the number of params). Split off the ADIF value manually and // only query-parse the small status header. full := string(body) head, adifPart := full, "" if i := strings.Index(full, "ADIF="); i >= 0 { head = full[:i] adifPart = full[i+len("ADIF="):] } vals, _ := url.ParseQuery(strings.TrimRight(head, "&")) out.Result = strings.ToUpper(strings.TrimSpace(vals.Get("RESULT"))) out.Count = strings.TrimSpace(vals.Get("COUNT")) if out.Result == "AUTH" || out.Result == "FAIL" { reason := strings.TrimSpace(vals.Get("REASON")) if reason == "" { reason = "fetch rejected" } return out, fmt.Errorf("qrz: %s", reason) } // The ADIF value may be url-encoded (%3C) and/or HTML-entity-encoded // (QRZ returns < > &). Decode both so the ADIF parser sees // real '<' / '>' tags. if strings.Contains(adifPart, "%3C") || strings.Contains(adifPart, "%3c") { if dec, derr := url.QueryUnescape(adifPart); derr == nil { adifPart = dec } } if strings.Contains(adifPart, "<") || strings.Contains(adifPart, ">") || strings.Contains(adifPart, "&") { adifPart = html.UnescapeString(adifPart) } out.ADIF = adifPart return out, nil } // TestQRZ checks a logbook API key with ACTION=STATUS and returns a short // human-readable summary (callsign + QSO count) for the settings UI. An // invalid key comes back as STATUS=AUTH → returned as an error. func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, error) { apiKey = strings.TrimSpace(apiKey) if apiKey == "" { return "", fmt.Errorf("qrz: api key not set") } form := url.Values{} form.Set("KEY", apiKey) form.Set("ACTION", "STATUS") req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode())) if err != nil { return "", fmt.Errorf("qrz: build request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if client == nil { client = &http.Client{Timeout: 20 * time.Second} } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("qrz: request failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) vals, err := url.ParseQuery(strings.TrimSpace(string(body))) if err != nil { return "", fmt.Errorf("qrz: bad response: %w", err) } status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS"))) if status == "AUTH" || status == "FAIL" { reason := strings.TrimSpace(vals.Get("REASON")) if reason == "" { reason = "invalid API key" } return "", fmt.Errorf("qrz: %s", reason) } call := strings.TrimSpace(vals.Get("CALLSIGN")) count := strings.TrimSpace(vals.Get("COUNT")) switch { case call != "" && count != "": return fmt.Sprintf("Connected — %s logbook, %s QSOs", call, count), nil case call != "": return fmt.Sprintf("Connected — %s logbook", call), nil default: return "Connected — key OK", nil } } // parseQRZResponse decodes QRZ's "&"-joined, URL-encoded reply. func parseQRZResponse(body string) (UploadResult, error) { vals, err := url.ParseQuery(strings.TrimSpace(body)) if err != nil { return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err) } status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS"))) reason := strings.TrimSpace(vals.Get("REASON")) logID := strings.TrimSpace(vals.Get("LOGID")) switch status { case "OK": return UploadResult{OK: true, LogID: logID, Message: reason}, nil case "FAIL": // A duplicate is a benign failure — the QSO is in the logbook, so // from our side the upload "succeeded". Detect it from the reason. if isDuplicateReason(reason) { return UploadResult{OK: true, LogID: logID, Message: "already in logbook"}, nil } return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: upload failed: %s", reason) case "AUTH": return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: auth error: %s", reason) default: return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: unexpected status %q (%s)", status, reason) } } // isDuplicateReason recognises the various phrasings QRZ uses when a QSO is // already present. func isDuplicateReason(reason string) bool { r := strings.ToLower(reason) return strings.Contains(r, "duplicate") || strings.Contains(r, "already") || strings.Contains(r, "unable to add") && strings.Contains(r, "exists") }