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 link out of the DownloadInBox reply.
// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes,
// e.g. `` or ``, and the
// path may be root-relative or a bare filename. Match case-insensitively with
// optional quoting; resolve to an absolute URL afterwards.
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.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: …", "You have no … confirmations").
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page))
}
// Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so
// the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\
// xxx.adi`); normalise those. It may also be absolute or a bare filename.
link := strings.TrimSpace(m[1])
link = strings.ReplaceAll(link, `\`, "/")
link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…"
var fileURL string
switch {
case strings.HasPrefix(strings.ToLower(link), "http"):
fileURL = link
case strings.HasPrefix(link, "/"):
fileURL = eqslBaseURL + link
default:
fileURL = eqslBaseURL + "/" + link
}
// Step 2: fetch the generated .adi file.
adif, err := eqslGet(ctx, client, fileURL)
if err != nil {
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
}
return adif, nil
}
// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's
// own error/notice text if present, else a short tag-stripped snippet of the page
// so the real wording (e.g. "You have no Inbox items") is visible in the log.
func eqslNoLinkReason(page string) string {
low := strings.ToLower(page)
for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} {
if i := strings.Index(low, marker); i >= 0 {
seg := page[i:]
if len(seg) > 160 {
seg = seg[:160]
}
return strings.Join(strings.Fields(stripTags(seg)), " ")
}
}
txt := strings.Join(strings.Fields(stripTags(page)), " ")
if len(txt) > 200 {
txt = txt[:200]
}
if txt == "" {
txt = "empty response"
}
return txt
}
// stripTags removes HTML tags for a readable one-line diagnostic.
func stripTags(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)
}
}
}
return b.String()
}
// 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
}