Files
2026-06-05 17:26:44 +08:00

134 lines
3.3 KiB
Go

package geo
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
recordstore "speedtest/internal/store"
)
const defaultBaseURL = "https://ipapi.co"
type Resolver struct {
baseURL string
client http.Client
}
type ipapiResponse struct {
IP string `json:"ip"`
Query string `json:"query"`
CountryName string `json:"country_name"`
Country string `json:"country"`
Region string `json:"region"`
RegionName string `json:"region_name"`
City string `json:"city"`
ASN string `json:"asn"`
AS string `json:"as"`
Org string `json:"org"`
ISP string `json:"isp"`
Organization string `json:"organization"`
Error bool `json:"error"`
Reason string `json:"reason"`
}
func New(baseURL string, timeout time.Duration) *Resolver {
if strings.TrimSpace(baseURL) == "" {
baseURL = defaultBaseURL
}
if timeout <= 0 {
timeout = 2 * time.Second
}
return &Resolver{
baseURL: strings.TrimRight(baseURL, "/"),
client: http.Client{Timeout: timeout},
}
}
func Chain(resolvers ...*Resolver) func(context.Context, string) (recordstore.ClientInfo, error) {
return func(ctx context.Context, ip string) (recordstore.ClientInfo, error) {
var firstErr error
var best recordstore.ClientInfo
for _, resolver := range resolvers {
if resolver == nil {
continue
}
info, err := resolver.Resolve(ctx, ip)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if strings.TrimSpace(info.ISP) != "" {
return info, nil
}
if best.IP == "" {
best = info
}
}
if best.IP != "" {
return best, nil
}
if firstErr != nil {
return recordstore.ClientInfo{}, firstErr
}
return recordstore.ClientInfo{}, fmt.Errorf("no IP resolvers configured")
}
}
func (r *Resolver) Resolve(ctx context.Context, ip string) (recordstore.ClientInfo, error) {
requestURL := r.baseURL + "/" + url.PathEscape(ip) + "/json/"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return recordstore.ClientInfo{}, err
}
httpResponse, err := r.client.Do(request)
if err != nil {
return recordstore.ClientInfo{}, err
}
defer httpResponse.Body.Close()
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
return recordstore.ClientInfo{}, fmt.Errorf("ip lookup returned %s", httpResponse.Status)
}
var payload ipapiResponse
if err := json.NewDecoder(httpResponse.Body).Decode(&payload); err != nil {
return recordstore.ClientInfo{}, err
}
if payload.Error {
if payload.Reason == "" {
payload.Reason = "provider returned an error"
}
return recordstore.ClientInfo{}, fmt.Errorf("ip lookup failed: %s", payload.Reason)
}
info := recordstore.ClientInfo{
IP: firstNonEmpty(payload.IP, payload.Query),
ISP: firstNonEmpty(payload.ISP, payload.Org, payload.Organization),
Region: firstNonEmpty(payload.Region, payload.RegionName),
Country: firstNonEmpty(payload.CountryName, payload.Country),
City: payload.City,
ASN: firstNonEmpty(payload.ASN, payload.AS),
Source: "ipapi.co",
}
if info.IP == "" {
info.IP = ip
}
return info, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}