// Package geoip resolves a request to an ISO-3166-1 alpha-2 country code, // best-effort. Two sources, in priority order: // // 1. Cloudflare's CF-IPCountry header — instant, no network, present when // the site is proxied through Cloudflare. // 2. A GeoIP lookup on the client IP via the free ip-api.com endpoint — // the fallback for when we're not behind Cloudflare (the current // deployment is DNS-only). // // Everything is fire-and-forget: every failure path returns "" and the // caller simply leaves the country unknown. Nothing here blocks a request // on the critical path — callers run LookupCountryByIP from a goroutine. package geoip import ( "context" "encoding/json" "net" "net/http" "strings" "time" ) // CountryFromCloudflare reads the CF-IPCountry header. Cloudflare sets it // to a 2-letter code, or to a sentinel ("XX" unknown, "T1" Tor) we treat // as absent. Returns "" when the header isn't a usable country. func CountryFromCloudflare(r *http.Request) string { cc := strings.ToUpper(strings.TrimSpace(r.Header.Get("CF-IPCountry"))) if len(cc) != 2 || cc == "XX" || cc == "T1" { return "" } return cc } // ClientIP returns the best guess at the caller's IP. The middleware.RealIP // middleware already rewrites r.RemoteAddr to the real client (right-most // X-Forwarded-For entry from the trusted edge proxy), so we just strip any port. func ClientIP(r *http.Request) string { host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) if err != nil { // RemoteAddr may already be bare (no port) behind some proxies. return strings.TrimSpace(r.RemoteAddr) } return host } // LookupCountryByIP resolves an IP to a country code via ip-api.com. // Returns "" for empty / private / loopback IPs (no point asking) and on // any error, timeout, or non-success response. Respects ctx for // cancellation/timeout — callers should pass a short-timeout context. func LookupCountryByIP(ctx context.Context, ip string) string { ip = strings.TrimSpace(ip) if ip == "" { return "" } parsed := net.ParseIP(ip) if parsed == nil || parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() || parsed.IsUnspecified() { return "" } // Free tier is HTTP-only; countryCode keeps the payload tiny. url := "http://ip-api.com/json/" + ip + "?fields=status,countryCode" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "" } client := &http.Client{Timeout: 4 * time.Second} resp, err := client.Do(req) if err != nil { return "" } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "" } var body struct { Status string `json:"status"` CountryCode string `json:"countryCode"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return "" } if body.Status != "success" { return "" } cc := strings.ToUpper(strings.TrimSpace(body.CountryCode)) if len(cc) != 2 { return "" } return cc }