package extsvc import ( "archive/tar" "bytes" "compress/gzip" "context" "fmt" "io" "mime/multipart" "net/http" "net/url" "strings" "time" ) // HamQTH real-time QSO upload. // // One POST per QSO to qso_realtime.php with the account username/password — // the same credentials the HamQTH callbook lookup uses. The answer is the // HTTP status code, not a body format: 200 saved, 400 rejected (bad band, // duplicate…, reason in the body), 403 wrong credentials, 500 server error. const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php" // hamqthFullLogURL takes a WHOLE log as a file. Note "whole": HamQTH's own // documentation says "you always have to upload whole log. HamQTH doesn't // support partial upload" — the file REPLACES what is on the site. That is why // it is not the batch path for a selection, and why the caller must have said // so out loud before we get here. const hamqthFullLogURL = "https://www.hamqth.com/prg_log_upload.php" // hamqthMaxUpload is the documented ceiling for one upload. const hamqthMaxUpload = 20 << 20 // hamqthCompressAbove is where a plain .adi stops being sent as text. Well // under the limit: the multipart envelope and the form fields ride along too. const hamqthCompressAbove = 12 << 20 // hamqthLoginURL is the callbook session login — the one authenticated HamQTH // endpoint that cannot change anything in the log, which is what the settings // Test button must call. const hamqthLoginURL = "https://www.hamqth.com/xml.php" // UploadHamQTH pushes one ADIF record to the HamQTH online logbook. func UploadHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) { return uploadHamQTHTo(ctx, client, hamqthUploadURL, cfg, adifRecord) } func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) { user := strings.TrimSpace(cfg.Username) switch { case user == "": return UploadResult{}, fmt.Errorf("hamqth: username not set") case cfg.Password == "": return UploadResult{}, fmt.Errorf("hamqth: password not set") } rec := strings.TrimSpace(adifRecord) if rec == "" { return UploadResult{}, fmt.Errorf("hamqth: empty ADIF record") } form := url.Values{} form.Set("u", user) form.Set("p", cfg.Password) // c: the logbook callsign when the account holds several; empty means the // account's own call, which is the common case. if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" { form.Set("c", c) } form.Set("adif", rec) form.Set("prg", "OpsLog") form.Set("cmd", "insert") req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return UploadResult{}, 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 UploadResult{}, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) msg := strings.TrimSpace(string(body)) switch resp.StatusCode { case http.StatusOK: return UploadResult{OK: true}, nil case http.StatusBadRequest: // "Rejected" covers duplicates too. A duplicate is a SUCCESS for our // purposes — the QSO is in the logbook, retrying it forever isn't — // same treatment HRDLog's 0 gets. if strings.Contains(strings.ToLower(msg), "dupl") { return UploadResult{OK: true, Ignored: true, Message: "already in logbook"}, nil } if msg == "" { msg = "QSO rejected" } return UploadResult{OK: false, Message: msg}, nil case http.StatusForbidden: return UploadResult{}, fmt.Errorf("hamqth: wrong username or password") default: if msg != "" && len(msg) < 200 { return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg) } return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode) } } // UploadHamQTHFullLog replaces the account's log with the given ADIF. // // DESTRUCTIVE by design of the remote API, not by ours: everything on HamQTH // for this callsign that is not in this file stops existing. The caller owns // the confirmation. // // The file goes in the multipart field "f" (HamQTH's own curl example: // curl -F f=@log.adi -F send_log=OK -F u=… -F p=…). A large log is sent as a // tar.gz — one of the archive formats the site unpacks — because the ceiling is // 20 MB and a six-figure log passes it as plain text. func UploadHamQTHFullLog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifText string) (UploadResult, error) { user := strings.TrimSpace(cfg.Username) switch { case user == "": return UploadResult{}, fmt.Errorf("hamqth: username not set") case cfg.Password == "": return UploadResult{}, fmt.Errorf("hamqth: password not set") case strings.TrimSpace(adifText) == "": return UploadResult{}, fmt.Errorf("hamqth: nothing to upload") } payload := []byte(adifText) name := "opslog.adi" if len(payload) > hamqthCompressAbove { gz, err := tarGzADIF(payload) if err != nil { return UploadResult{}, fmt.Errorf("hamqth: compressing the log: %w", err) } payload, name = gz, "opslog.tar.gz" } if len(payload) > hamqthMaxUpload { return UploadResult{}, fmt.Errorf("hamqth: the log is %d MB compressed, over HamQTH's %d MB limit", len(payload)>>20, hamqthMaxUpload>>20) } var body bytes.Buffer mw := multipart.NewWriter(&body) _ = mw.WriteField("u", user) _ = mw.WriteField("p", cfg.Password) if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" { _ = mw.WriteField("c", c) } _ = mw.WriteField("send_log", "OK") fw, err := mw.CreateFormFile("f", name) if err != nil { return UploadResult{}, err } if _, err := fw.Write(payload); err != nil { return UploadResult{}, err } if err := mw.Close(); err != nil { return UploadResult{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, hamqthFullLogURL, &body) if err != nil { return UploadResult{}, err } req.Header.Set("Content-Type", mw.FormDataContentType()) if client == nil { // A whole log is a long POST on a slow uplink. client = &http.Client{Timeout: 10 * time.Minute} } resp, err := client.Do(req) if err != nil { return UploadResult{}, err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) msg := strings.TrimSpace(string(raw)) if looksLikeHTML(msg) { msg = "" } if resp.StatusCode != http.StatusOK { if msg != "" && len(msg) < 300 { return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg) } return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode) } // The site answers in prose, and only its own refusals are worth reading // back: the ADIF itself is validated later, in the background, and any // complaint about it reaches the operator by e-mail rather than here. low := strings.ToLower(msg) switch { case strings.Contains(low, "successfully"): return UploadResult{OK: true, Message: msg}, nil case strings.Contains(low, "wrong username"), strings.Contains(low, "password"): return UploadResult{}, fmt.Errorf("hamqth: %s", msg) case strings.Contains(low, "cannot upload log for this callsign"): return UploadResult{}, fmt.Errorf("hamqth: %s", msg) case msg == "": // HTTP 200 with nothing to say: taken as accepted, and said so. return UploadResult{OK: true, Message: "uploaded (no reply text)"}, nil default: return UploadResult{OK: false, Message: msg}, nil } } // tarGzADIF wraps the ADIF as log.adi inside a tar.gz — the archive must carry // a .adi/.adif member for HamQTH to find the log in it. func tarGzADIF(adif []byte) ([]byte, error) { var out bytes.Buffer gz := gzip.NewWriter(&out) tw := tar.NewWriter(gz) if err := tw.WriteHeader(&tar.Header{ Name: "opslog.adi", Mode: 0o644, Size: int64(len(adif)), }); err != nil { return nil, err } if _, err := tw.Write(adif); err != nil { return nil, err } if err := tw.Close(); err != nil { return nil, err } if err := gz.Close(); err != nil { return nil, err } return out.Bytes(), nil } // TestHamQTH verifies the credentials against the callbook session login — // authenticated, and unable to touch the log. func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) { user := strings.TrimSpace(cfg.Username) if user == "" || cfg.Password == "" { return "", fmt.Errorf("hamqth: set the username and password first") } q := url.Values{} q.Set("u", user) q.Set("p", cfg.Password) req, err := http.NewRequestWithContext(ctx, http.MethodGet, hamqthLoginURL+"?"+q.Encode(), nil) if err != nil { return "", err } if client == nil { client = &http.Client{Timeout: 30 * time.Second} } resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) s := string(body) if strings.Contains(s, "") { return fmt.Sprintf("Connected to HamQTH as %s.", user), nil } if i := strings.Index(s, ""); i >= 0 { e := s[i+len(""):] if j := strings.Index(e, ""); j >= 0 { return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j])) } } return "", fmt.Errorf("hamqth: unexpected answer — check the username and password") }