package extsvc import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // HAMLOG.online — a cloud logbook whose confirmations feed its own award // programme, so operators want their contacts there as they make them. // // # Where this protocol comes from // // HAMLOG publishes no API documentation. What follows is read from THEIR OWN // client, the HAMLOG Agent (github.com/hamlogonline/Agent, Hamlog/hamlog_api.py) // — the authoritative source short of asking them, and the same code their own // users run: // // POST https://hamlog.online/api/agent/ (JSON in, JSON out) // // {"KEYSTATUS": {"APIKEY": k}} → {"STATUS":"OK","CALLSIGN":…,"EXPIRES":…} // {"ADIFADD": {"APIKEY": k, "ADIFDATA": rec}} → {"STATUS":"OK"} // {"QSOADD": {"APIKEY": k, "DATA": {…}}} → field map, keys upper-cased // {"LOGOUT": {"APIKEY": k}} // // A failure answers {"ERROR": "…"} with no STATUS, so success is "STATUS is // exactly OK" rather than "no error field" — an unknown reply shape must not // read as an accepted QSO. // // ADIFADD is the verb used here: OpsLog already builds a full ADIF record for // every other service, and sending the same bytes keeps one representation of // a contact instead of two. // // The operator gets their key from https://hamlog.online/account/agent.php. const ( hamlogAPIEndpoint = "https://hamlog.online/api/agent/" hamlogKeyPage = "https://hamlog.online/account/agent.php" ) // hamlogReply is the shape both success and failure share. type hamlogReply struct { Status string `json:"STATUS"` Error string `json:"ERROR"` Callsign string `json:"CALLSIGN"` Expires any `json:"EXPIRES"` // seconds since the epoch; string or number depending on the verb } // hamlogPost sends one verb and decodes the reply. func hamlogPost(ctx context.Context, client *http.Client, endpoint string, body map[string]any) (hamlogReply, error) { buf, err := json.Marshal(body) if err != nil { return hamlogReply{}, fmt.Errorf("hamlog: encode request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf)) if err != nil { return hamlogReply{}, fmt.Errorf("hamlog: build request: %w", err) } 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 hamlogReply{}, fmt.Errorf("hamlog: request failed: %w", err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) var r hamlogReply if jerr := json.Unmarshal(raw, &r); jerr != nil { // Not JSON at all — a proxy error page, a maintenance notice. Report what // arrived rather than "invalid character '<'", which tells an operator // nothing about their own setup. msg := strings.TrimSpace(string(raw)) if len(msg) > 200 { msg = msg[:200] } if msg == "" { msg = fmt.Sprintf("HTTP %d", resp.StatusCode) } return hamlogReply{}, fmt.Errorf("hamlog: unexpected reply: %s", msg) } return r, nil } // hamlogFailure turns a reply into a human-readable reason, or "" on success. func hamlogFailure(r hamlogReply) string { if strings.EqualFold(strings.TrimSpace(r.Status), "OK") { return "" } if e := strings.TrimSpace(r.Error); e != "" { return e } return "rejected" } // UploadHamlog pushes one ADIF record to HAMLOG.online. func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) { return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord) } func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) { key := strings.TrimSpace(cfg.APIKey) if key == "" { return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage) } rec := strings.TrimSpace(adifRecord) if rec == "" { return UploadResult{}, fmt.Errorf("hamlog: empty ADIF record") } r, err := hamlogPost(ctx, client, endpoint, map[string]any{ "ADIFADD": map[string]any{"APIKEY": key, "ADIFDATA": rec}, }) if err != nil { return UploadResult{}, err } if reason := hamlogFailure(r); reason != "" { return UploadResult{OK: false, Message: reason}, nil } return UploadResult{OK: true}, nil } // CheckHamlogKey validates an API key and reports the callsign it belongs to. // // Worth its own call because HAMLOG offers what no other service here does: the // key can be checked BEFORE the first QSO, and the answer names the account. An // operator who pasted the key of another callsign — or one that has expired — // finds out in the settings panel rather than through a week of silent refusals. func CheckHamlogKey(ctx context.Context, client *http.Client, key string) (callsign string, err error) { key = strings.TrimSpace(key) if key == "" { return "", fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage) } r, perr := hamlogPost(ctx, client, hamlogAPIEndpoint, map[string]any{ "KEYSTATUS": map[string]any{"APIKEY": key}, }) if perr != nil { return "", perr } if reason := hamlogFailure(r); reason != "" { return "", fmt.Errorf("hamlog: %s", reason) } return strings.ToUpper(strings.TrimSpace(r.Callsign)), nil }