The per-QSO API is the only correct way to send a SELECTION, and at the pace it must be driven a 14k backlog costs the better part of an hour. HamQTH's other endpoint takes a whole log as one file — and REPLACES what is on the site with it: its documentation says plainly that partial uploads do not exist. So it is offered as its own deliberate act behind a confirmation, never as the batch path behind 'send these', where it would delete every QSO the operator had not selected. Scoped to the callsign this profile uploads as, so a database holding two operators' contacts cannot push one into the other's log; tar.gz above 12 MB because the ceiling is 20 and a six-figure log passes it as text; and every QSO not already stamped is marked sent afterwards, in bulk, so the backlog list agrees with reality.
269 lines
9.2 KiB
Go
269 lines
9.2 KiB
Go
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 <insert>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 [email protected] -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, "<session_id>") {
|
|
return fmt.Sprintf("Connected to HamQTH as %s.", user), nil
|
|
}
|
|
if i := strings.Index(s, "<error>"); i >= 0 {
|
|
e := s[i+len("<error>"):]
|
|
if j := strings.Index(e, "</error>"); j >= 0 {
|
|
return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j]))
|
|
}
|
|
}
|
|
return "", fmt.Errorf("hamqth: unexpected answer — check the username and password")
|
|
}
|