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)" // looksLikeHTML reports a body that is a web page rather than an answer. Club // Log serves its normal site for refusals and blocks, so this is what separates // "here is what went wrong" from 4 KB of markup an operator cannot act on. func looksLikeHTML(s string) bool { l := strings.ToLower(strings.TrimSpace(s)) return strings.HasPrefix(l, " 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. // TestClublog checks the credentials against Club Log, not against themselves. // // It used to verify that the three fields were non-empty and then report // "Ready — via ". Nothing was sent anywhere, so a wrong password // produced exactly the same green message as a right one. That is worse than // having no button: it is confidence the test never earned, and it cost an // operator the one moment they were actually looking for the problem. // // The download endpoint is used because it is READ-ONLY: testing a password // must not put a record into someone's log. Club Log answers a rejected login // with 403 before sending any body, so the answer arrives immediately; on // success the body is a log, and we read a few bytes and hang up rather than // pull it down to prove a point. func TestClublog(ctx context.Context, cfg ServiceConfig) (string, error) { email := strings.TrimSpace(cfg.Email) call := strings.ToUpper(strings.TrimSpace(cfg.Callsign)) switch { case email == "": return "", fmt.Errorf("clublog: account email not set") case cfg.Password == "": return "", fmt.Errorf("clublog: password not set") case call == "": return "", fmt.Errorf("clublog: logbook callsign not set") } if ctx == nil { ctx = context.Background() } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() form := url.Values{} form.Set("email", email) form.Set("password", cfg.Password) form.Set("call", call) // No date filter. A "startyear=2099" was tried to keep the reply small, but // Club Log answers an unrecognised request with the SAME 403 it uses for a // refused login — so an unverified parameter would have made every correct // password look wrong, which is the failure this whole change exists to end. // The reply is capped at 4 KB and the body closed immediately instead; the // status code arrives before any of it. req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogDownloadURL, strings.NewReader(form.Encode())) if err != nil { return "", fmt.Errorf("clublog: build request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", clublogUserAgent) resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return "", fmt.Errorf("clublog: could not reach Club Log: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) msg := strings.TrimSpace(string(body)) switch resp.StatusCode { case http.StatusOK: return fmt.Sprintf("Ready — %s via %s (Club Log accepted the login)", call, email), nil case http.StatusUnauthorized, http.StatusForbidden: // Club Log refuses with its ordinary web page, not an error string, so the // body is 4 KB of markup that says nothing to an operator. It goes to the // log — that is where a real diagnosis happens — and the message says the // one thing there is to do about it. LogSink("clublog: login refused (http %d), body: %s", resp.StatusCode, msg) return "", fmt.Errorf("Club Log refused the login — check the account e-mail, " + "the password and the logbook callsign. They are the Club Log website's own credentials") default: LogSink("clublog: unexpected http %d, body: %s", resp.StatusCode, msg) if looksLikeHTML(msg) { return "", fmt.Errorf("clublog: Club Log answered with a web page (http %d) instead of a log — see the log file", resp.StatusCode) } if len(msg) > 200 { msg = msg[:200] + "…" } return "", fmt.Errorf("clublog: http %d %s", resp.StatusCode, msg) } } // 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") }