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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
-1
File diff suppressed because one or more lines are too long
-37
File diff suppressed because one or more lines are too long
+37
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]+`)
|
||||
|
||||
Reference in New Issue
Block a user