149 lines
3.8 KiB
Go
149 lines
3.8 KiB
Go
package geo
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// -------------------------------
|
|
// Public IP + Geolocation (ipwho.is)
|
|
// -------------------------------
|
|
|
|
// GetUserLocation resolves the user's IP into:
|
|
// city, latitude, longitude
|
|
func GetUserLocation() (string, float64, float64, error) {
|
|
fmt.Println("[geo] Detecting location...")
|
|
|
|
ip, err := fetchPublicIP()
|
|
if err != nil {
|
|
return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err)
|
|
}
|
|
|
|
city, province, lat, lon, err := lookupIP(ip.String())
|
|
if err != nil {
|
|
return "", 0, 0, fmt.Errorf("IP geolocation failed: %w", err)
|
|
}
|
|
|
|
// e.g., "Ottawa, Ontario"
|
|
locationName := fmt.Sprintf("%s, %s", city, province)
|
|
|
|
fmt.Printf("[geo] Detected: %s (%.4f, %.4f)\n", locationName, lat, lon)
|
|
return locationName, lat, lon, nil
|
|
}
|
|
|
|
// -------------------------------
|
|
// PUBLIC IP DETECTION
|
|
// -------------------------------
|
|
|
|
func fetchPublicIP() (net.IP, error) {
|
|
providers := []string{
|
|
"https://ipv4.icanhazip.com",
|
|
"https://api.ipify.org?format=json",
|
|
"https://ifconfig.co/json",
|
|
}
|
|
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
|
|
for _, url := range providers {
|
|
ip, err := tryProvider(url, client)
|
|
if err == nil && ip != nil {
|
|
return ip, nil
|
|
}
|
|
fmt.Println("[geo] Fallback:", err)
|
|
}
|
|
|
|
return nil, fmt.Errorf("all IP detection methods failed")
|
|
}
|
|
|
|
func tryProvider(url string, client *http.Client) (net.IP, error) {
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url)
|
|
}
|
|
|
|
// JSON-based API fallback
|
|
var j struct{ IP string `json:"ip"` }
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&j); err == nil && j.IP != "" {
|
|
ip := net.ParseIP(j.IP)
|
|
if ip != nil && ip.To4() != nil {
|
|
return ip, nil
|
|
}
|
|
}
|
|
|
|
// Plain text fallback → must re-fetch body
|
|
resp2, err := client.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp2.Body.Close()
|
|
|
|
buf := make([]byte, 64)
|
|
n, _ := resp2.Body.Read(buf)
|
|
ip := net.ParseIP(strings.TrimSpace(string(buf[:n])))
|
|
|
|
if ip != nil && ip.To4() != nil {
|
|
return ip, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid response from %s", url)
|
|
}
|
|
|
|
// -------------------------------
|
|
// GEO LOOKUP USING ipwho.is
|
|
// -------------------------------
|
|
|
|
func lookupIP(ip string) (city string, province string, lat float64, lon float64, err error) {
|
|
url := fmt.Sprintf("https://ipwho.is/%s", ip)
|
|
|
|
client := &http.Client{Timeout: 6 * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return "", "", 0, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", "", 0, 0, fmt.Errorf("ipwho.is returned HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var data struct {
|
|
Success bool `json:"success"`
|
|
City string `json:"city"`
|
|
Region string `json:"region"`
|
|
Lat float64 `json:"latitude"`
|
|
Lon float64 `json:"longitude"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
return "", "", 0, 0, fmt.Errorf("decode error: %w", err)
|
|
}
|
|
|
|
if !data.Success {
|
|
return "", "", 0, 0, fmt.Errorf("ipwho.is error: %s", data.Message)
|
|
}
|
|
|
|
// Clean fields
|
|
city = strings.TrimSpace(data.City)
|
|
province = strings.TrimSpace(data.Region)
|
|
|
|
if city == "" {
|
|
city = "Unknown"
|
|
}
|
|
if province == "" {
|
|
province = "Unknown"
|
|
}
|
|
|
|
return city, province, data.Lat, data.Lon, nil
|
|
}
|