Files
rogee de9ac92ede feat: GeoIP-based geo detection + configurable rename template
- Add MaxMind GeoLite2-City mmdb auto-download and IP-based geo lookup
  (covers all 249 countries globally, replacing limited 8-country regex)
- Add RenameOptions with 5 toggleable fields (flag/alias/country/city/index)
  stored as rename_options_json on collections
- Collection rename pipeline uses DetectGeoWithServer(name, server) which
  tries GeoIP lookup first, falls back to name-based regex matching
- Chinese country/city names + emoji flag prefix
- Country-city dedup: skip city when it equals country (e.g. 香港/香港)
- URI fragment encoding: url.QueryEscape -> url.PathEscape (%20 not +)
- Egress cache key excludes name field so renamed nodes still match cache
- Frontend: 5 toggle buttons in collection edit form, default all on
- Migrations: 0004 (source alias + collection rename_enabled),
  0005 (collection rename_options_json)
2026-07-28 14:44:54 +08:00

97 lines
2.5 KiB
Go

package geoip
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
const (
// GeoLite2-City mmdb download sources (tried in order)
// P3TERX/GeoLite.mmdb — GitHub-hosted, updated weekly, direct .mmdb (no tar)
mmdbMirrorURL = "https://raw.githubusercontent.com/P3TERX/GeoLite.mmdb/download/GeoLite2-City.mmdb"
// jsdelivr CDN mirror of the same repo (China-accessible)
mmdbFallbackURL = "https://cdn.jsdelivr.net/gh/P3TERX/GeoLite.mmdb@download/GeoLite2-City.mmdb"
// fastgit mirror (China-accessible)
mmdbFastgitURL = "https://raw.fastgit.org/P3TERX/GeoLite.mmdb/download/GeoLite2-City.mmdb"
mmdbDownloadTimeout = 120 * time.Second
)
// DownloadMmdb downloads the GeoLite2-City mmdb file to the given path.
// It tries multiple mirrors in order.
func DownloadMmdb(destPath string) error {
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return fmt.Errorf("create mmdb dir: %w", err)
}
urls := []string{mmdbMirrorURL, mmdbFallbackURL, mmdbFastgitURL}
var lastErr error
for _, u := range urls {
if err := downloadDirect(destPath, u); err == nil {
return nil
} else {
lastErr = err
}
}
return fmt.Errorf("all download sources failed, last error: %w", lastErr)
}
func downloadDirect(destPath, url string) error {
client := &http.Client{Timeout: mmdbDownloadTimeout}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "sub-store/1.0")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
os.Remove(destPath)
return err
}
// Verify it's a valid mmdb by checking the file size (basic sanity)
if !isValidMmdb(destPath) {
os.Remove(destPath)
return fmt.Errorf("downloaded file is not a valid mmdb")
}
return nil
}
func isValidMmdb(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return false
}
return info.Size() > 1024
}
// EnsureMmdb ensures the mmdb file exists, downloading it if needed.
// Returns nil if the file already exists or was downloaded successfully.
func EnsureMmdb(path string) error {
if _, err := os.Stat(path); err == nil {
return nil // already exists
}
return DownloadMmdb(path)
}