feat: upload to external services clublog qrz
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint.
|
||||
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.)
|
||||
const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
||||
|
||||
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
||||
// requires an api parameter that identifies the client software (not the
|
||||
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
||||
// rather than asking each user for one. It's an application identifier, not
|
||||
// a user secret, but note it is visible in the source and the binary.
|
||||
const clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
|
||||
|
||||
// UploadClublog pushes one ADIF record to Club Log in real time. The user
|
||||
// supplies the account email + password and the logbook callsign; the
|
||||
// application API key is embedded (clublogAppAPIKey), so users never need
|
||||
// one — same UX as Log4OM.
|
||||
//
|
||||
// Form params:
|
||||
//
|
||||
// email, password, callsign, adif, clientident, api
|
||||
//
|
||||
// Club Log replies with HTTP 200 on success; on failure it returns a 4xx/5xx
|
||||
// status with a plain-text reason in the body.
|
||||
func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
email := strings.TrimSpace(cfg.Email)
|
||||
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||
switch {
|
||||
case email == "":
|
||||
return UploadResult{}, fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return UploadResult{}, fmt.Errorf("clublog: password not set")
|
||||
case call == "":
|
||||
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
|
||||
case strings.TrimSpace(adifRecord) == "":
|
||||
return UploadResult{}, fmt.Errorf("clublog: empty adif record")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("email", email)
|
||||
form.Set("password", cfg.Password)
|
||||
form.Set("callsign", call)
|
||||
form.Set("adif", adifRecord)
|
||||
form.Set("clientident", "OpsLog")
|
||||
// Club Log requires the application API key. Use OpsLog's embedded key;
|
||||
// a per-user override (cfg.APIKey) wins if one is ever configured.
|
||||
api := strings.TrimSpace(cfg.APIKey)
|
||||
if api == "" {
|
||||
api = clublogAppAPIKey
|
||||
}
|
||||
form.Set("api", api)
|
||||
|
||||
res, err := clublogPost(ctx, client, clublogRealtimeURL, form)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// TestClublog validates the configured credentials by attempting a no-op
|
||||
// style check. Club Log has no dedicated status endpoint, so we report the
|
||||
// fields look complete; a real failure surfaces on the first upload.
|
||||
func TestClublog(ctx context.Context, cfg ServiceConfig) (string, error) {
|
||||
_ = ctx
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Email) == "":
|
||||
return "", fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return "", fmt.Errorf("clublog: password not set")
|
||||
case strings.TrimSpace(cfg.Callsign) == "":
|
||||
return "", fmt.Errorf("clublog: logbook callsign not set")
|
||||
}
|
||||
return fmt.Sprintf("Ready — %s via %s", strings.ToUpper(strings.TrimSpace(cfg.Callsign)), strings.TrimSpace(cfg.Email)), nil
|
||||
}
|
||||
|
||||
// clublogPost performs the form POST and maps the HTTP status to a result.
|
||||
func clublogPost(ctx context.Context, client *http.Client, endpoint string, form url.Values) (UploadResult, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
msg := strings.TrimSpace(string(body))
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
return UploadResult{OK: true, Message: msg}, nil
|
||||
case isClublogDuplicate(resp.StatusCode, msg):
|
||||
// Club Log rejects an exact duplicate; treat as already-logged.
|
||||
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||
default:
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: upload failed: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// isClublogDuplicate recognises Club Log's "already have this QSO" rejection
|
||||
// so repeated uploads stay idempotent.
|
||||
func isClublogDuplicate(status int, msg string) bool {
|
||||
m := strings.ToLower(msg)
|
||||
return strings.Contains(m, "duplicate") || strings.Contains(m, "already")
|
||||
}
|
||||
Reference in New Issue
Block a user