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
|
||||
}
|
||||
Reference in New Issue
Block a user