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 /index.php/api/qso // {"key":"…","station_profile_id":"1","type":"adif","string":""} // // 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 }