Files
OpsLog/internal/extsvc/clublog.go
T

259 lines
9.5 KiB
Go

package extsvc
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint, used
// when a QSO is logged. Bulk/manual uploads go to clublogBatchURL instead.
const clublogRealtimeURL = "https://clublog.org/realtime.php"
// clublogBatchURL is Club Log's batch ADIF endpoint: it accepts a whole ADIF
// file in one multipart request and dedupes server-side, so a manual upload of
// N QSOs is one HTTP request instead of N realtime.php calls.
const clublogBatchURL = "https://clublog.org/putlogs.php"
// clublogUserAgent identifies OpsLog to Club Log. Go's default
// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx
// returns 403 Forbidden before the request reaches the app), so every request
// must send a real, app-identifying User-Agent.
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
// 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
}
// UploadClublogADIF pushes a whole ADIF document (header + many records) to
// Club Log's batch endpoint (putlogs.php) in a single multipart request. Use
// this for manual/bulk uploads instead of calling UploadClublog per QSO. Club
// Log dedupes server-side, so re-uploading QSOs it already holds is harmless.
//
// Multipart form fields: email, password, callsign, api, clientident, and the
// ADIF as a "file" upload. Returns HTTP 200 on success with a summary body.
func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConfig, adifDoc 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(adifDoc) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif document")
}
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("email", email)
_ = mw.WriteField("password", cfg.Password)
_ = mw.WriteField("callsign", call)
_ = mw.WriteField("api", api)
_ = mw.WriteField("clientident", "OpsLog")
fw, err := mw.CreateFormFile("file", "opslog.adi")
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build form: %w", err)
}
if _, err := io.WriteString(fw, adifDoc); err != nil {
return UploadResult{}, fmt.Errorf("clublog: write adif: %w", err)
}
if err := mw.Close(); err != nil {
return UploadResult{}, fmt.Errorf("clublog: finalise form: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogBatchURL, &buf)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil {
client = &http.Client{Timeout: 120 * 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))
if resp.StatusCode == http.StatusOK {
return UploadResult{OK: true, Message: msg}, nil
}
diag := clublogFailureDiag(resp, msg)
return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag)
}
// clublogFailureDiag turns a non-200 response into a readable one-liner that
// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare,
// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx"
// page means the request never reached the app; the fingerprint headers below
// tell the operator whether it's Club Log's own WAF or something on their side.
func clublogFailureDiag(resp *http.Response, body string) string {
server := strings.TrimSpace(resp.Header.Get("Server"))
// Notable intermediary fingerprints — presence points at the culprit.
fp := []string{}
for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} {
if v := strings.TrimSpace(resp.Header.Get(h)); v != "" {
fp = append(fp, h+"="+v)
}
}
// Collapse the HTML error page to something short.
summary := stripHTMLBrief(body)
if summary == "" {
summary = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary)
if server != "" {
out += " [server=" + server + "]"
}
if len(fp) > 0 {
out += " [" + strings.Join(fp, " ") + "]"
}
return out
}
// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160
// chars of visible text — enough to read "403 Forbidden" without the markup.
func stripHTMLBrief(s string) string {
var b strings.Builder
depth := 0
for _, r := range s {
switch r {
case '<':
depth++
case '>':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
out := strings.Join(strings.Fields(b.String()), " ")
if len(out) > 160 {
out = out[:160] + "…"
}
return out
}
// 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")
req.Header.Set("User-Agent", clublogUserAgent)
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")
}