feat(hamqth): upload the whole log in one file
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.
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -18,6 +22,20 @@ import (
|
||||
// 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.
|
||||
@@ -92,6 +110,127 @@ func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, c
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user