93 lines
2.9 KiB
Go
93 lines
2.9 KiB
Go
// 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 chi RealIP
|
|
// middleware already rewrites r.RemoteAddr from X-Forwarded-For /
|
|
// X-Real-IP, so we just strip the 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
|
|
}
|