package extsvc import ( "context" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "strings" "time" ) // eqslImportURL is eQSL.cc's ADIF import endpoint. It accepts a form-encoded // POST (or URL params) with the account credentials and the ADIF content. const eqslImportURL = "https://www.eQSL.cc/qslcard/ImportADIF.cfm" // eqslResultRe extracts "Result: X out of Y records added" from the reply. var eqslResultRe = regexp.MustCompile(`(?i)result:\s*(\d+)\s+out of\s+(\d+)\s+records added`) // eqslPost performs the import POST and returns the raw response body. eQSL // replies HTTP 200 with a plain-text/HTML body for both success and errors; // callers classify it via the markers below. func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) (string, error) { form := url.Values{} form.Set("EQSL_USER", user) form.Set("EQSL_PSWD", pswd) form.Set("ADIFData", adif) req, err := http.NewRequestWithContext(ctx, http.MethodPost, eqslImportURL, strings.NewReader(form.Encode())) if err != nil { return "", fmt.Errorf("eqsl: build request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if client == nil { client = &http.Client{Timeout: 30 * time.Second} } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("eqsl: request failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) msg := strings.TrimSpace(string(body)) if resp.StatusCode != http.StatusOK { if msg == "" { msg = fmt.Sprintf("HTTP %d", resp.StatusCode) } return msg, fmt.Errorf("eqsl: http %d: %s", resp.StatusCode, msg) } return msg, nil } // eQSL Inbox download endpoint + base host. DownloadInBox.cfm builds an ADIF of // the account's *received* eQSLs (confirmations) and returns an HTML page that // links to the generated .adi file; we then fetch that file (two-step API). const ( eqslDownloadInboxURL = "https://www.eQSL.cc/qslcard/DownloadInBox.cfm" eqslBaseURL = "https://www.eQSL.cc" ) // eqslAdiHrefRe pulls the generated .adi path out of the DownloadInBox reply, // e.g. ``. var eqslAdiHrefRe = regexp.MustCompile(`(?i)href="(/[^"]+\.adi)"`) // DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs = // confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since // then, incremental "last download"); qthNick comes from the config when the // account has several QTH profiles. func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) { user := strings.ToUpper(strings.TrimSpace(cfg.Username)) if user == "" || strings.TrimSpace(cfg.Password) == "" { return "", fmt.Errorf("eqsl: username/password not set") } if client == nil { client = &http.Client{Timeout: 120 * time.Second} } q := url.Values{} q.Set("UserName", user) q.Set("Password", cfg.Password) if nick := strings.TrimSpace(cfg.QTHNickname); nick != "" { q.Set("QTHNickname", nick) } if s := eqslRcvdSince(since); s != "" { q.Set("RcvdSince", s) // eQSL wants YYYYMMDDHHMM } // Step 1: ask eQSL to build the inbox ADIF; the reply is HTML linking to it. page, err := eqslGet(ctx, client, eqslDownloadInboxURL+"?"+q.Encode()) if err != nil { return "", err } if reason := authErrEQSL(page); reason != "" { return "", fmt.Errorf("eqsl: %s", reason) } m := eqslAdiHrefRe.FindStringSubmatch(page) if m == nil { // eQSL reports problems inline ("Error: …", or "no QSLs" wording). return "", fmt.Errorf("eqsl: no download link in response: %s", eqslReason(page)) } // Step 2: fetch the generated .adi file. adif, err := eqslGet(ctx, client, eqslBaseURL+m[1]) if err != nil { return "", fmt.Errorf("eqsl: fetch adif: %w", err) } return adif, nil } // eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on // a non-200 status. func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return "", fmt.Errorf("eqsl: build request: %w", err) } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("eqsl: request failed: %w", err) } defer resp.Body.Close() b, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024)) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("eqsl: http %d", resp.StatusCode) } return string(b), nil } // eqslRcvdSince converts the app's "YYYY-MM-DD" into eQSL's RcvdSince format // "YYYYMMDDHHMM" (midnight). Empty in → empty out (full pull). func eqslRcvdSince(since string) string { s := strings.ReplaceAll(strings.TrimSpace(since), "-", "") if len(s) == 8 { // YYYYMMDD → append 0000 (00:00) return s + "0000" } return s } // authErrEQSL returns a reason when the response signals bad credentials, else // "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd". func authErrEQSL(body string) string { if strings.Contains(strings.ToLower(body), "no match on eqsl") { return "invalid username or password" } return "" } // eqslRecordWithNickname prepends the APP_EQSL_QTH_NICKNAME tag to an ADIF // record when nick is set, so eQSL files the QSO under the right QTH profile // (required when the account has more than one). ADIF field order is free, so // prepending before the rest of the record is valid. func eqslRecordWithNickname(record, nick string) string { nick = strings.TrimSpace(nick) if nick == "" { return record } return fmt.Sprintf("%s%s", len(nick), nick, record) } // UploadEQSL pushes one ADIF record to eQSL.cc for the given account. qthNick // is the optional eQSL QTH nickname. // // eQSL replies with text: "Result: 1 out of 1 records added" on success, // "Bad record: Duplicate" for an already-present QSO (treated as success so // retries are idempotent), or "Error: No match on eQSL_User/eQSL_Pswd" for bad // credentials. func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, adifRecord string) (UploadResult, error) { user = strings.ToUpper(strings.TrimSpace(user)) if user == "" { return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set") } if strings.TrimSpace(pswd) == "" { return UploadResult{}, fmt.Errorf("eqsl: password not set") } if strings.TrimSpace(adifRecord) == "" { return UploadResult{}, fmt.Errorf("eqsl: empty adif record") } body, err := eqslPost(ctx, client, user, pswd, eqslRecordWithNickname(adifRecord, qthNick)) if err != nil { return UploadResult{OK: false, Message: body}, err } b := strings.ToLower(body) if reason := authErrEQSL(body); reason != "" { return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason) } if strings.Contains(b, "duplicate") { return UploadResult{OK: true, Message: "already in logbook"}, nil } if m := eqslResultRe.FindStringSubmatch(body); m != nil { added, _ := strconv.Atoi(m[1]) if added >= 1 { return UploadResult{OK: true, Message: strings.TrimSpace(m[0])}, nil } // "0 out of N" — eQSL accepted nothing; surface why if it said so. reason := eqslReason(body) return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason) } reason := eqslReason(body) return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason) } // eqslReason trims an eQSL reply to a short human-readable reason: the first // "Error:" / "Warning:" / "Bad record:" line if present, else the whole body // (capped), else a generic phrase. func eqslReason(body string) string { for _, line := range strings.Split(body, "\n") { l := strings.TrimSpace(line) ll := strings.ToLower(l) if strings.HasPrefix(ll, "error:") || strings.HasPrefix(ll, "warning:") || strings.Contains(ll, "bad record") { return l } } b := strings.TrimSpace(body) if b == "" { return "upload rejected" } if len(b) > 200 { b = b[:200] } return b } // TestEQSL validates the configured eQSL credentials with a REAL request: it // posts an empty ADIF so nothing is inserted, then checks for the bad-login // marker. Anything else means the credentials were accepted. func TestEQSL(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) { user := strings.ToUpper(strings.TrimSpace(cfg.Username)) if user == "" { return "", fmt.Errorf("eqsl: username (callsign) not set") } if strings.TrimSpace(cfg.Password) == "" { return "", fmt.Errorf("eqsl: password not set") } body, err := eqslPost(ctx, client, user, cfg.Password, "") if err != nil { return "", err } if reason := authErrEQSL(body); reason != "" { return "", fmt.Errorf("eqsl: %s", reason) } return fmt.Sprintf("Credentials accepted — %s", user), nil }