Club Log refuses with its ordinary web page rather than an error string, so the new test reported a rejected login by pasting a 403 page — title, stylesheets, navigation and all — into the status bar. The body now goes to the log, where a real diagnosis happens, and the operator gets the one sentence there is to act on: check the e-mail, the password and the logbook callsign. Dropped the startyear=2099 filter with it, and that one matters more than it looks. It was there to keep the reply small, but it was never verified against Club Log's API — and Club Log answers an unrecognised request with the SAME 403 it uses for a refused login. An unverified parameter would therefore have made every CORRECT password look wrong, which is precisely the failure this change set out to end. The reply is capped at 4 KB and closed at once instead; the status code arrives ahead of the body either way.
339 lines
13 KiB
Go
339 lines
13 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)"
|
|
|
|
// 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, "<!doctype html") || strings.HasPrefix(l, "<html")
|
|
}
|
|
|
|
// clublogDownloadURL is Club Log's ADIF export. Used ONLY to verify credentials
|
|
// (see TestClublog): it is the one authenticated endpoint that cannot change
|
|
// anything in the operator's log, which is what a test button must never do.
|
|
const clublogDownloadURL = "https://clublog.org/getadif.php"
|
|
|
|
// 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.
|
|
// 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 — <call> via <email>". 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")
|
|
}
|