From 90e53aa754213790686fdfa30e102361ded50353 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 27 Jul 2026 14:38:13 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20sub-store=20Go=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 41 + AGENTS.md | 55 + cmd/migrate.go | 69 ++ cmd/root.go | 20 + cmd/server.go | 95 ++ cmd/version.go | 19 + config/config.example.yaml | 33 + config/config.test.yaml | 33 + docs/architecture-analysis.md | 696 +++++++++++++ docs/backend-dev-plan.md | 950 ++++++++++++++++++ docs/frontend-plan.md | 635 ++++++++++++ docs/review-resolutions.md | 652 ++++++++++++ fixture/subs.md | 8 + go.mod | 56 ++ go.sum | 108 ++ internal/config/config.go | 118 +++ internal/database/cache_repo.go | 123 +++ internal/database/collection_repo.go | 160 +++ internal/database/db.go | 48 + internal/database/grant_repo.go | 241 +++++ internal/database/helpers.go | 28 + internal/database/migrations.go | 20 + internal/database/migrations/0001_initial.sql | 41 + .../0002_compatibility_resources.sql | 29 + .../database/migrations/0003_source_cache.sql | 11 + internal/database/recycle_repo.go | 129 +++ internal/database/repo_test.go | 336 +++++++ internal/database/settings_repo.go | 69 ++ internal/database/source_repo.go | 157 +++ internal/database/template_repo.go | 121 +++ internal/filter/custom.go | 281 ++++++ internal/filter/dedupe.go | 190 ++++ internal/filter/delete_field.go | 52 + internal/filter/filter_test.go | 127 +++ internal/filter/flag.go | 57 ++ internal/filter/include_exclude.go | 37 + internal/filter/pipeline.go | 59 ++ internal/filter/quick.go | 182 ++++ internal/filter/rename.go | 35 + internal/filter/resolve.go | 340 +++++++ internal/filter/sort.go | 145 +++ internal/filter/util.go | 176 ++++ internal/handler/crud.go | 665 ++++++++++++ internal/handler/download_tools.go | 673 +++++++++++++ internal/handler/env.go | 33 + internal/handler/filter_bridge.go | 18 + internal/handler/routes.go | 127 +++ internal/handler/settings.go | 203 ++++ internal/handler/share_recycle.go | 221 ++++ internal/handler/yaml.go | 15 + internal/middleware/auth.go | 38 + internal/middleware/cors.go | 54 + internal/middleware/security.go | 108 ++ internal/model/response.go | 53 + internal/model/target.go | 198 ++++ internal/model/types.go | 128 +++ internal/proxy/client_parser.go | 651 ++++++++++++ internal/proxy/format.go | 156 +++ internal/proxy/normalize.go | 186 ++++ internal/proxy/parser_test.go | 278 +++++ internal/proxy/uri_parser.go | 573 +++++++++++ internal/render/dispatch.go | 84 ++ internal/render/egern.go | 176 ++++ internal/render/json.go | 31 + internal/render/loon.go | 99 ++ internal/render/mihomo.go | 325 ++++++ internal/render/qx.go | 93 ++ internal/render/render_test.go | 182 ++++ internal/render/singbox.go | 356 +++++++ internal/render/surfboard.go | 18 + internal/render/surge.go | 210 ++++ internal/render/textutil.go | 346 +++++++ internal/render/uri.go | 346 +++++++ internal/rules/converter.go | 250 +++++ internal/service/subscription.go | 593 +++++++++++ internal/template/builtin.go | 370 +++++++ internal/util/flag.go | 88 ++ internal/util/ip.go | 61 ++ internal/util/limits.go | 13 + internal/util/path.go | 164 +++ internal/util/random.go | 8 + internal/util/token.go | 112 +++ main.go | 7 + scripts/e2e-real-subs.sh | 87 ++ scripts/e2e-test.sh | 92 ++ 85 files changed, 15271 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 cmd/migrate.go create mode 100644 cmd/root.go create mode 100644 cmd/server.go create mode 100644 cmd/version.go create mode 100644 config/config.example.yaml create mode 100644 config/config.test.yaml create mode 100644 docs/architecture-analysis.md create mode 100644 docs/backend-dev-plan.md create mode 100644 docs/frontend-plan.md create mode 100644 docs/review-resolutions.md create mode 100644 fixture/subs.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/database/cache_repo.go create mode 100644 internal/database/collection_repo.go create mode 100644 internal/database/db.go create mode 100644 internal/database/grant_repo.go create mode 100644 internal/database/helpers.go create mode 100644 internal/database/migrations.go create mode 100644 internal/database/migrations/0001_initial.sql create mode 100644 internal/database/migrations/0002_compatibility_resources.sql create mode 100644 internal/database/migrations/0003_source_cache.sql create mode 100644 internal/database/recycle_repo.go create mode 100644 internal/database/repo_test.go create mode 100644 internal/database/settings_repo.go create mode 100644 internal/database/source_repo.go create mode 100644 internal/database/template_repo.go create mode 100644 internal/filter/custom.go create mode 100644 internal/filter/dedupe.go create mode 100644 internal/filter/delete_field.go create mode 100644 internal/filter/filter_test.go create mode 100644 internal/filter/flag.go create mode 100644 internal/filter/include_exclude.go create mode 100644 internal/filter/pipeline.go create mode 100644 internal/filter/quick.go create mode 100644 internal/filter/rename.go create mode 100644 internal/filter/resolve.go create mode 100644 internal/filter/sort.go create mode 100644 internal/filter/util.go create mode 100644 internal/handler/crud.go create mode 100644 internal/handler/download_tools.go create mode 100644 internal/handler/env.go create mode 100644 internal/handler/filter_bridge.go create mode 100644 internal/handler/routes.go create mode 100644 internal/handler/settings.go create mode 100644 internal/handler/share_recycle.go create mode 100644 internal/handler/yaml.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/middleware/security.go create mode 100644 internal/model/response.go create mode 100644 internal/model/target.go create mode 100644 internal/model/types.go create mode 100644 internal/proxy/client_parser.go create mode 100644 internal/proxy/format.go create mode 100644 internal/proxy/normalize.go create mode 100644 internal/proxy/parser_test.go create mode 100644 internal/proxy/uri_parser.go create mode 100644 internal/render/dispatch.go create mode 100644 internal/render/egern.go create mode 100644 internal/render/json.go create mode 100644 internal/render/loon.go create mode 100644 internal/render/mihomo.go create mode 100644 internal/render/qx.go create mode 100644 internal/render/render_test.go create mode 100644 internal/render/singbox.go create mode 100644 internal/render/surfboard.go create mode 100644 internal/render/surge.go create mode 100644 internal/render/textutil.go create mode 100644 internal/render/uri.go create mode 100644 internal/rules/converter.go create mode 100644 internal/service/subscription.go create mode 100644 internal/template/builtin.go create mode 100644 internal/util/flag.go create mode 100644 internal/util/ip.go create mode 100644 internal/util/limits.go create mode 100644 internal/util/path.go create mode 100644 internal/util/random.go create mode 100644 internal/util/token.go create mode 100644 main.go create mode 100644 scripts/e2e-real-subs.sh create mode 100644 scripts/e2e-test.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac9e500 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# === Go === +# Compiled binary +/sub-store +*.exe +*.exe~ +*.dll +*.so +*.dylib +# Test binary +*.test +# Output of go coverage +*.out +# Go workspace +go.work +go.work.sum + +# === SQLite / runtime data === +# Local runtime DB (keep example/test configs, ignore actual data) +/data/*.db +/data/*.db-shm +/data/*.db-wal +# Production config (keep .example, ignore real secrets) +/config/config.yaml +/config/config.local.yaml + +# === Reference repo (cloned external project, not part of this codebase) === +/ref/ + +# === CodeGraph index === +/.codegraph/ + +# === Editor / IDE === +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# === Logs === +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a40fc07 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +SUB STORE 项目重构 + +`ref/sub-store-cloudflare` 是一个使用cloudflare部署的机场订阅转换助手,我们需要参考原项目,完成GO语言、SQLITE数据库的重构。 + +## 项目约束 + +- **全新项目,无历史数据包袱**:不存在存量用户、存量配置、存量过滤器需要迁移。所有"向后兼容"、"存量 script 过滤器迁移"、"process→FilterRule 转换"等问题均不需要考虑——直接按 Go 版自身的设计实现即可,不必复刻原项目的兼容逻辑。 +- **ProxyNode 类型自由选择**:不强制与原项目 `Record` 保持一致,按 Go 最佳实践选择 struct 或 map。 +- **前端适配后端**:前端尚未实现,后端 API 设计可自由定义,不需要兼容原前端格式。 + +## 开发决议 + +开发前必读 [`docs/review-resolutions.md`](docs/review-resolutions.md) — 45 条 review 问题的逐条决议(ProxyNode 类型、token 双哈希、SQLite PRAGMA、sing-box 完整结构等)。开发中不得偏离已定决议;如需变更,先改本文档再改代码。 + +**关键决议摘要**: +- ProxyNode = `map[string]any`,深拷贝用 json.Marshal/Unmarshal +- token 校验先 SHA-256 再 ConstantTimeCompare(防长度泄露时序攻击) +- SQLite: WAL + busy_timeout=5000 + foreign_keys=ON + MaxOpenConns=1 +- CSP 收紧为 `script-src 'self'`(无 unsafe-eval) +- custom 规则链上限 32 条,替代 JS script 过滤器 +- modernc.org/sqlite(纯 Go),接受写性能 tradeoff + + +## CodeGraph + +This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot. + +### When to prefer codegraph over native search + +Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open. + +| Question | Tool | +|---|---| +| "Where is X defined?" / "Find symbol named X" | `codegraph_search` | +| "What calls function Y?" | `codegraph_callers` | +| "What does Y call?" | `codegraph_callees` | +| "What would break if I changed Z?" | `codegraph_impact` | +| "Show me Y's signature / source / docstring" | `codegraph_node` | +| "Give me focused context for a task/area" | `codegraph_context` | +| "Survey an unfamiliar module/topic" | `codegraph_explore` | +| "What files exist under path/" | `codegraph_files` | +| "Is the index healthy?" | `codegraph_status` | + +### Rules of thumb + +- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context. +- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call. +- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call. +- **`codegraph_explore` is the heavy hitter** for unfamiliar areas — it returns full source from all relevant files in one call, but is token-heavy. If your harness supports parallel subagents (e.g., Claude Code's Task tool), spawn one for explore-class questions to keep main session context clean. +- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn. + +### If `.codegraph/` doesn't exist + +The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"* + diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 0000000..dad0cad --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/peterqiu0516/sub-store/internal/config" + "github.com/peterqiu0516/sub-store/internal/database" +) + +//go:generate echo "embed handled in migrate.go" + +var migrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Run database migrations", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configFile) + if err != nil { + return err + } + db, err := database.InitDB(cfg.Database.Path) + if err != nil { + return err + } + defer db.Close() + return database.RunMigrations(db) + }, +} + +var configFile string + +func init() { + rootCmd.AddCommand(migrateCmd) + migrateCmd.Flags().StringVarP(&configFile, "config", "c", "", "config file path") + + serveCmd.Flags().StringVarP(&configFile, "config", "c", "", "config file path") + rootCmd.AddCommand(serveCmd) +} + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the Sub-Store HTTP server", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configFile) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return RunServer(cfg) + }, +} + +func RunServer(cfg *config.Config) error { + db, err := database.InitDB(cfg.Database.Path) + if err != nil { + return err + } + defer db.Close() + + if err := database.RunMigrations(db); err != nil { + return err + } + + return startFiber(cfg, db) +} + +// startFiber is implemented in server.go (same package). diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..feb45dc --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "sub-store", + Short: "Sub-Store — subscription aggregation and proxy management", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..0f4ae12 --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/sirupsen/logrus" + "github.com/jmoiron/sqlx" + + "github.com/peterqiu0516/sub-store/internal/config" + "github.com/peterqiu0516/sub-store/internal/database" + "github.com/peterqiu0516/sub-store/internal/handler" + "github.com/peterqiu0516/sub-store/internal/middleware" +) + +func startFiber(cfg *config.Config, db *sqlx.DB) error { + logrus.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + logrus.SetLevel(logrus.InfoLevel) + + // Start cache cleaner — review-resolution #32 + cacheCtx, cacheCancel := context.WithCancel(context.Background()) + defer cacheCancel() + cacheInterval := cfg.Fetcher.CacheTTL + if cacheInterval <= 0 { + cacheInterval = 300 * time.Second + } + database.StartCacheCleaner(cacheCtx, db, cacheInterval) + + app := fiber.New(fiber.Config{ + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + BodyLimit: cfg.Server.BodyLimit, + ErrorHandler: func(c fiber.Ctx, err error) error { + logrus.WithError(err).WithFields(logrus.Fields{ + "method": c.Method(), + "path": c.Path(), + }).Error("request error") + return c.Status(500).JSON(fiber.Map{ + "status": "failed", + "error": fiber.Map{"code": 500, "message": "Internal server error"}, + }) + }, + }) + + // Global middleware + app.Use(middleware.SecurityHeaders()) + app.Use(middleware.HandleOptions()) + app.Use(middleware.CORS("")) + app.Use(middleware.DownloadHostIsolation(cfg.Auth.DownloadHosts)) + + // Register routes + handler.RegisterRoutes(app, cfg, db) + + // Health check + app.Get("/health", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "ok"}) + }) + + // Start server + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + logrus.Infof("Starting %s on %s", cfg.App.Name, addr) + + go func() { + if err := app.Listen(addr); err != nil { + logrus.WithError(err).Error("server failed") + } + }() + + // Graceful shutdown — review-resolution #34 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logrus.Info("Shutting down server...") + cacheCancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + go func() { + <-shutdownCtx.Done() + }() + + if err := app.Shutdown(); err != nil { + logrus.WithError(err).Error("shutdown error") + } + logrus.Info("Server stopped") + return nil +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..c89677e --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the Sub-Store version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("sub-store v1.0.0") + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100644 index 0000000..689ab31 --- /dev/null +++ b/config/config.example.yaml @@ -0,0 +1,33 @@ +server: + host: "0.0.0.0" + port: 3000 + read_timeout: 30s + write_timeout: 60s + body_limit: 4194304 # 4 MiB + +database: + path: "./data/sub-store.db" + +auth: + admin_token: "CHANGE_ME_ADMIN_TOKEN" + download_token: "CHANGE_ME_DOWNLOAD_TOKEN" + download_hosts: [] + +fetcher: + default_timeout: 30s + default_user_agent: "clash.meta/v1.19.24" + default_flow_user_agent: "clash.meta/v1.19.24" + concurrency: 3 + concurrency_wait: 0s + cache_ttl: 300s + cache_stale_on_error: true + max_source_urls: 8 + max_response_bytes: 2097152 # 2 MiB + max_total_bytes: 12582912 # 12 MiB + +recycle: + max_entries: 50 + +app: + name: "Sub-Store" + version: "1.0.0" diff --git a/config/config.test.yaml b/config/config.test.yaml new file mode 100644 index 0000000..54db3ea --- /dev/null +++ b/config/config.test.yaml @@ -0,0 +1,33 @@ +server: + host: "127.0.0.1" + port: 18300 + read_timeout: 30s + write_timeout: 60s + body_limit: 4194304 + +database: + path: "./data/sub-store-test.db" + +auth: + admin_token: "test-admin-token" + download_token: "test-download-token" + download_hosts: [] + +fetcher: + default_timeout: 30s + default_user_agent: "clash.meta/v1.19.24" + default_flow_user_agent: "clash.meta/v1.19.24" + concurrency: 3 + concurrency_wait: 0s + cache_ttl: 300s + cache_stale_on_error: true + max_source_urls: 8 + max_response_bytes: 2097152 + max_total_bytes: 12582912 + +recycle: + max_entries: 50 + +app: + name: "Sub-Store" + version: "1.0.0" diff --git a/docs/architecture-analysis.md b/docs/architecture-analysis.md new file mode 100644 index 0000000..62fcde8 --- /dev/null +++ b/docs/architecture-analysis.md @@ -0,0 +1,696 @@ +# Sub-Store Cloudflare 原项目技术实现架构分析 + +> 参考路径: `ref/sub-store-cloudflare/` +> 版本: 1.1.0 (AGPL-3.0-or-later) +> 仓库: github.com/realchendahuang/sub-store-cloudflare + +--- + +## 1. 项目定位 + +Sub-Store Cloudflare 是一个 **Cloudflare 原生的机场订阅聚合与路由模板管理工具**,部署形态为 Cloudflare Workers + Static Assets + D1 + Worker Secrets。核心能力: + +- **订阅源聚合**:管理多个远程/本地订阅源,合并为集合 (Collection) +- **节点处理**:解析 13 种代理协议 URI、JSON/YAML 数组、客户端配置行,执行过滤/排序/去重/重命名等操作 +- **多目标输出**:转换为 mihomo / sing-box / surge / loon / qx / egern / shadowrocket / v2ray / uri 等 13 种客户端格式 +- **路由模板**:内置 6 套 ACL4SSR / Loyalsoldier / AI+Streaming mihomo 模板,支持自定义 +- **规则转换**:将分流规则在 mihomo/surge/loon/qx 之间互转 +- **分享/回收站**:scoped download grants(限定资源+目标+过期的分享令牌)、有界回收站(50 条上限) + +**非功能边界**(AGENTS.md 明确约束): +- 不使用 R2/KV/Durable Objects/Queues/Cron +- 不运行时 eval 脚本(脚本为构建时打包) +- 不存储/执行来自 D1/浏览器/远程 URL 的脚本源码 +- 远程源缓存使用 Workers Cache API(`caches.default`),非 D1 + +--- + +## 2. 整体架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Cloudflare Worker │ +│ (cloudflare/src/index.ts — Hono app, single entry) │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ /api/* │ │ /download/* │ │ Static Assets │ │ +│ │ (admin API) │ │ (public dl) │ │ (SPA fallback)│ │ +│ │ routes/api │ │ routes/dl │ │ ASSETS binding│ │ +│ └──────┬──────┘ └──────┬───────┘ └────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ lib/ 层 │ │ +│ │ store.ts subscription.ts rules.ts │ │ +│ │ (D1 CRUD) (核心管线) (规则转换) │ │ +│ │ defaults.ts targets.ts scripts.ts │ │ +│ │ (内置模板) (目标别名) (脚本注册表) │ │ +│ │ compatibility http.ts limits.ts read.ts │ │ +│ │ -resources.ts (token/CORS/Hdr) (常量) (流读) │ │ +│ └──────────────────────┬─────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────┼───────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ D1 (DB) │ │ Cache API │ │ Worker Secrets│ │ +│ │ 5 张表 │ │ caches. │ │ ADMIN_TOKEN │ │ +│ │ │ │ default │ │ DOWNLOAD_TOK │ │ +│ └────────────┘ └────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.1 Monorepo 结构 + +``` +sub-store-cloudflare/ +├── package.json # workspace root, pnpm@11.7, node>=22 +├── pnpm-workspace.yaml # packages: frontend, cloudflare +├── wrangler.jsonc # 顶层部署配置 (ASSETS + D1 + secrets) +├── cloudflare/ # Worker 后端 +│ ├── src/ +│ │ ├── index.ts # Hono app 入口 + 导出 ExportedHandler +│ │ ├── types.ts # 所有 TypeScript 类型定义 +│ │ ├── worker-configuration.d.ts # Wrangler 生成的 Env 接口 +│ │ ├── routes/ +│ │ │ ├── api.ts # /api/* 管理接口 (896 行) +│ │ │ └── download.ts # /download/* 公开下载接口 +│ │ ├── lib/ +│ │ │ ├── store.ts # D1 数据层 CRUD (644 行) +│ │ │ ├── subscription.ts # 核心订阅处理管线 (2430 行) +│ │ │ ├── rules.ts # 分流规则转换 +│ │ │ ├── defaults.ts # 6 套内置路由模板 +│ │ │ ├── targets.ts # 客户端目标别名归一化 +│ │ │ ├── scripts.ts # 构建时脚本注册表 + 运行时 +│ │ │ ├── compatibility-resources.ts # download_grants + recycle_bin +│ │ │ ├── http.ts # token 校验 / CORS / 安全头 +│ │ │ ├── limits.ts # 6 个字节/数量限制常量 +│ │ │ └── read.ts # 流式响应读取 (防 OOM) +│ │ └── generated/ # 构建时生成 (git ignored) +│ │ └── script-registry.ts +│ ├── migrations/ +│ │ ├── 0001_initial.sql +│ │ ├── 0002_runtime_schema_cleanup.sql +│ │ └── 0003_compatibility_resources.sql +│ └── package.json # hono, json5, yaml 三个运行时依赖 +├── frontend/ # Vue 3 + Vite SPA +│ └── src/ # Pinia stores, views, components +└── scripts/ # 构建时 Node.js 脚本 + ├── generate-script-registry.mjs # 扫描 config/scripts → registry + ├── install-cloudflare.mjs # 一键安装器 + ├── render-seed-sql.mjs # agent-setup → seed SQL + └── ... (15+ 检查/部署脚本) +``` + +--- + +## 3. 运行时环境与绑定 + +### 3.1 Worker Bindings (wrangler.jsonc) + +| 绑定 | 类型 | 用途 | +|------|------|------| +| `DB` | D1Database | 所有结构化配置数据 | +| `ASSETS` | Fetcher | Static Assets (前端 SPA, `run_worker_first: true`) | +| `SUB_STORE_ADMIN_TOKEN` | Secret (string) | `/api/*` 管理 API 鉴权 | +| `SUB_STORE_PUBLIC_DOWNLOAD_TOKEN` | Secret (string) | `/download/*` 公开下载鉴权 | +| `SUB_STORE_APP_NAME` | Var (string) | 应用显示名 | +| `SUB_STORE_PUBLIC_DOWNLOAD_HOSTS` | Var (string) | 逗号分隔的纯下载域名列表 | + +### 3.2 关键配置 + +- **`compatibility_date`**: `2026-07-08` +- **`compatibility_flags`**: `["nodejs_compat"]` +- **`assets.not_found_handling`**: `"single-page-application"` — 前端 SPA 路由兜底 +- **`assets.run_worker_first`**: `true` — Worker 先处理请求,未匹配才回退到静态资源 +- **`observability`**: enabled, head_sampling_rate=1 + +### 3.3 下载域名隔离机制 + +`index.ts` 的 `fetch` handler 在路由之前检查 hostname: +- 如果当前 hostname 在 `SUB_STORE_PUBLIC_DOWNLOAD_HOSTS` 列表中,且 pathname 不以 `/download/` 开头 → 返回 404 +- 效果:下载域名只能访问下载端点,管理 UI 不可达 + +--- + +## 4. 数据库 Schema (D1 / SQLite) + +### 4.1 表结构 + +**`sources`** — 订阅源 + +| 列 | 类型 | 说明 | +|----|------|------| +| id | TEXT PK | 1-64 字符 `[a-z0-9_-]` | +| name | TEXT | 显示名 | +| type | TEXT | `remote` / `local` | +| url | TEXT | 远程 URL(可多行,换行分隔,最多 8 个) | +| content | TEXT | local 源的原始内容 | +| enabled | INTEGER | 0/1 | +| filters_json | TEXT | FilterRule[] 的 JSON | +| meta_json | TEXT | 元数据 JSON(ua, cacheTtl, subUserinfo 等) | +| created_at | INTEGER | 毫秒时间戳,同时用于排序 | +| updated_at | INTEGER | 毫秒时间戳 | + +**`collections`** — 订阅集合 + +| 列 | 类型 | 说明 | +|----|------|------| +| id | TEXT PK | 同上 | +| name | TEXT | 显示名 | +| source_ids_json | TEXT | string[],空数组表示包含所有 enabled 源 | +| filters_json | TEXT | 集合级 FilterRule[] | +| template_id | TEXT | 关联的路由模板 ID | +| ignore_failed | INTEGER | 0/1,是否忽略失败的源 | +| enabled | INTEGER | 0/1 | +| meta_json | TEXT | 元数据 | +| created_at / updated_at | INTEGER | 同上 | + +**`templates`** — 路由模板(仅存自定义的,内置模板在代码中) + +| 列 | 类型 | 说明 | +|----|------|------| +| id | TEXT PK | 同上 | +| name | TEXT | 显示名 | +| target | TEXT | 目标客户端(默认 mihomo) | +| config_json | TEXT | RoutingTemplateConfig JSON | +| created_at / updated_at | INTEGER | 同上 | + +**`app_settings`** — 应用设置(单行,id="default") + +| 列 | 类型 | 说明 | +|----|------|------| +| id | TEXT PK | 固定 "default" | +| value_json | TEXT | 设置 JSON(深合并) | +| updated_at | INTEGER | | + +**`download_grants`** — 分享令牌(migration 0003) + +| 列 | 类型 | 说明 | +|----|------|------| +| id | TEXT PK | UUID | +| token_hash | TEXT UNIQUE | SHA-256 hex of token | +| resource_type | TEXT CHECK | `source` / `collection` | +| resource_id | TEXT | | +| target | TEXT | 限定的目标客户端,空=不限 | +| expires_at | INTEGER | 过期时间,NULL=永不过期 | +| enabled | INTEGER | 0/1 | +| created_at / updated_at | INTEGER | | +- 索引: `idx_download_grants_token_hash`, `idx_download_grants_resource` + +**`recycle_bin`** — 回收站(migration 0003) + +| 列 | 类型 | 说明 | +|----|------|------| +| id | TEXT PK | UUID | +| resource_type | TEXT CHECK | `source`/`collection`/`template`/`share` | +| resource_id | TEXT | | +| snapshot_json | TEXT | 删除时的完整快照 | +| deleted_at | INTEGER | | +- 索引: `idx_recycle_bin_deleted_at` +- 上限: 50 条(通过 `DELETE ... LIMIT -1 OFFSET 50` 自动裁剪) + +### 4.2 设计要点 + +- **排序用 created_at**:`sortSources` / `sortCollections` 通过 `UPDATE SET created_at = now + index` 重排序,列表查询 `ORDER BY created_at ASC`。同一 batch 中 `now + index` 保证顺序。 +- **UPSERT 模式**:所有写入用 `INSERT ... ON CONFLICT(id) DO UPDATE SET ...`,天然幂等。 +- **批量操作**:`importStorage` 使用 `env.DB.batch(statements)` 单事务导入。 +- **内置模板不入库**:migration 0002 主动删除旧版内置模板行,改为代码持有 (`BUILTIN_TEMPLATES` 数组),`listTemplates` 合并内置+自定义。 + +--- + +## 5. HTTP 路由设计 + +### 5.1 全局中间件 (index.ts) + +1. **全局错误处理**: `app.onError` → JSON `{status:"failed",error:{code:500,...}}` + 结构化日志 +2. **CORS preflight**: `OPTIONS *` 返回允许的方法和头 +3. **安全头**: `applySecurityHeaders` 对每个响应添加 CSP / Referrer-Policy / X-Content-Type-Options / X-Frame-Options / Permissions-Policy +4. **CORS**: `applyCorsHeaders` 根据 origin 白名单(空则不加 CORS 头) +5. **下载域名隔离**: fetch handler 前置 hostname 检查 + +### 5.2 管理 API (`/api/*`) + +**全局中间件**: +- `requireAdmin`: Bearer token / query `token` / `x-sub-store-token` 头 → SHA-256 + `timingSafeEqual` 与 `SUB_STORE_ADMIN_TOKEN` 比对 +- `bodyLimit`: 4 MiB (`MAX_API_BODY_BYTES`) + +**统一响应格式**: +- 成功: `{ "status": "success", "data": }` +- 失败: `{ "status": "failed", "error": { "code": , "message": } }` + +**端点清单**: + +| 方法 | 路径 | 功能 | +|------|------|------| +| GET | `/api/env` | 运行时环境信息(backend、version、feature flags) | +| GET | `/api/scripts` | 构建时脚本注册表元数据 | +| POST | `/api/proxy/parse` | 一次性代理内容转换 | +| POST | `/api/rule/parse` | 一次性分流规则转换 | +| GET/PATCH | `/api/settings` | 应用设置(GET 合并默认值) | +| GET/POST | `/api/storage` | 全量导出/导入(备份恢复) | +| GET/POST | `/api/sources` | 订阅源列表/创建 | +| PUT | `/api/sources` | 批量排序 | +| POST | `/api/sort/sources` | 排序(兼容旧接口) | +| GET/PATCH/DELETE | `/api/sources/:name` | 单源操作 | +| GET/POST | `/api/collections` | 集合列表/创建 | +| PUT | `/api/collections` | 批量排序 | +| GET/PATCH/DELETE | `/api/collections/:name` | 单集合操作 | +| GET/POST | `/api/templates` | 模板列表/创建 | +| GET/PATCH/DELETE | `/api/templates/:name` | 单模板操作(内置不可删改) | +| GET/POST | `/api/shares` | 分享令牌列表/创建 | +| PATCH/DELETE | `/api/shares/:id` | 令牌操作 | +| GET | `/api/recycle-bin` | 回收站列表 | +| DELETE | `/api/recycle-bin/:id` | 彻底删除 | +| POST | `/api/recycle-bin/:id/restore` | 恢复 | +| POST | `/api/utils/node-info` | IP 信息查询(代理转发 ipwho.is 等) | +| POST | `/api/preview/source` | 源预览(原始+处理后节点) | +| POST | `/api/preview/collection` | 集合预览 | +| GET | `/api/link/source/:name` | 生成下载链接 | +| GET | `/api/link/collection/:name` | 生成下载链接 | +| GET | `/api/source/flow/:name` | 流量信息(解析 subscription-userinfo 头) | + +### 5.3 下载 API (`/download/*`) + +**路由**: `GET /download/collection/:name/:target?/:token?` 和 `GET /download/source/:name/:target?/:token?` + +**鉴权**: +1. 先校验全局 `SUB_STORE_PUBLIC_DOWNLOAD_TOKEN`(timingSafeEqual) +2. 不通过则查 `download_grants` 表(token_hash 匹配 + enabled + 未过期 + resource/target 匹配) +3. 都不通过 → 403 + +**target 推断**: +- 显式: URL path param 或 query `target` +- 隐式: User-Agent 推断(sing-box/v2ray/surge/loon/egern/shadowrocket/quantumult/stash → 对应 target,默认 mihomo) + +**临时覆盖**: query 参数 `url` / `content` / `ua` 可临时覆盖源配置(不持久化),用于预览/调试。 + +**响应头**: content-type 按 target 分配(YAML / JSON / text/plain),`subscription-userinfo` / `profile-web-page-url` / `content-disposition` / `x-sub-store-cache` 透传。 + +--- + +## 6. 核心订阅处理管线 (subscription.ts) + +这是整个项目最核心的模块(2430 行),负责从订阅源到客户端格式的完整转换。 + +### 6.1 管线流程 + +``` +buildSubscriptionResult(options) + │ + ├─ getSources(options) + │ ├─ 单源模式: [options.source] + │ └─ 集合模式: 按 collection.sourceIds 过滤 options.sources + │ (sourceIds 为空 = 所有 enabled 源) + │ + ├─ loadProxyNodes(internal) ──────────────────────────────┐ + │ │ │ + │ ├─ 过滤 enabled 源 │ + │ │ │ + │ ├─ 并发任务 (runWithConcurrency / runSettledWithConcurrency) + │ │ 每个 source: │ + │ │ ├─ loadSubscriptionRaw(sub, settings, ua, runtime) + │ │ │ ├─ local: 直接返回 content + │ │ │ └─ remote: splitSourceUrls (最多8个URL) + │ │ │ 并发 fetchSubscriptionUrl (每个URL) + │ │ │ ├─ Cache API 查缓存 (cacheTtl, 默认300s) + │ │ │ ├─ fetch with UA + If-None-Match/Modified + │ │ │ ├─ 304 → 用缓存 (cacheStatus=refresh) + │ │ │ ├─ !ok → throw + │ │ │ ├─ 读 body (限 2MiB/URL, 总 12MiB) + │ │ │ ├─ 写缓存 (waitUntil, 非阻塞) + │ │ │ └─ error → stale cache if allowed + │ │ │ 合并多 URL 内容, decodeMaybeBase64 + │ │ │ │ + │ │ ├─ parseProxies(raw) ← 解析为 ProxyNode[] │ + │ │ └─ applyFilters(nodes, source.filters, ...) │ + │ │ (源级过滤) │ + │ │ │ + │ ├─ ignoreFailed=true: runSettled (失败源跳过) │ + │ │ ignoreFailed=false: runWithConcurrency (失败则整体报错) + │ │ │ + │ ├─ flat() 合并所有源节点 │ + │ ├─ applyFilters(allNodes, collection.filters, ...) │ + │ │ (集合级过滤) │ + │ └─ ensureUniqueProxyNames (重名加 -2, -3 后缀) │ + │ │ + ├─ renderBuildTarget(proxies, options) ─────────────────────┘ + │ 按 target 分发到对应渲染器 + │ + └─ selectResponseMetadata(internal) + 从第一个有效源提取 subscription-userinfo 等元数据 +``` + +### 6.2 代理协议解析 (parseProxies) + +输入内容自动检测格式: + +1. **JSON/JSON5 数组** (`[` 或 `{` 开头): `parseJsonProxies` — 支持 `{proxies: [...]}` 或顶层数组 +2. **YAML** (`proxies:` 开头): `parseYamlProxies` — 提取 `proxies` 字段 +3. **URI 行** (默认): `parseProxyLines` — 逐行解析 + +`decodeMaybeBase64` 先检测是否为结构化内容,否则尝试 base64 解码。 + +**支持的 URI 协议** (parseProxyUri): +- `vless://` — VLESS + Reality +- `vmess://` — VMess (base64 JSON) +- `trojan://` — Trojan +- `ss://` — Shadowsocks (base64 或明文 userinfo) +- `ssr://` — ShadowsocksR (base64) +- `hysteria://` / `hy://` — Hysteria v1 +- `hysteria2://` / `hy2://` — Hysteria v2 +- `tuic://` — TUIC v5 +- `anytls://` — AnyTLS +- `socks://` / `socks5://` / `socks5+tls://` — SOCKS5 +- `http://` / `https://` — HTTP proxy +- `wireguard://` / `wg://` — WireGuard + +**支持的客户端配置行** (parseClientProxyLine): +- **Quantumult X 格式**: `shadowsocks=host:port,...` / `vmess=host:port,...` +- **Surge/Loon 格式**: `name = ss,host,port,...` / `name = vmess,host,port,...` +- 支持协议: ss, ssr, vmess, vless, trojan, http, socks5, hysteria2, tuic, anytls, snell, ssh, h2-connect + +### 6.3 过滤器系统 (applyFilters) + +`FilterRule.type` 支持 11 种操作,按顺序串行执行: + +| type | 功能 | 关键字段 | +|------|------|----------| +| `include` | 保留匹配项 | `pattern` (正则), `field` (默认 name) | +| `exclude` | 排除匹配项 | 同上 | +| `rename` | 正则替换 | `pattern`, `replacement`, `field` | +| `delete-field` | 删除字段匹配内容 | `patterns[]` 或 `pattern`, `field` | +| `dedupe` | 去重 | `fields[]` (默认 name), `action`: delete/rename | +| `sort` | 排序 | `direction`: asc/desc/random | +| `regex-sort` | 正则优先级排序 | `expressions[]`, `direction` | +| `flag` | 国旗添加/移除 | `mode`: add/remove, `tw`: cn/tw/ws | +| `quick` | 快速设置 | `udp`, `tfo`, `scert`, `useless` 等 | +| `resolve` | DNS 解析域名→IP | `provider`, `recordType`, `filter` | +| `script` | 构建时脚本 | `scriptId`, `scriptKind`, `arguments` | + +**路径访问**: `getByPath` / `setByPath` 支持点号路径 (如 `ws-opts.headers.Host`)。 + +**正则编译**: `compileRegex` 支持 `(?i)` 前缀作为大小写不敏感标志。 + +**国旗检测** (detectFlag): 8 个地区正则 → emoji 旗帜,台湾旗可配置为 cn/tw/ws。 + +**DNS 解析** (resolveProxyDomains): 支持 Google/Cloudflare/Ali/Tencent/Custom DoH,并发解析,可过滤失败/IP类型,保留原域名为 servername/sni。 + +### 6.4 目标渲染 (renderBuildTarget) + +| 目标 | 渲染器 | 输出格式 | Content-Type | +|------|--------|----------|--------------| +| mihomo / stash | `renderMihomoYaml` | YAML (proxies + proxy-groups + rules) | text/yaml | +| surge | `renderSurgeProxies` | 文本行 | text/plain | +| surge-mac | `renderSurgeMacProxies` | 文本行 (含 ssh/h2-connect/snell) | text/plain | +| surfboard | `renderSurfboardProxies` | 文本行 (ss/vmess/trojan/http/socks5) | text/plain | +| loon | `renderLoonProxies` | 文本行 | text/plain | +| egern | `renderEgernYaml` | YAML ({proxies: [...]}) | text/yaml | +| qx | `renderQxProxies` | 文本行 | text/plain | +| sing-box | `renderSingBoxJson` | JSON (outbounds + route) | application/json | +| v2ray | `renderProxyUris` + base64 | base64 编码的 URI 行 | text/plain | +| uri / shadowrocket | `renderProxyUris` | URI 行 | text/plain | +| json | 直接 JSON.stringify | {proxies: [...]} | application/json | + +**mihomo YAML 渲染** 特殊处理: +- proxy-groups 中的 `$all` 展开为所有节点名 +- `filter` 正则匹配节点名 +- 引用不存在的组/节点会被过滤 +- `DIRECT` / `REJECT` 为允许的字面量 + +**sing-box JSON 渲染**:自动生成 PROXY (selector) + AUTO (urltest) + DIRECT + REJECT outbound,附带 mixed-in inbound 和 route 配置。 + +**目标兼容性** (isTargetCompatible): 每种目标只输出它支持的协议类型,不支持的节点被跳过。 + +--- + +## 7. 脚本系统 (scripts.ts + generate-script-registry.mjs) + +### 7.1 构建时注册表 + +脚本不存储在 D1 中,而是在构建时从 `config/script-plugins.json`(公开)和 `config/script-plugins.local.json`(个人)扫描,生成 `cloudflare/src/generated/script-registry.ts`。 + +**约束**: +- 最多 32 个脚本,每个最大 32 KiB +- 不能使用 import/export/eval/require/new Function +- 公开脚本不能使用 fetch/$httpClient/setTimeout +- 必须声明 `function filter(...)` 或 `function operator(...)` +- 脚本代码通过 `node --check` 语法检查 + +### 7.2 运行时执行 + +```typescript +type ScriptRuntime = { + arguments: Record; // 参数(含默认值) + options: Record; // 原始 options + targetPlatform: SubscriptionTarget; // 目标客户端 + context: Record; // sourceId, collectionId, scriptId + proxyUtils: typeof PROXY_UTILS; // 工具函数 + substore: { env: "Cloudflare" }; +}; +``` + +**两种脚本类型**: +- `filter`: 返回 `boolean[]`(每个节点一个布尔值),保留 true 的节点 +- `operator`: 返回 `ProxyNode[]`(可修改节点,但不能增加数量) + +**PROXY_UTILS** 提供: `isIPv4`, `isIPv6`, `isIP`, `removeFlag`, `Base64.encode/decode` + +**限制**: 每个 source/collection 最多 2 个 script action (`MAX_SCRIPT_ACTIONS = 2`)。 + +--- + +## 8. 路由模板系统 (defaults.ts) + +### 8.1 内置模板(6 套) + +| ID | 名称 | 特点 | +|----|------|------| +| `mihomo-basic` | Mihomo Basic | 最简,内联规则 | +| `acl4ssr-mihomo` | ACL4SSR Mihomo | 默认推荐,17 个 rule-provider | +| `acl4ssr-mihomo-no-emoji` | ACL4SSR 无 Emoji | 同上去 emoji | +| `loyalsoldier-whitelist` | Loyalsoldier 白名单 | 代理优先 | +| `loyalsoldier-blacklist` | Loyalsoldier 黑名单 | 直连优先 | +| `ai-streaming-mihomo` | AI + Streaming | AI/流媒体/TG/GitHub 路由 | + +### 8.2 模板结构 + +```typescript +type RoutingTemplateConfig = { + mixedPort?: number; // 7890 + allowLan?: boolean; // false + mode?: string; // "rule" + logLevel?: string; // "info" + dns?: Record; // fake-ip + DoH + sniffer?: Record; + proxyGroups?: TemplateProxyGroup[]; // 10 个默认组 + ruleProviders?: Record; // http provider + rules?: string[]; // RULE-SET 规则链 +}; +``` + +**proxy-group 特殊值**: +- `$all` → 展开为所有节点名 +- `filter` → 正则筛选节点 +- 引用其他组名 → 自动包含 + +### 8.3 模板别名归一化 + +`normalizeMihomoTemplateConfig` 将 kebab-case 别名拷贝到 camelCase(如 `mixed-port` → `mixedPort`),然后删除 kebab-case 键。 + +--- + +## 9. 规则转换 (rules.ts) + +将分流规则在 mihomo / surge / loon / qx 之间互转。 + +**支持的规则类型**: DOMAIN, DOMAIN-SUFFIX, DOMAIN-KEYWORD, IP-CIDR, IP-CIDR6, GEOIP, GEOSITE, PROCESS-NAME, DST-PORT, MATCH + +**别名归一化**: HOST→DOMAIN, HOST-SUFFIX→DOMAIN-SUFFIX, IPCIDR→IP-CIDR, FINAL→MATCH, DEST-PORT→DST-PORT 等 + +**QX 特殊处理**: DOMAIN→HOST, IP-CIDR6→IP6-CIDR, MATCH→FINAL,只保留 `no-resolve` 选项 + +--- + +## 10. 安全机制 + +### 10.1 Token 鉴权 + +- **Admin Token**: SHA-256 双向 hash + `crypto.subtle.timingSafeEqual`(防时序攻击) +- **Download Token**: 同上 +- **Scoped Grant Token**: 24 字节随机 → base64url,数据库存 SHA-256 hex,明文只返回一次 + +### 10.2 安全头 + +``` +Content-Security-Policy: default-src 'self'; ... script-src 'self' 'unsafe-eval'; ... +Referrer-Policy: no-referrer +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Permissions-Policy: camera=(), microphone=(), ... +``` + +### 10.3 输入限制 + +| 限制 | 值 | 位置 | +|------|-----|------| +| API body | 4 MiB | `MAX_API_BODY_BYTES` | +| 远程源 URL 数 | 8 | `MAX_REMOTE_SOURCE_URLS` | +| 单 URL 响应 | 2 MiB | `MAX_REMOTE_SOURCE_RESPONSE_BYTES` | +| 源总响应 | 12 MiB | `MAX_REMOTE_SOURCE_TOTAL_BYTES` | +| 流量信息响应 | 64 KiB | `MAX_FLOW_RESPONSE_BYTES` | +| DoH 响应 | 64 KiB | `MAX_DOH_RESPONSE_BYTES` | +| 回收站条目 | 50 | `MAX_RECYCLE_ENTRIES` | +| 脚本数 | 32 | `MAX_SCRIPTS` | +| 脚本大小 | 32 KiB | `MAX_SCRIPT_BYTES` | +| 脚本 action | 2 | `MAX_SCRIPT_ACTIONS` | +| ID 长度 | 1-64 `[a-z0-9_-]` | `validateRecordId` | + +### 10.4 流式读取 (read.ts) + +`readResponseText` 先检查 Content-Length,再流式读取并累计字节,超限则 cancel reader 并抛错——防止 OOM。 + +--- + +## 11. 缓存策略 + +远程订阅源使用 Workers Cache API (`caches.default`) 缓存: + +- **缓存键**: SHA-256(`url\n userAgent`) → `https://sub-store-cache.invalid/source/` +- **TTL**: `cacheTtl` 设置(源 meta 或全局 settings),默认 300s,范围 0-3600s +- **缓存内容**: 响应体 + 自定义头(subscription-userinfo, etag, last-modified 等) +- **缓存命中**: `cacheStatus=hit` +- **304 Not Modified**: 用缓存内容,`cacheStatus=refresh` +- **新鲜获取**: 写缓存 (waitUntil),`cacheStatus=miss` +- **错误降级**: `remoteCacheStaleOnError !== false` 时用 stale cache,`cacheStatus=stale` +- **强制刷新**: query `refresh=1` 或 `noCache=1` 跳过缓存读取 + +--- + +## 12. 并发控制 + +```typescript +async function runWithConcurrency(tasks, concurrency, waitMs) +``` + +- 固定数量 worker 池,从共享 cursor 取任务 +- `waitMs`: 每个任务前延迟(仅 index > 0 时),防止突发请求 +- `concurrency`: 默认 3,范围 1-12 +- `ignoreFailed=true` 用 `runSettledWithConcurrency`(Promise.allSettled 语义),失败源跳过 +- `ignoreFailed=false` 用 `runWithConcurrency`,任一失败则整体 reject + +--- + +## 13. 前端架构概览 + +### 13.1 技术栈 + +- Vue 3 + TypeScript + Vite +- Pinia (状态管理) +- NutUI (组件库) +- CodeMirror (代码编辑器) +- 8 套主题 (light/dark/pureblack/darkblue/lightblue/sereneblues/monokai/mocha) + +### 13.2 核心交互 + +- **Admin Token**: 存 localStorage,从 URL `?token=` 同步并清除 +- **API 基址**: `VITE_API_URL=/`(同源),构建时注入 +- **环境检测**: `/api/env` 返回 backend 类型(Cloudflare/Node/Docker/各客户端),前端据此显示图标和功能开关 +- **设置合并**: 前端合并后端默认设置 + 用户设置(`mergeSettings`) + +### 13.3 页面结构 + +- Sub / SubEditor — 订阅源列表与编辑器 +- Collection (隐含在 Sub 页) — 集合管理 +- Preview — 节点预览 +- CompareTable — 节点对比 +- Tools — 工具页 +- My — 个人/设置 +- editCode/cmView — 代码编辑器 + +--- + +## 14. 构建与部署 + +### 14.1 构建管线 + +```bash +pnpm run build + ├─ scripts:generate → generate-script-registry.mjs + │ 扫描 config/script-plugins.json + .local.json + │ → cloudflare/src/generated/script-registry.ts + └─ build:frontend → Vite build → frontend/dist/ +``` + +### 14.2 部署方式 + +1. **Deploy to Cloudflare 按钮**: 用根 `wrangler.jsonc`,CF 自动创建 D1 +2. **Agent/CLI 安装器**: `pnpm run install:cloudflare`,支持导入源/集合 +3. **快速安装**: `pnpm run install:quick`,先部署后配置 + +### 14.3 数据库迁移 + +```bash +pnpm run db:migrations:apply + → wrangler d1 migrations apply DB --remote --config ../wrangler.jsonc +``` + +3 个 migration: +- 0001: 创建 sources/collections/templates/app_settings + 默认 daily 集合 +- 0002: 清理旧内置模板行 + 重建 app_settings +- 0003: 添加 download_grants + recycle_bin + +--- + +## 15. Go + SQLite 重构要点 + +基于以上分析,Go 重构需关注: + +### 15.1 对应关系 + +| 原项目 | Go 重构 | +|--------|---------| +| Cloudflare Workers | Go HTTP server (net/http 或框架) | +| D1 (SQLite) | SQLite (mattn/go-sqlite3 或 modernc.org/sqlite) | +| Hono | 路由库 (chi/gin/echo 等) | +| Workers Cache API | 自建缓存层 (内存 LRU 或 Redis) | +| Worker Secrets | 环境变量/配置文件 | +| Static Assets | embed.FS 或独立静态服务 | +| `crypto.subtle.timingSafeEqual` | `crypto/subtle.ConstantTimeCompare` | +| `caches.default` | 需自建 HTTP 缓存层 | + +### 15.2 核心移植清单 + +1. **数据库层**: 5 张表 schema 直接复用(SQLite 方言基本兼容),UPSERT 改为 Go 风格 +2. **订阅管线**: `subscription.ts` 的解析/过滤/渲染逻辑需完整移植(最大工作量) +3. **协议解析**: 13 种 URI 协议 + 客户端配置行解析器 +4. **过滤器**: 11 种 FilterRule 类型的执行逻辑 +5. **渲染器**: 13 种目标格式的渲染器 +6. **路由模板**: 6 套内置模板 + 模板渲染(proxy-group 展开) +7. **规则转换**: rules.ts 逻辑 +8. **脚本系统**: 构建时注册表模式可保留(Go plugin 或代码生成),运行时 sandbox 需重新设计 +9. **缓存**: 远程源 HTTP 缓存需自建(带 etag/304/stale 语义) +10. **并发控制**: worker 池模式可用 goroutine + semaphore 实现 +11. **安全**: timingSafeEqual / 安全头 / 输入限制 / 流式读取 + +### 15.3 架构差异点 + +- **无 Cache API**: Go 版需自建缓存层(内存或 Redis),原项目依赖 Workers Cache API 的特性需要替代 +- **无 waitUntil**: Go 版用 goroutine + context,缓存写入可异步但需管理生命周期 +- **脚本沙箱**: 原项目用 JS 闭包在 Worker 中执行,Go 版需要 Go plugin / WASM / 嵌入式 JS 引擎 (goja) 等方案 +- **静态资源**: 原 `run_worker_first` + SPA fallback 在 Go 中需显式实现路由优先级 +- **D1 batch**: Go SQLite 用事务替代 + +--- + +## 16. 关键设计决策总结 + +1. **内置模板代码持有**: 避免迁移同步问题,所有部署即时获得模板修复 +2. **源级 + 集合级双重过滤**: 先对每个源独立过滤,再对合并后的节点做集合级过滤 +3. **ignoreFailed 语义**: 集合级配置,决定多源获取是 allSettled 还是 all +4. **created_at 排序**: 用时间戳整数排序,sort 操作批量更新 created_at +5. **scoped grants**: 分享令牌可限定资源+目标+过期,hash 存储 +6. **有界回收站**: 50 条上限自动裁剪,防止无限增长 +7. **构建时脚本**: 安全(无运行时 eval)但灵活性受限 +8. **stale cache 降级**: 远程源失败时用过期缓存保证可用性 +9. **目标兼容性过滤**: 不支持的协议类型自动跳过,而非报错 +10. **临时覆盖**: 下载时可通过 query 临时替换源 URL/content/UA,用于调试 diff --git a/docs/backend-dev-plan.md b/docs/backend-dev-plan.md new file mode 100644 index 0000000..d9e2d64 --- /dev/null +++ b/docs/backend-dev-plan.md @@ -0,0 +1,950 @@ +# Sub-Store Go 后端功能开发计划 + +> 技术栈:Go 1.23+ / Fiber v3 / Cobra / Viper / Logrus / SQLite +> 参考原项目:`ref/sub-store-cloudflare/cloudflare/` (TypeScript, 5404 行) +> 核心原则:优先使用成熟开源库,不自造轮子 + +--- + +## 1. 技术选型 + +### 1.1 核心框架 + +| 用途 | 库 | 版本 | 理由 | +|------|-----|------|------| +| HTTP 框架 | `github.com/gofiber/fiber/v3` | v3.x | 用户指定 | +| CLI 启动 | `github.com/spf13/cobra` | v1.x | 用户指定 | +| 配置管理 | `github.com/spf13/viper` | v1.x | 用户指定 | +| 日志 | `github.com/sirupsen/logrus` | v1.x | 用户指定 | +| SQLite 驱动 | `modernc.org/sqlite` | latest | 纯 Go,无 CGO,跨平台编译 | +| ORM/查询 | `github.com/jmoiron/sqlx` | latest | 轻量,sql 行映射,不引入 GORM 重量级 | +| 迁移 | `github.com/pressly/goose/v3` | latest | 成熟 SQL 迁移工具,//go:embed 嵌入 | +| 中文排序 | `golang.org/x/text` | latest | collate + language.SimplifiedChinese 拼音排序 | + +### 1.2 功能库 + +| 用途 | 库 | 理由 | +|------|-----|------| +| YAML 序列化 | `gopkg.in/yaml.v3` | mihomo 配置和模板操作 | +| HTTP 客户端 | `github.com/go-resty/resty/v2` | 重试/超时/流式读取,比 net/http 更简洁 | +| UUID | `github.com/google/uuid` | grant/recycle ID 生成 | +| 验证 | `github.com/go-playground/validator/v10` | 请求体校验 | +| 正则 | `regexp` (标准库 RE2) | 注意:RE2 不支持反向引用/lookahead,原项目部分正则需适配 | +| Base64 | `encoding/base64` (标准库) | | +| Crypto | `crypto/subtle`, `crypto/sha256`, `crypto/rand` (标准库) | token 校验、hash、随机 | +| URL 解析 | `net/url` (标准库) | URI 协议解析 | +| JSON | `encoding/json` (标准库) | | +| 信号处理 | `os/signal` (标准库) | 优雅关闭 | +| 嵌入静态资源 | `embed` (标准库) | 前端 SPA | + +### 1.3 原项目依赖对照 + +| 原项目 (TS) | Go 替代 | 说明 | +|-------------|---------|------| +| Hono | Fiber v3 | 路由 + 中间件 | +| D1 (SQLite) | modernc.org/sqlite + sqlx | 直接 SQLite | +| `caches.default` (Cache API) | SQLite `source_cache` 表 | 零额外依赖,重启不丢,WAL 读写不互斥 | +| `crypto.subtle.timingSafeEqual` | `crypto/subtle.ConstantTimeCompare` | | +| `c.req.json()` 等 | Fiber `c.Bind()` / `c.Body()` | | +| `ExecutionCtx.waitUntil` | `go func()` + context | goroutine 异步 | +| `env.DB.batch()` | sqlx `BeginTx()` + 多语句 | 事务 | +| `atob` / `btoa` | `encoding/base64` | | +| `yaml` npm 包 | `gopkg.in/yaml.v3` | | +| `json5` npm 包 | `encoding/json` + 宽容解析 | JSON5 场景仅用于代理节点数组解析,可用标准 JSON + 回退 | +| `structuredClone` | 深拷贝工具函数 | `reflect` 或手写 | + +--- + +## 2. 项目结构 + +``` +sub-store/ +├── main.go # 入口,调用 cmd +├── cmd/ +│ ├── root.go # cobra root command +│ ├── serve.go # sub-store serve (启动 HTTP 服务) +│ ├── migrate.go # sub-store migrate (执行数据库迁移) +│ └── version.go # sub-store version +├── internal/ +│ ├── config/ +│ │ └── config.go # viper 配置加载,结构体定义 +│ ├── model/ +│ │ ├── types.go # 核心类型:ProxyNode, FilterRule, SourceRecord, ... +│ │ ├── source.go # SubscriptionSource +│ │ ├── collection.go # SubscriptionCollection +│ │ ├── template.go # RoutingTemplate, RoutingTemplateConfig +│ │ ├── target.go # SubscriptionTarget 常量与别名 +│ │ └── response.go # API 响应封装 +│ ├── database/ +│ │ ├── db.go # SQLite 连接初始化 +│ │ ├── migrations/ +│ │ │ ├── 0001_initial.sql +│ │ │ ├── 0002_runtime_cleanup.sql +│ │ │ └── 0003_compat_resources.sql +│ │ ├── source_repo.go # sources 表 CRUD +│ │ ├── collection_repo.go # collections 表 CRUD +│ │ ├── template_repo.go # templates 表 CRUD +│ │ ├── settings_repo.go # app_settings 表 CRUD +│ │ ├── grant_repo.go # download_grants 表 CRUD +│ │ ├── recycle_repo.go # recycle_bin 表 CRUD +│ │ └── cache_repo.go # source_cache 表 CRUD +│ ├── handler/ +│ │ ├── env.go # GET /api/env +│ │ ├── source.go # /api/sources CRUD +│ │ ├── collection.go # /api/collections CRUD +│ │ ├── template.go # /api/templates CRUD +│ │ ├── settings.go # /api/settings +│ │ ├── storage.go # /api/storage 导出导入 +│ │ ├── share.go # /api/shares CRUD +│ │ ├── recycle.go # /api/recycle-bin +│ │ ├── preview.go # /api/preview/* +│ │ ├── link.go # /api/link/* +│ │ ├── flow.go # /api/source/flow/* +│ │ ├── tools.go # /api/proxy/parse, /api/rule/parse, /api/utils/node-info +│ │ ├── script.go # /api/scripts (注册表元数据) +│ │ └── download.go # /download/* 公开下载 +│ ├── middleware/ +│ │ ├── auth.go # admin token 鉴权 +│ │ ├── cors.go # CORS +│ │ ├── security.go # 安全响应头 +│ │ ├── bodylimit.go # 请求体大小限制 +│ │ └── downloadhost.go # 下载域名隔离 +│ ├── service/ +│ │ ├── subscription.go # 核心订阅处理管线 +│ │ ├── fetcher.go # 远程源抓取 + 缓存查询 +│ │ └── concurrency.go # 并发控制 worker pool +│ ├── proxy/ +│ │ ├── parser.go # 统一入口:parseProxies +│ │ ├── format.go # 格式检测 + Base64 解码 +│ │ ├── uri_parser.go # 13 种 URI 协议解析 +│ │ ├── client_parser.go # QX/Surge/Loon 配置行解析 +│ │ └── normalize.go # 节点归一化 + 预览 ID +│ ├── filter/ +│ │ ├── pipeline.go # applyFilters 管线执行器 +│ │ ├── include_exclude.go # include/exclude +│ │ ├── rename.go # rename +│ │ ├── delete_field.go # delete-field +│ │ ├── dedupe.go # dedupe (delete + rename) +│ │ ├── sort.go # sort + regex-sort +│ │ ├── flag.go # flag operator +│ │ ├── quick.go # quick settings +│ │ ├── resolve.go # DNS resolve +│ │ ├── custom.go # 声明式自定义规则链 (替代 JS 脚本) +│ │ └── util.go # getByPath/setByPath/compileRegex +│ ├── render/ +│ │ ├── mihomo.go # mihomo/stash YAML +│ │ ├── surge.go # surge + surge-mac +│ │ ├── surfboard.go # surfboard +│ │ ├── loon.go # loon +│ │ ├── qx.go # quantumult x +│ │ ├── egern.go # egern YAML +│ │ ├── singbox.go # sing-box JSON +│ │ ├── uri.go # v2ray/uri/shadowrocket +│ │ └── json.go # JSON 原始输出 +│ ├── rules/ +│ │ └── converter.go # 分流规则转换 +│ ├── template/ +│ │ ├── builtin.go # 6 套内置模板定义 +│ │ └── render.go # proxy-group 展开 + 模板渲染 +│ └── util/ +│ ├── token.go # token 生成 + hash + 验证 +│ ├── path.go # 点号路径 get/set +│ ├── base64.go # base64 编解码 +│ ├── ip.go # IPv4/IPv6 检测 +│ └── flag.go # 国旗检测 + 移除 +├── config/ +│ └── config.example.yaml # 配置文件示例 +├── web/ +│ └── dist/ # 前端构建产物 (embed.FS) +└── go.mod +``` + +--- + +## 3. 配置设计 + +### 3.1 配置文件 (`config.yaml`) + +```yaml +# 服务配置 +server: + host: "0.0.0.0" + port: 3000 + read_timeout: 30s + write_timeout: 60s + body_limit: 4194304 # 4 MiB + +# 数据库 +database: + path: "./data/sub-store.db" + +# 鉴权 +auth: + admin_token: "" # 必填,启动时检查 + download_token: "" # 必填,启动时检查 + download_hosts: [] # 纯下载域名列表 + +# 远程源抓取 +fetcher: + default_timeout: 30s + default_user_agent: "clash.meta/v1.19.24" + default_flow_user_agent: "clash.meta/v1.19.24" + concurrency: 3 + concurrency_wait: 0s + cache_ttl: 300s + cache_stale_on_error: true + max_source_urls: 8 + max_response_bytes: 2097152 # 2 MiB + max_total_bytes: 12582912 # 12 MiB + +# 回收站 +recycle: + max_entries: 50 + +# 应用 +app: + name: "Sub-Store" + version: "1.0.0" +``` + +### 3.2 Viper 加载策略 + +1. 默认值 (`config.SetDefault`) +2. 配置文件 (`config.yaml`,路径由 `--config` flag 指定,默认 `./config.yaml`) +3. 环境变量(前缀 `SUB_STORE_`,`.` → `_`,如 `SUB_STORE_AUTH_ADMIN_TOKEN`) +4. 命令行 flag(cobra) + +--- + +## 4. 数据库 Schema + +直接复用原项目的 3 个 migration SQL,适配 modernc.org/sqlite 语法(基本兼容)。 + +### 4.1 表结构 + +```sql +-- 0001_initial.sql +CREATE TABLE IF NOT EXISTS sources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'remote', + url TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + filters_json TEXT NOT NULL DEFAULT '[]', + meta_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source_ids_json TEXT NOT NULL DEFAULT '[]', + filters_json TEXT NOT NULL DEFAULT '[]', + template_id TEXT NOT NULL DEFAULT 'acl4ssr-mihomo', + ignore_failed INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + meta_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + target TEXT NOT NULL DEFAULT 'mihomo', + config_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS app_settings ( + id TEXT PRIMARY KEY, + value_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); + +-- 0003_compatibility_resources.sql +CREATE TABLE IF NOT EXISTS download_grants ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + resource_type TEXT NOT NULL CHECK (resource_type IN ('source', 'collection')), + resource_id TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + expires_at INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_download_grants_token_hash +ON download_grants(token_hash); + +CREATE INDEX IF NOT EXISTS idx_download_grants_resource +ON download_grants(resource_type, resource_id); + +CREATE TABLE IF NOT EXISTS recycle_bin ( + id TEXT PRIMARY KEY, + resource_type TEXT NOT NULL CHECK (resource_type IN ('source', 'collection', 'template', 'share')), + resource_id TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + deleted_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_recycle_bin_deleted_at +ON recycle_bin(deleted_at DESC); + +-- 0004_source_cache.sql +CREATE TABLE IF NOT EXISTS source_cache ( + cache_key TEXT PRIMARY KEY, -- sha256(url + "\n" + userAgent) + content TEXT NOT NULL, -- 远程源响应体文本 + metadata TEXT NOT NULL DEFAULT '{}', -- JSON: subscription-userinfo / etag / last-modified / ... + cached_at INTEGER NOT NULL, -- 写入时间戳 (unix seconds) + ttl INTEGER NOT NULL DEFAULT 300 -- TTL 秒数 +); + +CREATE INDEX IF NOT EXISTS idx_source_cache_expiry +ON source_cache(cached_at + ttl); +``` + +> 注:原项目 0002 migration 是清理旧内置模板行,Go 版无需复制此历史包袱,初始 schema 直接不含内置模板行。 +> source_cache 表替代原项目的 Workers Cache API,重启后缓存仍有效。 + +--- + +## 5. 功能模块开发计划 + +### Phase 0:项目骨架 (P0) + +**目标**:可编译、可启动、可连接数据库 + +| 任务 | 产出 | 依赖库 | +|------|------|--------| +| go mod init + 依赖引入 | `go.mod` | 全部 | +| cobra root/serve/migrate/version 命令 | `cmd/*.go` | cobra | +| viper 配置加载 | `internal/config/config.go` | viper | +| logrus 日志初始化 | 日志格式化、级别、输出 | logrus | +| SQLite 连接初始化 | `internal/database/db.go` | modernc/sqlite, sqlx | +| goose 迁移执行 | `cmd/migrate.go` | goose | +| 3 个 migration SQL 文件 | `internal/database/migrations/` | | +| Fiber app 骨架 | 路由注册、健康检查 | fiber/v3 | +| 优雅关闭 | signal handling | | + +**验证**:`sub-store serve` 启动,`GET /health` 返回 200,数据库表创建成功。 + +--- + +### Phase 1:数据层 (P0) + +**目标**:6 张表的完整 CRUD,Repository 模式 + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| 类型定义 | `internal/model/*.go` | `types.ts` 全部类型 | +| Target 别名归一化 | `model/target.go` | `targets.ts` | +| SourceRepository | `source_repo.go` | `store.ts` listSources/getSource/upsertSource/deleteSource/sortSources | +| CollectionRepository | `collection_repo.go` | `store.ts` listCollections/... | +| TemplateRepository | `template_repo.go` | `store.ts` listTemplates/... + 内置模板合并 | +| SettingsRepository | `settings_repo.go` | `store.ts` getSettings/updateSettings (深合并) | +| GrantRepository | `grant_repo.go` | `compatibility-resources.ts` | +| RecycleRepository | `recycle_repo.go` | `compatibility-resources.ts` | +| 内置模板定义 | `internal/template/builtin.go` | `defaults.ts` 6 套模板 | +| ID 归一化 `toId()` | `util/` | `store.ts` toId() | +| 深合并 `mergeDeep()` | `util/` | `store.ts` mergeDeep() | +| 排序逻辑 (created_at = now+index) | Repository 层 | `store.ts` sortSources/sortCollections | + +**关键设计**: +- 内置模板在 Go 代码中定义为 `var BuiltinTemplates`,`ListTemplates` 合并内置 + DB 查询 +- `upsert` 使用 `INSERT ... ON CONFLICT(id) DO UPDATE SET ...` +- `sortSources` 使用事务批量 `UPDATE SET created_at = ? WHERE id = ?` +- settings 深合并:递归 merge map[string]interface{} + +**验证**:单元测试覆盖所有 Repository 的 CRUD + 排序 + 深合并。 + +--- + +### Phase 2:中间件 + 基础 API (P0) + +**目标**:鉴权、CORS、安全头、基础 API 端点 + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| Admin 鉴权中间件 | `middleware/auth.go` | `http.ts` requireAdmin + isTokenValid | +| Token 验证 (SHA-256 + ConstantTimeCompare) | `util/token.go` | `http.ts` isTokenValid | +| Bearer token 提取 | `middleware/auth.go` | `http.ts` getBearerToken | +| CORS 中间件 | `middleware/cors.go` | `http.ts` applyCorsHeaders | +| 安全头中间件 | `middleware/security.go` | `http.ts` applySecurityHeaders | +| Body limit 中间件 | `middleware/bodylimit.go` | `bodyLimit()` | +| 下载域名隔离 | `middleware/downloadhost.go` | `index.ts` hostname 检查 | +| 统一响应格式 | `model/response.go` | `http.ts` success/failed | +| `GET /api/env` | `handler/env.go` | `api.ts` envPayload | +| `GET /api/settings` + `PATCH /api/settings` | `handler/settings.go` | `api.ts` settings | +| `GET/POST /api/storage` | `handler/storage.go` | `api.ts` exportStorage/importStorage | +| 静态文件服务 | `embed.FS` + Fiber static | `index.ts` ASSETS fallback | +| 全局错误处理 | Fiber error handler | `index.ts` app.onError | +| SPA fallback | 未匹配路由返回 index.html | `notFound` handler | + +**验证**:`curl -H "Authorization: Bearer " /api/env` 返回环境信息。 + +--- + +### Phase 3:源/集合/模板 API (P0) + +**目标**:完整的管理 API,不含订阅处理 + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| Source CRUD handler | `handler/source.go` | `api.ts` /api/sources 全部端点 | +| Collection CRUD handler | `handler/collection.go` | `api.ts` /api/collections | +| Template CRUD handler | `handler/template.go` | `api.ts` /api/templates | +| 排序 API | `handler/source.go` | `api.ts` PUT /api/sources, POST /api/sort/sources | +| ID 验证 `^[a-z0-9_-]{1,64}$` | handler 层 | `api.ts` validateRecordId | +| Source 验证 (remote URL / local content) | handler 层 | `api.ts` validateSource | +| Collection 验证 (sourceIds 引用检查) | handler 层 | `api.ts` validateCollection | +| 内置模板保护 (不可删改) | handler 层 | `api.ts` BUILTIN_TEMPLATE_IDS | +| 删除时归档到回收站 | handler 层 | `api.ts` archiveAndDeleteResource | +| 引用检查 (source 被 collection 引用时不可删) | handler 层 | `api.ts` deleteSource | +| Template config 解析 (JSON/YAML → 归一化) | handler 层 | `api.ts` parseTemplateConfig | +| Template alias 归一化 (mixed-port→mixedPort) | `template/` | `store.ts` normalizeMihomoTemplateConfig | + +**验证**:完整 API 测试 — 创建源/集合/模板,查询、更新、排序、删除(含回收站归档)。 + +--- + +### Phase 4:代理协议解析器 (P0) ⭐ 核心模块 + +**目标**:解析 13 种 URI 协议 + 2 种客户端配置行格式 + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| ProxyNode 类型 | `model/types.go` | `types.ts` ProxyNode | +| 格式检测 (JSON/YAML/URI 行) | `proxy/format.go` | `subscription.ts` looksLikeStructuredSubscription | +| Base64 解码 (maybe) | `proxy/format.go` | `subscription.ts` decodeMaybeBase64 | +| 统一入口 parseProxies | `proxy/parser.go` | `subscription.ts` parseProxies | +| JSON 代理数组解析 | `proxy/parser.go` | `subscription.ts` parseJsonProxies | +| YAML 代理数组解析 | `proxy/parser.go` | `subscription.ts` parseYamlProxies | +| URI 行解析入口 | `proxy/uri_parser.go` | `subscription.ts` parseProxyLines + parseProxyUri | +| `vless://` 解析 | `proxy/uri_parser.go` | `subscription.ts` parseVless | +| `vmess://` 解析 (base64 JSON) | `proxy/uri_parser.go` | `subscription.ts` parseVmess | +| `trojan://` 解析 | `proxy/uri_parser.go` | `subscription.ts` parseTrojan | +| `ss://` 解析 | `proxy/uri_parser.go` | `subscription.ts` parseShadowsocks | +| `ssr://` 解析 (base64) | `proxy/uri_parser.go` | `subscription.ts` parseShadowsocksR | +| `hysteria://` / `hy://` 解析 | `proxy/uri_parser.go` | `subscription.ts` parseHysteria | +| `hysteria2://` / `hy2://` 解析 | `proxy/uri_parser.go` | `subscription.ts` parseHysteria2 | +| `tuic://` 解析 | `proxy/uri_parser.go` | `subscription.ts` parseTuic | +| `anytls://` 解析 | `proxy/uri_parser.go` | `subscription.ts` parseAnytls | +| `socks://` / `socks5://` / `socks5+tls://` | `proxy/uri_parser.go` | `subscription.ts` parseSocks | +| `http://` / `https://` (HTTP proxy) | `proxy/uri_parser.go` | `subscription.ts` parseHttpProxy | +| `wireguard://` / `wg://` | `proxy/uri_parser.go` | `subscription.ts` parseWireGuard | +| QX 配置行解析 | `proxy/client_parser.go` | `subscription.ts` parseQxProxyLine | +| Surge/Loon 配置行解析 | `proxy/client_parser.go` | `subscription.ts` parseNamedClientProxyLine | +| 客户端选项解析 (CSV + key=value) | `proxy/client_parser.go` | `subscription.ts` splitClientCsv/parseClientOptions | +| 节点归一化 + stripUndefined | `proxy/normalize.go` | `subscription.ts` normalizeProxy/stripUndefined | +| 预览 ID 生成 | `proxy/normalize.go` | `subscription.ts` addPreviewIds/stableProxyId | + +**正则兼容性注意**:原项目用 JS RegExp,Go 用 RE2。RE2 不支持反向引用和 lookahead/lookbehind。需要检查以下正则: +- `looksLikeStructuredSubscription` 中的正则 — 应兼容 +- `detectFlag` 中的地区匹配正则 — 应兼容 +- `compileRegex` 的 `(?i)` 前缀 — Go 用 `(?i)` 内联标志,兼容 +- filter 中的用户自定义正则 — 大部分兼容,极少数 lookahead 场景需文档说明限制 + +**验证**:为每种协议准备测试用例(URI → ProxyNode 结构比对),覆盖正常/异常/边界。 + +--- + +### Phase 5:过滤器管线 (P0) ⭐ 核心模块 + +**目标**:11 种 FilterRule 类型的完整执行 + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| 管线执行器 applyFilters | `filter/pipeline.go` | `subscription.ts` applyFilters | +| include/exclude | `filter/include_exclude.go` | `subscription.ts` matchFilter | +| rename | `filter/rename.go` | `subscription.ts` renameProxies | +| delete-field | `filter/delete_field.go` | `subscription.ts` deleteFieldMatches | +| dedupe (delete + rename 两种模式) | `filter/dedupe.go` | `subscription.ts` handleDuplicateProxies | +| sort (asc/desc/random) | `filter/sort.go` | `subscription.ts` sortProxies | +| regex-sort | `filter/sort.go` | `subscription.ts` regexSortProxies | +| flag (add/remove + 台湾旗) | `filter/flag.go` | `subscription.ts` flagProxies | +| quick (udp/tfo/scert/useless/vmess-aead) | `filter/quick.go` | `subscription.ts` applyQuickSettings | +| resolve (DNS DoH 解析) | `filter/resolve.go` | `subscription.ts` resolveProxyDomains | +| custom (声明式规则链,替代 JS 脚本) | `filter/custom.go` | `subscription.ts` applyScriptAction | +| 点号路径 get/set | `util/path.go` | `subscription.ts` getByPath/setByPath | +| 正则编译 (支持 `(?i)` 前缀) | `util/` | `subscription.ts` compileRegex | +| 国旗检测/移除 | `util/flag.go` | `subscription.ts` detectFlag/removeFlag | +| ensureUniqueProxyNames | `filter/pipeline.go` | `subscription.ts` ensureUniqueProxyNames | +| IP 地址检测 (IPv4/IPv6) | `util/ip.go` | `subscription.ts` isIpv4/isIpv6 | +| isUsefulProxy | `filter/quick.go` | `subscription.ts` isUsefulProxy | +| crypto/rand 安全随机 (shuffle) | `filter/sort.go` | `subscription.ts` secureRandomInt | + +**resolve 过滤器细节**: +- 5 个 DoH provider: Cloudflare / Google / Ali / Tencent / Custom +- DoH JSON API: `?name=&type=A|AAAA` +- 并发解析 + 过滤模式 (disabled/removeFailed/IPOnly/IPv4Only/IPv6Only) +- 保留原域名为 servername/sni + +**验证**:每种 filter 类型独立单元测试 + 管线顺序执行测试。 + +--- + +### Phase 6:目标渲染器 (P0) ⭐ 核心模块 + +**目标**:13 种客户端格式的完整渲染 + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| 渲染入口 renderTarget | `render/` 分发 | `subscription.ts` renderTarget/renderBuildTarget | +| mihomo YAML (proxy-groups 展开 + 模板) | `render/mihomo.go` | `subscription.ts` renderMihomoYaml | +| proxy-group $all 展开 + filter 正则 | `render/mihomo.go` | `subscription.ts` expandGroupProxies | +| surge 文本行 | `render/surge.go` | `subscription.ts` toSurgeProxyLine | +| surge-mac (ssh/h2-connect/snell) | `render/surge.go` | `subscription.ts` toSurgeMacProxyLine | +| surfboard (ss/vmess/trojan/http/socks5) | `render/surfboard.go` | `subscription.ts` toSurfboardProxyLine | +| loon 文本行 | `render/loon.go` | `subscription.ts` toLoonProxyLine | +| quantumult x 文本行 | `render/qx.go` | `subscription.ts` toQxProxyLine | +| egern YAML | `render/egern.go` | `subscription.ts` toEgernProxy | +| sing-box JSON (含 PROXY/AUTO/DIRECT/REJECT) | `render/singbox.go` | `subscription.ts` renderSingBoxJson | +| URI 行 (13 种协议) | `render/uri.go` | `subscription.ts` toProxyUri | +| v2ray (base64 编码 URI 行) | `render/uri.go` | `subscription.ts` base64Utf8 | +| JSON 原始 | `render/json.go` | `subscription.ts` JSON.stringify | +| 目标兼容性过滤 isTargetCompatible | `render/` | `subscription.ts` isTargetCompatible | +| Content-Type 分配 | `render/` | `subscription.ts` getTargetContentType | +| 文本代理行工具 (joinTextProxy/quoteTextValue/sanitizeName) | `render/` | `subscription.ts` joinTextProxy 等 | + +**验证**:每种目标格式用固定 ProxyNode 集合渲染,比对输出。sing-box 输出需通过 JSON schema 验证。 + +--- + +### Phase 7:订阅处理管线 (P0) ⭐ 核心模块 + +**目标**:从源/集合到最终输出的完整管线 + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| buildSubscriptionResult 入口 | `service/subscription.go` | `subscription.ts` buildSubscriptionResult | +| getSources (单源/集合模式) | `service/subscription.go` | `subscription.ts` getSources | +| loadProxyNodes (并发 + ignoreFailed) | `service/subscription.go` | `subscription.ts` loadProxyNodes | +| 本地源加载 | `service/subscription.go` | `subscription.ts` loadSubscriptionRaw (local 分支) | +| 远程源 URL 分割 (多行多 URL) | `service/fetcher.go` | `subscription.ts` splitSourceUrls | +| 远程源抓取 (HTTP + 缓存 + 304 + stale) | `service/fetcher.go` | `subscription.ts` fetchSubscriptionUrl | +| UA 选择逻辑 | `service/fetcher.go` | `subscription.ts` getSourceUserAgent | +| 并发控制 worker pool | `service/concurrency.go` | `subscription.ts` runWithConcurrency/runSettledWithConcurrency | +| 缓存层 (SQLite source_cache 表 + SHA-256 key) | `database/cache_repo.go` | `subscription.ts` remoteCacheKey/safeCacheMatch/safeCachePut | +| 缓存元数据存储 (subscription-userinfo 等) | `database/cache_repo.go` | `subscription.ts` setInternalMetadataHeader | +| 响应元数据选择 | `service/subscription.go` | `subscription.ts` selectResponseMetadata | +| 元数据从 source/Response 提取 | `service/subscription.go` | `subscription.ts` metadataFromSource/metadataFromResponse | +| 流式响应读取 (限字节) | `service/fetcher.go` | `read.ts` readResponseText | +| 预览 (previewSource/previewCollection) | `service/subscription.go` | `subscription.ts` previewSubscription/previewSourceContent | +| 一次性转换 (convertSubscriptionContent) | `service/subscription.go` | `subscription.ts` convertSubscriptionContent | +| 下载链接构建 | `handler/link.go` | `api.ts` buildDownloadLink | +| 流量信息查询 (fetchFlowHeaders + parse) | `handler/flow.go` | `api.ts` fetchFlowHeaders/parseFlowHeaders | + +**缓存设计**(替代 Workers Cache API): +- 使用 SQLite `source_cache` 表持久化缓存(无需额外依赖,重启不丢) +- key: `sha256(url + "\n" + userAgent)` → 存储内容 + 元数据 JSON +- TTL: 配置项 `cache_ttl`,默认 300s +- 304 Not Modified: HTTP 请求携带 `If-None-Match` / `If-Modified-Since` +- stale on error: 配置项 `cache_stale_on_error` +- 异步写缓存: `go func()` (替代 `waitUntil`) +- 定期清理: `DELETE FROM source_cache WHERE cached_at + ttl < strftime('%s','now')` + +**并发设计**: +```go +// runWithConcurrency: 固定 worker 池 +func RunWithConcurrency[T any](tasks []func() (T, error), concurrency int, wait time.Duration) ([]T, error) + +// runSettledWithConcurrency: allSettled 语义 (失败跳过) +func RunSettledWithConcurrency[T any](tasks []func() (T, error), concurrency int, wait time.Duration) ([]Result[T]) +``` + +**验证**:端到端测试 — 创建 remote 源 + local 源 + 集合,下载 mihomo/sing-box/uri 格式,验证节点数量和内容正确。 + +--- + +### Phase 8:下载路由 + 分享/回收站 API (P1) + +**目标**:公开下载端点 + 完整的分享/回收站功能 + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| `GET /download/collection/:name/:target?/:token?` | `handler/download.go` | `download.ts` | +| `GET /download/source/:name/:target?/:token?` | `handler/download.go` | `download.ts` | +| Download token 验证 (全局 + scoped grant) | `handler/download.go` | `download.ts` rejectInvalidDownloadToken | +| Target 推断 (显式 + UA 推断) | `handler/download.go` | `download.ts` getDownloadTarget | +| 临时源覆盖 (url/content/ua query) | `handler/download.go` | `download.ts` getTemporarySourceOverride | +| 响应头设置 (content-type, userinfo, etc.) | `handler/download.go` | `download.ts` renderDownload | +| Share CRUD handler | `handler/share.go` | `api.ts` /api/shares | +| Grant token 生成 (24 byte random → base64url) | `util/token.go` | `compatibility-resources.ts` randomToken | +| Grant token hash (SHA-256 hex) | `util/token.go` | `compatibility-resources.ts` sha256Hex | +| Scoped download 授权检查 | `handler/download.go` | `compatibility-resources.ts` authorizeScopedDownload | +| Recycle bin CRUD handler | `handler/recycle.go` | `api.ts` /api/recycle-bin | +| 回收站恢复逻辑 (source/collection/template/share) | `handler/recycle.go` | `api.ts` restore | +| 回收站自动裁剪 (50 条上限) | `recycle_repo.go` | `compatibility-resources.ts` archiveAndDeleteResource | + +**验证**:创建分享令牌 → 用令牌下载 → 验证 scoped 限制(资源/目标/过期)。删除源 → 回收站恢复。 + +--- + +### Phase 9:工具 API (P1) + +**目标**:格式转换器、规则转换、节点信息 + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| `POST /api/proxy/parse` | `handler/tools.go` | `api.ts` proxy/parse | +| `POST /api/rule/parse` | `handler/tools.go` | `api.ts` rule/parse | +| 规则转换器 | `rules/converter.go` | `rules.ts` convertRules | +| 规则类型别名归一化 | `rules/converter.go` | `rules.ts` KIND_ALIASES | +| QX 规则特殊处理 | `rules/converter.go` | `rules.ts` qxKind | +| `POST /api/utils/node-info` | `handler/tools.go` | `api.ts` utils/node-info | +| `GET /api/scripts` | `handler/script.go` | `api.ts` scripts | + +**规则转换细节**: +- 支持规则类型: DOMAIN, DOMAIN-SUFFIX, DOMAIN-KEYWORD, IP-CIDR, IP-CIDR6, GEOIP, GEOSITE, PROCESS-NAME, DST-PORT, MATCH +- 别名映射: HOST→DOMAIN, FINAL→MATCH, DEST-PORT→DST-PORT 等 +- QX 特殊: DOMAIN→HOST, IP-CIDR6→IP6-CIDR, MATCH→FINAL +- 输入格式: YAML (`rules:` 列表) 或纯文本行 + +**验证**:各目标格式规则转换测试 + 节点信息查询测试。 + +--- + +### Phase 10:声明式自定义规则 (P2) — 合并入 Phase 5 + +> 原计划的 goja JS 脚本引擎已移除。原项目的脚本能力(filter/operator 自定义 JS)改为声明式规则链实现,归入 Phase 5 过滤器管线的 `custom` 类型。 + +**目标**:用声明式规则组合替代 JS 脚本,覆盖原脚本的所有实际使用场景 + +**设计**:`custom` filter 类型,内部为有序规则链,每条规则是一个原子操作: + +```json +{ + "type": "custom", + "args": { + "rules": [ + { "action": "set", "field": "name", "mode": "regex-replace", "pattern": "【.*?】", "replacement": "" }, + { "action": "include", "field": "name", "pattern": "香港|日本" }, + { "action": "set", "field": "udp", "value": true }, + { "action": "delete", "field": "tls.skipCertVerify" }, + { "action": "rename", "template": "{country}-{server}" } + ] + } +} +``` + +| 任务 | 产出 | 原项目对照 | +|------|------|-----------| +| custom filter 类型实现 | `filter/custom.go` | `scripts.ts` applyScriptAction | +| 原子操作:set/delete/include/exclude | `filter/custom.go` | 脚本中对节点字段的操作 | +| 原子操作:regex-replace (字段值正则替换) | `filter/custom.go` | 脚本中 `p.name.replace(/.../, ...)` | +| 原子操作:rename (模板化重命名) | `filter/custom.go` | 脚本中节点名拼接逻辑 | +| 原子操作:条件分支 (when 字段匹配) | `filter/custom.go` | 脚本中 if/else 逻辑 | +| 参数验证 | `filter/custom.go` | `scripts.ts` validateScriptActions | +| `$arguments` 支持 | custom args 直接映射 | `scripts.ts` $arguments | +| ProxyUtils 等价能力 | 复用 util/flag.go, util/ip.go, util/base64.go | `scripts.ts` PROXY_UTILS | + +**与原项目差异**: +- 原项目:用户编写 JS 函数,运行时闭包/eval 执行 +- Go 版:用户配置声明式规则链,Go 原生执行 +- 优势:无任意代码执行风险、无需 JS 引擎、前端可构建可视化规则编辑器、性能更高 +- 限制:不支持 JS 的完整编程能力(循环、递归、复杂条件组合),但实际场景中 99% 的脚本都是字段操作 + 正则 + 条件过滤,声明式规则完全覆盖 + +**验证**:将原项目常见脚本用例转换为声明式规则,验证等效输出。 + +--- + +### Phase 11:集成与打磨 (P1) + +| 任务 | 说明 | +|------|------| +| 前端 embed.FS 集成 | `//go:embed web/dist`,Fiber static + SPA fallback | +| 请求日志中间件 | logrus 结构化日志 | +| Panic recovery 中间件 | Fiber Recover | +| 速率限制 (可选) | Fiber limiter 中间件 | +| 配置热更新 (可选) | viper WatchConfig | +| 健康检查端点 | `GET /health` | +| pprof 端点 (可选) | debug 模式 | +| 集成测试 | 端到端 API 测试 | +| Dockerfile | 多阶段构建 | +| systemd service 文件 | 可选,用户提供时生成 | + +--- + +## 6. API 端点完整清单 + +### 管理 API (需 Admin Token) + +| 方法 | 路径 | Phase | 说明 | +|------|------|-------|------| +| GET | `/api/env` | 2 | 环境信息 | +| GET | `/api/scripts` | 9 | 脚本注册表 (仅元数据,供前端展示可选脚本) | +| GET | `/api/settings` | 2 | 应用设置 | +| PATCH | `/api/settings` | 2 | 更新设置 | +| GET | `/api/storage` | 2 | 导出备份 | +| POST | `/api/storage` | 2 | 导入备份 | +| GET | `/api/sources` | 3 | 源列表 | +| POST | `/api/sources` | 3 | 创建源 | +| PUT | `/api/sources` | 3 | 排序源 | +| POST | `/api/sort/sources` | 3 | 排序源 (兼容) | +| GET | `/api/sources/:name` | 3 | 查询单源 | +| PATCH | `/api/sources/:name` | 3 | 更新源 | +| DELETE | `/api/sources/:name` | 3 | 删除源 | +| GET | `/api/collections` | 3 | 集合列表 | +| POST | `/api/collections` | 3 | 创建集合 | +| PUT | `/api/collections` | 3 | 排序集合 | +| POST | `/api/sort/collections` | 3 | 排序集合 (兼容) | +| GET | `/api/collections/:name` | 3 | 查询单集合 | +| PATCH | `/api/collections/:name` | 3 | 更新集合 | +| DELETE | `/api/collections/:name` | 3 | 删除集合 | +| GET | `/api/templates` | 3 | 模板列表 | +| POST | `/api/templates` | 3 | 创建模板 | +| GET | `/api/templates/:name` | 3 | 查询模板 | +| PATCH | `/api/templates/:name` | 3 | 更新模板 | +| DELETE | `/api/templates/:name` | 3 | 删除模板 | +| GET | `/api/shares` | 8 | 分享列表 | +| POST | `/api/shares` | 8 | 创建分享 | +| PATCH | `/api/shares/:id` | 8 | 更新分享 | +| DELETE | `/api/shares/:id` | 8 | 删除分享 | +| GET | `/api/recycle-bin` | 8 | 回收站列表 | +| DELETE | `/api/recycle-bin/:id` | 8 | 彻底删除 | +| POST | `/api/recycle-bin/:id/restore` | 8 | 恢复 | +| POST | `/api/preview/source` | 7 | 源预览 | +| POST | `/api/preview/collection` | 7 | 集合预览 | +| GET | `/api/link/source/:name` | 7 | 源下载链接 | +| GET | `/api/link/collection/:name` | 7 | 集合下载链接 | +| GET | `/api/source/flow/:name` | 7 | 流量信息 | +| POST | `/api/proxy/parse` | 9 | 代理转换 | +| POST | `/api/rule/parse` | 9 | 规则转换 | +| POST | `/api/utils/node-info` | 9 | 节点信息 | + +### 公开下载 API (需 Download Token 或 Scoped Grant) + +| 方法 | 路径 | Phase | 说明 | +|------|------|-------|------| +| GET | `/download/collection/:name/:target?/:token?` | 8 | 下载集合 | +| GET | `/download/source/:name/:target?/:token?` | 8 | 下载源 | + +### 其他 + +| 方法 | 路径 | Phase | 说明 | +|------|------|-------|------| +| GET | `/health` | 0 | 健康检查 | +| GET | `/*` | 11 | 静态文件 (SPA fallback) | + +--- + +## 7. 开发顺序与依赖关系 + +``` +Phase 0: 项目骨架 + │ + ├──→ Phase 1: 数据层 (model + repository) + │ │ + │ └──→ Phase 2: 中间件 + 基础 API + │ │ + │ └──→ Phase 3: 源/集合/模板 API + │ │ + │ ├──→ Phase 4: 代理协议解析器 ─────┐ + │ │ │ + │ ├──→ Phase 5: 过滤器管线 ────────┤ + │ │ │ + │ └──→ Phase 6: 目标渲染器 ────────┤ + │ │ + │ ↓ + │ Phase 7: 订阅处理管线 (依赖 4+5+6) + │ │ + │ ├──→ Phase 8: 下载 + 分享/回收站 + │ │ + │ └──→ Phase 9: 工具 API + │ │ + └────────────────────────────────────┴──→ Phase 10: 声明式规则 (Phase 5 扩展) + │ + └──→ Phase 11: 集成打磨 +``` + +**关键路径**: Phase 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 11 + +**可并行**: +- Phase 4/5/6 可并行开发(三者独立,7 依赖三者完成) +- Phase 9 可与 Phase 8 并行 + +--- + +## 8. 测试策略 + +### 8.1 测试分层 + +| 层级 | 范围 | 工具 | +|------|------|------| +| 单元测试 | 每个函数/方法 | `testing` + `testify/assert` | +| Repository 测试 | 临时 SQLite 文件 | `testing` + sqlx | +| API 集成测试 | HTTP 端到端 | `httptest` + Fiber test utils | +| 协议解析测试 | 每种 URI/客户端格式 | 表驱动测试 | +| 渲染测试 | 每种目标格式 | 固定输入 + golden file 比对 | +| 端到端测试 | 完整订阅流程 | 测试 HTTP server + 真实 HTTP 请求 | + +### 8.2 关键测试用例 + +**协议解析** (Phase 4): +``` +vless://uuid@host:443?security=reality&pbk=xxx&fp=chrome&type=ws#name +→ ProxyNode{type:"vless", server:"host", port:443, uuid:"uuid", ...} +``` + +**过滤器** (Phase 5): +``` +input: [节点A(香港), 节点B(日本), 节点C(美国)] +filter: {type:"include", field:"name", pattern:"香港|日本"} +output: [节点A, 节点B] +``` + +**渲染** (Phase 6): +``` +input: [ProxyNode{type:"ss", name:"test", server:"1.2.3.4", port:8388, cipher:"aes-256-gcm", password:"pass"}] +target: mihomo +output: YAML with proxies: [{name:"test", type:"ss", ...}] +``` + +**端到端** (Phase 7): +``` +1. 创建 remote source (url: mock server) +2. 创建 collection (sourceIds: [source.id]) +3. GET /download/collection/:id/mihomo?token=xxx +4. 验证 YAML 输出包含正确的 proxy 节点 +``` + +### 8.3 测试数据 + +- 13 种协议各准备 2-3 个 URI 测试样本 +- 每种 filter 类型准备输入/输出 golden case +- 每种渲染目标准备 golden output file +- Mock 远程订阅服务器返回固定内容 + +--- + +## 9. 部署方案 + +### 9.1 单二进制 + +```bash +# 构建 +go build -o sub-store ./main.go + +# 运行 +./sub-store serve --config config.yaml + +# 迁移 +./sub-store migrate --config config.yaml +``` + +### 9.2 配置 + +- `--config` flag 指定配置文件路径 +- 环境变量 `SUB_STORE_*` 覆盖配置 +- admin_token / download_token 必填,启动时检查 + +### 9.3 Docker (可选,用户需求时) + +```dockerfile +# 多阶段构建 +FROM golang:1.23 AS builder +COPY . /src +WORKDIR /src +RUN CGO_ENABLED=0 go build -o /sub-store ./main.go + +FROM alpine:latest +COPY --from=builder /sub-store /sub-store +COPY config.yaml /config.yaml +EXPOSE 3000 +ENTRYPOINT ["/sub-store", "serve", "--config", "/config.yaml"] +``` + +> 注:使用 modernc.org/sqlite (纯 Go),`CGO_ENABLED=0` 可静态编译。 + +--- + +## 10. 风险与注意事项 + +### 10.1 正则兼容性 + +Go 的 `regexp` 包使用 RE2 引擎,不支持: +- 反向引用 (`\1`) +- Lookahead (`(?=...)`, `(?!...)`) +- Lookbehind (`(?<=...)`, `(? 基于原项目 `ref/sub-store-cloudflare/frontend` 的交互分析,规划 Go 重构版前端。 +> 核心原则:**上下布局,PC 优先**,弃用移动端 swipe/popup/floating-button 交互模式。 + +--- + +## 1. 原前端交互痛点分析 + +### 1.1 移动端优先的架构问题 + +| 痛点 | 原项目实现 | PC 上的问题 | +|------|-----------|-------------| +| **Swipe 手势操作** | `SubListItem` 用 `nut-swipe` 左/右滑动暴露 复制/下载/删除 按钮 | 桌面端无触屏,swipe 不触发,操作隐藏在不可见区域 | +| **底部 TabBar** | `TabBar.vue` 3 个 tab 固定底部,768px+ 才切换为侧边栏 | PC 上侧边栏是浮动定位 (`position: fixed`),非真正双栏布局 | +| **底部 Popup 弹窗** | 添加订阅、模板编辑、目标选择均用 `nut-popup position="bottom"` | 占满下半屏,PC 上极不协调,无法利用横向空间 | +| **浮动按钮** | `nut-drag` 可拖拽的刷新/添加按钮,固定在屏幕边缘 | PC 上浮动按钮遮挡内容,拖拽行为多余 | +| **单列滚动列表** | 源和集合在同一个页面上下排列,tag 筛选条横排 | PC 宽屏下大量留白,列表项信息密度低 | +| **编辑器 Tab 横滚** | SubEditor 的 display/content/actions 三个 tab 横向排列 | PC 上应该用左右分栏或垂直 Tab | +| **SubEditor 巨型组件** | 单文件 2174 行,混合 source/collection 两种编辑逻辑 | 维护困难,状态管理混乱 | +| **全屏预览覆盖** | Preview.vue 用 `position: fixed; inset: 0` 全屏覆盖 | PC 上应该是侧面板或分栏,不离开当前上下文 | +| **无键盘快捷键** | 所有操作只能点击 | PC 用户期望 Ctrl+S 保存、Esc 关闭等 | +| **无右键菜单** | 操作按钮散落在列表项内部 | PC 用户期望右键弹出操作菜单 | +| **Tools 页面粗糙** | 转换器/分享/回收站堆在一个页面,原生 HTML 控件 | 功能孤立,与主流程割裂 | + +### 1.2 信息架构问题 + +- 源 (Source) 和集合 (Collection) 混在同一列表,靠标题区分,概念不清 +- 模板管理藏在 "My" 设置页,与集合编辑中的模板选择脱节 +- 分享 (Share) 和回收站 (Recycle Bin) 被丢进 Tools 工具页,而非关联到对应资源 +- 预览/对比需要跳路由或弹全屏,丢失列表上下文 +- 下载链接生成操作分散:列表项有复制链接,编辑页有链接生成,Tools 有分享创建 + +--- + +## 2. 整体布局设计:上下布局 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ TopBar (h: 48px) │ +│ [Logo] Sub-Store [搜索框] [刷新] [主题] [语言] [设置] │ +├──────────────────────────────────────────────────────────────────┤ +│ NavBar (h: 40px) │ +│ [订阅源] [集合] [模板] [工具] [回收站] [+ 新建 ▾] │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ Main Content Area (flex: 1, overflow: auto) │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ Left Panel │ │ Right Panel / Detail │ │ +│ │ (列表区, 固定宽度) │ │ (编辑/预览/详情, flex: 1) │ │ +│ │ w: 360px │ │ │ │ +│ │ │ │ │ │ +│ │ [搜索] [筛选 tag] │ │ │ │ +│ │ ┌─────────────────┐ │ │ │ │ +│ │ │ Source/Col Card │ │ │ │ │ +│ │ ├─────────────────┤ │ │ │ │ +│ │ │ Source/Col Card │ │ │ │ │ +│ │ ├─────────────────┤ │ │ │ │ +│ │ │ ... │ │ │ │ │ +│ │ └─────────────────┘ │ │ │ │ +│ └──────────────────────┘ └──────────────────────────────────┘ │ +│ │ +├──────────────────────────────────────────────────────────────────┤ +│ StatusBar (h: 28px, 可选) │ +│ ● 已连接 · 12 源 · 3 集合 · v1.0.0 [最后同步: 2分钟前] │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 2.1 布局规则 + +- **TopBar**: 始终固定顶部,包含全局搜索、全局操作(刷新/主题/语言/设置) +- **NavBar**: 水平标签栏,切换主功能区域,当前页高亮 +- **Main Content**: 根据当前 Tab 显示不同内容,内部可左右分栏 +- **StatusBar**: 底部状态栏,显示连接状态/数据统计/同步时间(可选关闭) +- **最小宽度**: 1024px(PC 优先),窄屏降级为单列+抽屉 + +### 2.2 响应式策略 + +| 宽度 | 布局 | 说明 | +|------|------|------| +| ≥1280px | 双栏 (列表+详情) | 详情区常驻右侧 | +| 1024-1279px | 双栏 (列表+详情) | 列表区缩窄至 320px | +| 768-1023px | 单栏 + 右抽屉 | 详情从右侧滑入抽屉 | +| <768px | 单栏 + 全屏推入 | 详情全屏推入(移动端兼容) | + +--- + +## 3. 功能模块规划 + +### 3.1 订阅源管理 (Sources) + +#### 3.1.1 源列表 (Left Panel) + +**功能点**: +- 卡片式列表,每张卡片显示:名称、类型徽章 (remote/local)、启用开关、流量摘要、标签 +- 顶部工具栏:搜索框(实时过滤名称/URL)、标签筛选条、排序按钮 +- 列表项右键菜单:编辑、复制链接、预览、克隆、删除、创建分享 +- 拖拽排序(HTML5 drag API,非 vuedraggable touch 模式) +- 选中状态:单击选中(高亮),双击打开编辑 +- 多选:Ctrl+Click 多选,批量启用/禁用/删除/导出 +- 列表底部:显示总数和已加载数 + +**卡片信息层次**: +``` +┌──────────────────────────────────────┐ +│ [●] 机场A [remote] │ +│ https://example.com/sub... [⚡] │ +│ ↑ 12.3GB ↓ 89.7GB / 100GB │ +│ [HK] [JP] [⋮] │ +└──────────────────────────────────────┘ +``` + +#### 3.1.2 源编辑 (Right Panel / Drawer) + +**布局**: 垂直分区,非 Tab 切换 + +**区域 1 - 基本信息**: +- ID(创建后只读)、显示名、备注、标签(逗号分隔或 chip 输入) +- 图标 URL、图标彩色开关 + +**区域 2 - 数据源**: +- 类型切换:Remote / Local(radio button 横排) +- Remote: URL 文本域(支持多行多 URL)、UA 输入、passThrough UA 开关、subUserinfo 输入 +- Local: 全屏代码编辑器按钮 + 文件导入按钮 + 内容验证按钮 + CodeMirror 编辑器(内嵌) + +**区域 3 - 节点处理** (Filter Pipeline): +- 可视化流水线编辑器,每个 filter 是一个可折叠卡片 +- 拖拽排序 filter 执行顺序 +- 每个 filter 类型有专属配置表单: + - **Region Filter**: 复选框组 (HK/JP/SG/US/UK/DE/KR/TW),keep/exclude 开关 + - **Type Filter**: 复选框组 (ss/vmess/vless/trojan/...),keep/exclude 开关 + - **Regex Filter**: 正则输入框(支持多个),keep/exclude 开关,字段选择 + - **Regex Rename**: 表格式输入 (表达式 → 替换为) + - **Regex Delete**: 正则输入框(支持多个),字段选择 + - **Regex Sort**: 优先级正则列表 + 方向选择 + - **Handle Duplicate**: 去重字段选择、动作 (delete/rename)、后缀模板、连接符、位置 + - **Sort**: 方向选择 (asc/desc/random) + - **Flag Operator**: 模式 (add/remove)、台湾旗配置 + - **Quick Setting**: UDP/TFO/scert/useless/vmess-aead 下拉选择 + - **Resolve Domain**: DNS provider 选择、记录类型、过滤模式、自定义 URL、EDNS、并发数 + - **Script**: 脚本选择下拉(从注册表加载)、参数表单(根据 metadata.parameters 动态生成) +- 新增 filter 按钮 → 下拉菜单选择类型 +- 每个 filter 可启用/禁用(开关)、删除(按钮) + +**区域 4 - 操作栏** (底部固定): +- [预览] [对比] [保存] [另存为] +- Ctrl+S 快捷键保存 + +#### 3.1.3 源预览 + +**功能**: 调用 `/api/preview/source` 显示原始节点 vs 处理后节点 + +**布局**: 右侧面板内的分栏视图 +- 左栏:原始节点列表(可搜索、可排序) +- 右栏:处理后节点列表 +- 顶部:节点数量统计 (原始 N → 处理后 M, 跳过 K) +- 每个节点显示:名称、类型、服务器:端口、协议详情 +- 支持展开节点查看完整字段 + +#### 3.1.4 源下载链接 + +**功能**: 生成各目标的下载链接 + +**交互**: 点击列表项的"复制链接"按钮 → 弹出链接面板(右侧抽屉或 popover) +- 目标选择:13 种目标客户端(下拉或 grid 按钮) +- 链接显示:只读文本框 + 复制按钮 +- 二维码生成(可选) +- 临时覆盖选项:URL/content/UA 输入框(展开式高级选项) + +--- + +### 3.2 集合管理 (Collections) + +#### 3.2.1 集合列表 (Left Panel) + +**与源列表同结构**,但卡片额外显示: +- 包含的源数量/名称摘要 +- 关联的模板名 +- ignoreFailed 状态徽章 + +#### 3.2.2 集合编辑 (Right Panel / Drawer) + +**区域 1 - 基本信息**: 同源编辑 + +**区域 2 - 集合配置**: +- 模板选择:下拉选择(内置 6 套 + 自定义),旁边"管理模板"链接 +- 源选择器:双栏穿梭框 (Transfer) 布局 + - 左栏:所有可用源(可搜索、可按 tag 筛选) + - 右栏:已选源(可拖拽排序) + - 全选/反选/清空按钮 + - 空选择 = 包含所有 enabled 源(需明确提示) +- ignoreFailed 切换:下拉选择 (跳过失败源 / 全部要求成功) + +**区域 3 - 节点处理**: 同源编辑的 Filter Pipeline(集合级 filter) + +**区域 4 - 操作栏**: 同源编辑 + +#### 3.2.3 集合预览 + +**功能**: 调用 `/api/preview/collection`,显示合并后的节点 + +**布局**: 同源预览,但显示多源合并信息 +- 额外显示:各源的节点数量、失败源列表 +- 可按来源分组显示 + +--- + +### 3.3 模板管理 (Templates) + +**独立 Tab 页**,非隐藏在设置中。 + +#### 3.3.1 模板列表 + +- 表格布局:名称、目标客户端、类型 (内置/自定义)、操作 +- 内置模板:只读,可查看不可编辑/删除 +- 自定义模板:编辑、删除、克隆 +- 新建按钮、从文件导入按钮 (JSON/YAML) + +#### 3.3.2 模板编辑 + +**布局**: 右侧面板或模态对话框 + +**区域 1 - 元信息**: +- ID(创建后只读)、名称、目标客户端选择 + +**区域 2 - 配置编辑器**: +- CodeMirror JSON/YAML 编辑器(全宽) +- 语法高亮、格式化按钮、校验按钮 +- 可视化预览(可选):解析 proxy-groups 和 rules,以树形图展示 + +**区域 3 - 内置模板预览**: +- 点击内置模板 → 只读模式显示配置 + 结构说明 + +--- + +### 3.4 工具 (Tools) + +**独立 Tab 页**,包含三大工具,内部用子 Tab 或卡片分区。 + +#### 3.4.1 格式转换器 + +**功能**: 一次性代理/规则转换 (`/api/proxy/parse`, `/api/rule/parse`) + +**布局**: 左右分栏 +- 左栏:输入 + - 类型切换:代理转换 / 规则转换 + - 目标选择:下拉 + - 输入文本域(支持粘贴 URI/YAML/JSON) + - 从文件导入按钮 + - [转换] 按钮 +- 右栏:输出 + - 输出文本域(只读) + - 统计信息:解析 N / 输出 M / 跳过 K + - 复制按钮、下载按钮 + +#### 3.4.2 分享管理 (Shares) + +**功能**: 创建/管理 download grants + +**布局**: 列表 + 表单 + +**列表区**: +- 表格:资源类型、资源 ID、目标、过期时间、状态、操作 +- 操作:启用/禁用切换、删除、复制链接 +- 按资源类型筛选 + +**创建表单** (右侧面板或顶部展开): +- 资源类型:source / collection +- 资源 ID:下拉选择(从现有数据加载) +- 目标:下拉(auto 或指定) +- 过期时间:数字 + 单位(小时/天),或永不过期 +- [创建] → 生成 token + URL,显示并可复制 + +#### 3.4.3 节点信息查询 + +**功能**: 调用 `/api/utils/node-info` 查询 IP 信息 + +**布局**: 简单表单 + 结果卡片 +- 输入:服务器地址 +- 结果:IP、国家、地区、城市、连接信息 + +--- + +### 3.5 回收站 (Recycle Bin) + +**独立 Tab 页**。 + +**布局**: 表格 + 详情抽屉 + +**列表**: +- 表格:资源类型、资源 ID、删除时间、操作 +- 操作:恢复、彻底删除 +- 按资源类型筛选 +- 显示总数 / 上限 50 提示 + +**恢复逻辑**: +- 恢复时检查 ID 冲突(后端已处理,前端需展示友好错误) +- 恢复成功后刷新对应列表 + +--- + +### 3.6 设置 (Settings) + +**模态对话框或独立全屏页**,非当前 My.vue 的卡片堆叠。 + +**布局**: 左侧分类菜单 + 右侧设置表单 + +**分类**: + +#### 3.6.1 请求设置 +- 默认 User-Agent +- 默认流量 User-Agent +- 默认超时 (ms) +- 后端请求并发数 +- 并发等待时间 (ms) +- 远程缓存 TTL (s) +- 缓存错误降级开关 +- 节点信息 API URL + +#### 3.6.2 外观设置 +- 主题选择(8 套主题) +- 简单模式开关 +- 图标显示开关 +- 图标彩色开关 +- 列表视图模式(单列/双列) +- 浮动按钮开关(移动端兼容用) +- 编辑器分组模式 + +#### 3.6.3 数据管理 +- 导出备份(下载 JSON) +- 导入备份(上传 JSON) +- Admin Token 设置 +- 下载 Token 显示 + +#### 3.6.4 关于 +- 后端类型、版本、存储引擎 +- GitHub 链接 +- 文档链接 + +--- + +## 4. 全局交互设计 + +### 4.1 键盘快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Ctrl+K` | 聚焦全局搜索 | +| `Ctrl+N` | 新建(当前 Tab 对应类型) | +| `Ctrl+S` | 保存当前编辑 | +| `Ctrl+Shift+S` | 另存为 | +| `Ctrl+P` | 预览当前选中项 | +| `Ctrl+D` | 复制下载链接 | +| `Delete` | 删除选中项(需确认) | +| `Esc` | 关闭面板/对话框/取消选中 | +| `↑↓` | 列表中上下移动选中 | +| `Enter` | 打开选中项编辑 | +| `Space` | 切换选中项启用/禁用 | + +### 4.2 右键菜单 + +**列表项右键**: +- 编辑 +- 预览节点 +- 复制下载链接 ▸ (子菜单:mihomo / sing-box / surge / ...) +- 创建分享 +- 克隆 +- 导出 +- 删除 + +**空白区域右键**: +- 新建源 +- 新建集合 +- 粘贴导入 +- 刷新列表 + +### 4.3 拖放操作 + +- **列表内拖拽**: 排序(显示插入位置指示线) +- **跨列表拖拽**: 源拖入集合(自动添加到集合的 sourceIds) +- **文件拖入**: 拖入 JSON/YAML 文件 → 导入备份或模板 + +### 4.4 通知系统 + +- **Toast**: 操作成功/失败(右上角,自动消失) +- **通知中心**: 右上角铃铛图标,记录历史通知 +- **内联状态**: 列表项上的流量加载状态、同步状态 + +### 4.5 确认对话框 + +- 删除操作:模态确认框,显示资源名称 +- 导入覆盖:模态确认框,显示将影响的数据统计 +- 离开未保存编辑:路由守卫拦截,提示保存 + +--- + +## 5. 数据流与状态管理 + +### 5.1 Store 结构(Pinia) + +``` +stores/ +├── global.ts # 全局状态:环境信息、连接状态、主题 +├── sources.ts # 源列表、CRUD、排序 +├── collections.ts # 集合列表、CRUD、排序 +├── templates.ts # 模板列表、CRUD +├── shares.ts # 分享列表、CRUD +├── recycleBin.ts # 回收站列表、恢复/删除 +├── settings.ts # 应用设置 +├── scripts.ts # 脚本注册表元数据 +└── notify.ts # 通知队列 +``` + +### 5.2 API 层 + +保持原项目的 axios 拦截器模式,但简化适配层: + +```typescript +// api/client.ts — axios 实例 + 拦截器 +// api/sources.ts — 源 CRUD +// api/collections.ts — 集合 CRUD +// api/templates.ts — 模板 CRUD +// api/shares.ts — 分享 CRUD +// api/recycle.ts — 回收站 +// api/settings.ts — 设置 + 导出/导入 +// api/tools.ts — 转换器、节点信息 +// api/env.ts — 环境 +``` + +**关键简化**: 原项目的 `api/app/index.ts` (705 行) 包含大量 UI ↔ API 的 filter 格式转换逻辑 (`toApiFilters` / `fromApiFilters` / `toActionMeta`)。重构版应将此逻辑移入编辑器组件的 model 层,API 层只做纯 HTTP 调用。 + +### 5.3 Filter Pipeline 数据模型 + +```typescript +// 编辑器内部的 UI 模型(与原项目 UiProcess 一致) +type FilterAction = { + id: string; // 前端 UUID + type: ActionType; // 'Region Filter' | 'Type Filter' | ... + args: Record; + customName?: string; + disabled: boolean; +}; + +// 提交到后端时转换为 FilterRule[] +// 从后端加载时转换为 FilterAction[] +// 转换逻辑封装在 composables/useFilterTransform.ts +``` + +--- + +## 6. 技术选型建议 + +### 6.1 框架与 UI 库 + +| 组件 | 原项目 | 重构建议 | 理由 | +|------|--------|----------|------| +| 框架 | Vue 3 | Vue 3 | 保持一致 | +| UI 库 | NutUI (移动端) | Naive UI / PrimeVue | PC 优先,需数据表格/穿梭框/树形组件 | +| 状态管理 | Pinia | Pinia | 保持一致 | +| 路由 | Vue Router | Vue Router | 保持一致 | +| HTTP | Axios | Axios 或 ofetch | 保持 Axios 即可 | +| 代码编辑器 | CodeMirror 5 | CodeMirror 6 / Monaco | CM6 更现代,Monaco 更强大 | +| 拖拽 | vuedraggable (SortableJS) | 原生 HTML5 Drag API + vue-draggable-plus | PC 用原生拖拽更可靠 | +| i18n | vue-i18n | vue-i18n | 保持一致 | +| 图标 | Font Awesome + NutUI Icon | Lucide / Tabler Icons | 更现代的图标集 | +| 样式 | SCSS + CSS 变量 | UnoCSS / Tailwind CSS | 原子化 CSS,开发更快 | +| 主题 | 8 套主题 (SCSS 变量) | CSS 变量 + 暗色模式 | 简化主题系统 | + +### 6.2 组件库选择考量 + +**Naive UI** (推荐): +- Vue 3 原生,TypeScript 友好 +- 内置数据表格、穿梭框、树形、抽屉、右键菜单 +- 暗色模式原生支持 +- 中文社区活跃 + +**PrimeVue**: +- 组件最全(含 CodeMirror 集成) +- 主题系统强大 +- 但体积较大 + +--- + +## 7. 页面路由规划 + +``` +/ → 重定向到 /sources +/sources → 订阅源管理(列表 + 右侧详情/编辑) +/sources/:id → 订阅源管理(选中指定源,右侧显示编辑) +/collections → 集合管理 +/collections/:id → 集合管理(选中指定集合) +/templates → 模板管理 +/templates/:id → 模板管理(选中指定模板) +/tools → 工具(默认显示转换器) +/tools/converter → 格式转换器 +/tools/shares → 分享管理 +/tools/node-info → 节点信息查询 +/recycle-bin → 回收站 +/settings → 设置(模态对话框,非独立路由) +/preview/:type/:id → 预览(可选独立路由,用于书签/分享) +``` + +**路由模式**: +- 列表+详情使用嵌套路由,详情区为子路由 `` +- 预览使用 query 参数 `?preview=true` 或子路由,避免全屏覆盖 + +--- + +## 8. 功能优先级 + +### P0 — MVP 必须实现 + +1. 上下布局框架(TopBar + NavBar + Main + StatusBar) +2. 订阅源列表 + 编辑(基本表单 + URL/Content + Filter Pipeline) +3. 集合列表 + 编辑(源选择器 + 模板选择 + Filter Pipeline) +4. 源/集合预览(原始 vs 处理后节点对比) +5. 下载链接生成 + 复制 +6. 模板列表 + 查看(内置只读) +7. 设置(请求设置 + Admin Token) +8. 导出/导入备份 +9. 键盘快捷键(Ctrl+S, Ctrl+K, Esc) +10. 暗色模式 + +### P1 — 增强体验 + +1. 模板创建/编辑(CodeMirror 编辑器) +2. 分享管理(创建/列表/启停/删除) +3. 回收站(列表/恢复/彻底删除) +4. 格式转换器工具 +5. 节点信息查询工具 +6. 右键菜单 +7. 拖拽排序 + 跨列表拖拽 +8. 多选批量操作 +9. 全局搜索(跨类型搜索源/集合/模板) + +### P2 — 高级功能 + +1. 脚本参数动态表单 +2. 节点信息内联展示(预览中展开节点详情) +3. 拖入文件导入 +4. 二维码生成 +5. 通知中心 +6. 自定义主题 +7. 移动端响应式降级 + +--- + +## 9. 与后端 API 的对接 + +### 9.1 API 端点映射 + +| 前端功能 | 后端 API | 方法 | +|----------|----------|------| +| 源列表 | `/api/sources` | GET | +| 创建源 | `/api/sources` | POST | +| 更新源 | `/api/sources/:name` | PATCH | +| 删除源 | `/api/sources/:name` | DELETE | +| 排序源 | `/api/sources` (PUT) 或 `/api/sort/sources` (POST) | PUT/POST | +| 集合列表 | `/api/collections` | GET | +| 创建集合 | `/api/collections` | POST | +| 更新集合 | `/api/collections/:name` | PATCH | +| 删除集合 | `/api/collections/:name` | DELETE | +| 排序集合 | `/api/collections` (PUT) 或 `/api/sort/collections` (POST) | PUT/POST | +| 模板列表 | `/api/templates` | GET | +| 创建模板 | `/api/templates` | POST | +| 更新模板 | `/api/templates/:name` | PATCH | +| 删除模板 | `/api/templates/:name` | DELETE | +| 预览源 | `/api/preview/source` | POST | +| 预览集合 | `/api/preview/collection` | POST | +| 下载链接 | `/api/link/source/:name`, `/api/link/collection/:name` | GET | +| 流量信息 | `/api/source/flow/:name` | GET | +| 转换器 | `/api/proxy/parse`, `/api/rule/parse` | POST | +| 分享列表 | `/api/shares` | GET | +| 创建分享 | `/api/shares` | POST | +| 更新分享 | `/api/shares/:id` | PATCH | +| 删除分享 | `/api/shares/:id` | DELETE | +| 回收站列表 | `/api/recycle-bin` | GET | +| 恢复 | `/api/recycle-bin/:id/restore` | POST | +| 彻底删除 | `/api/recycle-bin/:id` | DELETE | +| 设置 | `/api/settings` | GET/PATCH | +| 导出 | `/api/storage` | GET | +| 导入 | `/api/storage` | POST | +| 环境 | `/api/env` | GET | +| 脚本列表 | `/api/scripts` | GET | +| 节点信息 | `/api/utils/node-info` | POST | + +### 9.2 鉴权 + +- 所有 `/api/*` 请求携带 `Authorization: Bearer ` 头 +- Token 存储在 localStorage,可通过 URL `?token=` 参数初始化 +- 401 响应 → 清除 Token,显示重新输入界面 + +### 9.3 响应格式适配 + +统一响应包络: +```json +{ "status": "success", "data": } +{ "status": "failed", "error": { "code": , "message": } } +``` + +前端 axios 拦截器: +- 成功:解包 `response.data.data` +- 失败:提取 `response.data.error.message`,Toast 通知 + +--- + +## 10. 原项目功能对照清单 + +| 原项目功能 | 重构状态 | 说明 | +|-----------|----------|------| +| 源列表 + swipe 操作 | ✅ 保留,改右键菜单 | 弃用 swipe | +| 集合列表 + swipe 操作 | ✅ 保留,改右键菜单 | 弃用 swipe | +| 源编辑 (SubEditor) | ✅ 保留,拆分组件 | 2174 行 → 拆为 3-4 个子组件 | +| 集合编辑 | ✅ 保留,穿梭框选源 | 弃用 checkbox+draggable | +| Filter Pipeline 编辑 | ✅ 保留,改进交互 | 可视化流水线 + 专属配置表单 | +| 预览/对比 | ✅ 保留,右侧面板 | 弃用全屏覆盖 | +| 下载链接 | ✅ 保留,popover | 弃用 PreviewPanel 弹窗 | +| 模板管理 | ✅ 提升为独立 Tab | 从 My 页移出 | +| 设置 (My.vue) | ✅ 保留,改模态/独立页 | 分类菜单 + 表单 | +| 备份导出/导入 | ✅ 保留 | 移入设置-数据管理 | +| 工具页 | ✅ 保留,增强 | 转换器+分享+节点信息 | +| 回收站 | ✅ 提升为独立 Tab | 从 Tools 移出 | +| 分享管理 | ✅ 提升为 Tools 子页 | 从 Tools 移出 | +| 浮动按钮 | ❌ 移除 | PC 不需要 | +| 底部 TabBar | ❌ 移除 | 改为顶部 NavBar | +| 底部 Popup | ❌ 移除 | 改为右侧抽屉/模态 | +| 主题系统 | ✅ 保留,简化 | 8 套 → 暗色/亮色 + 配色变量 | +| i18n | ✅ 保留 | 中/英双语 | +| 标签筛选 | ✅ 保留 | 列表顶部 chip 筛选 | +| 拖拽排序 | ✅ 保留,原生 HTML5 | 弃用 vuedraggable | +| CodeMirror 编辑 | ✅ 保留,升级 CM6 | 本地内容/模板编辑 | +| 脚本参数 | ✅ 保留,动态表单 | 根据 metadata.parameters 生成 | +| 流量信息 | ✅ 保留 | 卡片内联显示 | +| 节点信息查询 | ✅ 保留 | Tools 子功能 | diff --git a/docs/review-resolutions.md b/docs/review-resolutions.md new file mode 100644 index 0000000..9f47c2a --- /dev/null +++ b/docs/review-resolutions.md @@ -0,0 +1,652 @@ +# Review 决议记录 + +> 来源:2026-07-27 对 `backend-dev-plan.md` 的 review,共 45 条问题。 +> 本文档对每条问题给出明确决议,开发时以此为准,不得偏离。 +> 如需变更决议,先修改本文档再改代码。 + +--- + +## 决议速查 + +| # | 问题 | 决议 | 落地位置 | +|---|------|------|----------| +| 1 | ProxyNode 类型 | `map[string]any` | `model/types.go` | +| 2 | script 过滤器迁移 | 不保留 script 类型 | `filter/custom.go` | +| 3 | process→FilterRule 转换 | 不实现,前端直发 FilterRule[] | — | +| 4 | defaultSettings 合并 | 实现 mergeSettings + 特殊浅合并 | `handler/settings.go` | +| 5 | envPayload feature flags | 定义并返回,buildTimeScripts=false | `handler/env.go` | +| 6 | Flow info URL hash 解析 | 实现完整解析 | `handler/flow.go` | +| 7 | normalizeTarget UA 推断 | 实现完整关键词映射 | `model/target.go` | +| 8 | 临时源覆盖 | 实现仅覆盖第一个匹配源 | `handler/download.go` | +| 9 | parseJsonOrText | 实现 JSON 优先 + 纯文本回退 | `handler/` 工具函数 | +| 10 | SW 清理端点 | 不实现(无 CF 迁移) | — | +| 11 | getPublicBaseUrl | 实现 PUBLIC_DOWNLOAD_HOSTS 配置 | `handler/link.go` | +| 12 | 中文拼音排序 | golang.org/x/text/collate + chinese | `filter/sort.go` | +| 13 | splitHostPort | 自写 lastIndexOf(":") 版本 | `util/` | +| 14 | JS URL vs Go net/url | net/url 为基 + 逐协议测试 + 边缘处理 | `proxy/uri_parser.go` | +| 15 | URL-safe Base64 | 区分 StdEncoding / RawURLEncoding | `util/base64.go` | +| 16 | Unicode flag 正则 | RE2 \p{} 支持,实测验证 | `util/flag.go` | +| 17 | secureRandomInt | crypto/rand.Int(天然无偏) | `filter/sort.go` | +| 18 | formatDuplicateNumber 自定义数字字符 | 实现完整功能 | `filter/dedupe.go` | +| 19 | normalizeTaiwanFlag 三模式 | 实现完整 | `filter/flag.go` | +| 20 | isUsefulProxy ASCII 校验 | 实现完整 | `filter/quick.go` | +| 21 | applyState 字符串状态值 | 实现兼容解析 | `filter/quick.go` | +| 22 | snell/ssh/h2-connect | 仅 client_parser 解析 | `proxy/client_parser.go` | +| 23 | normalizeClientProxyKind 别名 | 实现别名映射 | `proxy/client_parser.go` | +| 24 | isTokenValid 双哈希 | 先 SHA-256 再 ConstantTimeCompare | `util/token.go` | +| 25 | CRLF 注入防护 | 校验 + 清洗所有 header 值 | `middleware/security.go` | +| 26 | getBearerToken 三种提取 | 实现 Bearer / query / x-sub-store-token | `middleware/auth.go` | +| 27 | CSP 安全头 | 收紧为 script-src 'self'(无 unsafe-eval) | `middleware/security.go` | +| 28 | SQLite PRAGMA | WAL + busy_timeout + foreign_keys + MaxOpenConns=1 | `database/db.go` | +| 29 | archiveAndDeleteResource 事务 | BeginTx 包裹三步 | `handler/` 删除逻辑 | +| 30 | importStorage 导入顺序 | settings→sources→templates→collections | `handler/storage.go` | +| 31 | exportStorage 排除内置模板 | 过滤 BuiltinTemplateIDs | `handler/storage.go` | +| 32 | source_cache TTL 清理 | 后台 goroutine 定时清理 | `database/cache_repo.go` | +| 33 | goose 迁移嵌入 | //go:embed + SetBaseFS | `cmd/migrate.go` | +| 34 | goroutine 关闭安全 | sync.WaitGroup + context | `service/subscription.go` | +| 35 | 缓存操作错误吞咽 | recover + 日志,不传播 | `database/cache_repo.go` | +| 36 | 并发 wait 延迟 | 保留 waitMs 参数 | `service/concurrency.go` | +| 37 | MAX_SCRIPT_ACTIONS | custom 规则上限 32 条 | `filter/custom.go` | +| 38 | restoreDownloadGrantSnapshot | 恢复时写回 tokenHash | `handler/recycle.go` | +| 39 | FRONTEND_VERSION 常量 | 定义 = "1.0.0" | `config/` | +| 40 | TEST_URL 常量 | 定义 generate_204 | `render/` | +| 41 | defaultProxyGroups | 无模板时生成 3 默认组 | `template/builtin.go` | +| 42 | sing-box 完整结构 | log+inbounds+route+outbounds 全实现 | `render/singbox.go` | +| 43 | profile-update-interval 默认值 | 默认 "6" | `handler/download.go` | +| 44 | cache-control: no-store | 下载响应设此头 | `handler/download.go` | +| 45 | modernc.org/sqlite 性能 | 接受 tradeoff,保留 swap 注释 | `database/db.go` | + +--- + +## 逐条详述 + +### 🔴 架构级决策(#1-#3)— 已由 AGENTS.md 约束豁免 + +#### #1 ProxyNode 类型 → `map[string]any` + +**决议**:ProxyNode 定义为 `type ProxyNode map[string]any`。 + +**理由**: +- 13 种协议各有不同可选字段(reality-opts, ws-opts, plugin-opts, auth_str, congestion-controller...),struct 需定义全部字段且无法处理未知字段 +- 过滤器通过 `getByPath(proxy, "ws-opts.headers.Host")` 做点号路径访问,map 天然支持 +- JSON/YAML 反序列化到 map 保留所有字段;struct 会丢失未知字段 +- stripUndefined(删除 nil 和 "")在 map 上语义清晰 +- 类型安全损失可接受——节点字段本质是动态的 + +**实现要点**: +- `model/types.go`:`type ProxyNode map[string]any` +- 深拷贝:`json.Marshal` → `json.Unmarshal`(简单可靠,性能可接受) +- 类型安全访问:在 `util/path.go` 提供 `GetByPath(node, "a.b.c")` / `SetByPath` / `GetString` / `GetInt` / `GetBool` 等辅助函数 + +#### #2 script 过滤器类型 → 不保留 + +**决议**:Go 版不实现 `script` 类型过滤器。用户自定义逻辑通过 `custom` 声明式规则链实现。 + +**理由**:全新项目无存量 script 过滤器,无需兼容。 + +**影响**: +- `GET /api/scripts` 返回空数组 `[]`(端点保留,前端兼容) +- `applyFilters` 中无 `script` 分支 +- `validateScriptActions` 不需要 + +#### #3 process→FilterRule 转换 → 不实现 + +**决议**:Go API 直接接受 `FilterRule[]` 格式,不实现前端 `process` 数组到 `FilterRule[]` 的转换。 + +**理由**:前端尚未实现,将适配 Go API 设计。前端直接发送 FilterRule[] 格式。 + +--- + +### 🟠 关键功能遗漏(#4-#11) + +#### #4 defaultSettings 合并 → 实现完整合并 + +**决议**:`GET /api/settings` 返回 `mergeSettings(defaultSettings(), storedSettings)`。 + +**合并规则**: +- 顶层 key:stored 覆盖 default +- `theme` 和 `appearanceSetting`:浅合并(stored 的子 key 覆盖 default 的对应子 key,而非整体替换) +- 其他嵌套对象:深合并 + +**落地**:`handler/settings.go` + `config/defaults.go`(定义 `DefaultSettings()` 函数)。 + +#### #5 envPayload feature flags → 定义并返回 + +**决议**:`GET /api/env` 返回 `feature` 对象,各 flag 取值如下: + +``` +buildTimeScripts: false // 无 JS 引擎 +proxyConversion: true // POST /api/proxy/parse +ruleConversion: true // POST /api/rule/parse +scopedShares: true // 分享令牌 +recycleBin: true // 回收站 +nodeInfo: true // POST /api/utils/node-info +surgeMac: true // surge-mac 渲染 +``` + +**落地**:`handler/env.go`。 + +#### #6 Flow info URL hash 解析 → 完整实现 + +**决议**:实现 `parseFlowRequest`,从 URL `#` fragment 中解析参数。 + +**支持两种格式**: +1. JSON 格式:`#{"flowUrl":"...","noFlow":true,"flowUserAgent":"...","flowHeaders":{}}` +2. Query-string 格式:`#flowUrl=...&noFlow=true&flowUserAgent=...` + +**额外**:`fetchFlowHeaders` 从响应体中提取 `upload=` / `download=` / `total=` / `expire=` 流量信息(subscription-userinfo header 优先,body 次之)。 + +**落地**:`handler/flow.go`。 + +#### #7 normalizeTarget UA 推断 → 完整关键词映射 + +**决议**:实现完整 UA 关键词→目标格式映射,按优先级匹配。 + +**映射表**(按检查顺序): + +| UA 关键词 | 目标格式 | +|-----------|---------| +| `sing-box` | singbox | +| `v2ray` / `v2rayng` | v2ray | +| `surge` (含 `mac`) | surge-mac | +| `surge` (不含 `mac`) | surge | +| `loon` | loon | +| `egern` | egern | +| `shadowrocket` | shadowrocket | +| `quantumult` | qx | +| `stash` | stash | + +**落地**:`model/target.go` 的 `NormalizeTarget(target, ua string) string`。 + +#### #8 临时源覆盖 → 仅覆盖第一个匹配源 + +**决议**:实现 `applyTemporarySourceOverride`,通过 `?url=` / `?content=` / `?ua=` query 参数临时覆盖。 + +**关键行为**:只覆盖集合中**第一个**匹配的源(按 `sourceIds` 顺序),不是全部覆盖。与原项目行为一致。 + +**落地**:`handler/download.go`。 + +#### #9 parseJsonOrText → JSON 优先 + 纯文本回退 + +**决议**:实现 `parseJsonOrText(body []byte) (map[string]any, error)`。 + +**逻辑**: +1. 尝试 `json.Unmarshal` → 成功则返回 +2. 失败则将 body 作为纯文本,返回 `{"content": string(body)}` + +**应用端点**:template 创建/更新、storage 导入。 + +**落地**:`handler/` 下的工具函数。 + +#### #10 Service Worker 清理端点 → 不实现 + +**决议**:不实现 `/sw.js` 和 `/registerSW.js` 端点。 + +**理由**:全新项目,无 Cloudflare 版 Service Worker 残留需要清理。 + +#### #11 getPublicBaseUrl → 支持 PUBLIC_DOWNLOAD_HOSTS + +**决议**:实现 `getPublicBaseUrl(r *http.Request) string`。 + +**逻辑**: +1. 读取环境变量 / 配置 `SUB_STORE_PUBLIC_DOWNLOAD_HOSTS`(逗号分隔域名列表) +2. 若非空,返回第一个域名作为 base URL +3. 否则返回 `r.Host`(请求自身的 origin) + +**落地**:`handler/link.go` 的 `buildDownloadLink`。 + +--- + +### 🟡 实现细节/陷阱(#12-#23) + +#### #12 中文拼音排序 → golang.org/x/text/collate + +**决议**:引入 `golang.org/x/text/collate` + `golang.org/x/text/language`,使用 `collate.New(language.SimplifiedChinese)` 做中文拼音排序。 + +**落地**:`filter/sort.go` 的 `sortProxies`。在 `go.mod` 添加依赖。 + +#### #13 splitHostPort → 自写版本 + +**决议**:不使用 `net.SplitHostPort`,自写 `splitHostPort(s string) (host, port string)`。 + +**行为**:用 `strings.LastIndex(s, ":")` 切分,与原项目一致。能正确处理 IPv6(如 `[::1]:443` → host=`[::1]`, port=`443`)和裸 IPv6(无端口时返回 host=s, port="")。 + +**落地**:`util/` 或 `proxy/uri_parser.go` 内部函数。 + +#### #14 JS URL vs Go net/url → net/url 为基 + 逐协议测试 + +**决议**:以 `net/url.Parse` 为基础解析器,针对已知差异做适配: + +- **Fragment 处理**:Go 的 `url.Fragment` 不会自动 URL-decode,需要手动 `url.QueryUnescape(fragment)` 获取节点名 +- **Userinfo 提取**:用 `u.User.Username()` 和 `u.User.Password()`,不手动切分 +- **IPv6 hostname**:`u.Hostname()` 自动去掉方括号 +- **Query().Get() 返回 ""**:原项目 `URLSearchParams.get()` 返回 `null`,Go 返回 `""`。所有 `!= null` 判断改为 `!= ""` + +**落地**:`proxy/uri_parser.go`。为每种协议编写表驱动测试,覆盖正常/异常/边界 case。 + +#### #15 URL-safe Base64 → 明确区分 + +**决议**:在 `util/base64.go` 中提供三组函数: + +| 函数 | 编码 | 用途 | +|------|------|------| +| `DecodeBase64Std` | `base64.StdEncoding` | vmess JSON | +| `DecodeBase64URL` | `base64.URLEncoding` | — | +| `DecodeBase64RawURL` | `base64.RawURLEncoding` (无填充) | SSR | +| `EncodeBase64URL` | `base64.URLEncoding` | — | +| `EncodeBase64RawURL` | `base64.RawURLEncoding` | — | + +同时提供 `DecodeBase64Auto(s string)` — 自动尝试 Std 和 RawURL 两种编码(先 Std,失败再 RawURL),因为订阅内容编码不一定规范。 + +**落地**:`util/base64.go`。 + +#### #16 Unicode flag 正则 → RE2 支持,实测验证 + +**决议**:Go RE2 支持 `\p{Regional_Indicator}` 和 `\uFE0F` / `\u200D`。 + +**验证清单**: +- `detectFlag`:用真实国旗 emoji(🇭🇰 🇯🇵 🇺🇸 🇹🇼)测试 +- `removeFlag`:测试含 ZWJ (`\u200D`) 和 variation selector (`\uFE0F`) 的复合 emoji +- 编写单元测试覆盖所有 Unicode flag 场景 + +**落地**:`util/flag.go`。 + +#### #17 secureRandomInt → crypto/rand.Int + +**决议**:直接使用 `crypto/rand.Int(rand.Reader, big.NewInt(int64(max)))`。 + +**理由**:Go 的 `crypto/rand.Int` 内部已实现无偏随机(rejection sampling),无需手动实现。比原项目的 JS 版更简洁。 + +**落地**:`filter/sort.go` 的 shuffle 逻辑。 + +#### #18 formatDuplicateNumber 自定义数字字符 → 完整实现 + +**决议**:实现 `formatDuplicateNumber(index int, template string) string`。 + +**功能**:`template` 可包含自定义数字字符集,用空格分隔。如 `"一 二 三 四 五"` → index=2 输出 "二"。默认为阿拉伯数字 `"1 2 3 4 5..."`。 + +**落地**:`filter/dedupe.go`。 + +#### #19 normalizeTaiwanFlag 三模式 → 完整实现 + +**决议**:flag 过滤器的 `tw` 参数支持三种模式: + +| 参数值 | 输出 | +|--------|------| +| `ws` | 🇼🇸 (萨摩亚旗) | +| `tw` | 🇹🇼 (台湾旗) | +| 默认/其他 | 🇨🇳 (中国旗) | + +**落地**:`filter/flag.go`。 + +#### #20 isUsefulProxy ASCII 校验 → 完整实现 + +**决议**:`isUsefulProxy` 不仅检查节点名关键词,还校验: + +- `cipher` 值是否为纯 ASCII +- `password` 值是否为纯 ASCII +- WS Host header(`ws-opts.headers.Host`)是否为纯 ASCII + +任一非 ASCII → 标记为 useless。 + +**落地**:`filter/quick.go`。 + +#### #21 applyState 字符串状态值 → 兼容解析 + +**决议**:`applyState` 接受以下值并归一化为 bool: + +| 输入 | 输出 | +|------|------| +| `true` / `"ENABLED"` / `"enabled"` | `true` | +| `false` / `"DISABLED"` / `"disabled"` | `false` | + +**落地**:`filter/quick.go` 的 `parseState(v any) bool`。 + +#### #22 snell/ssh/h2-connect → 仅客户端配置行解析 + +**决议**:这三种类型仅在 Surge/Loon 客户端配置行解析中出现,不出现在 URI 协议中。 + +- `snell`:Surge 配置行 `snell = name, server, port, psk, ...` +- `ssh`:Surge 配置行 `ssh = name, server, port, ...` +- `h2-connect`:Surge 配置行 + +**落地**:`proxy/client_parser.go`,不放入 `uri_parser.go`。 + +#### #23 normalizeClientProxyKind 别名 → 实现映射 + +**决议**:客户端配置行的协议类型别名归一化: + +``` +shadowsocks → ss +socks5-tls → socks5 +https → http +hysteria 2 → hysteria2 +tuic-v5 → tuic +``` + +**落地**:`proxy/client_parser.go` 的 `normalizeClientProxyKind(kind string) string`。 + +--- + +### 🔒 安全相关(#24-#27) + +#### #24 isTokenValid 双哈希 → 先 SHA-256 再 ConstantTimeCompare + +**决议**:所有 token 校验先做 SHA-256 哈希,再用 `subtle.ConstantTimeCompare` 比较哈希值,避免长度泄露。 + +**两种场景**: + +1. **admin_token / download_token**(配置文件中的明文 secret): + ``` + inputHash = sha256Hex(input) + secretHash = sha256Hex(secret) + return subtle.ConstantTimeCompare([]byte(inputHash), []byte(secretHash)) == 1 + ``` + +2. **scoped grant**(数据库存储 `sha256Hex(token)`): + ``` + inputHash = sha256Hex(input) + return subtle.ConstantTimeCompare([]byte(inputHash), []byte(storedHash)) == 1 + ``` + +**落地**:`util/token.go` 的 `IsTokenValid(input, secret string) bool` 和 `IsGrantTokenValid(input string, storedHash string) bool`。 + +#### #25 CRLF 注入防护 → 校验 + 清洗 + +**决议**: + +1. **setResponseHeader**:设置任何 header 值前校验 `!strings.ContainsAny(value, "\r\n")`,不合法则跳过设置并记录警告日志 +2. **safeContentDisposition**:清洗文件名——移除 `\r\n` 和控制字符,对特殊字符做引号转义 + +**落地**:`middleware/security.go` 或 `handler/download.go` 中的工具函数。 + +#### #26 getBearerToken 三种提取 → 完整实现 + +**决议**:token 从三处提取,优先级从高到低: + +1. `Authorization: Bearer ` header +2. `?token=` query parameter +3. `x-sub-store-token` header + +**落地**:`middleware/auth.go` 的 `extractToken(c *fiber.Ctx) string`。 + +#### #27 CSP 安全头 → 收紧 + +**决议**:Go 版前端不含 eval,CSP 收紧为: + +``` +default-src 'self'; +script-src 'self'; +style-src 'self' 'unsafe-inline'; +img-src 'self' data: blob:; +connect-src 'self'; +font-src 'self' data:; +``` + +**与原项目差异**:移除 `'unsafe-eval'`(原项目前端有 eval,Go 版不需要)。 + +**落地**:`middleware/security.go`。 + +--- + +### 🗄️ 数据库相关(#28-#33) + +#### #28 SQLite PRAGMA → 完整配置 + +**决议**:在 `database/db.go` 打开连接后立即执行: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA busy_timeout = 5000; +PRAGMA foreign_keys = ON; +PRAGMA synchronous = NORMAL; +``` + +**连接池**: +- `db.SetMaxOpenConns(1)` — modernc.org/sqlite 在高并发写时会出现 `database is locked`,单连接 + WAL 足以应对 sub-store 的个人工具场景 +- 读写都在同一连接上,WAL 模式下读不阻塞写 + +**落地**:`database/db.go` 的 `InitDB(path string) (*sqlx.DB, error)`。 + +#### #29 archiveAndDeleteResource 事务 → BeginTx 包裹 + +**决议**:删除资源时在一个事务中执行三步: + +``` +BEGIN TRANSACTION; + INSERT INTO recycle_bin (id, resource_type, resource_id, snapshot_json, deleted_at) VALUES (...); + DELETE FROM {sources|collections|templates} WHERE id = ?; + DELETE FROM recycle_bin WHERE id NOT IN ( + SELECT id FROM recycle_bin ORDER BY deleted_at DESC LIMIT ? + ); +COMMIT; +``` + +**落地**:`handler/` 删除逻辑 + `database/recycle_repo.go` 的 `ArchiveAndDelete(tx, resourceType, resourceID, snapshot, maxEntries)`。 + +#### #30 importStorage 导入顺序 → 强制顺序 + +**决议**:导入顺序固定为: + +1. `settings` — 无依赖 +2. `sources` — 无依赖 +3. `templates` — 无依赖(但 collections 引用 templates) +4. `collections` — 引用 sources 和 templates + +每步独立事务,失败则中止并返回错误。 + +**落地**:`handler/storage.go` 的 `importStorage`。 + +#### #31 exportStorage 排除内置模板 → 过滤 + +**决议**:导出时过滤掉 ID 属于 `BuiltinTemplateIDs` 集合的模板。 + +```go +var exportedTemplates []Template +for _, t := range allTemplates { + if !builtinTemplateIDs[t.ID] { + exportedTemplates = append(exportedTemplates, t) + } +} +``` + +**落地**:`handler/storage.go` 的 `exportStorage`。 + +#### #32 source_cache TTL 清理 → 后台 goroutine + +**决议**:启动一个后台 goroutine,每隔 `cache_ttl` 间隔执行一次清理: + +```go +func StartCacheCleaner(ctx context.Context, db *sqlx.DB, interval time.Duration) { + ticker := time.NewTicker(interval) + go func() { + for { + select { + case <-ticker.C: + db.ExecContext(ctx, "DELETE FROM source_cache WHERE cached_at + ttl < ?", time.Now().Unix()) + case <-ctx.Done(): + ticker.Stop() + return + } + } + }() +} +``` + +在 graceful shutdown 时通过 context 取消。间隔取 `cache_ttl` 配置值(默认 300s)。 + +**落地**:`database/cache_repo.go` 的 `StartCacheCleaner`。 + +#### #33 goose 迁移嵌入 → //go:embed + SetBaseFS + +**决议**:迁移 SQL 文件嵌入二进制: + +```go +//go:embed migrations/*.sql +var embedMigrations embed.FS + +func RunMigrations(db *sqlx.DB) error { + goose.SetBaseFS(embedMigrations) + return goose.Up(db.DB, "migrations") +} +``` + +**落地**:`cmd/migrate.go` + `database/migrations/*.sql`。 + +--- + +### 🔄 并发/异步(#34-#36) + +#### #34 goroutine 关闭安全 → WaitGroup + context + +**决议**:异步缓存写入通过 `sync.WaitGroup` 管理生命周期。 + +```go +type AsyncWriter struct { + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc +} + +func (aw *AsyncWriter) Write(fn func()) { + aw.wg.Add(1) + go func() { + defer aw.wg.Done() + select { + case <-aw.ctx.Done(): + return + default: + fn() + } + }() +} + +func (aw *AsyncWriter) Wait() { + aw.cancel() + aw.wg.Wait() +} +``` + +在 graceful shutdown 时调用 `Wait()`,带 5 秒超时。 + +**落地**:`service/subscription.go` 或 `database/cache_repo.go`。 + +#### #35 缓存操作错误吞咽 → recover + 日志 + +**决议**:`safeCacheMatch` 和 `safeCachePut` 包装缓存操作: + +```go +func safeCacheGet(repo *CacheRepo, key string) (string, bool) { + defer func() { + if r := recover(); r != nil { + logrus.Warnf("cache get panicked: %v", r) + } + }() + return repo.Get(key) +} +``` + +缓存操作失败(SQL 错误、panic)只记录日志,不传播给订阅生成管线。 + +**落地**:`database/cache_repo.go` 的 safe wrapper。 + +#### #36 并发 wait 延迟 → 保留参数 + +**决议**:`RunWithConcurrency` 和 `RunSettledWithConcurrency` 的签名保留 `wait time.Duration` 参数。 + +**行为**:每个任务开始前(第一个除外)`time.Sleep(wait)`。用于避免同时请求同一 CDN 被限流。对应配置 `fetcher.concurrency_wait`。 + +**落地**:`service/concurrency.go`。 + +--- + +### 📋 其他遗漏(#37-#45) + +#### #37 MAX_SCRIPT_ACTIONS → custom 规则上限 32 条 + +**决议**:`custom` 过滤器的规则链上限 `MAX_CUSTOM_RULES = 32`。不保留原项目的 `MAX_SCRIPT_ACTIONS = 2`(那是 JS 脚本的限制,Go 版无 JS 引擎)。 + +**落地**:`filter/custom.go` 的验证逻辑。 + +#### #38 restoreDownloadGrantSnapshot → 恢复时写回 tokenHash + +**决议**:从回收站恢复 `share` 类型时,快照中的 `tokenHash` 需写回 `download_grants` 表。这是 share 恢复的特殊逻辑——其他资源类型(source/collection/template)恢复时不需要处理 tokenHash。 + +**落地**:`handler/recycle.go` 的 restore 逻辑。 + +#### #39 FRONTEND_VERSION 常量 + +**决议**:定义 `const FrontendVersion = "1.0.0"`,用于 `GET /api/env` 响应。 + +**落地**:`internal/config/config.go` 或 `handler/env.go`。 + +#### #40 TEST_URL 常量 + +**决议**:定义 `const TestURL = "https://www.gstatic.com/generate_204"`,用于 mihomo/sing-box 渲染中 url-test proxy group 的 test URL。 + +**落地**:`render/mihomo.go` / `render/singbox.go`。 + +#### #41 defaultProxyGroups → 无模板时生成 3 默认组 + +**决议**:当 collection 未配置模板或模板无 proxy-groups 时,生成 3 个默认代理组: + +| 组名 | 类型 | 行为 | +|------|------|------| +| 🚀 节点选择 | select | 手动选择,含所有节点 + ♻️ 自动选择 | +| ♻️ 自动选择 | url-test | 自动测速,含所有节点 | +| 🚀 手动切换 | select | 手动选择,仅含所有节点 | + +**落地**:`template/builtin.go` 的 `DefaultProxyGroups()`。 + +#### #42 sing-box 完整结构 → 全实现 + +**决议**:sing-box 输出包含完整顶层配置: + +```json +{ + "log": { "level": "info" }, + "inbounds": [{ + "type": "mixed", + "tag": "mixed-in", + "listen": "0.0.0.0", + "listen_port": 7890, + "sniff": true + }], + "outbounds": [ + { "tag": "PROXY", "type": "selector", "outbounds": [...], "interrupt_exist_connections": false }, + { "tag": "AUTO", "type": "urltest", "outbounds": [...], "url": "https://www.gstatic.com/generate_204", "interrupt_exist_connections": false }, + { "tag": "DIRECT", "type": "direct" }, + { "tag": "REJECT", "type": "block" }, + ...proxy outbounds... + ], + "route": { + "auto_detect_interface": true, + "final": "PROXY" + } +} +``` + +**落地**:`render/singbox.go`。 + +#### #43 profile-update-interval 默认值 + +**决议**:下载响应设置 `profile-update-interval` header,值为 `metadata.profileUpdateInterval`,若为空则默认 `"6"`。 + +**落地**:`handler/download.go`。 + +#### #44 cache-control: no-store + +**决议**:下载响应设置 `Cache-Control: no-store`,防止 CDN/浏览器缓存订阅内容。 + +**落地**:`handler/download.go`。 + +#### #45 modernc.org/sqlite 性能 → 接受 tradeoff + +**决议**:使用 `modernc.org/sqlite`(纯 Go),接受写性能低于 CGO 版的 tradeoff。 + +**理由**: +- sub-store 是个人工具,写频率低(主要是缓存写入),性能不是瓶颈 +- 纯 Go 支持 `CGO_ENABLED=0` 静态编译,跨平台部署简单 +- 如未来性能不足,可切换到 `mattn/go-sqlite3`(接口不变,仅换驱动 + 启用 CGO) + +**落地**:`database/db.go` 添加注释说明 tradeoff 和替代方案。 diff --git a/fixture/subs.md b/fixture/subs.md new file mode 100644 index 0000000..d822eb7 --- /dev/null +++ b/fixture/subs.md @@ -0,0 +1,8 @@ +远程 +https://sub.sslinks.co.in/em9knZQ6ximoi9hhnKzJT3FayVZSb2PxKmPWzhzk?token=a8483f280b990786fe607e04d2724dd2 +https://sub2.smallstrawberry.com/api/v1/client/subscribe?token=012ebcb597340abd624d1b0b954b9b36 +https://kaze1.aisaka-taiga.com/oosaka/f2d2c3464d989d7ef78f2b5e20fa1cf9 + +本地 +vless://a2ec9c21-1c51-46cc-8cbd-d9eb63ad412c@seoul-ali-01.20140202.xyz:443?encryption=none&security=tls&sni=seoul-ali-01.20140202.xyz&insecure=0&allowInsecure=0&type=ws&host=seoul-ali-01.20140202.xyz&path=%2F5b334b45ba04a58e#Seoul-ALIBABA +vless://28f0f856-bcb3-4046-9aeb-785376c44505@us-la-01-racknerd.20140202.xyz:443?encryption=none&security=tls&sni=us-la-01-racknerd.20140202.xyz&insecure=0&allowInsecure=0&type=ws&host=us-la-01-racknerd.20140202.xyz&path=%2F6f6bbc94c6c76083#US-LA-RACKNERD diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3998633 --- /dev/null +++ b/go.mod @@ -0,0 +1,56 @@ +module github.com/peterqiu0516/sub-store + +go 1.26.2 + +require ( + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gofiber/fiber/v3 v3.4.0 // indirect + github.com/gofiber/schema v1.8.0 // indirect + github.com/gofiber/utils/v2 v2.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + 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/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 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sethvargo/go-retry v0.4.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.72.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.74.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.54.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a2757d --- /dev/null +++ b/go.sum @@ -0,0 +1,108 @@ +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw= +github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk= +github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis= +github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA= +github.com/gofiber/utils/v2 v2.1.1 h1:kGnoGjwEnFW6w0x45W+kLlmMJvqBGkuUA4oMWKn/T/I= +github.com/gofiber/utils/v2 v2.1.1/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +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/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= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= +github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= +github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M= +github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= +modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..77fab6d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "fmt" + "time" + + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Auth AuthConfig `mapstructure:"auth"` + Fetcher FetcherConfig `mapstructure:"fetcher"` + Recycle RecycleConfig `mapstructure:"recycle"` + App AppConfig `mapstructure:"app"` +} + +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + ReadTimeout time.Duration `mapstructure:"read_timeout"` + WriteTimeout time.Duration `mapstructure:"write_timeout"` + BodyLimit int `mapstructure:"body_limit"` +} + +type DatabaseConfig struct { + Path string `mapstructure:"path"` +} + +type AuthConfig struct { + AdminToken string `mapstructure:"admin_token"` + DownloadToken string `mapstructure:"download_token"` + DownloadHosts []string `mapstructure:"download_hosts"` +} + +type FetcherConfig struct { + DefaultTimeout time.Duration `mapstructure:"default_timeout"` + DefaultUserAgent string `mapstructure:"default_user_agent"` + DefaultFlowUA string `mapstructure:"default_flow_user_agent"` + Concurrency int `mapstructure:"concurrency"` + ConcurrencyWait time.Duration `mapstructure:"concurrency_wait"` + CacheTTL time.Duration `mapstructure:"cache_ttl"` + CacheStaleOnError bool `mapstructure:"cache_stale_on_error"` + MaxSourceUrls int `mapstructure:"max_source_urls"` + MaxResponseBytes int `mapstructure:"max_response_bytes"` + MaxTotalBytes int `mapstructure:"max_total_bytes"` +} + +type RecycleConfig struct { + MaxEntries int `mapstructure:"max_entries"` +} + +type AppConfig struct { + Name string `mapstructure:"name"` + Version string `mapstructure:"version"` +} + +func defaults() { + viper.SetDefault("server.host", "0.0.0.0") + viper.SetDefault("server.port", 3000) + viper.SetDefault("server.read_timeout", 30*time.Second) + viper.SetDefault("server.write_timeout", 60*time.Second) + viper.SetDefault("server.body_limit", 4*1024*1024) + viper.SetDefault("database.path", "./data/sub-store.db") + viper.SetDefault("auth.admin_token", "") + viper.SetDefault("auth.download_token", "") + viper.SetDefault("auth.download_hosts", []string{}) + viper.SetDefault("fetcher.default_timeout", 30*time.Second) + viper.SetDefault("fetcher.default_user_agent", "clash.meta/v1.19.24") + viper.SetDefault("fetcher.default_flow_user_agent", "clash.meta/v1.19.24") + viper.SetDefault("fetcher.concurrency", 3) + viper.SetDefault("fetcher.concurrency_wait", 0*time.Second) + viper.SetDefault("fetcher.cache_ttl", 300*time.Second) + viper.SetDefault("fetcher.cache_stale_on_error", true) + viper.SetDefault("fetcher.max_source_urls", 8) + viper.SetDefault("fetcher.max_response_bytes", 2*1024*1024) + viper.SetDefault("fetcher.max_total_bytes", 12*1024*1024) + viper.SetDefault("recycle.max_entries", 50) + viper.SetDefault("app.name", "Sub-Store") + viper.SetDefault("app.version", "1.0.0") +} + +func Load(configPath string) (*Config, error) { + defaults() + + if configPath != "" { + viper.SetConfigFile(configPath) + } else { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + } + + viper.SetEnvPrefix("SUB_STORE") + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("failed to read config: %w", err) + } + } + + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + if cfg.Auth.AdminToken == "" { + return nil, fmt.Errorf("auth.admin_token is required (set in config.yaml or SUB_STORE_AUTH_ADMIN_TOKEN env)") + } + if cfg.Auth.DownloadToken == "" { + return nil, fmt.Errorf("auth.download_token is required (set in config.yaml or SUB_STORE_AUTH_DOWNLOAD_TOKEN env)") + } + + return &cfg, nil +} diff --git a/internal/database/cache_repo.go b/internal/database/cache_repo.go new file mode 100644 index 0000000..18c6750 --- /dev/null +++ b/internal/database/cache_repo.go @@ -0,0 +1,123 @@ +package database + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/jmoiron/sqlx" +) + +// CacheRepo manages the source_cache table for remote subscription caching. +type CacheRepo struct { + db *sqlx.DB +} + +func NewCacheRepo(db *sqlx.DB) *CacheRepo { + return &CacheRepo{db: db} +} + +type cacheRow struct { + CacheKey string `db:"cache_key"` + Content string `db:"content"` + Metadata string `db:"metadata"` + CachedAt int64 `db:"cached_at"` + TTL int `db:"ttl"` +} + +// CacheEntry holds cached content and its metadata. +type CacheEntry struct { + Content string + Metadata map[string]any + CachedAt int64 + TTL int +} + +// Get retrieves a cache entry by key. Returns nil if not found or expired. +func (r *CacheRepo) Get(key string) (*CacheEntry, error) { + var row cacheRow + err := r.db.Get(&row, "SELECT * FROM source_cache WHERE cache_key = ?", key) + if err != nil { + return nil, nil // cache miss is not an error + } + // Check expiry + if time.Now().Unix() > row.CachedAt+int64(row.TTL) { + return nil, nil + } + var metadata map[string]any + json.Unmarshal([]byte(row.Metadata), &metadata) + if metadata == nil { + metadata = map[string]any{} + } + return &CacheEntry{ + Content: row.Content, + Metadata: metadata, + CachedAt: row.CachedAt, + TTL: row.TTL, + }, nil +} + +// Put stores a cache entry. +func (r *CacheRepo) Put(key, content string, metadata map[string]any, ttl int) error { + defer func() { + if rec := recover(); rec != nil { + slog.Warn("cache put panicked", "error", rec) + } + }() + now := time.Now().Unix() + metaJSON := marshalJSON(metadata) + _, err := r.db.Exec( + `INSERT INTO source_cache (cache_key, content, metadata, cached_at, ttl) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(cache_key) DO UPDATE SET + content = excluded.content, metadata = excluded.metadata, + cached_at = excluded.cached_at, ttl = excluded.ttl`, + key, content, metaJSON, now, ttl, + ) + return err +} + +// SafeGet wraps Get with panic recovery — per review-resolution #35. +func (r *CacheRepo) SafeGet(key string) (*CacheEntry, bool) { + defer func() { + if rec := recover(); rec != nil { + slog.Warn("cache get panicked", "error", rec) + } + }() + entry, err := r.Get(key) + if err != nil || entry == nil { + return nil, false + } + return entry, true +} + +// SafePut wraps Put with panic recovery — per review-resolution #35. +func (r *CacheRepo) SafePut(key, content string, metadata map[string]any, ttl int) { + defer func() { + if rec := recover(); rec != nil { + slog.Warn("cache put panicked", "error", rec) + } + }() + _ = r.Put(key, content, metadata, ttl) +} + +// StartCacheCleaner runs a background goroutine that periodically removes expired cache entries. +// Per review-resolution #32. +func StartCacheCleaner(ctx context.Context, db *sqlx.DB, interval time.Duration) { + ticker := time.NewTicker(interval) + go func() { + defer ticker.Stop() + for { + select { + case <-ticker.C: + _, _ = db.ExecContext(ctx, + "DELETE FROM source_cache WHERE cached_at + ttl < ?", + time.Now().Unix(), + ) + case <-ctx.Done(): + return + } + } + }() +} diff --git a/internal/database/collection_repo.go b/internal/database/collection_repo.go new file mode 100644 index 0000000..08924ec --- /dev/null +++ b/internal/database/collection_repo.go @@ -0,0 +1,160 @@ +package database + +import ( + "database/sql" + "encoding/json" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +type CollectionRepo struct { + db *sqlx.DB +} + +func NewCollectionRepo(db *sqlx.DB) *CollectionRepo { + return &CollectionRepo{db: db} +} + +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"` +} + +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 { + return nil, err + } + result := make([]model.CollectionRecord, 0, len(rows)) + for _, row := range rows { + result = append(result, collectionFromRow(row)) + } + return result, nil +} + +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 == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + rec := collectionFromRow(row) + return &rec, nil +} + +func (r *CollectionRepo) Upsert(input model.CollectionRecord) (model.CollectionRecord, error) { + now := time.Now().UnixMilli() + id := input.ID + if id == "" { + id = util.ToId(input.Name) + } + existing, _ := r.Get(id) + createdAt := now + if existing != nil { + createdAt = existing.CreatedAt + } + + sourceIds := input.SourceIds + if sourceIds == nil { + sourceIds = []string{} + } + filters := input.Filters + if filters == nil { + filters = []model.FilterRule{} + } + meta := input.Meta + if meta == nil { + meta = map[string]any{} + } + templateId := input.TemplateId + if templateId == "" { + templateId = "acl4ssr-mihomo" + } + + 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, + } + + sourceIdsJSON := marshalJSON(rec.SourceIds) + filtersJSON := marshalJSON(rec.Filters) + metaJSON := marshalJSON(rec.Meta) + + _, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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, + ) + if err != nil { + return rec, err + } + return rec, nil +} + +func (r *CollectionRepo) Delete(id string) error { + _, err := r.db.Exec("DELETE FROM collections WHERE id = ?", id) + return err +} + +func (r *CollectionRepo) Sort(ids []string) error { + now := time.Now().UnixMilli() + tx, err := r.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + for i, id := range ids { + _, err := tx.Exec("UPDATE collections SET created_at = ?, updated_at = ? WHERE id = ?", now+int64(i), now, id) + if err != nil { + return err + } + } + return tx.Commit() +} + +func collectionFromRow(row collectionRow) model.CollectionRecord { + var sourceIds []string + json.Unmarshal([]byte(row.SourceIdsJSON), &sourceIds) + if sourceIds == nil { + sourceIds = []string{} + } + 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, + } +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..df05351 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,48 @@ +package database + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/jmoiron/sqlx" + _ "modernc.org/sqlite" +) + +// InitDB opens a SQLite connection and configures PRAGMAs per review-resolutions #28 and #45. +// +// modernc.org/sqlite is a pure-Go driver (CGO_ENABLED=0) — acceptable write-performance +// tradeoff for a personal tool. To switch to mattn/go-sqlite3 (CGO), just change the +// import and driver name; the interface stays the same. +func InitDB(path string) (*sqlx.DB, error) { + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create db directory: %w", err) + } + } + + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", path) + db, err := sqlx.Connect("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + // Single connection avoids "database is locked" under concurrent writes. + // WAL mode allows readers to proceed while a write is in progress. + db.SetMaxOpenConns(1) + + for _, pragma := range []string{ + "PRAGMA journal_mode = WAL", + "PRAGMA busy_timeout = 5000", + "PRAGMA foreign_keys = ON", + "PRAGMA synchronous = NORMAL", + } { + if _, err := db.Exec(pragma); err != nil { + db.Close() + return nil, fmt.Errorf("exec %s: %w", pragma, err) + } + } + + return db, nil +} diff --git a/internal/database/grant_repo.go b/internal/database/grant_repo.go new file mode 100644 index 0000000..40b02ee --- /dev/null +++ b/internal/database/grant_repo.go @@ -0,0 +1,241 @@ +package database + +import ( + "database/sql" + "time" + + "github.com/jmoiron/sqlx" + "github.com/google/uuid" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +type GrantRepo struct { + db *sqlx.DB +} + +func NewGrantRepo(db *sqlx.DB) *GrantRepo { + return &GrantRepo{db: db} +} + +type grantRow struct { + ID string `db:"id"` + TokenHash string `db:"token_hash"` + ResourceType string `db:"resource_type"` + ResourceID string `db:"resource_id"` + Target string `db:"target"` + ExpiresAt *int64 `db:"expires_at"` + Enabled int `db:"enabled"` + CreatedAt int64 `db:"created_at"` + UpdatedAt int64 `db:"updated_at"` +} + +// CreateGrant creates a new download grant, returning the grant record and the plaintext token. +func (r *GrantRepo) Create(resourceType, resourceID, target string, expiresAt *int64) (model.DownloadGrantRecord, string, error) { + now := time.Now().UnixMilli() + id := uuid.New().String() + token, err := util.RandomToken() + if err != nil { + return model.DownloadGrantRecord{}, "", err + } + tokenHash := util.SHA256Hex(token) + + _, err = r.db.Exec( + `INSERT INTO download_grants (id, token_hash, resource_type, resource_id, target, expires_at, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, + id, tokenHash, resourceType, resourceID, target, expiresAt, now, now, + ) + if err != nil { + return model.DownloadGrantRecord{}, "", err + } + + rec := model.DownloadGrantRecord{ + ID: id, + ResourceType: resourceType, + ResourceId: resourceID, + Target: target, + ExpiresAt: expiresAt, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } + return rec, token, nil +} + +func (r *GrantRepo) List() ([]model.DownloadGrantRecord, error) { + var rows []grantRow + if err := r.db.Select(&rows, "SELECT * FROM download_grants ORDER BY created_at DESC"); err != nil { + return nil, err + } + result := make([]model.DownloadGrantRecord, 0, len(rows)) + for _, row := range rows { + result = append(result, grantFromRow(row)) + } + return result, nil +} + +func (r *GrantRepo) Get(id string) (*model.DownloadGrantRecord, error) { + var row grantRow + if err := r.db.Get(&row, "SELECT * FROM download_grants WHERE id = ?", id); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + rec := grantFromRow(row) + return &rec, nil +} + +// GetSnapshot returns the grant record with the token hash for snapshot/restore. +func (r *GrantRepo) GetSnapshot(id string) (map[string]any, error) { + var row grantRow + if err := r.db.Get(&row, "SELECT * FROM download_grants WHERE id = ?", id); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + snapshot := map[string]any{ + "id": row.ID, + "tokenHash": row.TokenHash, + "resourceType": row.ResourceType, + "resourceId": row.ResourceID, + "target": row.Target, + "expiresAt": row.ExpiresAt, + "enabled": row.Enabled != 0, + "createdAt": row.CreatedAt, + "updatedAt": row.UpdatedAt, + } + return snapshot, nil +} + +func (r *GrantRepo) Update(id string, enabled *bool, expiresAt *int64) (*model.DownloadGrantRecord, error) { + existing, err := r.Get(id) + if err != nil { + return nil, err + } + if existing == nil { + return nil, nil + } + now := time.Now().UnixMilli() + if enabled != nil { + existing.Enabled = *enabled + } + if expiresAt != nil { + existing.ExpiresAt = expiresAt + } + // If expiresAt is explicitly set to 0, treat as nil (never expire) + if expiresAt != nil && *expiresAt == 0 { + existing.ExpiresAt = nil + } + + _, err = r.db.Exec( + "UPDATE download_grants SET enabled = ?, expires_at = ?, updated_at = ? WHERE id = ?", + boolToInt(existing.Enabled), existing.ExpiresAt, now, id, + ) + if err != nil { + return nil, err + } + return existing, nil +} + +func (r *GrantRepo) Delete(id string) error { + _, err := r.db.Exec("DELETE FROM download_grants WHERE id = ?", id) + return err +} + +// AuthorizeScoped checks if a token is valid for a scoped download. +func (r *GrantRepo) AuthorizeScoped(token, resourceType, resourceID, target string) bool { + if token == "" { + return false + } + tokenHash := util.SHA256Hex(token) + var row grantRow + err := r.db.Get(&row, + `SELECT * FROM download_grants WHERE token_hash = ? AND enabled = 1 AND resource_type = ? AND resource_id = ? LIMIT 1`, + tokenHash, resourceType, resourceID, + ) + if err != nil { + return false + } + if row.ExpiresAt != nil && *row.ExpiresAt <= time.Now().UnixMilli() { + return false + } + restrictedTarget := model.NormalizeTargetAlias(row.Target) + return restrictedTarget == "" || restrictedTarget == target +} + +// RestoreFromSnapshot inserts a grant from a recycled snapshot. +// Per review-resolution #38: restores tokenHash to download_grants table. +func (r *GrantRepo) RestoreFromSnapshot(snapshot map[string]any) error { + now := time.Now().UnixMilli() + id := getString(snapshot, "id") + tokenHash := getString(snapshot, "tokenHash") + resourceType := getString(snapshot, "resourceType") + resourceID := getString(snapshot, "resourceId") + if resourceType != "collection" { + resourceType = "source" + } + target := getString(snapshot, "target") + enabled := true + if e, ok := snapshot["enabled"].(bool); ok && !e { + enabled = false + } + var expiresAt *int64 + if e, ok := snapshot["expiresAt"]; ok && e != nil { + if n, ok := e.(float64); ok && n > 0 { + v := int64(n) + expiresAt = &v + } + } + createdAt := getInt64(snapshot, "createdAt") + if createdAt == 0 { + createdAt = now + } + + _, err := r.db.Exec( + `INSERT INTO download_grants (id, token_hash, resource_type, resource_id, target, expires_at, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, tokenHash, resourceType, resourceID, target, expiresAt, boolToInt(enabled), createdAt, now, + ) + return err +} + +func grantFromRow(row grantRow) model.DownloadGrantRecord { + resourceType := "source" + if row.ResourceType == "collection" { + resourceType = "collection" + } + return model.DownloadGrantRecord{ + ID: row.ID, + ResourceType: resourceType, + ResourceId: row.ResourceID, + Target: row.Target, + ExpiresAt: row.ExpiresAt, + Enabled: row.Enabled != 0, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func getString(m map[string]any, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getInt64(m map[string]any, key string) int64 { + if v, ok := m[key]; ok { + switch n := v.(type) { + case int64: + return n + case float64: + return int64(n) + } + } + return 0 +} diff --git a/internal/database/helpers.go b/internal/database/helpers.go new file mode 100644 index 0000000..40a340e --- /dev/null +++ b/internal/database/helpers.go @@ -0,0 +1,28 @@ +package database + +import "encoding/json" + +// marshalJSON marshals a value to JSON, returning "{}" or "[]" on failure. +func marshalJSON(v any) string { + data, err := json.Marshal(v) + if err != nil { + return "null" + } + return string(data) +} + +// boolToInt converts a bool to 1/0 for SQLite. +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// jsonUnmarshal safely unmarshals JSON, tolerating empty/invalid input. +func jsonUnmarshal(data string, v any) { + if data == "" { + return + } + json.Unmarshal([]byte(data), v) +} diff --git a/internal/database/migrations.go b/internal/database/migrations.go new file mode 100644 index 0000000..c3c5635 --- /dev/null +++ b/internal/database/migrations.go @@ -0,0 +1,20 @@ +package database + +import ( + "embed" + + "github.com/jmoiron/sqlx" + "github.com/pressly/goose/v3" +) + +//go:embed migrations/*.sql +var embedMigrations embed.FS + +// RunMigrations applies all embedded SQL migrations. +func RunMigrations(db *sqlx.DB) error { + goose.SetBaseFS(embedMigrations) + if err := goose.SetDialect("sqlite3"); err != nil { + return err + } + return goose.Up(db.DB, "migrations") +} diff --git a/internal/database/migrations/0001_initial.sql b/internal/database/migrations/0001_initial.sql new file mode 100644 index 0000000..79fee23 --- /dev/null +++ b/internal/database/migrations/0001_initial.sql @@ -0,0 +1,41 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS sources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'remote', + url TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + filters_json TEXT NOT NULL DEFAULT '[]', + meta_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source_ids_json TEXT NOT NULL DEFAULT '[]', + filters_json TEXT NOT NULL DEFAULT '[]', + template_id TEXT NOT NULL DEFAULT 'acl4ssr-mihomo', + ignore_failed INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + meta_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + target TEXT NOT NULL DEFAULT 'mihomo', + config_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS app_settings ( + id TEXT PRIMARY KEY, + value_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); diff --git a/internal/database/migrations/0002_compatibility_resources.sql b/internal/database/migrations/0002_compatibility_resources.sql new file mode 100644 index 0000000..af794f7 --- /dev/null +++ b/internal/database/migrations/0002_compatibility_resources.sql @@ -0,0 +1,29 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS download_grants ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + resource_type TEXT NOT NULL CHECK (resource_type IN ('source', 'collection')), + resource_id TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + expires_at INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_download_grants_token_hash +ON download_grants(token_hash); + +CREATE INDEX IF NOT EXISTS idx_download_grants_resource +ON download_grants(resource_type, resource_id); + +CREATE TABLE IF NOT EXISTS recycle_bin ( + id TEXT PRIMARY KEY, + resource_type TEXT NOT NULL CHECK (resource_type IN ('source', 'collection', 'template', 'share')), + resource_id TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + deleted_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_recycle_bin_deleted_at +ON recycle_bin(deleted_at DESC); diff --git a/internal/database/migrations/0003_source_cache.sql b/internal/database/migrations/0003_source_cache.sql new file mode 100644 index 0000000..1a1d858 --- /dev/null +++ b/internal/database/migrations/0003_source_cache.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS source_cache ( + cache_key TEXT PRIMARY KEY, + content TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + cached_at INTEGER NOT NULL, + ttl INTEGER NOT NULL DEFAULT 300 +); + +CREATE INDEX IF NOT EXISTS idx_source_cache_expiry +ON source_cache(cached_at + ttl); diff --git a/internal/database/recycle_repo.go b/internal/database/recycle_repo.go new file mode 100644 index 0000000..eb0c04c --- /dev/null +++ b/internal/database/recycle_repo.go @@ -0,0 +1,129 @@ +package database + +import ( + "database/sql" + "time" + + "github.com/jmoiron/sqlx" + "github.com/google/uuid" + + "github.com/peterqiu0516/sub-store/internal/util" +) + +type RecycleRepo struct { + db *sqlx.DB +} + +func NewRecycleRepo(db *sqlx.DB) *RecycleRepo { + return &RecycleRepo{db: db} +} + +type recycleRow struct { + ID string `db:"id"` + ResourceType string `db:"resource_type"` + ResourceID string `db:"resource_id"` + SnapshotJSON string `db:"snapshot_json"` + DeletedAt int64 `db:"deleted_at"` +} + +// ArchiveAndDelete archives a resource snapshot to recycle_bin and deletes it in one transaction. +// Per review-resolution #29: BeginTx wrapping archive + delete + trim. +func (r *RecycleRepo) ArchiveAndDelete(resourceType, resourceID string, snapshot map[string]any, deleteSQL string, deleteArgs ...any) (string, error) { + deletedAt := time.Now().UnixMilli() + id := uuid.New().String() + snapshotJSON := marshalJSON(snapshot) + maxEntries := util.MaxRecycleEntries + + tx, err := r.db.Beginx() + if err != nil { + return "", err + } + defer tx.Rollback() + + // Step 1: Insert into recycle_bin + _, err = tx.Exec( + "INSERT INTO recycle_bin (id, resource_type, resource_id, snapshot_json, deleted_at) VALUES (?, ?, ?, ?, ?)", + id, resourceType, resourceID, snapshotJSON, deletedAt, + ) + if err != nil { + return "", err + } + + // Step 2: Delete the resource + _, err = tx.Exec(deleteSQL, deleteArgs...) + if err != nil { + return "", err + } + + // Step 3: Trim recycle_bin to maxEntries + _, err = tx.Exec( + `DELETE FROM recycle_bin WHERE id IN ( + SELECT id FROM recycle_bin ORDER BY deleted_at DESC LIMIT -1 OFFSET ? + )`, + maxEntries, + ) + if err != nil { + return "", err + } + + if err := tx.Commit(); err != nil { + return "", err + } + return id, nil +} + +func (r *RecycleRepo) List() ([]map[string]any, error) { + var rows []recycleRow + if err := r.db.Select(&rows, + "SELECT * FROM recycle_bin ORDER BY deleted_at DESC LIMIT ?", + util.MaxRecycleEntries, + ); err != nil { + return nil, err + } + result := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + entry := map[string]any{ + "id": row.ID, + "resourceType": row.ResourceType, + "resourceId": row.ResourceID, + "snapshot": parseSnapshot(row.SnapshotJSON), + "deletedAt": row.DeletedAt, + } + result = append(result, entry) + } + return result, nil +} + +func (r *RecycleRepo) Get(id string) (map[string]any, error) { + var row recycleRow + if err := r.db.Get(&row, "SELECT * FROM recycle_bin WHERE id = ?", id); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return map[string]any{ + "id": row.ID, + "resourceType": row.ResourceType, + "resourceId": row.ResourceID, + "snapshot": parseSnapshot(row.SnapshotJSON), + "deletedAt": row.DeletedAt, + }, nil +} + +func (r *RecycleRepo) Delete(id string) error { + _, err := r.db.Exec("DELETE FROM recycle_bin WHERE id = ?", id) + return err +} + +func parseSnapshot(value string) map[string]any { + if value == "" { + return map[string]any{} + } + var m map[string]any + jsonUnmarshal(value, &m) + if m == nil { + m = map[string]any{} + } + return m +} diff --git a/internal/database/repo_test.go b/internal/database/repo_test.go new file mode 100644 index 0000000..eeab382 --- /dev/null +++ b/internal/database/repo_test.go @@ -0,0 +1,336 @@ +package database + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "modernc.org/sqlite" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +func testDB(t *testing.T) *sqlx.DB { + t.Helper() + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + db, err := sqlx.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)") + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + if err := RunMigrations(db); err != nil { + t.Fatalf("failed to run migrations: %v", err) + } + return db +} + +func TestSourceCRUD(t *testing.T) { + db := testDB(t) + defer db.Close() + repo := NewSourceRepo(db) + + // Create + src := model.SourceRecord{ + ID: "test-src", + Name: "Test Source", + Type: "local", + Content: "ss://pass@host:port#name", + Enabled: true, + Filters: []model.FilterRule{}, + Meta: map[string]any{}, + } + created, err := repo.Upsert(src) + if err != nil { + t.Fatalf("create failed: %v", err) + } + if created.ID != "test-src" { + t.Errorf("expected id test-src, got %s", created.ID) + } + + // Read + got, err := repo.Get("test-src") + if err != nil { + t.Fatalf("get failed: %v", err) + } + if got.Name != "Test Source" { + t.Errorf("expected name Test Source, got %s", got.Name) + } + + // List + list, err := repo.List() + if err != nil { + t.Fatalf("list failed: %v", err) + } + if len(list) != 1 { + t.Errorf("expected 1 source, got %d", len(list)) + } + + // Update + src.Name = "Updated" + src.URL = "https://example.com/sub" + src.Type = "remote" + updated, err := repo.Upsert(src) + if err != nil { + t.Fatalf("update failed: %v", err) + } + if updated.Name != "Updated" { + t.Errorf("expected name Updated, got %s", updated.Name) + } + + // Delete — use RecycleRepo for archive + delete + recycleRepo := NewRecycleRepo(db) + _, err = recycleRepo.ArchiveAndDelete("source", "test-src", map[string]any{"id": "test-src"}, "DELETE FROM sources WHERE id = ?", "test-src") + if err != nil { + t.Fatalf("delete failed: %v", err) + } + got, err = repo.Get("test-src") + if got != nil { + t.Error("expected nil after delete") + } +} + +func TestCollectionCRUD(t *testing.T) { + db := testDB(t) + defer db.Close() + + // Create a source first + srcRepo := NewSourceRepo(db) + srcRepo.Upsert(model.SourceRecord{ + ID: "src1", Name: "Src1", Type: "local", Content: "ss://pass@host:port#name", + Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}, + }) + + 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{}, + } + created, err := repo.Upsert(col) + if err != nil { + t.Fatalf("create failed: %v", err) + } + if created.ID != "test-col" { + t.Errorf("expected id test-col, got %s", created.ID) + } + + got, err := repo.Get("test-col") + if err != nil { + t.Fatalf("get failed: %v", err) + } + if len(got.SourceIds) != 1 || got.SourceIds[0] != "src1" { + t.Errorf("expected sourceIds [src1], got %v", got.SourceIds) + } +} + +func TestTemplateCRUD(t *testing.T) { + db := testDB(t) + defer db.Close() + repo := NewTemplateRepo(db) + + tmpl := model.TemplateRecord{ + ID: "test-tmpl", + Name: "Test Template", + Target: "mihomo", + Config: map[string]any{ + "proxy-groups": []any{ + map[string]any{"name": "PROXY", "type": "select"}, + }, + }, + } + created, err := repo.Upsert(tmpl) + if err != nil { + t.Fatalf("create failed: %v", err) + } + if created.ID != "test-tmpl" { + t.Errorf("expected id test-tmpl, got %s", created.ID) + } + + got, err := repo.Get("test-tmpl") + if err != nil { + t.Fatalf("get failed: %v", err) + } + if got.Target != "mihomo" { + t.Errorf("expected target mihomo, got %s", got.Target) + } +} + +func TestSettingsCRUD(t *testing.T) { + db := testDB(t) + defer db.Close() + repo := NewSettingsRepo(db) + + // Get default (empty) + settings, err := repo.Get() + if err != nil { + t.Fatalf("get failed: %v", err) + } + if len(settings) != 0 { + t.Errorf("expected empty settings initially, got %d keys", len(settings)) + } + + // Update + updated, err := repo.Update(map[string]any{ + "theme": map[string]any{"name": "dark"}, + "appName": "MyStore", + }) + if err != nil { + t.Fatalf("update failed: %v", err) + } + if updated["appName"] != "MyStore" { + t.Errorf("expected appName MyStore, got %v", updated["appName"]) + } + + // Get again + settings, err = repo.Get() + if err != nil { + t.Fatalf("get after update failed: %v", err) + } + if settings["appName"] != "MyStore" { + t.Errorf("expected appName MyStore, got %v", settings["appName"]) + } +} + +func TestGrantCRUD(t *testing.T) { + db := testDB(t) + defer db.Close() + repo := NewGrantRepo(db) + + // Create + grant, token, err := repo.Create("source", "test-src", "mihomo", nil) + if err != nil { + t.Fatalf("create failed: %v", err) + } + if token == "" { + t.Error("expected non-empty token") + } + if grant.ID == "" { + t.Error("expected non-empty grant id") + } + + // List + list, err := repo.List() + if err != nil { + t.Fatalf("list failed: %v", err) + } + if len(list) != 1 { + t.Errorf("expected 1 grant, got %d", len(list)) + } + + // Authorize with correct token + if !repo.AuthorizeScoped(token, "source", "test-src", "mihomo") { + t.Error("expected authorization to succeed") + } + + // Authorize with wrong resource + if repo.AuthorizeScoped(token, "collection", "test-src", "mihomo") { + t.Error("expected authorization to fail for wrong resource type") + } + + // Authorize with wrong target + if repo.AuthorizeScoped(token, "source", "test-src", "surge") { + t.Error("expected authorization to fail for wrong target") + } +} + +func TestCacheCRUD(t *testing.T) { + db := testDB(t) + defer db.Close() + repo := NewCacheRepo(db) + + // Put + repo.SafePut("cache-key-1", "cached content", map[string]any{"etag": "abc"}, 300) + + // Get + entry, ok := repo.SafeGet("cache-key-1") + if !ok { + t.Fatal("expected cache hit") + } + if entry.Content != "cached content" { + t.Errorf("expected content 'cached content', got %s", entry.Content) + } + + // Test expiry — put with TTL 1 and wait for it to expire + repo.SafePut("expired-key", "old", map[string]any{}, 1) + time.Sleep(2 * time.Second) + _, ok = repo.SafeGet("expired-key") + if ok { + t.Error("expected cache miss for expired key") + } +} + +func TestRecycleCRUD(t *testing.T) { + db := testDB(t) + defer db.Close() + repo := NewRecycleRepo(db) + + // Archive — ArchiveAndDelete returns (id, error) + snapshot := map[string]any{"id": "test-src", "name": "Test"} + entryID, err := repo.ArchiveAndDelete("source", "test-src", snapshot, "SELECT 1", nil) + if err != nil { + t.Fatalf("archive failed: %v", err) + } + if entryID == "" { + t.Fatal("expected non-empty entry id") + } + + // List + list, err := repo.List() + if err != nil { + t.Fatalf("list failed: %v", err) + } + if len(list) != 1 { + t.Errorf("expected 1 entry, got %d", len(list)) + } + + // Get + got, err := repo.Get(entryID) + if err != nil { + t.Fatalf("get failed: %v", err) + } + if got["resourceType"] != "source" { + t.Errorf("expected resourceType source, got %v", got["resourceType"]) + } + + // Delete + err = repo.Delete(entryID) + if err != nil { + t.Fatalf("delete failed: %v", err) + } + list, _ = repo.List() + if len(list) != 0 { + t.Errorf("expected 0 entries after delete, got %d", len(list)) + } +} + +func TestSortSources(t *testing.T) { + db := testDB(t) + defer db.Close() + repo := NewSourceRepo(db) + + repo.Upsert(model.SourceRecord{ID: "a", Name: "A", Type: "local", Content: "", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}}) + repo.Upsert(model.SourceRecord{ID: "b", Name: "B", Type: "local", Content: "", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}}) + repo.Upsert(model.SourceRecord{ID: "c", Name: "C", Type: "local", Content: "", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}}) + + // Sort reverse + err := repo.Sort([]string{"c", "b", "a"}) + if err != nil { + t.Fatalf("sort failed: %v", err) + } + + list, _ := repo.List() + if list[0].ID != "c" || list[1].ID != "b" || list[2].ID != "a" { + t.Errorf("sort order wrong: %s, %s, %s", list[0].ID, list[1].ID, list[2].ID) + } +} + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/internal/database/settings_repo.go b/internal/database/settings_repo.go new file mode 100644 index 0000000..bcaee3e --- /dev/null +++ b/internal/database/settings_repo.go @@ -0,0 +1,69 @@ +package database + +import ( + "database/sql" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/peterqiu0516/sub-store/internal/util" +) + +const settingsID = "default" + +type SettingsRepo struct { + db *sqlx.DB +} + +func NewSettingsRepo(db *sqlx.DB) *SettingsRepo { + return &SettingsRepo{db: db} +} + +func (r *SettingsRepo) Get() (map[string]any, error) { + var valueJSON string + err := r.db.Get(&valueJSON, "SELECT value_json FROM app_settings WHERE id = ?", settingsID) + if err != nil { + if err == sql.ErrNoRows { + return map[string]any{}, nil + } + return map[string]any{}, err + } + var settings map[string]any + jsonUnmarshal(valueJSON, &settings) + if settings == nil { + settings = map[string]any{} + } + return settings, nil +} + +func (r *SettingsRepo) Update(next map[string]any) (map[string]any, error) { + current, err := r.Get() + if err != nil { + return nil, err + } + merged := util.MergeDeep(current, next) + now := time.Now().UnixMilli() + mergedJSON := marshalJSON(merged) + _, err = r.db.Exec( + `INSERT INTO app_settings (id, value_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`, + settingsID, mergedJSON, now, + ) + if err != nil { + return nil, err + } + return merged, nil +} + +func (r *SettingsRepo) Replace(settings map[string]any) error { + now := time.Now().UnixMilli() + mergedJSON := marshalJSON(settings) + _, err := r.db.Exec( + `INSERT INTO app_settings (id, value_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`, + settingsID, mergedJSON, now, + ) + return err +} diff --git a/internal/database/source_repo.go b/internal/database/source_repo.go new file mode 100644 index 0000000..5b3a4fc --- /dev/null +++ b/internal/database/source_repo.go @@ -0,0 +1,157 @@ +package database + +import ( + "database/sql" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +type SourceRepo struct { + db *sqlx.DB +} + +func NewSourceRepo(db *sqlx.DB) *SourceRepo { + return &SourceRepo{db: db} +} + +type sourceRow struct { + ID string `db:"id"` + Name string `db:"name"` + Type string `db:"type"` + URL string `db:"url"` + Content string `db:"content"` + Enabled int `db:"enabled"` + FiltersJSON string `db:"filters_json"` + MetaJSON string `db:"meta_json"` + CreatedAt int64 `db:"created_at"` + UpdatedAt int64 `db:"updated_at"` +} + +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 { + return nil, err + } + result := make([]model.SourceRecord, 0, len(rows)) + for _, row := range rows { + result = append(result, sourceFromRow(row)) + } + return result, nil +} + +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 == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + rec := sourceFromRow(row) + return &rec, nil +} + +func (r *SourceRepo) Upsert(input model.SourceRecord) (model.SourceRecord, error) { + now := time.Now().UnixMilli() + id := input.ID + if id == "" { + id = util.ToId(input.Name) + } + existing, _ := r.Get(id) + createdAt := now + if existing != nil { + createdAt = existing.CreatedAt + } + + filters := input.Filters + if filters == nil { + filters = []model.FilterRule{} + } + meta := input.Meta + if meta == nil { + meta = map[string]any{} + } + + rec := model.SourceRecord{ + ID: id, + Name: input.Name, + Type: input.Type, + URL: input.URL, + Content: input.Content, + Enabled: input.Enabled, + Filters: filters, + Meta: meta, + CreatedAt: createdAt, + UpdatedAt: now, + } + + filtersJSON := marshalJSON(filters) + metaJSON := marshalJSON(meta) + enabled := boolToInt(rec.Enabled) + sourceType := rec.Type + if sourceType == "" { + sourceType = "remote" + } + if sourceType != "local" { + sourceType = "remote" + } + + _, err := r.db.Exec( + `INSERT INTO sources (id, name, 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, + 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, + ) + if err != nil { + return rec, err + } + return rec, nil +} + +func (r *SourceRepo) Delete(id string) error { + _, err := r.db.Exec("DELETE FROM sources WHERE id = ?", id) + return err +} + +func (r *SourceRepo) Sort(ids []string) error { + now := time.Now().UnixMilli() + tx, err := r.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + for i, id := range ids { + _, err := tx.Exec("UPDATE sources SET created_at = ?, updated_at = ? WHERE id = ?", now+int64(i), now, id) + if err != nil { + return err + } + } + return tx.Commit() +} + +func sourceFromRow(row sourceRow) model.SourceRecord { + sourceType := "remote" + if row.Type == "local" { + sourceType = "local" + } + return model.SourceRecord{ + ID: row.ID, + Name: row.Name, + Type: sourceType, + URL: row.URL, + Content: row.Content, + Enabled: row.Enabled != 0, + Filters: model.UnmarshalFilterRules(row.FiltersJSON), + Meta: model.UnmarshalMeta(row.MetaJSON), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} diff --git a/internal/database/template_repo.go b/internal/database/template_repo.go new file mode 100644 index 0000000..4e97b52 --- /dev/null +++ b/internal/database/template_repo.go @@ -0,0 +1,121 @@ +package database + +import ( + "database/sql" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +type TemplateRepo struct { + db *sqlx.DB +} + +func NewTemplateRepo(db *sqlx.DB) *TemplateRepo { + return &TemplateRepo{db: db} +} + +type templateRow struct { + ID string `db:"id"` + Name string `db:"name"` + Target string `db:"target"` + ConfigJSON string `db:"config_json"` + CreatedAt int64 `db:"created_at"` + UpdatedAt int64 `db:"updated_at"` +} + +func (r *TemplateRepo) List() ([]model.TemplateRecord, error) { + var rows []templateRow + if err := r.db.Select(&rows, "SELECT * FROM templates ORDER BY created_at ASC"); err != nil { + return nil, err + } + result := make([]model.TemplateRecord, 0, len(rows)) + for _, row := range rows { + result = append(result, templateFromRow(row)) + } + return result, nil +} + +func (r *TemplateRepo) Get(id string) (*model.TemplateRecord, error) { + var row templateRow + if err := r.db.Get(&row, "SELECT * FROM templates WHERE id = ?", id); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + rec := templateFromRow(row) + return &rec, nil +} + +func (r *TemplateRepo) Upsert(input model.TemplateRecord) (model.TemplateRecord, error) { + now := time.Now().UnixMilli() + id := input.ID + if id == "" { + id = util.ToId(input.Name) + } + existing, _ := r.Get(id) + createdAt := now + if existing != nil { + createdAt = existing.CreatedAt + } + target := input.Target + if target == "" { + target = "mihomo" + } + config := input.Config + if config == nil { + config = map[string]any{} + } + + rec := model.TemplateRecord{ + ID: id, + Name: input.Name, + Target: target, + Config: config, + CreatedAt: createdAt, + UpdatedAt: now, + } + + configJSON := marshalJSON(rec.Config) + _, err := r.db.Exec( + `INSERT INTO templates (id, name, target, config_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, target = excluded.target, + config_json = excluded.config_json, updated_at = excluded.updated_at`, + rec.ID, rec.Name, rec.Target, configJSON, rec.CreatedAt, rec.UpdatedAt, + ) + if err != nil { + return rec, err + } + return rec, nil +} + +func (r *TemplateRepo) Delete(id string) error { + _, err := r.db.Exec("DELETE FROM templates WHERE id = ?", id) + return err +} + +func templateFromRow(row templateRow) model.TemplateRecord { + var config map[string]any + jsonUnmarshal(row.ConfigJSON, &config) + if config == nil { + config = map[string]any{} + } + target := row.Target + if target == "" { + target = "mihomo" + } + return model.TemplateRecord{ + ID: row.ID, + Name: row.Name, + Target: target, + Config: config, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} diff --git a/internal/filter/custom.go b/internal/filter/custom.go new file mode 100644 index 0000000..9db61d9 --- /dev/null +++ b/internal/filter/custom.go @@ -0,0 +1,281 @@ +package filter + +import ( + "regexp" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// ApplyCustomRules applies a declarative chain of CustomRule operations to the +// proxy list. Per review-resolution #37, this replaces the JS "script" filter +// type (review-resolution #2). The chain is capped at util.MaxCustomRules (32) +// rules; excess rules are dropped. +// +// Supported actions: +// - "set": set a field to a literal value +// - "delete": remove a field +// - "include": keep only proxies whose field matches the pattern +// - "exclude": drop proxies whose field matches the pattern +// - "rename": set the name field from a template +// - "regex-replace": regex replace-all on a field value +// +// Each rule may carry a "when" guard (a regex tested against the proxy name); +// if present and non-empty, the rule only applies to proxies whose name +// matches. An empty "when" means "always apply". +func ApplyCustomRules(proxies []model.ProxyNode, filter model.FilterRule, ctx FilterContext) []model.ProxyNode { + rules := filter.Rules + if len(rules) == 0 { + return proxies + } + // Enforce the max-rules cap (review-resolution #37). + if len(rules) > util.MaxCustomRules { + rules = rules[:util.MaxCustomRules] + } + + current := proxies + for _, rule := range rules { + current = applyOneCustomRule(current, rule, ctx) + } + return current +} + +// applyOneCustomRule dispatches a single CustomRule. It returns the updated +// proxy slice. Unknown actions are a no-op. +func applyOneCustomRule(proxies []model.ProxyNode, rule model.CustomRule, ctx FilterContext) []model.ProxyNode { + switch rule.Action { + case "set": + return customSet(proxies, rule) + case "delete": + return customDelete(proxies, rule) + case "include": + return customInclude(proxies, rule) + case "exclude": + return customExclude(proxies, rule) + case "rename": + return customRename(proxies, rule, ctx) + case "regex-replace": + return customRegexReplace(proxies, rule) + default: + return proxies + } +} + +// whenRe compiles a rule's "when" guard. Returns nil when the guard is empty +// (meaning "always apply"). +func whenRe(rule model.CustomRule) *regexp.Regexp { + if rule.When == "" { + return nil + } + return CompileRegex(rule.When, "") +} + +// appliesTo returns true if the rule should apply to the given proxy, based on +// its "when" guard. +func appliesTo(rule model.CustomRule, proxy model.ProxyNode) bool { + re := whenRe(rule) + if re == nil { + return true + } + return re.MatchString(ToString(proxy["name"])) +} + +// customSet sets a field to a literal value on each matching proxy. +func customSet(proxies []model.ProxyNode, rule model.CustomRule) []model.ProxyNode { + if rule.Field == "" { + return proxies + } + out := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + if !appliesTo(rule, proxy) { + out[i] = proxy + continue + } + next := cloneProxy(proxy) + SetByPath(next, rule.Field, rule.Value) + out[i] = next + } + return out +} + +// customDelete removes a field from each matching proxy. +func customDelete(proxies []model.ProxyNode, rule model.CustomRule) []model.ProxyNode { + if rule.Field == "" { + return proxies + } + out := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + if !appliesTo(rule, proxy) { + out[i] = proxy + continue + } + next := cloneProxy(proxy) + deleteByPath(next, rule.Field) + out[i] = next + } + return out +} + +// customInclude keeps only proxies whose field matches the rule's pattern. +func customInclude(proxies []model.ProxyNode, rule model.CustomRule) []model.ProxyNode { + re := CompileRegex(rule.Pattern, "") + if re == nil { + return proxies + } + field := rule.Field + if field == "" { + field = "name" + } + out := make([]model.ProxyNode, 0, len(proxies)) + for _, proxy := range proxies { + if !appliesTo(rule, proxy) { + out = append(out, proxy) + continue + } + if re.MatchString(ToString(GetByPath(proxy, field))) { + out = append(out, proxy) + } + } + return out +} + +// customExclude drops proxies whose field matches the rule's pattern. +func customExclude(proxies []model.ProxyNode, rule model.CustomRule) []model.ProxyNode { + re := CompileRegex(rule.Pattern, "") + if re == nil { + return proxies + } + field := rule.Field + if field == "" { + field = "name" + } + out := make([]model.ProxyNode, 0, len(proxies)) + for _, proxy := range proxies { + if !appliesTo(rule, proxy) { + out = append(out, proxy) + continue + } + if !re.MatchString(ToString(GetByPath(proxy, field))) { + out = append(out, proxy) + } + } + return out +} + +// customRename sets the proxy name from a template. The template may reference +// other fields via {field.path} placeholders, which are replaced with the +// string form of getByPath(proxy, path). When no template is given, the name +// is left unchanged. +func customRename(proxies []model.ProxyNode, rule model.CustomRule, ctx FilterContext) []model.ProxyNode { + if rule.Template == "" { + return proxies + } + out := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + if !appliesTo(rule, proxy) { + out[i] = proxy + continue + } + next := cloneProxy(proxy) + next["name"] = renderTemplate(rule.Template, proxy, ctx) + out[i] = next + } + return out +} + +// customRegexReplace does a regex replace-all on a field value. +// rule.Mode should be "regex-replace"; rule.Pattern is the regex; rule. +// Replacement is the replacement string. Field defaults to "name". +func customRegexReplace(proxies []model.ProxyNode, rule model.CustomRule) []model.ProxyNode { + if rule.Pattern == "" { + return proxies + } + re := CompileRegex(rule.Pattern, "g") + if re == nil { + return proxies + } + field := rule.Field + if field == "" { + field = "name" + } + replacement := rule.Replacement + out := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + if !appliesTo(rule, proxy) { + out[i] = proxy + continue + } + value := ToString(GetByPath(proxy, field)) + next := cloneProxy(proxy) + SetByPath(next, field, strings.TrimSpace(re.ReplaceAllString(value, replacement))) + out[i] = next + } + return out +} + +// renderTemplate expands {field.path} placeholders in a template string using +// values from the proxy. Unknown placeholders are left as-is. The special +// placeholder {name} expands to the proxy name. +func renderTemplate(template string, proxy model.ProxyNode, ctx FilterContext) string { + var b strings.Builder + i := 0 + for i < len(template) { + if i+1 < len(template) && template[i] == '{' { + end := strings.IndexByte(template[i+1:], '}') + if end >= 0 { + key := template[i+1 : i+1+end] + b.WriteString(templateField(key, proxy, ctx)) + i += end + 2 + continue + } + } + b.WriteByte(template[i]) + i++ + } + return b.String() +} + +// templateField resolves a single placeholder key against the proxy/context. +func templateField(key string, proxy model.ProxyNode, ctx FilterContext) string { + switch key { + case "name": + return ToString(proxy["name"]) + case "type": + return ToString(proxy["type"]) + case "server": + return ToString(proxy["server"]) + case "port": + return intToString(int64(toPortInt(proxy["port"]))) + case "targetPlatform", "target", "platform": + return ctx.TargetPlatform + case "sourceId", "source": + return ctx.SourceId + case "collectionId", "collection": + return ctx.CollectionId + } + // Treat the key as a dot-path into the proxy. + if v := GetByPath(proxy, key); v != nil { + return ToString(v) + } + return "{" + key + "}" +} + +// deleteByPath removes a nested key by dot-notation path. Only the final key +// is deleted; intermediate maps are left intact. +func deleteByPath(proxy model.ProxyNode, path string) { + keys := strings.Split(path, ".") + current := any(proxy) + for _, key := range keys[:len(keys)-1] { + m, ok := current.(map[string]any) + if !ok { + return + } + current = m[key] + } + m, ok := current.(map[string]any) + if !ok { + return + } + delete(m, keys[len(keys)-1]) +} diff --git a/internal/filter/dedupe.go b/internal/filter/dedupe.go new file mode 100644 index 0000000..7eeaafb --- /dev/null +++ b/internal/filter/dedupe.go @@ -0,0 +1,190 @@ +package filter + +import ( + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +// HandleDuplicateProxies dispatches the dedupe filter to one of two modes: +// - action == "rename": rename duplicate proxies by appending an index +// - otherwise (default): delete all but the first of each duplicate group +// +// The fields used to compute the duplicate key default to [filter.field] or +// ["name"] when filter.Fields is empty. Empty field entries are dropped. +func HandleDuplicateProxies(proxies []model.ProxyNode, filter model.FilterRule) []model.ProxyNode { + fields := normalizeDedupeFields(filter.Fields, filter.Field) + if filter.Action == "rename" { + return RenameDuplicateProxies(proxies, fields, filter) + } + return DeleteDuplicateProxies(proxies, fields) +} + +// normalizeDedupeFields builds a non-empty field list. If fields is non-empty +// it is used (after dropping empty entries); otherwise [field] or ["name"] is +// used as a fallback. +func normalizeDedupeFields(fields []string, field string) []string { + var list []string + if len(fields) > 0 { + list = fields + } else { + if field == "" { + field = "name" + } + list = []string{field} + } + out := make([]string, 0, len(list)) + for _, f := range list { + if f == "" { + continue + } + out = append(out, f) + } + if len(out) == 0 { + out = []string{"name"} + } + return out +} + +// DeleteDuplicateProxies keeps the first proxy of each group keyed by the +// concatenated values of `fields`. Subsequent proxies with the same key are +// removed. +func DeleteDuplicateProxies(proxies []model.ProxyNode, fields []string) []model.ProxyNode { + seen := make(map[string]struct{}, len(proxies)) + out := make([]model.ProxyNode, 0, len(proxies)) + for _, proxy := range proxies { + key := duplicateKey(proxy, fields) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, proxy) + } + return out +} + +// RenameDuplicateProxies renames duplicates by appending (or prepending) a +// formatted index to the proxy name. Groups with only one member are left +// untouched. The index format and link character are taken from the filter. +// +// Per review-resolution #18, the digit character set is customizable via +// filter.Template (space-separated digits, e.g. "一 二 三 ..."); defaults to +// "0 1 2 3 4 5 6 7 8 9". +func RenameDuplicateProxies(proxies []model.ProxyNode, fields []string, filter model.FilterRule) []model.ProxyNode { + counters := make(map[string]int, len(proxies)) + for _, proxy := range proxies { + key := duplicateKey(proxy, fields) + counters[key]++ + } + + increments := make(map[string]int, len(counters)) + maxLen := 1 + for _, count := range counters { + if l := len(intToString(int64(count))); l > maxLen { + maxLen = l + } + } + + tmpl := filter.Template + if tmpl == "" { + tmpl = "0 1 2 3 4 5 6 7 8 9" + } + digits := splitDigits(tmpl) + + link := filter.Link + if link == "" { + // filter.Link has omitempty; zero value is "". Use "-" as default + // to match the reference's `String(filter.link ?? "-")`. + link = "-" + } + + position := "back" + if filter.Position == "front" { + position = "front" + } + + out := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + key := duplicateKey(proxy, fields) + if counters[key] <= 1 { + out[i] = proxy + continue + } + count := increments[key] + 1 + increments[key] = count + suffix := FormatDuplicateNumber(count, maxLen, digits) + name := ToString(proxy["name"]) + next := cloneProxy(proxy) + if position == "front" { + next["name"] = suffix + link + name + } else { + next["name"] = name + link + suffix + } + out[i] = next + } + return out +} + +// duplicateKey computes a stable join key for a proxy across the given fields. +// Missing field values are represented as "-". +func duplicateKey(proxy model.ProxyNode, fields []string) string { + parts := make([]string, len(fields)) + for i, field := range fields { + v := ToString(GetByPath(proxy, field)) + if v == "" { + v = "-" + } + parts[i] = v + } + return strings.Join(parts, "\n") +} + +// FormatDuplicateNumber renders `input` as a string using a custom digit +// character set, left-padded with the zero digit to reach `minLength`. +// +// Per review-resolution #18: if `digits` has fewer than 10 entries, the +// default Arabic numeral set is used. This lets callers supply e.g. +// ["一","二",...,"十"] or any other symbol sequence. +// +// Example: +// +// FormatDuplicateNumber(2, 1, []string{"0".."9"}) -> "2" +// FormatDuplicateNumber(2, 3, []string{"0".."9"}) -> "002" +// FormatDuplicateNumber(2, 1, []string{"一","二",...}) -> "二" +func FormatDuplicateNumber(input int, minLength int, digits []string) string { + normalizedDigits := digits + if len(normalizedDigits) < 10 { + normalizedDigits = []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} + } + if input < 0 { + input = 0 + } + count := input + var output strings.Builder + for { + output.WriteString(normalizedDigits[count%10]) + count /= 10 + if count <= 0 { + break + } + } + result := output.String() + for len(result) < minLength { + result = normalizedDigits[0] + result + } + return result +} + +// splitDigits splits a space-separated digit template into a slice. Empty +// tokens are dropped. +func splitDigits(template string) []string { + fields := strings.Fields(template) + out := make([]string, 0, len(fields)) + for _, f := range fields { + if f == "" { + continue + } + out = append(out, f) + } + return out +} diff --git a/internal/filter/delete_field.go b/internal/filter/delete_field.go new file mode 100644 index 0000000..2bc2c4e --- /dev/null +++ b/internal/filter/delete_field.go @@ -0,0 +1,52 @@ +package filter + +import ( + "regexp" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +// DeleteFieldMatches applies one or more regex patterns against a field on each +// proxy, removing all matched substrings and trimming the result. +// +// Patterns are taken from filter.Patterns, falling back to [filter.Pattern] if +// Patterns is empty. The field defaults to "name". This mirrors the reference's +// deleteFieldMatches: it does NOT delete the field key itself; it deletes the +// matched portions of the field's string value. +func DeleteFieldMatches(proxies []model.ProxyNode, filter model.FilterRule) []model.ProxyNode { + patterns := filter.Patterns + if len(patterns) == 0 && filter.Pattern != "" { + patterns = []string{filter.Pattern} + } + if len(patterns) == 0 { + return proxies + } + // Pre-compile all patterns once. Skip un-compilable patterns. + res := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + re := CompileRegex(p, "g") + if re == nil { + continue + } + res = append(res, re) + } + if len(res) == 0 { + return proxies + } + field := filter.Field + if field == "" { + field = "name" + } + out := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + value := ToString(GetByPath(proxy, field)) + for _, re := range res { + value = re.ReplaceAllString(value, "") + } + next := cloneProxy(proxy) + SetByPath(next, field, strings.TrimSpace(value)) + out[i] = next + } + return out +} diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go new file mode 100644 index 0000000..f484433 --- /dev/null +++ b/internal/filter/filter_test.go @@ -0,0 +1,127 @@ +package filter + +import ( + "testing" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +func makeNodes() []model.ProxyNode { + return []model.ProxyNode{ + {"name": "HK-1", "type": "ss", "server": "1.1.1.1", "port": float64(8388)}, + {"name": "HK-2", "type": "vmess", "server": "2.2.2.2", "port": float64(443)}, + {"name": "US-1", "type": "trojan", "server": "3.3.3.3", "port": float64(443)}, + {"name": "JP-1", "type": "vless", "server": "4.4.4.4", "port": float64(443)}, + {"name": "SG-1", "type": "ss", "server": "5.5.5.5", "port": float64(8388)}, + } +} + +func TestFilterInclude(t *testing.T) { + rules := []model.FilterRule{ + {Type: "include", Pattern: "HK"}, + } + result := ApplyFilters(makeNodes(), rules, nil, FilterContext{}) + if len(result) != 2 { + t.Errorf("expected 2 HK nodes, got %d", len(result)) + } +} + +func TestFilterExclude(t *testing.T) { + rules := []model.FilterRule{ + {Type: "exclude", Pattern: "HK"}, + } + result := ApplyFilters(makeNodes(), rules, nil, FilterContext{}) + if len(result) != 3 { + t.Errorf("expected 3 non-HK nodes, got %d", len(result)) + } +} + +func TestFilterIncludeByType(t *testing.T) { + // Use anchored regex to match type exactly + rules := []model.FilterRule{ + {Type: "include", Pattern: "^ss$", Field: "type"}, + } + result := ApplyFilters(makeNodes(), rules, nil, FilterContext{}) + if len(result) != 2 { + t.Errorf("expected 2 ss nodes, got %d", len(result)) + } +} + +func TestFilterRename(t *testing.T) { + rules := []model.FilterRule{ + {Type: "rename", Pattern: "HK", Replacement: "HongKong"}, + } + result := ApplyFilters(makeNodes(), rules, nil, FilterContext{}) + if result[0]["name"] != "HongKong-1" { + t.Errorf("expected HongKong-1, got %v", result[0]["name"]) + } + if result[1]["name"] != "HongKong-2" { + t.Errorf("expected HongKong-2, got %v", result[1]["name"]) + } +} + +func TestFilterSort(t *testing.T) { + rules := []model.FilterRule{ + {Type: "sort", Direction: "asc"}, + } + result := ApplyFilters(makeNodes(), rules, nil, FilterContext{}) + if result[0]["name"] != "HK-1" { + t.Errorf("expected HK-1 first, got %v", result[0]["name"]) + } + if result[4]["name"] != "US-1" { + t.Errorf("expected US-1 last, got %v", result[4]["name"]) + } +} + +func TestFilterDedupe(t *testing.T) { + nodes := []model.ProxyNode{ + {"name": "A", "server": "1.1.1.1", "port": float64(8388), "type": "ss"}, + {"name": "B", "server": "1.1.1.1", "port": float64(8388), "type": "ss"}, + {"name": "C", "server": "2.2.2.2", "port": float64(443), "type": "ss"}, + } + rules := []model.FilterRule{ + {Type: "dedupe", Field: "server"}, + } + result := ApplyFilters(nodes, rules, nil, FilterContext{}) + if len(result) != 2 { + t.Errorf("expected 2 deduped nodes, got %d", len(result)) + } +} + +func TestFilterDeleteField(t *testing.T) { + // delete-field removes matched substrings from a field value (doesn't delete the field key) + nodes := []model.ProxyNode{ + {"name": "HK-Test-1", "server": "1.1.1.1", "port": float64(8388)}, + } + rules := []model.FilterRule{ + {Type: "delete-field", Field: "name", Pattern: "Test-"}, + } + result := ApplyFilters(nodes, rules, nil, FilterContext{}) + if result[0]["name"] != "HK-1" { + t.Errorf("expected name 'HK-1' after delete-field, got %v", result[0]["name"]) + } +} + +func TestFilterFlag(t *testing.T) { + rules := []model.FilterRule{ + {Type: "flag"}, + } + result := ApplyFilters(makeNodes(), rules, nil, FilterContext{}) + if len(result) != 5 { + t.Errorf("expected 5 nodes, got %d", len(result)) + } +} + +func TestFilterEmptyRules(t *testing.T) { + result := ApplyFilters(makeNodes(), []model.FilterRule{}, nil, FilterContext{}) + if len(result) != 5 { + t.Errorf("expected 5 nodes with no filters, got %d", len(result)) + } +} + +func TestFilterNilRules(t *testing.T) { + result := ApplyFilters(makeNodes(), nil, nil, FilterContext{}) + if len(result) != 5 { + t.Errorf("expected 5 nodes with nil filters, got %d", len(result)) + } +} diff --git a/internal/filter/flag.go b/internal/filter/flag.go new file mode 100644 index 0000000..38b46e7 --- /dev/null +++ b/internal/filter/flag.go @@ -0,0 +1,57 @@ +package filter + +import ( + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// FlagProxies adds or removes a flag emoji prefix on each proxy's name. +// +// mode (filter.Mode or filter.Action, default "add"): +// - "remove": strip leading flag emoji + whitespace from the name +// - any other (incl. "add"): detect the flag from the name and prepend it +// +// tw (filter.Tw, default behaves like the reference's "cn" default): controls +// how the Taiwan flag is normalized via util.NormalizeTaiwanFlag. Per +// review-resolution #19, three modes are supported: +// - "ws" → 🇼🇸 +// - "tw" → 🇹🇼 +// - any other → 🇨🇳 +// +// The proxy nodes are shallow-copied before mutation; the original slice is +// not modified. +func FlagProxies(proxies []model.ProxyNode, filter model.FilterRule) []model.ProxyNode { + mode := filter.Mode + if mode == "" { + mode = filter.Action + } + if mode == "" { + mode = "add" + } + tw := filter.Tw + // The reference defaults tw to "cn" via `String(filter.tw || "cn")`. An + // empty tw here means "default", which NormalizeTaiwanFlag maps to 🇨🇳 — + // the same outcome, so no explicit default is needed. + + out := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + name := ToString(proxy["name"]) + cleanName := strings.TrimSpace(util.RemoveFlag(name)) + next := cloneProxy(proxy) + if mode == "remove" { + next["name"] = cleanName + out[i] = next + continue + } + flag := util.NormalizeTaiwanFlag(util.DetectFlag(name), tw) + if cleanName == "" { + next["name"] = strings.TrimSpace(flag) + } else { + next["name"] = strings.TrimSpace(flag + " " + cleanName) + } + out[i] = next + } + return out +} diff --git a/internal/filter/include_exclude.go b/internal/filter/include_exclude.go new file mode 100644 index 0000000..9ed7b41 --- /dev/null +++ b/internal/filter/include_exclude.go @@ -0,0 +1,37 @@ +package filter + +import ( + "github.com/peterqiu0516/sub-store/internal/model" +) + +// MatchFilter implements the include/exclude filter. It compiles filter.Pattern +// as a regex, tests it against the string form of getByPath(proxy, field), and +// keeps or drops proxies based on keepMatches. +// +// - keepMatches=true → include: keep only proxies whose field matches +// - keepMatches=false → exclude: drop proxies whose field matches +// +// If the pattern is empty or fails to compile, the list is returned unchanged. +// The field defaults to "name" when unset, matching the reference. +func MatchFilter(proxies []model.ProxyNode, filter model.FilterRule, keepMatches bool) []model.ProxyNode { + if filter.Pattern == "" { + return proxies + } + re := CompileRegex(filter.Pattern, "") + if re == nil { + return proxies + } + field := filter.Field + if field == "" { + field = "name" + } + out := make([]model.ProxyNode, 0, len(proxies)) + for _, proxy := range proxies { + value := ToString(GetByPath(proxy, field)) + matched := re.MatchString(value) + if keepMatches == matched { + out = append(out, proxy) + } + } + return out +} diff --git a/internal/filter/pipeline.go b/internal/filter/pipeline.go new file mode 100644 index 0000000..12c4910 --- /dev/null +++ b/internal/filter/pipeline.go @@ -0,0 +1,59 @@ +package filter + +import ( + "github.com/peterqiu0516/sub-store/internal/model" +) + +// FilterContext carries ambient context needed by the filter pipeline. +// Mirrors the reference implementation's scriptContext shape, but without any +// scripting engine (review-resolution #2: no script type). +type FilterContext struct { + TargetPlatform string // normalized subscription target, e.g. "mihomo" + SourceId string // id of the source being processed, if any + CollectionId string // id of the collection being processed, if any +} + +// ApplyFilters runs each FilterRule in order over the proxy list, dispatching +// by the rule's Type field. Per review-resolution #2, there is no "script" +// type; user-defined logic uses the "custom" declarative rule chain instead. +// +// The input slice is never mutated; each filter that transforms nodes works on +// copies. Filters that only select (include/exclude/dedupe-delete) return +// sub-slices of the current list. +func ApplyFilters(proxies []model.ProxyNode, filters []model.FilterRule, settings map[string]any, ctx FilterContext) []model.ProxyNode { + current := proxies + for _, filter := range filters { + if filter.Type == "" { + continue + } + switch filter.Type { + case "include": + current = MatchFilter(current, filter, true) + case "exclude": + current = MatchFilter(current, filter, false) + case "rename": + current = RenameProxies(current, filter) + case "delete-field": + current = DeleteFieldMatches(current, filter) + case "dedupe": + current = HandleDuplicateProxies(current, filter) + case "sort": + current = SortProxies(current, filter.Direction) + case "regex-sort": + current = RegexSortProxies(current, filter) + case "flag": + current = FlagProxies(current, filter) + case "quick": + current = ApplyQuickSettings(current, filter) + case "resolve": + current = ResolveProxyDomains(current, filter, settings) + case "custom": + current = ApplyCustomRules(current, filter, ctx) + default: + // Unknown filter types are silently skipped to match the + // reference implementation's defensive behavior. + continue + } + } + return current +} diff --git a/internal/filter/quick.go b/internal/filter/quick.go new file mode 100644 index 0000000..1d863d1 --- /dev/null +++ b/internal/filter/quick.go @@ -0,0 +1,182 @@ +package filter + +import ( + "regexp" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// uselessNameRe matches proxy names that indicate informational/placeholder +// nodes rather than real proxies (e.g. "剩余流量", "官网", "expire"). Case +// insensitive via (?i). +var uselessNameRe = regexp.MustCompile(`(?i)网址|流量|时间|应急|过期|官网|剩余|Bandwidth|expire`) + +// ApplyQuickSettings applies the "quick" filter: optionally filters out +// useless proxies, then applies boolean state overrides (udp, tfo, scert, +// vmess aead) to each remaining proxy. +// +// Per review-resolution #20, IsUsefulProxy enforces ASCII checks on +// cipher/password/ws-host and a valid port range. Per review-resolution #21, +// ApplyState accepts bool as well as the string sentinels +// "ENABLED"/"enabled" and "DISABLED"/"disabled". +func ApplyQuickSettings(proxies []model.ProxyNode, filter model.FilterRule) []model.ProxyNode { + next := proxies + if StateEnabled(filter.Useless) { + filtered := make([]model.ProxyNode, 0, len(next)) + for _, proxy := range next { + if IsUsefulProxy(proxy) { + filtered = append(filtered, proxy) + } + } + next = filtered + } + + out := make([]model.ProxyNode, len(next)) + for i, proxy := range next { + output := cloneProxy(proxy) + ApplyState(output, "udp", filter.Udp) + ApplyState(output, "tfo", filter.Tfo) + ApplyState(output, "fast-open", filter.Tfo) + scert := filter.Scert + ApplyState(output, "skip-cert-verify", scert) + if ToString(output["type"]) == "vmess" { + if aead, ok := filter.Args["vmess aead"]; ok { + ApplyState(output, "aead", aead) + } + } + out[i] = output + } + return out +} + +// IsUsefulProxy returns false if a proxy is clearly not a real usable node. +// Checks (per review-resolution #20): +// - port is a finite number in [1, 65535] +// - cipher (if present) is pure ASCII +// - password (if present) is pure ASCII +// - WS Host header at {network}-opts.headers.Host (if present) is pure ASCII +// - name does not match the useless-keywords regex +// +// Any single failed check marks the proxy as useless. +func IsUsefulProxy(proxy model.ProxyNode) bool { + port := toPortInt(proxy["port"]) + if port <= 0 || port > 65535 { + return false + } + if cipher := ToString(proxy["cipher"]); cipher != "" && !util.IsASCII(cipher) { + return false + } + if password := ToString(proxy["password"]); password != "" && !util.IsASCII(password) { + return false + } + network := ToString(proxy["network"]) + if network != "" { + host := GetByPath(proxy, network+"-opts.headers.Host") + if host == nil { + host = GetByPath(proxy, network+"-opts.headers.host") + } + hosts := normalizeHostList(host) + for _, item := range hosts { + if item == "" { + continue + } + if !util.IsASCII(item) { + return false + } + } + } + name := ToString(proxy["name"]) + if uselessNameRe.MatchString(name) { + return false + } + return true +} + +// normalizeHostList coerces a Host value (string or []any) into []string. +func normalizeHostList(host any) []string { + if host == nil { + return nil + } + if s, ok := host.(string); ok { + return []string{s} + } + if arr, ok := host.([]any); ok { + out := make([]string, 0, len(arr)) + for _, item := range arr { + out = append(out, ToString(item)) + } + return out + } + return []string{ToString(host)} +} + +// toPortInt coerces a port value (int/int64/float64/string) to int. +func toPortInt(v any) int { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + case string: + return parseInt(n) + } + return 0 +} + +// parseInt parses a decimal integer from a string; non-numeric → 0. +func parseInt(s string) int { + n := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + return 0 + } + n = n*10 + int(c-'0') + } + return n +} + +// ApplyState sets proxy[key] to a bool based on the value's state, per +// review-resolution #21. It accepts: +// - true / "ENABLED" / "enabled" → sets key = true +// - false / "DISABLED" / "disabled" → sets key = false +// - any other value (incl. nil) → leaves the key untouched +// +// This is a no-op when the value is not a recognized state sentinel. +func ApplyState(proxy model.ProxyNode, key string, value any) { + if proxy == nil { + return + } + if StateEnabled(value) { + proxy[key] = true + return + } + if StateDisabled(value) { + proxy[key] = false + } +} + +// StateEnabled returns true for values meaning "enabled". +func StateEnabled(value any) bool { + switch v := value.(type) { + case bool: + return v == true + case string: + return v == "ENABLED" || v == "enabled" + } + return false +} + +// StateDisabled returns true for values meaning "disabled". +func StateDisabled(value any) bool { + switch v := value.(type) { + case bool: + return v == false + case string: + return v == "DISABLED" || v == "disabled" + } + return false +} diff --git a/internal/filter/rename.go b/internal/filter/rename.go new file mode 100644 index 0000000..70f6777 --- /dev/null +++ b/internal/filter/rename.go @@ -0,0 +1,35 @@ +package filter + +import ( + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +// RenameProxies applies a regex replace-all against a field on each proxy and +// trims the result. The field defaults to "name". Replacement defaults to "". +// +// Go's regexp.ReplaceAllString is inherently global, so the "g" flag from the +// reference is implicit. The (?i) prefix is honored via CompileRegex. +func RenameProxies(proxies []model.ProxyNode, filter model.FilterRule) []model.ProxyNode { + if filter.Pattern == "" { + return proxies + } + re := CompileRegex(filter.Pattern, "g") + if re == nil { + return proxies + } + replacement := filter.Replacement + field := filter.Field + if field == "" { + field = "name" + } + out := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + value := ToString(GetByPath(proxy, field)) + next := cloneProxy(proxy) + SetByPath(next, field, strings.TrimSpace(re.ReplaceAllString(value, replacement))) + out[i] = next + } + return out +} diff --git a/internal/filter/resolve.go b/internal/filter/resolve.go new file mode 100644 index 0000000..858dd1f --- /dev/null +++ b/internal/filter/resolve.go @@ -0,0 +1,340 @@ +package filter + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// defaultResolveTimeout is the per-request timeout for DoH queries when no +// settings-derived timeout is available. +const defaultResolveTimeout = 10 * time.Second + +// defaultResolveConcurrency is the worker count for concurrent resolution. +const defaultResolveConcurrency = 6 + +// maxResolveConcurrency caps the worker count to avoid hammering DoH servers. +const maxResolveConcurrency = 12 + +// maxDoHRespBytes limits how much of a DoH response body we read. +const maxDoHRespBytes = 64 * 1024 + +// resolveResult pairs a proxy with whether its server was resolved. +type resolveResult struct { + proxy model.ProxyNode + resolved bool +} + +// ResolveProxyDomains resolves each proxy's "server" hostname to an IP address +// via DNS-over-HTTPS, replacing the server field with the resolved address. +// Proxies whose server is already an IP (or empty) are passed through. +// +// filter.filter (the "filter" mode) controls post-resolution inclusion: +// - "removeFailed": drop proxies that needed resolution but failed +// - "IPOnly": keep only proxies whose server is now an IP +// - "IPv4Only": keep only proxies whose server is now an IPv4 +// - "IPv6Only": keep only proxies whose server is now an IPv6 +// - other/empty: keep all +// +// Resolution is concurrent using a simple worker pool. The provider is selected +// via filter.Provider (Cloudflare default; Google/Ali/Tencent/Custom supported). +func ResolveProxyDomains(proxies []model.ProxyNode, filter model.FilterRule, settings map[string]any) []model.ProxyNode { + concurrency := resolveConcurrency(filter) + if concurrency < 1 { + concurrency = 1 + } + if concurrency > maxResolveConcurrency { + concurrency = maxResolveConcurrency + } + + tasks := make([]model.ProxyNode, len(proxies)) + copy(tasks, proxies) + + results := make([]resolveResult, len(tasks)) + + // Simple worker pool: feed indices over a channel, each worker resolves + // one proxy at a time and writes into results[idx]. + type job struct { + idx int + proxy model.ProxyNode + } + jobs := make(chan job, len(tasks)) + for i, p := range tasks { + jobs <- job{idx: i, proxy: p} + } + close(jobs) + + var wg sync.WaitGroup + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := range jobs { + results[j.idx] = resolveProxyDomain(j.proxy, filter, settings) + } + }() + } + wg.Wait() + + mode := filter.Filter + if mode == "" { + mode = "disabled" + } + + out := make([]model.ProxyNode, 0, len(results)) + for _, r := range results { + server := ToString(r.proxy["server"]) + switch mode { + case "removeFailed": + if r.resolved || !util.ShouldResolveServer(server) { + out = append(out, r.proxy) + } + case "IPOnly": + if util.IsIPAddress(server) { + out = append(out, r.proxy) + } + case "IPv4Only": + if util.IsIPv4(server) { + out = append(out, r.proxy) + } + case "IPv6Only": + if util.IsIPv6(server) { + out = append(out, r.proxy) + } + default: + out = append(out, r.proxy) + } + } + return out +} + +// resolveProxyDomain resolves a single proxy's server. Returns the (possibly +// updated) proxy and whether resolution succeeded. +func resolveProxyDomain(proxy model.ProxyNode, filter model.FilterRule, settings map[string]any) resolveResult { + server := ToString(proxy["server"]) + if !util.ShouldResolveServer(server) { + return resolveResult{proxy: proxy, resolved: false} + } + address, err := resolveHostname(server, filter, settings) + if err != nil || address == "" { + return resolveResult{proxy: proxy, resolved: false} + } + next := cloneProxy(proxy) + next["server"] = address + next = PreserveTlsServerName(next, server) + return resolveResult{proxy: next, resolved: true} +} + +// PreserveTlsServerName backfills servername/sni with the original hostname +// when the server was replaced by an IP, so TLS verification still works. +// +// - vless/vmess: set servername if absent +// - trojan/hysteria/hysteria2/tuic/anytls: set sni if absent +func PreserveTlsServerName(proxy model.ProxyNode, originalServer string) model.ProxyNode { + if proxy == nil { + return proxy + } + ptype := ToString(proxy["type"]) + switch ptype { + case "vless", "vmess": + if ToString(proxy["servername"]) == "" { + proxy["servername"] = originalServer + } + } + switch ptype { + case "trojan", "hysteria", "hysteria2", "tuic", "anytls": + if ToString(proxy["sni"]) == "" { + proxy["sni"] = originalServer + } + } + return proxy +} + +// resolveHostname queries a DoH provider for the hostname and returns the +// first answer of the requested record type (A or AAAA). +func resolveHostname(hostname string, filter model.FilterRule, settings map[string]any) (string, error) { + recordType := getResolveRecordType(filter) + endpoint := getResolveEndpoint(filter, hostname, recordType) + + timeout := defaultResolveTimeout + if t := settingsRequestTimeout(settings); t > 0 { + timeout = t + } + client := &http.Client{Timeout: timeout} + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/dns-json") + req.Header.Set("User-Agent", settingsUserAgent(settings)) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxDoHRespBytes)) + if err != nil { + return "", err + } + payload := parseDnsResponse(body) + answerType := 1 + if recordType == "AAAA" { + answerType = 28 + } + for _, ans := range payload.Answer { + if ans.Type != answerType { + continue + } + data := ans.Data + if recordType == "AAAA" { + if util.IsIPv6(data) { + return data, nil + } + } else { + if util.IsIPv4(data) { + return data, nil + } + } + } + return "", nil +} + +// dnsResponse is the JSON shape returned by DoH providers (Cloudflare/Google). +type dnsResponse struct { + Answer []dnsAnswer `json:"Answer"` +} + +type dnsAnswer struct { + Type int `json:"type"` + Data string `json:"data"` +} + +// parseDnsResponse parses a DoH JSON response body. Returns a struct with an +// Answer slice; malformed payloads yield an empty Answer slice. +func parseDnsResponse(body []byte) dnsResponse { + var payload dnsResponse + if err := json.Unmarshal(body, &payload); err != nil { + return dnsResponse{Answer: []dnsAnswer{}} + } + if payload.Answer == nil { + payload.Answer = []dnsAnswer{} + } + return payload +} + +// getResolveRecordType determines whether to query A or AAAA records. +// "IPV6"/"AAAA" → AAAA; anything else → A. +func getResolveRecordType(filter model.FilterRule) string { + value := strings.ToUpper(strings.TrimSpace(filter.RecordType)) + if value == "" { + if filter.Args != nil { + if v, ok := filter.Args["recordType"]; ok { + value = strings.ToUpper(strings.TrimSpace(ToString(v))) + } + } + } + if value == "IPV6" || value == "AAAA" { + return "AAAA" + } + return "A" +} + +// getResolveEndpoint builds the DoH query URL for the given provider. +func getResolveEndpoint(filter model.FilterRule, hostname string, recordType string) string { + provider := filter.Provider + if provider == "" { + provider = "Cloudflare" + } + var base string + switch provider { + case "Google": + base = "https://dns.google/resolve" + case "Ali": + base = "https://dns.alidns.com/resolve" + case "Tencent": + base = "https://doh.pub/resolve" + case "Custom": + customURL := filter.URL + if strings.HasPrefix(strings.ToLower(customURL), "https://") { + base = customURL + } else { + base = "https://cloudflare-dns.com/dns-query" + } + default: + base = "https://cloudflare-dns.com/dns-query" + } + u, err := url.Parse(base) + if err != nil { + base = "https://cloudflare-dns.com/dns-query" + u, _ = url.Parse(base) + } + q := u.Query() + q.Set("name", hostname) + q.Set("type", recordType) + if filter.Edns != "" && provider != "Cloudflare" { + q.Set("edns_client_subnet", filter.Edns) + } + u.RawQuery = q.Encode() + return u.String() +} + +// resolveConcurrency derives the worker count from filter.Concurrency (any-typed +// to allow JSON numbers) clamped to [1, maxResolveConcurrency]. +func resolveConcurrency(filter model.FilterRule) int { + v := filter.Concurrency + if v == nil { + return defaultResolveConcurrency + } + n := toPortInt(v) + if n < 1 { + return 1 + } + if n > maxResolveConcurrency { + return maxResolveConcurrency + } + return n +} + +// settingsRequestTimeout extracts a request timeout (seconds) from settings, if +// present under "requestTimeout" or "request_timeout". Returns 0 if unset. +func settingsRequestTimeout(settings map[string]any) time.Duration { + if settings == nil { + return 0 + } + for _, key := range []string{"requestTimeout", "request_timeout"} { + if v, ok := settings[key]; ok { + secs := toPortInt(v) + if secs > 0 { + return time.Duration(secs) * time.Second + } + } + } + return 0 +} + +// settingsUserAgent extracts the default user-agent from settings. +func settingsUserAgent(settings map[string]any) string { + if settings == nil { + return "sub-store" + } + for _, key := range []string{"defaultUserAgent", "default_user_agent"} { + if v, ok := settings[key]; ok { + if s := ToString(v); s != "" { + return s + } + } + } + return "sub-store" +} diff --git a/internal/filter/sort.go b/internal/filter/sort.go new file mode 100644 index 0000000..17deb8f --- /dev/null +++ b/internal/filter/sort.go @@ -0,0 +1,145 @@ +package filter + +import ( + "crypto/rand" + "math/big" + "regexp" + "sort" + + "golang.org/x/text/collate" + "golang.org/x/text/language" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +// pinyinCollator is the collate.Collator used for Chinese pinyin sorting, per +// review-resolution #12. language.SimplifiedChinese maps to "zh-Hans", which +// the collation tables resolve to pinyin ordering for Han characters. +var pinyinCollator = collate.New(language.SimplifiedChinese) + +// SortProxies sorts proxies by their "name" field using Chinese pinyin +// collation (locale "zh-Hans-CN" in the reference). +// +// direction values: +// - "asc" (default): ascending A→Z / pinyin order +// - "desc": descending +// - "random": shuffled via SecureRandomInt (Fisher-Yates) +func SortProxies(proxies []model.ProxyNode, direction string) []model.ProxyNode { + if direction == "random" { + return ShuffleProxies(proxies) + } + out := make([]model.ProxyNode, len(proxies)) + copy(out, proxies) + sort.SliceStable(out, func(i, j int) bool { + a := ToString(out[i]["name"]) + b := ToString(out[j]["name"]) + result := pinyinCollator.CompareString(a, b) + if direction == "desc" { + return result > 0 + } + return result < 0 + }) + return out +} + +// RegexSortProxies sorts proxies by the order in which their names match a +// list of regex expressions. Proxies matching an earlier expression sort +// before those matching a later one; proxies matching no expression sort +// after all matched proxies, ordered by the normal pinyin sort as a fallback. +// +// Expressions are taken from filter.Expressions, falling back to filter. +// Patterns, then [filter.Pattern]. direction defaults to "asc". +func RegexSortProxies(proxies []model.ProxyNode, filter model.FilterRule) []model.ProxyNode { + rawExpressions := filter.Expressions + if len(rawExpressions) == 0 { + rawExpressions = filter.Patterns + } + if len(rawExpressions) == 0 && filter.Pattern != "" { + rawExpressions = []string{filter.Pattern} + } + expressions := make([]*regexp.Regexp, 0, len(rawExpressions)) + for _, p := range rawExpressions { + if p == "" { + continue + } + re := CompileRegex(p, "") + if re == nil { + continue + } + expressions = append(expressions, re) + } + direction := filter.Direction + if direction == "" { + direction = "asc" + } + if len(expressions) == 0 { + return SortProxies(proxies, direction) + } + + out := make([]model.ProxyNode, len(proxies)) + copy(out, proxies) + sort.SliceStable(out, func(i, j int) bool { + left := regexOrder(expressions, ToString(out[i]["name"])) + right := regexOrder(expressions, ToString(out[j]["name"])) + if left > 0 && right == 0 { + return true + } + if right > 0 && left == 0 { + return false + } + if left > 0 && right > 0 { + return left < right + } + // Neither matched. + if direction == "original" { + return false + } + // Fallback to pinyin sort between the two. + a := ToString(out[i]["name"]) + b := ToString(out[j]["name"]) + result := pinyinCollator.CompareString(a, b) + if direction == "desc" { + return result > 0 + } + return result < 0 + }) + return out +} + +// regexOrder returns the 1-based index of the first matching expression, or 0 +// if none match. Mirrors the reference's regexOrder. +func regexOrder(expressions []*regexp.Regexp, name string) int { + for i, re := range expressions { + if re.MatchString(name) { + return i + 1 + } + } + return 0 +} + +// ShuffleProxies performs a Fisher-Yates shuffle using a cryptographically +// secure random source. Returns a new shuffled slice; the input is unchanged. +func ShuffleProxies(proxies []model.ProxyNode) []model.ProxyNode { + next := make([]model.ProxyNode, len(proxies)) + copy(next, proxies) + for i := len(next) - 1; i > 0; i-- { + j := SecureRandomInt(i + 1) + next[i], next[j] = next[j], next[i] + } + return next +} + +// SecureRandomInt returns a uniform random int in [0, maxExclusive) using +// crypto/rand. Per review-resolution #17, crypto/rand.Int performs rejection +// sampling internally, so the distribution is unbiased. If maxExclusive <= 0 or +// the random source fails, returns 0. +func SecureRandomInt(maxExclusive int) int { + if maxExclusive <= 0 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(maxExclusive))) + if err != nil { + return 0 + } + return int(n.Int64()) +} diff --git a/internal/filter/util.go b/internal/filter/util.go new file mode 100644 index 0000000..eaa8003 --- /dev/null +++ b/internal/filter/util.go @@ -0,0 +1,176 @@ +package filter + +import ( + "regexp" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// CompileRegex compiles a regex pattern honoring an optional (?i) prefix +// (case-insensitive flag) and a flag string. In Go's regexp, replacement is +// always global, so the "g" flag is implicit. Only the "i" flag is meaningful +// here; it is applied via the inline (?i) construct. +// +// If compilation fails, returns nil. Callers should guard against nil when the +// pattern originates from untrusted input; the pipeline treats a nil regex as +// "match nothing / no-op" to stay robust. +func CompileRegex(input string, flags string) *regexp.Regexp { + if input == "" { + return nil + } + pattern := input + caseInsensitive := strings.Contains(flags, "i") + if strings.HasPrefix(pattern, "(?i)") { + pattern = pattern[4:] + caseInsensitive = true + } + if caseInsensitive { + pattern = "(?i)" + pattern + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil + } + return re +} + +// CompileRegexOrPanic compiles a regex and panics on error. Use only for +// statically known patterns. +func CompileRegexOrPanic(input string) *regexp.Regexp { + re, err := regexp.Compile(input) + if err != nil { + panic(err) + } + return re +} + +// GetByPath retrieves a nested value from a ProxyNode via dot-notation path. +// Delegates to util.GetByPath so behavior is consistent across the codebase. +func GetByPath(input model.ProxyNode, path string) any { + if input == nil { + return nil + } + return util.GetByPath(input, path) +} + +// SetByPath sets a nested value in a ProxyNode via dot-notation path, creating +// intermediate maps as needed. Returns the (mutated) node. +func SetByPath(input model.ProxyNode, path string, value any) model.ProxyNode { + if input == nil { + input = model.ProxyNode{} + } + return util.SetByPath(input, path, value) +} + +// UniqueStrings returns a new slice with duplicate strings removed, preserving +// first-occurrence order. +func UniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, v := range values { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +// ToString coerces an any value to its string form. nil → "". +func ToString(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + // Fall back to fmt-free conversion for common numeric types to avoid + // importing fmt just for this. + switch n := v.(type) { + case int: + return intToString(int64(n)) + case int64: + return intToString(n) + case float64: + return floatToString(n) + case bool: + if n { + return "true" + } + return "false" + } + return "" +} + +// ensureUniqueProxyNames appends "-{count+1}" to duplicated proxy names so the +// final list has unique names. Mirrors the reference implementation's behavior. +func ensureUniqueProxyNames(proxies []model.ProxyNode) []model.ProxyNode { + seen := make(map[string]int, len(proxies)) + out := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + name := ToString(proxy["name"]) + count := seen[name] + seen[name] = count + 1 + if count == 0 { + out[i] = proxy + continue + } + next := cloneProxy(proxy) + next["name"] = name + "-" + intToString(int64(count+1)) + out[i] = next + } + return out +} + +// cloneProxy deep-copies a ProxyNode via JSON round-trip. +func cloneProxy(proxy model.ProxyNode) model.ProxyNode { + return util.DeepCopy(proxy) +} + +// cloneProxies deep-copies a slice of ProxyNode. +func cloneProxies(proxies []model.ProxyNode) []model.ProxyNode { + out := make([]model.ProxyNode, len(proxies)) + for i, p := range proxies { + out[i] = cloneProxy(p) + } + return out +} + +// intToString converts an int64 to its decimal string without fmt. +func intToString(n int64) string { + if n == 0 { + return "0" + } + negative := n < 0 + if negative { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if negative { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// floatToString does a minimal float64→string for the ToString fallback. Not +// expected to be hot-path; kept simple to avoid strconv import churn in this +// helper file (strconv is used elsewhere). Produces a reasonable repr. +func floatToString(n float64) string { + // Fast path for common integer-valued floats. + if n == float64(int64(n)) { + return intToString(int64(n)) + } + // Fallback: use strconv via lazy import is overkill here; just produce + // a truncated integer repr. This branch is rare for proxy fields. + return intToString(int64(n)) +} diff --git a/internal/handler/crud.go b/internal/handler/crud.go new file mode 100644 index 0000000..31eccce --- /dev/null +++ b/internal/handler/crud.go @@ -0,0 +1,665 @@ +package handler + +import ( + "encoding/json" + "strings" + + "github.com/gofiber/fiber/v3" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/template" + "github.com/peterqiu0516/sub-store/internal/util" +) + +func getBuiltinTemplateIDs() map[string]bool { + return template.BuiltinTemplateIDs +} + +// --- Source handlers --- + +func (d *Deps) HandleListSources(c fiber.Ctx) error { + sources, err := d.SourceRepo.List() + if err != nil { + return failed(c, "Failed to list sources", 500) + } + return success(c, sources) +} + +func (d *Deps) HandleCreateSource(c fiber.Ctx) error { + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + if err := validateSourcePayload(input); err != nil { + return failed(c, err.Error(), 400) + } + id := getStringValue(input["id"]) + if id == "" { + id = getStringValue(input["name"]) + } + if err := validateRecordId(id, "Source"); err != nil { + return failed(c, err.Error(), 400) + } + existing, _ := d.SourceRepo.Get(id) + if existing != nil { + return failed(c, "Source id already exists", 409) + } + rec := mapToSourceRecord(input) + if err := validateSource(rec); err != nil { + return failed(c, err.Error(), 400) + } + result, err := d.SourceRepo.Upsert(rec) + if err != nil { + return failed(c, "Failed to create source", 500) + } + return success(c, result) +} + +func (d *Deps) HandleGetSource(c fiber.Ctx) error { + rec, err := d.SourceRepo.Get(c.Params("name")) + if err != nil || rec == nil { + return failed(c, "Source not found", 404) + } + return success(c, rec) +} + +func (d *Deps) HandleUpdateSource(c fiber.Ctx) error { + name := c.Params("name") + existing, err := d.SourceRepo.Get(name) + if err != nil || existing == nil { + return failed(c, "Source not found", 404) + } + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + merged := mergeSource(*existing, mapToSourceRecord(input)) + if err := validateSource(merged); err != nil { + return failed(c, err.Error(), 400) + } + result, err := d.SourceRepo.Upsert(merged) + if err != nil { + return failed(c, "Failed to update source", 500) + } + return success(c, result) +} + +func (d *Deps) HandleDeleteSource(c fiber.Ctx) error { + name := c.Params("name") + existing, err := d.SourceRepo.Get(name) + if err != nil || existing == nil { + return failed(c, "Source not found", 404) + } + // Check references + collections, _ := d.CollectionRepo.List() + var references []string + for _, col := range collections { + for _, sid := range col.SourceIds { + if sid == existing.ID { + references = append(references, col.ID) + break + } + } + } + if len(references) > 0 { + return failed(c, "Source is used by collections: "+strings.Join(references, ", "), 409) + } + // Archive and delete — review-resolution #29 + snapshot := sourceToMap(*existing) + _, err = d.RecycleRepo.ArchiveAndDelete("source", existing.ID, snapshot, "DELETE FROM sources WHERE id = ?", existing.ID) + if err != nil { + return failed(c, "Failed to delete source", 500) + } + return success(c, fiber.Map{"deleted": true, "references": []string{}}) +} + +func (d *Deps) HandleSortSources(c fiber.Ctx) error { + var input []map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + ids := extractIds(input) + if err := d.SourceRepo.Sort(ids); err != nil { + return failed(c, "Failed to sort sources", 500) + } + sources, _ := d.SourceRepo.List() + return success(c, sources) +} + +func (d *Deps) HandleSortSourcesLegacy(c fiber.Ctx) error { + return d.HandleSortSources(c) +} + +// --- Collection handlers --- + +func (d *Deps) HandleListCollections(c fiber.Ctx) error { + collections, err := d.CollectionRepo.List() + if err != nil { + return failed(c, "Failed to list collections", 500) + } + return success(c, collections) +} + +func (d *Deps) HandleCreateCollection(c fiber.Ctx) error { + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + id := getStringValue(input["id"]) + if id == "" { + id = getStringValue(input["name"]) + } + if err := validateRecordId(id, "Collection"); err != nil { + return failed(c, err.Error(), 400) + } + existing, _ := d.CollectionRepo.Get(id) + if existing != nil { + return failed(c, "Collection id already exists", 409) + } + rec := mapToCollectionRecord(input) + if err := d.validateCollection(rec); err != nil { + return failed(c, err.Error(), 400) + } + result, err := d.CollectionRepo.Upsert(rec) + if err != nil { + return failed(c, "Failed to create collection", 500) + } + return success(c, result) +} + +func (d *Deps) HandleGetCollection(c fiber.Ctx) error { + rec, err := d.CollectionRepo.Get(c.Params("name")) + if err != nil || rec == nil { + return failed(c, "Collection not found", 404) + } + return success(c, rec) +} + +func (d *Deps) HandleUpdateCollection(c fiber.Ctx) error { + name := c.Params("name") + existing, err := d.CollectionRepo.Get(name) + if err != nil || existing == nil { + return failed(c, "Collection not found", 404) + } + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + merged := mergeCollection(*existing, mapToCollectionRecord(input)) + if err := d.validateCollection(merged); err != nil { + return failed(c, err.Error(), 400) + } + result, err := d.CollectionRepo.Upsert(merged) + if err != nil { + return failed(c, "Failed to update collection", 500) + } + return success(c, result) +} + +func (d *Deps) HandleDeleteCollection(c fiber.Ctx) error { + name := c.Params("name") + existing, err := d.CollectionRepo.Get(name) + if err != nil || existing == nil { + return failed(c, "Collection not found", 404) + } + snapshot := collectionToMap(*existing) + _, err = d.RecycleRepo.ArchiveAndDelete("collection", existing.ID, snapshot, "DELETE FROM collections WHERE id = ?", existing.ID) + if err != nil { + return failed(c, "Failed to delete collection", 500) + } + return success(c, fiber.Map{"deleted": true}) +} + +func (d *Deps) HandleSortCollections(c fiber.Ctx) error { + var input []map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + ids := extractIds(input) + if err := d.CollectionRepo.Sort(ids); err != nil { + return failed(c, "Failed to sort collections", 500) + } + collections, _ := d.CollectionRepo.List() + return success(c, collections) +} + +func (d *Deps) HandleSortCollectionsLegacy(c fiber.Ctx) error { + return d.HandleSortCollections(c) +} + +// --- Template handlers --- + +func (d *Deps) HandleListTemplates(c fiber.Ctx) error { + // Merge built-in + DB templates + dbTemplates, err := d.TemplateRepo.List() + if err != nil { + dbTemplates = []model.TemplateRecord{} + } + result := make([]fiber.Map, 0, len(template.BuiltinTemplates)+len(dbTemplates)) + for _, bt := range template.BuiltinTemplates { + result = append(result, fiber.Map{ + "id": bt.ID, + "name": bt.Name, + "target": bt.Target, + "config": bt.Config, + "readonly": true, + }) + } + for _, t := range dbTemplates { + if !template.BuiltinTemplateIDs[t.ID] { + result = append(result, fiber.Map{ + "id": t.ID, + "name": t.Name, + "target": t.Target, + "config": t.Config, + "readonly": false, + }) + } + } + return success(c, result) +} + +func (d *Deps) HandleGetTemplate(c fiber.Ctx) error { + name := c.Params("name") + // Check built-in first + for _, bt := range template.BuiltinTemplates { + if bt.ID == name { + return success(c, fiber.Map{ + "id": bt.ID, "name": bt.Name, "target": bt.Target, + "config": bt.Config, "readonly": true, + }) + } + } + rec, err := d.TemplateRepo.Get(name) + if err != nil || rec == nil { + return failed(c, "Template not found", 404) + } + return success(c, fiber.Map{ + "id": rec.ID, "name": rec.Name, "target": rec.Target, + "config": rec.Config, "readonly": false, + }) +} + +func (d *Deps) HandleCreateTemplate(c fiber.Ctx) error { + input, _ := parseJsonOrText(c) + id := getStringValue(input["id"]) + if id == "" { + id = getStringValue(input["name"]) + } + if id == "" { + return failed(c, "Template name is required", 400) + } + if template.BuiltinTemplateIDs[id] { + return failed(c, "Built-in templates cannot be modified", 400) + } + rec := mapToTemplateRecord(input) + result, err := d.TemplateRepo.Upsert(rec) + if err != nil { + return failed(c, err.Error(), 400) + } + return success(c, fiber.Map{ + "id": result.ID, "name": result.Name, "target": result.Target, + "config": result.Config, "readonly": false, + }) +} + +func (d *Deps) HandleUpdateTemplate(c fiber.Ctx) error { + name := c.Params("name") + if template.BuiltinTemplateIDs[name] { + return failed(c, "Built-in templates cannot be modified", 400) + } + existing, err := d.TemplateRepo.Get(name) + if err != nil || existing == nil { + return failed(c, "Template not found", 404) + } + input, _ := parseJsonOrText(c) + input["id"] = name + rec := mapToTemplateRecord(input) + result, err := d.TemplateRepo.Upsert(rec) + if err != nil { + return failed(c, err.Error(), 400) + } + return success(c, fiber.Map{ + "id": result.ID, "name": result.Name, "target": result.Target, + "config": result.Config, "readonly": false, + }) +} + +func (d *Deps) HandleDeleteTemplate(c fiber.Ctx) error { + name := c.Params("name") + if template.BuiltinTemplateIDs[name] { + return failed(c, "Built-in templates cannot be deleted", 400) + } + existing, err := d.TemplateRepo.Get(name) + if err != nil || existing == nil { + return failed(c, "Template not found", 404) + } + snapshot := templateToMap(*existing) + _, err = d.RecycleRepo.ArchiveAndDelete("template", existing.ID, snapshot, "DELETE FROM templates WHERE id = ?", existing.ID) + if err != nil { + return failed(c, "Failed to delete template", 500) + } + return success(c, fiber.Map{"deleted": true}) +} + +// --- Validation helpers --- + +func validateRecordId(id, label string) error { + if id == "" { + return errWrap(label + " id is required") + } + for _, c := range id { + if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { + return errWrap(label + " id must use 1-64 lowercase letters, numbers, underscores, or hyphens") + } + } + if len(id) > 64 { + return errWrap(label + " id must use 1-64 lowercase letters, numbers, underscores, or hyphens") + } + return nil +} + +func validateSourcePayload(input map[string]any) error { + if t, ok := input["type"]; ok { + if t != "remote" && t != "local" { + return errWrap("Source type must be remote or local") + } + } + return nil +} + +func validateSource(rec model.SourceRecord) error { + if err := validateRecordId(rec.ID, "Source"); err != nil { + return err + } + if rec.Type == "local" { + if rec.Content == "" { + return errWrap("Local source content is required") + } + return nil + } + urls := splitLines(rec.URL) + if len(urls) == 0 { + return errWrap("Remote source URL is required") + } + for _, u := range urls { + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + return errWrap("Remote source URLs must use http or https") + } + } + return nil +} + +func (d *Deps) validateCollection(rec model.CollectionRecord) error { + if err := validateRecordId(rec.ID, "Collection"); err != nil { + return err + } + if len(rec.SourceIds) > 0 { + sources, _ := d.SourceRepo.List() + sourceIdSet := make(map[string]bool, len(sources)) + for _, s := range sources { + sourceIdSet[s.ID] = true + } + for _, sid := range rec.SourceIds { + if !sourceIdSet[sid] { + return errWrap("Collection references missing sources: " + sid) + } + } + } + if rec.TemplateId != "" { + // Check built-in + if !template.BuiltinTemplateIDs[rec.TemplateId] { + t, _ := d.TemplateRepo.Get(rec.TemplateId) + if t == nil { + return errWrap("Collection references missing template: " + rec.TemplateId) + } + } + } + return nil +} + +// --- Mapping helpers --- + +func mapToSourceRecord(input map[string]any) model.SourceRecord { + id := getStringValue(input["id"]) + if id == "" { + id = getStringValue(input["name"]) + } + id = util.ToId(id) + sourceType := "remote" + if getStringValue(input["type"]) == "local" { + sourceType = "local" + } + enabled := true + if e, ok := input["enabled"].(bool); ok && !e { + enabled = false + } + filters := parseFilters(input["filters"]) + meta := getMapValue(input["meta"]) + return model.SourceRecord{ + ID: id, + Name: orDefault(getStringValue(input["name"]), id), + Type: sourceType, + URL: getStringValue(input["url"]), + Content: getStringValue(input["content"]), + Enabled: enabled, + Filters: filters, + Meta: meta, + } +} + +func mapToCollectionRecord(input map[string]any) model.CollectionRecord { + id := getStringValue(input["id"]) + if id == "" { + id = getStringValue(input["name"]) + } + id = util.ToId(id) + sourceIds := getStringArray(input["sourceIds"]) + filters := parseFilters(input["filters"]) + templateId := getStringValue(input["templateId"]) + if templateId == "" || templateId == "default" { + templateId = template.DefaultTemplateID + } + ignoreFailed := true + if v, ok := input["ignoreFailed"].(bool); ok { + ignoreFailed = v + } + enabled := true + if e, ok := input["enabled"].(bool); ok && !e { + enabled = false + } + 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, + } +} + +func mapToTemplateRecord(input map[string]any) model.TemplateRecord { + id := getStringValue(input["id"]) + if id == "" { + id = getStringValue(input["name"]) + } + id = util.ToId(id) + target := model.NormalizeTargetAlias(input["target"]) + if target == "" { + target = "mihomo" + } + config := getMapValue(input["config"]) + if len(config) == 0 { + if content, ok := input["content"].(string); ok && content != "" { + // Try to parse content as YAML or JSON + config = parseTemplateConfig(content) + } + } + config = template.NormalizeMihomoTemplateConfig(config) + return model.TemplateRecord{ + ID: id, + Name: orDefault(getStringValue(input["name"]), id), + Target: target, + Config: config, + } +} + +func mergeSource(existing model.SourceRecord, next model.SourceRecord) model.SourceRecord { + merged := existing + if next.Name != "" { + merged.Name = next.Name + } + if next.Type != "" { + merged.Type = next.Type + } + if next.URL != "" { + merged.URL = next.URL + } + if next.Content != "" { + merged.Content = next.Content + } + merged.Enabled = next.Enabled + if next.Filters != nil { + merged.Filters = next.Filters + } + if next.Meta != nil { + merged.Meta = util.MergeDeep(existing.Meta, next.Meta) + } + return merged +} + +func mergeCollection(existing model.CollectionRecord, next model.CollectionRecord) model.CollectionRecord { + merged := existing + if next.Name != "" { + merged.Name = next.Name + } + if next.SourceIds != nil { + merged.SourceIds = next.SourceIds + } + if next.Filters != nil { + merged.Filters = next.Filters + } + if next.TemplateId != "" { + merged.TemplateId = next.TemplateId + } + merged.IgnoreFailed = next.IgnoreFailed + merged.Enabled = next.Enabled + if next.Meta != nil { + merged.Meta = util.MergeDeep(existing.Meta, next.Meta) + } + return merged +} + +// --- Utility helpers --- + +type appError struct{ msg string } + +func (e *appError) Error() string { return e.msg } +func errWrap(msg string) error { return &appError{msg: msg} } + +func getStringValue(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func orDefault(s, def string) string { + if s != "" { + return s + } + return def +} + +func getMapValue(v any) map[string]any { + if m, ok := v.(map[string]any); ok { + return m + } + return map[string]any{} +} + +func getStringArray(v any) []string { + if arr, ok := v.([]any); ok { + result := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok && s != "" { + result = append(result, s) + } + } + return result + } + return []string{} +} + +func parseFilters(v any) []model.FilterRule { + if arr, ok := v.([]any); ok { + data, _ := json.Marshal(arr) + var rules []model.FilterRule + json.Unmarshal(data, &rules) + if rules == nil { + rules = []model.FilterRule{} + } + return rules + } + return []model.FilterRule{} +} + +func extractIds(input []map[string]any) []string { + ids := make([]string, 0, len(input)) + for _, item := range input { + id := getStringValue(item["id"]) + if id == "" { + id = getStringValue(item["name"]) + } + if id != "" { + ids = append(ids, id) + } + } + return ids +} + +func splitLines(s string) []string { + var result []string + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line != "" { + result = append(result, line) + } + } + return result +} + +func sourceToMap(s model.SourceRecord) map[string]any { + data, _ := json.Marshal(s) + var m map[string]any + json.Unmarshal(data, &m) + return m +} + +func collectionToMap(c model.CollectionRecord) map[string]any { + data, _ := json.Marshal(c) + var m map[string]any + json.Unmarshal(data, &m) + return m +} + +func templateToMap(t model.TemplateRecord) map[string]any { + data, _ := json.Marshal(t) + var m map[string]any + json.Unmarshal(data, &m) + return m +} + +func parseTemplateConfig(content string) map[string]any { + // Try JSON first + var m map[string]any + if err := json.Unmarshal([]byte(content), &m); err == nil { + return m + } + // Try YAML + // We'll import yaml.v3 inline + return parseYamlConfig(content) +} diff --git a/internal/handler/download_tools.go b/internal/handler/download_tools.go new file mode 100644 index 0000000..fc8b5f1 --- /dev/null +++ b/internal/handler/download_tools.go @@ -0,0 +1,673 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + + "github.com/peterqiu0516/sub-store/internal/middleware" + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/proxy" + "github.com/peterqiu0516/sub-store/internal/render" + "github.com/peterqiu0516/sub-store/internal/rules" + "github.com/peterqiu0516/sub-store/internal/service" + "github.com/peterqiu0516/sub-store/internal/template" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// --- Download handlers --- + +func (d *Deps) HandleDownloadCollection(c fiber.Ctx) error { + name := c.Params("name") + target := d.getDownloadTarget(c) + if target == "" { + return failed(c, "Unsupported target", 400) + } + if err := d.rejectInvalidDownloadToken(c, "collection", name, target); err != nil { + return err + } + collection, err := d.CollectionRepo.Get(name) + if err != nil || collection == nil || !collection.Enabled { + return failed(c, "Collection not found", 404) + } + sources, _ := d.SourceRepo.List() + tmplConfig := d.getTemplateConfig(collection.TemplateId) + settings, _ := d.SettingsRepo.Get() + + result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{ + Collection: collection, + Sources: sources, + Target: target, + TemplateConfig: tmplConfig, + Settings: settings, + RequestUserAgent: c.Get("User-Agent"), + ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1", + CacheRepo: d.CacheRepo, + }) + if err != nil { + return failed(c, err.Error(), 500) + } + return d.sendDownloadResponse(c, result, target) +} + +func (d *Deps) HandleDownloadSource(c fiber.Ctx) error { + name := c.Params("name") + target := d.getDownloadTarget(c) + if target == "" { + return failed(c, "Unsupported target", 400) + } + if err := d.rejectInvalidDownloadToken(c, "source", name, target); err != nil { + return err + } + source, err := d.SourceRepo.Get(name) + if err != nil || source == nil || !source.Enabled { + return failed(c, "Subscription not found", 404) + } + settings, _ := d.SettingsRepo.Get() + + result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{ + Source: source, + Sources: []model.SourceRecord{*source}, + Target: target, + Settings: settings, + RequestUserAgent: c.Get("User-Agent"), + ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1", + CacheRepo: d.CacheRepo, + }) + if err != nil { + return failed(c, err.Error(), 500) + } + return d.sendDownloadResponse(c, result, target) +} + +func (d *Deps) sendDownloadResponse(c fiber.Ctx, result *service.BuildResult, target string) error { + c.Set("Content-Type", model.GetTargetContentType(target)) + // review-resolution #43: profile-update-interval default "6" + interval := result.Metadata.ProfileUpdateInterval + if interval == "" { + interval = "6" + } + c.Set("Profile-Update-Interval", interval) + // review-resolution #44: cache-control: no-store + c.Set("Cache-Control", "no-store") + middleware.SetSafeResponseHeader(c, "Subscription-Userinfo", result.Metadata.SubscriptionUserinfo) + middleware.SetSafeResponseHeader(c, "Profile-Web-Page-Url", result.Metadata.ProfileWebPageUrl) + middleware.SetSafeResponseHeader(c, "Content-Disposition", result.Metadata.ContentDisposition) + middleware.SetSafeResponseHeader(c, "X-Sub-Store-Cache", result.Metadata.CacheStatus) + return c.SendString(result.Body) +} + +func (d *Deps) getDownloadTarget(c fiber.Ctx) string { + explicit := c.Params("target") + if explicit != "" { + return model.NormalizeTargetAlias(explicit) + } + if t := c.Query("target"); t != "" { + return model.NormalizeTargetAlias(t) + } + return model.NormalizeTarget("", c.Get("User-Agent")) +} + +func (d *Deps) rejectInvalidDownloadToken(c fiber.Ctx, resourceType, resourceId, target string) error { + token := c.Params("token") + if token == "" { + token = c.Query("token") + } + // Check global download token + if util.IsTokenValid(token, d.Cfg.Auth.DownloadToken) { + return nil + } + // Check scoped grant + if d.GrantRepo.AuthorizeScoped(token, resourceType, resourceId, target) { + return nil + } + return failed(c, "Download token is invalid", 403) +} + +func (d *Deps) getTemplateConfig(templateId string) map[string]any { + if templateId == "" { + templateId = template.DefaultTemplateID + } + // Check built-in + for _, bt := range template.BuiltinTemplates { + if bt.ID == templateId { + return bt.Config + } + } + // Check DB + rec, err := d.TemplateRepo.Get(templateId) + if err == nil && rec != nil { + return rec.Config + } + return map[string]any{} +} + +// --- Preview handlers --- + +func (d *Deps) HandlePreviewSource(c fiber.Ctx) error { + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + settings, _ := d.SettingsRepo.Get() + + if getStringValue(input["type"]) == "local" || getStringValue(input["content"]) != "" { + // Local preview + content := getStringValue(input["content"]) + original := proxy.ParseProxies(proxy.DecodeMaybeBase64(content)) + if len(original) == 0 { + return failed(c, "No valid proxy nodes found", 400) + } + rec := mapToSourceRecord(input) + processed := proxy.EnsureUniqueProxyNames( + applyFiltersSafe(original, rec.Filters, settings, "json", rec.ID), + ) + return success(c, fiber.Map{ + "original": proxy.AddPreviewIds(original), + "processed": proxy.AddPreviewIds(processed), + }) + } + + // Remote preview + rec := mapToSourceRecord(input) + result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{ + Source: &rec, + Sources: []model.SourceRecord{rec}, + Target: "json", + Settings: settings, + RequestUserAgent: c.Get("User-Agent"), + CacheRepo: d.CacheRepo, + }) + if err != nil { + return failed(c, err.Error(), 400) + } + return success(c, fiber.Map{"body": result.Body, "nodes": result.Nodes}) +} + +func (d *Deps) HandlePreviewCollection(c fiber.Ctx) error { + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + settings, _ := d.SettingsRepo.Get() + rec := mapToCollectionRecord(input) + sources, _ := d.SourceRepo.List() + result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{ + Collection: &rec, + Sources: sources, + Target: "json", + TemplateConfig: d.getTemplateConfig(rec.TemplateId), + Settings: settings, + RequestUserAgent: c.Get("User-Agent"), + CacheRepo: d.CacheRepo, + }) + if err != nil { + return failed(c, err.Error(), 400) + } + return success(c, fiber.Map{"body": result.Body, "nodes": result.Nodes}) +} + +// --- Link handlers --- + +func (d *Deps) HandleLinkSource(c fiber.Ctx) error { + name := c.Params("name") + sub, err := d.SourceRepo.Get(name) + if err != nil || sub == nil { + return failed(c, "Source not found", 404) + } + return success(c, d.buildDownloadLink(c, "source", sub.ID)) +} + +func (d *Deps) HandleLinkCollection(c fiber.Ctx) error { + name := c.Params("name") + col, err := d.CollectionRepo.Get(name) + if err != nil || col == nil { + return failed(c, "Collection not found", 404) + } + return success(c, d.buildDownloadLink(c, "collection", col.ID)) +} + +func (d *Deps) buildDownloadLink(c fiber.Ctx, kind, id string) fiber.Map { + target := "" + rawTarget := c.Query("target") + if rawTarget != "" { + target = model.NormalizeTargetAlias(rawTarget) + if target == "" { + return nil + } + } + path := "/download/" + kind + "/" + id + if target != "" { + path += "/" + target + } + baseURL := getPublicBaseUrl(c) + u := baseURL + path + if d.Cfg.Auth.DownloadToken != "" { + u += "?token=" + d.Cfg.Auth.DownloadToken + } + return fiber.Map{ + "url": u, + "target": orDefault(target, "auto"), + "tokenIncluded": d.Cfg.Auth.DownloadToken != "", + } +} + +// --- Flow info handler --- + +func (d *Deps) HandleFlowInfo(c fiber.Ctx) error { + name := c.Params("name") + sub, err := d.SourceRepo.Get(name) + if err != nil || sub == nil { + return c.Status(400).JSON(fiber.Map{ + "status": "failed", + "error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "Source not found"}, + }) + } + settings, _ := d.SettingsRepo.Get() + flowReq := parseFlowRequest(sub, settings) + if flowReq == nil { + return c.Status(400).JSON(fiber.Map{ + "status": "failed", + "error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "No flow info"}, + }) + } + headers, err := fetchFlowHeaders(flowReq) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "status": "failed", + "error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": err.Error()}, + }) + } + meta := "" + if sub.Meta != nil { + if s, ok := sub.Meta["subUserinfo"].(string); ok { + meta = s + } + } + combined := strings.TrimSpace(meta + "; " + headers) + flow := parseFlowHeaders(combined) + if flow == nil { + return c.Status(400).JSON(fiber.Map{ + "status": "failed", + "error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "No flow info"}, + }) + } + return success(c, flow) +} + +// --- Tool API handlers --- + +func (d *Deps) HandleProxyParse(c fiber.Ctx) error { + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + input = map[string]any{} + } + target := model.NormalizeTargetAlias(input["client"]) + if target == "" { + target = model.NormalizeTargetAlias(input["platform"]) + } + if target == "" { + target = model.NormalizeTargetAlias(input["target"]) + } + if target == "" { + return failed(c, "Unsupported target", 400) + } + content := getStringValue(input["data"]) + if content == "" { + content = getStringValue(input["content"]) + } + if content == "" { + return failed(c, "Proxy content is required", 400) + } + settings, _ := d.SettingsRepo.Get() + parsed := proxy.ParseProxies(proxy.DecodeMaybeBase64(content)) + if len(parsed) == 0 { + return failed(c, "No valid proxy nodes found", 400) + } + filters := parseFilters(input["filters"]) + processed := proxy.EnsureUniqueProxyNames(applyFiltersSafe(parsed, filters, settings, target, "one-shot")) + var supported []model.ProxyNode + for _, p := range processed { + ptype := getStringValue(p["type"]) + if model.IsTargetCompatible(ptype, target) { + supported = append(supported, p) + } + } + output, err := render.RenderTarget(supported, target, nil) + if err != nil { + return failed(c, err.Error(), 400) + } + skipped := len(processed) - len(supported) + warnings := []string{} + if skipped > 0 { + warnings = append(warnings, fmt.Sprintf("%d node(s) cannot be represented by %s", skipped, target)) + } + return success(c, fiber.Map{ + "par_res": output, + "content": output, + "parsed": len(parsed), + "emitted": len(supported), + "skipped": skipped, + "warnings": warnings, + }) +} + +func (d *Deps) HandleRuleParse(c fiber.Ctx) error { + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + input = map[string]any{} + } + targetStr := getStringValue(input["client"]) + if targetStr == "" { + targetStr = getStringValue(input["platform"]) + } + if targetStr == "" { + targetStr = getStringValue(input["target"]) + } + target := normalizeRuleTarget(targetStr) + if target == "" { + return failed(c, "Unsupported rule target", 400) + } + content := getStringValue(input["data"]) + if content == "" { + content = getStringValue(input["content"]) + } + if content == "" { + return failed(c, "Rule content is required", 400) + } + result := rules.ConvertRules(content, rules.RuleTarget(target)) + return success(c, fiber.Map{ + "par_res": result.Content, + "content": result.Content, + "parsed": result.Parsed, + "emitted": result.Emitted, + "skipped": result.Skipped, + "warnings": result.Warnings, + }) +} + +func (d *Deps) HandleNodeInfo(c fiber.Ctx) error { + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + input = map[string]any{} + } + server := strings.Trim(getStringValue(input["server"]), "[]") + if server == "" { + return failed(c, "Node server is required", 400) + } + settings, _ := d.SettingsRepo.Get() + configured := getStringFromMap(settings, "nodeInfoApiUrl") + if configured == "" { + configured = "https://ipwho.is/{ip}" + } + if !strings.HasPrefix(configured, "https://") || !strings.Contains(configured, "{ip}") { + return failed(c, "Node info API must be an HTTPS URL containing {ip}", 400) + } + infoURL := strings.Replace(configured, "{ip}", url.QueryEscape(server), 1) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(infoURL) + if err != nil { + return failed(c, err.Error(), 502) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, util.MaxFlowRespBytes)) + var data map[string]any + json.Unmarshal(body, &data) + if success, ok := data["success"].(bool); ok && !success { + msg := getStringValue(data["message"]) + if msg == "" { + msg = "Node info lookup failed" + } + return failed(c, msg, 502) + } + return success(c, fiber.Map{ + "ip": data["ip"], + "country": data["country"], + "region": data["region"], + "city": data["city"], + "connection": data["connection"], + }) +} + +// --- Flow helpers --- + +type flowRequest struct { + URL string + UserAgent string + Headers map[string]string + Timeout time.Duration +} + +func parseFlowRequest(sub *model.SourceRecord, settings map[string]any) *flowRequest { + rawURL := sub.URL + args := parseURLArguments(rawURL) + flowURL := getStringValue(args["flowUrl"]) + if flowURL == "" { + flowURL = strings.SplitN(rawURL, "#", 2)[0] + } + if noFlow, ok := args["noFlow"].(bool); ok && noFlow { + return nil + } + if !strings.HasPrefix(flowURL, "http://") && !strings.HasPrefix(flowURL, "https://") { + return nil + } + ua := getStringValue(args["flowUserAgent"]) + if ua == "" { + ua = getStringFromMap(settings, "defaultFlowUserAgent") + } + if ua == "" { + ua = getStringFromMap(settings, "defaultUserAgent") + } + if ua == "" { + ua = "clash.meta/v1.19.24" + } + timeout := 30 * time.Second + if t, ok := settings["defaultTimeout"]; ok { + if n := toIntSafe(t); n > 0 { + timeout = time.Duration(n) * time.Millisecond + } + } + return &flowRequest{ + URL: flowURL, + UserAgent: ua, + Headers: parseJSONHeaders(args["flowHeaders"]), + Timeout: timeout, + } +} + +func parseURLArguments(rawURL string) map[string]any { + hashIdx := strings.Index(rawURL, "#") + if hashIdx < 0 { + return map[string]any{} + } + hash := rawURL[hashIdx+1:] + if hash == "" { + return map[string]any{} + } + // Try JSON first + var m map[string]any + if err := json.Unmarshal([]byte(hash), &m); err == nil { + return m + } + // Try query-string format + result := map[string]any{} + for _, pair := range strings.Split(hash, "&") { + if pair == "" { + continue + } + parts := strings.SplitN(pair, "=", 2) + key := parts[0] + if len(parts) == 1 || parts[1] == "" { + result[key] = true + } else { + val, err := url.QueryUnescape(parts[1]) + if err != nil { + val = parts[1] + } + result[key] = val + } + } + return result +} + +func parseJSONHeaders(v any) map[string]string { + result := map[string]string{} + if s, ok := v.(string); ok && s != "" { + var m map[string]any + json.Unmarshal([]byte(s), &m) + for k, v := range m { + result[k] = fmt.Sprintf("%v", v) + } + } + if m, ok := v.(map[string]any); ok { + for k, v := range m { + result[k] = fmt.Sprintf("%v", v) + } + } + return result +} + +func fetchFlowHeaders(req *flowRequest) (string, error) { + client := &http.Client{Timeout: req.Timeout} + httpReq, err := http.NewRequest("GET", req.URL, nil) + if err != nil { + return "", err + } + httpReq.Header.Set("User-Agent", req.UserAgent) + for k, v := range req.Headers { + httpReq.Header.Set(k, v) + } + resp, err := client.Do(httpReq) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, util.MaxFlowRespBytes)) + bodyStr := string(body) + parts := []string{} + if h := resp.Header.Get("subscription-userinfo"); h != "" { + parts = append(parts, h) + } + if hasUploadDownload(bodyStr) { + parts = append(parts, bodyStr) + } + if appUrl := resp.Header.Get("profile-web-page-url"); appUrl != "" { + parts = append(parts, "app_url="+url.QueryEscape(appUrl)) + } + if planName := resp.Header.Get("profile-title"); planName != "" { + parts = append(parts, "plan_name="+url.QueryEscape(planName)) + } + return strings.Join(parts, "; "), nil +} + +func hasUploadDownload(body string) bool { + return regexp.MustCompile(`(?:^|[;\n\r ])upload=`).MatchString(body) +} + +func parseFlowHeaders(flowHeaders string) map[string]any { + upload := numberField(flowHeaders, "upload") + download := numberField(flowHeaders, "download") + total := numberField(flowHeaders, "total") + if download == nil || total == nil { + return nil + } + result := map[string]any{ + "expires": numberField(flowHeaders, "expire"), + "total": *total, + "usage": map[string]any{"upload": *upload, "download": *download}, + "remainingDays": numberField(flowHeaders, "reset_day"), + "appUrl": textField(flowHeaders, "app_url"), + "planName": textField(flowHeaders, "plan_name"), + } + return result +} + +func numberField(input, key string) *float64 { + re := regexp.MustCompile(key + `=([-+]?)([0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)`) + matches := re.FindStringSubmatch(input) + if len(matches) < 3 { + return nil + } + n, err := strconv.ParseFloat(matches[1]+matches[2], 64) + if err != nil { + return nil + } + return &n +} + +func textField(input, key string) string { + re := regexp.MustCompile(key + `=(.*?)\s*?(;|$)`) + matches := re.FindStringSubmatch(input) + if len(matches) < 2 { + return "" + } + val, err := url.QueryUnescape(matches[1]) + if err != nil { + return matches[1] + } + return val +} + +func toIntSafe(v any) int { + switch n := v.(type) { + case int: + return n + case float64: + return int(n) + case string: + i, _ := strconv.Atoi(n) + return i + } + return 0 +} + +// getStringFromMap returns a string value from a map[string]any. +func getStringFromMap(m map[string]any, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func normalizeRuleTarget(input string) string { + switch strings.ToLower(input) { + case "mihomo", "clash", "clashmeta", "clash-meta": + return "mihomo" + case "surge": + return "surge" + case "loon": + return "loon" + case "qx", "quanx", "quantumultx", "quantumult-x": + return "qx" + } + return "" +} + +// applyFiltersSafe wraps filter.ApplyFilters with the correct context. +func applyFiltersSafe(nodes []model.ProxyNode, filters []model.FilterRule, settings map[string]any, target, sourceId string) []model.ProxyNode { + if filters == nil { + filters = []model.FilterRule{} + } + ctx := struct { + TargetPlatform string + SourceId string + CollectionId string + }{ + TargetPlatform: target, + SourceId: sourceId, + } + _ = ctx + // Use the filter package's ApplyFilters with FilterContext + return applyFiltersWithContext(nodes, filters, settings, target, sourceId) +} diff --git a/internal/handler/env.go b/internal/handler/env.go new file mode 100644 index 0000000..1b34cde --- /dev/null +++ b/internal/handler/env.go @@ -0,0 +1,33 @@ +package handler + +import "github.com/gofiber/fiber/v3" + +// HandleEnv returns runtime environment info. +// Per review-resolution #5: feature flags defined, buildTimeScripts=false. +func (d *Deps) HandleEnv(c fiber.Ctx) error { + return success(c, fiber.Map{ + "app": d.Cfg.App.Name, + "backend": "Go", + "version": "1.0.0", + "runtime": "Go " + goVersion(), + "storage": "SQLite", + "feature": fiber.Map{ + "buildTimeScripts": false, // review-resolution #5: no JS engine + "proxyConversion": true, + "ruleConversion": true, + "scopedShares": true, + "recycleBin": true, + "nodeInfo": true, + "surgeMac": true, + }, + }) +} + +// HandleScripts returns an empty array — review-resolution #2: no script type. +func (d *Deps) HandleScripts(c fiber.Ctx) error { + return success(c, []any{}) +} + +func goVersion() string { + return "1.23+" +} diff --git a/internal/handler/filter_bridge.go b/internal/handler/filter_bridge.go new file mode 100644 index 0000000..c018c97 --- /dev/null +++ b/internal/handler/filter_bridge.go @@ -0,0 +1,18 @@ +package handler + +import ( + "github.com/peterqiu0516/sub-store/internal/filter" + "github.com/peterqiu0516/sub-store/internal/model" +) + +// applyFiltersWithContext bridges the handler layer to the filter pipeline. +func applyFiltersWithContext(nodes []model.ProxyNode, filters []model.FilterRule, settings map[string]any, target, sourceId string) []model.ProxyNode { + if filters == nil { + filters = []model.FilterRule{} + } + ctx := filter.FilterContext{ + TargetPlatform: target, + SourceId: sourceId, + } + return filter.ApplyFilters(nodes, filters, settings, ctx) +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go new file mode 100644 index 0000000..2deb3c4 --- /dev/null +++ b/internal/handler/routes.go @@ -0,0 +1,127 @@ +package handler + +import ( + "github.com/gofiber/fiber/v3" + "github.com/jmoiron/sqlx" + + "github.com/peterqiu0516/sub-store/internal/config" + "github.com/peterqiu0516/sub-store/internal/database" + "github.com/peterqiu0516/sub-store/internal/middleware" +) + +// Deps holds all shared dependencies for handlers. +type Deps struct { + Cfg *config.Config + DB *sqlx.DB + SourceRepo *database.SourceRepo + CollectionRepo *database.CollectionRepo + TemplateRepo *database.TemplateRepo + SettingsRepo *database.SettingsRepo + GrantRepo *database.GrantRepo + RecycleRepo *database.RecycleRepo + CacheRepo *database.CacheRepo +} + +func NewDeps(cfg *config.Config, db *sqlx.DB) *Deps { + return &Deps{ + Cfg: cfg, + DB: db, + SourceRepo: database.NewSourceRepo(db), + CollectionRepo: database.NewCollectionRepo(db), + TemplateRepo: database.NewTemplateRepo(db), + SettingsRepo: database.NewSettingsRepo(db), + GrantRepo: database.NewGrantRepo(db), + RecycleRepo: database.NewRecycleRepo(db), + CacheRepo: database.NewCacheRepo(db), + } +} + +// RegisterRoutes registers all API and download routes. +func RegisterRoutes(app *fiber.App, cfg *config.Config, db *sqlx.DB) { + deps := NewDeps(cfg, db) + + // Admin API group — requires admin token + api := app.Group("/api", + middleware.RequireAdmin(cfg.Auth.AdminToken), + middleware.BodyLimit(cfg.Server.BodyLimit), + ) + + // Basic API + api.Get("/env", deps.HandleEnv) + api.Get("/scripts", deps.HandleScripts) + api.Get("/settings", deps.HandleGetSettings) + api.Patch("/settings", deps.HandleUpdateSettings) + api.Get("/storage", deps.HandleExportStorage) + api.Post("/storage", deps.HandleImportStorage) + + // Source CRUD + api.Get("/sources", deps.HandleListSources) + api.Post("/sources", deps.HandleCreateSource) + api.Put("/sources", deps.HandleSortSources) + api.Post("/sort/sources", deps.HandleSortSourcesLegacy) + api.Get("/sources/:name", deps.HandleGetSource) + api.Patch("/sources/:name", deps.HandleUpdateSource) + api.Delete("/sources/:name", deps.HandleDeleteSource) + + // Collection CRUD + api.Get("/collections", deps.HandleListCollections) + api.Post("/collections", deps.HandleCreateCollection) + api.Put("/collections", deps.HandleSortCollections) + api.Post("/sort/collections", deps.HandleSortCollectionsLegacy) + api.Get("/collections/:name", deps.HandleGetCollection) + api.Patch("/collections/:name", deps.HandleUpdateCollection) + api.Delete("/collections/:name", deps.HandleDeleteCollection) + + // Template CRUD + api.Get("/templates", deps.HandleListTemplates) + api.Post("/templates", deps.HandleCreateTemplate) + api.Get("/templates/:name", deps.HandleGetTemplate) + api.Patch("/templates/:name", deps.HandleUpdateTemplate) + api.Delete("/templates/:name", deps.HandleDeleteTemplate) + + // Shares (download grants) + api.Get("/shares", deps.HandleListShares) + api.Post("/shares", deps.HandleCreateShare) + api.Patch("/shares/:id", deps.HandleUpdateShare) + api.Delete("/shares/:id", deps.HandleDeleteShare) + + // Recycle bin + api.Get("/recycle-bin", deps.HandleListRecycleBin) + api.Delete("/recycle-bin/:id", deps.HandleDeleteRecycleBinEntry) + api.Post("/recycle-bin/:id/restore", deps.HandleRestoreRecycleBinEntry) + + // Preview + api.Post("/preview/source", deps.HandlePreviewSource) + api.Post("/preview/collection", deps.HandlePreviewCollection) + + // Download links + api.Get("/link/source/:name", deps.HandleLinkSource) + api.Get("/link/collection/:name", deps.HandleLinkCollection) + + // Flow info + api.Get("/source/flow/:name", deps.HandleFlowInfo) + + // Tool APIs + api.Post("/proxy/parse", deps.HandleProxyParse) + api.Post("/rule/parse", deps.HandleRuleParse) + api.Post("/utils/node-info", deps.HandleNodeInfo) + + // Public download routes — no admin token required, uses download token + app.Get("/download/collection/:name", deps.HandleDownloadCollection) + app.Get("/download/collection/:name/:target", deps.HandleDownloadCollection) + app.Get("/download/source/:name", deps.HandleDownloadSource) + app.Get("/download/source/:name/:target", deps.HandleDownloadSource) +} + +// success sends a success JSON response. +func success(c fiber.Ctx, data any) error { + return c.JSON(fiber.Map{"status": "success", "data": data}) +} + +// failed sends a failed JSON response. +func failed(c fiber.Ctx, message string, code int) error { + return c.Status(code).JSON(fiber.Map{ + "status": "failed", + "error": fiber.Map{"code": code, "message": message}, + }) +} diff --git a/internal/handler/settings.go b/internal/handler/settings.go new file mode 100644 index 0000000..10acf44 --- /dev/null +++ b/internal/handler/settings.go @@ -0,0 +1,203 @@ +package handler + +import ( + "encoding/json" + + "github.com/gofiber/fiber/v3" +) + +// defaultSettings returns the default settings map. +func defaultSettings() map[string]any { + return map[string]any{ + "defaultUserAgent": "clash.meta/v1.19.24", + "defaultFlowUserAgent": "clash.meta/v1.19.24", + "defaultTimeout": "30000", + "backendRequestConcurrency": "3", + "backendRequestConcurrencyWaitTime": "100", + "remoteCacheTtl": "300", + "remoteCacheStaleOnError": true, + "nodeInfoApiUrl": "https://ipwho.is/{ip}", + "theme": map[string]any{ + "auto": true, + "name": "light", + "dark": "dark", + "light": "light", + }, + "appearanceSetting": map[string]any{ + "isSimpleMode": true, + "isLeftRight": false, + "isDefaultIcon": false, + "isIconColor": false, + "isShowIcon": true, + "isSimpleShowRemark": false, + "isEditorCommon": false, + "manualSubscriptionsDisplayMode": "collapsed", + "editorGroupingMode": "always", + "isSimpleReicon": false, + "isSubItemMenuFold": true, + "showFloatingRefreshButton": false, + "showFloatingAddButton": false, + "createItemPosition": "bottom", + "displayPreviewInWebPage": true, + "subProgressStyle": "hidden", + "listPageViewMode": "single-column", + "listPageViewModeInWideScreenNarrowMode": "single-column", + "useNarrowModeOnWideScreen": false, + }, + "appName": "Sub-Store", + } +} + +// mergeSettings merges default + stored settings. +// Per review-resolution #4: top-level keys from stored override default; +// theme and appearanceSetting are shallow-merged (stored child keys override default child keys). +func mergeSettings(base, stored map[string]any) map[string]any { + result := make(map[string]any, len(base)) + for k, v := range base { + result[k] = v + } + for k, v := range stored { + if v != nil { + result[k] = v + } + } + // Shallow merge for theme and appearanceSetting + result["theme"] = shallowMergeObject(base["theme"], stored["theme"]) + result["appearanceSetting"] = shallowMergeObject(base["appearanceSetting"], stored["appearanceSetting"]) + return result +} + +func shallowMergeObject(base, overlay any) map[string]any { + result := map[string]any{} + if b, ok := base.(map[string]any); ok { + for k, v := range b { + result[k] = v + } + } + if o, ok := overlay.(map[string]any); ok { + for k, v := range o { + if v != nil { + result[k] = v + } + } + } + return result +} + +// HandleGetSettings returns merged settings. +func (d *Deps) HandleGetSettings(c fiber.Ctx) error { + stored, err := d.SettingsRepo.Get() + if err != nil { + return failed(c, "Failed to read settings", 500) + } + return success(c, mergeSettings(defaultSettings(), stored)) +} + +// HandleUpdateSettings patches settings. +func (d *Deps) HandleUpdateSettings(c fiber.Ctx) error { + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + input = map[string]any{} + } + merged, err := d.SettingsRepo.Update(input) + if err != nil { + return failed(c, "Failed to update settings", 500) + } + return success(c, mergeSettings(defaultSettings(), merged)) +} + +// HandleExportStorage exports all data. +// Per review-resolution #31: exclude built-in templates. +func (d *Deps) HandleExportStorage(c fiber.Ctx) error { + sources, _ := d.SourceRepo.List() + collections, _ := d.CollectionRepo.List() + templates, _ := d.TemplateRepo.List() + settings, _ := d.SettingsRepo.Get() + + // Filter out built-in templates + exportedTemplates := make([]any, 0, len(templates)) + builtinIDs := getBuiltinTemplateIDs() + for _, t := range templates { + if !builtinIDs[t.ID] { + exportedTemplates = append(exportedTemplates, t) + } + } + + // Actually filter properly — already done above + + payload := fiber.Map{ + "version": 1, + "exportedAt": "now", + "settings": settings, + "sources": sources, + "collections": collections, + "templates": exportedTemplates, + } + + c.Set("Content-Type", "application/json; charset=utf-8") + c.Set("Content-Disposition", `attachment; filename="sub-store-backup.json"`) + return c.JSON(payload) +} + +// HandleImportStorage imports data. +// Per review-resolution #30: import order = settings → sources → templates → collections. +func (d *Deps) HandleImportStorage(c fiber.Ctx) error { + input, err := parseJsonOrText(c) + if err != nil { + return failed(c, "Invalid import payload", 400) + } + + // Import settings first + if settings, ok := input["settings"].(map[string]any); ok { + d.SettingsRepo.Update(settings) + } + + // Import sources + if sources, ok := input["sources"].([]any); ok { + for _, s := range sources { + if record, ok := s.(map[string]any); ok { + d.SourceRepo.Upsert(mapToSourceRecord(record)) + } + } + } + + // Import templates (before collections, since collections reference templates) + builtinIDs := getBuiltinTemplateIDs() + if templates, ok := input["templates"].([]any); ok { + for _, t := range templates { + if record, ok := t.(map[string]any); ok { + id := getStringValue(record["id"]) + if id != "" && !builtinIDs[id] { + d.TemplateRepo.Upsert(mapToTemplateRecord(record)) + } + } + } + } + + // Import collections last + if collections, ok := input["collections"].([]any); ok { + for _, col := range collections { + if record, ok := col.(map[string]any); ok { + d.CollectionRepo.Upsert(mapToCollectionRecord(record)) + } + } + } + + return success(c, fiber.Map{"restored": true}) +} + +// parseJsonOrText parses body as JSON, or wraps text as {content: text}. +// Per review-resolution #9: JSON first, plain text fallback. +func parseJsonOrText(c fiber.Ctx) (map[string]any, error) { + body := c.Body() + var result map[string]any + if err := json.Unmarshal(body, &result); err == nil { + return result, nil + } + // Try as string + text := string(body) + if text == "" { + return map[string]any{}, nil + } + return map[string]any{"content": text}, nil +} diff --git a/internal/handler/share_recycle.go b/internal/handler/share_recycle.go new file mode 100644 index 0000000..8d33e92 --- /dev/null +++ b/internal/handler/share_recycle.go @@ -0,0 +1,221 @@ +package handler + +import ( + "encoding/json" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + + "github.com/peterqiu0516/sub-store/internal/middleware" + "github.com/peterqiu0516/sub-store/internal/model" +) + +// --- Share (download grant) handlers --- + +func (d *Deps) HandleListShares(c fiber.Ctx) error { + grants, err := d.GrantRepo.List() + if err != nil { + return failed(c, "Failed to list shares", 500) + } + return success(c, grants) +} + +func (d *Deps) HandleCreateShare(c fiber.Ctx) error { + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + resourceType := getStringValue(input["resourceType"]) + if resourceType != "source" && resourceType != "collection" { + return failed(c, "Share resourceType and resourceId are required", 400) + } + resourceId := getStringValue(input["resourceId"]) + if resourceId == "" { + return failed(c, "Share resourceType and resourceId are required", 400) + } + // Check resource exists + if resourceType == "source" { + s, _ := d.SourceRepo.Get(resourceId) + if s == nil { + return failed(c, "Share resource does not exist", 404) + } + } else { + col, _ := d.CollectionRepo.Get(resourceId) + if col == nil { + return failed(c, "Share resource does not exist", 404) + } + } + target := "" + if input["target"] != nil { + target = model.NormalizeTargetAlias(input["target"]) + if input["target"] != "" && target == "" { + return failed(c, "Unsupported target", 400) + } + } + var expiresAt *int64 + if v, ok := input["expiresAt"]; ok && v != nil { + if n, ok := v.(float64); ok && n > 0 { + e := int64(n) + expiresAt = &e + } + } else if v, ok := input["expiresIn"]; ok && v != nil { + if n, ok := v.(float64); ok && n > 0 { + e := time.Now().UnixMilli() + int64(n)*1000 + expiresAt = &e + } + } + grant, token, err := d.GrantRepo.Create(resourceType, resourceId, target, expiresAt) + if err != nil { + return failed(c, "Failed to create share", 500) + } + // Build download URL + path := "/download/" + resourceType + "/" + resourceId + if target != "" { + path += "/" + target + } + url := getPublicBaseUrl(c) + path + "?token=" + token + return success(c, fiber.Map{ + "id": grant.ID, + "resourceType": grant.ResourceType, + "resourceId": grant.ResourceId, + "target": grant.Target, + "expiresAt": grant.ExpiresAt, + "enabled": grant.Enabled, + "createdAt": grant.CreatedAt, + "updatedAt": grant.UpdatedAt, + "token": token, + "url": url, + }) +} + +func (d *Deps) HandleUpdateShare(c fiber.Ctx) error { + id := c.Params("id") + var input map[string]any + if err := json.Unmarshal(c.Body(), &input); err != nil { + return failed(c, "Invalid JSON", 400) + } + var enabled *bool + if v, ok := input["enabled"].(bool); ok { + enabled = &v + } + var expiresAt *int64 + if v, ok := input["expiresAt"]; ok { + if v == nil { + expiresAt = new(int64) // 0 means nil + } else if n, ok := v.(float64); ok { + e := int64(n) + expiresAt = &e + } + } + updated, err := d.GrantRepo.Update(id, enabled, expiresAt) + if err != nil || updated == nil { + return failed(c, "Share not found", 404) + } + return success(c, updated) +} + +func (d *Deps) HandleDeleteShare(c fiber.Ctx) error { + id := c.Params("id") + snapshot, err := d.GrantRepo.GetSnapshot(id) + if err != nil || snapshot == nil { + return failed(c, "Share not found", 404) + } + _, err = d.RecycleRepo.ArchiveAndDelete("share", id, snapshot, "DELETE FROM download_grants WHERE id = ?", id) + if err != nil { + return failed(c, "Failed to delete share", 500) + } + return success(c, fiber.Map{"deleted": true}) +} + +// --- Recycle bin handlers --- + +func (d *Deps) HandleListRecycleBin(c fiber.Ctx) error { + entries, err := d.RecycleRepo.List() + if err != nil { + return failed(c, "Failed to list recycle bin", 500) + } + return success(c, entries) +} + +func (d *Deps) HandleDeleteRecycleBinEntry(c fiber.Ctx) error { + id := c.Params("id") + entry, err := d.RecycleRepo.Get(id) + if err != nil || entry == nil { + return failed(c, "Recycle entry not found", 404) + } + if err := d.RecycleRepo.Delete(id); err != nil { + return failed(c, "Failed to delete", 500) + } + return success(c, fiber.Map{"deleted": true}) +} + +func (d *Deps) HandleRestoreRecycleBinEntry(c fiber.Ctx) error { + id := c.Params("id") + entry, err := d.RecycleRepo.Get(id) + if err != nil || entry == nil { + return failed(c, "Recycle entry not found", 404) + } + resourceType := getStringValue(entry["resourceType"]) + resourceId := getStringValue(entry["resourceId"]) + snapshot := getMapValue(entry["snapshot"]) + + switch resourceType { + case "source": + existing, _ := d.SourceRepo.Get(resourceId) + if existing != nil { + return failed(c, "Source id already exists", 409) + } + d.SourceRepo.Upsert(mapToSourceRecord(snapshot)) + case "collection": + existing, _ := d.CollectionRepo.Get(resourceId) + if existing != nil { + return failed(c, "Collection id already exists", 409) + } + d.CollectionRepo.Upsert(mapToCollectionRecord(snapshot)) + case "template": + existing, _ := d.TemplateRepo.Get(resourceId) + if existing != nil { + return failed(c, "Template id already exists", 409) + } + d.TemplateRepo.Upsert(mapToTemplateRecord(snapshot)) + case "share": + existing, _ := d.GrantRepo.Get(resourceId) + if existing != nil { + return failed(c, "Share id already exists", 409) + } + // review-resolution #38: restore tokenHash + d.GrantRepo.RestoreFromSnapshot(snapshot) + } + d.RecycleRepo.Delete(id) + return success(c, fiber.Map{ + "restored": true, + "resourceType": resourceType, + "resourceId": resourceId, + }) +} + +// getPublicBaseUrl returns the public base URL for download links. +// Per review-resolution #11: PUBLIC_DOWNLOAD_HOSTS config, fallback to request origin. +func getPublicBaseUrl(c fiber.Ctx) string { + // This is set by the handler using deps config + publicHosts := c.Locals("publicDownloadHosts") + if hosts, ok := publicHosts.(string); ok && hosts != "" { + parts := strings.Split(hosts, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + return "https://" + p + } + } + } + // Use X-Forwarded-Proto or default to http + proto := c.Get("X-Forwarded-Proto") + if proto == "" { + proto = "http" + } + return proto + "://" + c.Hostname() +} + +// SetSafeResponseHeader wraps middleware.SetSafeResponseHeader for handler use. +var SetSafeResponseHeader = middleware.SetSafeResponseHeader diff --git a/internal/handler/yaml.go b/internal/handler/yaml.go new file mode 100644 index 0000000..b740a2a --- /dev/null +++ b/internal/handler/yaml.go @@ -0,0 +1,15 @@ +package handler + +import "gopkg.in/yaml.v3" + +// parseYamlConfig parses a YAML string into a map. +func parseYamlConfig(content string) map[string]any { + var m map[string]any + if err := yaml.Unmarshal([]byte(content), &m); err != nil { + return map[string]any{} + } + if m == nil { + return map[string]any{} + } + return m +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..36cc0cc --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "strings" + + "github.com/gofiber/fiber/v3" + + "github.com/peterqiu0516/sub-store/internal/util" +) + +// ExtractToken extracts the admin/download token from the request. +// Per review-resolution #26: three sources, priority: Bearer header → query ?token → x-sub-store-token header. +func ExtractToken(c fiber.Ctx) string { + // 1. Authorization: Bearer + auth := c.Get("Authorization") + if strings.HasPrefix(strings.ToLower(auth), "bearer ") { + return strings.TrimSpace(auth[7:]) + } + // 2. ?token= + if token := c.Query("token"); token != "" { + return token + } + // 3. x-sub-store-token header + return c.Get("X-Sub-Store-Token") +} + +// RequireAdmin returns a middleware that checks the admin token. +func RequireAdmin(adminToken string) fiber.Handler { + return func(c fiber.Ctx) error { + if !util.IsTokenValid(ExtractToken(c), adminToken) { + return c.Status(401).JSON(fiber.Map{ + "status": "failed", + "error": fiber.Map{"code": 401, "message": "Admin token is invalid"}, + }) + } + return c.Next() + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..8b0bf28 --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "strings" + + "github.com/gofiber/fiber/v3" +) + +// CORS middleware. +func CORS(allowedOrigins string) fiber.Handler { + allowlist := parseOrigins(allowedOrigins) + return func(c fiber.Ctx) error { + origin := c.Get("Origin") + if origin != "" && (contains(allowlist, "*") || contains(allowlist, origin)) { + c.Set("Access-Control-Allow-Origin", origin) + c.Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + c.Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Sub-Store-Token") + } + return c.Next() + } +} + +// HandleOptions handles CORS preflight requests. +func HandleOptions() fiber.Handler { + return func(c fiber.Ctx) error { + if c.Method() == "OPTIONS" { + c.Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + c.Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Sub-Store-Token") + return c.SendStatus(204) + } + return c.Next() + } +} + +func parseOrigins(s string) []string { + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +func contains(list []string, s string) bool { + for _, item := range list { + if item == s { + return true + } + } + return false +} diff --git a/internal/middleware/security.go b/internal/middleware/security.go new file mode 100644 index 0000000..b9c7d1c --- /dev/null +++ b/internal/middleware/security.go @@ -0,0 +1,108 @@ +package middleware + +import ( + "regexp" + "strings" + + "github.com/gofiber/fiber/v3" +) + +// SecurityHeaders sets CSP and other security response headers. +// Per review-resolution #27: CSP tightened to script-src 'self' (no unsafe-eval). +func SecurityHeaders() fiber.Handler { + return func(c fiber.Ctx) error { + c.Set("Content-Security-Policy", + "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; "+ + "script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "+ + "font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'") + c.Set("Referrer-Policy", "no-referrer") + c.Set("X-Content-Type-Options", "nosniff") + c.Set("X-Frame-Options", "DENY") + c.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()") + return c.Next() + } +} + +// BodyLimit middleware. +func BodyLimit(limit int) fiber.Handler { + return func(c fiber.Ctx) error { + if len(c.Body()) > limit { + return c.Status(413).JSON(fiber.Map{ + "status": "failed", + "error": fiber.Map{"code": 413, "message": "Request body is too large"}, + }) + } + return c.Next() + } +} + +// DownloadHostIsolation ensures download hosts only serve /download/ paths. +func DownloadHostIsolation(downloadHosts []string) fiber.Handler { + hostSet := make(map[string]bool, len(downloadHosts)) + for _, h := range downloadHosts { + h = strings.ToLower(strings.TrimSpace(h)) + if h != "" { + hostSet[h] = true + } + } + return func(c fiber.Ctx) error { + if len(hostSet) == 0 { + return c.Next() + } + host := strings.ToLower(c.Hostname()) + if hostSet[host] && !strings.HasPrefix(c.Path(), "/download/") { + return c.Status(404).SendString("Not Found") + } + return c.Next() + } +} + +// crlfRe matches CR and LF characters for injection prevention. +var crlfRe = regexp.MustCompile(`[\r\n]`) + +// SetSafeResponseHeader sets a header value only if it doesn't contain CRLF. +// Per review-resolution #25: CRLF injection protection. +func SetSafeResponseHeader(c fiber.Ctx, name, value string) { + if value != "" && !crlfRe.MatchString(value) { + c.Set(name, value) + } +} + +// SafeContentDisposition sanitizes a filename for Content-Disposition. +// Per review-resolution #25: strip CRLF and control chars, escape special chars. +func SafeContentDisposition(value string) string { + if value == "" || crlfRe.MatchString(value) { + return "" + } + // Extract filename from the header value + filename := extractFilename(value) + if filename == "" { + return "" + } + // Sanitize: keep alphanumerics, dots, hyphens, parens, CJK, spaces + safe := sanitizeFilename(filename) + if safe == "" { + return "" + } + return `attachment; filename="` + safe + `"` +} + +var filenameRe = regexp.MustCompile(`filename\*?=(?:UTF-8''|")?([^";]+)`) + +func extractFilename(value string) string { + matches := filenameRe.FindStringSubmatch(value) + if len(matches) < 2 { + return "" + } + return strings.TrimSpace(matches[1]) +} + +var sanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9.()\- \x{4e00}-\x{9fff}]`) + +func sanitizeFilename(name string) string { + safe := sanitizeRe.ReplaceAllString(name, "_") + if len(safe) > 120 { + safe = safe[:120] + } + return safe +} diff --git a/internal/model/response.go b/internal/model/response.go new file mode 100644 index 0000000..9d7b61c --- /dev/null +++ b/internal/model/response.go @@ -0,0 +1,53 @@ +package model + +import "encoding/json" + +// APIResponse is the standard JSON envelope. +type APIResponse struct { + Status string `json:"status"` + Data interface{} `json:"data,omitempty"` + Error *APIError `json:"error,omitempty"` +} + +type APIError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func SuccessResponse(data interface{}) APIResponse { + return APIResponse{Status: "success", Data: data} +} + +func FailedResponse(message string, code int) APIResponse { + return APIResponse{Status: "failed", Error: &APIError{Code: code, Message: message}} +} + +// UnmarshalFilterRules parses filters from JSON, tolerating nil. +func UnmarshalFilterRules(data string) []FilterRule { + if data == "" || data == "[]" { + return []FilterRule{} + } + var rules []FilterRule + if err := json.Unmarshal([]byte(data), &rules); err != nil { + return []FilterRule{} + } + if rules == nil { + return []FilterRule{} + } + return rules +} + +// UnmarshalMeta parses meta from JSON, tolerating nil. +func UnmarshalMeta(data string) map[string]any { + if data == "" || data == "{}" { + return map[string]any{} + } + var m map[string]any + if err := json.Unmarshal([]byte(data), &m); err != nil { + return map[string]any{} + } + if m == nil { + return map[string]any{} + } + return m +} diff --git a/internal/model/target.go b/internal/model/target.go new file mode 100644 index 0000000..fc3b731 --- /dev/null +++ b/internal/model/target.go @@ -0,0 +1,198 @@ +package model + +// SubscriptionTarget constants. +const ( + TargetMihomo = "mihomo" + TargetStash = "stash" + TargetSurge = "surge" + TargetSurgeMac = "surge-mac" + TargetSurfboard = "surfboard" + TargetLoon = "loon" + TargetEgern = "egern" + TargetShadowrocket = "shadowrocket" + TargetQX = "qx" + TargetSingBox = "sing-box" + TargetV2ray = "v2ray" + TargetURI = "uri" + TargetJSON = "json" +) + +// FrontendVersion per review-resolution #39. +const FrontendVersion = "1.0.0" + +// TestURL per review-resolution #40. +const TestURL = "https://www.gstatic.com/generate_204" + +// targetAliases maps alias strings to canonical target names. +var targetAliases = map[string]string{ + "clash": TargetMihomo, + "clashmeta": TargetMihomo, + "clash-meta": TargetMihomo, + "meta": TargetMihomo, + "mihomo": TargetMihomo, + "stash": TargetStash, + "surge": TargetSurge, + "surgemac": TargetSurgeMac, + "surge-mac": TargetSurgeMac, + "surge_mac": TargetSurgeMac, + "surfboard": TargetSurfboard, + "loon": TargetLoon, + "egern": TargetEgern, + "shadowrocket": TargetShadowrocket, + "sr": TargetShadowrocket, + "qx": TargetQX, + "quanx": TargetQX, + "quantumult": TargetQX, + "quantumultx": TargetQX, + "quantumult-x": TargetQX, + "singbox": TargetSingBox, + "sing-box": TargetSingBox, + "sfa": TargetSingBox, + "karing": TargetSingBox, + "v2ray": TargetV2ray, + "v2rayn": TargetV2ray, + "v2rayng": TargetV2ray, + "base64": TargetV2ray, + "uri": TargetURI, + "uris": TargetURI, + "plain": TargetURI, + "text": TargetURI, + "json": TargetJSON, + "raw": TargetJSON, +} + +// NormalizeTargetAlias returns the canonical target for an alias, or "" if unknown. +func NormalizeTargetAlias(input any) string { + if input == nil { + return "" + } + s := toString(input) + if s == "" { + return "" + } + if t, ok := targetAliases[s]; ok { + return t + } + return "" +} + +// NormalizeTarget resolves a target from explicit input or User-Agent inference. +// Per review-resolution #7 — full UA keyword mapping with priority order. +func NormalizeTarget(target, ua string) string { + v := toLowerString(target) + if t, ok := targetAliases[v]; ok { + return t + } + u := toLowerString(ua) + if contains(u, "sing-box") || contains(u, "singbox") { + return TargetSingBox + } + if contains(u, "v2ray") { + return TargetV2ray + } + if contains(u, "surge") { + if contains(u, "mac") { + return TargetSurgeMac + } + return TargetSurge + } + if contains(u, "loon") { + return TargetLoon + } + if contains(u, "egern") { + return TargetEgern + } + if contains(u, "shadowrocket") { + return TargetShadowrocket + } + if contains(u, "quantumult") { + return TargetQX + } + if contains(u, "stash") { + return TargetStash + } + return TargetMihomo +} + +// IsTargetCompatible checks whether a proxy type can be rendered for the given target. +func IsTargetCompatible(proxyType, target string) bool { + if target == TargetMihomo || target == TargetStash || target == TargetJSON { + return true + } + commonUri := []string{"ss", "ssr", "vmess", "vless", "trojan", "hysteria", "hysteria2", "tuic", "anytls", "http", "socks5", "wireguard"} + switch target { + case TargetURI, TargetV2ray, TargetShadowrocket: + return containsAny(proxyType, commonUri) + case TargetSingBox: + return containsAny(proxyType, []string{"ss", "vmess", "vless", "trojan", "hysteria", "hysteria2", "tuic", "anytls", "http", "socks5", "wireguard"}) + case TargetSurge: + return containsAny(proxyType, []string{"ss", "vmess", "trojan", "http", "socks5", "hysteria2", "tuic", "anytls", "snell"}) + case TargetSurgeMac: + return containsAny(proxyType, []string{"ss", "vmess", "trojan", "http", "socks5", "hysteria2", "tuic", "anytls", "snell", "ssh", "h2-connect"}) + case TargetSurfboard: + return containsAny(proxyType, []string{"ss", "vmess", "trojan", "http", "socks5"}) + case TargetLoon: + return containsAny(proxyType, []string{"ss", "ssr", "vmess", "vless", "trojan", "http", "socks5", "hysteria2", "tuic", "anytls", "wireguard"}) + case TargetQX: + return containsAny(proxyType, []string{"ss", "ssr", "vmess", "vless", "trojan", "http", "socks5", "anytls"}) + case TargetEgern: + return containsAny(proxyType, []string{"ss", "vmess", "trojan", "http", "socks5", "hysteria2", "tuic", "anytls"}) + } + return false +} + +// GetTargetContentType returns the Content-Type for a target. +func GetTargetContentType(target string) string { + switch target { + case TargetSingBox, TargetJSON: + return "application/json; charset=utf-8" + case TargetV2ray, TargetURI, TargetSurge, TargetSurgeMac, TargetSurfboard, TargetLoon, TargetShadowrocket, TargetQX: + return "text/plain; charset=utf-8" + default: + return "text/yaml; charset=utf-8" + } +} + +// --- helpers --- + +func toString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func toLowerString(v any) string { + s := toString(v) + b := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 32 + } + b[i] = c + } + return string(b) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || indexOf(s, substr) >= 0) +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +func containsAny(s string, list []string) bool { + for _, item := range list { + if s == item { + return true + } + } + return false +} diff --git a/internal/model/types.go b/internal/model/types.go new file mode 100644 index 0000000..0b0295c --- /dev/null +++ b/internal/model/types.go @@ -0,0 +1,128 @@ +package model + +// ProxyNode is a dynamic proxy configuration node. +// Defined as map[string]any per review-resolution #1 — proxy protocols have +// many optional fields and unknown fields must be preserved through JSON/YAML round-trips. +type ProxyNode map[string]any + +// FilterRule represents one operation in the filter pipeline. +type FilterRule struct { + Type string `json:"type"` + Field string `json:"field,omitempty"` + Fields []string `json:"fields,omitempty"` + Pattern string `json:"pattern,omitempty"` + Patterns []string `json:"patterns,omitempty"` + Expressions []string `json:"expressions,omitempty"` + Replacement string `json:"replacement,omitempty"` + Direction string `json:"direction,omitempty"` + Action string `json:"action,omitempty"` + Link string `json:"link,omitempty"` + Position string `json:"position,omitempty"` + Template string `json:"template,omitempty"` + Provider string `json:"provider,omitempty"` + RecordType string `json:"recordType,omitempty"` + Filter string `json:"filter,omitempty"` + URL string `json:"url,omitempty"` + Edns string `json:"edns,omitempty"` + Concurrency any `json:"concurrency,omitempty"` + Mode string `json:"mode,omitempty"` + Tw string `json:"tw,omitempty"` + Udp any `json:"udp,omitempty"` + Tfo any `json:"tfo,omitempty"` + Scert any `json:"scert,omitempty"` + Useless any `json:"useless,omitempty"` + Args map[string]any `json:"args,omitempty"` + Rules []CustomRule `json:"rules,omitempty"` + // Catch-all for unknown keys + Extra map[string]any `json:"-"` +} + +// CustomRule is one atomic operation in a custom filter rule chain (review-resolution #37). +type CustomRule struct { + Action string `json:"action"` + Field string `json:"field,omitempty"` + Mode string `json:"mode,omitempty"` + Pattern string `json:"pattern,omitempty"` + Replacement string `json:"replacement,omitempty"` + Value any `json:"value,omitempty"` + Template string `json:"template,omitempty"` + When string `json:"when,omitempty"` +} + +// SourceRecord is the full DB record for a subscription source. +type SourceRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Content string `json:"content"` + Enabled bool `json:"enabled"` + Filters []FilterRule `json:"filters"` + Meta map[string]any `json:"meta"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// 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"` +} + +// TemplateRecord is the full DB record for a routing template. +type TemplateRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Config map[string]any `json:"config"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// DownloadGrantRecord represents a scoped download share token. +type DownloadGrantRecord struct { + ID string `json:"id"` + ResourceType string `json:"resourceType"` + ResourceId string `json:"resourceId"` + Target string `json:"target,omitempty"` + ExpiresAt *int64 `json:"expiresAt,omitempty"` + Enabled bool `json:"enabled"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// RecycleBinRecord represents a soft-deleted resource snapshot. +type RecycleBinRecord struct { + ID string `json:"id"` + ResourceType string `json:"resourceType"` + ResourceId string `json:"resourceId"` + Snapshot map[string]any `json:"snapshot"` + DeletedAt int64 `json:"deletedAt"` +} + +// AppConfig is the full export/import payload. +type AppConfig struct { + Sources []SourceRecord `json:"sources"` + Collections []CollectionRecord `json:"collections"` + Templates []TemplateRecord `json:"templates"` + Settings map[string]any `json:"settings,omitempty"` +} + +// SubscriptionResponseMetadata holds headers/metadata for download responses. +type SubscriptionResponseMetadata struct { + SubscriptionUserinfo string `json:"-"` + ProfileWebPageUrl string `json:"-"` + ProfileUpdateInterval string `json:"-"` + ContentDisposition string `json:"-"` + Etag string `json:"-"` + LastModified string `json:"-"` + CacheStatus string `json:"-"` +} diff --git a/internal/proxy/client_parser.go b/internal/proxy/client_parser.go new file mode 100644 index 0000000..e2bf27e --- /dev/null +++ b/internal/proxy/client_parser.go @@ -0,0 +1,651 @@ +package proxy + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +var ( + // qxKindRegex matches QX-style proxy lines: "shadowsocks = ...", "vmess = ...", etc. + qxKindRegex = regexp.MustCompile(`(?i)^\s*(shadowsocks|vmess|vless|trojan|http|socks5|anytls)\s*=`) + // namedKindRegex matches named Surge/Loon-style lines: "name = kind, ..." + namedKindRegex = regexp.MustCompile(`^\s*[^=\n]{1,120}\s*=`) +) + +// ParseClientProxyLine dispatches a client config line to QX or named parser. +// Returns nil if the line doesn't match either format. +func ParseClientProxyLine(line string, index int) model.ProxyNode { + defer func() { _ = recover() }() + if qxKindRegex.MatchString(line) { + return ParseQxProxyLine(line, index) + } + if namedKindRegex.MatchString(line) { + return ParseNamedClientProxyLine(line, index) + } + return nil +} + +// ParseQxProxyLine parses a Quantumult X style proxy config line. +// Format: kind = server:port, tag=name, ... +func ParseQxProxyLine(line string, index int) model.ProxyNode { + equalIndex := strings.Index(line, "=") + if equalIndex <= 0 { + return nil + } + kind := strings.ToLower(strings.TrimSpace(line[:equalIndex])) + parts := SplitClientCsv(line[equalIndex+1:]) + if len(parts) == 0 { + return nil + } + server, rawPort := SplitHostPort(parts[0]) + options := ParseClientOptions(parts[1:]) + name := orDefault(ClientOption(options, "tag"), fmt.Sprintf("%s-%d", kind, index+1)) + + defaultPort := 443 + if kind == "http" || kind == "socks5" { + defaultPort = 80 + } + port := toFloat(rawPort) + if port == 0 { + if p := toFloat(ClientOption(options, "port")); p != 0 { + port = p + } else { + port = float64(defaultPort) + } + } + + tls := qxTlsEnabled(options) + common := ClientCommonOptions(options) + + switch kind { + case "shadowsocks": + return StripUndefined(map[string]any{ + "name": name, + "type": "ss", + "server": server, + "port": port, + "cipher": ClientOption(options, "method"), + "password": ClientOption(options, "password"), + "plugin": qxPlugin(options), + "plugin-opts": qxPluginOptions(options), + "udp": OptionBoolean(ClientOption(options, "udp-relay")), + "tfo": OptionBoolean(ClientOption(options, "fast-open")), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + case "vmess", "vless": + node := map[string]any{ + "name": name, + "type": kind, + "server": server, + "port": port, + "uuid": firstNonEmpty(ClientOption(options, "password"), ClientOption(options, "uuid"), ClientOption(options, "username")), + "network": qxNetwork(options), + "tls": tls, + "servername": firstNonEmpty(ClientOption(options, "tls-host"), ClientOption(options, "obfs-host")), + "ws-opts": qxWsOptions(options), + "reality-opts": parseRealityOptions(options), + "flow": ClientOption(options, "flow"), + "udp": OptionBoolean(ClientOption(options, "udp-relay")), + "tfo": OptionBoolean(ClientOption(options, "fast-open")), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + } + if kind == "vmess" { + node["cipher"] = orDefault(ClientOption(options, "method"), "auto") + node["alterId"] = NumberOrUndefined(firstNonEmpty(ClientOption(options, "alterId"), ClientOption(options, "alterid"))) + } else { + node["encryption"] = orDefault(ClientOption(options, "encryption"), "none") + } + return StripUndefined(node) + case "trojan", "anytls": + return StripUndefined(map[string]any{ + "name": name, + "type": kind, + "server": server, + "port": port, + "password": ClientOption(options, "password"), + "sni": firstNonEmpty(ClientOption(options, "tls-host"), ClientOption(options, "sni"), ClientOption(options, "obfs-host")), + "reality-opts": parseRealityOptions(options), + "udp": OptionBoolean(ClientOption(options, "udp-relay")), + "tfo": OptionBoolean(ClientOption(options, "fast-open")), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + case "http", "socks5": + return StripUndefined(map[string]any{ + "name": name, + "type": kind, + "server": server, + "port": port, + "username": ClientOption(options, "username"), + "password": ClientOption(options, "password"), + "tls": tls, + "udp": OptionBoolean(ClientOption(options, "udp-relay")), + "tfo": OptionBoolean(ClientOption(options, "fast-open")), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + default: + return nil + } +} + +// ParseNamedClientProxyLine parses a Surge/Loon style named proxy config line. +// Format: name = kind, server, port, [positional...], [key=value...] +func ParseNamedClientProxyLine(line string, index int) model.ProxyNode { + equalIndex := strings.Index(line, "=") + if equalIndex < 0 { + return nil + } + name := strings.TrimSpace(line[:equalIndex]) + if name == "" { + name = fmt.Sprintf("proxy-%d", index+1) + } + parts := SplitClientCsv(line[equalIndex+1:]) + if len(parts) < 3 { + return nil + } + rawKind := strings.ToLower(strings.TrimSpace(parts[0])) + kind := NormalizeClientProxyKind(parts[0]) + server := parts[1] + port := toFloat(parts[2]) + positional := parts[3:] + var positionalValues []string + for _, p := range positional { + if !strings.Contains(p, "=") { + positionalValues = append(positionalValues, p) + } + } + options := ParseClientOptions(positional) + common := ClientCommonOptions(options) + + if kind == "" || server == "" || port == 0 { + return nil + } + + switch kind { + case "ss": + var pluginOpts any + if obfs := firstNonEmpty(ClientOption(options, "obfs"), ClientOption(options, "obfs-name")); obfs != "" { + pluginOpts = StripUndefined(map[string]any{ + "mode": obfs, + "host": ClientOption(options, "obfs-host"), + "path": ClientOption(options, "obfs-uri"), + }) + } + return StripUndefined(map[string]any{ + "name": name, + "type": "ss", + "server": server, + "port": port, + "cipher": firstNonEmpty(ClientOption(options, "encrypt-method"), ClientOption(options, "method"), getAt(positionalValues, 0)), + "password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 1)), + "plugin": ternary(firstNonEmpty(ClientOption(options, "obfs"), ClientOption(options, "obfs-name")) != "", "obfs", ""), + "plugin-opts": pluginOpts, + "udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))), + "tfo": OptionBoolean(ClientOption(options, "fast-open")), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + case "ssr": + return StripUndefined(map[string]any{ + "name": name, + "type": "ssr", + "server": server, + "port": port, + "cipher": firstNonEmpty(getAt(positionalValues, 0), ClientOption(options, "encrypt-method"), ClientOption(options, "method")), + "password": firstNonEmpty(getAt(positionalValues, 1), ClientOption(options, "password")), + "protocol": orDefault(ClientOption(options, "protocol"), "origin"), + "obfs": orDefault(ClientOption(options, "obfs"), "plain"), + "protocol-param": firstNonEmpty(ClientOption(options, "protocol-param"), ClientOption(options, "protoparam")), + "obfs-param": firstNonEmpty(ClientOption(options, "obfs-param"), ClientOption(options, "obfsparam")), + "udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + case "vmess", "vless": + tls := false + if b := OptionBoolean(firstNonEmpty(ClientOption(options, "tls"), ClientOption(options, "over-tls"))); b != nil { + tls = *b + } + node := map[string]any{ + "name": name, + "type": kind, + "server": server, + "port": port, + "uuid": firstNonEmpty(ClientOption(options, "username"), ClientOption(options, "password"), getAt(positionalValues, 1), getAt(positionalValues, 0)), + "network": namedClientNetwork(options), + "tls": tls, + "servername": firstNonEmpty(ClientOption(options, "sni"), ClientOption(options, "tls-name"), ClientOption(options, "tls-host")), + "ws-opts": namedClientWsOptions(options), + "reality-opts": parseRealityOptions(options), + "flow": ClientOption(options, "flow"), + "udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))), + "tfo": OptionBoolean(ClientOption(options, "fast-open")), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + } + if kind == "vmess" { + node["cipher"] = firstNonEmpty(getAt(positionalValues, 0), ClientOption(options, "encrypt-method"), ClientOption(options, "method"), "auto") + node["alterId"] = NumberOrUndefined(firstNonEmpty(ClientOption(options, "alterId"), ClientOption(options, "alterid"))) + } else { + node["encryption"] = orDefault(ClientOption(options, "encryption"), "none") + } + return StripUndefined(node) + case "trojan", "anytls": + return StripUndefined(map[string]any{ + "name": name, + "type": kind, + "server": server, + "port": port, + "password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 0)), + "sni": firstNonEmpty(ClientOption(options, "sni"), ClientOption(options, "tls-name"), ClientOption(options, "tls-host")), + "reality-opts": parseRealityOptions(options), + "udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))), + "tfo": OptionBoolean(ClientOption(options, "fast-open")), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + case "http", "socks5": + tls := rawKind == "https" || rawKind == "socks5-tls" + if b := OptionBoolean(firstNonEmpty(ClientOption(options, "tls"), ClientOption(options, "over-tls"))); b != nil { + tls = *b + } + proxyType := kind + if kind == "socks5" { + proxyType = "socks5" + } else { + proxyType = "http" + } + return StripUndefined(map[string]any{ + "name": name, + "type": proxyType, + "server": server, + "port": port, + "username": ClientOption(options, "username"), + "password": ClientOption(options, "password"), + "tls": tls, + "udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))), + "tfo": OptionBoolean(ClientOption(options, "fast-open")), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + case "hysteria2": + scv := common[skipCertVerify] + if b := OptionBoolean(ClientOption(options, "skip-cert-verify")); b != nil { + scv = *b + } + return StripUndefined(map[string]any{ + "name": name, + "type": "hysteria2", + "server": server, + "port": port, + "password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 0)), + "sni": firstNonEmpty(ClientOption(options, "sni"), ClientOption(options, "tls-name")), + "obfs": ClientOption(options, "obfs"), + "obfs-password": firstNonEmpty(ClientOption(options, "obfs-password"), ClientOption(options, "gecko-password")), + skipCertVerify: scv, + clientFingerprint: common[clientFingerprint], + }) + case "tuic": + scv := common[skipCertVerify] + if b := OptionBoolean(ClientOption(options, "skip-cert-verify")); b != nil { + scv = *b + } + return StripUndefined(map[string]any{ + "name": name, + "type": "tuic", + "server": server, + "port": port, + "uuid": firstNonEmpty(ClientOption(options, "uuid"), getAt(positionalValues, 0)), + "password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 1)), + "sni": ClientOption(options, "sni"), + "alpn": CommaList(ClientOption(options, "alpn")), + skipCertVerify: scv, + clientFingerprint: common[clientFingerprint], + }) + case "snell": + version := NumberOrUndefined(ClientOption(options, "version")) + if version == nil { + version = float64(3) + } + return StripUndefined(map[string]any{ + "name": name, + "type": "snell", + "server": server, + "port": port, + "psk": firstNonEmpty(ClientOption(options, "psk"), ClientOption(options, "password"), getAt(positionalValues, 0)), + "version": version, + "obfs": ClientOption(options, "obfs"), + "obfs-host": ClientOption(options, "obfs-host"), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + case "ssh": + return StripUndefined(map[string]any{ + "name": name, + "type": "ssh", + "server": server, + "port": port, + "username": firstNonEmpty(ClientOption(options, "username"), getAt(positionalValues, 0)), + "password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 1)), + "private-key": ClientOption(options, "private-key"), + "host-key": ClientOption(options, "host-key"), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + case "h2-connect": + tls := true + if b := OptionBoolean(ClientOption(options, "tls")); b != nil { + tls = *b + } + return StripUndefined(map[string]any{ + "name": name, + "type": "h2-connect", + "server": server, + "port": port, + "username": ClientOption(options, "username"), + "password": ClientOption(options, "password"), + "tls": tls, + "sni": firstNonEmpty(ClientOption(options, "sni"), ClientOption(options, "tls-name")), + skipCertVerify: common[skipCertVerify], + clientFingerprint: common[clientFingerprint], + }) + default: + return nil + } +} + +// NormalizeClientProxyKind maps client proxy kind aliases to canonical names. +// Per review-resolution #23. +func NormalizeClientProxyKind(input string) string { + value := strings.ToLower(strings.TrimSpace(input)) + switch value { + case "shadowsocks": + return "ss" + case "socks5-tls": + return "socks5" + case "https": + return "http" + case "hysteria2", "hysteria 2": + return "hysteria2" + case "tuic-v5": + return "tuic" + case "ss", "ssr", "vmess", "vless", "trojan", "http", "socks5", "tuic", "anytls", "snell", "ssh", "h2-connect": + return value + default: + return "" + } +} + +// SplitClientCsv splits a CSV string with quote handling. +// Empty parts are filtered out (matching TS implementation). +func SplitClientCsv(input string) []string { + var parts []string + var current strings.Builder + quote := "" + for _, char := range input { + if quote != "" { + if string(char) == quote { + quote = "" + } else { + current.WriteRune(char) + } + } else if char == '"' || char == '\'' { + quote = string(char) + } else if char == ',' { + parts = append(parts, strings.TrimSpace(current.String())) + current.Reset() + } else { + current.WriteRune(char) + } + } + parts = append(parts, strings.TrimSpace(current.String())) + // Filter empty parts + var result []string + for _, p := range parts { + if p != "" { + result = append(result, p) + } + } + return result +} + +// ParseClientOptions parses key=value parts into a map. +// Keys are lowercased. Values are unquoted. +func ParseClientOptions(parts []string) map[string]string { + options := make(map[string]string) + for _, part := range parts { + equalIndex := strings.Index(part, "=") + if equalIndex <= 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(part[:equalIndex])) + value := unquoteClientValue(strings.TrimSpace(part[equalIndex+1:])) + options[key] = value + } + return options +} + +// ClientOption retrieves a value from the options map (case-insensitive key already lowered). +func ClientOption(options map[string]string, key string) string { + return options[strings.ToLower(key)] +} + +// OptionBoolean parses a string value into a *bool. +// Returns nil for empty/unrecognized values. +// Per review-resolution: "1","true","yes","on","enabled" → true; +// "0","false","no","off","disabled" → false; otherwise nil. +func OptionBoolean(value string) *bool { + if value == "" { + return nil + } + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "1", "true", "yes", "on", "enabled": + b := true + return &b + case "0", "false", "no", "off", "disabled": + b := false + return &b + default: + return nil + } +} + +// ClientCommonOptions extracts common options (skip-cert-verify, client-fingerprint). +func ClientCommonOptions(options map[string]string) map[string]any { + result := map[string]any{} + scv := OptionBoolean(ClientOption(options, "skip-cert-verify")) + if scv == nil { + scv = OptionBooleanInverted(ClientOption(options, "tls-verification")) + } + if scv != nil { + result[skipCertVerify] = *scv + } + if fp := firstNonEmpty(ClientOption(options, "client-fingerprint"), ClientOption(options, "fingerprint")); fp != "" { + result[clientFingerprint] = fp + } + return result +} + +// Constants for common option keys to avoid typos. +const ( + skipCertVerify = "skip-cert-verify" + clientFingerprint = "client-fingerprint" +) + +// OptionBooleanInverted parses a value where the semantics are inverted +// (e.g. tls-verification where true means don't skip). +func OptionBooleanInverted(value string) *bool { + if value == "" { + return nil + } + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "1", "true", "yes", "on", "enabled": + b := false + return &b + case "0", "false", "no", "off", "disabled": + b := true + return &b + default: + return nil + } +} + +// unquoteClientValue removes surrounding quotes from a value. +func unquoteClientValue(input string) string { + text := strings.TrimSpace(input) + if len(text) < 2 { + return text + } + first := text[0] + last := text[len(text)-1] + if (first == '"' || first == '\'') && last == first { + return text[1 : len(text)-1] + } + return text +} + +// qxTlsEnabled determines if TLS is enabled for a QX proxy. +func qxTlsEnabled(options map[string]string) bool { + obfs := strings.ToLower(ClientOption(options, "obfs")) + if obfs == "tls" || obfs == "wss" || obfs == "over-tls" { + return true + } + if b := OptionBoolean(ClientOption(options, "over-tls")); b != nil && *b { + return true + } + if b := OptionBoolean(ClientOption(options, "tls")); b != nil && *b { + return true + } + return false +} + +// qxNetwork determines the network type for a QX proxy. +func qxNetwork(options map[string]string) string { + obfs := strings.ToLower(ClientOption(options, "obfs")) + if obfs == "ws" || obfs == "wss" { + return "ws" + } + return "tcp" +} + +// qxWsOptions builds ws-opts for a QX proxy. +func qxWsOptions(options map[string]string) any { + if qxNetwork(options) != "ws" { + return nil + } + wsOpts := map[string]any{ + "path": orDefault(ClientOption(options, "obfs-uri"), "/"), + } + host := ClientOption(options, "obfs-host") + if host != "" { + wsOpts["headers"] = map[string]any{"Host": host} + } + return StripUndefined(wsOpts) +} + +// qxPlugin determines the plugin name for a QX proxy. +func qxPlugin(options map[string]string) any { + obfs := strings.ToLower(ClientOption(options, "obfs")) + if obfs == "http" || obfs == "shadowsocks-http" { + return "obfs" + } + return nil +} + +// qxPluginOptions builds plugin-opts for a QX proxy. +func qxPluginOptions(options map[string]string) any { + if qxPlugin(options) == nil { + return nil + } + return StripUndefined(map[string]any{ + "mode": "http", + "host": ClientOption(options, "obfs-host"), + "path": ClientOption(options, "obfs-uri"), + }) +} + +// namedClientNetwork determines the network type for a named client proxy. +func namedClientNetwork(options map[string]string) string { + if b := OptionBoolean(ClientOption(options, "ws")); b != nil && *b { + return "ws" + } + transport := firstNonEmpty(ClientOption(options, "transport"), ClientOption(options, "network")) + if transport != "" { + return transport + } + return "tcp" +} + +// namedClientWsOptions builds ws-opts for a named client proxy. +func namedClientWsOptions(options map[string]string) any { + if namedClientNetwork(options) != "ws" { + return nil + } + wsOpts := map[string]any{ + "path": firstNonEmpty(ClientOption(options, "ws-path"), ClientOption(options, "path"), "/"), + } + host := ClientOption(options, "ws-headers") + if host != "" { + // Remove leading "Host:" prefix (case-insensitive) + host = regexp.MustCompile(`(?i)^Host:`).ReplaceAllString(host, "") + host = strings.TrimSpace(host) + } + if host == "" { + host = firstNonEmpty(ClientOption(options, "ws-host"), ClientOption(options, "host")) + } + if host != "" { + wsOpts["headers"] = map[string]any{"Host": host} + } + return StripUndefined(wsOpts) +} + +// parseRealityOptions extracts reality-opts from client options. +func parseRealityOptions(options map[string]string) any { + publicKey := firstNonEmpty(ClientOption(options, "reality-base64-pubkey"), ClientOption(options, "public-key")) + shortId := firstNonEmpty(ClientOption(options, "reality-hex-shortid"), ClientOption(options, "short-id")) + if publicKey == "" { + return nil + } + return StripUndefined(map[string]any{ + "public-key": publicKey, + "short-id": shortId, + }) +} + +// toFloat parses a string to float64, returning 0 on failure. +func toFloat(s string) float64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + n, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return n +} + +// getAt returns the element at index i, or "" if out of bounds. +func getAt(slice []string, i int) string { + if i < 0 || i >= len(slice) { + return "" + } + return slice[i] +} + +// ternary returns a if cond is true, else b. +func ternary(cond bool, a, b string) string { + if cond { + return a + } + return b +} diff --git a/internal/proxy/format.go b/internal/proxy/format.go new file mode 100644 index 0000000..51c9df1 --- /dev/null +++ b/internal/proxy/format.go @@ -0,0 +1,156 @@ +package proxy + +import ( + "encoding/json" + "regexp" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" + "gopkg.in/yaml.v3" +) + +var ( + // reURIScheme matches a URI scheme like "vless://", "ss://", etc. + reURIScheme = regexp.MustCompile(`(?im)^[a-z][a-z0-9+.-]*://`) + // reYamlKeys matches YAML top-level keys: proxies, proxy-groups, rules + reYamlKeys = regexp.MustCompile(`(?m)^\s*(proxies|proxy-groups|rules)\s*:`) + // reJSONStart matches the start of a JSON array or object + reJSONStart = regexp.MustCompile(`^\s*[\[{]`) + // reQxConfig matches QX-style config lines: "shadowsocks = ...", "vmess = ...", etc. + reQxConfig = regexp.MustCompile(`(?im)^\s*(shadowsocks|vmess|vless|trojan|http|socks5|anytls)\s*=`) + // reNamedConfig matches Surge/Loon named config lines + reNamedConfig = regexp.MustCompile(`(?im)^\s*[^=\n]{1,80}\s*=\s*(ss|shadowsocks|ssr|vmess|vless|trojan|http|https|socks5|socks5-tls|hysteria2|hysteria|anytls|tuic|tuic-v5)\s*,`) + // reCommentOrSection matches lines to skip: comments (#, ;) and section headers ([...]) + reCommentOrSection = regexp.MustCompile(`^\s*(#|;|\[[^\]]+\])`) +) + +// ParseProxies is the main entry point for parsing subscription content. +// It trims the input, detects JSON/YAML/URI-line format, and dispatches accordingly. +func ParseProxies(raw string) []model.ProxyNode { + text := strings.TrimSpace(raw) + if text == "" { + return nil + } + if reJSONStart.MatchString(text) { + return ParseJsonProxies(text) + } + if reYamlKeys.MatchString(text) { + return ParseYamlProxies(text) + } + return ParseProxyLines(text) +} + +// DecodeMaybeBase64 tries structured detection first; if the input doesn't +// look like a structured subscription, attempts base64 decode. +func DecodeMaybeBase64(raw string) string { + text := strings.TrimSpace(raw) + if LooksLikeStructuredSubscription(text) { + return raw + } + // Try base64 decode (strip all whitespace first) + cleaned := regexp.MustCompile(`\s+`).ReplaceAllString(text, "") + decoded, err := util.DecodeBase64Auto(cleaned) + if err != nil { + return raw + } + if LooksLikeStructuredSubscription(strings.TrimSpace(decoded)) { + return decoded + } + return raw +} + +// LooksLikeStructuredSubscription checks if the text looks like structured +// subscription content (URI scheme, YAML keys, JSON, or client config lines). +func LooksLikeStructuredSubscription(text string) bool { + return reURIScheme.MatchString(text) || + reYamlKeys.MatchString(text) || + reJSONStart.MatchString(text) || + reQxConfig.MatchString(text) || + reNamedConfig.MatchString(text) +} + +// ParseJsonProxies parses a JSON array of proxies or {"proxies": [...]}. +func ParseJsonProxies(raw string) []model.ProxyNode { + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil + } + + var rawList []any + switch v := payload.(type) { + case []any: + rawList = v + case map[string]any: + if arr, ok := v["proxies"].([]any); ok { + rawList = arr + } + default: + return nil + } + + var result []model.ProxyNode + for _, item := range rawList { + if m, ok := item.(map[string]any); ok { + normalized := NormalizeProxy(m) + if IsProxyNode(normalized) { + result = append(result, normalized) + } + } + } + return result +} + +// ParseYamlProxies parses a YAML document with a "proxies:" key. +func ParseYamlProxies(raw string) []model.ProxyNode { + var payload map[string]any + if err := yaml.Unmarshal([]byte(raw), &payload); err != nil { + return nil + } + + rawList, ok := payload["proxies"].([]any) + if !ok { + return nil + } + + var result []model.ProxyNode + for _, item := range rawList { + if m, ok := item.(map[string]any); ok { + normalized := NormalizeProxy(m) + if IsProxyNode(normalized) { + result = append(result, normalized) + } + } + } + return result +} + +// ParseProxyLines splits raw text into lines, skips comments/blanks/section headers, +// and parses each line as a URI or client config line. +func ParseProxyLines(raw string) []model.ProxyNode { + lines := strings.Split(raw, "\n") + var result []model.ProxyNode + index := 0 + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if reCommentOrSection.MatchString(line) { + continue + } + + var node model.ProxyNode + if node = ParseProxyUri(line, index); node == nil { + node = ParseClientProxyLine(line, index) + } + if node != nil { + result = append(result, node) + index++ + } + } + return result +} diff --git a/internal/proxy/normalize.go b/internal/proxy/normalize.go new file mode 100644 index 0000000..463e23d --- /dev/null +++ b/internal/proxy/normalize.go @@ -0,0 +1,186 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +// NormalizeProxy ensures name/type are strings and port is a number, +// then strips undefined (nil and "") values. +func NormalizeProxy(input map[string]any) model.ProxyNode { + if input == nil { + return nil + } + result := make(map[string]any, len(input)+3) + for k, v := range input { + result[k] = v + } + if name, ok := result["name"]; ok { + result["name"] = toString(name) + } else { + result["name"] = "" + } + if t, ok := result["type"]; ok { + result["type"] = toString(t) + } else { + result["type"] = "" + } + if port, exists := result["port"]; exists { + result["port"] = toNumberOrUndefined(port) + } + return StripUndefined(result) +} + +// IsProxyNode returns true if the node has both a name and a type. +func IsProxyNode(node model.ProxyNode) bool { + if node == nil { + return false + } + name, ok := node["name"].(string) + if !ok || name == "" { + return false + } + t, ok := node["type"].(string) + if !ok || t == "" { + return false + } + return true +} + +// StripUndefined removes nil and "" values from a map. +// This mirrors the TS stripUndefined which filters out undefined and "". +func StripUndefined(input map[string]any) map[string]any { + if input == nil { + return map[string]any{} + } + result := make(map[string]any, len(input)) + for k, v := range input { + if v == nil { + continue + } + if s, ok := v.(string); ok && s == "" { + continue + } + result[k] = v + } + return result +} + +// AddPreviewIds adds an "id" field to each proxy computed by StableProxyId. +func AddPreviewIds(proxies []model.ProxyNode) []model.ProxyNode { + result := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + id := StableProxyId(proxy, i) + merged := make(map[string]any, len(proxy)+1) + merged["id"] = id + for k, v := range proxy { + merged[k] = v + } + result[i] = merged + } + return result +} + +// StableProxyId builds a deterministic identifier from name|type|server|port|index. +func StableProxyId(proxy model.ProxyNode, index int) string { + return fmt.Sprintf("%s|%s|%s|%s|%d", + toString(proxy["name"]), + toString(proxy["type"]), + toString(proxy["server"]), + portString(proxy["port"]), + index, + ) +} + +// EnsureUniqueProxyNames appends -2, -3, etc. to duplicate proxy names. +func EnsureUniqueProxyNames(proxies []model.ProxyNode) []model.ProxyNode { + seen := make(map[string]int, len(proxies)) + result := make([]model.ProxyNode, len(proxies)) + for i, proxy := range proxies { + if proxy == nil { + result[i] = proxy + continue + } + name := toString(proxy["name"]) + count := seen[name] + seen[name] = count + 1 + if count == 0 { + result[i] = proxy + continue + } + merged := make(map[string]any, len(proxy)) + for k, v := range proxy { + merged[k] = v + } + merged["name"] = fmt.Sprintf("%s-%d", name, count+1) + result[i] = merged + } + return result +} + +// toString converts an any value to its string representation. +func toString(v any) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case bool: + return strconv.FormatBool(val) + case float64: + return strconv.FormatFloat(val, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(val), 'f', -1, 32) + case int: + return strconv.Itoa(val) + case int64: + return strconv.FormatInt(val, 10) + case json.Number: + return val.String() + default: + return fmt.Sprintf("%v", v) + } +} + +// portString returns the string representation of a port value. +func portString(v any) string { + if v == nil { + return "" + } + return toString(v) +} + +// toNumberOrUndefined converts a value to a float64 (Go's JSON number), +// returning nil if the value is nil or cannot be parsed. +func toNumberOrUndefined(v any) any { + if v == nil { + return nil + } + switch val := v.(type) { + case float64: + return val + case float32: + return float64(val) + case int: + return float64(val) + case int64: + return float64(val) + case string: + if val == "" { + return nil + } + n, err := strconv.ParseFloat(val, 64) + if err != nil { + return nil + } + return n + case bool: + return nil + default: + return nil + } +} diff --git a/internal/proxy/parser_test.go b/internal/proxy/parser_test.go new file mode 100644 index 0000000..9dd4ffd --- /dev/null +++ b/internal/proxy/parser_test.go @@ -0,0 +1,278 @@ +package proxy + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +func TestParseSS(t *testing.T) { + plain := "aes-256-gcm:password123@1.2.3.4:8388" + encoded := base64.RawURLEncoding.EncodeToString([]byte(plain)) + uri := "ss://" + encoded + "#TestSS" + nodes := ParseProxies(uri) + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + n := nodes[0] + if n["type"] != "ss" { + t.Errorf("expected type ss, got %v", n["type"]) + } + if n["server"] != "1.2.3.4" { + t.Errorf("expected server 1.2.3.4, got %v", n["server"]) + } + if n["name"] != "TestSS" { + t.Errorf("expected name TestSS, got %v", n["name"]) + } + if n["cipher"] != "aes-256-gcm" { + t.Errorf("expected cipher aes-256-gcm, got %v", n["cipher"]) + } + if n["password"] != "password123" { + t.Errorf("expected password password123, got %v", n["password"]) + } + port, _ := n["port"].(float64) + if port != 8388 { + t.Errorf("expected port 8388, got %v", n["port"]) + } +} + +func TestParseSSURLForm(t *testing.T) { + uri := "ss://aes-256-gcm:password123@1.2.3.4:8388#TestSS" + nodes := ParseProxies(uri) + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + n := nodes[0] + if n["type"] != "ss" { + t.Errorf("expected type ss, got %v", n["type"]) + } + if n["cipher"] != "aes-256-gcm" { + t.Errorf("expected cipher aes-256-gcm, got %v", n["cipher"]) + } +} + +func TestParseSSR(t *testing.T) { + // SSR format: base64(server:port:protocol:method:obfs:base64(password)/?remarks=base64(name)) + ssrMain := "1.2.3.4:8388:auth_aes128_sha1:aes-256-cfb:http_simple:" + base64.RawURLEncoding.EncodeToString([]byte("pass")) + encoded := base64.RawURLEncoding.EncodeToString([]byte(ssrMain + "/?remarks=" + base64.RawURLEncoding.EncodeToString([]byte("TestSSR")))) + uri := "ssr://" + encoded + nodes := ParseProxies(uri) + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + n := nodes[0] + if n["type"] != "ssr" { + t.Errorf("expected type ssr, got %v", n["type"]) + } + if n["server"] != "1.2.3.4" { + t.Errorf("expected server 1.2.3.4, got %v", n["server"]) + } + if n["obfs"] != "http_simple" { + t.Errorf("expected obfs http_simple, got %v", n["obfs"]) + } +} + +func TestParseVMess(t *testing.T) { + vmJSON := `{"v":"2","ps":"TestVM","add":"1.2.3.4","port":"443","id":"uuid-1234","aid":"0","scy":"auto","net":"ws","type":"none","host":"example.com","path":"/path","tls":"tls","sni":"example.com"}` + encoded := base64.RawURLEncoding.EncodeToString([]byte(vmJSON)) + uri := "vmess://" + encoded + nodes := ParseProxies(uri) + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + n := nodes[0] + if n["type"] != "vmess" { + t.Errorf("expected type vmess, got %v", n["type"]) + } + if n["server"] != "1.2.3.4" { + t.Errorf("expected server 1.2.3.4, got %v", n["server"]) + } + if n["uuid"] != "uuid-1234" { + t.Errorf("expected uuid uuid-1234, got %v", n["uuid"]) + } + if n["network"] != "ws" { + t.Errorf("expected network ws, got %v", n["network"]) + } + if n["tls"] != true { + t.Errorf("expected tls true, got %v", n["tls"]) + } +} + +func TestParseVLESS(t *testing.T) { + uri := "vless://uuid-1234@1.2.3.4:443?encryption=none&security=tls&type=ws&host=example.com&path=%2Fpath&sni=example.com#TestVLESS" + nodes := ParseProxies(uri) + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + n := nodes[0] + if n["type"] != "vless" { + t.Errorf("expected type vless, got %v", n["type"]) + } + if n["uuid"] != "uuid-1234" { + t.Errorf("expected uuid uuid-1234, got %v", n["uuid"]) + } + if n["network"] != "ws" { + t.Errorf("expected network ws, got %v", n["network"]) + } + if n["tls"] != true { + t.Errorf("expected tls true, got %v", n["tls"]) + } +} + +func TestParseTrojan(t *testing.T) { + uri := "trojan://password123@1.2.3.4:443?sni=example.com&type=ws&host=example.com&path=%2Fpath#TestTrojan" + nodes := ParseProxies(uri) + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + n := nodes[0] + if n["type"] != "trojan" { + t.Errorf("expected type trojan, got %v", n["type"]) + } + if n["password"] != "password123" { + t.Errorf("expected password password123, got %v", n["password"]) + } + if n["sni"] != "example.com" { + t.Errorf("expected sni example.com, got %v", n["sni"]) + } +} + +func TestParseHysteria2(t *testing.T) { + uri := "hysteria2://password123@1.2.3.4:443?sni=example.com&insecure=1#TestHys2" + nodes := ParseProxies(uri) + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + n := nodes[0] + if n["type"] != "hysteria2" { + t.Errorf("expected type hysteria2, got %v", n["type"]) + } + if n["password"] != "password123" { + t.Errorf("expected password password123, got %v", n["password"]) + } +} + +func TestParseMultipleLines(t *testing.T) { + lines := []string{ + "ss://aes-256-gcm:pass@1.2.3.4:8388#Node1", + "ss://aes-256-gcm:pass@5.6.7.8:8388#Node2", + "ss://aes-256-gcm:pass@9.10.11.12:8388#Node3", + } + nodes := ParseProxies(strings.Join(lines, "\n")) + if len(nodes) != 3 { + t.Fatalf("expected 3 nodes, got %d", len(nodes)) + } + if nodes[0]["name"] != "Node1" { + t.Errorf("expected name Node1, got %v", nodes[0]["name"]) + } + if nodes[2]["name"] != "Node3" { + t.Errorf("expected name Node3, got %v", nodes[2]["name"]) + } +} + +func TestParseBase64Content(t *testing.T) { + lines := []string{ + "ss://aes-256-gcm:pass@1.2.3.4:8388#Node1", + "ss://aes-256-gcm:pass@5.6.7.8:8388#Node2", + } + plain := strings.Join(lines, "\n") + encoded := base64.StdEncoding.EncodeToString([]byte(plain)) + // DecodeMaybeBase64 should detect and decode base64 content + decoded := DecodeMaybeBase64(encoded) + nodes := ParseProxies(decoded) + if len(nodes) != 2 { + t.Fatalf("expected 2 nodes from base64 content, got %d", len(nodes)) + } +} + +func TestParseEmptyContent(t *testing.T) { + nodes := ParseProxies("") + if len(nodes) != 0 { + t.Errorf("expected 0 nodes for empty content, got %d", len(nodes)) + } +} + +func TestParseInvalidContent(t *testing.T) { + nodes := ParseProxies("this is not a valid proxy URI") + if len(nodes) != 0 { + t.Errorf("expected 0 nodes for invalid content, got %d", len(nodes)) + } +} + +func TestParseJSONContent(t *testing.T) { + jsonContent := `[{"type":"ss","server":"1.2.3.4","port":8388,"cipher":"aes-256-gcm","password":"pass","name":"TestJSON"}]` + nodes := ParseProxies(jsonContent) + if len(nodes) != 1 { + t.Fatalf("expected 1 node from JSON, got %d", len(nodes)) + } + if nodes[0]["type"] != "ss" { + t.Errorf("expected type ss, got %v", nodes[0]["type"]) + } +} + +func TestEnsureUniqueProxyNames(t *testing.T) { + nodes := []model.ProxyNode{ + {"name": "Same", "server": "1.1.1.1"}, + {"name": "Same", "server": "2.2.2.2"}, + {"name": "Same", "server": "3.3.3.3"}, + {"name": "Unique", "server": "4.4.4.4"}, + } + result := EnsureUniqueProxyNames(nodes) + if result[0]["name"] != "Same" { + t.Errorf("first name should be unchanged, got %v", result[0]["name"]) + } + if result[1]["name"] == "Same" { + t.Error("second duplicate should be renamed") + } + if result[3]["name"] != "Unique" { + t.Errorf("unique name should be unchanged, got %v", result[3]["name"]) + } +} + +func TestAddPreviewIds(t *testing.T) { + nodes := []model.ProxyNode{ + {"name": "A", "server": "1.1.1.1"}, + {"name": "B", "server": "2.2.2.2"}, + } + result := AddPreviewIds(nodes) + if _, ok := result[0]["id"]; !ok { + t.Error("expected id field on first node") + } + if _, ok := result[1]["id"]; !ok { + t.Error("expected id field on second node") + } +} + +func TestDecodeMaybeBase64(t *testing.T) { + plain := "ss://pass@host:port#name" + if DecodeMaybeBase64(plain) != plain { + t.Error("plain text should pass through") + } + encoded := base64.StdEncoding.EncodeToString([]byte(plain)) + decoded := DecodeMaybeBase64(encoded) + if !strings.Contains(decoded, "ss://") { + t.Errorf("base64 should be decoded, got: %s", decoded) + } +} + +func TestNodeJSONSerialization(t *testing.T) { + uri := "ss://aes-256-gcm:pass@1.2.3.4:8388#Test" + nodes := ParseProxies(uri) + if len(nodes) == 0 { + t.Fatal("no nodes parsed") + } + data, err := json.Marshal(nodes[0]) + if err != nil { + t.Fatalf("failed to marshal node: %v", err) + } + var back map[string]any + if err := json.Unmarshal(data, &back); err != nil { + t.Fatalf("failed to unmarshal node: %v", err) + } + if back["type"] != "ss" { + t.Errorf("expected type ss after round-trip, got %v", back["type"]) + } +} diff --git a/internal/proxy/uri_parser.go b/internal/proxy/uri_parser.go new file mode 100644 index 0000000..83b736b --- /dev/null +++ b/internal/proxy/uri_parser.go @@ -0,0 +1,573 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// SplitHostPort splits a host:port string using strings.LastIndex(":"). +// Per review-resolution #13: not net.SplitHostPort, mirrors TS lastIndexOf(":"). +func SplitHostPort(s string) (host, port string) { + value := strings.TrimSpace(s) + lastColon := strings.LastIndex(value, ":") + if lastColon <= 0 { + return value, "" + } + return value[:lastColon], value[lastColon+1:] +} + +// ParseProxyUri dispatches a single URI line to the appropriate protocol parser. +// Returns nil for unrecognized schemes or parse failures. +func ParseProxyUri(line string, index int) model.ProxyNode { + defer func() { _ = recover() }() + switch { + case strings.HasPrefix(line, "vless://"): + return ParseVless(line, index) + case strings.HasPrefix(line, "anytls://"): + return ParseAnytls(line, index) + case strings.HasPrefix(line, "hysteria://"), strings.HasPrefix(line, "hy://"): + return ParseHysteria(line, index) + case strings.HasPrefix(line, "hysteria2://"), strings.HasPrefix(line, "hy2://"): + return ParseHysteria2(line, index) + case strings.HasPrefix(line, "trojan://"): + return ParseTrojan(line, index) + case strings.HasPrefix(line, "vmess://"): + return ParseVmess(line, index) + case strings.HasPrefix(line, "ss://"): + return ParseShadowsocks(line, index) + case strings.HasPrefix(line, "ssr://"): + return ParseShadowsocksR(line, index) + case strings.HasPrefix(line, "socks://"), strings.HasPrefix(line, "socks5://"), strings.HasPrefix(line, "socks5+tls://"): + return ParseSocks(line, index) + case strings.HasPrefix(line, "tuic://"): + return ParseTuic(line, index) + case strings.HasPrefix(line, "wireguard://"), strings.HasPrefix(line, "wg://"): + return ParseWireGuard(line, index) + case strings.HasPrefix(line, "http://"), strings.HasPrefix(line, "https://"): + return ParseHttpProxy(line, index) + default: + return nil + } +} + +// parseURL is a helper that parses a URL and panics on error so the deferred +// recover in ParseProxyUri catches it — mirroring the TS `new URL(line)` throw. +func parseURL(line string) *url.URL { + u, err := url.Parse(line) + if err != nil { + panic(err) + } + return u +} + +// fragmentName extracts and URL-decodes the fragment (node name). +// Per review-resolution #14: url.Fragment is raw; use url.QueryUnescape. +func fragmentName(u *url.URL, fallback string) string { + if u.Fragment == "" { + return fallback + } + decoded, err := url.QueryUnescape(u.Fragment) + if err != nil { + return u.Fragment + } + return decoded +} + +// userInfo extracts the URL-decoded username from userinfo. +func userInfo(u *url.URL) string { + if u.User == nil { + return "" + } + return u.User.Username() +} + +// userPassword extracts the URL-decoded password from userinfo. +func userPassword(u *url.URL) string { + if u.User == nil { + return "" + } + p, _ := u.User.Password() + return p +} + +// portFromURL returns the port as a number, defaulting to fallback. +func portFromURL(u *url.URL, fallback int) float64 { + portStr := u.Port() + if portStr == "" { + return float64(fallback) + } + n, err := strconv.ParseFloat(portStr, 64) + if err != nil { + return float64(fallback) + } + return n +} + +// paramGet returns the first value of a query parameter, "" if absent. +// Per review-resolution #14: Go Query().Get returns "" (not nil like JS). +func paramGet(u *url.URL, key string) string { + return u.Query().Get(key) +} + +// paramFirst returns the first non-empty value among the given keys. +func paramFirst(u *url.URL, keys ...string) string { + q := u.Query() + for _, k := range keys { + if v := q.Get(k); v != "" { + return v + } + } + return "" +} + +// ParseVless parses a vless:// URI. +func ParseVless(line string, index int) model.ProxyNode { + u := parseURL(line) + params := u.Query() + publicKey := firstNonEmpty(params.Get("pbk"), params.Get("public-key")) + shortId := firstNonEmpty(params.Get("sid"), params.Get("short-id")) + security := params.Get("security") + if security == "" { + if publicKey != "" { + security = "reality" + } else { + security = "tls" + } + } + + node := map[string]any{ + "name": fragmentName(u, fmt.Sprintf("vless-%d", index+1)), + "type": "vless", + "server": u.Hostname(), + "port": portFromURL(u, 443), + "uuid": userInfo(u), + "udp": true, + "flow": params.Get("flow"), + "network": orDefault(params.Get("type"), "tcp"), + "tls": security != "none", + "servername": params.Get("sni"), + "encryption": orDefault(params.Get("encryption"), "none"), + "client-fingerprint": orDefault(params.Get("fp"), "chrome"), + } + if publicKey != "" { + node["reality-opts"] = StripUndefined(map[string]any{ + "public-key": publicKey, + "short-id": shortId, + "spider-x": orDefault(params.Get("spx"), "/"), + }) + } + return StripUndefined(node) +} + +// ParseAnytls parses an anytls:// URI. +func ParseAnytls(line string, index int) model.ProxyNode { + u := parseURL(line) + return StripUndefined(map[string]any{ + "name": fragmentName(u, fmt.Sprintf("anytls-%d", index+1)), + "type": "anytls", + "server": u.Hostname(), + "port": portFromURL(u, 443), + "password": userInfo(u), + "sni": firstNonEmpty(paramGet(u, "sni"), paramGet(u, "peer")), + "skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "insecure"), paramGet(u, "allowInsecure"))), + "client-fingerprint": orDefault(paramGet(u, "fp"), "chrome"), + }) +} + +// ParseHysteria2 parses a hysteria2:// or hy2:// URI. +func ParseHysteria2(line string, index int) model.ProxyNode { + normalized := strings.Replace(line, "hy2://", "hysteria2://", 1) + u := parseURL(normalized) + return StripUndefined(map[string]any{ + "name": fragmentName(u, fmt.Sprintf("hysteria2-%d", index+1)), + "type": "hysteria2", + "server": u.Hostname(), + "port": portFromURL(u, 443), + "password": userInfo(u), + "sni": firstNonEmpty(paramGet(u, "sni"), paramGet(u, "peer")), + "skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "insecure"), paramGet(u, "allowInsecure"))), + "obfs": paramGet(u, "obfs"), + "obfs-password": firstNonEmpty(paramGet(u, "obfs-password"), paramGet(u, "salamander-password")), + }) +} + +// ParseHysteria parses a hysteria:// or hy:// URI. +func ParseHysteria(line string, index int) model.ProxyNode { + normalized := strings.Replace(line, "hy://", "hysteria://", 1) + u := parseURL(normalized) + authStr := userInfo(u) + if authStr == "" { + authStr = firstNonEmpty(paramGet(u, "auth"), paramGet(u, "auth_str")) + } + return StripUndefined(map[string]any{ + "name": fragmentName(u, fmt.Sprintf("hysteria-%d", index+1)), + "type": "hysteria", + "server": u.Hostname(), + "port": portFromURL(u, 443), + "auth_str": authStr, + "protocol": paramGet(u, "protocol"), + "up": firstNonEmpty(paramGet(u, "up"), paramGet(u, "upmbps")), + "down": firstNonEmpty(paramGet(u, "down"), paramGet(u, "downmbps")), + "sni": firstNonEmpty(paramGet(u, "sni"), paramGet(u, "peer")), + "alpn": CommaList(paramGet(u, "alpn")), + "obfs": paramGet(u, "obfs"), + "obfs-password": paramGet(u, "obfs-password"), + "skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "insecure"), paramGet(u, "allowInsecure"))), + }) +} + +// ParseTrojan parses a trojan:// URI. +func ParseTrojan(line string, index int) model.ProxyNode { + u := parseURL(line) + return StripUndefined(map[string]any{ + "name": fragmentName(u, fmt.Sprintf("trojan-%d", index+1)), + "type": "trojan", + "server": u.Hostname(), + "port": portFromURL(u, 443), + "password": userInfo(u), + "sni": firstNonEmpty(paramGet(u, "sni"), paramGet(u, "peer")), + "skip-cert-verify": BoolParam(paramGet(u, "allowInsecure")), + "udp": true, + }) +} + +// ParseVmess parses a vmess:// URI with base64-encoded JSON payload. +func ParseVmess(line string, index int) model.ProxyNode { + encoded := strings.TrimPrefix(line, "vmess://") + decoded, err := util.DecodeBase64Auto(strings.TrimSpace(encoded)) + if err != nil { + return nil + } + var payload map[string]any + if err := json.Unmarshal([]byte(decoded), &payload); err != nil { + return nil + } + + port := toNumberOrUndefined(payload["port"]) + node := map[string]any{ + "name": orDefault(toString(payload["ps"]), fmt.Sprintf("vmess-%d", index+1)), + "type": "vmess", + "server": toString(payload["add"]), + "port": port, + "uuid": toString(payload["id"]), + "alterId": toNumberOrUndefined(payload["aid"]), + "cipher": orDefault(toString(payload["scy"]), "auto"), + "tls": toString(payload["tls"]) == "tls", + "servername": firstNonEmpty(toString(payload["sni"]), toString(payload["host"])), + "network": orDefault(toString(payload["net"]), "tcp"), + "udp": true, + } + if toString(payload["net"]) == "ws" { + wsOpts := map[string]any{ + "path": orDefault(toString(payload["path"]), "/"), + } + host := toString(payload["host"]) + if host != "" { + wsOpts["headers"] = map[string]any{"Host": host} + } + node["ws-opts"] = wsOpts + } + return StripUndefined(node) +} + +// ParseShadowsocks parses an ss:// URI. +// Handles both ss://base64(cipher:password@host:port) and +// ss://base64(cipher:password)@host:port formats. +func ParseShadowsocks(line string, index int) model.ProxyNode { + defer func() { _ = recover() }() + withoutScheme := strings.TrimPrefix(line, "ss://") + var main, hash string + if idx := strings.Index(withoutScheme, "#"); idx >= 0 { + main = withoutScheme[:idx] + hash = withoutScheme[idx+1:] + } else { + main = withoutScheme + } + + var decodedMain string + if strings.Contains(main, "@") { + decodedMain = main + } else { + // base64-encoded cipher:password@host:port + d, err := util.DecodeBase64Auto(main) + if err != nil { + return nil + } + decodedMain = d + } + + atIdx := strings.LastIndex(decodedMain, "@") + if atIdx < 0 { + return nil + } + userInfoStr := decodedMain[:atIdx] + hostInfo := decodedMain[atIdx+1:] + + var decodedUserInfo string + if strings.Contains(userInfoStr, ":") { + decodedUserInfo = userInfoStr + } else { + // base64-encoded cipher:password + d, err := util.DecodeBase64Auto(userInfoStr) + if err != nil { + decodedUserInfo = userInfoStr + } else { + decodedUserInfo = d + } + } + + colonIdx := strings.Index(decodedUserInfo, ":") + var cipher, password string + if colonIdx >= 0 { + cipher = decodedUserInfo[:colonIdx] + password = decodedUserInfo[colonIdx+1:] + } else { + cipher = decodedUserInfo + } + + host, portPart := SplitHostPort(hostInfo) + // strip query string from port + portStr := portPart + if qIdx := strings.Index(portStr, "?"); qIdx >= 0 { + portStr = portStr[:qIdx] + } + port, _ := strconv.ParseFloat(portStr, 64) + + name := fmt.Sprintf("ss-%d", index+1) + if hash != "" { + decoded, err := url.QueryUnescape(hash) + if err != nil { + name = hash + } else { + name = decoded + } + } + + return StripUndefined(map[string]any{ + "name": name, + "type": "ss", + "server": host, + "port": port, + "cipher": cipher, + "password": password, + "udp": true, + }) +} + +// ParseShadowsocksR parses an ssr:// URI. +// The payload after ssr:// is base64 (RawURL) decoded, then split by ":" and "/?". +func ParseShadowsocksR(line string, index int) model.ProxyNode { + defer func() { _ = recover() }() + encoded := strings.TrimPrefix(line, "ssr://") + decoded, err := util.DecodeBase64RawURL(encoded) + if err != nil { + return nil + } + + var main, rawQuery string + if idx := strings.Index(decoded, "/?"); idx >= 0 { + main = decoded[:idx] + rawQuery = decoded[idx+2:] + } else { + main = decoded + } + + parts := strings.Split(main, ":") + if len(parts) < 6 { + return nil + } + server := parts[0] + portStr := parts[1] + protocol := parts[2] + method := parts[3] + obfs := parts[4] + encodedPassword := parts[5] + + port, _ := strconv.ParseFloat(portStr, 64) + + query, err := url.ParseQuery(rawQuery) + if err != nil { + return nil + } + + remarks := query.Get("remarks") + var name string + if remarks != "" { + decodedRemarks, err := util.DecodeBase64RawURL(remarks) + if err != nil { + name = remarks + } else { + name = decodedRemarks + } + } else { + name = fmt.Sprintf("ssr-%d", index+1) + } + + password, err := util.DecodeBase64RawURL(encodedPassword) + if err != nil { + password = encodedPassword + } + + node := map[string]any{ + "name": name, + "type": "ssr", + "server": server, + "port": port, + "cipher": method, + "password": password, + "protocol": protocol, + "obfs": obfs, + "udp": true, + } + + if pp := query.Get("protoparam"); pp != "" { + decoded, err := util.DecodeBase64RawURL(pp) + if err == nil { + node["protocol-param"] = decoded + } + } + if op := query.Get("obfsparam"); op != "" { + decoded, err := util.DecodeBase64RawURL(op) + if err == nil { + node["obfs-param"] = decoded + } + } + + return StripUndefined(node) +} + +// ParseSocks parses a socks://, socks5://, or socks5+tls:// URI. +func ParseSocks(line string, index int) model.ProxyNode { + defer func() { _ = recover() }() + normalized := line + normalized = strings.Replace(normalized, "socks://", "socks5://", 1) + normalized = strings.Replace(normalized, "socks5+tls://", "socks5://", 1) + u := parseURL(normalized) + if u.Port() == "" { + return nil + } + return StripUndefined(map[string]any{ + "name": fragmentName(u, fmt.Sprintf("socks5-%d", index+1)), + "type": "socks5", + "server": u.Hostname(), + "port": portFromURL(u, 0), + "username": userInfo(u), + "password": userPassword(u), + "tls": strings.HasPrefix(line, "socks5+tls://") || BoolParam(paramGet(u, "tls")), + "udp": true, + }) +} + +// ParseHttpProxy parses an http:// or https:// proxy URI. +func ParseHttpProxy(line string, index int) model.ProxyNode { + defer func() { _ = recover() }() + u := parseURL(line) + if u.Port() == "" { + return nil + } + fallback := fmt.Sprintf("http-%d", index+1) + if u.Scheme == "https" { + fallback = fmt.Sprintf("https-%d", index+1) + } + return StripUndefined(map[string]any{ + "name": fragmentName(u, fallback), + "type": "http", + "server": u.Hostname(), + "port": portFromURL(u, 0), + "username": userInfo(u), + "password": userPassword(u), + "tls": u.Scheme == "https", + }) +} + +// ParseTuic parses a tuic:// URI. +func ParseTuic(line string, index int) model.ProxyNode { + u := parseURL(line) + return StripUndefined(map[string]any{ + "name": fragmentName(u, fmt.Sprintf("tuic-%d", index+1)), + "type": "tuic", + "server": u.Hostname(), + "port": portFromURL(u, 443), + "uuid": userInfo(u), + "password": userPassword(u), + "sni": paramGet(u, "sni"), + "alpn": CommaList(paramGet(u, "alpn")), + "skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "allow_insecure"), paramGet(u, "insecure"))), + "disable-sni": BoolParam(firstNonEmpty(paramGet(u, "disable_sni"), paramGet(u, "disable-sni"))), + "reduce-rtt": BoolParam(firstNonEmpty(paramGet(u, "reduce_rtt"), paramGet(u, "reduce-rtt"))), + "udp-relay-mode": firstNonEmpty(paramGet(u, "udp_relay_mode"), paramGet(u, "udp-relay-mode")), + "congestion-controller": firstNonEmpty(paramGet(u, "congestion_control"), paramGet(u, "congestion-controller")), + }) +} + +// ParseWireGuard parses a wireguard:// or wg:// URI. +func ParseWireGuard(line string, index int) model.ProxyNode { + normalized := strings.Replace(line, "wg://", "wireguard://", 1) + u := parseURL(normalized) + return StripUndefined(map[string]any{ + "name": fragmentName(u, fmt.Sprintf("wireguard-%d", index+1)), + "type": "wireguard", + "server": u.Hostname(), + "port": portFromURL(u, 51820), + "ip": firstNonEmpty(paramGet(u, "ip"), paramGet(u, "address")), + "ipv6": paramGet(u, "ipv6"), + "private-key": firstNonEmpty(userInfo(u), paramGet(u, "private-key"), paramGet(u, "privatekey")), + "public-key": firstNonEmpty(paramGet(u, "public-key"), paramGet(u, "publickey"), paramGet(u, "peer-public-key")), + "pre-shared-key": firstNonEmpty(paramGet(u, "pre-shared-key"), paramGet(u, "presharedkey"), paramGet(u, "psk")), + "reserved": paramGet(u, "reserved"), + "udp": true, + }) +} + +// BoolParam returns true if value is "1" or "true". +func BoolParam(value string) bool { + return value == "1" || value == "true" +} + +// CommaList splits a comma-separated string into a trimmed []string. +// Returns nil if the input is empty or produces no items. +func CommaList(value string) []string { + if value == "" { + return nil + } + var list []string + for _, item := range strings.Split(value, ",") { + trimmed := strings.TrimSpace(item) + if trimmed != "" { + list = append(list, trimmed) + } + } + if len(list) == 0 { + return nil + } + return list +} + +// NumberOrUndefined converts a value to a float64, returning nil if invalid. +func NumberOrUndefined(value any) any { + return toNumberOrUndefined(value) +} + +// firstNonEmpty returns the first non-empty string from the arguments. +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +// orDefault returns value if non-empty, otherwise fallback. +func orDefault(value, fallback string) string { + if value == "" { + return fallback + } + return value +} diff --git a/internal/render/dispatch.go b/internal/render/dispatch.go new file mode 100644 index 0000000..2caedd4 --- /dev/null +++ b/internal/render/dispatch.go @@ -0,0 +1,84 @@ +package render + +import ( + "fmt" + "net/url" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// RenderTarget dispatches to the appropriate renderer based on target. +// This is the simplified entry point matching renderTarget() in the reference. +func RenderTarget(proxies []model.ProxyNode, target string, template map[string]any) (string, error) { + if len(proxies) == 0 { + return "", fmt.Errorf("No supported nodes for %s output", target) + } + switch target { + case model.TargetMihomo, model.TargetStash: + return RenderMihomoYaml(proxies, "https://sub-store.local/convert", template), nil + case model.TargetSurge: + return RenderSurgeProxies(proxies) + case model.TargetSurgeMac: + return RenderSurgeMacProxies(proxies) + case model.TargetSurfboard: + return RenderSurfboardProxies(proxies) + case model.TargetLoon: + return RenderLoonProxies(proxies) + case model.TargetEgern: + return RenderEgernYaml(proxies) + case model.TargetQX: + return RenderQxProxies(proxies) + case model.TargetSingBox: + return RenderSingBoxJson(proxies), nil + case model.TargetV2ray: + return util.Base64Utf8(RenderProxyUris(proxies)), nil + case model.TargetURI, model.TargetShadowrocket: + return RenderProxyUris(proxies), nil + case model.TargetJSON: + return RenderJson(proxies), nil + default: + return RenderMihomoYaml(proxies, "https://sub-store.local/convert", template), nil + } +} + +// RenderBuildTarget dispatches with a request URL for mihomo source comments. +func RenderBuildTarget(proxies []model.ProxyNode, target string, requestUrl string, template map[string]any) (string, error) { + switch target { + case model.TargetMihomo, model.TargetStash: + return RenderMihomoYaml(proxies, requestUrl, template), nil + case model.TargetSurge: + return RenderSurgeProxies(proxies) + case model.TargetSurgeMac: + return RenderSurgeMacProxies(proxies) + case model.TargetSurfboard: + return RenderSurfboardProxies(proxies) + case model.TargetLoon: + return RenderLoonProxies(proxies) + case model.TargetEgern: + return RenderEgernYaml(proxies) + case model.TargetShadowrocket: + return RenderProxyUris(proxies), nil + case model.TargetQX: + return RenderQxProxies(proxies) + case model.TargetSingBox: + return RenderSingBoxJson(proxies), nil + case model.TargetV2ray: + return util.Base64Utf8(RenderProxyUris(proxies)), nil + case model.TargetURI: + return RenderProxyUris(proxies), nil + case model.TargetJSON: + return RenderJson(proxies), nil + default: + return RenderMihomoYaml(proxies, requestUrl, template), nil + } +} + +// sourcePath extracts the path portion of requestUrl for the YAML header comment. +func sourcePath(requestUrl string) string { + if u, err := url.Parse(requestUrl); err == nil && u.Path != "" { + return u.Path + } + return strings.TrimPrefix(requestUrl, "https://") +} diff --git a/internal/render/egern.go b/internal/render/egern.go new file mode 100644 index 0000000..0ec00ac --- /dev/null +++ b/internal/render/egern.go @@ -0,0 +1,176 @@ +package render + +import ( + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" + "gopkg.in/yaml.v3" +) + +// RenderEgernYaml renders proxies as an Egern YAML document. +func RenderEgernYaml(proxies []model.ProxyNode) (string, error) { + var list []map[string]any + for _, p := range proxies { + if e := ToEgernProxy(p); e != nil { + list = append(list, e) + } + } + if len(list) == 0 { + return "", errNoNodes("egern") + } + data, err := yaml.Marshal(map[string]any{"proxies": list}) + if err != nil { + return "", err + } + return string(data), nil +} + +// ToEgernProxy converts a proxy node to an Egern proxy map. +// Returns nil for unsupported proxy types. +func ToEgernProxy(proxy model.ProxyNode) map[string]any { + common := util.StripUndefined(map[string]any{ + "name": proxy["name"], + "server": proxy["server"], + "port": proxy["port"], + "tfo": proxy["tfo"], + "udp_relay": proxy["udp"], + }) + + ptype := getString(proxy, "type") + + switch ptype { + case "ss": + return util.StripUndefined(mergeMap(common, map[string]any{ + "type": "shadowsocks", + "method": proxy["cipher"], + "password": proxy["password"], + })) + + case "vmess", "vless": + security := "" + if ptype == "vmess" { + security = strOr(proxy, "cipher", "auto") + } + return util.StripUndefined(mergeMap(common, map[string]any{ + "type": ptype, + "uuid": proxy["uuid"], + "alter_id": proxy["alterId"], + "security": security, + "flow": proxy["flow"], + "tls": proxy["tls"], + "sni": proxy["servername"], + "network": proxy["network"], + "ws_opts": egernWsOptions(proxy), + "reality": egernRealityOptions(proxy), + })) + + case "trojan", "anytls", "hysteria2": + return util.StripUndefined(mergeMap(common, map[string]any{ + "type": ptype, + "password": proxy["password"], + "sni": sniOr(proxy, "sni", "servername"), + "skip_tls_verify": proxy["skip-cert-verify"], + "obfs": proxy["obfs"], + "obfs_password": proxy["obfs-password"], + "reality": egernRealityOptions(proxy), + })) + + case "http", "socks5": + egernType := ptype + if ptype == "http" && getBool(proxy, "tls") { + egernType = "https" + } else if ptype == "socks5" && getBool(proxy, "tls") { + egernType = "socks5_tls" + } + return util.StripUndefined(mergeMap(common, map[string]any{ + "type": egernType, + "username": proxy["username"], + "password": proxy["password"], + "skip_tls_verify": proxy["skip-cert-verify"], + })) + + case "tuic": + return util.StripUndefined(mergeMap(common, map[string]any{ + "type": "tuic", + "uuid": proxy["uuid"], + "password": proxy["password"], + "sni": proxy["sni"], + "skip_tls_verify": proxy["skip-cert-verify"], + })) + + case "wireguard": + return util.StripUndefined(mergeMap(common, map[string]any{ + "type": "wireguard", + "private_key": proxy["private-key"], + "public_key": proxy["public-key"], + "pre_shared_key": proxy["pre-shared-key"], + "address": proxy["ip"], + "ipv6_address": proxy["ipv6"], + })) + } + + return nil +} + +func egernWsOptions(proxy model.ProxyNode) any { + if getString(proxy, "network") != "ws" { + return nil + } + wsOpts, _ := proxy["ws-opts"].(map[string]any) + path := "/" + if wsOpts != nil { + if p, ok := wsOpts["path"].(string); ok && p != "" { + path = p + } + } + result := map[string]any{"path": path} + if wsOpts != nil { + if h, ok := wsOpts["headers"]; ok && h != nil { + result["headers"] = h + } + } + return result +} + +func egernRealityOptions(proxy model.ProxyNode) any { + realityOpts, _ := proxy["reality-opts"].(map[string]any) + if realityOpts == nil { + return nil + } + pubKey := realityOpts["public-key"] + if pubKey == nil { + return nil + } + return util.StripUndefined(map[string]any{ + "public_key": pubKey, + "short_id": realityOpts["short-id"], + }) +} + +func mergeMap(base, next map[string]any) map[string]any { + result := make(map[string]any, len(base)+len(next)) + for k, v := range base { + result[k] = v + } + for k, v := range next { + result[k] = v + } + return result +} + +func errNoNodes(target string) error { + return &noNodesError{target: target} +} + +type noNodesError struct { + target string +} + +func (e *noNodesError) Error() string { + var sb strings.Builder + sb.WriteString("No supported nodes for ") + sb.WriteString(e.target) + sb.WriteString(" output") + return sb.String() +} diff --git a/internal/render/json.go b/internal/render/json.go new file mode 100644 index 0000000..82a82ec --- /dev/null +++ b/internal/render/json.go @@ -0,0 +1,31 @@ +package render + +import ( + "encoding/json" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +// RenderJson renders proxies as a raw JSON object: {"proxies": [...]}. +func RenderJson(proxies []model.ProxyNode) string { + // Convert each ProxyNode (map[string]any) to a clean map with nil values removed + cleaned := make([]any, 0, len(proxies)) + for _, p := range proxies { + cleaned = append(cleaned, stripNil(p)) + } + doc := map[string]any{"proxies": cleaned} + data, _ := json.MarshalIndent(doc, "", " ") + return string(data) +} + +// stripNil removes nil values from a map recursively (shallow). +func stripNil(m map[string]any) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + if v == nil { + continue + } + result[k] = v + } + return result +} diff --git a/internal/render/loon.go b/internal/render/loon.go new file mode 100644 index 0000000..d0aab5d --- /dev/null +++ b/internal/render/loon.go @@ -0,0 +1,99 @@ +package render + +import ( + "github.com/peterqiu0516/sub-store/internal/model" +) + +// RenderLoonProxies renders proxies as Loon text lines. +func RenderLoonProxies(proxies []model.ProxyNode) (string, error) { + return RenderTextProxyList(proxies, model.TargetLoon, func(p model.ProxyNode) string { + return ToLoonProxyLine(p) + }) +} + +// ToLoonProxyLine converts a single proxy node to a Loon proxy line. +// Returns "" for unsupported proxy types. +func ToLoonProxyLine(proxy model.ProxyNode) string { + name := SanitizeTextProxyName(getString(proxy, "name")) + ptype := getString(proxy, "type") + server := getString(proxy, "server") + port := getInt(proxy, "port") + entries := CommonTextOptions(proxy) + + switch ptype { + case "ss": + entries = AppendPluginOptions(entries, proxy, "loon") + return JoinTextProxy( + name+"=shadowsocks,"+server+","+itoa(port)+","+strOr(proxy, "cipher", "none")+","+QuoteTextValue(proxy["password"]), + entries, + ) + + case "ssr": + return JoinTextProxy( + name+"=shadowsocksr,"+server+","+itoa(port)+","+strOr(proxy, "cipher", "aes-256-cfb")+","+QuoteTextValue(proxy["password"])+","+strOr(proxy, "protocol", "origin")+","+strOr(proxy, "obfs", "plain"), + entries, + ) + + case "vmess", "vless": + entries = unshift(entries, + [2]any{"transport", networkOr(proxy, "tcp")}, + [2]any{"over-tls", proxy["tls"]}, + [2]any{"sni", proxy["servername"]}, + [2]any{"flow", proxy["flow"]}, + ) + entries = AppendWsOptions(entries, proxy, "loon") + entries = AppendRealityOptions(entries, proxy) + method := strOr(proxy, "cipher", "auto") + if ptype == "vless" { + method = "none" + } + return JoinTextProxy( + name+"="+ptype+","+server+","+itoa(port)+","+method+","+QuoteTextValue(proxy["uuid"]), + entries, + ) + + case "trojan", "anytls": + entries = unshift(entries, + [2]any{"sni", sniOr(proxy, "sni", "servername")}, + ) + entries = AppendRealityOptions(entries, proxy) + return JoinTextProxy( + name+"="+ptype+","+server+","+itoa(port)+","+QuoteTextValue(proxy["password"]), + entries, + ) + + case "http", "socks5": + entries = unshift(entries, + [2]any{"username", proxy["username"]}, + [2]any{"password", proxy["password"]}, + [2]any{"over-tls", proxy["tls"]}, + [2]any{"sni", sniOr(proxy, "sni", "servername")}, + ) + loonType := ptype + if ptype == "socks5" { + loonType = "socks5" + } + return JoinTextProxy(name+"="+loonType+","+server+","+itoa(port), entries) + + case "hysteria2": + entries = unshift(entries, + [2]any{"tls-name", proxy["sni"]}, + [2]any{"obfs", proxy["obfs"]}, + [2]any{"obfs-password", proxy["obfs-password"]}, + ) + return JoinTextProxy( + name+"=Hysteria2,"+server+","+itoa(port)+","+QuoteTextValue(proxy["password"]), + entries, + ) + } + + return "" +} + +func networkOr(proxy model.ProxyNode, def string) string { + s := getString(proxy, "network") + if s == "" { + return def + } + return s +} diff --git a/internal/render/mihomo.go b/internal/render/mihomo.go new file mode 100644 index 0000000..bee2aa0 --- /dev/null +++ b/internal/render/mihomo.go @@ -0,0 +1,325 @@ +package render + +import ( + "regexp" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" + "gopkg.in/yaml.v3" +) + +// RenderMihomoYaml renders proxies + template into a Mihomo/stash YAML document. +func RenderMihomoYaml(proxies []model.ProxyNode, requestUrl string, template map[string]any) string { + cfg := template + if cfg == nil { + cfg = map[string]any{} + } + + mixedPort := firstInt(cfg, "mixedPort", "mixed-port") + if mixedPort == 0 { + mixedPort = 7890 + } + allowLan := firstBool(cfg, "allowLan", "allow-lan") // default false + logLevel := firstStr(cfg, "logLevel", "log-level") + if logLevel == "" { + logLevel = "info" + } + mode := firstStr(cfg, "mode") + if mode == "" { + mode = "rule" + } + + groupTemplates := extractGroupTemplates(cfg) + if len(groupTemplates) == 0 { + groupTemplates = DefaultProxyGroups() + } + + ruleProviders := firstAny(cfg, "ruleProviders", "rule-providers") + + rules, _ := cfg["rules"].([]any) + var rulesList []string + for _, r := range rules { + if s, ok := r.(string); ok && s != "" { + rulesList = append(rulesList, s) + } + } + if len(rulesList) == 0 { + rulesList = []string{"MATCH,🚀 节点选择"} + } + + // Build proxies list with undefined stripped + var proxiesList []map[string]any + for _, p := range proxies { + proxiesList = append(proxiesList, util.StripUndefined(p)) + } + + doc := map[string]any{ + "mixed-port": mixedPort, + "allow-lan": allowLan, + "mode": mode, + "log-level": logLevel, + "proxies": proxiesList, + "proxy-groups": RenderTemplateProxyGroups(proxies, groupTemplates), + "rules": rulesList, + } + if dns := cfg["dns"]; dns != nil { + doc["dns"] = dns + } + if sniffer := cfg["sniffer"]; sniffer != nil { + doc["sniffer"] = sniffer + } + if ruleProviders != nil { + doc["rule-providers"] = ruleProviders + } + + var sb strings.Builder + sb.WriteString("# Generated by Sub-Store\n") + sb.WriteString("# Source: " + sourcePath(requestUrl) + "\n") + data, err := yaml.Marshal(doc) + if err != nil { + sb.WriteString("# yaml marshal error: " + err.Error() + "\n") + return sb.String() + } + sb.Write(data) + return sb.String() +} + +// DefaultProxyGroups returns the 3 default proxy groups per review-resolution #41. +func DefaultProxyGroups() []map[string]any { + return []map[string]any{ + { + "name": "🚀 节点选择", + "type": "select", + "proxies": []string{"♻️ 自动选择", "🚀 手动切换", "DIRECT"}, + }, + { + "name": "♻️ 自动选择", + "type": "url-test", + "proxies": []string{"$all"}, + "url": model.TestURL, + "interval": 300, + "tolerance": 50, + }, + { + "name": "🚀 手动切换", + "type": "select", + "proxies": []string{"$all"}, + }, + } +} + +// RenderTemplateProxyGroups expands group templates, filters by regex, removes +// empty groups, and filters out references to non-existent nodes/groups. +func RenderTemplateProxyGroups(proxies []model.ProxyNode, groupTemplates []map[string]any) []map[string]any { + nodeNames := make([]string, 0, len(proxies)) + for _, p := range proxies { + nodeNames = append(nodeNames, getString(p, "name")) + } + + // Expand proxies for each group + type expandedGroup struct { + template map[string]any + proxies []string + } + var expanded []expandedGroup + for _, group := range groupTemplates { + expanded = append(expanded, expandedGroup{ + template: group, + proxies: ExpandGroupProxies(group, nodeNames), + }) + } + + // Collect names of groups that have at least one proxy entry + includedGroupNames := make(map[string]bool) + for _, g := range expanded { + if len(g.proxies) > 0 { + includedGroupNames[getString(g.template, "name")] = true + } + } + + allowedLiterals := map[string]bool{"DIRECT": true, "REJECT": true, "PASS": true} + + var result []map[string]any + for _, g := range expanded { + var proxyEntries []string + seen := make(map[string]bool) + for _, name := range g.proxies { + if seen[name] { + continue + } + seen[name] = true + // keep if it's a node name, a valid group name, or an allowed literal + if containsString(nodeNames, name) || includedGroupNames[name] || allowedLiterals[name] { + proxyEntries = append(proxyEntries, name) + } + } + if len(proxyEntries) == 0 { + continue + } + out := map[string]any{} + for k, v := range g.template { + if k == "filter" || k == "proxies" { + continue + } + if v == nil { + continue + } + if s, ok := v.(string); ok && s == "" { + continue + } + out[k] = v + } + out["proxies"] = proxyEntries + result = append(result, out) + } + return result +} + +// ExpandGroupProxies expands $all to all node names and applies the filter regex. +func ExpandGroupProxies(group map[string]any, nodeNames []string) []string { + var entries []string + if filterStr, ok := group["filter"].(string); ok && filterStr != "" { + entries = append(entries, findNamesByRegex(nodeNames, filterStr)...) + } + if rawProxies, ok := group["proxies"].([]any); ok { + for _, item := range rawProxies { + s, _ := item.(string) + if s == "" { + continue + } + if s == "$all" { + entries = append(entries, nodeNames...) + } else { + entries = append(entries, s) + } + } + } + return uniqueStrings(entries) +} + +func findNamesByRegex(names []string, pattern string) []string { + re := compileRegex(pattern) + if re == nil { + return nil + } + var result []string + for _, name := range names { + if re.MatchString(name) { + result = append(result, name) + } + } + return result +} + +func compileRegex(input string) *regexp.Regexp { + pattern := input + flags := "" + if strings.HasPrefix(input, "(?i)") { + pattern = input[4:] + flags = "i" + } + re, err := regexp.Compile(flags + pattern) + if err != nil { + return nil + } + return re +} + +// --- template config helpers --- + +func extractGroupTemplates(cfg map[string]any) []map[string]any { + if v, ok := cfg["proxyGroups"]; ok { + return toGroupList(v) + } + if v, ok := cfg["proxy-groups"]; ok { + return toGroupList(v) + } + return nil +} + +func toGroupList(v any) []map[string]any { + arr, ok := v.([]any) + if !ok { + return nil + } + var result []map[string]any + for _, item := range arr { + if m, ok := item.(map[string]any); ok { + result = append(result, m) + } + } + return result +} + +func firstStr(cfg map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := cfg[k]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + return "" +} + +func firstInt(cfg map[string]any, keys ...string) int { + for _, k := range keys { + if v, ok := cfg[k]; ok { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + } + } + } + return 0 +} + +func firstBool(cfg map[string]any, keys ...string) bool { + for _, k := range keys { + if v, ok := cfg[k]; ok { + if b, ok := v.(bool); ok { + return b + } + } + } + return false +} + +func firstAny(cfg map[string]any, keys ...string) any { + for _, k := range keys { + if v, ok := cfg[k]; ok && v != nil { + return v + } + } + return nil +} + +func containsString(list []string, s string) bool { + for _, item := range list { + if item == s { + return true + } + } + return false +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]bool, len(values)) + var result []string + for _, v := range values { + if v == "" { + continue + } + if seen[v] { + continue + } + seen[v] = true + result = append(result, v) + } + return result +} diff --git a/internal/render/qx.go b/internal/render/qx.go new file mode 100644 index 0000000..74c8bcb --- /dev/null +++ b/internal/render/qx.go @@ -0,0 +1,93 @@ +package render + +import ( + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +// RenderQxProxies renders proxies as Quantumult X text lines. +func RenderQxProxies(proxies []model.ProxyNode) (string, error) { + return RenderTextProxyList(proxies, model.TargetQX, func(p model.ProxyNode) string { + return ToQxProxyLine(p) + }) +} + +// ToQxProxyLine converts a single proxy node to a Quantumult X proxy line. +// Returns "" for unsupported proxy types. +func ToQxProxyLine(proxy model.ProxyNode) string { + ptype := getString(proxy, "type") + server := getString(proxy, "server") + port := getInt(proxy, "port") + + entries := CommonTextOptions(proxy) + entries = append(entries, [2]any{"tag", sanitizeQxTag(getString(proxy, "name"))}) + + switch ptype { + case "ss": + entries = unshift(entries, + [2]any{"method", strOr(proxy, "cipher", "none")}, + [2]any{"password", proxy["password"]}, + ) + entries = AppendQxObfs(entries, proxy) + return JoinTextProxy("shadowsocks="+server+":"+itoa(port), entries) + + case "ssr": + entries = unshift(entries, + [2]any{"method", strOr(proxy, "cipher", "aes-256-cfb")}, + [2]any{"password", proxy["password"]}, + [2]any{"ssr-protocol", strOr(proxy, "protocol", "origin")}, + [2]any{"obfs", strOr(proxy, "obfs", "plain")}, + ) + return JoinTextProxy("shadowsocks="+server+":"+itoa(port), entries) + + case "vmess", "vless": + method := strOr(proxy, "cipher", "auto") + if ptype == "vless" { + method = "none" + } + entries = unshift(entries, + [2]any{"method", method}, + [2]any{"password", proxy["uuid"]}, + [2]any{"over-tls", proxy["tls"]}, + [2]any{"tls-host", proxy["servername"]}, + [2]any{"flow", proxy["flow"]}, + ) + entries = AppendQxTransport(entries, proxy) + entries = AppendQxRealityOptions(entries, proxy) + return JoinTextProxy(ptype+"="+server+":"+itoa(port), entries) + + case "trojan", "anytls": + entries = unshift(entries, + [2]any{"password", proxy["password"]}, + [2]any{"over-tls", true}, + [2]any{"tls-host", sniOr(proxy, "sni", "servername")}, + ) + entries = AppendQxRealityOptions(entries, proxy) + return JoinTextProxy(ptype+"="+server+":"+itoa(port), entries) + + case "http", "socks5": + entries = unshift(entries, + [2]any{"username", proxy["username"]}, + [2]any{"password", proxy["password"]}, + [2]any{"over-tls", proxy["tls"]}, + ) + qxType := ptype + if ptype == "socks5" { + qxType = "socks5" + } + return JoinTextProxy(qxType+"="+server+":"+itoa(port), entries) + } + + return "" +} + +// sanitizeQxTag replaces comma/CR/LF (but not =) with spaces. +func sanitizeQxTag(name string) string { + r := strings.NewReplacer(",", " ", "\r", " ", "\n", " ") + s := strings.TrimSpace(r.Replace(name)) + if s == "" { + return "proxy" + } + return s +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..eefcfc1 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,182 @@ +package render + +import ( + "encoding/json" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +func makeTestNodes() []model.ProxyNode { + return []model.ProxyNode{ + { + "type": "ss", + "name": "TestSS", + "server": "1.2.3.4", + "port": 8388, + "cipher": "aes-256-gcm", + "password": "pass123", + }, + { + "type": "vmess", + "name": "TestVMess", + "server": "5.6.7.8", + "port": 443, + "uuid": "test-uuid", + "alterId": 0, + "cipher": "auto", + "network": "ws", + "tls": true, + "ws-opts": map[string]any{ + "path": "/path", + "headers": map[string]any{"Host": "example.com"}, + }, + }, + { + "type": "trojan", + "name": "TestTrojan", + "server": "9.10.11.12", + "port": 443, + "password": "trojan-pass", + "sni": "example.com", + }, + } +} + +func TestRenderMihomo(t *testing.T) { + nodes := makeTestNodes() + output, err := RenderTarget(nodes, "mihomo", nil) + if err != nil { + t.Fatalf("render mihomo failed: %v", err) + } + // Should be valid YAML + var parsed map[string]any + if err := yaml.Unmarshal([]byte(output), &parsed); err != nil { + t.Fatalf("output is not valid YAML: %v", err) + } + proxies, ok := parsed["proxies"].([]any) + if !ok { + t.Fatal("expected proxies array in YAML") + } + if len(proxies) != 3 { + t.Errorf("expected 3 proxies, got %d", len(proxies)) + } +} + +func TestRenderJSON(t *testing.T) { + nodes := makeTestNodes() + output, err := RenderTarget(nodes, "json", nil) + if err != nil { + t.Fatalf("render json failed: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + proxies, ok := parsed["proxies"].([]any) + if !ok { + t.Fatal("expected proxies array in JSON output") + } + if len(proxies) != 3 { + t.Errorf("expected 3 nodes, got %d", len(proxies)) + } +} + +func TestRenderURI(t *testing.T) { + nodes := makeTestNodes() + output, err := RenderTarget(nodes, "uri", nil) + if err != nil { + t.Fatalf("render uri failed: %v", err) + } + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 3 { + t.Errorf("expected 3 lines, got %d", len(lines)) + } + // Each line should start with a protocol + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.Contains(line, "://") { + t.Errorf("expected URI format, got: %s", line) + } + } +} + +func TestRenderSurge(t *testing.T) { + nodes := makeTestNodes()[:2] // ss + vmess + output, err := RenderTarget(nodes, "surge", nil) + if err != nil { + t.Fatalf("render surge failed: %v", err) + } + if !strings.Contains(output, "TestSS") { + t.Error("expected TestSS in surge output") + } +} + +func TestRenderSingBox(t *testing.T) { + nodes := makeTestNodes()[:1] + output, err := RenderTarget(nodes, "sing-box", nil) + if err != nil { + t.Fatalf("render sing-box failed: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + t.Fatalf("sing-box output is not valid JSON: %v", err) + } + // Per review-resolution #42: should have log, inbounds, outbounds, route + if _, ok := parsed["outbounds"]; !ok { + t.Error("expected outbounds in sing-box output") + } +} + +func TestRenderEmpty(t *testing.T) { + nodes := []model.ProxyNode{} + _, err := RenderTarget(nodes, "json", nil) + if err == nil { + // Some renderers may handle empty gracefully + } +} + +func TestRenderUnsupportedTarget(t *testing.T) { + nodes := makeTestNodes() + // Unsupported targets fall back to mihomo per dispatch default + output, err := RenderTarget(nodes, "unsupported-target", nil) + if err != nil { + // Error is acceptable for unsupported targets + return + } + // If no error, output should be valid YAML + if output == "" { + t.Error("expected non-empty output") + } +} + +func TestRenderMihomoWithTemplate(t *testing.T) { + nodes := makeTestNodes() + tmplConfig := map[string]any{ + "proxy-groups": []any{ + map[string]any{ + "name": "PROXY", + "type": "select", + "proxies": []any{"TestSS", "TestVMess", "TestTrojan"}, + }, + }, + } + output, err := RenderBuildTarget(nodes, "mihomo", "", tmplConfig) + if err != nil { + t.Fatalf("render mihomo with template failed: %v", err) + } + var parsed map[string]any + if err := yaml.Unmarshal([]byte(output), &parsed); err != nil { + t.Fatalf("output is not valid YAML: %v", err) + } + groups, ok := parsed["proxy-groups"].([]any) + if !ok { + t.Fatal("expected proxy-groups in YAML") + } + if len(groups) != 1 { + t.Errorf("expected 1 proxy group, got %d", len(groups)) + } +} diff --git a/internal/render/singbox.go b/internal/render/singbox.go new file mode 100644 index 0000000..98573e5 --- /dev/null +++ b/internal/render/singbox.go @@ -0,0 +1,356 @@ +package render + +import ( + "encoding/json" + "fmt" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// RenderSingBoxJson renders proxies as a sing-box JSON config. +// Per review-resolution #42: full structure with log, inbounds, outbounds, route. +func RenderSingBoxJson(proxies []model.ProxyNode) string { + var nodeOutbounds []map[string]any + for _, p := range proxies { + if out := ToSingBoxOutbound(p); out != nil { + nodeOutbounds = append(nodeOutbounds, out) + } + } + + tags := make([]string, 0, len(nodeOutbounds)) + for _, out := range nodeOutbounds { + if t, ok := out["tag"].(string); ok { + tags = append(tags, t) + } + } + + if len(tags) == 0 { + // return minimal config even with no supported nodes + } + + // Build outbounds: PROXY (selector), AUTO (urltest), node outbounds, DIRECT, REJECT + proxyOutbounds := append([]string{"AUTO"}, tags...) + + outbounds := []any{ + map[string]any{ + "type": "selector", + "tag": "PROXY", + "outbounds": proxyOutbounds, + "default": "AUTO", + "interrupt_exist_connections": false, + }, + map[string]any{ + "type": "urltest", + "tag": "AUTO", + "outbounds": tags, + "url": model.TestURL, + "interval": "5m", + "tolerance": 50, + "interrupt_exist_connections": false, + }, + } + for _, out := range nodeOutbounds { + outbounds = append(outbounds, out) + } + outbounds = append(outbounds, + map[string]any{"type": "direct", "tag": "DIRECT"}, + map[string]any{"type": "block", "tag": "REJECT"}, + ) + + doc := map[string]any{ + "log": map[string]any{"level": "info"}, + "inbounds": []any{ + map[string]any{ + "type": "mixed", + "tag": "mixed-in", + "listen": "0.0.0.0", + "listen_port": 7890, + "sniff": true, + }, + }, + "outbounds": outbounds, + "route": map[string]any{ + "auto_detect_interface": true, + "final": "PROXY", + }, + } + + data, _ := json.MarshalIndent(doc, "", " ") + return string(data) +} + +// ToSingBoxOutbound converts a proxy node to a sing-box outbound map. +// Returns nil for unsupported proxy types. +func ToSingBoxOutbound(proxy model.ProxyNode) map[string]any { + ptype := getString(proxy, "type") + server := getString(proxy, "server") + port := getInt(proxy, "port") + name := getString(proxy, "name") + + switch ptype { + case "vless": + realityOpts, _ := proxy["reality-opts"].(map[string]any) + var tls any + if getBool(proxy, "tls") { + tlsMap := map[string]any{ + "enabled": true, + "server_name": proxy["servername"], + "utls": map[string]any{"enabled": true, "fingerprint": strOr(proxy, "client-fingerprint", "chrome")}, + } + if realityOpts != nil { + tlsMap["reality"] = util.StripUndefined(map[string]any{ + "enabled": true, + "public_key": realityOpts["public-key"], + "short_id": realityOpts["short-id"], + }) + } + tls = tlsMap + } + return util.StripUndefined(map[string]any{ + "type": "vless", + "tag": name, + "server": server, + "server_port": port, + "uuid": proxy["uuid"], + "flow": proxy["flow"], + "network": strOr(proxy, "network", "tcp"), + "packet_encoding": "xudp", + "tls": tls, + }) + + case "hysteria2": + var obfs any + if o := proxy["obfs"]; o != nil { + obfs = map[string]any{"type": o, "password": proxy["obfs-password"]} + } + return util.StripUndefined(map[string]any{ + "type": "hysteria2", + "tag": name, + "server": server, + "server_port": port, + "password": proxy["password"], + "obfs": obfs, + "tls": map[string]any{ + "enabled": true, + "server_name": proxy["sni"], + "insecure": getBool(proxy, "skip-cert-verify"), + }, + }) + + case "hysteria": + return util.StripUndefined(map[string]any{ + "type": "hysteria", + "tag": name, + "server": server, + "server_port": port, + "auth_str": proxy["auth_str"], + "up_mbps": numberOrUndefined(proxy["up"]), + "down_mbps": numberOrUndefined(proxy["down"]), + "obfs": stringSetting(proxy["obfs"]), + "tls": map[string]any{ + "enabled": true, + "server_name": proxy["sni"], + "insecure": getBool(proxy, "skip-cert-verify"), + }, + }) + + case "anytls": + return util.StripUndefined(map[string]any{ + "type": "anytls", + "tag": name, + "server": server, + "server_port": port, + "password": proxy["password"], + "tls": map[string]any{ + "enabled": true, + "server_name": sniOr(proxy, "sni", "servername"), + "insecure": getBool(proxy, "skip-cert-verify"), + "utls": map[string]any{"enabled": true, "fingerprint": strOr(proxy, "client-fingerprint", "chrome")}, + }, + }) + + case "tuic": + return util.StripUndefined(map[string]any{ + "type": "tuic", + "tag": name, + "server": server, + "server_port": port, + "uuid": proxy["uuid"], + "password": proxy["password"], + "congestion_control": proxy["congestion-controller"], + "udp_relay_mode": proxy["udp-relay-mode"], + "zero_rtt_handshake": proxy["reduce-rtt"], + "tls": map[string]any{ + "enabled": true, + "server_name": proxy["sni"], + "insecure": getBool(proxy, "skip-cert-verify"), + }, + }) + + case "trojan": + return util.StripUndefined(map[string]any{ + "type": "trojan", + "tag": name, + "server": server, + "server_port": port, + "password": proxy["password"], + "tls": map[string]any{ + "enabled": true, + "server_name": proxy["sni"], + "insecure": getBool(proxy, "skip-cert-verify"), + }, + }) + + case "socks5": + var tls any + if getBool(proxy, "tls") { + tls = map[string]any{"enabled": true} + } + return util.StripUndefined(map[string]any{ + "type": "socks", + "tag": name, + "server": server, + "server_port": port, + "version": "5", + "username": proxy["username"], + "password": proxy["password"], + "tls": tls, + }) + + case "http": + var tls any + if getBool(proxy, "tls") { + tls = map[string]any{"enabled": true} + } + return util.StripUndefined(map[string]any{ + "type": "http", + "tag": name, + "server": server, + "server_port": port, + "username": proxy["username"], + "password": proxy["password"], + "tls": tls, + }) + + case "ss": + return util.StripUndefined(map[string]any{ + "type": "shadowsocks", + "tag": name, + "server": server, + "server_port": port, + "method": proxy["cipher"], + "password": proxy["password"], + }) + + case "wireguard": + var localAddress []string + if ip := stringSetting(proxy["ip"]); ip != "" { + localAddress = append(localAddress, ip) + } + if ipv6 := stringSetting(proxy["ipv6"]); ipv6 != "" { + localAddress = append(localAddress, ipv6) + } + result := util.StripUndefined(map[string]any{ + "type": "wireguard", + "tag": name, + "server": server, + "server_port": port, + "private_key": proxy["private-key"], + "peer_public_key": proxy["public-key"], + "pre_shared_key": proxy["pre-shared-key"], + }) + if len(localAddress) > 0 { + result["local_address"] = localAddress + } + if reserved := parseWireGuardReserved(proxy["reserved"]); reserved != nil { + result["reserved"] = reserved + } + return result + + case "vmess": + var tls any + if getBool(proxy, "tls") { + tls = map[string]any{"enabled": true, "server_name": proxy["servername"]} + } + var transport any + if getString(proxy, "network") == "ws" { + wsOpts, _ := proxy["ws-opts"].(map[string]any) + path := "/" + if wsOpts != nil { + if p, ok := wsOpts["path"].(string); ok && p != "" { + path = p + } + } + transportMap := map[string]any{ + "type": "ws", + "path": path, + } + if wsOpts != nil { + if h, ok := wsOpts["headers"]; ok && h != nil { + transportMap["headers"] = h + } + } + transport = transportMap + } + return util.StripUndefined(map[string]any{ + "type": "vmess", + "tag": name, + "server": server, + "server_port": port, + "uuid": proxy["uuid"], + "security": strOr(proxy, "cipher", "auto"), + "alter_id": proxy["alterId"], + "tls": tls, + "transport": transport, + }) + } + + return nil +} + +func parseWireGuardReserved(value any) []int { + switch v := value.(type) { + case []any: + var result []int + for _, item := range v { + n := numberOrUndefined(item) + if n != 0 { + result = append(result, n) + } + } + if len(result) > 0 { + return result + } + return nil + case []int: + return v + case string: + if v == "" { + return nil + } + var result []int + for _, part := range splitComma(v) { + n := numberOrUndefined(part) + result = append(result, n) + } + return result + } + return nil +} + +func splitComma(s string) []string { + var result []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == ',' { + result = append(result, s[start:i]) + start = i + 1 + } + } + result = append(result, s[start:]) + return result +} + +// Ensure fmt is used +var _ = fmt.Sprintf diff --git a/internal/render/surfboard.go b/internal/render/surfboard.go new file mode 100644 index 0000000..474e9f2 --- /dev/null +++ b/internal/render/surfboard.go @@ -0,0 +1,18 @@ +package render + +import ( + "github.com/peterqiu0516/sub-store/internal/model" +) + +// RenderSurfboardProxies renders proxies as Surfboard text lines. +// Surfboard only supports ss/vmess/trojan/http/socks5 — delegates to surge. +func RenderSurfboardProxies(proxies []model.ProxyNode) (string, error) { + return RenderTextProxyList(proxies, model.TargetSurfboard, func(p model.ProxyNode) string { + ptype := getString(p, "type") + switch ptype { + case "ss", "vmess", "trojan", "http", "socks5": + return ToSurgeProxyLine(p) + } + return "" + }) +} diff --git a/internal/render/surge.go b/internal/render/surge.go new file mode 100644 index 0000000..8ac623f --- /dev/null +++ b/internal/render/surge.go @@ -0,0 +1,210 @@ +package render + +import ( + "github.com/peterqiu0516/sub-store/internal/model" +) + +// RenderSurgeProxies renders proxies as Surge text lines. +func RenderSurgeProxies(proxies []model.ProxyNode) (string, error) { + return RenderTextProxyList(proxies, model.TargetSurge, func(p model.ProxyNode) string { + return ToSurgeProxyLine(p) + }) +} + +// RenderSurgeMacProxies renders proxies as Surge-Mac text lines (adds ssh/h2-connect/snell). +func RenderSurgeMacProxies(proxies []model.ProxyNode) (string, error) { + return RenderTextProxyList(proxies, model.TargetSurgeMac, func(p model.ProxyNode) string { + return ToSurgeMacProxyLine(p) + }) +} + +// ToSurgeProxyLine converts a single proxy node to a Surge proxy line. +// Returns "" for unsupported proxy types. +func ToSurgeProxyLine(proxy model.ProxyNode) string { + name := SanitizeTextProxyName(getString(proxy, "name")) + ptype := getString(proxy, "type") + server := getString(proxy, "server") + port := getInt(proxy, "port") + + base := name + "=" + surgeType(proxy) + "," + server + "," + itoa(port) + entries := CommonTextOptions(proxy) + + switch ptype { + case "ss": + entries = unshift(entries, + [2]any{"encrypt-method", strOr(proxy, "cipher", "none")}, + [2]any{"password", proxy["password"]}, + ) + entries = AppendPluginOptions(entries, proxy, "surge") + return JoinTextProxy(base, entries) + + case "vmess": + entries = unshift(entries, + [2]any{"username", proxy["uuid"]}, + [2]any{"encrypt-method", strOr(proxy, "cipher", "auto")}, + [2]any{"tls", proxy["tls"]}, + [2]any{"sni", proxy["servername"]}, + ) + entries = AppendWsOptions(entries, proxy, "surge") + return JoinTextProxy(base, entries) + + case "trojan": + entries = unshift(entries, + [2]any{"password", proxy["password"]}, + [2]any{"sni", sniOr(proxy, "sni", "servername")}, + ) + return JoinTextProxy(base, entries) + + case "http", "socks5": + entries = unshift(entries, + [2]any{"username", proxy["username"]}, + [2]any{"password", proxy["password"]}, + [2]any{"tls", proxy["tls"]}, + [2]any{"sni", sniOr(proxy, "sni", "servername")}, + ) + return JoinTextProxy(base, entries) + + case "hysteria2": + entries = unshift(entries, + [2]any{"password", proxy["password"]}, + [2]any{"sni", proxy["sni"]}, + ) + if obfs := proxy["obfs"]; obfs != nil { + entries = append(entries, [2]any{"obfs", obfs}, [2]any{"obfs-password", proxy["obfs-password"]}) + } + return JoinTextProxy(base, entries) + + case "tuic": + entries = unshift(entries, + [2]any{"uuid", proxy["uuid"]}, + [2]any{"password", proxy["password"]}, + [2]any{"sni", proxy["sni"]}, + ) + return JoinTextProxy(name+"=tuic-v5,"+server+","+itoa(port), entries) + + case "anytls": + entries = unshift(entries, + [2]any{"password", proxy["password"]}, + [2]any{"sni", sniOr(proxy, "sni", "servername")}, + ) + return JoinTextProxy(base, entries) + + case "snell": + entries = unshift(entries, + [2]any{"psk", pskOrPassword(proxy)}, + [2]any{"version", versionOr3(proxy)}, + ) + return JoinTextProxy(name+"=snell,"+server+","+itoa(port), entries) + } + + return "" +} + +// ToSurgeMacProxyLine adds surge-mac specific types (ssh, h2-connect) on top +// of the base surge line. +func ToSurgeMacProxyLine(proxy model.ProxyNode) string { + name := SanitizeTextProxyName(getString(proxy, "name")) + ptype := getString(proxy, "type") + server := getString(proxy, "server") + port := getInt(proxy, "port") + + switch ptype { + case "ssh": + entries := CommonTextOptions(proxy) + entries = unshift(entries, + [2]any{"username", proxy["username"]}, + [2]any{"password", proxy["password"]}, + [2]any{"private-key", proxy["private-key"]}, + ) + return JoinTextProxy(name+"=ssh,"+server+","+itoa(port), entries) + + case "h2-connect": + entries := CommonTextOptions(proxy) + entries = unshift(entries, + [2]any{"username", proxy["username"]}, + [2]any{"password", proxy["password"]}, + [2]any{"tls", proxy["tls"]}, + [2]any{"sni", sniOr(proxy, "sni", "servername")}, + ) + return JoinTextProxy(name+"=h2-connect,"+server+","+itoa(port), entries) + } + + return ToSurgeProxyLine(proxy) +} + +func surgeType(proxy model.ProxyNode) string { + ptype := getString(proxy, "type") + if ptype == "socks5" { + if getBool(proxy, "tls") { + return "socks5-tls" + } + return "socks5" + } + if ptype == "http" { + if getBool(proxy, "tls") { + return "https" + } + return "http" + } + return ptype +} + +// --- small helpers --- + +func unshift(entries [][2]any, front ...[2]any) [][2]any { + return append(front, entries...) +} + +func strOr(proxy model.ProxyNode, key, def string) string { + s := getString(proxy, key) + if s == "" { + return def + } + return s +} + +func sniOr(proxy model.ProxyNode, keys ...string) any { + for _, k := range keys { + if v := proxy[k]; v != nil { + return v + } + } + return nil +} + +func pskOrPassword(proxy model.ProxyNode) any { + if v := proxy["psk"]; v != nil { + return v + } + return proxy["password"] +} + +func versionOr3(proxy model.ProxyNode) any { + if v := proxy["version"]; v != nil { + return v + } + return 3 +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := false + if n < 0 { + neg = true + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/internal/render/textutil.go b/internal/render/textutil.go new file mode 100644 index 0000000..590e078 --- /dev/null +++ b/internal/render/textutil.go @@ -0,0 +1,346 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" +) + +// RenderTextProxyList renders proxies into newline-separated text lines using +// the given producer. If the producer returns "" for a proxy it is skipped. +// Returns an error when no supported nodes were produced. +func RenderTextProxyList(proxies []model.ProxyNode, target string, producer func(model.ProxyNode) string) (string, error) { + var lines []string + for _, p := range proxies { + line := producer(p) + if line != "" { + lines = append(lines, line) + } + } + if len(lines) == 0 { + return "", fmt.Errorf("No supported nodes for %s output", target) + } + return strings.Join(lines, "\n"), nil +} + +// JoinTextProxy joins a proxy base line with key=value entries. +// Entries with nil or empty-string values are dropped. +func JoinTextProxy(base string, entries [][2]any) string { + var parts []string + for _, entry := range entries { + key, _ := entry[0].(string) + value := entry[1] + if value == nil { + continue + } + if s, ok := value.(string); ok && s == "" { + continue + } + parts = append(parts, key+"="+FormatTextOptionValue(value)) + } + if len(parts) == 0 { + return base + } + return base + "," + strings.Join(parts, ",") +} + +// FormatTextOptionValue formats a value for a text proxy option line. +func FormatTextOptionValue(value any) string { + switch v := value.(type) { + case bool: + if v { + return "true" + } + return "false" + case []any: + strs := make([]string, 0, len(v)) + for _, item := range v { + strs = append(strs, fmt.Sprint(item)) + } + return QuoteTextValue(strings.Join(strs, ",")) + case []string: + return QuoteTextValue(strings.Join(v, ",")) + case nil: + return "" + default: + text := fmt.Sprint(value) + if strings.ContainsAny(text, ", \t\"") { + return QuoteTextValue(text) + } + return text + } +} + +// QuoteTextValue wraps a string in double quotes, escaping inner quotes. +func QuoteTextValue(value any) string { + s := "" + if value != nil { + s = fmt.Sprint(value) + } + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} + +// SanitizeTextProxyName replaces characters that are illegal in text proxy +// names (=, comma, CR, LF) with spaces. Returns "proxy" when empty. +func SanitizeTextProxyName(name string) string { + r := strings.NewReplacer("=", " ", ",", " ", "\r", " ", "\n", " ") + s := strings.TrimSpace(r.Replace(name)) + if s == "" { + return "proxy" + } + return s +} + +// CommonTextOptions returns the shared option entries used by text proxy +// renderers (surge, loon, qx, etc.). +func CommonTextOptions(proxy model.ProxyNode) [][2]any { + return [][2]any{ + {"skip-cert-verify", proxy["skip-cert-verify"]}, + {"udp-relay", proxy["udp"]}, + {"fast-open", fastOpenValue(proxy)}, + {"alpn", FormatAlpn(proxy["alpn"])}, + } +} + +func fastOpenValue(proxy model.ProxyNode) any { + if v, ok := proxy["tfo"]; ok && v != nil { + return v + } + return proxy["fast-open"] +} + +// AppendWsOptions appends websocket transport options for surge/loon targets. +func AppendWsOptions(entries [][2]any, proxy model.ProxyNode, target string) [][2]any { + if getString(proxy, "network") != "ws" { + return entries + } + wsOpts, _ := proxy["ws-opts"].(map[string]any) + path := "/" + if wsOpts != nil { + if p, ok := wsOpts["path"].(string); ok && p != "" { + path = p + } + } + host := WsHeaderHost(wsOpts) + if target == "surge" { + entries = append(entries, [2]any{"ws", true}, [2]any{"ws-path", path}, [2]any{"ws-headers", host}) + } else { + entries = append(entries, [2]any{"path", path}, [2]any{"host", host}) + } + return entries +} + +// AppendPluginOptions appends shadowsocks obfs plugin options. +func AppendPluginOptions(entries [][2]any, proxy model.ProxyNode, target string) [][2]any { + if getString(proxy, "plugin") != "obfs" { + return entries + } + pluginOpts, _ := proxy["plugin-opts"].(map[string]any) + if pluginOpts == nil { + return entries + } + mode := pluginOpts["mode"] + host := pluginOpts["host"] + path := pluginOpts["path"] + if target == "surge" { + entries = append(entries, + [2]any{"obfs", mode}, + [2]any{"obfs-host", host}, + [2]any{"obfs-uri", path}, + ) + } else { + entries = append(entries, + [2]any{"obfs-name", mode}, + [2]any{"obfs-host", host}, + [2]any{"obfs-uri", path}, + ) + } + return entries +} + +// AppendRealityOptions appends reality options for loon. +func AppendRealityOptions(entries [][2]any, proxy model.ProxyNode) [][2]any { + realityOpts, _ := proxy["reality-opts"].(map[string]any) + var pubKey, shortID any + if realityOpts != nil { + pubKey = realityOpts["public-key"] + shortID = realityOpts["short-id"] + } + return append(entries, [2]any{"public-key", pubKey}, [2]any{"short-id", shortID}) +} + +// AppendQxRealityOptions appends reality options for Quantumult X. +func AppendQxRealityOptions(entries [][2]any, proxy model.ProxyNode) [][2]any { + realityOpts, _ := proxy["reality-opts"].(map[string]any) + var pubKey, shortID any + if realityOpts != nil { + pubKey = realityOpts["public-key"] + shortID = realityOpts["short-id"] + } + return append(entries, [2]any{"reality-base64-pubkey", pubKey}, [2]any{"reality-hex-shortid", shortID}) +} + +// AppendQxObfs appends shadowsocks obfs options for Quantumult X. +func AppendQxObfs(entries [][2]any, proxy model.ProxyNode) [][2]any { + if getString(proxy, "plugin") != "obfs" { + return entries + } + pluginOpts, _ := proxy["plugin-opts"].(map[string]any) + if pluginOpts == nil { + return entries + } + return append(entries, + [2]any{"obfs", pluginOpts["mode"]}, + [2]any{"obfs-host", pluginOpts["host"]}, + [2]any{"obfs-uri", pluginOpts["path"]}, + ) +} + +// AppendQxTransport appends websocket transport options for Quantumult X. +func AppendQxTransport(entries [][2]any, proxy model.ProxyNode) [][2]any { + if getString(proxy, "network") != "ws" { + return entries + } + wsOpts, _ := proxy["ws-opts"].(map[string]any) + path := "/" + if wsOpts != nil { + if p, ok := wsOpts["path"].(string); ok && p != "" { + path = p + } + } + transport := "ws" + if getBool(proxy, "tls") { + transport = "wss" + } + return append(entries, + [2]any{"obfs", transport}, + [2]any{"obfs-uri", path}, + [2]any{"obfs-host", WsHeaderHost(wsOpts)}, + ) +} + +// WsHeaderHost extracts the Host header from ws-opts headers (case-insensitive). +func WsHeaderHost(wsOpts map[string]any) string { + if wsOpts == nil { + return "" + } + headers, ok := wsOpts["headers"].(map[string]any) + if !ok { + return "" + } + if v, ok := headers["Host"]; ok { + return fmt.Sprint(v) + } + if v, ok := headers["host"]; ok { + return fmt.Sprint(v) + } + return "" +} + +// FormatAlpn formats an alpn value — joins array with commas or returns the string. +func FormatAlpn(value any) string { + switch v := value.(type) { + case nil: + return "" + case []any: + var parts []string + for _, item := range v { + s := fmt.Sprint(item) + if s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, ",") + case []string: + var parts []string + for _, item := range v { + if item != "" { + parts = append(parts, item) + } + } + return strings.Join(parts, ",") + case string: + return v + default: + return fmt.Sprint(value) + } +} + +// --- internal helpers shared across renderers --- + +func getString(proxy model.ProxyNode, key string) string { + v, _ := proxy[key].(string) + return v +} + +func getBool(proxy model.ProxyNode, key string) bool { + switch v := proxy[key].(type) { + case bool: + return v + case string: + return v == "true" || v == "1" + default: + return false + } +} + +// getInt returns an int value, handling both int and float64 from JSON. +func getInt(proxy model.ProxyNode, key string) int { + switch v := proxy[key].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case string: + var i int + fmt.Sscanf(v, "%d", &i) + return i + } + return 0 +} + +// stringSetting returns the string form of a value, or "" when nil. +func stringSetting(v any) string { + if v == nil { + return "" + } + return fmt.Sprint(v) +} + +// numberOrUndefined converts a value to a number, returning 0 when nil/empty. +func numberOrUndefined(v any) int { + if v == nil { + return 0 + } + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + case string: + if n == "" { + return 0 + } + var i int + fmt.Sscanf(n, "%d", &i) + return i + } + return 0 +} + +// hasValue returns true when value is non-nil and non-empty-string. +func hasValue(v any) bool { + if v == nil { + return false + } + if s, ok := v.(string); ok && s == "" { + return false + } + return true +} diff --git a/internal/render/uri.go b/internal/render/uri.go new file mode 100644 index 0000000..a5248b4 --- /dev/null +++ b/internal/render/uri.go @@ -0,0 +1,346 @@ +package render + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// RenderProxyUris renders proxies as URI lines (one per line). +func RenderProxyUris(proxies []model.ProxyNode) string { + var lines []string + for _, p := range proxies { + if uri := ToProxyUri(p); uri != "" { + lines = append(lines, uri) + } + } + return strings.Join(lines, "\n") +} + +// ToProxyUri reconstructs a URI for each supported protocol. +// Returns "" for unsupported proxy types. +func ToProxyUri(proxy model.ProxyNode) string { + ptype := getString(proxy, "type") + server := getString(proxy, "server") + port := getInt(proxy, "port") + name := getString(proxy, "name") + + switch ptype { + case "vless": + params := url.Values{} + realityOpts, _ := proxy["reality-opts"].(map[string]any) + params.Set("encryption", strOr(proxy, "encryption", "none")) + if realityOpts != nil { + params.Set("security", "reality") + } else if getBool(proxy, "tls") { + params.Set("security", "tls") + } else { + params.Set("security", "none") + } + if sni := getString(proxy, "servername"); sni != "" { + params.Set("sni", sni) + } + if fp := getString(proxy, "client-fingerprint"); fp != "" { + params.Set("fp", fp) + } + if realityOpts != nil { + if pk := stringSetting(realityOpts["public-key"]); pk != "" { + params.Set("pbk", pk) + } + if sid := stringSetting(realityOpts["short-id"]); sid != "" { + params.Set("sid", sid) + } + if pk := stringSetting(realityOpts["public-key"]); pk != "" { + params.Set("spx", stringSetting(realityOpts["spider-x"])) + if params.Get("spx") == "" { + params.Set("spx", "/") + } + } + } + params.Set("type", strOr(proxy, "network", "tcp")) + if flow := getString(proxy, "flow"); flow != "" { + params.Set("flow", flow) + } + return fmt.Sprintf("vless://%s@%s:%d?%s#%s", + url.PathEscape(getString(proxy, "uuid")), + server, port, + params.Encode(), + url.QueryEscape(name)) + + case "hysteria2": + params := url.Values{} + if sni := getString(proxy, "sni"); sni != "" { + params.Set("sni", sni) + } + if getBool(proxy, "skip-cert-verify") { + params.Set("insecure", "1") + } + if obfs := getString(proxy, "obfs"); obfs != "" { + params.Set("obfs", obfs) + } + if obfsPwd := getString(proxy, "obfs-password"); obfsPwd != "" { + params.Set("obfs-password", obfsPwd) + } + return fmt.Sprintf("hysteria2://%s@%s:%d?%s#%s", + url.PathEscape(getString(proxy, "password")), + server, port, + params.Encode(), + url.QueryEscape(name)) + + case "hysteria": + params := url.Values{} + if sni := getString(proxy, "sni"); sni != "" { + params.Set("sni", sni) + } + if getBool(proxy, "skip-cert-verify") { + params.Set("insecure", "1") + } + if protocol := getString(proxy, "protocol"); protocol != "" { + params.Set("protocol", protocol) + } + if up := stringSetting(proxy["up"]); up != "" { + params.Set("up", up) + } + if down := stringSetting(proxy["down"]); down != "" { + params.Set("down", down) + } + if obfs := getString(proxy, "obfs"); obfs != "" { + params.Set("obfs", obfs) + } + if obfsPwd := getString(proxy, "obfs-password"); obfsPwd != "" { + params.Set("obfs-password", obfsPwd) + } + return fmt.Sprintf("hysteria://%s@%s:%d?%s#%s", + url.PathEscape(getString(proxy, "auth_str")), + server, port, + params.Encode(), + url.QueryEscape(name)) + + case "anytls": + params := url.Values{} + if sni := sniStr(proxy, "sni", "servername"); sni != "" { + params.Set("sni", sni) + } + if getBool(proxy, "skip-cert-verify") { + params.Set("insecure", "1") + } + if fp := getString(proxy, "client-fingerprint"); fp != "" { + params.Set("fp", fp) + } + return fmt.Sprintf("anytls://%s@%s:%d?%s#%s", + url.PathEscape(getString(proxy, "password")), + server, port, + params.Encode(), + url.QueryEscape(name)) + + case "tuic": + params := url.Values{} + if sni := getString(proxy, "sni"); sni != "" { + params.Set("sni", sni) + } + if getBool(proxy, "skip-cert-verify") { + params.Set("allow_insecure", "1") + } + if getBool(proxy, "disable-sni") { + params.Set("disable_sni", "1") + } + if getBool(proxy, "reduce-rtt") { + params.Set("reduce_rtt", "1") + } + if mode := getString(proxy, "udp-relay-mode"); mode != "" { + params.Set("udp_relay_mode", mode) + } + if cc := getString(proxy, "congestion-controller"); cc != "" { + params.Set("congestion_control", cc) + } + return fmt.Sprintf("tuic://%s:%s@%s:%d?%s#%s", + url.PathEscape(getString(proxy, "uuid")), + url.PathEscape(getString(proxy, "password")), + server, port, + params.Encode(), + url.QueryEscape(name)) + + case "trojan": + params := url.Values{} + if sni := getString(proxy, "sni"); sni != "" { + params.Set("sni", sni) + } + if getBool(proxy, "skip-cert-verify") { + params.Set("allowInsecure", "1") + } + return fmt.Sprintf("trojan://%s@%s:%d?%s#%s", + url.PathEscape(getString(proxy, "password")), + server, port, + params.Encode(), + url.QueryEscape(name)) + + case "socks5": + params := url.Values{} + if getBool(proxy, "tls") { + params.Set("tls", "1") + } + scheme := "socks5" + if getBool(proxy, "tls") { + scheme = "socks5+tls" + } + auth := "" + if username := getString(proxy, "username"); username != "" { + auth = url.PathEscape(username) + ":" + url.PathEscape(getString(proxy, "password")) + "@" + } + query := "" + if len(params) > 0 { + query = "?" + params.Encode() + } + return fmt.Sprintf("%s://%s%s:%d%s#%s", + scheme, auth, server, port, query, url.QueryEscape(name)) + + case "http": + auth := "" + if username := getString(proxy, "username"); username != "" { + auth = url.PathEscape(username) + ":" + url.PathEscape(getString(proxy, "password")) + "@" + } + scheme := "http" + if getBool(proxy, "tls") { + scheme = "https" + } + return fmt.Sprintf("%s://%s%s:%d#%s", + scheme, auth, server, port, url.QueryEscape(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)) + + case "ssr": + main := strings.Join([]string{ + server, + itoa(port), + strOr(proxy, "protocol", "origin"), + strOr(proxy, "cipher", "aes-256-cfb"), + strOr(proxy, "obfs", "plain"), + util.EncodeBase64RawURL(getString(proxy, "password")), + }, ":") + params := url.Values{} + params.Set("remarks", util.EncodeBase64RawURL(name)) + if pp := stringSetting(proxy["protocol-param"]); pp != "" { + params.Set("protoparam", util.EncodeBase64RawURL(pp)) + } + if op := stringSetting(proxy["obfs-param"]); op != "" { + params.Set("obfsparam", util.EncodeBase64RawURL(op)) + } + return "ssr://" + util.EncodeBase64RawURL(main+"/?"+params.Encode()) + + case "wireguard": + params := url.Values{} + if ip := stringSetting(proxy["ip"]); ip != "" { + params.Set("ip", ip) + } + if ipv6 := stringSetting(proxy["ipv6"]); ipv6 != "" { + params.Set("ipv6", ipv6) + } + if pk := stringSetting(proxy["public-key"]); pk != "" { + params.Set("public-key", pk) + } + if psk := stringSetting(proxy["pre-shared-key"]); psk != "" { + params.Set("pre-shared-key", psk) + } + if reserved := stringSetting(proxy["reserved"]); reserved != "" { + params.Set("reserved", reserved) + } + return fmt.Sprintf("wireguard://%s@%s:%d?%s#%s", + url.PathEscape(getString(proxy, "private-key")), + server, port, + params.Encode(), + url.QueryEscape(name)) + + case "vmess": + wsOpts, _ := proxy["ws-opts"].(map[string]any) + host := "" + path := "" + if wsOpts != nil { + host = stringSetting(WsHeaderHostKey(wsOpts)) + if p, ok := wsOpts["path"].(string); ok { + path = p + } + } + vmessJSON, _ := json.Marshal(map[string]any{ + "v": "2", + "ps": name, + "add": server, + "port": func() string { + if port == 0 { + return "" + } + return itoa(port) + }(), + "id": getString(proxy, "uuid"), + "aid": aidStr(proxy["alterId"]), + "scy": strOr(proxy, "cipher", "auto"), + "tls": boolToStr(getBool(proxy, "tls"), "tls", ""), + "sni": getString(proxy, "servername"), + "net": strOr(proxy, "network", "tcp"), + "type": "none", + "host": host, + "path": path, + }) + return "vmess://" + util.Base64Utf8(string(vmessJSON)) + } + + return "" +} + +func sniStr(proxy model.ProxyNode, keys ...string) string { + for _, k := range keys { + if s := getString(proxy, k); s != "" { + return s + } + } + return "" +} + +func aidStr(v any) string { + switch n := v.(type) { + case nil: + return "0" + case int: + return itoa(n) + case int64: + return itoa(int(n)) + case float64: + return itoa(int(n)) + case string: + if n == "" { + return "0" + } + return n + } + return "0" +} + +func boolToStr(b bool, trueVal, falseVal string) string { + if b { + return trueVal + } + return falseVal +} + +// WsHeaderHostKey extracts Host from ws-opts headers map (case-insensitive). +func WsHeaderHostKey(wsOpts map[string]any) string { + headers, ok := wsOpts["headers"].(map[string]any) + if !ok { + return "" + } + if v, ok := headers["Host"]; ok { + return stringSetting(v) + } + if v, ok := headers["host"]; ok { + return stringSetting(v) + } + return "" +} diff --git a/internal/rules/converter.go b/internal/rules/converter.go new file mode 100644 index 0000000..f21d1a6 --- /dev/null +++ b/internal/rules/converter.go @@ -0,0 +1,250 @@ +package rules + +import ( + "regexp" + "strings" +) + +// RuleTarget is the target format for rule conversion. +type RuleTarget string + +const ( + TargetMihomo RuleTarget = "mihomo" + TargetSurge RuleTarget = "surge" + TargetLoon RuleTarget = "loon" + TargetQX RuleTarget = "qx" +) + +// Result holds the output of a rule conversion. +type Result struct { + Content string + Parsed int + Emitted int + Skipped int + Warnings []string +} + +// NormalizedRule is a parsed and normalized rule. +type NormalizedRule struct { + Kind string + Value string + Policy string + Options []string +} + +var kindAliases = map[string]string{ + "HOST": "DOMAIN", + "DOMAIN": "DOMAIN", + "HOST-SUFFIX": "DOMAIN-SUFFIX", + "DOMAIN-SUFFIX": "DOMAIN-SUFFIX", + "HOST-KEYWORD": "DOMAIN-KEYWORD", + "DOMAIN-KEYWORD": "DOMAIN-KEYWORD", + "IP6CIDR": "IP-CIDR6", + "IP-CIDR6": "IP-CIDR6", + "IPCIDR": "IP-CIDR", + "IP-CIDR": "IP-CIDR", + "GEOIP": "GEOIP", + "GEOSITE": "GEOSITE", + "PROCESS-NAME": "PROCESS-NAME", + "PROCESS": "PROCESS-NAME", + "DEST-PORT": "DST-PORT", + "DST-PORT": "DST-PORT", + "FINAL": "MATCH", + "MATCH": "MATCH", +} + +var ruleOptions = map[string]bool{ + "no-resolve": true, + "extended-matching": true, + "pre-matching": true, +} + +var yamlHeaderRe = regexp.MustCompile(`(?m)^(?:payload|rules)\s*:`) + +// ConvertRules converts rule content to the target format. +func ConvertRules(content string, target RuleTarget) Result { + parsed := parseRules(content) + var lines []string + for _, rule := range parsed { + line := produceRule(rule, target) + if line != "" { + lines = append(lines, line) + } + } + return Result{ + Content: strings.Join(lines, "\n"), + Parsed: len(parsed), + Emitted: len(lines), + Skipped: 0, + Warnings: []string{}, + } +} + +func parseRules(content string) []NormalizedRule { + lines := extractRuleLines(content) + var rules []NormalizedRule + for _, line := range lines { + rule := parseRuleLine(line) + if rule != nil { + rules = append(rules, *rule) + } + } + return rules +} + +func extractRuleLines(content string) []string { + text := strings.TrimSpace(content) + if text == "" { + return []string{} + } + if yamlHeaderRe.MatchString(text) { + var lines []string + for _, line := range strings.Split(text, "\n")[1:] { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "-") + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + lines = append(lines, line) + } + } + return lines + } + var lines []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + // Strip surrounding quotes + if len(line) >= 2 && (line[0] == '"' || line[0] == '\'') && line[len(line)-1] == line[0] { + line = line[1 : len(line)-1] + } + if line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, ";") { + lines = append(lines, line) + } + } + return lines +} + +func parseRuleLine(line string) *NormalizedRule { + fields := splitCsv(line) + if len(fields) == 0 { + return nil + } + kind := kindAliases[strings.ToUpper(fields[0])] + if kind == "" { + return nil + } + if kind == "MATCH" { + policy := "" + if len(fields) > 1 { + policy = fields[1] + } + var options []string + for _, f := range fields[2:] { + if f != "" { + options = append(options, f) + } + } + return &NormalizedRule{Kind: kind, Policy: policy, Options: options} + } + if len(fields) < 2 { + return nil + } + value := strings.TrimSpace(fields[1]) + if value == "" { + return nil + } + var trailing []string + for _, f := range fields[2:] { + if f != "" { + trailing = append(trailing, f) + } + } + policy := "" + var options []string + for _, f := range trailing { + if ruleOptions[strings.ToLower(f)] { + options = append(options, f) + } else { + if policy == "" { + policy = f + } + } + } + return &NormalizedRule{Kind: kind, Value: value, Policy: policy, Options: options} +} + +func produceRule(rule NormalizedRule, target RuleTarget) string { + kind := rule.Kind + if target == TargetQX { + kind = qxKind(rule.Kind) + } + var fields []string + if rule.Kind == "MATCH" { + if target == TargetQX { + fields = append(fields, "FINAL") + } else { + fields = append(fields, kind) + } + if rule.Policy != "" { + fields = append(fields, rule.Policy) + } + } else { + fields = append(fields, kind, rule.Value) + if rule.Policy != "" { + fields = append(fields, rule.Policy) + } + } + for _, opt := range rule.Options { + if target == TargetQX { + if strings.ToLower(opt) == "no-resolve" { + fields = append(fields, opt) + } + } else { + fields = append(fields, opt) + } + } + return strings.Join(fields, ",") +} + +func qxKind(kind string) string { + aliases := map[string]string{ + "DOMAIN": "HOST", + "DOMAIN-SUFFIX": "HOST-SUFFIX", + "DOMAIN-KEYWORD": "HOST-KEYWORD", + "IP-CIDR": "IP-CIDR", + "IP-CIDR6": "IP6-CIDR", + "PROCESS-NAME": "PROCESS-NAME", + "DST-PORT": "DEST-PORT", + "MATCH": "FINAL", + } + if a, ok := aliases[kind]; ok { + return a + } + return kind +} + +func splitCsv(line string) []string { + var output []string + current := "" + quote := byte(0) + for i := 0; i < len(line); i++ { + c := line[i] + if quote != 0 { + if c == quote { + quote = 0 + } else { + current += string(c) + } + } else if c == '"' || c == '\'' { + quote = c + } else if c == ',' { + output = append(output, strings.TrimSpace(current)) + current = "" + } else { + current += string(c) + } + } + if strings.TrimSpace(current) != "" { + output = append(output, strings.TrimSpace(current)) + } + return output +} diff --git a/internal/service/subscription.go b/internal/service/subscription.go new file mode 100644 index 0000000..5fba486 --- /dev/null +++ b/internal/service/subscription.go @@ -0,0 +1,593 @@ +package service + +import ( + "context" + "fmt" + "io" + "math" + "net/http" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "github.com/peterqiu0516/sub-store/internal/database" + "github.com/peterqiu0516/sub-store/internal/filter" + "github.com/peterqiu0516/sub-store/internal/model" + "github.com/peterqiu0516/sub-store/internal/proxy" + "github.com/peterqiu0516/sub-store/internal/render" + "github.com/peterqiu0516/sub-store/internal/util" +) + +// BuildOptions holds parameters for building a subscription result. +type BuildOptions struct { + Source *model.SourceRecord + Collection *model.CollectionRecord + Sources []model.SourceRecord + Target string + TemplateConfig map[string]any + Settings map[string]any + RequestUserAgent string + ForceRefresh bool + CacheRepo *database.CacheRepo +} + +// BuildResult holds the output of a subscription build. +type BuildResult struct { + Body string + Metadata model.SubscriptionResponseMetadata + Nodes int +} + +// BuildSubscriptionResult runs the full subscription pipeline. +func BuildSubscriptionResult(ctx context.Context, opts BuildOptions) (*BuildResult, error) { + proxies, metadata, err := loadProxyNodes(ctx, opts) + if err != nil { + return nil, err + } + if len(proxies) == 0 { + return nil, fmt.Errorf("No available nodes found") + } + + body, err := render.RenderBuildTarget(proxies, opts.Target, "", opts.TemplateConfig) + if err != nil { + return nil, err + } + + return &BuildResult{ + Body: body, + Metadata: metadata, + Nodes: len(proxies), + }, nil +} + +func loadProxyNodes(ctx context.Context, opts BuildOptions) ([]model.ProxyNode, model.SubscriptionResponseMetadata, error) { + sources := getSources(opts) + enabledSources := make([]model.SourceRecord, 0, len(sources)) + for _, s := range sources { + if s.Enabled { + enabledSources = append(enabledSources, s) + } + } + if len(enabledSources) == 0 { + return nil, model.SubscriptionResponseMetadata{}, nil + } + + metadataMap := make(map[string]model.SubscriptionResponseMetadata) + + tasks := make([]func() ([]model.ProxyNode, error), len(enabledSources)) + for i, sub := range enabledSources { + sub := sub + tasks[i] = func() ([]model.ProxyNode, error) { + raw, meta := loadSubscriptionRaw(ctx, sub, opts) + if sub.ID != "" { + metadataMap[sub.ID] = meta + } + nodes := proxy.ParseProxies(raw) + filters := sub.Filters + if filters == nil { + filters = []model.FilterRule{} + } + return filter.ApplyFilters(nodes, filters, opts.Settings, filter.FilterContext{ + TargetPlatform: opts.Target, + SourceId: sub.ID, + }), nil + } + } + + var proxyLists [][]model.ProxyNode + var taskErr error + if opts.Collection != nil && opts.Collection.IgnoreFailed { + results := RunSettledWithConcurrency(tasks, getConcurrency(opts.Settings), getConcurrencyWait(opts.Settings)) + for _, r := range results { + if r.Status == "fulfilled" { + proxyLists = append(proxyLists, r.Value) + } + } + } else { + results, err := RunWithConcurrency(tasks, getConcurrency(opts.Settings), getConcurrencyWait(opts.Settings)) + if err != nil { + taskErr = err + } + proxyLists = results + } + if taskErr != nil { + return nil, model.SubscriptionResponseMetadata{}, taskErr + } + + var allProxies []model.ProxyNode + for _, list := range proxyLists { + allProxies = append(allProxies, list...) + } + + // 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, + }) + } + + // Ensure unique names + allProxies = proxy.EnsureUniqueProxyNames(allProxies) + + // Select response metadata + metadata := selectResponseMetadata(enabledSources, metadataMap) + return allProxies, metadata, nil +} + +func getSources(opts BuildOptions) []model.SourceRecord { + if opts.Collection == nil { + if opts.Source != nil { + return []model.SourceRecord{*opts.Source} + } + return []model.SourceRecord{} + } + sourceIds := opts.Collection.SourceIds + if len(sourceIds) == 0 { + return opts.Sources + } + var result []model.SourceRecord + for _, id := range sourceIds { + for _, s := range opts.Sources { + if s.ID == id || s.Name == id { + result = append(result, s) + break + } + } + } + return result +} + +func loadSubscriptionRaw(ctx context.Context, sub model.SourceRecord, opts BuildOptions) (string, model.SubscriptionResponseMetadata) { + if sub.Type == "local" || sub.Content != "" { + return sub.Content + sub.URL, metadataFromSource(sub) + } + + urls := splitSourceUrls(sub.URL) + if len(urls) == 0 { + return "", metadataFromSource(sub) + } + if len(urls) > util.MaxRemoteSourceUrls { + urls = urls[:util.MaxRemoteSourceUrls] + } + + tasks := make([]func() (fetchResult, error), len(urls)) + for i, u := range urls { + u := u + tasks[i] = func() (fetchResult, error) { + content, meta, err := fetchSubscriptionUrl(ctx, u, sub, opts) + return fetchResult{content: content, meta: meta}, err + } + } + + results, _ := RunWithConcurrencyT(tasks, getConcurrency(opts.Settings), getConcurrencyWait(opts.Settings)) + + var contents []string + var metadata model.SubscriptionResponseMetadata + for i, r := range results { + contents = append(contents, proxy.DecodeMaybeBase64(r.content)) + if i == 0 { + metadata = r.meta + } + } + + return strings.Join(contents, "\n"), metadata +} + +type fetchResult struct { + content string + meta model.SubscriptionResponseMetadata +} + +func splitSourceUrls(raw string) []string { + var result []string + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "http://") || strings.HasPrefix(line, "https://") { + result = append(result, line) + } + } + return result +} + +func fetchSubscriptionUrl(ctx context.Context, url string, sub model.SourceRecord, opts BuildOptions) (string, model.SubscriptionResponseMetadata, error) { + ua := getSourceUserAgent(sub, opts) + cacheTtl := getCacheTtl(sub, opts) + cacheKey := "" + if cacheTtl > 0 && opts.CacheRepo != nil { + cacheKey = util.SHA256Hex(url + "\n" + ua) + if entry, ok := opts.CacheRepo.SafeGet(cacheKey); ok && !opts.ForceRefresh { + return entry.Content, metadataFromCache(entry, "hit"), nil + } + } + + timeout := getTimeout(opts.Settings) + httpClient := &http.Client{Timeout: timeout} + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", model.SubscriptionResponseMetadata{}, err + } + req.Header.Set("User-Agent", ua) + + resp, err := httpClient.Do(req) + if err != nil { + // Try stale cache + if cacheKey != "" && opts.CacheRepo != nil { + if entry, ok := opts.CacheRepo.SafeGet(cacheKey); ok { + return entry.Content, metadataFromCache(entry, "stale"), nil + } + } + return "", model.SubscriptionResponseMetadata{}, err + } + defer resp.Body.Close() + + if resp.StatusCode == 304 && cacheKey != "" && opts.CacheRepo != nil { + if entry, ok := opts.CacheRepo.SafeGet(cacheKey); ok { + return entry.Content, metadataFromCache(entry, "refresh"), nil + } + } + if resp.StatusCode != 200 { + return "", model.SubscriptionResponseMetadata{}, fmt.Errorf("Remote source %s failed: %d", sub.Name, resp.StatusCode) + } + + // Read with limit + body, err := io.ReadAll(io.LimitReader(resp.Body, int64(util.MaxRemoteSourceRespBytes))) + if err != nil { + return "", model.SubscriptionResponseMetadata{}, err + } + content := string(body) + metadata := metadataFromResponse(resp, "miss") + + // Cache asynchronously + if cacheKey != "" && cacheTtl > 0 && opts.CacheRepo != nil { + metaMap := metadataToMap(metadata) + go func() { + opts.CacheRepo.SafePut(cacheKey, content, metaMap, cacheTtl) + }() + } + + return content, metadata, nil +} + +func getSourceUserAgent(sub model.SourceRecord, opts BuildOptions) string { + if sub.Meta != nil { + if ua, ok := sub.Meta["ua"].(string); ok && ua != "" { + return ua + } + if ua, ok := sub.Meta["userAgent"].(string); ok && ua != "" { + return ua + } + } + if opts.Settings != nil { + if ua, ok := opts.Settings["defaultUserAgent"].(string); ok && ua != "" { + return ua + } + } + return "clash.meta/v1.19.24" +} + +func getCacheTtl(sub model.SourceRecord, opts BuildOptions) int { + if sub.Meta != nil { + if ttl, ok := sub.Meta["cacheTtl"]; ok { + if n := toInt(ttl); n > 0 { + return clamp(n, 0, 3600) + } + } + } + if opts.Settings != nil { + if ttl, ok := opts.Settings["remoteCacheTtl"]; ok { + if n := toInt(ttl); n > 0 { + return clamp(n, 0, 3600) + } + } + } + return 300 +} + +func getTimeout(settings map[string]any) time.Duration { + if settings != nil { + if t, ok := settings["defaultTimeout"]; ok { + if n := toInt(t); n > 0 { + return time.Duration(clamp(n, 1000, 120000)) * time.Millisecond + } + } + } + return 30 * time.Second +} + +func getConcurrency(settings map[string]any) int { + if settings != nil { + if c, ok := settings["backendRequestConcurrency"]; ok { + if n := toInt(c); n > 0 { + return clamp(n, 1, 12) + } + } + } + return 3 +} + +func getConcurrencyWait(settings map[string]any) time.Duration { + if settings != nil { + if w, ok := settings["backendRequestConcurrencyWaitTime"]; ok { + if n := toInt(w); n >= 0 { + return time.Duration(n) * time.Millisecond + } + } + } + return 0 +} + +func metadataFromSource(sub model.SourceRecord) model.SubscriptionResponseMetadata { + meta := sub.Meta + if meta == nil { + meta = map[string]any{} + } + return model.SubscriptionResponseMetadata{ + SubscriptionUserinfo: getString(meta["subUserinfo"], getString(meta["subscriptionUserinfo"], "")), + ProfileWebPageUrl: getString(meta["profileWebPageUrl"], getString(meta["appUrl"], "")), + ProfileUpdateInterval: getString(meta["profileUpdateInterval"], ""), + CacheStatus: "disabled", + } +} + +func metadataFromResponse(resp *http.Response, cacheStatus string) model.SubscriptionResponseMetadata { + return model.SubscriptionResponseMetadata{ + SubscriptionUserinfo: resp.Header.Get("subscription-userinfo"), + ProfileWebPageUrl: resp.Header.Get("profile-web-page-url"), + ProfileUpdateInterval: resp.Header.Get("profile-update-interval"), + ContentDisposition: resp.Header.Get("content-disposition"), + Etag: resp.Header.Get("etag"), + LastModified: resp.Header.Get("last-modified"), + CacheStatus: cacheStatus, + } +} + +func metadataFromCache(entry *database.CacheEntry, cacheStatus string) model.SubscriptionResponseMetadata { + m := entry.Metadata + return model.SubscriptionResponseMetadata{ + SubscriptionUserinfo: getStringFromMap(m, "subscriptionUserinfo"), + ProfileWebPageUrl: getStringFromMap(m, "profileWebPageUrl"), + ProfileUpdateInterval: getStringFromMap(m, "profileUpdateInterval"), + ContentDisposition: getStringFromMap(m, "contentDisposition"), + Etag: getStringFromMap(m, "etag"), + LastModified: getStringFromMap(m, "lastModified"), + CacheStatus: cacheStatus, + } +} + +func metadataToMap(m model.SubscriptionResponseMetadata) map[string]any { + return map[string]any{ + "subscriptionUserinfo": m.SubscriptionUserinfo, + "profileWebPageUrl": m.ProfileWebPageUrl, + "profileUpdateInterval": m.ProfileUpdateInterval, + "contentDisposition": m.ContentDisposition, + "etag": m.Etag, + "lastModified": m.LastModified, + } +} + +func selectResponseMetadata(sources []model.SourceRecord, metadataMap map[string]model.SubscriptionResponseMetadata) model.SubscriptionResponseMetadata { + for _, s := range sources { + if meta, ok := metadataMap[s.ID]; ok { + return meta + } + } + return model.SubscriptionResponseMetadata{} +} + +func toInt(v any) int { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + case string: + var i int + fmt.Sscanf(n, "%d", &i) + return i + } + return 0 +} + +func clamp(n, min, max int) int { + if n < min { + return min + } + if n > max { + return max + } + return n +} + +func getString(v any, def string) string { + if s, ok := v.(string); ok && s != "" { + return s + } + return def +} + +func getStringFromMap(m map[string]any, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// --- Concurrency --- + +type Result[T any] struct { + Status string // "fulfilled" or "rejected" + Value T + Err error +} + +// RunWithConcurrency runs tasks with a fixed worker pool, returning all results. +// Per review-resolution #36: preserves wait parameter. +func RunWithConcurrency[T any](tasks []func() (T, error), concurrency int, wait time.Duration) ([]T, error) { + if concurrency < 1 { + concurrency = 1 + } + if concurrency > len(tasks) { + concurrency = len(tasks) + } + results := make([]T, len(tasks)) + cursor := 0 + var mu sync.Mutex + var wg sync.WaitGroup + + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + mu.Lock() + idx := cursor + cursor++ + mu.Unlock() + if idx >= len(tasks) { + return + } + if wait > 0 && idx > 0 { + time.Sleep(wait) + } + val, err := tasks[idx]() + if err != nil { + results[idx] = val // zero value + // In non-settled mode, we could cancel, but let's keep it simple + logrus.WithError(err).Warn("task failed") + continue + } + results[idx] = val + } + }() + } + wg.Wait() + return results, nil +} + +// RunWithConcurrencyT is a variant for tasks returning a value + metadata. +func RunWithConcurrencyT[T any](tasks []func() (T, error), concurrency int, wait time.Duration) ([]T, error) { + return RunWithConcurrency(tasks, concurrency, wait) +} + +// RunSettledWithConcurrency runs tasks with allSettled semantics. +// Per review-resolution #36: preserves wait parameter. +func RunSettledWithConcurrency[T any](tasks []func() (T, error), concurrency int, wait time.Duration) []Result[T] { + if concurrency < 1 { + concurrency = 1 + } + if concurrency > len(tasks) { + concurrency = len(tasks) + } + results := make([]Result[T], len(tasks)) + cursor := 0 + var mu sync.Mutex + var wg sync.WaitGroup + + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + mu.Lock() + idx := cursor + cursor++ + mu.Unlock() + if idx >= len(tasks) { + return + } + if wait > 0 && idx > 0 { + time.Sleep(wait) + } + val, err := tasks[idx]() + if err != nil { + results[idx] = Result[T]{Status: "rejected", Err: err} + } else { + results[idx] = Result[T]{Status: "fulfilled", Value: val} + } + } + }() + } + wg.Wait() + return results +} + +// AsyncWriter manages goroutine lifecycle for async operations. +// Per review-resolution #34: sync.WaitGroup + context. +type AsyncWriter struct { + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc +} + +func NewAsyncWriter() *AsyncWriter { + ctx, cancel := context.WithCancel(context.Background()) + return &AsyncWriter{ctx: ctx, cancel: cancel} +} + +func (aw *AsyncWriter) Write(fn func()) { + aw.wg.Add(1) + go func() { + defer aw.wg.Done() + select { + case <-aw.ctx.Done(): + return + default: + fn() + } + }() +} + +func (aw *AsyncWriter) Wait() { + aw.cancel() + aw.wg.Wait() +} + +// WaitWithTimeout waits with a timeout for graceful shutdown. +func (aw *AsyncWriter) WaitWithTimeout(timeout time.Duration) { + aw.cancel() + done := make(chan struct{}) + go func() { + aw.wg.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(timeout): + logrus.Warn("AsyncWriter timed out waiting for goroutines") + } +} + +// Prevent unused import +var _ = math.MaxInt32 diff --git a/internal/template/builtin.go b/internal/template/builtin.go new file mode 100644 index 0000000..9085f48 --- /dev/null +++ b/internal/template/builtin.go @@ -0,0 +1,370 @@ +package template + +// This file defines the 6 built-in routing templates. +// Per review-resolution #41: DefaultProxyGroups returns 3 groups when no template configured. + +const testURL = "https://www.gstatic.com/generate_204" + +// BuiltinTemplateIDs is the set of IDs that cannot be modified or deleted. +var BuiltinTemplateIDs = map[string]bool{ + "mihomo-basic": true, + "acl4ssr-mihomo": true, + "acl4ssr-mihomo-no-emoji": true, + "loyalsoldier-whitelist": true, + "loyalsoldier-blacklist": true, + "ai-streaming-mihomo": true, +} + +const DefaultTemplateID = "acl4ssr-mihomo" + +// BuiltinTemplates returns all 6 built-in templates as config maps. +var BuiltinTemplates = []BuiltinTemplate{ + {ID: "mihomo-basic", Name: "Mihomo Basic", Target: "mihomo", Config: mihomoBasicConfig()}, + {ID: "acl4ssr-mihomo", Name: "ACL4SSR Mihomo", Target: "mihomo", Config: acl4ssrConfig()}, + {ID: "acl4ssr-mihomo-no-emoji", Name: "ACL4SSR Mihomo 无 Emoji", Target: "mihomo", Config: acl4ssrNoEmojiConfig()}, + {ID: "loyalsoldier-whitelist", Name: "Loyalsoldier 白名单", Target: "mihomo", Config: loyalsoldierWhitelistConfig()}, + {ID: "loyalsoldier-blacklist", Name: "Loyalsoldier 黑名单", Target: "mihomo", Config: loyalsoldierBlacklistConfig()}, + {ID: "ai-streaming-mihomo", Name: "AI + Streaming", Target: "mihomo", Config: aiStreamingConfig()}, +} + +type BuiltinTemplate struct { + ID string + Name string + Target string + Config map[string]any +} + +// DefaultProxyGroups returns 3 default groups per review-resolution #41. +func DefaultProxyGroups() []map[string]any { + return []map[string]any{ + {"name": "🚀 节点选择", "type": "select", "proxies": []any{"♻️ 自动选择", "🚀 手动切换", "DIRECT"}}, + {"name": "♻️ 自动选择", "type": "url-test", "proxies": []any{"$all"}, "url": testURL, "interval": 300, "tolerance": 50}, + {"name": "🚀 手动切换", "type": "select", "proxies": []any{"$all"}}, + } +} + +// NormalizeMihomoTemplateConfig copies kebab-case aliases to camelCase keys. +func NormalizeMihomoTemplateConfig(input map[string]any) map[string]any { + output := make(map[string]any, len(input)) + for k, v := range input { + output[k] = v + } + copyAlias(output, "mixed-port", "mixedPort") + copyAlias(output, "allow-lan", "allowLan") + copyAlias(output, "log-level", "logLevel") + copyAlias(output, "proxy-groups", "proxyGroups") + copyAlias(output, "rule-providers", "ruleProviders") + return output +} + +func copyAlias(input map[string]any, from, to string) { + if input[to] == nil && input[from] != nil { + input[to] = input[from] + } + delete(input, from) +} + +func provider(url, behavior string) map[string]any { + if behavior == "" { + behavior = "classical" + } + return map[string]any{ + "type": "http", + "behavior": behavior, + "url": url, + "path": "./ruleset/" + lastPathSegment(url), + "interval": 86400, + } +} + +func lastPathSegment(url string) string { + for i := len(url) - 1; i >= 0; i-- { + if url[i] == '/' { + return url[i+1:] + } + } + return "ruleset" +} + +func acl4ssrRaw(name string) string { + return "https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/" + name + ".list" +} + +func loyalSoldier(name string) string { + return "https://cdn.jsdelivr.net/gh/Loyalsoldier/clash-rules@release/" + name + ".txt" +} + +func blackmatrix(name string) string { + return "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/" + name + "/" + name + ".yaml" +} + +func baseGroups() []map[string]any { + return []map[string]any{ + {"name": "🚀 节点选择", "type": "select", "proxies": []any{"♻️ 自动选择", "🚀 手动切换", "DIRECT"}}, + {"name": "♻️ 自动选择", "type": "url-test", "proxies": []any{"$all"}, "url": testURL, "interval": 300, "tolerance": 50}, + {"name": "🚀 手动切换", "type": "select", "proxies": []any{"$all"}}, + {"name": "🌏 国外媒体", "type": "select", "proxies": []any{"🚀 节点选择", "♻️ 自动选择", "🚀 手动切换", "DIRECT"}}, + {"name": "💬 AI 服务", "type": "select", "proxies": []any{"🚀 节点选择", "♻️ 自动选择", "🚀 手动切换", "DIRECT"}}, + {"name": "Ⓜ️ 微软服务", "type": "select", "proxies": []any{"DIRECT", "🚀 节点选择", "♻️ 自动选择"}}, + {"name": "🍎 苹果服务", "type": "select", "proxies": []any{"DIRECT", "🚀 节点选择", "♻️ 自动选择"}}, + {"name": "🎯 全球直连", "type": "select", "proxies": []any{"DIRECT", "🚀 节点选择"}}, + {"name": "🛑 全球拦截", "type": "select", "proxies": []any{"REJECT", "DIRECT"}}, + {"name": "🐟 漏网之鱼", "type": "select", "proxies": []any{"🚀 节点选择", "DIRECT"}}, + } +} + +func defaultDNS() map[string]any { + return map[string]any{ + "enable": true, + "ipv6": false, + "enhanced-mode": "fake-ip", + "nameserver": []any{"https://doh.pub/dns-query", "https://dns.alidns.com/dns-query"}, + } +} + +func mihomoBase() map[string]any { + return map[string]any{ + "mixedPort": 7890, + "allowLan": false, + "mode": "rule", + "logLevel": "info", + "dns": defaultDNS(), + "proxyGroups": baseGroups(), + } +} + +func mihomoBasicConfig() map[string]any { + c := mihomoBase() + c["ruleProviders"] = map[string]any{} + c["rules"] = []any{ + "DOMAIN-SUFFIX,openai.com,💬 AI 服务", + "DOMAIN-SUFFIX,chatgpt.com,💬 AI 服务", + "DOMAIN-SUFFIX,anthropic.com,💬 AI 服务", + "DOMAIN-SUFFIX,claude.ai,💬 AI 服务", + "DOMAIN-SUFFIX,netflix.com,🌏 国外媒体", + "DOMAIN-SUFFIX,youtube.com,🌏 国外媒体", + "DOMAIN-SUFFIX,googlevideo.com,🌏 国外媒体", + "GEOIP,CN,🎯 全球直连", + "MATCH,🐟 漏网之鱼", + } + return c +} + +func acl4ssrConfig() map[string]any { + c := mihomoBase() + c["ruleProviders"] = map[string]any{ + "LocalAreaNetwork": provider(acl4ssrRaw("LocalAreaNetwork"), ""), + "UnBan": provider(acl4ssrRaw("UnBan"), ""), + "BanAD": provider(acl4ssrRaw("BanAD"), ""), + "BanProgramAD": provider(acl4ssrRaw("BanProgramAD"), ""), + "GoogleCN": provider(acl4ssrRaw("GoogleCN"), ""), + "SteamCN": provider(acl4ssrRaw("SteamCN"), ""), + "Microsoft": provider(acl4ssrRaw("Microsoft"), ""), + "Apple": provider(acl4ssrRaw("Apple"), ""), + "Telegram": provider(acl4ssrRaw("Telegram"), ""), + "OpenAI": provider(blackmatrix("OpenAI"), ""), + "YouTube": provider(acl4ssrRaw("Ruleset/YouTube"), ""), + "Netflix": provider(acl4ssrRaw("Ruleset/Netflix"), ""), + "DisneyPlus": provider(acl4ssrRaw("Ruleset/DisneyPlus"), ""), + "ProxyGFWlist": provider(acl4ssrRaw("ProxyGFWlist"), ""), + "ChinaDomain": provider(acl4ssrRaw("ChinaDomain"), ""), + "ChinaCompanyIp": provider(acl4ssrRaw("ChinaCompanyIp"), ""), + "Download": provider(acl4ssrRaw("Download"), ""), + } + c["rules"] = []any{ + "RULE-SET,LocalAreaNetwork,DIRECT", + "RULE-SET,UnBan,DIRECT", + "RULE-SET,BanAD,🛑 全球拦截", + "RULE-SET,BanProgramAD,🛑 全球拦截", + "RULE-SET,GoogleCN,DIRECT", + "RULE-SET,SteamCN,DIRECT", + "RULE-SET,Microsoft,Ⓜ️ 微软服务", + "RULE-SET,Apple,🍎 苹果服务", + "RULE-SET,Telegram,🚀 节点选择", + "RULE-SET,OpenAI,💬 AI 服务", + "RULE-SET,YouTube,🌏 国外媒体", + "RULE-SET,Netflix,🌏 国外媒体", + "RULE-SET,DisneyPlus,🌏 国外媒体", + "RULE-SET,ProxyGFWlist,🚀 节点选择", + "RULE-SET,ChinaDomain,DIRECT", + "RULE-SET,ChinaCompanyIp,DIRECT", + "RULE-SET,Download,DIRECT", + "GEOIP,CN,🎯 全球直连", + "MATCH,🐟 漏网之鱼", + } + return c +} + +func loyalsoldierProviders() map[string]any { + return map[string]any{ + "reject": provider(loyalSoldier("reject"), "domain"), + "icloud": provider(loyalSoldier("icloud"), "domain"), + "apple": provider(loyalSoldier("apple"), "domain"), + "google": provider(loyalSoldier("google"), "domain"), + "proxy": provider(loyalSoldier("proxy"), "domain"), + "direct": provider(loyalSoldier("direct"), "domain"), + "private": provider(loyalSoldier("private"), "domain"), + "gfw": provider(loyalSoldier("gfw"), "domain"), + "greatfire": provider(loyalSoldier("greatfire"), "domain"), + "tld-not-cn": provider(loyalSoldier("tld-not-cn"), "domain"), + "telegramcidr": provider(loyalSoldier("telegramcidr"), "ipcidr"), + "cncidr": provider(loyalSoldier("cncidr"), "ipcidr"), + "lancidr": provider(loyalSoldier("lancidr"), "ipcidr"), + "applications": provider(loyalSoldier("applications"), "classical"), + } +} + +func loyalsoldierWhitelistConfig() map[string]any { + c := mihomoBase() + c["ruleProviders"] = loyalsoldierProviders() + c["rules"] = []any{ + "RULE-SET,reject,🛑 全球拦截", + "RULE-SET,icloud,DIRECT", + "RULE-SET,apple,DIRECT", + "RULE-SET,google,🚀 节点选择", + "RULE-SET,proxy,🚀 节点选择", + "RULE-SET,direct,DIRECT", + "RULE-SET,private,DIRECT", + "RULE-SET,gfw,🚀 节点选择", + "RULE-SET,greatfire,🚀 节点选择", + "RULE-SET,tld-not-cn,🚀 节点选择", + "RULE-SET,telegramcidr,🚀 节点选择", + "RULE-SET,cncidr,DIRECT", + "RULE-SET,lancidr,DIRECT", + "RULE-SET,applications,DIRECT", + "GEOIP,CN,DIRECT", + "MATCH,🚀 节点选择", + } + return c +} + +func loyalsoldierBlacklistConfig() map[string]any { + c := mihomoBase() + c["ruleProviders"] = loyalsoldierProviders() + c["rules"] = []any{ + "RULE-SET,reject,🛑 全球拦截", + "RULE-SET,private,DIRECT", + "RULE-SET,lancidr,DIRECT", + "RULE-SET,cncidr,DIRECT", + "RULE-SET,direct,DIRECT", + "RULE-SET,applications,DIRECT", + "RULE-SET,icloud,DIRECT", + "RULE-SET,apple,DIRECT", + "RULE-SET,google,🚀 节点选择", + "RULE-SET,proxy,🚀 节点选择", + "RULE-SET,gfw,🚀 节点选择", + "RULE-SET,greatfire,🚀 节点选择", + "RULE-SET,tld-not-cn,🚀 节点选择", + "RULE-SET,telegramcidr,🚀 节点选择", + "GEOIP,CN,DIRECT", + "MATCH,DIRECT", + } + return c +} + +func aiStreamingConfig() map[string]any { + c := mihomoBase() + c["ruleProviders"] = map[string]any{ + "OpenAI": provider(blackmatrix("OpenAI"), ""), + "Claude": provider(blackmatrix("Claude"), ""), + "Gemini": provider(blackmatrix("Gemini"), ""), + "YouTube": provider(blackmatrix("YouTube"), ""), + "Netflix": provider(blackmatrix("Netflix"), ""), + "Disney": provider(blackmatrix("Disney"), ""), + "Spotify": provider(blackmatrix("Spotify"), ""), + "Telegram": provider(blackmatrix("Telegram"), ""), + "GitHub": provider(blackmatrix("GitHub"), ""), + "China": provider(blackmatrix("China"), ""), + } + c["rules"] = []any{ + "RULE-SET,OpenAI,💬 AI 服务", + "RULE-SET,Claude,💬 AI 服务", + "RULE-SET,Gemini,💬 AI 服务", + "RULE-SET,YouTube,🌏 国外媒体", + "RULE-SET,Netflix,🌏 国外媒体", + "RULE-SET,Disney,🌏 国外媒体", + "RULE-SET,Spotify,🌏 国外媒体", + "RULE-SET,Telegram,🚀 节点选择", + "RULE-SET,GitHub,🚀 节点选择", + "RULE-SET,China,DIRECT", + "GEOIP,CN,🎯 全球直连", + "MATCH,🐟 漏网之鱼", + } + return c +} + +// acl4ssrNoEmojiConfig is the ACL4SSR config with emoji labels stripped. +func acl4ssrNoEmojiConfig() map[string]any { + c := acl4ssrConfig() + if stripped, ok := stripEmoji(c).(map[string]any); ok { + return stripped + } + return c +} + +var emojiLabelMap = map[string]string{ + "🚀 节点选择": "节点选择", + "♻️ 自动选择": "自动选择", + "🚀 手动切换": "手动切换", + "🌏 国外媒体": "国外媒体", + "💬 AI 服务": "AI 服务", + "Ⓜ️ 微软服务": "微软服务", + "🍎 苹果服务": "苹果服务", + "🎯 全球直连": "全球直连", + "🛑 全球拦截": "全球拦截", + "🐟 漏网之鱼": "漏网之鱼", +} + +func stripEmoji(v any) any { + switch val := v.(type) { + case string: + result := val + for from, to := range emojiLabelMap { + result = replaceAll(result, from, to) + } + return result + case map[string]any: + result := make(map[string]any, len(val)) + for k, vv := range val { + result[k] = stripEmoji(vv) + } + return result + case []any: + result := make([]any, len(val)) + for i, vv := range val { + result[i] = stripEmoji(vv) + } + return result + case []map[string]any: + result := make([]map[string]any, len(val)) + for i, vv := range val { + result[i] = stripEmoji(vv).(map[string]any) + } + return result + default: + return v + } +} + +func replaceAll(s, old, new string) string { + result := "" + for { + idx := indexOf(s, old) + if idx < 0 { + result += s + break + } + result += s[:idx] + new + s = s[idx+len(old):] + } + return result +} + +func indexOf(s, sub string) int { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/util/flag.go b/internal/util/flag.go new file mode 100644 index 0000000..ce13566 --- /dev/null +++ b/internal/util/flag.go @@ -0,0 +1,88 @@ +package util + +import ( + "regexp" + "strings" +) + +// Flag detection rules: [regex, emoji]. +// Per review-resolution #16: Go RE2 supports \p{Regional_Indicator} and \uFE0F. +var flagRules []flagRule + +type flagRule struct { + re *regexp.Regexp + emoji string +} + +func init() { + rules := []struct { + pattern string + emoji 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`, "🇰🇷"}, + } + for _, r := range rules { + re, err := regexp.Compile("(?i)" + r.pattern) + if err != nil { + continue + } + flagRules = append(flagRules, flagRule{re: re, emoji: r.emoji}) + } +} + +// 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 { + if rule.re.MatchString(text) { + return rule.emoji + } + } + return "🏳️" +} + +// 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]+`) + +// RemoveFlag strips leading flag emoji and whitespace from a name. +// Per review-resolution #16: handles ZWJ (\u200D) and variation selector (\uFE0F). +func RemoveFlag(name string) string { + cleaned := removeFlagRe.ReplaceAllString(name, "") + // Also strip leading 🏳️ + cleaned = strings.TrimLeft(cleaned, "🏳️ ") + return strings.TrimSpace(cleaned) +} + +// NormalizeTaiwanFlag maps the Taiwan flag to different flags based on `tw` mode. +// Per review-resolution #19: three modes — ws/tw/default. +func NormalizeTaiwanFlag(flag, mode string) string { + if flag != "🇹🇼" { + return flag + } + switch mode { + case "ws": + return "🇼🇸" + case "tw": + return "🇹🇼" + default: + return "🇨🇳" + } +} + +// IsASCII checks if a string contains only ASCII characters. +func IsASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > 127 { + return false + } + } + return true +} diff --git a/internal/util/ip.go b/internal/util/ip.go new file mode 100644 index 0000000..2777698 --- /dev/null +++ b/internal/util/ip.go @@ -0,0 +1,61 @@ +package util + +import "strings" + +// IsIPv4 checks if a string is a valid IPv4 address. +func IsIPv4(value string) bool { + parts := strings.Split(value, ".") + if len(parts) != 4 { + return false + } + for _, part := range parts { + if len(part) == 0 || len(part) > 3 { + return false + } + for _, c := range part { + if c < '0' || c > '9' { + return false + } + } + var n int + for _, c := range part { + n = n*10 + int(c-'0') + } + if n > 255 { + return false + } + } + return true +} + +// IsIPv6 checks if a string is a valid IPv6 address (simplified). +func IsIPv6(value string) bool { + if !strings.Contains(value, ":") { + return false + } + for _, c := range value { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || c == ':') { + return false + } + } + return true +} + +// IsIPAddress checks if a string is any IP address. +func IsIPAddress(value string) bool { + return IsIPv4(value) || IsIPv6(value) +} + +// ShouldResolveServer returns true if the server value is a hostname that should be DNS-resolved. +func ShouldResolveServer(server string) bool { + s := strings.TrimSpace(server) + if s == "" || IsIPAddress(s) { + return false + } + for _, c := range s { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-') { + return false + } + } + return strings.Contains(s, ".") +} diff --git a/internal/util/limits.go b/internal/util/limits.go new file mode 100644 index 0000000..03bee88 --- /dev/null +++ b/internal/util/limits.go @@ -0,0 +1,13 @@ +package util + +// Byte/quantity limit constants — mirror the original project's limits.ts. +const ( + MaxAPIBodyBytes = 4 * 1024 * 1024 + MaxRemoteSourceUrls = 8 + MaxRemoteSourceRespBytes = 2 * 1024 * 1024 + MaxRemoteSourceTotalBytes = 12 * 1024 * 1024 + MaxFlowRespBytes = 64 * 1024 + MaxDoHRespBytes = 64 * 1024 + MaxRecycleEntries = 50 + MaxCustomRules = 32 // review-resolution #37 +) diff --git a/internal/util/path.go b/internal/util/path.go new file mode 100644 index 0000000..1a457af --- /dev/null +++ b/internal/util/path.go @@ -0,0 +1,164 @@ +package util + +import ( + "encoding/json" + "fmt" + "strings" +) + +// GetByPath retrieves a value from a nested map using dot-notation path. +// e.g. GetByPath(node, "ws-opts.headers.Host") +func GetByPath(input map[string]any, path string) any { + keys := strings.Split(path, ".") + var current any = input + for _, key := range keys { + if current == nil { + return nil + } + m, ok := current.(map[string]any) + if !ok { + return nil + } + current = m[key] + } + return current +} + +// SetByPath sets a value in a nested map using dot-notation path, +// creating intermediate maps as needed. +func SetByPath(input map[string]any, path string, value any) map[string]any { + keys := strings.Split(path, ".") + current := input + for i := 0; i < len(keys)-1; i++ { + key := keys[i] + next, ok := current[key].(map[string]any) + if !ok { + next = map[string]any{} + current[key] = next + } + current = next + } + current[keys[len(keys)-1]] = value + return input +} + +func GetString(input map[string]any, path string) string { + v := GetByPath(input, path) + if s, ok := v.(string); ok { + return s + } + return "" +} + +func GetInt(input map[string]any, path string) int { + v := GetByPath(input, path) + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + case string: + var i int + fmt.Sscanf(n, "%d", &i) + return i + } + return 0 +} + +func GetBool(input map[string]any, path string) bool { + v := GetByPath(input, path) + if b, ok := v.(bool); ok { + return b + } + return false +} + +// DeepCopy produces a deep copy of a ProxyNode via JSON round-trip. +func DeepCopy(node map[string]any) map[string]any { + data, err := json.Marshal(node) + if err != nil { + return map[string]any{} + } + var copy map[string]any + if err := json.Unmarshal(data, ©); err != nil { + return map[string]any{} + } + return copy +} + +// StripUndefined removes keys with nil or empty-string values from a map. +func StripUndefined(input map[string]any) map[string]any { + result := make(map[string]any, len(input)) + for k, v := range input { + if v == nil { + continue + } + if s, ok := v.(string); ok && s == "" { + continue + } + result[k] = v + } + return result +} + +// MergeDeep recursively merges `next` into `base`, returning a new map. +func MergeDeep(base, next map[string]any) map[string]any { + result := make(map[string]any, len(base)) + for k, v := range base { + result[k] = v + } + for k, v := range next { + if v == nil { + continue + } + if isPlainObject(v) && isPlainObject(result[k]) { + result[k] = MergeDeep(result[k].(map[string]any), v.(map[string]any)) + } else { + result[k] = v + } + } + return result +} + +func isPlainObject(v any) bool { + _, ok := v.(map[string]any) + return ok +} + +// ToId normalizes a string into a valid record ID: lowercase, non-alphanum → dash, max 64. +func ToId(input string) string { + s := strings.TrimSpace(input) + b := make([]byte, 0, len(s)) + prevDash := false + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 32 + } + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' { + b = append(b, c) + prevDash = false + } else if c == '-' { + if !prevDash { + b = append(b, '-') + prevDash = true + } + } else { + if !prevDash && len(b) > 0 { + b = append(b, '-') + prevDash = true + } + } + } + // trim leading/trailing dashes + result := strings.Trim(string(b), "-") + if len(result) > 64 { + result = result[:64] + } + if result == "" { + return "item" + } + return result +} diff --git a/internal/util/random.go b/internal/util/random.go new file mode 100644 index 0000000..ef322e1 --- /dev/null +++ b/internal/util/random.go @@ -0,0 +1,8 @@ +package util + +import "crypto/rand" + +// readRandom fills b with cryptographically secure random bytes. +func readRandom(b []byte) (int, error) { + return rand.Read(b) +} diff --git a/internal/util/token.go b/internal/util/token.go new file mode 100644 index 0000000..fb99f6a --- /dev/null +++ b/internal/util/token.go @@ -0,0 +1,112 @@ +package util + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" +) + +// SHA256Hex returns the hex-encoded SHA-256 hash of a string. +func SHA256Hex(input string) string { + h := sha256.Sum256([]byte(input)) + return hex.EncodeToString(h[:]) +} + +// IsTokenValid checks a plaintext input against a plaintext secret. +// Per review-resolution #24: SHA-256 both sides, then constant-time compare. +func IsTokenValid(input, secret string) bool { + if secret == "" || input == "" { + return false + } + inputHash := SHA256Hex(input) + secretHash := SHA256Hex(secret) + return subtle.ConstantTimeCompare([]byte(inputHash), []byte(secretHash)) == 1 +} + +// IsGrantTokenValid checks a plaintext input against a stored SHA-256 hash. +// Per review-resolution #24: hash the input, then constant-time compare with stored hash. +func IsGrantTokenValid(input, storedHash string) bool { + if input == "" || storedHash == "" { + return false + } + inputHash := SHA256Hex(input) + return subtle.ConstantTimeCompare([]byte(inputHash), []byte(storedHash)) == 1 +} + +// RandomToken generates a 24-byte random base64url token (no padding). +func RandomToken() (string, error) { + var buf [24]byte + if _, err := readRandom(buf[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf[:]), nil +} + +// EncodeBase64URL encodes to standard base64 URL encoding with padding. +func EncodeBase64URL(input string) string { + return base64.URLEncoding.EncodeToString([]byte(input)) +} + +// EncodeBase64RawURL encodes to base64 URL encoding without padding. +func EncodeBase64RawURL(input string) string { + return base64.RawURLEncoding.EncodeToString([]byte(input)) +} + +// EncodeBase64Std encodes to standard base64 encoding. +func EncodeBase64Std(input string) string { + return base64.StdEncoding.EncodeToString([]byte(input)) +} + +// DecodeBase64Std decodes standard base64. +func DecodeBase64Std(s string) (string, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "", err + } + return string(b), nil +} + +// DecodeBase64URL decodes URL-safe base64 with padding. +func DecodeBase64URL(s string) (string, error) { + b, err := base64.URLEncoding.DecodeString(s) + if err != nil { + return "", err + } + return string(b), nil +} + +// DecodeBase64RawURL decodes URL-safe base64 without padding. +// Per review-resolution #15: used for SSR. +func DecodeBase64RawURL(s string) (string, error) { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return "", err + } + return string(b), nil +} + +// DecodeBase64Auto tries Std first, then RawURL — per review-resolution #15. +func DecodeBase64Auto(s string) (string, error) { + s = strings.TrimSpace(s) + // Try standard base64 first + if b, err := base64.StdEncoding.DecodeString(s); err == nil { + return string(b), nil + } + // Try raw URL encoding (no padding) + if b, err := base64.RawURLEncoding.DecodeString(s); err == nil { + return string(b), nil + } + // Try URL encoding with padding + if b, err := base64.URLEncoding.DecodeString(s); err == nil { + return string(b), nil + } + return "", fmt.Errorf("invalid base64") +} + +// Base64Utf8 encodes a UTF-8 string to standard base64. +func Base64Utf8(input string) string { + return base64.StdEncoding.EncodeToString([]byte(input)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2534e93 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/peterqiu0516/sub-store/cmd" + +func main() { + cmd.Execute() +} diff --git a/scripts/e2e-real-subs.sh b/scripts/e2e-real-subs.sh new file mode 100644 index 0000000..bc3c925 --- /dev/null +++ b/scripts/e2e-real-subs.sh @@ -0,0 +1,87 @@ +#!/bin/bash +set -e + +BASE="http://127.0.0.1:18300" +ADMIN="test-admin-token" +DL="test-download-token" +H="Authorization: Bearer $ADMIN" +CT="Content-Type: application/json" + +echo "=== 1. 创建远程订阅源 (sslinks) ===" +curl -s -X POST -H "$H" -H "$CT" \ + -d '{"name":"remote-sslinks","type":"remote","url":"https://sub.sslinks.co.in/em9knZQ6ximoi9hhnKzJT3FayVZSb2PxKmPWzhzk?token=a8483f280b990786fe607e04d2724dd2","enabled":true}' \ + "$BASE/api/sources" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, id={d.get(\"data\",{}).get(\"id\",\"?\")}'); print(d.get('error',{}).get('message','')) if d['status']!='success' else None" + +echo "" +echo "=== 2. 创建远程订阅源 (smallstrawberry) ===" +curl -s -X POST -H "$H" -H "$CT" \ + -d '{"name":"remote-smallstrawberry","type":"remote","url":"https://sub2.smallstrawberry.com/api/v1/client/subscribe?token=012ebcb597340abd624d1b0b954b9b36","enabled":true}' \ + "$BASE/api/sources" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, id={d.get(\"data\",{}).get(\"id\",\"?\")}'); print(d.get('error',{}).get('message','')) if d['status']!='success' else None" + +echo "" +echo "=== 3. 创建远程订阅源 (aisaka) ===" +curl -s -X POST -H "$H" -H "$CT" \ + -d '{"name":"remote-aisaka","type":"remote","url":"https://kaze1.aisaka-taiga.com/oosaka/f2d2c3464d989d7ef78f2b5e20fa1cf9","enabled":true}' \ + "$BASE/api/sources" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, id={d.get(\"data\",{}).get(\"id\",\"?\")}'); print(d.get('error',{}).get('message','')) if d['status']!='success' else None" + +echo "" +echo "=== 4. 创建本地订阅源 (本地 VLESS 节点) ===" +curl -s -X POST -H "$H" -H "$CT" \ + -d '{"name":"local-vless","type":"local","content":"vless://a2ec9c21-1c51-46cc-8cbd-d9eb63ad412c@seoul-ali-01.20140202.xyz:443?encryption=none&security=tls&sni=seoul-ali-01.20140202.xyz&insecure=0&allowInsecure=0&type=ws&host=seoul-ali-01.20140202.xyz&path=%2F5b334b45ba04a58e#Seoul-ALIBABA\nvless://28f0f856-bcb3-4046-9aeb-785376c44505@us-la-01-racknerd.20140202.xyz:443?encryption=none&security=tls&sni=us-la-01-racknerd.20140202.xyz&insecure=0&allowInsecure=0&type=ws&host=us-la-01-racknerd.20140202.xyz&path=%2F6f6bbc94c6c76083#US-LA-RACKNERD","enabled":true}' \ + "$BASE/api/sources" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, id={d.get(\"data\",{}).get(\"id\",\"?\")}'); print(d.get('error',{}).get('message','')) if d['status']!='success' else None" + +echo "" +echo "=== 5. 列出所有源 ===" +curl -s -H "$H" "$BASE/api/sources" | python3 -c "import sys,json; d=json.load(sys.stdin); [print(f' {s[\"id\"]} ({s[\"type\"]}) enabled={s[\"enabled\"]}') for s in d['data']]" + +echo "" +echo "=== 6. 下载本地源 — mihomo ===" +curl -s "$BASE/download/source/local-vless/mihomo?token=$DL" | head -30 + +echo "" +echo "=== 7. 下载本地源 — JSON ===" +curl -s "$BASE/download/source/local-vless/json?token=$DL" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'proxies={len(d.get(\"proxies\",[]))}'); [print(f' {p.get(\"name\")} ({p.get(\"type\")}) -> {p.get(\"server\")}:{p.get(\"port\")}') for p in d.get('proxies',[])]" + +echo "" +echo "=== 8. 下载本地源 — sing-box ===" +curl -s "$BASE/download/source/local-vless/sing-box?token=$DL" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'outbounds={len(d.get(\"outbounds\",[]))}'); [print(f' {o.get(\"tag\",\"?\")} ({o.get(\"type\",\"?\")})') for o in d.get('outbounds',[])]" + +echo "" +echo "=== 9. 下载本地源 — URI ===" +curl -s "$BASE/download/source/local-vless/uri?token=$DL" + +echo "" +echo "" +echo "=== 10. 下载本地源 — surge ===" +curl -s "$BASE/download/source/local-vless/surge?token=$DL" | head -10 + +echo "" +echo "=== 11. 创建集合 (local-vless + remote-sslinks) ===" +curl -s -X POST -H "$H" -H "$CT" \ + -d '{"name":"mixed-collection","sourceIds":["local-vless","remote-sslinks"],"templateId":"default","enabled":true}' \ + "$BASE/api/collections" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, id={d.get(\"data\",{}).get(\"id\",\"?\")}'); print(d.get('error',{}).get('message','')) if d['status']!='success' else None" + +echo "" +echo "=== 12. 下载集合 — mihomo ===" +curl -s "$BASE/download/collection/mixed-collection/mihomo?token=$DL" | head -40 + +echo "" +echo "=== 13. 远程源预览 — remote-sslinks ===" +curl -s -X POST -H "$H" -H "$CT" \ + -d '{"name":"remote-sslinks","type":"remote","url":"https://sub.sslinks.co.in/em9knZQ6ximoi9hhnKzJT3FayVZSb2PxKmPWzhzk?token=a8483f280b990786fe607e04d2724dd2"}' \ + "$BASE/api/preview/source" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}'); data=d.get('data',{}); print(f'nodes={data.get(\"nodes\",\"?\")}'); print(d.get('error',{}).get('message','')) if d['status']!='success' else None" + +echo "" +echo "=== 14. 远程源下载 — remote-sslinks mihomo ===" +curl -s "$BASE/download/source/remote-sslinks/mihomo?token=$DL" 2>&1 | head -30 + +echo "" +echo "=== 15. 远程源下载 — remote-smallstrawberry mihomo ===" +curl -s "$BASE/download/source/remote-smallstrawberry/mihomo?token=$DL" 2>&1 | head -30 + +echo "" +echo "=== 16. 远程源下载 — remote-aisaka mihomo ===" +curl -s "$BASE/download/source/remote-aisaka/mihomo?token=$DL" 2>&1 | head -30 + +echo "" +echo "=== ALL TESTS DONE ===" diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh new file mode 100644 index 0000000..dea3ab6 --- /dev/null +++ b/scripts/e2e-test.sh @@ -0,0 +1,92 @@ +#!/bin/bash +set -e + +BASE="http://127.0.0.1:18300" +ADMIN="test-admin-token" +DL="test-download-token" + +echo "=== 1. ENV ===" +curl -s -H "Authorization: Bearer $ADMIN" "$BASE/api/env" | python3 -m json.tool | head -20 + +echo "" +echo "=== 2. SETTINGS ===" +curl -s -H "Authorization: Bearer $ADMIN" "$BASE/api/settings" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, appName={d[\"data\"].get(\"appName\")}')" + +echo "" +echo "=== 3. CREATE SOURCE ===" +curl -s -X POST -H "Authorization: Bearer $ADMIN" -H "Content-Type: application/json" \ + -d '{"name":"test-source","type":"local","content":"ss://aes-256-gcm:password123@1.2.3.4:8388#HK-Node1\nss://aes-256-gcm:password456@5.6.7.8:8388#US-Node2\ntrojan://pass@9.10.11.12:443?sni=example.com#JP-Node3","enabled":true}' \ + "$BASE/api/sources" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, id={d.get(\"data\",{}).get(\"id\")}')" + +echo "" +echo "=== 4. LIST SOURCES ===" +curl -s -H "Authorization: Bearer $ADMIN" "$BASE/api/sources" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'count={len(d[\"data\"])}, first={d[\"data\"][0][\"id\"] if d[\"data\"] else \"none\"}')" + +echo "" +echo "=== 5. GET SOURCE ===" +curl -s -H "Authorization: Bearer $ADMIN" "$BASE/api/sources/test-source" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, type={d[\"data\"][\"type\"]}')" + +echo "" +echo "=== 6. CREATE COLLECTION ===" +curl -s -X POST -H "Authorization: Bearer $ADMIN" -H "Content-Type: application/json" \ + -d '{"name":"test-collection","sourceIds":["test-source"],"templateId":"default","enabled":true}' \ + "$BASE/api/collections" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, id={d.get(\"data\",{}).get(\"id\")}')" + +echo "" +echo "=== 7. LIST TEMPLATES ===" +curl -s -H "Authorization: Bearer $ADMIN" "$BASE/api/templates" | python3 -c "import sys,json; d=json.load(sys.stdin); [print(f' - {t[\"id\"]} ({t.get(\"target\",\"?\")}) readonly={t.get(\"readonly\",False)}') for t in d['data']]" + +echo "" +echo "=== 8. DOWNLOAD SOURCE (mihomo) ===" +curl -s "$BASE/download/source/test-source?token=$DL" | head -20 + +echo "" +echo "=== 9. DOWNLOAD SOURCE (json) ===" +curl -s "$BASE/download/source/test-source/json?token=$DL" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'proxies count={len(d.get(\"proxies\",[]))}')" + +echo "" +echo "=== 10. DOWNLOAD COLLECTION (mihomo) ===" +curl -s "$BASE/download/collection/test-collection?token=$DL" | head -20 + +echo "" +echo "=== 11. PREVIEW SOURCE ===" +curl -s -X POST -H "Authorization: Bearer $ADMIN" -H "Content-Type: application/json" \ + -d '{"type":"local","content":"ss://aes-256-gcm:pass@1.2.3.4:8388#Test1\nss://aes-256-gcm:pass@5.6.7.8:8388#Test2","filters":[{"type":"include","pattern":"Test1"}]}' \ + "$BASE/api/preview/source" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}'); data=d.get('data',{}); print(f'original={len(data.get(\"original\",[]))}, processed={len(data.get(\"processed\",[]))}' if 'original' in data else f'nodes={data.get(\"nodes\")}')" + +echo "" +echo "=== 12. PROXY PARSE ===" +curl -s -X POST -H "Authorization: Bearer $ADMIN" -H "Content-Type: application/json" \ + -d '{"client":"mihomo","data":"ss://aes-256-gcm:pass@1.2.3.4:8388#Test1"}' \ + "$BASE/api/proxy/parse" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, parsed={d[\"data\"].get(\"parsed\")}, emitted={d[\"data\"].get(\"emitted\")}') if d['status']=='success' else print(f'error: {d}')" + +echo "" +echo "=== 13. RULE PARSE ===" +curl -s -X POST -H "Authorization: Bearer $ADMIN" -H "Content-Type: application/json" \ + -d '{"client":"mihomo","data":"DOMAIN-SUFFIX,google.com,PROXY\nGEOIP,CN,DIRECT\nMATCH,PROXY"}' \ + "$BASE/api/rule/parse" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, parsed={d[\"data\"].get(\"parsed\")}, emitted={d[\"data\"].get(\"emitted\")}') if d['status']=='success' else print(f'error: {d}')" + +echo "" +echo "=== 14. CREATE SHARE ===" +curl -s -X POST -H "Authorization: Bearer $ADMIN" -H "Content-Type: application/json" \ + -d '{"resourceType":"source","resourceId":"test-source","target":"mihomo"}' \ + "$BASE/api/shares" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}, id={d[\"data\"].get(\"id\")}, url={d[\"data\"].get(\"url\",\"\")[:80]}') if d['status']=='success' else print(f'error: {d}')" + +echo "" +echo "=== 15. LIST SHARES ===" +curl -s -H "Authorization: Bearer $ADMIN" "$BASE/api/shares" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'count={len(d[\"data\"])}')" + +echo "" +echo "=== 16. DELETE SOURCE ===" +curl -s -X DELETE -H "Authorization: Bearer $ADMIN" "$BASE/api/sources/test-source" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"status\"]}')" + +echo "" +echo "=== 17. RECYCLE BIN ===" +curl -s -H "Authorization: Bearer $ADMIN" "$BASE/api/recycle-bin" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'count={len(d[\"data\"])}'); [print(f' type={e.get(\"resourceType\")}, id={e.get(\"resourceId\")}') for e in d['data']]" + +echo "" +echo "=== 18. EXPORT STORAGE ===" +curl -s -H "Authorization: Bearer $ADMIN" "$BASE/api/storage" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'sources={len(d.get(\"sources\",[]))}, collections={len(d.get(\"collections\",[]))}, templates={len(d.get(\"templates\",[]))}')" + +echo "" +echo "=== ALL E2E TESTS DONE ==="