Replace GNBC WFS with GeoGratis towns endpoint and remove MaxMind
This commit is contained in:
parent
7f00667213
commit
a3e92212d1
|
|
@ -1,41 +1,41 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/geo"
|
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/geo"
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/output"
|
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/output"
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather"
|
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/weather"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "skyfeed",
|
Use: "skyfeed",
|
||||||
Short: "Skyfeed - Open Weather Engine for Telefact and Terminal",
|
Short: "Skyfeed - Open Weather Engine for Telefact and Terminal",
|
||||||
Long: `Skyfeed fetches and normalizes weather data from Environment Canada,
|
Long: `Skyfeed fetches and normalizes weather data from Environment Canada,
|
||||||
using a local IP database for accurate geolocation. It supports both CLI and API modes.`,
|
using automatic IP-based geolocation. It supports both CLI and API modes.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
cmd.Help()
|
cmd.Help()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Initialize configuration and ensure data directories exist
|
// Initialize configuration and ensure data directories exist
|
||||||
config.Init()
|
config.Init()
|
||||||
|
|
||||||
// Register subcommands
|
// Register subcommands
|
||||||
rootCmd.AddCommand(fetchCmd)
|
rootCmd.AddCommand(fetchCmd)
|
||||||
rootCmd.AddCommand(showCmd)
|
rootCmd.AddCommand(showCmd)
|
||||||
rootCmd.AddCommand(updateCmd)
|
rootCmd.AddCommand(updateTownCmd)
|
||||||
rootCmd.AddCommand(debugLogoCmd)
|
rootCmd.AddCommand(debugLogoCmd)
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Println("Error:", err)
|
fmt.Println("Error:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|
@ -43,101 +43,106 @@ func main() {
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|
||||||
var fetchCmd = &cobra.Command{
|
var fetchCmd = &cobra.Command{
|
||||||
Use: "fetch",
|
Use: "fetch",
|
||||||
Short: "Fetch the latest weather data for your current location",
|
Short: "Fetch the latest weather data for your current location",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
output.LogInfo("Skyfeed: Checking IP database...")
|
|
||||||
if err := geo.EnsureIPDBUpToDate(); err != nil {
|
|
||||||
output.LogError(fmt.Sprintf("Failed to update IP DB: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
output.LogInfo("Skyfeed: Detecting location...")
|
// Ensure town index is present and current
|
||||||
city, lat, lon, err := geo.GetUserLocation()
|
output.LogInfo("Skyfeed: Checking town index…")
|
||||||
if err != nil {
|
if err := geo.EnsureTownIndexUpToDate(); err != nil {
|
||||||
output.LogError(fmt.Sprintf("Could not determine location: %v", err))
|
output.LogError(fmt.Sprintf("Failed to update town index: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
output.LogInfo(fmt.Sprintf("Detected: %s (%.4f, %.4f)", city, lat, lon))
|
|
||||||
|
|
||||||
output.LogInfo("Finding nearest Environment Canada station...")
|
// Detect location via new IP method
|
||||||
station, err := geo.FindNearestStation(lat, lon)
|
output.LogInfo("Skyfeed: Detecting location…")
|
||||||
if err != nil {
|
city, lat, lon, err := geo.GetUserLocation()
|
||||||
output.LogError(fmt.Sprintf("Station lookup failed: %v", err))
|
if err != nil {
|
||||||
return
|
output.LogError(fmt.Sprintf("Could not determine location: %v", err))
|
||||||
}
|
return
|
||||||
output.LogInfo(fmt.Sprintf("Nearest station: %s [%s]", station.Name, station.Code))
|
}
|
||||||
|
output.LogInfo(fmt.Sprintf("Detected: %s (%.4f, %.4f)", city, lat, lon))
|
||||||
|
|
||||||
output.LogInfo("Fetching latest weather data...")
|
// Find nearest EC station
|
||||||
province := station.Province
|
output.LogInfo("Finding nearest Environment Canada station…")
|
||||||
|
station, err := geo.FindNearestStation(lat, lon)
|
||||||
|
if err != nil {
|
||||||
|
output.LogError(fmt.Sprintf("Station lookup failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output.LogInfo(fmt.Sprintf("Nearest station: %s [%s]", station.Name, station.Code))
|
||||||
|
|
||||||
data, err := weather.FetchCurrent(station.Code, province)
|
// Fetch weather
|
||||||
if err != nil {
|
output.LogInfo("Fetching latest weather data…")
|
||||||
output.LogError(fmt.Sprintf("Weather fetch failed: %v", err))
|
province := station.Province
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render dynamic header
|
data, err := weather.FetchCurrent(station.Code, province)
|
||||||
output.RenderLogo(data.Condition)
|
if err != nil {
|
||||||
fmt.Println()
|
output.LogError(fmt.Sprintf("Weather fetch failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Main output
|
// Render dynamic header/logo
|
||||||
fmt.Println(output.FormatWeatherCLI(data, true))
|
output.PrintLogo(data.Condition)
|
||||||
},
|
fmt.Println()
|
||||||
|
|
||||||
|
// Main output
|
||||||
|
fmt.Println(output.FormatWeatherCLI(data, true))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var showCmd = &cobra.Command{
|
var showCmd = &cobra.Command{
|
||||||
Use: "show",
|
Use: "show",
|
||||||
Short: "Show cached weather data from disk",
|
Short: "Show cached weather data from disk",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
data, err := weather.LoadCached()
|
data, err := weather.LoadCached()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
output.LogError(fmt.Sprintf("Failed to load cache: %v", err))
|
output.LogError(fmt.Sprintf("Failed to load cache: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
output.RenderLogo(data.Condition)
|
output.PrintLogo(data.Condition)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
fmt.Println(output.FormatWeatherCLI(data, true))
|
fmt.Println(output.FormatWeatherCLI(data, true))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var updateCmd = &cobra.Command{
|
var updateTownCmd = &cobra.Command{
|
||||||
Use: "update-ipdb",
|
Use: "update-towns",
|
||||||
Short: "Manually update the local IP geolocation database",
|
Short: "Manually update the Canadian towns geolocation index",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
output.LogInfo("Forcing IP database update...")
|
output.LogInfo("Forcing town index update…")
|
||||||
if err := geo.ForceUpdateIPDB(); err != nil {
|
if err := geo.ForceUpdateTownIndex(); err != nil {
|
||||||
output.LogError(fmt.Sprintf("Update failed: %v", err))
|
output.LogError(fmt.Sprintf("Update failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
output.LogSuccess("IP database updated successfully.")
|
output.LogSuccess("Town index updated successfully.")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// Debug-only utility command
|
// Debug-only ASCII logo renderer
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|
||||||
var debugLogoCmd = &cobra.Command{
|
var debugLogoCmd = &cobra.Command{
|
||||||
Use: "debug-logo [condition]",
|
Use: "debug-logo [condition]",
|
||||||
Hidden: true,
|
Hidden: true,
|
||||||
Short: "Render the dynamic ASCII Skyfeed logo for a given condition (debug use only)",
|
Short: "Render the dynamic ASCII Skyfeed logo for a given condition",
|
||||||
Long: `This command renders the Skyfeed ASCII logo using a simulated
|
Long: `This command renders the Skyfeed ASCII logo using a simulated
|
||||||
weather condition. It is intended strictly for development and testing.
|
weather condition. It is intended strictly for development and testing.
|
||||||
Example:
|
Example:
|
||||||
skyfeed debug-logo "Light Snow"`,
|
skyfeed debug-logo "Light Snow"`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fmt.Println("Usage: skyfeed debug-logo \"Weather Condition\"")
|
fmt.Println("Usage: skyfeed debug-logo \"Weather Condition\"")
|
||||||
fmt.Println("Example: skyfeed debug-logo \"Sunny\"")
|
fmt.Println("Example: skyfeed debug-logo \"Sunny\"")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
condition := args[0]
|
condition := args[0]
|
||||||
fmt.Printf("Rendering logo for condition: %s\n\n", condition)
|
fmt.Printf("Rendering logo for condition: %s\n\n", condition)
|
||||||
|
|
||||||
output.RenderLogo(condition)
|
output.PrintLogo(condition)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,148 @@
|
||||||
package geo
|
package geo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
|
||||||
"github.com/oschwald/geoip2-golang"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetUserLocation resolves the user's public IP into a city and coordinates.
|
// -------------------------------
|
||||||
// It will try multiple fallback IP providers if the first one fails.
|
// Public IP + Geolocation (ipwho.is)
|
||||||
|
// -------------------------------
|
||||||
|
|
||||||
|
// GetUserLocation resolves the user's IP into:
|
||||||
|
// city, latitude, longitude
|
||||||
func GetUserLocation() (string, float64, float64, error) {
|
func GetUserLocation() (string, float64, float64, error) {
|
||||||
fmt.Println("[geo] Detecting location...")
|
fmt.Println("[geo] Detecting location...")
|
||||||
|
|
||||||
ip, err := fetchPublicIP()
|
ip, err := fetchPublicIP()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err)
|
return "", 0, 0, fmt.Errorf("failed to resolve public IP: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbPath := filepath.Join(config.DataDir, "GeoLite2-City.mmdb")
|
city, province, lat, lon, err := lookupIP(ip.String())
|
||||||
db, err := geoip2.Open(dbPath)
|
if err != nil {
|
||||||
if err != nil {
|
return "", 0, 0, fmt.Errorf("IP geolocation failed: %w", err)
|
||||||
return "", 0, 0, fmt.Errorf("failed to open GeoLite2 database: %w", err)
|
}
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
record, err := db.City(ip)
|
// e.g., "Ottawa, Ontario"
|
||||||
if err != nil {
|
locationName := fmt.Sprintf("%s, %s", city, province)
|
||||||
return "", 0, 0, fmt.Errorf("geoip lookup failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
city := record.City.Names["en"]
|
fmt.Printf("[geo] Detected: %s (%.4f, %.4f)\n", locationName, lat, lon)
|
||||||
prov := ""
|
return locationName, lat, lon, nil
|
||||||
if len(record.Subdivisions) > 0 {
|
|
||||||
prov = record.Subdivisions[0].Names["en"]
|
|
||||||
}
|
|
||||||
|
|
||||||
lat := record.Location.Latitude
|
|
||||||
lon := record.Location.Longitude
|
|
||||||
|
|
||||||
if city == "" && prov == "" {
|
|
||||||
return "", 0, 0, fmt.Errorf("no location info found for IP %s", ip.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[geo] Detected: %s, %s (%.4f, %.4f)\n", city, prov, lat, lon)
|
|
||||||
return fmt.Sprintf("%s, %s", city, prov), lat, lon, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchPublicIP tries multiple reliable endpoints for the public IPv4 address.
|
// -------------------------------
|
||||||
|
// PUBLIC IP DETECTION
|
||||||
|
// -------------------------------
|
||||||
|
|
||||||
func fetchPublicIP() (net.IP, error) {
|
func fetchPublicIP() (net.IP, error) {
|
||||||
providers := []string{
|
providers := []string{
|
||||||
"https://ipv4.icanhazip.com",
|
"https://ipv4.icanhazip.com",
|
||||||
"https://api.ipify.org?format=json",
|
"https://api.ipify.org?format=json",
|
||||||
"https://ifconfig.co/json",
|
"https://ifconfig.co/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
|
||||||
for _, url := range providers {
|
for _, url := range providers {
|
||||||
ip, err := tryProvider(url, client)
|
ip, err := tryProvider(url, client)
|
||||||
if err == nil && ip != nil {
|
if err == nil && ip != nil {
|
||||||
return ip, nil
|
return ip, nil
|
||||||
}
|
}
|
||||||
fmt.Println("[geo] Fallback:", err)
|
fmt.Println("[geo] Fallback:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("all IP detection methods failed")
|
return nil, fmt.Errorf("all IP detection methods failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryProvider queries a single IP API endpoint and parses IPv4 results.
|
|
||||||
func tryProvider(url string, client *http.Client) (net.IP, error) {
|
func tryProvider(url string, client *http.Client) (net.IP, error) {
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("network error (%s): %w", url, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url)
|
return nil, fmt.Errorf("HTTP %d (%s)", resp.StatusCode, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some APIs return plain text, others JSON
|
// JSON-based API fallback
|
||||||
var result struct {
|
var j struct{ IP string `json:"ip"` }
|
||||||
IP string `json:"ip"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try decode JSON
|
if err := json.NewDecoder(resp.Body).Decode(&j); err == nil && j.IP != "" {
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.IP != "" {
|
ip := net.ParseIP(j.IP)
|
||||||
ip := net.ParseIP(result.IP)
|
if ip != nil && ip.To4() != nil {
|
||||||
if ip != nil && ip.To4() != nil {
|
return ip, nil
|
||||||
return ip, nil
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: plain text
|
// Plain text fallback → must re-fetch body
|
||||||
resp2, err := client.Get(url)
|
resp2, err := client.Get(url)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
defer resp2.Body.Close()
|
return nil, err
|
||||||
buf := make([]byte, 64)
|
}
|
||||||
n, _ := resp2.Body.Read(buf)
|
defer resp2.Body.Close()
|
||||||
ip := net.ParseIP(string(buf[:n]))
|
|
||||||
if ip != nil && ip.To4() != nil {
|
|
||||||
return ip, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("no valid IP found from %s", url)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
package geo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"compress/gzip"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ipdbFileName = "GeoLite2-City.mmdb"
|
|
||||||
keyFileName = "maxmind.key"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EnsureIPDBUpToDate checks the local MaxMind database and refreshes monthly.
|
|
||||||
func EnsureIPDBUpToDate() error {
|
|
||||||
dbPath := filepath.Join(config.DataDir, ipdbFileName)
|
|
||||||
info, err := os.Stat(dbPath)
|
|
||||||
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
fmt.Println("[geo] No IP database found, downloading...")
|
|
||||||
return updateIPDB(dbPath)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to check IP DB: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
modTime := info.ModTime().UTC()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
if modTime.Before(firstOfMonth) {
|
|
||||||
fmt.Println("[geo] IP database is older than this month, refreshing...")
|
|
||||||
return updateIPDB(dbPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("[geo] IP database is current.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForceUpdateIPDB forces an immediate refresh.
|
|
||||||
func ForceUpdateIPDB() error {
|
|
||||||
dbPath := filepath.Join(config.DataDir, ipdbFileName)
|
|
||||||
fmt.Println("[geo] Forcing IP database update...")
|
|
||||||
return updateIPDB(dbPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateIPDB downloads and extracts the official GeoLite2 City database using your MaxMind key.
|
|
||||||
func updateIPDB(dest string) error {
|
|
||||||
keyPath := filepath.Join(config.ConfigDir, keyFileName)
|
|
||||||
keyBytes, err := os.ReadFile(keyPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("[geo] Missing MaxMind license key.\nPlease run:\n echo \"YOUR_KEY\" > %s", keyPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
key := strings.TrimSpace(string(keyBytes))
|
|
||||||
url := fmt.Sprintf("https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz", key)
|
|
||||||
|
|
||||||
tmpTar := dest + ".tar.gz"
|
|
||||||
if err := downloadFile(url, tmpTar); err != nil {
|
|
||||||
return fmt.Errorf("failed to download GeoLite2 archive: %w", err)
|
|
||||||
}
|
|
||||||
defer os.Remove(tmpTar)
|
|
||||||
|
|
||||||
if err := extractMMDB(tmpTar, dest); err != nil {
|
|
||||||
return fmt.Errorf("failed to extract mmdb: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("[geo] IP database updated successfully →", dest)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadFile streams a file from URL to disk.
|
|
||||||
func downloadFile(url, dest string) error {
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := os.Create(dest)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(out, resp.Body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractMMDB extracts the .mmdb file from a tar.gz archive.
|
|
||||||
func extractMMDB(src, dest string) error {
|
|
||||||
f, err := os.Open(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
gz, err := gzip.NewReader(f)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer gz.Close()
|
|
||||||
|
|
||||||
tr := tar.NewReader(gz)
|
|
||||||
for {
|
|
||||||
h, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if filepath.Ext(h.Name) == ".mmdb" {
|
|
||||||
out, err := os.Create(dest)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
_, err = io.Copy(out, tr)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("no .mmdb found in archive")
|
|
||||||
}
|
|
||||||
10
internal/geo/towns.go
Normal file
10
internal/geo/towns.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
package geo
|
||||||
|
|
||||||
|
// Town represents a Canadian populated place.
|
||||||
|
// Shared by both the updater and lookup system.
|
||||||
|
type Town struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Province string `json:"province"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
}
|
||||||
|
|
@ -1,56 +1,50 @@
|
||||||
package geo
|
package geo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Town represents a Canadian town record (loaded from towns.json).
|
|
||||||
type Town struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Province string `json:"province"`
|
|
||||||
Lat float64 `json:"lat"`
|
|
||||||
Lon float64 `json:"lon"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindNearestTown loads the cached towns.json and finds the closest town to given coordinates.
|
// FindNearestTown loads the cached towns.json and finds the closest town to given coordinates.
|
||||||
func FindNearestTown(lat, lon float64) (Town, error) {
|
func FindNearestTown(lat, lon float64) (Town, error) {
|
||||||
townsPath := filepath.Join(config.DataDir, "towns.json")
|
townsPath := filepath.Join(config.DataDir, "towns.json")
|
||||||
|
|
||||||
data, err := os.ReadFile(townsPath)
|
data, err := os.ReadFile(townsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Town{}, fmt.Errorf("failed to read town index: %w", err)
|
return Town{}, fmt.Errorf("failed to read town index: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var towns []Town
|
var towns []Town
|
||||||
if err := json.Unmarshal(data, &towns); err != nil {
|
if err := json.Unmarshal(data, &towns); err != nil {
|
||||||
return Town{}, fmt.Errorf("failed to parse towns.json: %w", err)
|
return Town{}, fmt.Errorf("failed to parse towns.json: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(towns) == 0 {
|
if len(towns) == 0 {
|
||||||
return Town{}, fmt.Errorf("no towns found in index")
|
return Town{}, fmt.Errorf("no towns found in index")
|
||||||
}
|
}
|
||||||
|
|
||||||
minDist := math.MaxFloat64
|
minDist := math.MaxFloat64
|
||||||
var nearest Town
|
var nearest Town
|
||||||
|
|
||||||
for _, t := range towns {
|
for _, t := range towns {
|
||||||
d := Haversine(lat, lon, t.Lat, t.Lon) // ✅ use shared helper from stations.go
|
d := Haversine(lat, lon, t.Lat, t.Lon)
|
||||||
if d < minDist {
|
if d < minDist {
|
||||||
minDist = d
|
minDist = d
|
||||||
nearest = t
|
nearest = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if nearest.Name == "" {
|
if nearest.Name == "" {
|
||||||
return Town{}, fmt.Errorf("no nearby town found")
|
return Town{}, fmt.Errorf("no nearby town found")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n", nearest.Name, nearest.Province, minDist)
|
fmt.Printf("[geo] Nearest town: %s, %s (%.2f km)\n",
|
||||||
return nearest, nil
|
nearest.Name, nearest.Province, minDist)
|
||||||
|
|
||||||
|
return nearest, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,123 @@
|
||||||
package geo
|
package geo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
"git.leaktechnologies.dev/Leak_Technologies/Skyfeed/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Official Geographical Names Board of Canada WFS API
|
// WORKING GeoGratis endpoint for all populated places (PPL)
|
||||||
// Docs: https://www.nrcan.gc.ca/earth-sciences/geography/geographical-names-board-canada/download-geographical-names-data/10786
|
gnbcAPIURL = "https://geogratis.gc.ca/services/geoname/en/geonames.json?feature_code=PPL"
|
||||||
gnbcAPIURL = "https://geogratis.gc.ca/geonames/servlet/com.gc.ccra.geonames.webservices.GeographicalNamesService?service=WFS&request=GetFeature&version=2.0.0&typeNames=geonames:geoname_eng&outputFormat=json&featureCode=PPL"
|
townsFile = "towns.json"
|
||||||
townsFile = "towns.json"
|
maxFetchTime = 5 * time.Minute
|
||||||
maxFetchTime = 5 * time.Minute
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TownRecord represents a single Canadian town.
|
|
||||||
type TownRecord struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Province string `json:"province"`
|
|
||||||
Lat float64 `json:"lat"`
|
|
||||||
Lon float64 `json:"lon"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureTownIndexUpToDate checks if the towns index needs updating (monthly).
|
// EnsureTownIndexUpToDate checks if the towns index needs updating (monthly).
|
||||||
func EnsureTownIndexUpToDate() error {
|
func EnsureTownIndexUpToDate() error {
|
||||||
dest := filepath.Join(config.DataDir, townsFile)
|
dest := filepath.Join(config.DataDir, townsFile)
|
||||||
|
|
||||||
info, err := os.Stat(dest)
|
info, err := os.Stat(dest)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
fmt.Println("[geo] No town index found, downloading...")
|
fmt.Println("[geo] No town index found, downloading...")
|
||||||
return downloadTownIndex(dest)
|
return downloadTownIndex(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to check town index: %w", err)
|
return fmt.Errorf("unable to check town index: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
modTime := info.ModTime().UTC()
|
modTime := info.ModTime().UTC()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
if modTime.Before(firstOfMonth) {
|
if modTime.Before(firstOfMonth) {
|
||||||
fmt.Println("[geo] Town index is older than this month, refreshing...")
|
fmt.Println("[geo] Town index is older than this month, refreshing...")
|
||||||
return downloadTownIndex(dest)
|
return downloadTownIndex(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[geo] Town index is current.")
|
fmt.Println("[geo] Town index is current.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForceUpdateTownIndex forces an immediate rebuild.
|
// ForceUpdateTownIndex forces an immediate rebuild.
|
||||||
func ForceUpdateTownIndex() error {
|
func ForceUpdateTownIndex() error {
|
||||||
dest := filepath.Join(config.DataDir, townsFile)
|
dest := filepath.Join(config.DataDir, townsFile)
|
||||||
fmt.Println("[geo] Forcing town index update...")
|
fmt.Println("[geo] Forcing town index update...")
|
||||||
return downloadTownIndex(dest)
|
return downloadTownIndex(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadTownIndex fetches and stores the Canadian town dataset.
|
|
||||||
func downloadTownIndex(dest string) error {
|
func downloadTownIndex(dest string) error {
|
||||||
client := &http.Client{Timeout: maxFetchTime}
|
client := &http.Client{Timeout: maxFetchTime}
|
||||||
|
|
||||||
fmt.Println("[geo] Fetching town data from GNBC WFS API...")
|
fmt.Println("[geo] Fetching town data from GeoGratis...")
|
||||||
resp, err := client.Get(gnbcAPIURL)
|
resp, err := client.Get(gnbcAPIURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fetch town dataset: %w", err)
|
return fmt.Errorf("failed to fetch town dataset: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
raw, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read GNBC response: %w", err)
|
return fmt.Errorf("failed to read GeoGratis response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
towns, err := parseGNBCJSON(raw)
|
towns, err := parseGNBCJSON(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse GNBC JSON: %w", err)
|
return fmt.Errorf("failed to parse GeoGratis JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.MarshalIndent(towns, "", " ")
|
data, err := json.MarshalIndent(towns, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encode towns: %w", err)
|
return fmt.Errorf("failed to encode towns.json: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(dest, data, 0644); err != nil {
|
if err := os.WriteFile(dest, data, 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write %s: %w", dest, err)
|
return fmt.Errorf("failed to write %s: %w", dest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns))
|
fmt.Printf("[geo] Town index updated → %s (%d towns)\n", dest, len(towns))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseGNBCJSON extracts relevant town info from the GNBC GeoJSON.
|
// Uses GeoGratis geonames.json structure
|
||||||
func parseGNBCJSON(data []byte) ([]TownRecord, error) {
|
func parseGNBCJSON(data []byte) ([]Town, error) {
|
||||||
var response struct {
|
var response struct {
|
||||||
Features []struct {
|
Items []struct {
|
||||||
Properties struct {
|
Name string `json:"name"`
|
||||||
Name string `json:"name"`
|
ProvinceCode string `json:"province_code"`
|
||||||
Province string `json:"province"`
|
Latitude float64 `json:"latitude"`
|
||||||
Latitude float64 `json:"latitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
Longitude float64 `json:"longitude"`
|
} `json:"items"`
|
||||||
} `json:"properties"`
|
}
|
||||||
} `json:"features"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &response); err != nil {
|
if err := json.Unmarshal(data, &response); err != nil {
|
||||||
return nil, fmt.Errorf("invalid GNBC JSON: %w", err)
|
return nil, fmt.Errorf("invalid GeoGratis JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var towns []TownRecord
|
var towns []Town
|
||||||
for _, f := range response.Features {
|
for _, item := range response.Items {
|
||||||
p := f.Properties
|
if item.Name == "" || item.ProvinceCode == "" {
|
||||||
if p.Name == "" || p.Province == "" {
|
continue
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
towns = append(towns, TownRecord{
|
|
||||||
Name: p.Name,
|
|
||||||
Province: p.Province,
|
|
||||||
Lat: p.Latitude,
|
|
||||||
Lon: p.Longitude,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return towns, nil
|
towns = append(towns, Town{
|
||||||
|
Name: item.Name,
|
||||||
|
Province: item.ProvinceCode,
|
||||||
|
Lat: item.Latitude,
|
||||||
|
Lon: item.Longitude,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return towns, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user