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
+1
View File
@@ -19,6 +19,7 @@ go.work.sum
/data/*.db
/data/*.db-shm
/data/*.db-wal
/data/*.mmdb
# Production config (keep .example, ignore real secrets)
/config/config.yaml
/config/config.local.yaml
+20
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@@ -16,6 +17,7 @@ import (
"github.com/peterqiu0516/sub-store/internal/config"
"github.com/peterqiu0516/sub-store/internal/database"
"github.com/peterqiu0516/sub-store/internal/geoip"
"github.com/peterqiu0516/sub-store/internal/handler"
"github.com/peterqiu0516/sub-store/internal/middleware"
)
@@ -54,6 +56,24 @@ func RunServer(cfg *config.Config) error {
return err
}
// Ensure GeoIP database exists (download in background if missing)
mmdbPath := filepath.Join(filepath.Dir(cfg.Database.Path), "GeoLite2-City.mmdb")
geoip.SetMmdbPath(mmdbPath)
if _, err := os.Stat(mmdbPath); err != nil {
logrus.Info("GeoIP database not found, downloading in background...")
go func() {
if err := geoip.DownloadMmdb(mmdbPath); err != nil {
logrus.WithError(err).Warn("GeoIP download failed, name-based detection will be used as fallback")
} else {
logrus.Info("GeoIP database downloaded successfully")
// Reload the reader
geoip.SetMmdbPath(mmdbPath)
}
}()
} else {
logrus.Info("GeoIP database found")
}
return startFiber(cfg, db)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sub-Store</title>
<script src="/config.js"></script>
<script type="module" crossorigin src="/assets/index-CPGYuQco.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkTyHILH.css">
<script type="module" crossorigin src="/assets/index-CgvGHl8V.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-s7owp4VX.css">
</head>
<body class="bg-gray-50 text-gray-900">
<div id="app"></div>
+37 -14
View File
@@ -20,7 +20,7 @@
<span>原始节点: {{ nodeCounts[col.id]?.original ?? '-' }}</span>
<span>处理后: {{ nodeCounts[col.id]?.processed ?? '-' }}</span>
<span>订阅源: {{ col.sourceIds?.length || 0 }}</span>
<span>过滤器: {{ col.filters?.length || 0 }}</span>
<span v-if="col.renameEnabled" class="text-purple-500">重命名已开启</span>
<span>模板: {{ col.templateId || 'default' }}</span>
<span v-if="col.ignoreFailed">忽略失败</span>
</div>
@@ -114,18 +114,23 @@
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.ignoreFailed" class="rounded" /> 忽略失败源
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.renameEnabled" class="rounded" /> 节点重命名
</label>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-sm font-medium">过滤器</label>
<button class="text-xs text-primary-600 hover:underline" @click="form.filters.push({ type: 'include', pattern: '' })">+ 添加</button>
</div>
<div v-for="(f, i) in form.filters" :key="i" class="flex gap-1 mb-1.5">
<select v-model="f.type" class="px-2 py-1 border rounded text-xs w-28">
<option v-for="t in filterTypes" :key="t" :value="t">{{ t }}</option>
</select>
<input v-model="f.pattern" placeholder="pattern" class="flex-1 px-2 py-1 border rounded text-xs" />
<button class="px-2 py-1 text-xs text-red-500 hover:bg-red-50 rounded" @click="form.filters.splice(i,1)">×</button>
<div v-if="form.renameEnabled" class="rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-600">
开启后所有节点将按以下格式重命名<br>
<code class="text-blue-800">[国旗] [别名] [国家] [城市] [序号]</code><br>
点击下方按钮可切换各字段的显示默认全开
<div class="flex flex-wrap gap-1.5 mt-2">
<button type="button" v-for="opt in renameOptionList" :key="opt.key"
class="px-2.5 py-1 rounded-full text-xs font-medium border transition-colors"
:class="form.renameOptions[opt.key]
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-gray-400'"
@click="form.renameOptions[opt.key] = !form.renameOptions[opt.key]">
{{ opt.label }}
</button>
</div>
</div>
</div>
@@ -169,8 +174,18 @@ const confirmDialog = ref({ show: false, title: '', message: '', danger: false,
const filterTypes = ['include', 'exclude', 'rename', 'dedupe', 'sort', 'delete-field', 'flag', 'quick', 'resolve', 'custom']
const renameOptionList = [
{ key: 'flag', label: '国旗' },
{ key: 'alias', label: '别名' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' },
{ key: 'index', label: '序号' },
]
const defaultRenameOptions = () => ({ flag: true, alias: true, country: true, city: true, index: true })
const emptyForm = () => ({
name: '', sourceIds: [], filters: [], templateId: '', ignoreFailed: true, enabled: true
name: '', sourceIds: [], renameEnabled: false, renameOptions: defaultRenameOptions(), templateId: '', ignoreFailed: true, enabled: true
})
const form = reactive(emptyForm())
@@ -217,10 +232,18 @@ function openCreate() {
}
function editCol(col) {
const ro = col.renameOptions || {}
Object.assign(form, {
name: col.id,
sourceIds: [...(col.sourceIds || [])],
filters: JSON.parse(JSON.stringify(col.filters || [])),
renameEnabled: col.renameEnabled === true,
renameOptions: {
flag: ro.flag !== false,
alias: ro.alias !== false,
country: ro.country !== false,
city: ro.city !== false,
index: ro.index !== false,
},
templateId: col.templateId || '',
ignoreFailed: col.ignoreFailed !== false,
enabled: col.enabled !== false,
+19 -6
View File
@@ -14,6 +14,7 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ src.name }}</span>
<span v-if="src.alias" class="px-1.5 py-0.5 rounded text-xs bg-purple-100 text-purple-700">{{ src.alias }}</span>
<span class="px-1.5 py-0.5 rounded text-xs"
:class="src.type === 'remote' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'">{{ src.type }}</span>
<span v-if="!src.enabled" class="px-1.5 py-0.5 rounded text-xs bg-red-100 text-red-700">已禁用</span>
@@ -22,6 +23,7 @@
<div class="flex gap-2 mt-1 text-xs text-gray-400">
<span>原始节点: {{ nodeCounts[src.id]?.original ?? '-' }}</span>
<span>处理后: {{ nodeCounts[src.id]?.processed ?? '-' }}</span>
<span v-if="src.alias">别名: {{ src.alias }}</span>
<span>过滤器: {{ src.filters?.length || 0 }}</span>
<span v-if="src.createdAt">创建: {{ formatTime(src.createdAt) }}</span>
</div>
@@ -87,11 +89,19 @@
<!-- Create/Edit Modal -->
<Modal v-model="showModal" :title="editing ? '编辑订阅源' : '新建订阅源'">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">名称 / ID</label>
<input v-model="form.name" :disabled="editing" placeholder="my-sub"
class="w-full px-3 py-2 border rounded-lg text-sm disabled:bg-gray-100" />
<p class="text-xs text-gray-400 mt-0.5">仅支持小写字母数字下划线连字符</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium mb-1">名称 / ID</label>
<input v-model="form.name" :disabled="editing" placeholder="my-sub"
class="w-full px-3 py-2 border rounded-lg text-sm disabled:bg-gray-100" />
<p class="text-xs text-gray-400 mt-0.5">小写字母数字下划线连字符</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">别名 (Alias)</label>
<input v-model="form.alias" placeholder="如:机场A"
class="w-full px-3 py-2 border rounded-lg text-sm" />
<p class="text-xs text-gray-400 mt-0.5">合集重命名时作为节点标识</p>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">类型</label>
@@ -225,7 +235,7 @@ const filterGuides = [
]
const emptyForm = () => ({
name: '', type: 'remote', url: '', content: '', enabled: true, filters: []
name: '', alias: '', type: 'remote', url: '', content: '', enabled: true, filters: []
})
const form = reactive(emptyForm())
@@ -305,6 +315,7 @@ function sourcePreviewPayload(src) {
return {
id: src.id,
name: src.name,
alias: src.alias,
type: src.type,
url: src.url,
content: src.content,
@@ -321,6 +332,7 @@ function openCreate() {
function editSource(src) {
Object.assign(form, {
name: src.id,
alias: src.alias || '',
type: src.type || 'remote',
url: src.url || '',
content: src.content || '',
@@ -339,6 +351,7 @@ async function save() {
try {
const payload = {
name: form.name,
alias: form.alias,
type: form.type,
url: form.url,
content: form.content,
+1
View File
@@ -24,6 +24,7 @@ require (
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pressly/goose/v3 v3.27.3 // indirect
+2
View File
@@ -45,6 +45,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+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+1F1E6U+1F1FF; ZWJ is U+200D; VS16 is U+FE0F.
var removeFlagRe = regexp.MustCompile(`^[\x{1F1E6}-\x{1F1FF}\x{FE0F}\x{200D}\s]+`)