233 lines
8.0 KiB
Go
233 lines
8.0 KiB
Go
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=<logbook key>&ACTION=INSERT&ADIF=<one record>&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=<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")
|
|
}
|