134 lines
3.3 KiB
Go
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 ""
|
|
}
|