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)
This commit is contained in:
2026-07-28 14:44:54 +08:00
parent 389c0d172a
commit de9ac92ede
29 changed files with 1256 additions and 225 deletions
+56 -43
View File
@@ -20,21 +20,24 @@ func NewCollectionRepo(db *sqlx.DB) *CollectionRepo {
}
type collectionRow struct {
ID string `db:"id"`
Name string `db:"name"`
SourceIdsJSON string `db:"source_ids_json"`
FiltersJSON string `db:"filters_json"`
TemplateID string `db:"template_id"`
IgnoreFailed int `db:"ignore_failed"`
Enabled int `db:"enabled"`
MetaJSON string `db:"meta_json"`
CreatedAt int64 `db:"created_at"`
UpdatedAt int64 `db:"updated_at"`
ID string `db:"id"`
Name string `db:"name"`
SourceIdsJSON string `db:"source_ids_json"`
RenameEnabled int `db:"rename_enabled"`
RenameOptionsJSON string `db:"rename_options_json"`
TemplateID string `db:"template_id"`
IgnoreFailed int `db:"ignore_failed"`
Enabled int `db:"enabled"`
MetaJSON string `db:"meta_json"`
CreatedAt int64 `db:"created_at"`
UpdatedAt int64 `db:"updated_at"`
}
const collectionColumns = `id, name, source_ids_json, rename_enabled, rename_options_json, template_id, ignore_failed, enabled, meta_json, created_at, updated_at`
func (r *CollectionRepo) List() ([]model.CollectionRecord, error) {
var rows []collectionRow
if err := r.db.Select(&rows, "SELECT * FROM collections ORDER BY created_at ASC"); err != nil {
if err := r.db.Select(&rows, `SELECT `+collectionColumns+` FROM collections ORDER BY created_at ASC`); err != nil {
return nil, err
}
result := make([]model.CollectionRecord, 0, len(rows))
@@ -46,7 +49,7 @@ func (r *CollectionRepo) List() ([]model.CollectionRecord, error) {
func (r *CollectionRepo) Get(id string) (*model.CollectionRecord, error) {
var row collectionRow
if err := r.db.Get(&row, "SELECT * FROM collections WHERE id = ?", id); err != nil {
if err := r.db.Get(&row, `SELECT `+collectionColumns+` FROM collections WHERE id = ?`, id); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
@@ -72,10 +75,6 @@ func (r *CollectionRepo) Upsert(input model.CollectionRecord) (model.CollectionR
if sourceIds == nil {
sourceIds = []string{}
}
filters := input.Filters
if filters == nil {
filters = []model.FilterRule{}
}
meta := input.Meta
if meta == nil {
meta = map[string]any{}
@@ -86,31 +85,35 @@ func (r *CollectionRepo) Upsert(input model.CollectionRecord) (model.CollectionR
}
rec := model.CollectionRecord{
ID: id,
Name: input.Name,
SourceIds: sourceIds,
Filters: filters,
TemplateId: templateId,
IgnoreFailed: input.IgnoreFailed,
Enabled: input.Enabled,
Meta: meta,
CreatedAt: createdAt,
UpdatedAt: now,
ID: id,
Name: input.Name,
SourceIds: sourceIds,
RenameEnabled: input.RenameEnabled,
RenameOptions: input.RenameOptions,
TemplateId: templateId,
IgnoreFailed: input.IgnoreFailed,
Enabled: input.Enabled,
Meta: meta,
CreatedAt: createdAt,
UpdatedAt: now,
}
sourceIdsJSON := marshalJSON(rec.SourceIds)
filtersJSON := marshalJSON(rec.Filters)
metaJSON := marshalJSON(rec.Meta)
renameOptionsJSON := ""
if rec.RenameOptions != nil {
renameOptionsJSON = marshalJSON(rec.RenameOptions)
}
_, err := r.db.Exec(
`INSERT INTO collections (id, name, source_ids_json, filters_json, template_id, ignore_failed, enabled, meta_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`INSERT INTO collections (id, name, source_ids_json, rename_enabled, rename_options_json, template_id, ignore_failed, enabled, meta_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, source_ids_json = excluded.source_ids_json,
filters_json = excluded.filters_json, template_id = excluded.template_id,
ignore_failed = excluded.ignore_failed, enabled = excluded.enabled,
meta_json = excluded.meta_json, updated_at = excluded.updated_at`,
rec.ID, rec.Name, sourceIdsJSON, filtersJSON, rec.TemplateId, boolToInt(rec.IgnoreFailed), boolToInt(rec.Enabled), metaJSON, rec.CreatedAt, rec.UpdatedAt,
rename_enabled = excluded.rename_enabled, rename_options_json = excluded.rename_options_json,
template_id = excluded.template_id, ignore_failed = excluded.ignore_failed,
enabled = excluded.enabled, meta_json = excluded.meta_json, updated_at = excluded.updated_at`,
rec.ID, rec.Name, sourceIdsJSON, boolToInt(rec.RenameEnabled), renameOptionsJSON, rec.TemplateId, boolToInt(rec.IgnoreFailed), boolToInt(rec.Enabled), metaJSON, rec.CreatedAt, rec.UpdatedAt,
)
if err != nil {
return rec, err
@@ -145,16 +148,26 @@ func collectionFromRow(row collectionRow) model.CollectionRecord {
if sourceIds == nil {
sourceIds = []string{}
}
var renameOptions *model.RenameOptions
if row.RenameOptionsJSON != "" {
var opts model.RenameOptions
if err := json.Unmarshal([]byte(row.RenameOptionsJSON), &opts); err == nil {
renameOptions = &opts
}
}
return model.CollectionRecord{
ID: row.ID,
Name: row.Name,
SourceIds: sourceIds,
Filters: model.UnmarshalFilterRules(row.FiltersJSON),
TemplateId: row.TemplateID,
IgnoreFailed: row.IgnoreFailed != 0,
Enabled: row.Enabled != 0,
Meta: model.UnmarshalMeta(row.MetaJSON),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
ID: row.ID,
Name: row.Name,
SourceIds: sourceIds,
RenameEnabled: row.RenameEnabled != 0,
RenameOptions: renameOptions,
TemplateId: row.TemplateID,
IgnoreFailed: row.IgnoreFailed != 0,
Enabled: row.Enabled != 0,
Meta: model.UnmarshalMeta(row.MetaJSON),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE sources ADD COLUMN alias TEXT NOT NULL DEFAULT '';
-- Collections no longer have filters; they have a rename toggle instead.
ALTER TABLE collections ADD COLUMN rename_enabled INTEGER NOT NULL DEFAULT 0;
-- +goose Down
-- SQLite does not support DROP COLUMN before 3.35; these columns are harmless
-- if left in place. For a clean rollback, recreate the tables without them.
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE collections ADD COLUMN rename_options_json TEXT NOT NULL DEFAULT '';
-- +goose Down
-- SQLite does not support DROP COLUMN before 3.35; harmless if left in place.
+4 -7
View File
@@ -160,7 +160,7 @@ func TestCollectionDelete(t *testing.T) {
defer db.Close()
repo := NewCollectionRepo(db)
repo.Upsert(model.CollectionRecord{
ID: "col-del", Name: "Del", SourceIds: []string{}, Filters: []model.FilterRule{},
ID: "col-del", Name: "Del", SourceIds: []string{},
TemplateId: "acl4ssr-mihomo", Meta: map[string]any{},
})
err := repo.Delete("col-del")
@@ -179,7 +179,7 @@ func TestCollectionSort(t *testing.T) {
repo := NewCollectionRepo(db)
for _, id := range []string{"a", "b", "c"} {
repo.Upsert(model.CollectionRecord{
ID: id, Name: id, SourceIds: []string{}, Filters: []model.FilterRule{},
ID: id, Name: id, SourceIds: []string{},
TemplateId: "acl4ssr-mihomo", Meta: map[string]any{},
})
}
@@ -365,7 +365,7 @@ func TestSourceUpsert_AutoId(t *testing.T) {
repo := NewSourceRepo(db)
rec, err := repo.Upsert(model.SourceRecord{
Name: "My Source", Type: "local", Content: "ss://pass@host:port#name",
Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{},
Enabled: true, Meta: map[string]any{},
})
if err != nil {
t.Fatalf("upsert failed: %v", err)
@@ -381,7 +381,7 @@ func TestSourceUpsert_DefaultTypeRemote(t *testing.T) {
repo := NewSourceRepo(db)
repo.Upsert(model.SourceRecord{
ID: "test", Name: "Test", Type: "", URL: "https://example.com/sub",
Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{},
Enabled: true, Meta: map[string]any{},
})
// Read back — sourceFromRow normalizes empty type to "remote"
got, _ := repo.Get("test")
@@ -455,9 +455,6 @@ func TestCollectionUpsert_Defaults(t *testing.T) {
if rec.SourceIds == nil {
t.Error("expected non-nil SourceIds")
}
if rec.Filters == nil {
t.Error("expected non-nil Filters")
}
if rec.Meta == nil {
t.Error("expected non-nil Meta")
}
+8 -8
View File
@@ -104,14 +104,14 @@ func TestCollectionCRUD(t *testing.T) {
repo := NewCollectionRepo(db)
col := model.CollectionRecord{
ID: "test-col",
Name: "Test Collection",
SourceIds: []string{"src1"},
Filters: []model.FilterRule{},
TemplateId: "default",
IgnoreFailed: true,
Enabled: true,
Meta: map[string]any{},
ID: "test-col",
Name: "Test Collection",
SourceIds: []string{"src1"},
RenameEnabled: true,
TemplateId: "default",
IgnoreFailed: true,
Enabled: true,
Meta: map[string]any{},
}
created, err := repo.Upsert(col)
if err != nil {
+9 -6
View File
@@ -21,6 +21,7 @@ func NewSourceRepo(db *sqlx.DB) *SourceRepo {
type sourceRow struct {
ID string `db:"id"`
Name string `db:"name"`
Alias string `db:"alias"`
Type string `db:"type"`
URL string `db:"url"`
Content string `db:"content"`
@@ -33,7 +34,7 @@ type sourceRow struct {
func (r *SourceRepo) List() ([]model.SourceRecord, error) {
var rows []sourceRow
if err := r.db.Select(&rows, "SELECT * FROM sources ORDER BY created_at ASC"); err != nil {
if err := r.db.Select(&rows, `SELECT id, name, alias, type, url, content, enabled, filters_json, meta_json, created_at, updated_at FROM sources ORDER BY created_at ASC`); err != nil {
return nil, err
}
result := make([]model.SourceRecord, 0, len(rows))
@@ -45,7 +46,7 @@ func (r *SourceRepo) List() ([]model.SourceRecord, error) {
func (r *SourceRepo) Get(id string) (*model.SourceRecord, error) {
var row sourceRow
if err := r.db.Get(&row, "SELECT * FROM sources WHERE id = ?", id); err != nil {
if err := r.db.Get(&row, `SELECT id, name, alias, type, url, content, enabled, filters_json, meta_json, created_at, updated_at FROM sources WHERE id = ?`, id); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
@@ -79,6 +80,7 @@ func (r *SourceRepo) Upsert(input model.SourceRecord) (model.SourceRecord, error
rec := model.SourceRecord{
ID: id,
Name: input.Name,
Alias: input.Alias,
Type: input.Type,
URL: input.URL,
Content: input.Content,
@@ -101,14 +103,14 @@ func (r *SourceRepo) Upsert(input model.SourceRecord) (model.SourceRecord, error
}
_, err := r.db.Exec(
`INSERT INTO sources (id, name, type, url, content, enabled, filters_json, meta_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`INSERT INTO sources (id, name, alias, type, url, content, enabled, filters_json, meta_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, type = excluded.type, url = excluded.url,
name = excluded.name, alias = excluded.alias, type = excluded.type, url = excluded.url,
content = excluded.content, enabled = excluded.enabled,
filters_json = excluded.filters_json, meta_json = excluded.meta_json,
updated_at = excluded.updated_at`,
rec.ID, rec.Name, sourceType, rec.URL, rec.Content, enabled, filtersJSON, metaJSON, rec.CreatedAt, rec.UpdatedAt,
rec.ID, rec.Name, rec.Alias, sourceType, rec.URL, rec.Content, enabled, filtersJSON, metaJSON, rec.CreatedAt, rec.UpdatedAt,
)
if err != nil {
return rec, err
@@ -145,6 +147,7 @@ func sourceFromRow(row sourceRow) model.SourceRecord {
return model.SourceRecord{
ID: row.ID,
Name: row.Name,
Alias: row.Alias,
Type: sourceType,
URL: row.URL,
Content: row.Content,
+147
View File
@@ -0,0 +1,147 @@
package filter
import (
"fmt"
"strings"
"github.com/peterqiu0516/sub-store/internal/model"
"github.com/peterqiu0516/sub-store/internal/util"
)
// defaultRenameOptions returns the default options (all fields included).
func defaultRenameOptions() model.RenameOptions {
return model.RenameOptions{
Flag: true,
Alias: true,
Country: true,
City: true,
Index: true,
}
}
// resolveRenameOptions returns the effective options, applying defaults for
// nil or zero-value fields.
func resolveRenameOptions(opts *model.RenameOptions) model.RenameOptions {
if opts == nil {
return defaultRenameOptions()
}
return *opts
}
// RenameCollectionNodes renames proxy nodes in the collection-level format:
//
// [emoji country flag] [alias] [country name] [city] [01...100]
//
// The alias is read from each node's "_sourceAlias" field (tagged by the
// subscription service from the source's Alias). Geographic info (flag,
// country, city) is auto-detected from the node's original name and server.
// Nodes are grouped by country+city+alias and numbered sequentially within
// each group, starting at 01. If only one node exists in a group, no number
// is appended.
//
// opts controls which fields are included in the output. A nil opts means all
// fields are included (full default format).
func RenameCollectionNodes(proxies []model.ProxyNode, opts *model.RenameOptions) []model.ProxyNode {
if len(proxies) == 0 {
return proxies
}
o := resolveRenameOptions(opts)
type groupKey struct {
alias string
country string
city string
}
groups := make(map[groupKey]int)
results := make([]model.ProxyNode, len(proxies))
// First pass: count group sizes
for i, proxy := range proxies {
if proxy == nil {
results[i] = proxy
continue
}
name := ToString(proxy["name"])
alias := ToString(proxy["_sourceAlias"])
server := ToString(proxy["server"])
geo := util.DetectGeoWithServer(name, server)
if geo.CountryName == "" {
results[i] = proxy
continue
}
key := groupKey{
alias: alias,
country: geo.CountryCN,
city: geo.CityCN,
}
groups[key]++
}
// Second pass: assign names with numbering
counters := make(map[groupKey]int)
for i, proxy := range proxies {
if proxy == nil {
continue
}
name := ToString(proxy["name"])
alias := ToString(proxy["_sourceAlias"])
server := ToString(proxy["server"])
geo := util.DetectGeoWithServer(name, server)
if geo.CountryName == "" {
results[i] = proxy
continue
}
key := groupKey{
alias: alias,
country: geo.CountryCN,
city: geo.CityCN,
}
counters[key]++
total := groups[key]
// Build name parts based on enabled options
var parts []string
if o.Flag && geo.Flag != "" {
parts = append(parts, geo.Flag)
}
if o.Alias && alias != "" {
parts = append(parts, "["+alias+"]")
}
// Country and city: city is only shown if different from country
if o.Country {
if o.City && geo.CityCN != "" && geo.CityCN != geo.CountryCN {
parts = append(parts, geo.CountryCN, geo.CityCN)
} else {
parts = append(parts, geo.CountryCN)
}
} else if o.City && geo.CityCN != "" && geo.CityCN != geo.CountryCN {
// City enabled but country disabled — show city alone
parts = append(parts, geo.CityCN)
}
baseName := strings.Join(parts, " ")
if o.Index && total > 1 {
baseName = fmt.Sprintf("%s %02d", baseName, counters[key])
}
next := cloneProxy(proxy)
delete(next, "_sourceAlias")
next["name"] = baseName
results[i] = next
}
return results
}
+255
View File
@@ -0,0 +1,255 @@
package filter
import (
"fmt"
"strings"
"testing"
"github.com/peterqiu0516/sub-store/internal/model"
)
func TestRenameCollectionNodes_SingleNode(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "香港 HK 1", "type": "ss", "_sourceAlias": "机场A"},
}
result := RenameCollectionNodes(proxies, nil)
if len(result) != 1 {
t.Fatalf("expected 1 node, got %d", len(result))
}
name := result[0]["name"].(string)
if !strings.Contains(name, "🇭🇰") {
t.Errorf("expected flag emoji, got %q", name)
}
if !strings.Contains(name, "[机场A]") {
t.Errorf("expected [alias] with brackets, got %q", name)
}
if !strings.Contains(name, "香港") {
t.Errorf("expected Chinese country name, got %q", name)
}
// 香港 as country == 香港 as city → should only appear once
if strings.Count(name, "香港") > 1 {
t.Errorf("country/city duplicate: %q", name)
}
if _, hasAlias := result[0]["_sourceAlias"]; hasAlias {
t.Error("_sourceAlias should be removed from output")
}
}
func TestRenameCollectionNodes_MultipleNodes(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "香港 1", "type": "ss", "_sourceAlias": "机场A"},
{"name": "香港 2", "type": "ss", "_sourceAlias": "机场A"},
{"name": "香港 3", "type": "ss", "_sourceAlias": "机场A"},
}
result := RenameCollectionNodes(proxies, nil)
if len(result) != 3 {
t.Fatalf("expected 3 nodes, got %d", len(result))
}
// All 3 should be numbered 01, 02, 03
for i, node := range result {
name := node["name"].(string)
expected := fmt.Sprintf("%02d", i+1)
if !strings.Contains(name, expected) {
t.Errorf("node[%d]: expected number %s in name %q", i, expected, name)
}
}
}
func TestRenameCollectionNodes_DifferentCountries(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "香港", "type": "ss", "_sourceAlias": "A"},
{"name": "东京", "type": "ss", "_sourceAlias": "A"},
}
result := RenameCollectionNodes(proxies, nil)
if len(result) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(result))
}
// Different countries → each is single in its group → no number
name0 := result[0]["name"].(string)
name1 := result[1]["name"].(string)
if !strings.Contains(name0, "🇭🇰") {
t.Errorf("node[0] expected HK flag, got %q", name0)
}
if !strings.Contains(name1, "🇯🇵") {
t.Errorf("node[1] expected JP flag, got %q", name1)
}
if !strings.Contains(name0, "香港") {
t.Errorf("node[0] expected 香港, got %q", name0)
}
if !strings.Contains(name1, "日本") {
t.Errorf("node[1] expected 日本, got %q", name1)
}
// Neither should have a number suffix
if strings.HasSuffix(name0, "01") {
t.Errorf("node[0] should not have number, got %q", name0)
}
}
func TestRenameCollectionNodes_NoAlias(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "香港 1", "type": "ss"},
{"name": "香港 2", "type": "ss"},
}
result := RenameCollectionNodes(proxies, nil)
if len(result) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(result))
}
for i, node := range result {
name := node["name"].(string)
if !strings.Contains(name, "🇭🇰") {
t.Errorf("node[%d]: expected flag, got %q", i, name)
}
if !strings.Contains(name, "香港") {
t.Errorf("node[%d]: expected Chinese country, got %q", i, name)
}
}
}
func TestRenameCollectionNodes_UnknownCountry(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "Unknown Node", "type": "ss", "_sourceAlias": "A"},
}
result := RenameCollectionNodes(proxies, nil)
if len(result) != 1 {
t.Fatalf("expected 1 node, got %d", len(result))
}
// Should keep original name
if result[0]["name"] != "Unknown Node" {
t.Errorf("expected original name, got %q", result[0]["name"])
}
}
func TestRenameCollectionNodes_CityDetection(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "洛杉矶 1", "type": "ss", "_sourceAlias": "A"},
{"name": "洛杉矶 2", "type": "ss", "_sourceAlias": "A"},
{"name": "纽约 1", "type": "ss", "_sourceAlias": "A"},
}
result := RenameCollectionNodes(proxies, nil)
if len(result) != 3 {
t.Fatalf("expected 3 nodes, got %d", len(result))
}
// LA nodes should be numbered 01, 02; NY node 01
laCount := 0
nyCount := 0
for _, node := range result {
name := node["name"].(string)
if strings.Contains(name, "洛杉矶") {
laCount++
}
if strings.Contains(name, "纽约") {
nyCount++
}
}
if laCount != 2 {
t.Errorf("expected 2 LA nodes, got %d", laCount)
}
if nyCount != 1 {
t.Errorf("expected 1 NY node, got %d", nyCount)
}
}
func TestRenameCollectionNodes_Empty(t *testing.T) {
result := RenameCollectionNodes(nil, nil)
if result != nil {
t.Errorf("expected nil for nil input, got %v", result)
}
result = RenameCollectionNodes([]model.ProxyNode{}, nil)
if len(result) != 0 {
t.Errorf("expected empty for empty input, got %d", len(result))
}
}
// --- RenameOptions tests ---
func TestRenameCollectionNodes_DisableFlag(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "香港 1", "type": "ss", "_sourceAlias": "机场A"},
}
opts := &model.RenameOptions{Flag: false, Alias: true, Country: true, City: true, Index: true}
result := RenameCollectionNodes(proxies, opts)
name := result[0]["name"].(string)
if strings.Contains(name, "🇭🇰") {
t.Errorf("flag should be disabled, got %q", name)
}
if !strings.Contains(name, "香港") {
t.Errorf("country should be present, got %q", name)
}
if !strings.Contains(name, "[机场A]") {
t.Errorf("alias should be present, got %q", name)
}
}
func TestRenameCollectionNodes_DisableAlias(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "香港 1", "type": "ss", "_sourceAlias": "机场A"},
}
opts := &model.RenameOptions{Flag: true, Alias: false, Country: true, City: true, Index: true}
result := RenameCollectionNodes(proxies, opts)
name := result[0]["name"].(string)
if strings.Contains(name, "[机场A]") {
t.Errorf("alias should be disabled, got %q", name)
}
if !strings.Contains(name, "🇭🇰") {
t.Errorf("flag should be present, got %q", name)
}
}
func TestRenameCollectionNodes_DisableCountry(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "洛杉矶 1", "type": "ss", "_sourceAlias": "A"},
}
opts := &model.RenameOptions{Flag: true, Alias: true, Country: false, City: true, Index: true}
result := RenameCollectionNodes(proxies, opts)
name := result[0]["name"].(string)
// Country name "美国" should not appear
if strings.Contains(name, "美国") {
t.Errorf("country should be disabled, got %q", name)
}
// City "洛杉矶" should still appear
if !strings.Contains(name, "洛杉矶") {
t.Errorf("city should be present, got %q", name)
}
}
func TestRenameCollectionNodes_DisableIndex(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "香港 1", "type": "ss", "_sourceAlias": "A"},
{"name": "香港 2", "type": "ss", "_sourceAlias": "A"},
}
opts := &model.RenameOptions{Flag: true, Alias: true, Country: true, City: true, Index: false}
result := RenameCollectionNodes(proxies, opts)
for i, node := range result {
name := node["name"].(string)
// Should not contain number suffix
suffix := fmt.Sprintf("%02d", i+1)
if strings.HasSuffix(name, suffix) {
t.Errorf("node[%d]: index should be disabled, got %q", i, name)
}
}
}
func TestRenameCollectionNodes_OnlyFlagAndIndex(t *testing.T) {
proxies := []model.ProxyNode{
{"name": "香港 1", "type": "ss", "_sourceAlias": "A"},
{"name": "香港 2", "type": "ss", "_sourceAlias": "A"},
}
opts := &model.RenameOptions{Flag: true, Alias: false, Country: false, City: false, Index: true}
result := RenameCollectionNodes(proxies, opts)
for i, node := range result {
name := node["name"].(string)
if !strings.HasPrefix(name, "🇭🇰") {
t.Errorf("node[%d]: should start with flag, got %q", i, name)
}
if strings.Contains(name, "香港") {
t.Errorf("node[%d]: country should not appear, got %q", i, name)
}
if strings.Contains(name, "[A]") {
t.Errorf("node[%d]: alias should not appear, got %q", i, name)
}
expected := fmt.Sprintf("🇭🇰 %02d", i+1)
if name != expected {
t.Errorf("node[%d]: expected %q, got %q", i, expected, name)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
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)
}
+207
View File
@@ -0,0 +1,207 @@
package geoip
import (
"fmt"
"net"
"os"
"path/filepath"
"sync"
"github.com/oschwald/maxminddb-golang"
)
// GeoResult holds the geographic lookup result for an IP.
type GeoResult struct {
CountryCode string // ISO 3166-1 alpha-2, e.g. "JP"
CountryName string // English country name, e.g. "Japan"
CountryNameCN string // Chinese country name, e.g. "日本"
CityName string // English city name (may be empty)
CityNameCN string // Chinese city name (may be empty)
}
var (
mu sync.Mutex
reader *maxminddb.Reader
loaded bool
mmdbPath string
)
// countryNameCN maps ISO country codes to Chinese names.
// Covers all 249 ISO 3166-1 countries.
var countryNameCN = map[string]string{
"AD": "安道尔", "AE": "阿联酋", "AF": "阿富汗", "AG": "安提瓜", "AI": "安圭拉",
"AL": "阿尔巴尼亚", "AM": "亚美尼亚", "AO": "安哥拉", "AQ": "南极洲", "AR": "阿根廷",
"AS": "美属萨摩亚", "AT": "奥地利", "AU": "澳大利亚", "AW": "阿鲁巴", "AX": "奥兰群岛",
"AZ": "阿塞拜疆", "BA": "波黑", "BB": "巴巴多斯", "BD": "孟加拉国", "BE": "比利时",
"BF": "布基纳法索", "BG": "保加利亚", "BH": "巴林", "BI": "布隆迪", "BJ": "贝宁",
"BL": "圣巴泰勒米", "BM": "百慕大", "BN": "文莱", "BO": "玻利维亚", "BQ": "荷兰加勒比",
"BR": "巴西", "BS": "巴哈马", "BT": "不丹", "BV": "布韦岛", "BW": "博茨瓦纳",
"BY": "白俄罗斯", "BZ": "伯利兹", "CA": "加拿大", "CC": "科科斯群岛", "CD": "刚果(金)",
"CF": "中非", "CG": "刚果(布)", "CH": "瑞士", "CI": "科特迪瓦", "CK": "库克群岛",
"CL": "智利", "CM": "喀麦隆", "CN": "中国", "CO": "哥伦比亚", "CR": "哥斯达黎加",
"CU": "古巴", "CV": "佛得角", "CW": "库拉索", "CX": "圣诞岛", "CY": "塞浦路斯",
"CZ": "捷克", "DE": "德国", "DJ": "吉布提", "DK": "丹麦", "DM": "多米尼克",
"DO": "多米尼加", "DZ": "阿尔及利亚", "EC": "厄瓜多尔", "EE": "爱沙尼亚", "EG": "埃及",
"EH": "西撒哈拉", "ER": "厄立特里亚", "ES": "西班牙", "ET": "埃塞俄比亚", "FI": "芬兰",
"FJ": "斐济", "FK": "福克兰群岛", "FM": "密克罗尼西亚", "FO": "法罗群岛", "FR": "法国",
"GA": "加蓬", "GB": "英国", "GD": "格林纳达", "GE": "格鲁吉亚", "GF": "法属圭亚那",
"GG": "根西岛", "GH": "加纳", "GI": "直布罗陀", "GL": "格陵兰", "GM": "冈比亚",
"GN": "几内亚", "GP": "瓜德罗普", "GQ": "赤道几内亚", "GR": "希腊", "GS": "南乔治亚",
"GT": "危地马拉", "GU": "关岛", "GW": "几内亚比绍", "GY": "圭亚那", "HK": "香港",
"HM": "赫德岛", "HN": "洪都拉斯", "HR": "克罗地亚", "HT": "海地", "HU": "匈牙利",
"ID": "印度尼西亚", "IE": "爱尔兰", "IL": "以色列", "IM": "马恩岛", "IN": "印度",
"IO": "英属印度洋领地", "IQ": "伊拉克", "IR": "伊朗", "IS": "冰岛", "IT": "意大利",
"JE": "泽西岛", "JM": "牙买加", "JO": "约旦", "JP": "日本", "KE": "肯尼亚",
"KG": "吉尔吉斯斯坦", "KH": "柬埔寨", "KI": "基里巴斯", "KM": "科摩罗", "KN": "圣基茨",
"KP": "朝鲜", "KR": "韩国", "KW": "科威特", "KY": "开曼群岛", "KZ": "哈萨克斯坦",
"LA": "老挝", "LB": "黎巴嫩", "LC": "圣卢西亚", "LI": "列支敦士登", "LK": "斯里兰卡",
"LR": "利比里亚", "LS": "莱索托", "LT": "立陶宛", "LU": "卢森堡", "LV": "拉脱维亚",
"LY": "利比亚", "MA": "摩洛哥", "MC": "摩纳哥", "MD": "摩尔多瓦", "ME": "黑山",
"MF": "法属圣马丁", "MG": "马达加斯加", "MH": "马绍尔群岛", "MK": "北马其顿", "ML": "马里",
"MM": "缅甸", "MN": "蒙古", "MO": "澳门", "MP": "北马里亚纳", "MQ": "马提尼克",
"MR": "毛里塔尼亚", "MS": "蒙特塞拉特", "MT": "马耳他", "MU": "毛里求斯", "MV": "马尔代夫",
"MW": "马拉维", "MX": "墨西哥", "MY": "马来西亚", "MZ": "莫桑比克", "NA": "纳米比亚",
"NC": "新喀里多尼亚", "NE": "尼日尔", "NF": "诺福克岛", "NG": "尼日利亚", "NI": "尼加拉瓜",
"NL": "荷兰", "NO": "挪威", "NP": "尼泊尔", "NR": "瑙鲁", "NU": "纽埃",
"NZ": "新西兰", "OM": "阿曼", "PA": "巴拿马", "PE": "秘鲁", "PF": "法属波利尼西亚",
"PG": "巴布亚新几内亚", "PH": "菲律宾", "PK": "巴基斯坦", "PL": "波兰", "PM": "圣皮埃尔",
"PN": "皮特凯恩", "PR": "波多黎各", "PS": "巴勒斯坦", "PT": "葡萄牙", "PW": "帕劳",
"PY": "巴拉圭", "QA": "卡塔尔", "RE": "留尼汪", "RO": "罗马尼亚", "RS": "塞尔维亚",
"RU": "俄罗斯", "RW": "卢旺达", "SA": "沙特", "SB": "所罗门群岛", "SC": "塞舌尔",
"SD": "苏丹", "SE": "瑞典", "SG": "新加坡", "SH": "圣赫勒拿", "SI": "斯洛文尼亚",
"SJ": "斯瓦尔巴", "SK": "斯洛伐克", "SL": "塞拉利昂", "SM": "圣马力诺", "SN": "塞内加尔",
"SO": "索马里", "SR": "苏里南", "SS": "南苏丹", "ST": "圣多美", "SV": "萨尔瓦多",
"SX": "圣马丁", "SY": "叙利亚", "SZ": "斯威士兰", "TC": "特克斯群岛", "TD": "乍得",
"TF": "法属南方领地", "TG": "多哥", "TH": "泰国", "TJ": "塔吉克斯坦", "TK": "托克劳",
"TL": "东帝汶", "TM": "土库曼斯坦", "TN": "突尼斯", "TO": "汤加", "TR": "土耳其",
"TT": "特立尼达", "TV": "图瓦卢", "TW": "台湾", "TZ": "坦桑尼亚", "UA": "乌克兰",
"UG": "乌干达", "UM": "美属小群岛", "US": "美国", "UY": "乌拉圭", "UZ": "乌兹别克斯坦",
"VA": "梵蒂冈", "VC": "圣文森特", "VE": "委内瑞拉", "VG": "英属维尔京", "VI": "美属维尔京",
"VN": "越南", "VU": "瓦努阿图", "WF": "瓦利斯", "WS": "萨摩亚", "YE": "也门",
"YT": "马约特", "ZA": "南非", "ZM": "赞比亚", "ZW": "津巴布韦",
}
// countryCodeToEmoji converts an ISO 3166-1 alpha-2 code to a flag emoji.
func countryCodeToEmoji(code string) string {
if len(code) != 2 {
return "🏳️"
}
c1 := code[0]
c2 := code[1]
if c1 < 'A' || c1 > 'Z' || c2 < 'A' || c2 > 'Z' {
return "🏳️"
}
// Regional indicator symbols: A=0x1F1E6, so subtract 'A' and add 0x1F1E6
r1 := rune(0x1F1E6) + rune(c1-'A')
r2 := rune(0x1F1E6) + rune(c2-'A')
return string(r1) + string(r2)
}
// CountryNameCN returns the Chinese name for an ISO country code.
func CountryNameCN(code string) string {
if name, ok := countryNameCN[code]; ok {
return name
}
return ""
}
// CountryEmoji returns the flag emoji for an ISO country code.
func CountryEmoji(code string) string {
return countryCodeToEmoji(code)
}
// SetMmdbPath sets the path to the GeoLite2-City mmdb file.
// Must be called before Lookup.
func SetMmdbPath(path string) {
mu.Lock()
defer mu.Unlock()
if mmdbPath != path {
mmdbPath = path
loaded = false
}
}
// ensureLoaded opens the mmdb reader on first use.
func ensureLoaded() error {
mu.Lock()
defer mu.Unlock()
if loaded {
return nil
}
if mmdbPath == "" {
mmdbPath = filepath.Join("data", "GeoLite2-City.mmdb")
}
if _, err := os.Stat(mmdbPath); err != nil {
return fmt.Errorf("GeoIP database not found at %s: %w", mmdbPath, err)
}
r, err := maxminddb.Open(mmdbPath)
if err != nil {
return fmt.Errorf("failed to open GeoIP database: %w", err)
}
reader = r
loaded = true
return nil
}
// Lookup returns geographic info for an IP address.
// Returns nil if the database is not available or the IP is not found.
func Lookup(ipStr string) *GeoResult {
if ipStr == "" {
return nil
}
ip := net.ParseIP(ipStr)
if ip == nil {
return nil
}
if err := ensureLoaded(); err != nil {
return nil
}
var record struct {
Country struct {
IsoCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
}
if err := reader.Lookup(ip, &record); err != nil {
return nil
}
code := record.Country.IsoCode
if code == "" {
return nil
}
result := &GeoResult{
CountryCode: code,
CountryName: record.Country.Names["en"],
CountryNameCN: record.Country.Names["zh-CN"],
CityName: record.City.Names["en"],
CityNameCN: record.City.Names["zh-CN"],
}
// Fallback: if zh-CN name is missing from mmdb, use our built-in map
if result.CountryNameCN == "" {
result.CountryNameCN = CountryNameCN(code)
}
return result
}
// LookupHost resolves a hostname to its first IP and then looks up geo info.
func LookupHost(host string) *GeoResult {
// If it's already an IP, lookup directly
if ip := net.ParseIP(host); ip != nil {
return Lookup(ip.String())
}
// Resolve domain
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
return nil
}
return Lookup(ips[0].String())
}
+50 -11
View File
@@ -436,9 +436,11 @@ func mapToSourceRecord(input map[string]any) model.SourceRecord {
}
filters := parseFilters(input["filters"])
meta := getMapValue(input["meta"])
alias := getStringValue(input["alias"])
return model.SourceRecord{
ID: id,
Name: orDefault(getStringValue(input["name"]), id),
Alias: alias,
Type: sourceType,
URL: getStringValue(input["url"]),
Content: getStringValue(input["content"]),
@@ -455,7 +457,39 @@ func mapToCollectionRecord(input map[string]any) model.CollectionRecord {
}
id = util.ToId(id)
sourceIds := getStringArray(input["sourceIds"])
filters := parseFilters(input["filters"])
renameEnabled := false
if v, ok := input["renameEnabled"].(bool); ok {
renameEnabled = v
}
// Parse rename options (all default to true when renameEnabled is on)
var renameOptions *model.RenameOptions
if opts, ok := input["renameOptions"].(map[string]any); ok {
ro := model.RenameOptions{
Flag: true,
Alias: true,
Country: true,
City: true,
Index: true,
}
if v, ok := opts["flag"].(bool); ok {
ro.Flag = v
}
if v, ok := opts["alias"].(bool); ok {
ro.Alias = v
}
if v, ok := opts["country"].(bool); ok {
ro.Country = v
}
if v, ok := opts["city"].(bool); ok {
ro.City = v
}
if v, ok := opts["index"].(bool); ok {
ro.Index = v
}
renameOptions = &ro
}
templateId := getStringValue(input["templateId"])
if templateId == "" || templateId == "default" {
templateId = template.DefaultTemplateID
@@ -470,14 +504,15 @@ func mapToCollectionRecord(input map[string]any) model.CollectionRecord {
}
meta := getMapValue(input["meta"])
return model.CollectionRecord{
ID: id,
Name: orDefault(getStringValue(input["name"]), id),
SourceIds: sourceIds,
Filters: filters,
TemplateId: templateId,
IgnoreFailed: ignoreFailed,
Enabled: enabled,
Meta: meta,
ID: id,
Name: orDefault(getStringValue(input["name"]), id),
SourceIds: sourceIds,
RenameEnabled: renameEnabled,
RenameOptions: renameOptions,
TemplateId: templateId,
IgnoreFailed: ignoreFailed,
Enabled: enabled,
Meta: meta,
}
}
@@ -512,6 +547,9 @@ func mergeSource(existing model.SourceRecord, next model.SourceRecord) model.Sou
if next.Name != "" {
merged.Name = next.Name
}
if next.Alias != "" {
merged.Alias = next.Alias
}
if next.Type != "" {
merged.Type = next.Type
}
@@ -539,8 +577,9 @@ func mergeCollection(existing model.CollectionRecord, next model.CollectionRecor
if next.SourceIds != nil {
merged.SourceIds = next.SourceIds
}
if next.Filters != nil {
merged.Filters = next.Filters
merged.RenameEnabled = next.RenameEnabled
if next.RenameOptions != nil {
merged.RenameOptions = next.RenameOptions
}
if next.TemplateId != "" {
merged.TemplateId = next.TemplateId
+1 -1
View File
@@ -78,7 +78,7 @@ func (d *Deps) HandleEgressInfo(c fiber.Ctx) error {
func egressCacheKey(node model.ProxyNode) string {
clean := model.ProxyNode{}
skip := map[string]bool{
"id": true, "latencyMs": true, "latencyError": true,
"id": true, "name": true, "latencyMs": true, "latencyError": true,
"egressIp": true, "egressCountry": true, "egressRegion": true, "egressError": true,
"country": true, "region": true, "city": true, "isp": true, "cached": true,
}
+54 -38
View File
@@ -255,6 +255,7 @@ func TestValidateSource(t *testing.T) {
func TestMapToSourceRecord(t *testing.T) {
in := map[string]any{
"name": "My Source!",
"alias": "MyAlias",
"type": "local",
"url": "https://x.com",
"content": "ss://abc",
@@ -269,6 +270,9 @@ func TestMapToSourceRecord(t *testing.T) {
if rec.Name != "My Source!" {
t.Errorf("Name = %q, want %q", rec.Name, "My Source!")
}
if rec.Alias != "MyAlias" {
t.Errorf("Alias = %q, want MyAlias", rec.Alias)
}
if rec.Type != "local" {
t.Errorf("Type = %q, want local", rec.Type)
}
@@ -300,13 +304,13 @@ func TestMapToSourceRecord(t *testing.T) {
func TestMapToCollectionRecord(t *testing.T) {
in := map[string]any{
"name": "My Col",
"sourceIds": []any{"s1", "s2", "", 123},
"templateId": "default",
"ignoreFailed": false,
"enabled": false,
"meta": map[string]any{"k": "v"},
"filters": []any{map[string]any{"type": "rename"}},
"name": "My Col",
"sourceIds": []any{"s1", "s2", "", 123},
"templateId": "default",
"ignoreFailed": false,
"enabled": false,
"meta": map[string]any{"k": "v"},
"renameEnabled": true,
}
rec := mapToCollectionRecord(in)
if rec.ID == "" {
@@ -327,14 +331,14 @@ func TestMapToCollectionRecord(t *testing.T) {
if rec.Enabled {
t.Error("expected Enabled=false")
}
if !rec.RenameEnabled {
t.Error("expected RenameEnabled=true")
}
if rec.Meta["k"] != "v" {
t.Errorf("Meta = %v", rec.Meta)
}
if len(rec.Filters) != 1 {
t.Errorf("Filters len = %d", len(rec.Filters))
}
// default ignoreFailed = true
// default ignoreFailed = true, default renameEnabled = false
rec2 := mapToCollectionRecord(map[string]any{"id": "c1"})
if !rec2.IgnoreFailed {
t.Error("expected default IgnoreFailed=true")
@@ -342,6 +346,9 @@ func TestMapToCollectionRecord(t *testing.T) {
if !rec2.Enabled {
t.Error("expected default Enabled=true")
}
if rec2.RenameEnabled {
t.Error("expected default RenameEnabled=false")
}
}
func TestMapToTemplateRecord(t *testing.T) {
@@ -380,11 +387,11 @@ func TestMapToTemplateRecord(t *testing.T) {
func TestMergeSource(t *testing.T) {
existing := model.SourceRecord{
ID: "s1", Name: "Old", Type: "local", Content: "old", Enabled: true,
ID: "s1", Name: "Old", Alias: "OldAlias", Type: "local", Content: "old", Enabled: true,
Meta: map[string]any{"a": "1"},
}
next := model.SourceRecord{
Name: "New", URL: "https://x.com", Type: "remote", Enabled: false,
Name: "New", Alias: "NewAlias", URL: "https://x.com", Type: "remote", Enabled: false,
Filters: []model.FilterRule{{Type: "rename"}},
Meta: map[string]any{"b": "2"},
}
@@ -392,6 +399,9 @@ func TestMergeSource(t *testing.T) {
if merged.Name != "New" {
t.Errorf("Name = %q", merged.Name)
}
if merged.Alias != "NewAlias" {
t.Errorf("Alias = %q, want NewAlias", merged.Alias)
}
if merged.Type != "remote" {
t.Errorf("Type = %q", merged.Type)
}
@@ -413,6 +423,9 @@ func TestMergeSource(t *testing.T) {
if merged2.Name != "Old" {
t.Errorf("Name = %q, want Old", merged2.Name)
}
if merged2.Alias != "OldAlias" {
t.Errorf("Alias = %q, want OldAlias", merged2.Alias)
}
}
func TestMergeCollection(t *testing.T) {
@@ -422,7 +435,7 @@ func TestMergeCollection(t *testing.T) {
}
next := model.CollectionRecord{
Name: "New", SourceIds: []string{"s2"}, TemplateId: "t2",
Enabled: false, IgnoreFailed: false, Meta: map[string]any{"b": "2"},
Enabled: false, IgnoreFailed: false, RenameEnabled: true, Meta: map[string]any{"b": "2"},
}
merged := mergeCollection(existing, next)
if merged.Name != "New" {
@@ -440,6 +453,9 @@ func TestMergeCollection(t *testing.T) {
if merged.IgnoreFailed {
t.Error("expected IgnoreFailed=false")
}
if !merged.RenameEnabled {
t.Error("expected RenameEnabled=true")
}
}
func TestGetStringValue(t *testing.T) {
@@ -999,8 +1015,8 @@ func TestHandleUpdateSettings(t *testing.T) {
func TestHandleExportStorage(t *testing.T) {
deps := newTestDeps(t)
// seed some data
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", SourceIds: []string{}, Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", SourceIds: []string{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.TemplateRepo.Upsert(model.TemplateRecord{ID: "custom-t", Name: "Custom", Target: "mihomo", Config: map[string]any{"k": "v"}})
app := newApp(deps)
@@ -1132,12 +1148,12 @@ func TestSourceCRUDHandlers(t *testing.T) {
assertStatus(t, "SortSources bad json", code, 400)
// Delete - referenced by collection (create collection referencing source first)
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "ref-col", Name: "RefCol", SourceIds: []string{"my-src"}, Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "ref-col", Name: "RefCol", SourceIds: []string{"my-src"}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
code, _ = doRequest(t, app, "DELETE", "/api/sources/my-src", "", nil)
assertStatus(t, "DeleteSource referenced", code, 409)
// Delete - remove collection reference, then delete
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "ref-col", Name: "RefCol", SourceIds: []string{}, Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "ref-col", Name: "RefCol", SourceIds: []string{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
code, body = doRequest(t, app, "DELETE", "/api/sources/my-src", "", nil)
assertStatus(t, "DeleteSource", code, 200)
data2 := body["data"].(map[string]any)
@@ -1258,22 +1274,22 @@ func TestCollectionWithTemplateValidation(t *testing.T) {
func TestValidateCollectionMissingTemplate(t *testing.T) {
deps := newTestDeps(t)
// built-in template is ok
rec := model.CollectionRecord{ID: "c1", TemplateId: "mihomo-basic", Filters: []model.FilterRule{}, Meta: map[string]any{}}
rec := model.CollectionRecord{ID: "c1", TemplateId: "mihomo-basic", Meta: map[string]any{}}
if err := deps.validateCollection(rec); err != nil {
t.Errorf("builtin template: unexpected err: %v", err)
}
// missing template
rec2 := model.CollectionRecord{ID: "c2", TemplateId: "no-such", Filters: []model.FilterRule{}, Meta: map[string]any{}}
rec2 := model.CollectionRecord{ID: "c2", TemplateId: "no-such", Meta: map[string]any{}}
if err := deps.validateCollection(rec2); err == nil {
t.Error("missing template: expected err")
}
// missing source ref
rec3 := model.CollectionRecord{ID: "c3", SourceIds: []string{"missing"}, Filters: []model.FilterRule{}, Meta: map[string]any{}}
rec3 := model.CollectionRecord{ID: "c3", SourceIds: []string{"missing"}, Meta: map[string]any{}}
if err := deps.validateCollection(rec3); err == nil {
t.Error("missing source: expected err")
}
// no sourceIds, no templateId -> ok
rec4 := model.CollectionRecord{ID: "c4", Filters: []model.FilterRule{}, Meta: map[string]any{}}
rec4 := model.CollectionRecord{ID: "c4", Meta: map[string]any{}}
if err := deps.validateCollection(rec4); err != nil {
t.Errorf("empty collection: unexpected err: %v", err)
}
@@ -1385,7 +1401,7 @@ func TestRecycleBinHandlers(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// seed + delete a source to populate recycle bin
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, _ := doRequest(t, app, "DELETE", "/api/sources/s1", "", nil)
assertStatus(t, "seed delete", code, 200)
@@ -1401,7 +1417,7 @@ func TestRecycleBinHandlers(t *testing.T) {
// Get via Restore (409 conflict if source recreated)
// First recreate source manually to test conflict path
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, _ = doRequest(t, app, "POST", "/api/recycle-bin/"+entryID+"/restore", "", nil)
assertStatus(t, "RestoreRecycleBin conflict", code, 409)
// remove the conflicting source so restore can proceed
@@ -1425,7 +1441,7 @@ func TestRecycleBinHandlers(t *testing.T) {
assertStatus(t, "RestoreRecycleBin not found", code, 404)
// Delete entry - seed another
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s2", Name: "S2", Type: "local", Content: "ss://y", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s2", Name: "S2", Type: "local", Content: "ss://y", Enabled: true, Meta: map[string]any{}})
doRequest(t, app, "DELETE", "/api/sources/s2", "", nil)
code, body = doRequest(t, app, "GET", "/api/recycle-bin", "", nil)
data = body["data"].([]any)
@@ -1448,7 +1464,7 @@ func TestRestoreCollectionAndTemplate(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// seed + delete collection
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
doRequest(t, app, "DELETE", "/api/collections/c1", "", nil)
// restore collection
code, body := doRequest(t, app, "GET", "/api/recycle-bin", "", nil)
@@ -1482,10 +1498,10 @@ func TestRestoreCollectionAndTemplate(t *testing.T) {
func TestRestoreCollectionConflict(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "cc1", Name: "CC1", Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "cc1", Name: "CC1", TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
doRequest(t, app, "DELETE", "/api/collections/cc1", "", nil)
// recreate to force conflict
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "cc1", Name: "CC1", Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "cc1", Name: "CC1", TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
code, body := doRequest(t, app, "GET", "/api/recycle-bin", "", nil)
data := body["data"].([]any)
var entryID string
@@ -1531,7 +1547,7 @@ func TestRestoreUnknownType(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// Archive a source (which works) then manually update its resourceType to "unknown"
deps.SourceRepo.Upsert(model.SourceRecord{ID: "u1", Name: "U1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "u1", Name: "U1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
deps.RecycleRepo.ArchiveAndDelete("source", "u1", sourceToMap(model.SourceRecord{ID: "u1", Name: "U1"}), "DELETE FROM sources WHERE id = ?", "u1")
// Manually update the resource_type to "unknown" for testing
deps.DB.Exec("UPDATE recycle_bin SET resource_type = 'unknown' WHERE resource_id = 'u1'")
@@ -1567,8 +1583,8 @@ func TestRestoreUnknownType(t *testing.T) {
func TestLinkHandlers(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
// Link source - found
code, body := doRequest(t, app, "GET", "/api/link/source/s1", "", nil)
@@ -1614,7 +1630,7 @@ func TestBuildDownloadLinkNoToken(t *testing.T) {
deps := newTestDeps(t)
deps.Cfg.Auth.DownloadToken = "" // no token
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, body := doRequest(t, app, "GET", "/api/link/source/s1", "", nil)
assertStatus(t, "LinkSource no token", code, 200)
d := body["data"].(map[string]any)
@@ -1783,7 +1799,7 @@ func TestHandleDownloadSourceNotFound(t *testing.T) {
code, _ = doRequest(t, app, "GET", "/sources/nope/dl-tok?target=mihomo", "", nil)
assertStatus(t, "DownloadSource not found", code, 404)
// bad target
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, _ = doRequest(t, app, "GET", "/sources/s1/dl-tok?target=bogus", "", nil)
assertStatus(t, "DownloadSource bad target", code, 400)
}
@@ -1801,7 +1817,7 @@ func TestHandleDownloadCollectionNotFound(t *testing.T) {
func TestHandleDownloadSourceLocalSuccess(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Meta: map[string]any{}})
code, body := doRequest(t, app, "GET", "/sources/s1/dl-tok?target=json", "", nil)
// Should succeed (200) — body contains rendered JSON
assertStatus(t, "DownloadSource local", code, 200)
@@ -1815,7 +1831,7 @@ func TestHandleDownloadSourceLocalSuccess(t *testing.T) {
func TestHandleDownloadSourceDisabled(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: false, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: false, Meta: map[string]any{}})
code, _ := doRequest(t, app, "GET", "/sources/s1/dl-tok?target=json", "", nil)
assertStatus(t, "DownloadSource disabled", code, 404)
}
@@ -1883,7 +1899,7 @@ func TestHandlePreviewCollectionBadJSON(t *testing.T) {
func TestHandlePreviewCollectionSuccess(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Meta: map[string]any{}})
code, body := doRequest(t, app, "POST", "/api/preview/collection", `{"name":"c1","sourceIds":["s1"]}`, nil)
assertStatus(t, "PreviewCollection", code, 200)
d := body["data"].(map[string]any)
@@ -1910,7 +1926,7 @@ func TestHandleFlowInfoNoFlow(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// local source has no flow URL -> parseFlowRequest returns nil
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, _ := doRequest(t, app, "GET", "/api/source/flow/s1", "", nil)
assertStatus(t, "FlowInfo no flow", code, 400)
}
@@ -1919,7 +1935,7 @@ func TestHandleFlowInfoWithMetaOnly(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// source with meta subUserinfo but no remote URL
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{"subUserinfo": "upload=100; download=200; total=1000"}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{"subUserinfo": "upload=100; download=200; total=1000"}})
code, body := doRequest(t, app, "GET", "/api/source/flow/s1", "", nil)
// Flow endpoint may return 200 or 400 depending on whether remote URL is required
if code != 200 && code != 400 {
+22 -10
View File
@@ -53,6 +53,7 @@ type CustomRule struct {
type SourceRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Alias string `json:"alias"`
Type string `json:"type"`
URL string `json:"url"`
Content string `json:"content"`
@@ -65,16 +66,27 @@ type SourceRecord struct {
// CollectionRecord is the full DB record for a subscription collection.
type CollectionRecord struct {
ID string `json:"id"`
Name string `json:"name"`
SourceIds []string `json:"sourceIds"`
Filters []FilterRule `json:"filters"`
TemplateId string `json:"templateId"`
IgnoreFailed bool `json:"ignoreFailed"`
Enabled bool `json:"enabled"`
Meta map[string]any `json:"meta"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
ID string `json:"id"`
Name string `json:"name"`
SourceIds []string `json:"sourceIds"`
RenameEnabled bool `json:"renameEnabled"`
RenameOptions *RenameOptions `json:"renameOptions,omitempty"`
TemplateId string `json:"templateId"`
IgnoreFailed bool `json:"ignoreFailed"`
Enabled bool `json:"enabled"`
Meta map[string]any `json:"meta"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// RenameOptions controls which fields appear in collection-level node renaming.
// All fields default to true (included). When stored as nil, all defaults apply.
type RenameOptions struct {
Flag bool `json:"flag"` // emoji country flag prefix
Alias bool `json:"alias"` // source alias in [brackets]
Country bool `json:"country"` // Chinese country name
City bool `json:"city"` // Chinese city name
Index bool `json:"index"` // sequential number (01, 02, …)
}
// TemplateRecord is the full DB record for a routing template.
+9 -9
View File
@@ -69,7 +69,7 @@ func ToProxyUri(proxy model.ProxyNode) string {
url.PathEscape(getString(proxy, "uuid")),
server, port,
params.Encode(),
url.QueryEscape(name))
url.PathEscape(name))
case "hysteria2":
params := url.Values{}
@@ -89,7 +89,7 @@ func ToProxyUri(proxy model.ProxyNode) string {
url.PathEscape(getString(proxy, "password")),
server, port,
params.Encode(),
url.QueryEscape(name))
url.PathEscape(name))
case "hysteria":
params := url.Values{}
@@ -118,7 +118,7 @@ func ToProxyUri(proxy model.ProxyNode) string {
url.PathEscape(getString(proxy, "auth_str")),
server, port,
params.Encode(),
url.QueryEscape(name))
url.PathEscape(name))
case "anytls":
params := url.Values{}
@@ -135,7 +135,7 @@ func ToProxyUri(proxy model.ProxyNode) string {
url.PathEscape(getString(proxy, "password")),
server, port,
params.Encode(),
url.QueryEscape(name))
url.PathEscape(name))
case "tuic":
params := url.Values{}
@@ -162,7 +162,7 @@ func ToProxyUri(proxy model.ProxyNode) string {
url.PathEscape(getString(proxy, "password")),
server, port,
params.Encode(),
url.QueryEscape(name))
url.PathEscape(name))
case "trojan":
params := url.Values{}
@@ -176,7 +176,7 @@ func ToProxyUri(proxy model.ProxyNode) string {
url.PathEscape(getString(proxy, "password")),
server, port,
params.Encode(),
url.QueryEscape(name))
url.PathEscape(name))
case "socks5":
params := url.Values{}
@@ -208,14 +208,14 @@ func ToProxyUri(proxy model.ProxyNode) string {
scheme = "https"
}
return fmt.Sprintf("%s://%s%s:%d#%s",
scheme, auth, server, port, url.QueryEscape(name))
scheme, auth, server, port, url.PathEscape(name))
case "ss":
userInfo := util.Base64Utf8(fmt.Sprintf("%s:%s@%s:%d",
getString(proxy, "cipher"),
getString(proxy, "password"),
server, port))
return fmt.Sprintf("ss://%s#%s", userInfo, url.QueryEscape(name))
return fmt.Sprintf("ss://%s#%s", userInfo, url.PathEscape(name))
case "ssr":
main := strings.Join([]string{
@@ -257,7 +257,7 @@ func ToProxyUri(proxy model.ProxyNode) string {
url.PathEscape(getString(proxy, "private-key")),
server, port,
params.Encode(),
url.QueryEscape(name))
url.PathEscape(name))
case "vmess":
wsOpts, _ := proxy["ws-opts"].(map[string]any)
+20 -12
View File
@@ -92,10 +92,25 @@ func loadProxyNodes(ctx context.Context, opts BuildOptions) ([]model.ProxyNode,
if filters == nil {
filters = []model.FilterRule{}
}
return filter.ApplyFilters(nodes, filters, opts.Settings, filter.FilterContext{
processed := filter.ApplyFilters(nodes, filters, opts.Settings, filter.FilterContext{
TargetPlatform: opts.Target,
SourceId: sub.ID,
}), nil
})
// Tag each node with the source's alias for collection-level renaming
if sub.Alias != "" {
for j := range processed {
if processed[j] == nil {
continue
}
tagged := make(map[string]any, len(processed[j])+1)
for k, v := range processed[j] {
tagged[k] = v
}
tagged["_sourceAlias"] = sub.Alias
processed[j] = tagged
}
}
return processed, nil
}
}
@@ -128,16 +143,9 @@ func loadProxyNodes(ctx context.Context, opts BuildOptions) ([]model.ProxyNode,
originalNodes += count
}
// Apply collection-level filters
if opts.Collection != nil {
colFilters := opts.Collection.Filters
if colFilters == nil {
colFilters = []model.FilterRule{}
}
allProxies = filter.ApplyFilters(allProxies, colFilters, opts.Settings, filter.FilterContext{
TargetPlatform: opts.Target,
CollectionId: opts.Collection.ID,
})
// Apply collection-level rename if enabled
if opts.Collection != nil && opts.Collection.RenameEnabled {
allProxies = filter.RenameCollectionNodes(allProxies, opts.Collection.RenameOptions)
}
// Ensure unique names
-2
View File
@@ -93,7 +93,6 @@ func TestBuildSubscriptionResult_Collection(t *testing.T) {
ID: "col1",
Name: "Collection",
SourceIds: []string{"src1", "src2"},
Filters: []model.FilterRule{},
TemplateId: "acl4ssr-mihomo",
Enabled: true,
Meta: map[string]any{},
@@ -126,7 +125,6 @@ func TestBuildSubscriptionResult_CollectionIgnoreFailed(t *testing.T) {
ID: "col1",
Name: "Collection",
SourceIds: []string{"src1", "src2"},
Filters: []model.FilterRule{},
TemplateId: "acl4ssr-mihomo",
IgnoreFailed: true,
Enabled: true,
+184 -18
View File
@@ -1,46 +1,155 @@
package util
import (
"net"
"regexp"
"strings"
"github.com/peterqiu0516/sub-store/internal/geoip"
)
// Flag detection rules: [regex, emoji].
// Flag detection rules: [regex, emoji, countryName, cityExtractor].
// Per review-resolution #16: Go RE2 supports \p{Regional_Indicator} and \uFE0F.
var flagRules []flagRule
type flagRule struct {
re *regexp.Regexp
emoji string
type geoRule struct {
re *regexp.Regexp
emoji string
countryName string
countryNameCN string
cityPatterns []cityPattern
}
type cityPattern struct {
re *regexp.Regexp
name string
nameCN string
}
var geoRules []geoRule
func init() {
rules := []struct {
pattern string
emoji string
pattern string
emoji string
countryName string
countryNameCN string
cities []struct{ pattern, name, nameCN string }
}{
{`香港|港|hong\s*kong|\bhk\b`, "🇭🇰"},
{`台湾|台灣|taiwan|\btw\b`, "🇹🇼"},
{`新加坡|狮城|獅城|singapore|\bsg\b`, "🇸🇬"},
{`日本|东京|東京|大阪|japan|tokyo|osaka|\bjp\b`, "🇯🇵"},
{`美国|美國|洛杉矶|洛杉磯|纽约|紐約|united\s*states|los\s*angeles|new\s*york|\bus\b|\busa\b`, "🇺🇸"},
{`英国|英國|伦敦|倫敦|united\s*kingdom|london|\buk\b`, "🇬🇧"},
{`德国|德國|法兰克福|法蘭克福|germany|frankfurt|\bde\b`, "🇩🇪"},
{`韩国|韓國|首尔|首爾|korea|seoul|\bkr\b`, "🇰🇷"},
{
pattern: `香港|港|hong\s*kong|\bhk\b`,
emoji: "🇭🇰",
countryName: "Hong Kong",
countryNameCN: "香港",
cities: []struct{ pattern, name, nameCN string }{
{`香港|Hong\s*Kong|HK`, "Hong Kong", "香港"},
},
},
{
pattern: `台湾|台灣|taiwan|\btw\b`,
emoji: "🇹🇼",
countryName: "Taiwan",
countryNameCN: "台湾",
cities: []struct{ pattern, name, nameCN string }{
{`台北|臺北|Taipei`, "Taipei", "台北"},
{`高雄|Gaoxiong|Kaohsiung`, "Kaohsiung", "高雄"},
},
},
{
pattern: `新加坡|狮城|獅城|singapore|\bsg\b`,
emoji: "🇸🇬",
countryName: "Singapore",
countryNameCN: "新加坡",
cities: []struct{ pattern, name, nameCN string }{
{`新加坡|Singapore|SG`, "Singapore", "新加坡"},
},
},
{
pattern: `日本|东京|東京|大阪|japan|tokyo|osaka|\bjp\b`,
emoji: "🇯🇵",
countryName: "Japan",
countryNameCN: "日本",
cities: []struct{ pattern, name, nameCN string }{
{`东京|東京|Tokyo`, "Tokyo", "东京"},
{`大阪|Osaka`, "Osaka", "大阪"},
},
},
{
pattern: `美国|美國|洛杉矶|洛杉磯|纽约|紐約|united\s*states|los\s*angeles|new\s*york|\bus\b|\busa\b`,
emoji: "🇺🇸",
countryName: "United States",
countryNameCN: "美国",
cities: []struct{ pattern, name, nameCN string }{
{`洛杉矶|洛杉磯|Los\s*Angeles|LA`, "Los Angeles", "洛杉矶"},
{`纽约|紐約|New\s*York|NYC`, "New York", "纽约"},
{`硅谷|Silicon\s*Valley|San\s*Jose|SJC`, "San Jose", "硅谷"},
{`西雅图|Seattle`, "Seattle", "西雅图"},
{`芝加哥|Chicago`, "Chicago", "芝加哥"},
{`达拉斯|Dallas`, "Dallas", "达拉斯"},
},
},
{
pattern: `英国|英國|伦敦|倫敦|united\s*kingdom|london|\buk\b`,
emoji: "🇬🇧",
countryName: "United Kingdom",
countryNameCN: "英国",
cities: []struct{ pattern, name, nameCN string }{
{`伦敦|倫敦|London`, "London", "伦敦"},
},
},
{
pattern: `德国|德國|法兰克福|法蘭克福|germany|frankfurt|\bde\b`,
emoji: "🇩🇪",
countryName: "Germany",
countryNameCN: "德国",
cities: []struct{ pattern, name, nameCN string }{
{`法兰克福|法蘭克福|Frankfurt`, "Frankfurt", "法兰克福"},
},
},
{
pattern: `韩国|韓國|首尔|首爾|korea|seoul|\bkr\b`,
emoji: "🇰🇷",
countryName: "South Korea",
countryNameCN: "韩国",
cities: []struct{ pattern, name, nameCN string }{
{`首尔|首爾|Seoul`, "Seoul", "首尔"},
},
},
}
for _, r := range rules {
re, err := regexp.Compile("(?i)" + r.pattern)
if err != nil {
continue
}
flagRules = append(flagRules, flagRule{re: re, emoji: r.emoji})
var cps []cityPattern
for _, c := range r.cities {
cre, err := regexp.Compile("(?i)" + c.pattern)
if err != nil {
continue
}
cps = append(cps, cityPattern{re: cre, name: c.name, nameCN: c.nameCN})
}
geoRules = append(geoRules, geoRule{
re: re,
emoji: r.emoji,
countryName: r.countryName,
countryNameCN: r.countryNameCN,
cityPatterns: cps,
})
}
}
// GeoInfo holds detected geographic information from a proxy name.
type GeoInfo struct {
Flag string // emoji flag
CountryName string // English country name
CountryCN string // Chinese country name
City string // English city name (may be empty)
CityCN string // Chinese city name (may be empty)
}
// DetectFlag returns the flag emoji for a name, or 🏳️ if no match.
func DetectFlag(name string) string {
text := strings.ToLower(name)
for _, rule := range flagRules {
for _, rule := range geoRules {
if rule.re.MatchString(text) {
return rule.emoji
}
@@ -48,6 +157,63 @@ func DetectFlag(name string) string {
return "🏳️"
}
// DetectGeo returns full geographic info (flag + country + city) from a name.
// It tries GeoIP lookup on the server address first, falling back to
// name-based regex matching.
func DetectGeo(name string) GeoInfo {
return detectGeoFromName(name)
}
// DetectGeoWithServer returns geographic info by looking up the proxy's
// server address via GeoIP first, falling back to name-based detection.
func DetectGeoWithServer(name, server string) GeoInfo {
// Try GeoIP lookup first
if server != "" {
host := server
// Strip port if present
if h, _, err := net.SplitHostPort(server); err == nil {
host = h
}
if result := geoip.LookupHost(host); result != nil {
return GeoInfo{
Flag: geoip.CountryEmoji(result.CountryCode),
CountryName: result.CountryName,
CountryCN: result.CountryNameCN,
City: result.CityName,
CityCN: result.CityNameCN,
}
}
}
// Fallback to name-based detection
return detectGeoFromName(name)
}
// detectGeoFromName does name-based regex matching for geo detection.
func detectGeoFromName(name string) GeoInfo {
text := strings.ToLower(name)
for _, rule := range geoRules {
if rule.re.MatchString(text) {
city := ""
cityCN := ""
for _, cp := range rule.cityPatterns {
if cp.re.MatchString(text) {
city = cp.name
cityCN = cp.nameCN
break
}
}
return GeoInfo{
Flag: rule.emoji,
CountryName: rule.countryName,
CountryCN: rule.countryNameCN,
City: city,
CityCN: cityCN,
}
}
}
return GeoInfo{}
}
// removeFlagRe matches leading flag emoji sequences and whitespace.
// Regional Indicator letters are U+1F1E6–U+1F1FF; ZWJ is U+200D; VS16 is U+FE0F.
var removeFlagRe = regexp.MustCompile(`^[\x{1F1E6}-\x{1F1FF}\x{FE0F}\x{200D}\s]+`)