247 lines
8.6 KiB
Go
247 lines
8.6 KiB
Go
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. `<A HREF="/qslcard/downloadedfiles/xxxx.adi">`.
|
|
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("<APP_EQSL_QTH_NICKNAME:%d>%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
|
|
}
|