chore: release v021.4

This commit is contained in:
2026-07-27 00:16:17 +02:00
parent aefb984974
commit cb27aa5ebf
13 changed files with 457 additions and 35 deletions
+195
View File
@@ -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
}
+13 -5
View File
@@ -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.
+69 -1
View File
@@ -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)
}