chore: release v021.4
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cloudlog (and its fork Wavelog) are self-hosted logbooks, so there is no
|
||||
// fixed endpoint: the user gives the base URL of their own instance and we
|
||||
// append the API path. Both expose the SAME contract — an ADIF record wrapped
|
||||
// in JSON — which is why one uploader serves both.
|
||||
//
|
||||
// POST <base>/index.php/api/qso
|
||||
// {"key":"…","station_profile_id":"1","type":"adif","string":"<call:4>… <eor>"}
|
||||
//
|
||||
// The station profile id is NOT optional: Cloudlog files the QSO under one of
|
||||
// the account's station locations, and a wrong id silently lands the contact in
|
||||
// someone else's log slot.
|
||||
const cloudlogAPIPath = "index.php/api/qso"
|
||||
|
||||
// cloudlogEndpoint builds the API URL from whatever the user pasted. People
|
||||
// paste the dashboard URL, the API URL, with or without a trailing slash or
|
||||
// index.php, so normalise all of it rather than make them guess the exact form.
|
||||
func cloudlogEndpoint(base string) (string, error) {
|
||||
u := strings.TrimSpace(base)
|
||||
if u == "" {
|
||||
return "", fmt.Errorf("cloudlog: URL not set")
|
||||
}
|
||||
// A bare host or IP is almost always meant as http:// on a LAN instance;
|
||||
// requiring the scheme just produces a confusing "unsupported protocol".
|
||||
if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
|
||||
u = "http://" + u
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
// Trim anything the user copied past the site root, so both
|
||||
// "https://log.f4bpo.fr" and "https://log.f4bpo.fr/index.php/api/qso" work.
|
||||
for _, suffix := range []string{"/index.php/api/qso", "/api/qso", "/index.php"} {
|
||||
if strings.HasSuffix(strings.ToLower(u), suffix) {
|
||||
u = u[:len(u)-len(suffix)]
|
||||
u = strings.TrimRight(u, "/")
|
||||
}
|
||||
}
|
||||
return u + "/" + cloudlogAPIPath, nil
|
||||
}
|
||||
|
||||
// cloudlogRequest is the JSON body both Cloudlog and Wavelog expect.
|
||||
type cloudlogRequest struct {
|
||||
Key string `json:"key"`
|
||||
StationID string `json:"station_profile_id"`
|
||||
Type string `json:"type"`
|
||||
String string `json:"string"`
|
||||
}
|
||||
|
||||
// cloudlogReply covers the documented failure shape
|
||||
// ({"status":"failed","reason":"missing api key"}); success replies vary
|
||||
// between versions, so success is judged on the HTTP status plus the ABSENCE
|
||||
// of a failure marker rather than on a field that may not be there.
|
||||
type cloudlogReply struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"`
|
||||
String string `json:"string"`
|
||||
}
|
||||
|
||||
// cloudlogPost sends one JSON body and returns the trimmed response.
|
||||
func cloudlogPost(ctx context.Context, client *http.Client, endpoint string, body cloudlogRequest) (string, int, error) {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cloudlog: encode request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cloudlog: build request: %w", err)
|
||||
}
|
||||
// Content-Type is what most "wrong JSON" reports come down to: without it
|
||||
// Cloudlog falls back to form-decoding and never sees the fields.
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("cloudlog: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
return strings.TrimSpace(string(raw)), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// cloudlogReason turns a reply into a human-readable failure reason, or ""
|
||||
// when the reply looks like a success.
|
||||
func cloudlogReason(body string, status int) string {
|
||||
var r cloudlogReply
|
||||
if json.Unmarshal([]byte(body), &r) == nil && strings.EqualFold(r.Status, "failed") {
|
||||
if r.Reason != "" {
|
||||
return r.Reason
|
||||
}
|
||||
return "rejected"
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
// The documented 401 body is {"status":"failed","reason":"missing api key"},
|
||||
// but a reverse proxy in front of the instance can swallow it.
|
||||
return "API key refused"
|
||||
case status == http.StatusNotFound:
|
||||
return "API not found at this URL — check the address of your Cloudlog/Wavelog instance"
|
||||
case status >= 400:
|
||||
msg := body
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
}
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", status)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UploadCloudlog pushes one ADIF record to a Cloudlog or Wavelog instance.
|
||||
//
|
||||
// Duplicates are handled by the server (Cloudlog dedupes on the fly), so a
|
||||
// retry of an already-accepted QSO is harmless — the upload stays idempotent
|
||||
// without OpsLog having to track it.
|
||||
func UploadCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
endpoint, err := cloudlogEndpoint(cfg.URL)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
key := strings.TrimSpace(cfg.APIKey)
|
||||
station := strings.TrimSpace(cfg.StationID)
|
||||
if key == "" {
|
||||
return UploadResult{}, fmt.Errorf("cloudlog: API key not set")
|
||||
}
|
||||
if station == "" {
|
||||
return UploadResult{}, fmt.Errorf("cloudlog: station ID not set")
|
||||
}
|
||||
if strings.TrimSpace(adifRecord) == "" {
|
||||
return UploadResult{}, fmt.Errorf("cloudlog: empty adif record")
|
||||
}
|
||||
|
||||
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
|
||||
Key: key, StationID: station, Type: "adif", String: adifRecord,
|
||||
})
|
||||
if err != nil {
|
||||
return UploadResult{OK: false, Message: body}, err
|
||||
}
|
||||
// The endpoint is echoed in both outcomes: a self-hosted instance means the
|
||||
// URL itself is a prime suspect, and a log line naming what was actually
|
||||
// called settles it without the operator having to guess how we normalised
|
||||
// what they typed.
|
||||
if reason := cloudlogReason(body, status); reason != "" {
|
||||
return UploadResult{OK: false, Message: reason},
|
||||
fmt.Errorf("cloudlog: POST %s → HTTP %d: %s", endpoint, status, reason)
|
||||
}
|
||||
return UploadResult{OK: true, Message: fmt.Sprintf("uploaded to %s (HTTP %d)", endpoint, status)}, nil
|
||||
}
|
||||
|
||||
// TestCloudlog validates URL, API key and station ID with a REAL request.
|
||||
//
|
||||
// It posts a well-formed body with an EMPTY ADIF string: the credentials are
|
||||
// checked by the server before the record is parsed, so a bad key or id fails
|
||||
// exactly as it would for a real QSO, while nothing is inserted.
|
||||
func TestCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||
endpoint, err := cloudlogEndpoint(cfg.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := strings.TrimSpace(cfg.APIKey)
|
||||
station := strings.TrimSpace(cfg.StationID)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("cloudlog: API key not set")
|
||||
}
|
||||
if station == "" {
|
||||
return "", fmt.Errorf("cloudlog: station ID not set")
|
||||
}
|
||||
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
|
||||
Key: key, StationID: station, Type: "adif", String: "",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reason := cloudlogReason(body, status); reason != "" {
|
||||
return "", fmt.Errorf("cloudlog: %s", reason)
|
||||
}
|
||||
return fmt.Sprintf("Connected — station profile %s", station), nil
|
||||
}
|
||||
@@ -33,6 +33,9 @@ const (
|
||||
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
||||
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
||||
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
|
||||
// only the instance URL differs, so one service handles both.
|
||||
ServiceCloudlog Service = "cloudlog"
|
||||
)
|
||||
|
||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||
@@ -63,6 +66,8 @@ const (
|
||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||
type ServiceConfig struct {
|
||||
APIKey string `json:"api_key"`
|
||||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
@@ -83,6 +88,8 @@ type ServiceConfig struct {
|
||||
// mode (defaults to immediate).
|
||||
func (c ServiceConfig) normalised() ServiceConfig {
|
||||
c.APIKey = strings.TrimSpace(c.APIKey)
|
||||
c.URL = strings.TrimSpace(c.URL)
|
||||
c.StationID = strings.TrimSpace(c.StationID)
|
||||
c.Email = strings.TrimSpace(c.Email)
|
||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||
c.Code = strings.TrimSpace(c.Code)
|
||||
@@ -115,11 +122,12 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
||||
|
||||
// ExternalServices bundles every service's config for the settings UI.
|
||||
type ExternalServices struct {
|
||||
QRZ ServiceConfig `json:"qrz"`
|
||||
Clublog ServiceConfig `json:"clublog"`
|
||||
LoTW ServiceConfig `json:"lotw"`
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
QRZ ServiceConfig `json:"qrz"`
|
||||
Clublog ServiceConfig `json:"clublog"`
|
||||
LoTW ServiceConfig `json:"lotw"`
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
|
||||
@@ -138,7 +138,30 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
cfg.LoTW = cfg.LoTW.normalised()
|
||||
cfg.HRDLog = cfg.HRDLog.normalised()
|
||||
cfg.EQSL = cfg.EQSL.normalised()
|
||||
cfg.Cloudlog = cfg.Cloudlog.normalised()
|
||||
m.cfg = cfg
|
||||
|
||||
// Summary of what is armed, written at startup and on every settings save.
|
||||
// It answers "is the service even switched on for this profile?" — the
|
||||
// settings are per-profile, so a service configured under another profile
|
||||
// looks enabled in the UI of the one and silent in the other.
|
||||
var on []string
|
||||
for _, s := range []struct {
|
||||
name string
|
||||
cfg ServiceConfig
|
||||
}{
|
||||
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
|
||||
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
|
||||
} {
|
||||
if s.cfg.AutoUpload {
|
||||
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
|
||||
}
|
||||
}
|
||||
if len(on) == 0 {
|
||||
m.logf("extsvc: auto-upload disabled for every service")
|
||||
} else {
|
||||
m.logf("extsvc: auto-upload armed for %s", strings.Join(on, " "))
|
||||
}
|
||||
}
|
||||
|
||||
// Config returns the current snapshot.
|
||||
@@ -182,6 +205,28 @@ func (m *Manager) OnQSOLogged(id int64) {
|
||||
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
|
||||
m.route(ServiceEQSL, id, e)
|
||||
}
|
||||
// Cloudlog / Wavelog — the instance URL, an API key and the station id.
|
||||
if c := cfg.Cloudlog; c.AutoUpload {
|
||||
// Say WHY nothing happens when the toggle is on but a field is missing.
|
||||
// Without this the whole path was silent — the operator saw no upload and
|
||||
// no log line, with no way to tell "disabled" from "broken".
|
||||
var missing []string
|
||||
if c.URL == "" {
|
||||
missing = append(missing, "URL")
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
missing = append(missing, "API key")
|
||||
}
|
||||
if c.StationID == "" {
|
||||
missing = append(missing, "station ID")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
m.logf("extsvc: cloudlog auto-upload is ON but not configured — missing %s (QSO %d not sent)",
|
||||
strings.Join(missing, ", "), id)
|
||||
} else {
|
||||
m.route(ServiceCloudlog, id, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route sends a logged QSO down the configured timing path: queue it for the
|
||||
@@ -229,6 +274,9 @@ func (m *Manager) onCloseServices() []Service {
|
||||
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
|
||||
out = append(out, ServiceEQSL)
|
||||
}
|
||||
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
||||
out = append(out, ServiceCloudlog)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -288,6 +336,12 @@ func (m *Manager) FlushOnClose() int {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceCloudlog:
|
||||
for _, id := range ids {
|
||||
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
@@ -376,6 +430,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
}
|
||||
}
|
||||
|
||||
// One line per attempt, BEFORE the request: a failure that never returns
|
||||
// (hung TCP connect to a self-hosted instance) otherwise leaves no trace at
|
||||
// all, and "did it even try?" is the first question when an upload is missing.
|
||||
m.logf("extsvc: %s uploading QSO %d…", svc, id)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -426,6 +485,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||
case ServiceCloudlog:
|
||||
// Cloudlog/Wavelog file the QSO under a station profile chosen by id,
|
||||
// so the ADIF keeps the QSO's own station call (no override).
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
@@ -441,7 +509,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
return false, true // transient (rate-limit / network) → worth a retry
|
||||
}
|
||||
|
||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q) %s", svc, id, res.LogID, res.Message)
|
||||
if m.deps.MarkUploaded != nil {
|
||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user