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") }