mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
98 lines
2.1 KiB
Go
98 lines
2.1 KiB
Go
|
|
// Package geo provides IP geolocation lookup using the free ip-api.com service.
|
||
|
|
// No API key is required. Rate limit: 45 requests/minute on the free tier.
|
||
|
|
// Results are cached in memory to reduce API calls.
|
||
|
|
package geo
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"log"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
type GeoResult struct {
|
||
|
|
CountryCode string
|
||
|
|
Country string
|
||
|
|
Cached bool
|
||
|
|
}
|
||
|
|
|
||
|
|
type cacheEntry struct {
|
||
|
|
result GeoResult
|
||
|
|
fetchedAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
var (
|
||
|
|
mu sync.Mutex
|
||
|
|
cache = make(map[string]*cacheEntry)
|
||
|
|
)
|
||
|
|
|
||
|
|
const cacheTTL = 24 * time.Hour
|
||
|
|
|
||
|
|
// Lookup returns the country for an IP address.
|
||
|
|
// Returns empty strings on failure (private IPs, rate limit, etc.).
|
||
|
|
func Lookup(ip string) GeoResult {
|
||
|
|
// Skip private / loopback
|
||
|
|
parsed := net.ParseIP(ip)
|
||
|
|
if parsed == nil || isPrivate(parsed) {
|
||
|
|
return GeoResult{}
|
||
|
|
}
|
||
|
|
|
||
|
|
mu.Lock()
|
||
|
|
if e, ok := cache[ip]; ok && time.Since(e.fetchedAt) < cacheTTL {
|
||
|
|
mu.Unlock()
|
||
|
|
r := e.result
|
||
|
|
r.Cached = true
|
||
|
|
return r
|
||
|
|
}
|
||
|
|
mu.Unlock()
|
||
|
|
|
||
|
|
result := fetchFromAPI(ip)
|
||
|
|
|
||
|
|
mu.Lock()
|
||
|
|
cache[ip] = &cacheEntry{result: result, fetchedAt: time.Now()}
|
||
|
|
mu.Unlock()
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func fetchFromAPI(ip string) GeoResult {
|
||
|
|
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode", ip)
|
||
|
|
client := &http.Client{Timeout: 3 * time.Second}
|
||
|
|
resp, err := client.Get(url)
|
||
|
|
if err != nil {
|
||
|
|
log.Printf("geo lookup failed for %s: %v", ip, err)
|
||
|
|
return GeoResult{}
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
var data struct {
|
||
|
|
Status string `json:"status"`
|
||
|
|
Country string `json:"country"`
|
||
|
|
CountryCode string `json:"countryCode"`
|
||
|
|
}
|
||
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.Status != "success" {
|
||
|
|
return GeoResult{}
|
||
|
|
}
|
||
|
|
return GeoResult{
|
||
|
|
CountryCode: strings.ToUpper(data.CountryCode),
|
||
|
|
Country: data.Country,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func isPrivate(ip net.IP) bool {
|
||
|
|
privateRanges := []string{
|
||
|
|
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||
|
|
"127.0.0.0/8", "::1/128", "fc00::/7",
|
||
|
|
}
|
||
|
|
for _, cidr := range privateRanges {
|
||
|
|
_, network, _ := net.ParseCIDR(cidr)
|
||
|
|
if network != nil && network.Contains(ip) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|