package extsvc import ( "context" "fmt" "io" "net/http" "net/url" "strings" "time" ) // hrdlogUploadURL is HRDLog.net's real-time upload endpoint. It accepts a // form-encoded POST with the uploader's callsign, the account's secret upload // Code (HRDLog account → "My Account" → upload code), an App identifier, and // one ADIF record. const hrdlogUploadURL = "https://robot.hrdlog.net/NewEntry.aspx" // hrdlogApp is the App identifier sent to HRDLog so uploads are attributed to // OpsLog in the user's HRDLog activity. const hrdlogApp = "OpsLog" // hrdlogPost performs the form POST to NewEntry.aspx and returns the raw // response body. The endpoint replies HTTP 200 with a small XML document even // for errors; callers classify it via the markers in classifyHRDLog. func hrdlogPost(ctx context.Context, client *http.Client, callsign, code, adif string) (string, error) { form := url.Values{} form.Set("Callsign", callsign) form.Set("Code", code) form.Set("App", hrdlogApp) form.Set("ADIFData", adif) req, err := http.NewRequestWithContext(ctx, http.MethodPost, hrdlogUploadURL, strings.NewReader(form.Encode())) if err != nil { return "", fmt.Errorf("hrdlog: build request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if client == nil { client = &http.Client{Timeout: 20 * time.Second} } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("hrdlog: 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 { if msg == "" { msg = fmt.Sprintf("HTTP %d", resp.StatusCode) } return msg, fmt.Errorf("hrdlog: http %d: %s", resp.StatusCode, msg) } return msg, nil } // authErrHRDLog returns a non-empty, human-readable reason when the response // signals a credential problem (wrong upload code or unregistered callsign), // or "" otherwise. Markers mirror HRDLog's documented XML replies // ("Invalid token" / "Unknown user"). func authErrHRDLog(body string) string { b := strings.ToLower(body) switch { case strings.Contains(b, "invalid token"): return "invalid upload code" case strings.Contains(b, "unknown user"): return "callsign not registered at HRDLog" default: return "" } } // UploadHRDLog pushes one ADIF record to HRDLog.net. callsign is the station // callsign the log belongs to, code is the account's upload code. // // Form fields (application/x-www-form-urlencoded POST): // // Callsign=&Code=&App=OpsLog&ADIFData= // // HRDLog replies with XML: "1" on success, "0" for a duplicate // (already logged — treated as success so retries are idempotent), or an // "" payload otherwise. func UploadHRDLog(ctx context.Context, client *http.Client, callsign, code, adifRecord string) (UploadResult, error) { callsign = strings.ToUpper(strings.TrimSpace(callsign)) code = strings.TrimSpace(code) if callsign == "" { return UploadResult{}, fmt.Errorf("hrdlog: station callsign not set") } if code == "" { return UploadResult{}, fmt.Errorf("hrdlog: upload code not set") } if strings.TrimSpace(adifRecord) == "" { return UploadResult{}, fmt.Errorf("hrdlog: empty adif record") } body, err := hrdlogPost(ctx, client, callsign, code, adifRecord) if err != nil { return UploadResult{OK: false, Message: body}, err } b := strings.ToLower(body) switch { case strings.Contains(b, "1"): return UploadResult{OK: true, Message: "uploaded"}, nil case strings.Contains(b, "0"): return UploadResult{OK: true, Message: "already in logbook"}, nil } reason := authErrHRDLog(body) if reason == "" { reason = body if reason == "" { reason = "upload rejected" } } return UploadResult{OK: false, Message: reason}, fmt.Errorf("hrdlog: upload failed: %s", reason) } // NOTE: HRDLog's NewEntry.aspx inserts ONLY the first record of a multi-record // ADIFData, so there is no batch upload — callers must POST one record per // request (see UploadHRDLog). The bulk uploader in app.go does exactly that. // TestHRDLog validates the configured HRDLog credentials with a REAL request: // it posts an empty ADIF so nothing is inserted, then checks for HRDLog's auth // errors. A wrong upload code comes back as "Invalid token", a wrong callsign // as "Unknown user"; anything else means the credentials were accepted (HRDLog // simply had no QSO to add). func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) { callsign := strings.ToUpper(strings.TrimSpace(cfg.Callsign)) code := strings.TrimSpace(cfg.Code) if callsign == "" { return "", fmt.Errorf("hrdlog: station callsign not set") } if code == "" { return "", fmt.Errorf("hrdlog: upload code not set") } body, err := hrdlogPost(ctx, client, callsign, code, "") if err != nil { return "", err } if reason := authErrHRDLog(body); reason != "" { return "", fmt.Errorf("hrdlog: %s", reason) } return fmt.Sprintf("Credentials accepted — %s", callsign), nil }