From f7e4ef5b8c46a25f275b4503ff50461618ced8be Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:58:36 +0800 Subject: [PATCH 01/36] fix: preserve parser and rule configuration contracts --- packages/core/package.json | 1 + packages/core/src/config/defaults.ts | 2 +- packages/core/src/core-contracts.test.ts | 8 +- packages/core/src/generator/chain.test.ts | 22 - packages/core/src/generator/chain.ts | 50 --- packages/core/src/generator/dns.ts | 3 +- packages/core/src/generator/yaml.test.ts | 20 + packages/core/src/generator/yaml.ts | 15 +- .../core/src/migrations/legacy-config.test.ts | 179 ++++++++ packages/core/src/migrations/legacy-config.ts | 396 ++++++++++++++++++ packages/core/src/parser/clash-yaml.test.ts | 19 + packages/core/src/parser/clash-yaml.ts | 96 +++-- .../src/parser/config-line-parser.test.ts | 5 + .../core/src/parser/config-line-tokenizer.ts | 5 +- .../parse-platform-proxy-line.test.ts | 14 + .../core/src/parser/platform/peggy/surge.js | 4 +- .../src/parser/protocols/simple-proxy.test.ts | 5 + .../core/src/parser/protocols/simple-proxy.ts | 6 +- .../src/parser/protocols/ssr-snell.test.ts | 13 + packages/core/src/parser/protocols/ssr.ts | 7 +- packages/core/src/proxy-group-targets.test.ts | 3 + packages/core/src/proxy-group-targets.ts | 5 +- packages/core/src/rules-database.test.ts | 77 ---- packages/core/src/rules-database.ts | 67 --- .../src/rules/custom-rule-helpers.test.ts | 14 +- packages/core/src/rules/custom-rule-utils.ts | 30 -- packages/core/src/rules/rule-model.test.ts | 5 +- packages/core/src/rules/rule-model.ts | 3 +- packages/core/src/rules/rules-utils.test.ts | 19 - .../core/src/subscription/config-utils.ts | 3 +- .../src/subscription/import-error.test.ts | 11 + .../core/src/subscription/import-error.ts | 16 +- .../subscription-userinfo.test.ts | 6 + .../src/subscription/subscription-userinfo.ts | 24 +- .../templates/config-template.fields.test.ts | 2 +- .../templates/config-template.groups.test.ts | 19 +- .../src/templates/config-template.test.ts | 61 ++- .../core/src/templates/config-template.ts | 54 ++- packages/server-core/src/cron-auth.test.ts | 4 + packages/server-core/src/cron-auth.ts | 6 +- .../src/crypto/encrypted-field.test.ts | 28 ++ .../server-core/src/crypto/encrypted-field.ts | 10 +- .../src/subscription/auto-update-failure.ts | 8 +- packages/server-core/src/subscription/crud.ts | 3 +- .../src/subscription/ssrf-ip.test.ts | 4 + .../server-core/src/subscription/ssrf-ip.ts | 86 ++-- .../sections/proxy-groups-categories.test.ts | 6 +- .../sections/proxy-groups-categories.tsx | 4 +- ...ups-custom-groups-panel-card-props.test.ts | 6 +- .../proxy-groups-custom-groups-panel.test.ts | 42 +- .../proxy-groups-custom-groups-panel.tsx | 60 ++- .../proxy-groups-custom-rules.test.ts | 12 +- .../sections/proxy-groups-custom-rules.tsx | 29 +- .../sections/proxy-groups-module-card.tsx | 2 +- .../proxy-groups-module-rules-panel.test.ts | 5 +- .../proxy-groups-module-rules-panel.tsx | 4 +- .../proxy-groups-rules-library.test.ts | 4 +- .../sections/proxy-groups-rules-library.tsx | 8 +- .../sections/rules-management-section.test.ts | 25 +- .../sections/rules-management-section.tsx | 27 +- .../converter/quick-mode/constants.test.ts | 7 + .../product/converter/quick-mode/constants.ts | 18 +- .../home/use-editing-subscription-loader.ts | 5 +- .../actions/custom-actions.test.ts | 20 +- .../config-store/actions/custom-actions.ts | 29 +- .../actions/proxy-group-actions.test.ts | 52 ++- .../actions/proxy-group-actions.ts | 64 ++- .../actions/proxy-group-rule-set-helpers.ts | 30 +- .../config-store/actions/template-actions.ts | 3 + .../ui/src/store/config-store/auth-handoff.ts | 7 +- 70 files changed, 1349 insertions(+), 558 deletions(-) create mode 100644 packages/core/src/migrations/legacy-config.test.ts create mode 100644 packages/core/src/migrations/legacy-config.ts diff --git a/packages/core/package.json b/packages/core/package.json index 85859fb..e29f699 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,6 +13,7 @@ "./generator/yaml-semantics": "./src/generator/yaml-semantics.ts", "./json": "./src/json.ts", "./mihomo/*": "./src/mihomo/*.ts", + "./migrations/*": "./src/migrations/*.ts", "./node-name-template": "./src/node-name-template.ts", "./node-identity": "./src/node-identity.ts", "./parser": "./src/parser/index.ts", diff --git a/packages/core/src/config/defaults.ts b/packages/core/src/config/defaults.ts index fb0f8d8..24c870a 100644 --- a/packages/core/src/config/defaults.ts +++ b/packages/core/src/config/defaults.ts @@ -122,7 +122,7 @@ dns: fake-ip-range: 198.18.0.1/16 default-nameserver: [180.76.76.76, 182.254.118.118, 8.8.8.8, 180.184.2.2] nameserver: [180.76.76.76, 119.29.29.29, 180.184.1.1, 223.5.5.5, 8.8.8.8, "https://223.6.6.6/dns-query#h3=true", "https://dns.alidns.com/dns-query", "https://cloudflare-dns.com/dns-query", "https://doh.pub/dns-query"] - fallback: ["https://000000.dns.nextdns.io/dns-query#h3=true", "https://dns.alidns.com/dns-query", "https://doh.pub/dns-query", "https://public.dns.iij.jp/dns-query", "https://101.101.101.101/dns-query", "https://208.67.220.220/dns-query", "tls://8.8.4.4", "tls://1.0.0.1:853", "https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"] + fallback: ["https://dns.quad9.net/dns-query", "https://dns.alidns.com/dns-query", "https://doh.pub/dns-query", "https://public.dns.iij.jp/dns-query", "https://101.101.101.101/dns-query", "https://208.67.220.220/dns-query", "tls://8.8.4.4", "tls://1.0.0.1:853", "https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"] fallback-filter: {geoip: true, ipcidr: [240.0.0.0/4, 0.0.0.0/32, 127.0.0.1/32], domain: ["+.google.com", "+.facebook.com", "+.twitter.com", "+.youtube.com", "+.xn--ngstr-lra8j.com", "+.google.cn", "+.googleapis.cn", "+.googleapis.com", "+.gvt1.com"]} fake-ip-filter: ["*.lan", "stun.*.*.*", "stun.*.*", time.windows.com, time.nist.gov, time.apple.com, time.asia.apple.com, "*.ntp.org.cn", "*.openwrt.pool.ntp.org", time1.cloud.tencent.com, time.ustc.edu.cn, pool.ntp.org, ntp.ubuntu.com, ntp.aliyun.com, ntp1.aliyun.com, ntp2.aliyun.com, ntp3.aliyun.com, ntp4.aliyun.com, ntp5.aliyun.com, ntp6.aliyun.com, ntp7.aliyun.com, time1.aliyun.com, time2.aliyun.com, time3.aliyun.com, time4.aliyun.com, time5.aliyun.com, time6.aliyun.com, time7.aliyun.com, "*.time.edu.cn", time1.apple.com, time2.apple.com, time3.apple.com, time4.apple.com, time5.apple.com, time6.apple.com, time7.apple.com, time1.google.com, time2.google.com, time3.google.com, time4.google.com, music.163.com, "*.music.163.com", "*.126.net", musicapi.taihe.com, music.taihe.com, songsearch.kugou.com, trackercdn.kugou.com, "*.kuwo.cn", api-jooxtt.sanook.com, api.joox.com, joox.com, y.qq.com, "*.y.qq.com", streamoc.music.tc.qq.com, mobileoc.music.tc.qq.com, isure.stream.qqmusic.qq.com, dl.stream.qqmusic.qq.com, aqqmusic.tc.qq.com, amobile.music.tc.qq.com, "*.xiami.com", "*.music.migu.cn", music.migu.cn, "*.msftconnecttest.com", "*.msftncsi.com", localhost.ptlogin2.qq.com, "*.*.*.srv.nintendo.net", "*.*.stun.playstation.net", "xbox.*.*.microsoft.com", "*.ipv6.microsoft.com", "*.*.xboxlive.com", speedtest.cros.wr.pvp.net] # nameserver-policy 示例(full) diff --git a/packages/core/src/core-contracts.test.ts b/packages/core/src/core-contracts.test.ts index 4279613..18ad9f5 100644 --- a/packages/core/src/core-contracts.test.ts +++ b/packages/core/src/core-contracts.test.ts @@ -173,14 +173,14 @@ describe("custom routing rule set contracts", () => { expect(parseRuleSetTargetValue("module:")).toBeNull(); expect(parseRuleSetTargetValue("bad:value")).toBeNull(); expect(extractRuleSetPathFromUrl("https://example.com/geo/geosite/youtube.mrs?raw=1")).toBe( - "geosite/youtube.mrs" + "https://example.com/geo/geosite/youtube.mrs?raw=1" ); expect(normalizeRuleSetPathInput("/geoip/private.mrs")).toBe("geoip/private.mrs"); expect(buildRuleSetUrlFromPath("geosite/youtube.mrs", "https://rules.example.com/geo/")).toBe( "https://rules.example.com/geo/geosite/youtube.mrs" ); expect(buildRuleSetUrlFromPath("https://cdn.example.com/geosite/youtube.mrs", DEFAULT_RULE_PROVIDER_BASE_URL)).toBe( - `${DEFAULT_RULE_PROVIDER_BASE_URL}/geosite/youtube.mrs` + "https://cdn.example.com/geosite/youtube.mrs" ); }); @@ -231,7 +231,7 @@ describe("custom routing rule set contracts", () => { id: "private", name: "private", behavior: "ipcidr", - path: "geoip/private.mrs", + path: "https://rules.example.com/geo/geoip/private.mrs", target: { kind: "module", id: moduleId, @@ -246,7 +246,7 @@ describe("custom routing rule set contracts", () => { id: "youtube", name: "YouTube", behavior: "domain", - path: "geosite/youtube.mrs", + path: "https://rules.example.com/geo/geosite/youtube.mrs?download=1", target: { kind: "custom", id: "custom", diff --git a/packages/core/src/generator/chain.test.ts b/packages/core/src/generator/chain.test.ts index b0141fa..c0b44a5 100644 --- a/packages/core/src/generator/chain.test.ts +++ b/packages/core/src/generator/chain.test.ts @@ -6,7 +6,6 @@ import { generateDialerProxyGroups, getDialerRelayNodes, getDialerTargetNodes, - suggestDialerGroups, validateDialerConfig, } from "./chain"; @@ -184,25 +183,4 @@ describe("dialer proxy chain helpers", () => { }); }); - it("suggests regional relay groups from node names", () => { - const suggestions = suggestDialerGroups([ - node("US Los Angeles 01"), - node("美国 New York 02"), - node("香港 HK 01"), - node("Unknown 01"), - ]); - - expect(suggestions).toEqual([ - { - name: "🇺🇸 美国中转", - relayNodes: ["US Los Angeles 01", "美国 New York 02"], - description: "使用美国节点作为中转", - }, - { - name: "🇭🇰 香港中转", - relayNodes: ["香港 HK 01"], - description: "使用香港节点作为中转", - }, - ]); - }); }); diff --git a/packages/core/src/generator/chain.ts b/packages/core/src/generator/chain.ts index 6ddd21f..4eb3487 100644 --- a/packages/core/src/generator/chain.ts +++ b/packages/core/src/generator/chain.ts @@ -151,53 +151,3 @@ export function validateDialerConfig( errors, }; } - -/** - * 从节点列表中推荐中转组合 - * 基于节点名称中的地区信息进行智能匹配 - */ -export function suggestDialerGroups( - nodes: ParsedNode[] -): Array<{ name: string; relayNodes: string[]; description: string }> { - const suggestions: Array<{ name: string; relayNodes: string[]; description: string }> = []; - - // 地区关键词 - const regions: Record = { - us: { keywords: ["美国", "US", "USA", "United States", "洛杉矶", "纽约", "西雅图"], emoji: "🇺🇸", name: "美国" }, - hk: { keywords: ["香港", "HK", "Hong Kong", "港"], emoji: "🇭🇰", name: "香港" }, - jp: { keywords: ["日本", "JP", "Japan", "东京", "大阪"], emoji: "🇯🇵", name: "日本" }, - sg: { keywords: ["新加坡", "SG", "Singapore", "狮城"], emoji: "🇸🇬", name: "新加坡" }, - tw: { keywords: ["台湾", "TW", "Taiwan", "台北"], emoji: "🇹🇼", name: "台湾" }, - kr: { keywords: ["韩国", "KR", "Korea", "首尔"], emoji: "🇰🇷", name: "韩国" }, - }; - - // 按地区分类节点 - const nodesByRegion: Record = {}; - - for (const node of nodes) { - const name = node.name.toLowerCase(); - for (const [region, config] of Object.entries(regions)) { - if (config.keywords.some((kw) => name.includes(kw.toLowerCase()))) { - if (!nodesByRegion[region]) { - nodesByRegion[region] = []; - } - nodesByRegion[region].push(node.name); - break; - } - } - } - - // 生成推荐的中转组 - for (const [region, config] of Object.entries(regions)) { - const regionNodes = nodesByRegion[region] || []; - if (regionNodes.length > 0) { - suggestions.push({ - name: `${config.emoji} ${config.name}中转`, - relayNodes: regionNodes.slice(0, 5), // 最多取5个节点 - description: `使用${config.name}节点作为中转`, - }); - } - } - - return suggestions; -} diff --git a/packages/core/src/generator/dns.ts b/packages/core/src/generator/dns.ts index dca1145..7917111 100644 --- a/packages/core/src/generator/dns.ts +++ b/packages/core/src/generator/dns.ts @@ -28,7 +28,7 @@ export const DEFAULT_DNS_CONFIG: DNSConfig = { "https://doh.pub/dns-query", ], fallback: [ - "https://000000.dns.nextdns.io/dns-query#h3=true", + "https://dns.quad9.net/dns-query", "https://dns.alidns.com/dns-query", "https://doh.pub/dns-query", "https://public.dns.iij.jp/dns-query", @@ -128,4 +128,3 @@ export const DEFAULT_DNS_CONFIG: DNSConfig = { "speedtest.cros.wr.pvp.net", ], }; - diff --git a/packages/core/src/generator/yaml.test.ts b/packages/core/src/generator/yaml.test.ts index 6bb6555..6294e21 100644 --- a/packages/core/src/generator/yaml.test.ts +++ b/packages/core/src/generator/yaml.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import yamlParser from "js-yaml"; import { collectDnsPolicyEntries, configToYaml } from "./yaml"; import type { ClashConfig } from "@subboost/core/types/config"; @@ -73,6 +74,25 @@ describe("configToYaml", () => { expect(yaml).toBe(["proxies:", "", "proxy-groups:", "", "rule-providers:", "", "rules:"].join("\n")); }); + it("quotes rule scalars that YAML would otherwise truncate or reinterpret", () => { + const rules = [ + "DOMAIN-SUFFIX,example.com,Netflix #1", + "DOMAIN,example.net,Group: Primary", + "MATCH,Select", + ]; + const output = configToYaml({ + proxies: [], + "proxy-groups": [], + "rule-providers": {}, + rules, + } as unknown as ClashConfig); + + expect((yamlParser.load(output) as ClashConfig).rules).toEqual(rules); + expect(output).toContain(' - "DOMAIN-SUFFIX,example.com,Netflix #1"'); + expect(output).toContain(' - "DOMAIN,example.net,Group: Primary"'); + expect(output).toContain(" - MATCH,Select"); + }); + it("collects DNS policy entries from clean string and array values only", () => { expect(collectDnsPolicyEntries(null)).toEqual([]); expect(collectDnsPolicyEntries([])).toEqual([]); diff --git a/packages/core/src/generator/yaml.ts b/packages/core/src/generator/yaml.ts index 7efb8cd..840f589 100644 --- a/packages/core/src/generator/yaml.ts +++ b/packages/core/src/generator/yaml.ts @@ -237,6 +237,19 @@ function toInlineYaml(value: unknown): string { return String(value); } +function toBlockSequenceScalar(value: string): string { + const trimmed = value.trim(); + const ambiguous = + !value || + trimmed !== value || + /[\r\n#]/.test(value) || + /:\s/.test(value) || + /^[\-?:,\[\]{}&*!|>'"%@`]/.test(value) || + /^(?:null|~|true|false|yes|no|on|off)$/i.test(value) || + /^[+-]?(?:\d+|\d*\.\d+|\d+\.\d*)(?:[eE][+-]?\d+)?$/.test(value); + return ambiguous ? `"${escapeYamlDoubleQuotedString(value)}"` : value; +} + function normalizeDnsPolicyValue(value: unknown): DnsPolicyValue | null { if (Array.isArray(value)) { const servers = value @@ -372,7 +385,7 @@ export function configToYaml(config: ClashConfig): string { lines.push("rules:"); if (config.rules) { for (const rule of config.rules) { - lines.push(` - ${rule}`); + lines.push(` - ${toBlockSequenceScalar(rule)}`); } } diff --git a/packages/core/src/migrations/legacy-config.test.ts b/packages/core/src/migrations/legacy-config.test.ts new file mode 100644 index 0000000..0d24326 --- /dev/null +++ b/packages/core/src/migrations/legacy-config.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { migrateLegacyConfig } from "./legacy-config"; + +describe("migrateLegacyConfig", () => { + it("keeps current configs unchanged", () => { + const config = { + customProxyGroups: [], + customRuleSets: [], + builtinRuleEdits: {}, + }; + + expect(migrateLegacyConfig(config)).toBe(config); + }); + + it("migrates filtered groups and every supported legacy rule shape", () => { + const result = migrateLegacyConfig({ + filteredProxyGroups: [ + { + id: "us-only", + name: "US Only", + groupType: "url-test", + sourceIds: ["airport", "airport"], + regions: ["us"], + excludedNodeNames: ["Old Node"], + }, + ], + customProxyGroups: [ + { + id: "custom-lab", + name: "Lab", + emoji: "🧪", + groupType: "select", + rules: [ + { + id: "lab-provider", + name: "Lab Provider", + path: "geosite/lab.mrs", + behavior: "domain", + }, + ], + }, + ], + customRules: [ + { id: "ordinary", type: "DOMAIN", value: "example.com", target: "US Only" }, + { + id: "manual-provider", + type: "RULE-SET", + value: "geoip/manual.mrs", + target: "US Only", + }, + ], + customRuleSets: [ + { + id: "existing-provider", + name: "Existing", + path: "geosite/existing.mrs", + behavior: "domain", + target: "US Only", + }, + ], + builtinRuleEdits: { + "module:youtube:youtube": { target: "US Only" }, + }, + moduleRuleExclusions: { + ai: ["openai", "anthropic"], + }, + moduleRuleOverrides: { + google: [ + { + id: "openai", + name: "OpenAI", + path: "geosite/openai.mrs", + behavior: "domain", + }, + { + id: "new-provider", + name: "New Provider", + path: "geoip/new-provider.mrs", + }, + ], + }, + allRulesOrderEditingEnabled: true, + dialerProxyGroups: [{ id: "relay", relayNodes: ["US Only"] }], + proxyGroupOrder: ["filtered:us-only"], + ruleOrder: [ + "module:ai:openai", + "module:google:new-provider", + "custom-group:custom-lab:lab-provider", + "custom-rule:manual-provider", + ], + }); + + expect(result).not.toHaveProperty("filteredProxyGroups"); + expect(result).not.toHaveProperty("moduleRuleExclusions"); + expect(result).not.toHaveProperty("moduleRuleOverrides"); + expect(result).not.toHaveProperty("allRulesOrderEditingEnabled"); + expect(result.proxyGroupAdvancedModeEnabled).toBe(true); + expect(result.customProxyGroups).toEqual([ + { + id: "custom-lab", + name: "Lab", + emoji: "🧪", + groupType: "select", + }, + expect.objectContaining({ + id: "migrated-filtered-us-only", + name: "US Only", + memberSource: "filtered-nodes", + groupType: "url-test", + advanced: { + sourceIds: ["airport"], + regions: ["us"], + excludedMembers: [{ kind: "node", name: "Old Node" }], + }, + }), + ]); + expect(result.customRules).toEqual([ + { + id: "ordinary", + type: "DOMAIN", + value: "example.com", + target: { kind: "custom", id: "migrated-filtered-us-only" }, + }, + ]); + expect(result.customRuleSets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "existing-provider", + target: { kind: "custom", id: "migrated-filtered-us-only" }, + }), + expect.objectContaining({ + id: "manual-provider", + behavior: "ipcidr", + target: { kind: "custom", id: "migrated-filtered-us-only" }, + }), + expect.objectContaining({ + id: "lab-provider", + target: { kind: "custom", id: "custom-lab" }, + }), + expect.objectContaining({ + id: "new-provider", + behavior: "ipcidr", + target: { kind: "module", id: "google" }, + }), + ]) + ); + expect(result.builtinRuleEdits).toEqual({ + "module:youtube:youtube": { + target: { kind: "custom", id: "migrated-filtered-us-only" }, + }, + "module:ai:openai": { target: { kind: "module", id: "google" } }, + "module:ai:anthropic": { enabled: false }, + }); + expect(result.dialerProxyGroups).toEqual([{ id: "relay", relayNodes: ["US Only"] }]); + expect(result.proxyGroupOrder).toEqual(["custom:migrated-filtered-us-only"]); + expect(result.ruleOrder).toEqual([ + "module:ai:openai", + "custom-rule-set:new-provider", + "custom-rule-set:lab-provider", + "custom-rule-set:manual-provider", + ]); + }); + + it("lets current builtin edits win when old and new fields overlap", () => { + const result = migrateLegacyConfig({ + moduleRuleExclusions: { ai: ["openai"] }, + builtinRuleEdits: { + "module:ai:openai": { target: { kind: "module", id: "select" } }, + }, + }); + + expect(result.builtinRuleEdits).toEqual({ + "module:ai:openai": { + enabled: false, + target: { kind: "module", id: "select" }, + }, + }); + }); +}); diff --git a/packages/core/src/migrations/legacy-config.ts b/packages/core/src/migrations/legacy-config.ts new file mode 100644 index 0000000..9513f3f --- /dev/null +++ b/packages/core/src/migrations/legacy-config.ts @@ -0,0 +1,396 @@ +import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-group-modules"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; +import { + isValidRuleSetPathOrUrl, + normalizeRuleSetPathInput, +} from "@subboost/core/rules/rule-model"; +import type { + CustomRuleSet, + ProxyGroupAdvancedConfig, + ProxyGroupRuleTarget, + RuleSetBehavior, +} from "@subboost/core/types/config"; + +type MutableRecord = Record; + +const LEGACY_GROUP_TYPES = new Set([ + "select", + "url-test", + "fallback", + "load-balance", + "direct-first", + "reject-first", +]); +const BUILTIN_MODULE_IDS = new Set(PROXY_GROUP_MODULES.map((module) => module.id)); +const BUILTIN_RULE_SOURCE_BY_ID = new Map( + PROXY_GROUP_MODULES.flatMap((module) => module.rules.map((rule) => [rule.id, module.id] as const)) +); + +function isRecord(value: unknown): value is MutableRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasOwn(value: MutableRecord, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function uniqueStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + const seen = new Set(); + for (const item of value) { + const text = stringValue(item); + if (!text || seen.has(text)) continue; + seen.add(text); + out.push(text); + } + return out; +} + +function makeUniqueValue(base: string, used: Set, fallback: string): string { + const normalized = base.trim() || fallback; + if (!used.has(normalized)) { + used.add(normalized); + return normalized; + } + let index = 2; + let candidate = `${normalized}-${index}`; + while (used.has(candidate)) { + index += 1; + candidate = `${normalized}-${index}`; + } + used.add(candidate); + return candidate; +} + +function makeUniqueName(base: string, used: Set): string { + const normalized = base.trim() || "自定义代理组"; + if (!used.has(normalized)) { + used.add(normalized); + return normalized; + } + let index = 2; + let candidate = `${normalized} (${index})`; + while (used.has(candidate)) { + index += 1; + candidate = `${normalized} (${index})`; + } + used.add(candidate); + return candidate; +} + +function migrateFilteredAdvanced(group: MutableRecord): ProxyGroupAdvancedConfig { + return normalizeProxyGroupAdvancedConfig({ + sourceIds: uniqueStringArray(group.sourceIds), + regions: uniqueStringArray(group.regions), + includeRegex: stringValue(group.includeRegex), + excludeRegex: stringValue(group.excludeRegex), + excludedMembers: uniqueStringArray(group.excludedNodeNames).map((name) => ({ + kind: "node" as const, + name, + })), + }); +} + +function retargetRuleTarget( + value: unknown, + nameMap: Map, + idMap: Map +): unknown { + const ref = normalizeProxyGroupTargetRef(value); + if (ref?.kind === "custom" && idMap.has(ref.id)) { + return { kind: "custom" as const, id: idMap.get(ref.id)!.id }; + } + const text = stringValue(value); + const migrated = text ? nameMap.get(text) : undefined; + return migrated ? { kind: "custom" as const, id: migrated.id } : value; +} + +function retargetStringArray(value: unknown, nameMap: Map): unknown { + if (!Array.isArray(value)) return value; + return value.map((item) => { + if (typeof item !== "string") return item; + return nameMap.get(item.trim())?.name ?? item; + }); +} + +function migrateFilteredProxyGroups(record: MutableRecord): MutableRecord { + if (!hasOwn(record, "filteredProxyGroups")) return record; + + const next = { ...record }; + const existingCustomGroups = Array.isArray(next.customProxyGroups) ? [...next.customProxyGroups] : []; + const validExistingGroups = existingCustomGroups.filter(isRecord); + const usedIds = new Set(validExistingGroups.map((group) => stringValue(group.id)).filter(Boolean)); + const usedNames = new Set(validExistingGroups.map((group) => stringValue(group.name)).filter(Boolean)); + const nameMap = new Map(); + const idMap = new Map(); + const migratedGroups: MutableRecord[] = []; + + if (Array.isArray(record.filteredProxyGroups)) { + for (const rawGroup of record.filteredProxyGroups) { + if (!isRecord(rawGroup) || rawGroup.enabled === false) continue; + const oldId = stringValue(rawGroup.id); + const oldName = stringValue(rawGroup.name); + if (!oldId || !oldName) continue; + + const id = makeUniqueValue(`migrated-filtered-${oldId}`, usedIds, "migrated-filtered-group"); + const name = makeUniqueName(oldName, usedNames); + const mapped = { id, name }; + idMap.set(oldId, mapped); + nameMap.set(oldName, mapped); + + const rawGroupType = stringValue(rawGroup.groupType) || "select"; + const groupType = LEGACY_GROUP_TYPES.has(rawGroupType) ? rawGroupType : "select"; + migratedGroups.push({ + id, + name, + emoji: stringValue(rawGroup.emoji), + description: "自定义代理组", + memberSource: "filtered-nodes", + groupType, + ...(groupType === "load-balance" && stringValue(rawGroup.strategy) + ? { strategy: stringValue(rawGroup.strategy) } + : {}), + advanced: migrateFilteredAdvanced(rawGroup), + }); + } + } + + next.customProxyGroups = [...existingCustomGroups, ...migratedGroups]; + if (migratedGroups.length > 0) next.proxyGroupAdvancedModeEnabled = true; + delete next.filteredProxyGroups; + + if (Array.isArray(next.customRules)) { + next.customRules = next.customRules.map((rule) => + isRecord(rule) && hasOwn(rule, "target") + ? { ...rule, target: retargetRuleTarget(rule.target, nameMap, idMap) } + : rule + ); + } + if (Array.isArray(next.customRuleSets)) { + next.customRuleSets = next.customRuleSets.map((ruleSet) => + isRecord(ruleSet) && hasOwn(ruleSet, "target") + ? { ...ruleSet, target: retargetRuleTarget(ruleSet.target, nameMap, idMap) } + : ruleSet + ); + } + if (isRecord(next.builtinRuleEdits)) { + next.builtinRuleEdits = Object.fromEntries( + Object.entries(next.builtinRuleEdits).map(([key, edit]) => [ + key, + isRecord(edit) && hasOwn(edit, "target") + ? { ...edit, target: retargetRuleTarget(edit.target, nameMap, idMap) } + : edit, + ]) + ); + } + if (Array.isArray(next.dialerProxyGroups)) { + next.dialerProxyGroups = next.dialerProxyGroups.map((group) => + isRecord(group) && hasOwn(group, "relayNodes") + ? { ...group, relayNodes: retargetStringArray(group.relayNodes, nameMap) } + : group + ); + } + if (Array.isArray(next.proxyGroupOrder)) { + next.proxyGroupOrder = next.proxyGroupOrder.map((key) => { + const text = stringValue(key); + if (!text.startsWith("filtered:")) return key; + const mapped = idMap.get(text.slice("filtered:".length)); + return mapped ? `custom:${mapped.id}` : key; + }); + } + + return next; +} + +type LegacyRuleProvider = Omit & { legacyIndex: number }; + +function normalizeBehavior(value: unknown, path: string): RuleSetBehavior | null { + if (value === "domain" || value === "ipcidr") return value; + if (path.toLowerCase().startsWith("geoip/")) return "ipcidr"; + if (path.toLowerCase().startsWith("geosite/")) return "domain"; + return null; +} + +function parseLegacyRuleProvider( + value: unknown, + legacyIndex: number, + pathFields: string[] = ["path"] +): LegacyRuleProvider | null { + if (!isRecord(value)) return null; + const id = stringValue(value.id); + let rawPath = ""; + for (const field of pathFields) { + rawPath = stringValue(value[field]); + if (rawPath) break; + } + const path = normalizeRuleSetPathInput(rawPath); + const behavior = normalizeBehavior(value.behavior, path); + if (!id || !behavior || !path || !isValidRuleSetPathOrUrl(path)) return null; + const noResolve = typeof value.noResolve === "boolean" ? value.noResolve : behavior === "ipcidr"; + return { + id, + name: stringValue(value.name) || id, + behavior, + path, + ...(noResolve ? { noResolve: true } : {}), + legacyIndex, + }; +} + +function parseLegacyOverrides(value: unknown): Record { + if (!isRecord(value)) return {}; + const out: Record = {}; + for (const [rawModuleId, rawRules] of Object.entries(value)) { + const moduleId = rawModuleId.trim(); + if (!moduleId || !Array.isArray(rawRules)) continue; + const rules = rawRules + .map((rule, index) => parseLegacyRuleProvider(rule, index)) + .filter((rule): rule is LegacyRuleProvider => Boolean(rule)); + if (rules.length > 0) out[moduleId] = rules; + } + return out; +} + +function moduleRuleKey(moduleId: string, ruleId: string): string { + return `module:${moduleId}:${ruleId}`; +} + +function targetForModule(moduleId: string, config: MutableRecord): ProxyGroupRuleTarget { + if (BUILTIN_MODULE_IDS.has(moduleId)) return { kind: "module", id: moduleId }; + const overrides = isRecord(config.proxyGroupNameOverrides) ? config.proxyGroupNameOverrides : {}; + return stringValue(overrides[moduleId]) || moduleId; +} + +function addUniqueRuleSet(ruleSets: unknown[], ruleSet: CustomRuleSet): string { + const existing = new Set( + ruleSets.filter(isRecord).map((item) => stringValue(item.id)).filter(Boolean) + ); + const id = makeUniqueValue(ruleSet.id, existing, "migrated-rule-set"); + ruleSets.push(id === ruleSet.id ? ruleSet : { ...ruleSet, id }); + return id; +} + +function migrateLegacyRuleFields(record: MutableRecord): MutableRecord { + const groupsHaveLegacyRules = Array.isArray(record.customProxyGroups) + && record.customProxyGroups.some((group) => isRecord(group) && hasOwn(group, "rules")); + const customRulesHaveRuleSet = Array.isArray(record.customRules) + && record.customRules.some((rule) => isRecord(rule) && rule.type === "RULE-SET"); + const hasLegacyFields = hasOwn(record, "moduleRuleOverrides") + || hasOwn(record, "moduleRuleExclusions") + || hasOwn(record, "allRulesOrderEditingEnabled") + || groupsHaveLegacyRules + || customRulesHaveRuleSet; + if (!hasLegacyFields) return record; + + const next = { ...record }; + const ruleSets = Array.isArray(record.customRuleSets) ? [...record.customRuleSets] : []; + const edits: MutableRecord = isRecord(record.builtinRuleEdits) ? { ...record.builtinRuleEdits } : {}; + const orderMoves = new Map(); + + if (Array.isArray(record.customRules)) { + const rules: unknown[] = []; + record.customRules.forEach((rule, index) => { + if (!isRecord(rule) || rule.type !== "RULE-SET") { + rules.push(rule); + return; + } + const parsed = parseLegacyRuleProvider(rule, index, ["value", "path"]); + const target = normalizeProxyGroupTargetRef(rule.target) ?? stringValue(rule.target); + if (!parsed || !target) { + rules.push(rule); + return; + } + const { legacyIndex: _legacyIndex, ...provider } = parsed; + const id = addUniqueRuleSet(ruleSets, { ...provider, target }); + orderMoves.set(`custom-rule:${parsed.id}`, `custom-rule-set:${id}`); + }); + next.customRules = rules; + } + + if (Array.isArray(record.customProxyGroups)) { + next.customProxyGroups = record.customProxyGroups.map((group) => { + if (!isRecord(group) || !hasOwn(group, "rules")) return group; + const { rules, ...rest } = group; + const target = stringValue(group.id) + ? { kind: "custom" as const, id: stringValue(group.id) } + : stringValue(group.name); + if (Array.isArray(rules) && target) { + rules.forEach((rule, index) => { + const parsed = parseLegacyRuleProvider(rule, index, ["path", "url"]); + if (!parsed) return; + const { legacyIndex: _legacyIndex, ...provider } = parsed; + const id = addUniqueRuleSet(ruleSets, { ...provider, target }); + orderMoves.set(`custom-group:${stringValue(group.id)}:${parsed.id}`, `custom-rule-set:${id}`); + }); + } + return rest; + }); + } + + const overrides = parseLegacyOverrides(record.moduleRuleOverrides); + const consumedOverrides = new Set(); + if (isRecord(record.moduleRuleExclusions)) { + for (const [rawSourceId, rawRuleIds] of Object.entries(record.moduleRuleExclusions)) { + const sourceId = rawSourceId.trim(); + if (!sourceId || !Array.isArray(rawRuleIds)) continue; + for (const ruleId of uniqueStringArray(rawRuleIds)) { + const canonicalSourceId = BUILTIN_RULE_SOURCE_BY_ID.get(ruleId) ?? sourceId; + const key = moduleRuleKey(canonicalSourceId, ruleId); + let legacyEdit: MutableRecord = { enabled: false }; + for (const [targetModuleId, rules] of Object.entries(overrides)) { + const moved = rules.find((rule) => rule.id === ruleId); + if (!moved) continue; + legacyEdit = { target: targetForModule(targetModuleId, record) }; + consumedOverrides.add(`${targetModuleId}:${moved.legacyIndex}`); + break; + } + edits[key] = isRecord(edits[key]) ? { ...legacyEdit, ...edits[key] } : legacyEdit; + } + } + } + + for (const [targetModuleId, rules] of Object.entries(overrides)) { + const target = targetForModule(targetModuleId, record); + for (const rule of rules) { + if (consumedOverrides.has(`${targetModuleId}:${rule.legacyIndex}`)) continue; + const sourceId = BUILTIN_RULE_SOURCE_BY_ID.get(rule.id); + if (sourceId === targetModuleId) continue; + const { legacyIndex: _legacyIndex, ...provider } = rule; + const id = addUniqueRuleSet(ruleSets, { ...provider, target }); + orderMoves.set(moduleRuleKey(targetModuleId, rule.id), `custom-rule-set:${id}`); + } + } + + if (Array.isArray(record.ruleOrder)) { + next.ruleOrder = record.ruleOrder.flatMap((item) => { + if (typeof item !== "string") return []; + if (orderMoves.has(item)) { + const moved = orderMoves.get(item); + return moved ? [moved] : []; + } + return item.startsWith("custom-group:") ? [] : [item]; + }); + } + + delete next.moduleRuleOverrides; + delete next.moduleRuleExclusions; + delete next.allRulesOrderEditingEnabled; + next.customRuleSets = ruleSets; + next.builtinRuleEdits = edits; + return next; +} + +/** + * Converts accepted pre-v3 configuration fields at trust-boundary entry points. + * Runtime state and newly persisted data must only use the current rule model. + */ +export function migrateLegacyConfig(config: T): T { + if (!isRecord(config)) return config; + return migrateLegacyRuleFields(migrateFilteredProxyGroups(config)) as T; +} diff --git a/packages/core/src/parser/clash-yaml.test.ts b/packages/core/src/parser/clash-yaml.test.ts index 102aef5..d36ad65 100644 --- a/packages/core/src/parser/clash-yaml.test.ts +++ b/packages/core/src/parser/clash-yaml.test.ts @@ -208,6 +208,25 @@ proxies: expect(mixed.errors[0]).toContain('节点 "Bad" 解析失败'); }); + it("repairs large malformed proxy lists without quadratic scanning", () => { + const count = 2_000; + const rows = ["proxies:"]; + for (let index = 0; index < count; index += 1) { + rows.push(` - name: Node ${index}`); + rows.push(" type: ss"); + rows.push(` server: node-${index}.example.com`); + rows.push(" port: 8388"); + } + + const startedAt = performance.now(); + const result = parseClashYaml(rows.join("\n")); + const elapsedMs = performance.now() - startedAt; + + expect(result.errors).toEqual([]); + expect(result.nodes).toHaveLength(count); + expect(elapsedMs).toBeLessThan(1_500); + }, 10_000); + it("parses consistently indented root flow proxy lists", () => { for (const spaces of [0, 1, 2, 4]) { const indent = " ".repeat(spaces); diff --git a/packages/core/src/parser/clash-yaml.ts b/packages/core/src/parser/clash-yaml.ts index 26381e6..3d5245b 100644 --- a/packages/core/src/parser/clash-yaml.ts +++ b/packages/core/src/parser/clash-yaml.ts @@ -106,6 +106,53 @@ function repairRootFlowProxyListIndent(input: string): string { return lines.join("\n"); } +function repairInlineListIndent(input: string): string { + // 修复常见的“列表项首行内联 key(- name: xxx)但后续 key 又多缩进”的不合法 YAML。 + // 逐个列表项消费其后续块,确保每一行最多检查一次,避免大型订阅退化为 O(n²)。 + const lines = input.split(/\r?\n/); + const out: string[] = []; + + for (let index = 0; index < lines.length; ) { + const line = lines[index]; + const match = line.match(/^(\s*)-\s*(name\s*:\s*.+)$/); + if (!match) { + out.push(line); + index += 1; + continue; + } + + const dashIndent = match[1].length; + const keyIndent = dashIndent + 2; + let minIndent = Number.POSITIVE_INFINITY; + let blockEnd = index + 1; + + while (blockEnd < lines.length) { + const nextLine = lines[blockEnd]; + const trimmed = nextLine.trim(); + if (!trimmed || trimmed.startsWith("#")) { + blockEnd += 1; + continue; + } + + const indent = countLeadingSpaces(nextLine); + if (indent <= dashIndent) break; + minIndent = Math.min(minIndent, indent); + blockEnd += 1; + } + + if (minIndent !== Number.POSITIVE_INFINITY && minIndent > keyIndent) { + out.push(`${" ".repeat(dashIndent)}-`); + out.push(`${" ".repeat(minIndent)}${match[2]}`); + } else { + out.push(line); + } + out.push(...lines.slice(index + 1, blockEnd)); + index = blockEnd; + } + + return out.join("\n"); +} + /** * 解析 Clash YAML 配置 */ @@ -116,55 +163,6 @@ export function parseClashYaml(content: string): ParseResult { try { const normalizeTabs = (s: string) => s.replace(/\t/g, " "); - const repairInlineListIndent = (input: string): string => { - // 修复常见的“列表项首行内联 key(- name: xxx)但后续 key 又多缩进”的不合法 YAML - // 例如(不合法): - // - name: ss-A - // type: ss - // 修复为(合法): - // - - // name: ss-A - // type: ss - const lines = input.split(/\r?\n/); - const out: string[] = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const m = line.match(/^(\s*)-\s*(name\s*:\s*.+)$/); - if (!m) { - out.push(line); - continue; - } - - const dashIndent = m[1].length; - const namePair = m[2]; - const keyIndent = dashIndent + 2; - - // 计算该列表项后续块内最小缩进(用于对齐 name/type/server 等) - let minIndent = Number.POSITIVE_INFINITY; - for (let j = i + 1; j < lines.length; j++) { - const l = lines[j]; - if (!l.trim() || l.trim().startsWith("#")) continue; - - const indent = (l.match(/^(\s*)/)?.[1].length ?? 0); - // 缩进回退到当前列表项的 '-' 缩进(或更浅)时,说明已经离开该列表项 - if (indent <= dashIndent) break; - minIndent = Math.min(minIndent, indent); - } - - // 仅当后续 key 明显比内联 key 更深时才修复,避免误改合法 YAML - if (minIndent !== Number.POSITIVE_INFINITY && minIndent > keyIndent) { - out.push(`${" ".repeat(dashIndent)}-`); - out.push(`${" ".repeat(minIndent)}${namePair}`); - continue; - } - - out.push(line); - } - - return out.join("\n"); - }; - const tryLoad = (raw: string): unknown => yaml.load(raw) as unknown; const normalizedContent = normalizeClashYamlScalarText(normalizeTabs(content)); diff --git a/packages/core/src/parser/config-line-parser.test.ts b/packages/core/src/parser/config-line-parser.test.ts index c21ae05..b1cbded 100644 --- a/packages/core/src/parser/config-line-parser.test.ts +++ b/packages/core/src/parser/config-line-parser.test.ts @@ -36,6 +36,11 @@ describe("config line tokenizer helpers", () => { }); expect(() => tokenizeConfigLine("broken")).toThrow("无效的配置行格式"); expect(() => tokenizeConfigLine("Bad = ss, example.com, 70000")).toThrow("配置行中的地址或端口无效"); + for (const invalidPort of ["1.5", "0x50", "8e3", "+80", "-1"]) { + expect(() => tokenizeConfigLine(`Bad = ss, example.com, ${invalidPort}`), invalidPort).toThrow( + "配置行中的地址或端口无效" + ); + } expect(tokenizeConfigLine("Ignored = ss, ignored.example.com, 8388, =empty, flag")).toMatchObject({ params: {}, extras: ["flag"], diff --git a/packages/core/src/parser/config-line-tokenizer.ts b/packages/core/src/parser/config-line-tokenizer.ts index 4c95cc1..f177746 100644 --- a/packages/core/src/parser/config-line-tokenizer.ts +++ b/packages/core/src/parser/config-line-tokenizer.ts @@ -39,8 +39,9 @@ export function tokenizeConfigLine(line: string): { const type = tokens[0].toLowerCase(); const host = stripQuotes(tokens[1]); - const port = Number(tokens[2]); - if (!host || !Number.isFinite(port) || port < 1 || port > 65535) { + const portText = stripQuotes(tokens[2]); + const port = Number(portText); + if (!host || !/^\d+$/.test(portText) || !Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("配置行中的地址或端口无效"); } diff --git a/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts b/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts index 59f1228..e6708a4 100644 --- a/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts +++ b/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts @@ -34,6 +34,20 @@ describe("parsePlatformProxyLine", () => { }); }); + it("keeps ws=false disabled and accepts IP or single-label SNI values", () => { + const disabledWs = parsePlatformProxyLine( + "No WS = vmess, vmess.example.com, 443, username=11111111-1111-4111-8111-111111111111, ws=false, ws-path=/ignored, tls=true, sni=127.0.0.1" + ); + const localSni = parsePlatformProxyLine( + "Local SNI = anytls, anytls.example.com, 443, password=secret, sni=localhost" + ); + + expect(disabledWs).toMatchObject({ servername: "127.0.0.1" }); + expect(disabledWs).not.toHaveProperty("network"); + expect(disabledWs).not.toHaveProperty("ws-opts"); + expect(localSni).toMatchObject({ sni: "localhost" }); + }); + it("parses Surge WireGuard through its referenced section", () => { const sections = new Map([ [ diff --git a/packages/core/src/parser/platform/peggy/surge.js b/packages/core/src/parser/platform/peggy/surge.js index 2998758..5d426e8 100644 --- a/packages/core/src/parser/platform/peggy/surge.js +++ b/packages/core/src/parser/platform/peggy/surge.js @@ -191,7 +191,7 @@ username = & { password = comma match:[^,]+ { proxy.password = match.join("").replace(/^"(.*)"$/, '$1').replace(/^'(.*?)'$/, '$1'); } tls = comma "tls" equals flag:bool { proxy.tls = flag; } -sni = comma "sni" equals sni:("off"/domain) { +sni = comma "sni" equals sni:("off"/server) { if (sni === "off") { proxy["disable-sni"] = true; } else { @@ -214,7 +214,7 @@ method = comma "encrypt-method" equals cipher:cipher { } cipher = ("aes-128-cfb"/"aes-128-ctr"/"aes-128-gcm"/"aes-192-cfb"/"aes-192-ctr"/"aes-192-gcm"/"aes-256-cfb"/"aes-256-ctr"/"aes-256-gcm"/"bf-cfb"/"camellia-128-cfb"/"camellia-192-cfb"/"camellia-256-cfb"/"cast5-cfb"/"chacha20-ietf-poly1305"/"chacha20-ietf"/"chacha20-poly1305"/"chacha20"/"des-cfb"/"idea-cfb"/"none"/"rc2-cfb"/"rc4-md5"/"rc4"/"salsa20"/"seed-cfb"/"xchacha20-ietf-poly1305"/"2022-blake3-aes-128-gcm"/"2022-blake3-aes-256-gcm"); -ws = comma "ws" equals flag:bool { obfs.type = "ws"; } +ws = comma "ws" equals flag:bool { if (flag) obfs.type = "ws"; } ws_headers = comma "ws-headers" equals headers:$[^,]+ { const pairs = headers.split("|"); const result = {}; diff --git a/packages/core/src/parser/protocols/simple-proxy.test.ts b/packages/core/src/parser/protocols/simple-proxy.test.ts index 489e131..e1987ca 100644 --- a/packages/core/src/parser/protocols/simple-proxy.test.ts +++ b/packages/core/src/parser/protocols/simple-proxy.test.ts @@ -15,6 +15,7 @@ describe("simple proxy parsers", () => { const colonAuth = parseHttp("http://colon.example.com:8080:user:p%40ss#Colon"); const headerAlias = parseHttp("http://alias.example.com:8080?header=bad|:skip|X-Empty:&skip_cert_verify=no&peer=peer.example.com"); const ipv6 = parseHttp("http://[2001:db8::1]:8080#IPv6"); + const ipv6DefaultPort = parseHttp("http://[2001:db8::2]"); const tlsVerified = parseHttp("http://verified.example.com:8080?tls-verification=true#Verified"); expect(http).toMatchObject({ @@ -67,6 +68,10 @@ describe("simple proxy parsers", () => { server: "2001:db8::1", port: 8080, }); + expect(ipv6DefaultPort).toMatchObject({ + server: "2001:db8::2", + port: 80, + }); expect(tlsVerified).toMatchObject({ name: "Verified", "skip-cert-verify": false, diff --git a/packages/core/src/parser/protocols/simple-proxy.ts b/packages/core/src/parser/protocols/simple-proxy.ts index 0ed34e8..1977f09 100644 --- a/packages/core/src/parser/protocols/simple-proxy.ts +++ b/packages/core/src/parser/protocols/simple-proxy.ts @@ -42,7 +42,10 @@ function stripMetaSuffix(input: string): { value: string; name?: string } { } const bracket = takeTerminalEnclosedValue(remaining, "[", "]"); - if (bracket.enclosed !== undefined) { + const bracketIsIpv6Host = + bracket.enclosed?.includes(":") && + (remaining.startsWith("[") || /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^/?#@]+@)?\[[^\]]+\]$/.test(remaining)); + if (bracket.enclosed !== undefined && !bracketIsIpv6Host) { remaining = bracket.value; } @@ -531,4 +534,3 @@ export function parseTelegramProxyLink(uri: string): SocksNode | HttpNode { return buildNode(kind, { name, server, port, username, password }) as HttpNode; } - diff --git a/packages/core/src/parser/protocols/ssr-snell.test.ts b/packages/core/src/parser/protocols/ssr-snell.test.ts index 6515052..9b27b01 100644 --- a/packages/core/src/parser/protocols/ssr-snell.test.ts +++ b/packages/core/src/parser/protocols/ssr-snell.test.ts @@ -74,6 +74,19 @@ describe("SSR and Snell parsers", () => { }); }); + it("preserves literal plus and percent sequences after SSR base64 decoding", () => { + const literal = "A+B%20C"; + const node = parseSSR( + `ssr://${b64(`literal.example.com:8388:origin:aes-256-cfb:plain:${b64("secret")}/?remarks=${b64(literal)}&protoparam=${b64(literal)}&obfsparam=${b64(literal)}`)}` + ); + + expect(node).toMatchObject({ + name: literal, + "protocol-param": literal, + "obfs-param": literal, + }); + }); + it("keeps SSR validation errors explicit", () => { expect(() => parseSSR("http://bad")).toThrow("无效的 SSR 链接"); expect(() => parseSSR("ssr://")).toThrow("无效的 SSR 链接"); diff --git a/packages/core/src/parser/protocols/ssr.ts b/packages/core/src/parser/protocols/ssr.ts index 686db20..4e8f46a 100644 --- a/packages/core/src/parser/protocols/ssr.ts +++ b/packages/core/src/parser/protocols/ssr.ts @@ -98,7 +98,7 @@ export function parseSSR(uri: string): SSRNode { const params = parseSsrQuery(query); const remarks = safeDecodeSsrBase64(params.remarks || params.remark || ""); - const name = remarks ? safeDecodeFormUrlEncoded(remarks) : `SSR-${server}:${port}`; + const name = remarks || `SSR-${server}:${port}`; const node: SSRNode = { name, @@ -114,14 +114,13 @@ export function parseSSR(uri: string): SSRNode { const protoparam = safeDecodeSsrBase64(params.protoparam || params["protocol-param"] || ""); if (protoparam) { - node["protocol-param"] = safeDecodeFormUrlEncoded(protoparam); + node["protocol-param"] = protoparam; } const obfsparam = safeDecodeSsrBase64(params.obfsparam || params["obfs-param"] || ""); if (obfsparam) { - node["obfs-param"] = safeDecodeFormUrlEncoded(obfsparam); + node["obfs-param"] = obfsparam; } return node; } - diff --git a/packages/core/src/proxy-group-targets.test.ts b/packages/core/src/proxy-group-targets.test.ts index 27b51b3..8bf1c05 100644 --- a/packages/core/src/proxy-group-targets.test.ts +++ b/packages/core/src/proxy-group-targets.test.ts @@ -42,6 +42,9 @@ describe("proxy group target helpers", () => { expect(resolveProxyGroupTargetName({ kind: "custom", id: "media" }, options)).toBe("Media"); expect(resolveProxyGroupTargetName({ kind: "custom", id: "missing" }, options)).toBe("DIRECT"); expect(resolveProxyGroupTargetName({ kind: "module", id: "missing" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "toString" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "constructor" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "hasOwnProperty" }, options)).toBe("DIRECT"); expect(resolveProxyGroupTargetName({ kind: "node" } as never, options)).toBe("DIRECT"); expect(ruleTargetMatchesName(" Proxy ", "Proxy")).toBe(true); expect(ruleTargetMatchesName({ kind: "module", id: "auto" }, "Auto")).toBe(false); diff --git a/packages/core/src/proxy-group-targets.ts b/packages/core/src/proxy-group-targets.ts index 3ca3f6d..862f88c 100644 --- a/packages/core/src/proxy-group-targets.ts +++ b/packages/core/src/proxy-group-targets.ts @@ -55,7 +55,10 @@ export function resolveProxyGroupTargetName( if (!normalized) return options.fallbackTarget || ""; if (normalized.kind === "module") { - return options.moduleNames[normalized.id]?.trim() || options.fallbackTarget || ""; + const moduleName = Object.prototype.hasOwnProperty.call(options.moduleNames, normalized.id) + ? options.moduleNames[normalized.id] + : undefined; + return (typeof moduleName === "string" ? moduleName.trim() : "") || options.fallbackTarget || ""; } const customName = (options.customProxyGroups || []).find((group) => group.id === normalized.id)?.name?.trim(); diff --git a/packages/core/src/rules-database.test.ts b/packages/core/src/rules-database.test.ts index 35863be..976a3e7 100644 --- a/packages/core/src/rules-database.test.ts +++ b/packages/core/src/rules-database.test.ts @@ -5,12 +5,6 @@ import { GEOSITE_RULES, RULE_CATEGORIES, TOTAL_RULES_COUNT, - createCustomRule, - generateRuleUrl, - getRuleById, - getRulesByCategory, - isValidRuleUrl, - searchRules, } from "./rules-database"; describe("rules database index", () => { @@ -28,75 +22,4 @@ describe("rules database index", () => { format: "mrs", }); }); - - it("searches by id, English name, Chinese display name, and ignores empty input", () => { - expect(searchRules(" openai ")).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "openai", - category: "ai", - }), - ]) - ); - expect(searchRules("TELEGRAM")).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "telegram", - category: "social", - }), - ]) - ); - expect(searchRules("电报")).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "telegram", - }), - ]) - ); - expect(searchRules(" ")).toEqual([]); - }); - - it("finds rules by category and exact id", () => { - const aiRules = getRulesByCategory("ai"); - - expect(aiRules.length).toBeGreaterThan(0); - expect(aiRules.every((rule) => rule.category === "ai")).toBe(true); - expect(getRuleById("netflix")).toMatchObject({ - nameZh: "奈飞", - category: "media", - }); - expect(getRuleById("missing-rule")).toBeUndefined(); - }); - - it("generates and validates rule URLs for built-in and custom sources", () => { - expect(generateRuleUrl("openai")).toMatch(/\/geosite\/openai\.mrs$/); - expect(generateRuleUrl("telegram", "geoip")).toMatch(/\/geoip\/telegram\.mrs$/); - - expect( - createCustomRule("custom-domain", "custom-domain", "自定义域名", "domain") - ).toMatchObject({ - id: "custom-domain", - category: "other", - behavior: "domain", - url: expect.stringMatching(/\/geosite\/custom-domain\.mrs$/), - }); - expect( - createCustomRule( - "custom-ip", - "custom-ip", - "自定义 IP", - "ipcidr", - "https://raw.githubusercontent.com/example/rules/custom-ip.mrs" - ) - ).toMatchObject({ - id: "custom-ip", - behavior: "ipcidr", - url: "https://raw.githubusercontent.com/example/rules/custom-ip.mrs", - }); - - expect(isValidRuleUrl(generateRuleUrl("openai"))).toBe(true); - expect(isValidRuleUrl("https://raw.githubusercontent.com/example/rules/list.mrs")).toBe(true); - expect(isValidRuleUrl("https://cdn.jsdelivr.net/gh/example/rules/list.mrs")).toBe(true); - expect(isValidRuleUrl("https://example.com/list.mrs")).toBe(false); - }); }); diff --git a/packages/core/src/rules-database.ts b/packages/core/src/rules-database.ts index 0d89898..7dc0ee7 100644 --- a/packages/core/src/rules-database.ts +++ b/packages/core/src/rules-database.ts @@ -397,72 +397,5 @@ export const GEOIP_RULES: RuleSetInfo[] = GEOIP_RULES_RAW; // 合并所有规则 export const ALL_RULES: RuleSetInfo[] = [...GEOSITE_RULES, ...GEOIP_RULES]; -/** - * 搜索规则集(支持中英文) - */ -export function searchRules(keyword: string): RuleSetInfo[] { - const lowerKeyword = keyword.toLowerCase().trim(); - if (!lowerKeyword) return []; - - return ALL_RULES.filter( - (rule) => - rule.id.toLowerCase().includes(lowerKeyword) || - rule.name.toLowerCase().includes(lowerKeyword) || - rule.nameZh.toLowerCase().includes(keyword.toLowerCase()) - ); -} - -/** - * 按分类获取规则 - */ -export function getRulesByCategory(category: RuleSetInfo["category"]): RuleSetInfo[] { - return ALL_RULES.filter((rule) => rule.category === category); -} - -/** - * 获取规则详情 - */ -export function getRuleById(id: string): RuleSetInfo | undefined { - return ALL_RULES.find((rule) => rule.id === id); -} - -/** - * 根据规则名称生成 URL(用于用户输入自定义规则名) - */ -export function generateRuleUrl(ruleName: string, type: "geosite" | "geoip" = "geosite"): string { - return `${BASE_URL}/${type}/${ruleName}.mrs`; -} - -/** - * 创建自定义规则 - */ -export function createCustomRule( - id: string, - name: string, - nameZh: string, - behavior: "domain" | "ipcidr", - url?: string -): RuleSetInfo { - const type = behavior === "ipcidr" ? "geoip" : "geosite"; - return { - id, - name, - nameZh, - category: "other", - behavior, - format: "mrs", - url: url || generateRuleUrl(name, type), - }; -} - -/** - * 验证规则 URL 是否有效 - */ -export function isValidRuleUrl(url: string): boolean { - return url.startsWith(BASE_URL) || - url.startsWith("https://raw.githubusercontent.com/") || - url.startsWith("https://cdn.jsdelivr.net/"); -} - // 导出规则总数 export const TOTAL_RULES_COUNT = ALL_RULES.length; diff --git a/packages/core/src/rules/custom-rule-helpers.test.ts b/packages/core/src/rules/custom-rule-helpers.test.ts index 76a626a..c4fbce6 100644 --- a/packages/core/src/rules/custom-rule-helpers.test.ts +++ b/packages/core/src/rules/custom-rule-helpers.test.ts @@ -15,7 +15,6 @@ import { getCustomRuleOrderKey, isCustomRuleType, listEditableRuleOrderKeys, - reconcileRuleOrder, } from "./custom-rule-utils"; import type { CustomProxyGroup, CustomRule, CustomRuleSet } from "@subboost/core/types/config"; @@ -29,7 +28,7 @@ describe("custom routing rule set helpers", () => { expect(parseRuleSetTargetValue("other:select")).toBeNull(); expect(extractRuleSetPathFromUrl("https://cdn.example/rules/geosite/openai.mrs?token=1")).toBe( - "geosite/openai.mrs" + "https://cdn.example/rules/geosite/openai.mrs?token=1" ); expect(extractRuleSetPathFromUrl("plain/rule.txt")).toBe("plain/rule.txt"); expect(normalizeRuleSetPathInput(" /geoip/cn.mrs ")).toBe("geoip/cn.mrs"); @@ -98,7 +97,7 @@ describe("custom routing rule set helpers", () => { source: { kind: "custom-rule-set", id: "custom-rule" }, id: "custom-rule", name: "custom-rule", - path: "geosite/custom.mrs", + path: "https://cdn.example/geosite/custom.mrs", target: expect.objectContaining({ id: "custom-a", value: "custom:custom-a" }), noResolve: true, }), @@ -153,15 +152,6 @@ describe("custom rule id and order helpers", () => { `custom-rule:${ruleWithoutId.id}`, "custom-rule-set:rule-a", ]); - expect(reconcileRuleOrder(undefined, [], [])).toEqual([]); - expect( - reconcileRuleOrder( - [" missing ", "custom-rule-set:rule-a", "custom-rule-set:rule-a"], - customRules, - customRuleSets - ) - ).toEqual(["custom-rule-set:rule-a", `custom-rule:${ruleWithoutId.id}`]); - expect(reconcileRuleOrder("bad" as never, customRules, [])).toEqual([`custom-rule:${ruleWithoutId.id}`]); }); }); diff --git a/packages/core/src/rules/custom-rule-utils.ts b/packages/core/src/rules/custom-rule-utils.ts index ad17ddd..0dc3527 100644 --- a/packages/core/src/rules/custom-rule-utils.ts +++ b/packages/core/src/rules/custom-rule-utils.ts @@ -93,33 +93,3 @@ export function listEditableRuleOrderKeys(customRules: CustomRule[], customRuleS } return keys; } - -export function reconcileRuleOrder( - ruleOrder: string[] | undefined, - customRules: CustomRule[], - customRuleSets: CustomRuleSet[] = [] -): string[] { - const available = listEditableRuleOrderKeys(customRules, customRuleSets); - if (available.length === 0) return []; - - const availableSet = new Set(available); - const next: string[] = []; - const seen = new Set(); - - if (Array.isArray(ruleOrder)) { - for (const rawKey of ruleOrder) { - const key = toTrimmedString(rawKey); - if (!key || seen.has(key) || !availableSet.has(key)) continue; - seen.add(key); - next.push(key); - } - } - - for (const key of available) { - if (seen.has(key)) continue; - seen.add(key); - next.push(key); - } - - return next; -} diff --git a/packages/core/src/rules/rule-model.test.ts b/packages/core/src/rules/rule-model.test.ts index c625390..327370b 100644 --- a/packages/core/src/rules/rule-model.test.ts +++ b/packages/core/src/rules/rule-model.test.ts @@ -10,13 +10,16 @@ import { describe("rule model normalization", () => { it("normalizes rule set paths, URLs, and builtin edits", () => { - expect(extractRuleSetPathFromUrl(" https://example.com/meta/geosite/google.mrs?download=1 ")).toBe("geosite/google.mrs"); + expect(extractRuleSetPathFromUrl(" https://example.com/meta/geosite/google.mrs?download=1 ")).toBe( + "https://example.com/meta/geosite/google.mrs?download=1" + ); expect(extractRuleSetPathFromUrl("custom/list.txt")).toBe("custom/list.txt"); expect(normalizeRuleSetPathInput("////geoip/private.mrs")).toBe("geoip/private.mrs"); expect(isValidRuleSetPathOrUrl("geosite/google.mrs")).toBe(true); expect(isValidRuleSetPathOrUrl("https://example.com/rules/list.txt")).toBe(true); expect(isValidRuleSetPathOrUrl("plain.txt")).toBe(false); expect(buildRuleSetUrlFromPath("https://cdn.example.com/custom.mrs", "https://base.example.com/")).toBe("https://cdn.example.com/custom.mrs"); + expect(normalizeRuleSetPathInput("geosite/google.mrs.bak")).toBe("geosite/google.mrs.bak"); expect(buildRuleSetUrlFromPath("/geosite/google.mrs", "https://base.example.com////")).toBe("https://base.example.com/geosite/google.mrs"); expect(normalizeBuiltinRuleEdits(null)).toEqual({}); expect( diff --git a/packages/core/src/rules/rule-model.ts b/packages/core/src/rules/rule-model.ts index 399c74a..91a65a3 100644 --- a/packages/core/src/rules/rule-model.ts +++ b/packages/core/src/rules/rule-model.ts @@ -33,7 +33,8 @@ function trimTrailingSlashes(value: string): string { export function extractRuleSetPathFromUrl(url: string): string { const trimmed = url.trim(); - const match = trimmed.match(/(?:^|\/)(geosite|geoip)\/[^/?#\s]+\.mrs/i); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + const match = trimmed.match(/^(?:\/+)?(geosite|geoip)\/[^/?#\s]+\.mrs$/i); if (!match) return trimmed; return trimLeadingSlashes(match[0]); } diff --git a/packages/core/src/rules/rules-utils.test.ts b/packages/core/src/rules/rules-utils.test.ts index 895b4ed..01efd23 100644 --- a/packages/core/src/rules/rules-utils.test.ts +++ b/packages/core/src/rules/rules-utils.test.ts @@ -14,7 +14,6 @@ import { getCustomRuleOrderKey, isCustomRuleType, listEditableRuleOrderKeys, - reconcileRuleOrder, } from "./custom-rule-utils"; describe("custom rule helpers", () => { @@ -53,24 +52,6 @@ describe("custom rule helpers", () => { "custom-rule:custom-rule-domain-suffix-example-com-direct-3", "custom-rule-set:nested", ]); - expect( - reconcileRuleOrder( - ["missing", "custom-rule-set:nested", "custom-rule-set:nested"], - [rule], - [ - { - id: "nested", - name: "Nested", - behavior: "domain", - path: "https://rules.example.com/a.mrs", - target: "Group", - }, - ] - ) - ).toEqual([ - "custom-rule-set:nested", - "custom-rule:custom-rule-domain-suffix-example-com-direct-3", - ]); }); it("previews batch custom rule imports with ready, skipped, error, and duplicate rows", () => { diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index 09d4517..9e64c25 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -19,6 +19,7 @@ import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; +import { migrateLegacyConfig } from "@subboost/core/migrations/legacy-config"; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -235,7 +236,7 @@ export function buildGenerateOptionsFromConfig( proxyProviders?: Record; } ): GenerateOptions { - const config = rawConfig; + const config = migrateLegacyConfig(rawConfig); const { testUrl, testInterval } = getEffectiveTestOptions(config); const proxyProviders = opts.proxyProviders ?? buildProxyProvidersFromConfig(config, { testUrl, testInterval }); diff --git a/packages/core/src/subscription/import-error.test.ts b/packages/core/src/subscription/import-error.test.ts index afab71d..e45928d 100644 --- a/packages/core/src/subscription/import-error.test.ts +++ b/packages/core/src/subscription/import-error.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { SubscriptionImportError, createSubscriptionImportErrorInfo, + extractHttpStatus, getSubscriptionImportErrorBadgeText, getSubscriptionImportErrorCategoryLabel, inferSubscriptionImportErrorCategory, @@ -11,6 +12,16 @@ import { sanitizePublicErrorText, } from "./import-error"; +describe("extractHttpStatus", () => { + it("only extracts explicit HTTP error status context", () => { + expect(extractHttpStatus("upstream HTTP 503 then 404")).toBe(503); + expect(extractHttpStatus("request failed with status code 429")).toBe(429); + expect(extractHttpStatus("upstream returned 502")).toBe(502); + expect(extractHttpStatus("成功解析 502 个节点")).toBeNull(); + expect(extractHttpStatus("HTTP 200")).toBeNull(); + }); +}); + describe("sanitizePublicErrorText", () => { it("does not mistake protocol URIs for Windows file paths", () => { const protocols = [ diff --git a/packages/core/src/subscription/import-error.ts b/packages/core/src/subscription/import-error.ts index 2bf513f..44e6dfa 100644 --- a/packages/core/src/subscription/import-error.ts +++ b/packages/core/src/subscription/import-error.ts @@ -71,13 +71,13 @@ const NETWORK_CODE_BADGE: Record = { EAI_AGAIN: "DNS", }; -function extractHttpStatusFromText(text: string): number | null { - const match = text.match(/\b(4\d{2}|5\d{2})\b/); - if (match) { - const code = parseInt(match[1], 10); - if (code >= 400 && code < 600) return code; - } - return null; +export function extractHttpStatus(text: string): number | null { + const match = text.match( + /\b(?:HTTP(?:\/\d(?:\.\d)?)?|status(?:\s+code)?|returned|responded(?:\s+with)?)\s*[:=]?\s*(\d{3})\b/i + ); + if (!match) return null; + const code = Number.parseInt(match[1], 10); + return code >= 400 && code < 600 ? code : null; } function extractNetworkCodeFromText(text: string): string | null { @@ -166,7 +166,7 @@ export function getSubscriptionImportErrorBadgeText( if (info.httpStatus && HTTP_STATUS_BADGE[info.httpStatus]) { return HTTP_STATUS_BADGE[info.httpStatus]; } - const httpFromText = extractHttpStatusFromText(info.message) ?? extractHttpStatusFromText(info.detail || ""); + const httpFromText = extractHttpStatus(info.message) ?? extractHttpStatus(info.detail || ""); if (httpFromText && HTTP_STATUS_BADGE[httpFromText]) { return HTTP_STATUS_BADGE[httpFromText]; } diff --git a/packages/core/src/subscription/subscription-userinfo.test.ts b/packages/core/src/subscription/subscription-userinfo.test.ts index 6c881cc..23b66a8 100644 --- a/packages/core/src/subscription/subscription-userinfo.test.ts +++ b/packages/core/src/subscription/subscription-userinfo.test.ts @@ -36,6 +36,12 @@ describe("subscription user info helpers", () => { }); }); + it("normalizes millisecond expiry values and rejects implausible magnitudes", () => { + expect(parseSubscriptionUserInfo("expire=1760000000000").expire).toBe(1760000000); + expect(parseSubscriptionUserInfo("expire=9999999999999999").expire).toBeUndefined(); + expect(normalizeSubscriptionUserInfo({ expire: 1760000000000 }).expire).toBe(1760000000); + }); + it("normalizes plausibility and merges traffic snapshots", () => { expect(hasSubscriptionUserInfo(null)).toBe(false); expect(hasSubscriptionUserInfo(undefined)).toBe(false); diff --git a/packages/core/src/subscription/subscription-userinfo.ts b/packages/core/src/subscription/subscription-userinfo.ts index d668e27..35f9376 100644 --- a/packages/core/src/subscription/subscription-userinfo.ts +++ b/packages/core/src/subscription/subscription-userinfo.ts @@ -17,7 +17,8 @@ export function parseSubscriptionUserInfo(header: string): SubscriptionUserInfo if (key && value) { const numeric = parseHeaderNumericValue(value); if (numeric !== undefined) { - info[key] = numeric; + const normalizedNumeric = key === "expire" ? normalizeExpireTimestampSeconds(numeric) : numeric; + if (normalizedNumeric !== undefined) info[key] = normalizedNumeric; continue; } @@ -72,9 +73,23 @@ function isFiniteNonNegativeNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } +const MIN_EXPIRE_TIMESTAMP_SECONDS = 946684800; // 2000-01-01 +const MAX_EXPIRE_TIMESTAMP_SECONDS = 253402300799; // 9999-12-31T23:59:59Z +const MIN_EXPIRE_TIMESTAMP_MILLISECONDS = MIN_EXPIRE_TIMESTAMP_SECONDS * 1000; +const MAX_EXPIRE_TIMESTAMP_MILLISECONDS = MAX_EXPIRE_TIMESTAMP_SECONDS * 1000; + +function normalizeExpireTimestampSeconds(value: unknown): number | undefined { + if (!isFiniteNonNegativeNumber(value)) return undefined; + const integer = Math.floor(value); + if (integer > MIN_EXPIRE_TIMESTAMP_SECONDS && integer <= MAX_EXPIRE_TIMESTAMP_SECONDS) return integer; + if (integer > MIN_EXPIRE_TIMESTAMP_MILLISECONDS && integer <= MAX_EXPIRE_TIMESTAMP_MILLISECONDS) { + return Math.floor(integer / 1000); + } + return undefined; +} + function isValidExpireTimestampSeconds(value: unknown): value is number { - // expire 通常为秒级 Unix 时间戳;过滤掉 0/1970 等无意义值 - return isFiniteNonNegativeNumber(value) && value > 946684800; // 2000-01-01 + return typeof value === "number" && normalizeExpireTimestampSeconds(value) === value; } function hasMeaningfulTrafficSnapshot(info: SubscriptionUserInfo): boolean { @@ -257,7 +272,8 @@ export function normalizeSubscriptionUserInfo(info: SubscriptionUserInfo | null if (isFiniteNonNegativeNumber(info.upload)) out.upload = info.upload; if (isFiniteNonNegativeNumber(info.download)) out.download = info.download; if (isFiniteNonNegativeNumber(info.total)) out.total = info.total; - if (isValidExpireTimestampSeconds(info.expire)) out.expire = info.expire; + const expire = normalizeExpireTimestampSeconds(info.expire); + if (expire !== undefined) out.expire = expire; return out; } diff --git a/packages/core/src/templates/config-template.fields.test.ts b/packages/core/src/templates/config-template.fields.test.ts index a81947b..a7d8618 100644 --- a/packages/core/src/templates/config-template.fields.test.ts +++ b/packages/core/src/templates/config-template.fields.test.ts @@ -254,7 +254,7 @@ describe("validateSubBoostTemplateConfig field validation", () => { "module:cn:geolocation-cn": { target: 1 }, } as never, }, - "builtinRuleEdits.target 必须是字符串" + "builtinRuleEdits.target 必须是字符串或有效代理组引用" ); expectInvalid( { diff --git a/packages/core/src/templates/config-template.groups.test.ts b/packages/core/src/templates/config-template.groups.test.ts index b581430..8292a7e 100644 --- a/packages/core/src/templates/config-template.groups.test.ts +++ b/packages/core/src/templates/config-template.groups.test.ts @@ -188,8 +188,8 @@ describe("validateSubBoostTemplateConfig custom groups", () => { "customProxyGroups.includeInGroupMembers 必须是布尔值" ); - expectInvalid( - { + const migratedLegacyGroup = validateSubBoostTemplateConfig( + validConfig({ customProxyGroups: [ { id: "legacy", @@ -199,9 +199,12 @@ describe("validateSubBoostTemplateConfig custom groups", () => { rules: [], } as never, ], - }, - "模板配置包含已移除字段: customProxyGroups[0].rules" + }) ); + expect(migratedLegacyGroup.ok).toBe(true); + if (migratedLegacyGroup.ok) { + expect(migratedLegacyGroup.config.customProxyGroups[0]).not.toHaveProperty("rules"); + } const validCustomGroup = validateSubBoostTemplateConfig( validConfig({ @@ -253,6 +256,12 @@ describe("validateSubBoostTemplateConfig custom groups", () => { } const removedField = `filtered${"ProxyGroups"}`; - expectInvalid({ [removedField]: [] } as never, `模板配置包含已移除字段: ${removedField}`); + const migratedEmptyFilteredGroups = validateSubBoostTemplateConfig( + validConfig({ [removedField]: [] } as never) + ); + expect(migratedEmptyFilteredGroups.ok).toBe(true); + if (migratedEmptyFilteredGroups.ok) { + expect(migratedEmptyFilteredGroups.config).not.toHaveProperty(removedField); + } }); }); diff --git a/packages/core/src/templates/config-template.test.ts b/packages/core/src/templates/config-template.test.ts index 14bfc77..8231e27 100644 --- a/packages/core/src/templates/config-template.test.ts +++ b/packages/core/src/templates/config-template.test.ts @@ -59,6 +59,7 @@ describe("validateSubBoostTemplateConfig", () => { id: "custom", name: "Custom", emoji: "", + enabled: false, memberSource: "filtered-nodes", includeInGroupMembers: false, groupType: "load-balance", @@ -70,7 +71,7 @@ describe("validateSubBoostTemplateConfig", () => { name: "Custom Rule", behavior: "domain", path: "https://rules.example.com/custom.mrs", - target: "Custom", + target: { kind: "custom", id: "custom" }, noResolve: false, }, { @@ -87,7 +88,7 @@ describe("validateSubBoostTemplateConfig", () => { id: "custom-rule-a", type: "DOMAIN-SUFFIX", value: " example.com ", - target: "DIRECT", + target: { kind: "module", id: "select" }, noResolve: true, }, ], @@ -102,7 +103,7 @@ describe("validateSubBoostTemplateConfig", () => { }, ], builtinRuleEdits: { - [`module:${moduleId}:openai`]: { enabled: false }, + [`module:${moduleId}:openai`]: { enabled: false, target: { kind: "custom", id: "custom" } }, }, proxyGroupNameOverrides: { [moduleId]: " Renamed ", @@ -120,6 +121,7 @@ describe("validateSubBoostTemplateConfig", () => { emoji: "", memberSource: "filtered-nodes", includeInGroupMembers: false, + enabled: false, groupType: "load-balance", strategy: DEFAULT_LOAD_BALANCE_STRATEGY, }); @@ -130,7 +132,7 @@ describe("validateSubBoostTemplateConfig", () => { name: "Custom Rule", behavior: "domain", path: "https://rules.example.com/custom.mrs", - target: "Custom", + target: { kind: "custom", id: "custom" }, }), expect.objectContaining({ id: "extra", @@ -145,6 +147,7 @@ describe("validateSubBoostTemplateConfig", () => { id: "custom-rule-a", value: "example.com", noResolve: true, + target: { kind: "module", id: "select" }, }); expect(result.config.dialerProxyGroups[0]).toMatchObject({ id: "relay", @@ -153,7 +156,10 @@ describe("validateSubBoostTemplateConfig", () => { targetNodes: ["Target A"], enabled: false, }); - expect(result.config.builtinRuleEdits?.[`module:${moduleId}:openai`]).toEqual({ enabled: false }); + expect(result.config.builtinRuleEdits?.[`module:${moduleId}:openai`]).toEqual({ + enabled: false, + target: { kind: "custom", id: "custom" }, + }); expect(result.config.proxyGroupNameOverrides).toEqual({ [moduleId]: "Renamed" }); expect(result.config).not.toHaveProperty("moduleRuleOverrides"); expect(result.config).not.toHaveProperty("moduleRuleExclusions"); @@ -190,19 +196,40 @@ describe("validateSubBoostTemplateConfig", () => { expect(result.config).not.toHaveProperty("experimentalCnUseCnRuleSet"); }); - it("rejects removed rule-model compatibility fields", () => { - expectInvalid({ moduleRuleOverrides: {} } as never, "模板配置包含已移除字段: moduleRuleOverrides"); - expectInvalid({ moduleRuleExclusions: {} } as never, "模板配置包含已移除字段: moduleRuleExclusions"); - expectInvalid({ allRulesOrderEditingEnabled: true } as never, "模板配置包含已移除字段: allRulesOrderEditingEnabled"); - const removedFilteredGroupsField = `filtered${"ProxyGroups"}`; - expectInvalid( - { [removedFilteredGroupsField]: [] } as never, - `模板配置包含已移除字段: ${removedFilteredGroupsField}` - ); - expectInvalid( - { customProxyGroups: [{ id: "custom", name: "Custom", emoji: "", groupType: "select", rules: [] }] } as never, - "模板配置包含已移除字段: customProxyGroups[0].rules" + it("migrates removed rule-model compatibility fields at the template boundary", () => { + const result = validateSubBoostTemplateConfig( + validConfig({ + moduleRuleExclusions: { ai: ["openai"] }, + allRulesOrderEditingEnabled: true, + customProxyGroups: [ + { + id: "custom", + name: "Custom", + emoji: "", + groupType: "select", + rules: [ + { + id: "legacy-provider", + name: "Legacy Provider", + path: "geosite/legacy.mrs", + behavior: "domain", + }, + ], + }, + ], + } as never) ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.config.builtinRuleEdits?.["module:ai:openai"]).toEqual({ enabled: false }); + expect(result.config.customRuleSets).toEqual([ + expect.objectContaining({ + id: "legacy-provider", + target: { kind: "custom", id: "custom" }, + }), + ]); + expect(result.config.customProxyGroups[0]).not.toHaveProperty("rules"); }); it("rejects template configs that hide every enabled module", () => { diff --git a/packages/core/src/templates/config-template.ts b/packages/core/src/templates/config-template.ts index 0af2254..471597a 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -3,6 +3,8 @@ import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { ensureCustomRuleId, isCustomRuleType } from "@subboost/core/rules/custom-rule-utils"; import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; +import { migrateLegacyConfig } from "@subboost/core/migrations/legacy-config"; import { isValidRuleSetPathOrUrl, normalizeRuleModelFromConfig, @@ -15,6 +17,7 @@ import { type CustomProxyGroup, type CustomRule, type LoadBalanceStrategy, + type ProxyGroupRuleTarget, type ProxyGroupGroupType, type TemplateType, } from "@subboost/core/types/config"; @@ -30,20 +33,13 @@ const BUILTIN_MODULE_IDS = new Set(PROXY_GROUP_MODULES.map((module) => module.id const BUILTIN_RULE_KEYS = new Set( PROXY_GROUP_MODULES.flatMap((module) => module.rules.map((rule) => `module:${module.id}:${rule.id}`)) ); -const REMOVED_TEMPLATE_FIELDS = new Set([ - "moduleRuleOverrides", - "moduleRuleExclusions", - "allRulesOrderEditingEnabled", - "filteredProxyGroups", -]); - -export function validateSubBoostTemplateConfig(value: unknown): ValidationResult { - if (!isRecord(value)) return invalid("模板配置必须是对象"); +export function validateSubBoostTemplateConfig(rawValue: unknown): ValidationResult { + const migratedValue = migrateLegacyConfig(rawValue); + if (!isRecord(migratedValue)) return invalid("模板配置必须是对象"); + const value = migratedValue; if (value.schema !== SUBBOOST_TEMPLATE_CONFIG_SCHEMA) { return invalid("模板配置 schema 无效"); } - const removedField = findRemovedTemplateField(value); - if (removedField) return invalid(`模板配置包含已移除字段: ${removedField}`); const template = parseTemplateType(value.template); if (!template) return invalid("模板类型无效"); @@ -155,20 +151,6 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function findRemovedTemplateField(value: Record): string | null { - for (const field of REMOVED_TEMPLATE_FIELDS) { - if (field in value) return field; - } - - if (!Array.isArray(value.customProxyGroups)) return null; - for (let index = 0; index < value.customProxyGroups.length; index += 1) { - const group = value.customProxyGroups[index]; - if (isRecord(group) && "rules" in group) return `customProxyGroups[${index}].rules`; - } - - return null; -} - function parseTemplateType(value: unknown): TemplateType | null { if (value === "minimal" || value === "standard" || value === "full") return value; return null; @@ -185,6 +167,16 @@ function parseRequiredString( return { ok: true, value: opts.allowEmpty ? value : trimmed }; } +function parseRuleTarget( + value: unknown, + field: string +): { ok: true; value: ProxyGroupRuleTarget } | { ok: false; error: string } { + const ref = normalizeProxyGroupTargetRef(value); + if (ref) return { ok: true, value: ref }; + if (typeof value === "string") return parseRequiredString(value, field); + return invalid(`${field} 必须是字符串或有效代理组引用`); +} + function parseBoolean(value: unknown, field: string): { ok: true; value: boolean } | { ok: false; error: string } { if (typeof value !== "boolean") return invalid(`${field} 必须是布尔值`); return { ok: true, value }; @@ -289,7 +281,7 @@ function parseCustomRules(value: unknown): { ok: true; value: CustomRule[] } | { if (typeof item.type !== "string" || !isCustomRuleType(item.type)) return invalid("customRules 包含无效类型"); const ruleValue = parseRequiredString(item.value, "customRules.value"); if (!ruleValue.ok) return ruleValue; - const target = parseRequiredString(item.target, "customRules.target"); + const target = parseRuleTarget(item.target, "customRules.target"); if (!target.ok) return target; const noResolve = parseOptionalBoolean(item.noResolve, "customRules.noResolve"); if (!noResolve.ok) return noResolve; @@ -324,6 +316,8 @@ function parseCustomProxyGroups(value: unknown): { ok: true; value: CustomProxyG if (!groupType.ok) return groupType; const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "customProxyGroups.strategy"); if (!strategy.ok) return strategy; + const enabled = parseOptionalBoolean(item.enabled, "customProxyGroups.enabled"); + if (!enabled.ok) return enabled; if (item.memberSource !== undefined && item.memberSource !== "filtered-nodes") { return invalid("customProxyGroups.memberSource 无效"); } @@ -335,6 +329,7 @@ function parseCustomProxyGroups(value: unknown): { ok: true; value: CustomProxyG id: id.value, name: name.value, emoji: emoji.value, + ...(enabled.value !== undefined ? { enabled: enabled.value } : {}), ...(description ? { description } : {}), ...(item.memberSource === "filtered-nodes" ? { memberSource: "filtered-nodes" as const } : {}), ...(typeof item.includeInGroupMembers === "boolean" @@ -378,7 +373,7 @@ function parseCustomRuleSets(value: unknown): { ok: true; value: true } | { ok: if (!path.ok) return path; const normalizedPath = normalizeRuleSetPathInput(path.value); if (!isValidRuleSetPathOrUrl(normalizedPath)) return invalid("customRuleSets.path 无效"); - const target = parseRequiredString(item.target, "customRuleSets.target"); + const target = parseRuleTarget(item.target, "customRuleSets.target"); if (!target.ok) return target; const noResolve = parseOptionalBoolean(item.noResolve, "customRuleSets.noResolve"); if (!noResolve.ok) return noResolve; @@ -393,8 +388,9 @@ function parseBuiltinRuleEdits(value: unknown): { ok: true; value: true } | { ok const key = rawKey.trim(); if (!BUILTIN_RULE_KEYS.has(key)) return invalid("builtinRuleEdits 包含未知内置规则"); if (!isRecord(rawEdit)) return invalid("builtinRuleEdits 的值必须是对象"); - if ("target" in rawEdit && typeof rawEdit.target !== "string") { - return invalid("builtinRuleEdits.target 必须是字符串"); + if ("target" in rawEdit) { + const target = parseRuleTarget(rawEdit.target, "builtinRuleEdits.target"); + if (!target.ok) return target; } if ("enabled" in rawEdit && rawEdit.enabled !== false) { return invalid("builtinRuleEdits.enabled 只能是 false"); diff --git a/packages/server-core/src/cron-auth.test.ts b/packages/server-core/src/cron-auth.test.ts index ef22541..6ede0c2 100644 --- a/packages/server-core/src/cron-auth.test.ts +++ b/packages/server-core/src/cron-auth.test.ts @@ -19,6 +19,10 @@ describe("server-core cron auth helpers", () => { ok: false, reason: "unauthorized", }); + expect(validateCronSecret({ cronSecret: "a-much-longer-secret", authorization: "Bearer short" })).toEqual({ + ok: false, + reason: "unauthorized", + }); expect(validateCronSecret({ cronSecret: "secret", authorization: "bearer secret " })).toEqual({ ok: true, }); diff --git a/packages/server-core/src/cron-auth.ts b/packages/server-core/src/cron-auth.ts index 86a0478..19ed5a8 100644 --- a/packages/server-core/src/cron-auth.ts +++ b/packages/server-core/src/cron-auth.ts @@ -1,3 +1,5 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + export type CronSecretValidationResult = | { ok: true } | { ok: false; reason: "missing-secret" | "unauthorized" }; @@ -13,7 +15,9 @@ export function validateCronSecret(params: { authorization: string | null | undefined; }): CronSecretValidationResult { if (!params.cronSecret) return { ok: false, reason: "missing-secret" }; - if (extractBearerToken(params.authorization) !== params.cronSecret) { + const suppliedDigest = createHash("sha256").update(extractBearerToken(params.authorization)).digest(); + const expectedDigest = createHash("sha256").update(params.cronSecret).digest(); + if (!timingSafeEqual(suppliedDigest, expectedDigest)) { return { ok: false, reason: "unauthorized" }; } return { ok: true }; diff --git a/packages/server-core/src/crypto/encrypted-field.test.ts b/packages/server-core/src/crypto/encrypted-field.test.ts index 2ba4176..d0000ad 100644 --- a/packages/server-core/src/crypto/encrypted-field.test.ts +++ b/packages/server-core/src/crypto/encrypted-field.test.ts @@ -24,4 +24,32 @@ describe("encrypted field crypto", () => { expect(() => decryptEncryptedFieldV2(oldShapeCiphertext, masterKey)).toThrow("Invalid ciphertext v2 format"); }); + it("round-trips an empty plaintext", () => { + const ciphertext = encryptEncryptedFieldV2("", masterKey); + + expect(ciphertext.endsWith(":")).toBe(true); + expect(decryptEncryptedFieldV2(ciphertext, masterKey)).toBe(""); + }); + + it("rejects missing segments and invalid hex encodings", () => { + expect(() => + decryptEncryptedFieldV2( + "v2:00112233445566778899aabb:00112233445566778899aabbccddeeff", + masterKey + ) + ).toThrow("Invalid ciphertext v2 format"); + expect(() => + decryptEncryptedFieldV2( + "v2:zz112233445566778899aabb:00112233445566778899aabbccddeeff:", + masterKey + ) + ).toThrow("Invalid ciphertext v2 encoding"); + expect(() => + decryptEncryptedFieldV2( + "v2:00112233445566778899aabb:00112233445566778899aabbccddeeff:f", + masterKey + ) + ).toThrow("Invalid ciphertext v2 encoding"); + }); + }); diff --git a/packages/server-core/src/crypto/encrypted-field.ts b/packages/server-core/src/crypto/encrypted-field.ts index 7821b9c..83dc9b8 100644 --- a/packages/server-core/src/crypto/encrypted-field.ts +++ b/packages/server-core/src/crypto/encrypted-field.ts @@ -40,10 +40,18 @@ export function decryptEncryptedFieldV2(ciphertext: string, masterKey: string): const parts = ciphertext.split(":"); const [prefix, ivHex, authTagHex, encrypted] = parts; - if (prefix !== V2_PREFIX || !ivHex || !authTagHex || !encrypted || parts.length !== 4) { + if (prefix !== V2_PREFIX || !ivHex || !authTagHex || encrypted === undefined || parts.length !== 4) { throw new Error("Invalid ciphertext v2 format"); } + if ( + !/^[0-9a-f]{24}$/i.test(ivHex) + || !/^[0-9a-f]{32}$/i.test(authTagHex) + || !/^(?:[0-9a-f]{2})*$/i.test(encrypted) + ) { + throw new Error("Invalid ciphertext v2 encoding"); + } + const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); if (iv.length !== IV_LENGTH || authTag.length !== TAG_LENGTH) { diff --git a/packages/server-core/src/subscription/auto-update-failure.ts b/packages/server-core/src/subscription/auto-update-failure.ts index 24e7ad2..bdb6b5a 100644 --- a/packages/server-core/src/subscription/auto-update-failure.ts +++ b/packages/server-core/src/subscription/auto-update-failure.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { safeParseJsonObject } from "@subboost/core/json"; +import { extractHttpStatus } from "@subboost/core/subscription/import-error"; // Product-neutral failure policy shared by server adapters. export const AUTO_UPDATE_EXTERNAL_FAILURE_THRESHOLD = 3; @@ -96,13 +97,6 @@ export function serializeAutoUpdateFailureSourceState(state: AutoUpdateFailureSo return Object.keys(state).length > 0 ? JSON.stringify(state) : null; } -function extractHttpStatus(text: string): number | null { - const match = text.match(/\bHTTP\s+(\d{3})\b/i) ?? text.match(/\b(4\d{2}|5\d{2})\b/); - if (!match) return null; - const status = Number.parseInt(match[1], 10); - return Number.isFinite(status) ? status : null; -} - export function classifyStableExternalAutoUpdateFailure( source: AutoUpdateFailureSourceLike ): AutoUpdateFailureClassification { diff --git a/packages/server-core/src/subscription/crud.ts b/packages/server-core/src/subscription/crud.ts index 142c19c..874182c 100644 --- a/packages/server-core/src/subscription/crud.ts +++ b/packages/server-core/src/subscription/crud.ts @@ -5,6 +5,7 @@ import type { ParsedNode } from "@subboost/core/types/node"; import { resolveSubscriptionAutoUpdateState, type SubscriptionAutoUpdateStateFields } from "./auto-update-state"; import { normalizeSavedSourcesForPersistence, type NormalizeSavedSourcesForPersistenceOptions } from "./saved-sources"; import { resolveSmartNodeMatchingEnabled } from "./refresh-node-snapshot"; +import { migrateLegacyConfig } from "@subboost/core/migrations/legacy-config"; export type SubscriptionConfigInput = { config?: unknown; @@ -114,7 +115,7 @@ export function normalizeSubscriptionConfigForPersistence( baseConfig.smartNodeMatchingEnabled = options.defaultSmartNodeMatchingEnabled; } - return baseConfig; + return migrateLegacyConfig(baseConfig); } export function serializeSubscriptionAutoUpdateState( diff --git a/packages/server-core/src/subscription/ssrf-ip.test.ts b/packages/server-core/src/subscription/ssrf-ip.test.ts index fb9b2a5..80e2f30 100644 --- a/packages/server-core/src/subscription/ssrf-ip.test.ts +++ b/packages/server-core/src/subscription/ssrf-ip.test.ts @@ -36,6 +36,7 @@ describe("SSRF IP classification", () => { "::", "0:0:0:0:0:0:0:0", "::1", + "0:0:0:0:0:0:0:1", "fc00::1", "fd00::1", "fe80::1", @@ -45,6 +46,9 @@ describe("SSRF IP classification", () => { "2001:0db8::1", "::ffff:127.0.0.1", "::ffff:c0a8:1", + "0:0:0:0:0:ffff:7f00:1", + "0:0:0:0:0:ffff:c0a8:1", + "::127.0.0.1", ]) { expect(isPrivateOrReservedIp(ip), ip).toBe(true); } diff --git a/packages/server-core/src/subscription/ssrf-ip.ts b/packages/server-core/src/subscription/ssrf-ip.ts index 03e7c14..736bfb2 100644 --- a/packages/server-core/src/subscription/ssrf-ip.ts +++ b/packages/server-core/src/subscription/ssrf-ip.ts @@ -58,49 +58,63 @@ function isBenchmarkReservedIPv4(ip: string): boolean { return ipv4InCidr(ipInt, baseInt, 15); } -function ipv4FromMappedHexTail(value: string): string | null { - const parts = value.split(":"); - if (parts.length !== 2) return null; - const nums = parts.map((part) => Number.parseInt(part, 16)); - if (nums.some((num) => !Number.isInteger(num) || num < 0 || num > 0xffff)) return null; +function ipv4FromHextets(high: number, low: number): string { return [ - nums[0] >> 8, - nums[0] & 0xff, - nums[1] >> 8, - nums[1] & 0xff, + high >> 8, + high & 0xff, + low >> 8, + low & 0xff, ].join("."); } -function isDocumentationIPv6(ip: string): boolean { - const [first = "", second = ""] = ip.split(":"); - return Number.parseInt(first, 16) === 0x2001 && Number.parseInt(second, 16) === 0x0db8; -} - -function isPrivateOrReservedIPv6(ip: string): boolean { - const normalized = ip.toLowerCase(); - if (normalized === "::" || normalized === "0:0:0:0:0:0:0:0") return true; - if (normalized === "::1") return true; - - const firstHextet = normalized.split(":")[0] || ""; - if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) return true; - if ( - firstHextet.startsWith("fe8") || - firstHextet.startsWith("fe9") || - firstHextet.startsWith("fea") || - firstHextet.startsWith("feb") - ) { - return true; +function expandIpv6Hextets(ip: string): number[] | null { + let normalized = ip.toLowerCase(); + if (normalized.includes(".")) { + const lastColon = normalized.lastIndexOf(":"); + if (lastColon < 0) return null; + const ipv4Int = ipv4ToInt(normalized.slice(lastColon + 1)); + if (ipv4Int === null) return null; + normalized = `${normalized.slice(0, lastColon)}:${((ipv4Int >>> 16) & 0xffff).toString(16)}:${( + ipv4Int & 0xffff + ).toString(16)}`; } - if (firstHextet.startsWith("ff")) return true; - if (isDocumentationIPv6(normalized)) return true; - const lastSegment = normalized.split(":").slice(-1)[0] || ""; - if (lastSegment.includes(".")) { - return isPrivateOrReservedIPv4(lastSegment); + const compressionIndex = normalized.indexOf("::"); + if (compressionIndex !== normalized.lastIndexOf("::")) return null; + + const left = (compressionIndex === -1 ? normalized : normalized.slice(0, compressionIndex)) + .split(":") + .filter(Boolean); + const right = (compressionIndex === -1 ? "" : normalized.slice(compressionIndex + 2)) + .split(":") + .filter(Boolean); + const missing = 8 - left.length - right.length; + if ((compressionIndex === -1 && missing !== 0) || (compressionIndex !== -1 && missing < 1)) return null; + + const parts = compressionIndex === -1 ? left : [...left, ...Array(missing).fill("0"), ...right]; + const hextets = parts.map((part) => Number.parseInt(part, 16)); + if (hextets.length !== 8 || hextets.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) { + return null; } - if (normalized.startsWith("::ffff:")) { - const mappedIpv4 = ipv4FromMappedHexTail(normalized.slice("::ffff:".length)); - return mappedIpv4 ? isPrivateOrReservedIPv4(mappedIpv4) : false; + return hextets; +} + +function isPrivateOrReservedIPv6(ip: string): boolean { + const hextets = expandIpv6Hextets(ip); + if (!hextets) return true; + + const [first, second] = hextets; + const allButLastZero = hextets.slice(0, 7).every((part) => part === 0); + if (hextets.every((part) => part === 0) || (allButLastZero && hextets[7] === 1)) return true; + if ((first & 0xfe00) === 0xfc00) return true; + if ((first & 0xffc0) === 0xfe80) return true; + if ((first & 0xff00) === 0xff00) return true; + if (first === 0x2001 && second === 0x0db8) return true; + + const isIpv4Compatible = hextets.slice(0, 6).every((part) => part === 0); + const isIpv4Mapped = hextets.slice(0, 5).every((part) => part === 0) && hextets[5] === 0xffff; + if (isIpv4Compatible || isIpv4Mapped) { + return isPrivateOrReservedIPv4(ipv4FromHextets(hextets[6], hextets[7])); } return false; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts index 35b862b..ccd59a3 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts @@ -318,8 +318,10 @@ describe("ProxyGroupsCategories", () => { expect(mocks.store.removeModuleRule).toHaveBeenCalledWith("auto", "rule-a"); card.onMoveRule("rule-a", "top"); expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("auto", "rule-a", "top"); - card.onMoveManualRule("manual-1", "Fallback"); - expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { target: "Fallback" }); + card.onMoveManualRule("manual-1", { kind: "module", id: "fallback", name: "Fallback" }); + expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { + target: { kind: "module", id: "fallback" }, + }); card.onRestoreRule("rule-a"); expect(mocks.store.restoreModuleRule).toHaveBeenCalledWith("auto", "rule-a"); card.onResetRuleTarget("rule-a"); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx index e12d52d..aafe4be 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx @@ -582,8 +582,8 @@ export function ProxyGroupsCategories() { onMoveRule={(ruleId, target) => moveModuleRule(module.id, ruleId, target) } - onMoveManualRule={(ruleId, targetName) => - updateCustomRule(ruleId, { target: targetName }) + onMoveManualRule={(ruleId, target) => + updateCustomRule(ruleId, { target: { kind: target.kind, id: target.id } }) } onRemoveManualRule={removeCustomRule} onRestoreRule={(ruleId) => diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts index 62bae9f..57b7cf9 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts @@ -170,7 +170,7 @@ describe("ProxyGroupsCustomGroupsPanel card props", () => { card.onAddRuleToCustomGroup(); card.onRemoveExtraRule(); card.onMoveRule(); - card.onMoveManualRule("manual-1", "Auto"); + card.onMoveManualRule("manual-1", { kind: "module", id: "auto", name: "Auto" }); card.onRemoveManualRule(0); card.onRestoreRule(); card.onResetRuleTarget(); @@ -185,7 +185,9 @@ describe("ProxyGroupsCustomGroupsPanel card props", () => { expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { enabled: true }); expect(mocks.store.removeCustomProxyGroup).toHaveBeenCalledWith("custom-1"); - expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { target: "Auto" }); + expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { + target: { kind: "module", id: "auto" }, + }); expect(mocks.store.removeCustomRule).toHaveBeenCalledWith(0); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { groupType: "load-balance", diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts index abbc63c..03f1d51 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts @@ -66,8 +66,12 @@ vi.mock("@subboost/ui/components/ui/input", () => ({ vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); vi.mock("@subboost/core/generator/proxy-groups", () => ({ PROXY_GROUP_MODULES: [ - { id: "auto", name: "Auto" }, - { id: "fallback", name: "Fallback" }, + { + id: "auto", + name: "Auto", + rules: [{ id: "builtin-a", name: "Builtin A", behavior: "domain", path: "geosite/builtin-a.mrs" }], + }, + { id: "fallback", name: "Fallback", rules: [] }, ], })); vi.mock("@subboost/core/proxy-group-name", () => ({ @@ -176,6 +180,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { customRules: [{ id: "manual-1", target: "🧩 Custom" }], customProxyGroups: [customGroup, targetGroup], customRuleSets: [sourceRule], + builtinRuleEdits: {}, dialerProxyGroups: [{ name: "Dialer" }], addCustomProxyGroup: vi.fn(), removeCustomProxyGroup: vi.fn(), @@ -290,11 +295,40 @@ describe("ProxyGroupsCustomGroupsPanel", () => { }); + it("shows builtin rules moved into a custom group and wires their actions", () => { + mocks.store.builtinRuleEdits = { + "module:auto:builtin-a": { target: { kind: "custom", id: "custom-1" } }, + }; + + renderPanel({ 0: new Set(["custom-1"]) }); + + expect(mocks.captures.ruleRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Builtin A", source: "preset", state: "moved" }), + ]), + ); + const builtinMove = mocks.captures.moveMenus.find((menu: any) => menu.ariaLabel === "移动 Builtin A 规则集"); + builtinMove.onMove({ kind: "module", id: "fallback", name: "Fallback" }); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("auto", "builtin-a", { + kind: "module", + id: "fallback", + name: "Fallback", + }); + + mocks.captures.buttons.find((button: any) => button["aria-label"] === "删除 Builtin A 规则集").onClick(); + expect(mocks.store.removeModuleRule).toHaveBeenCalledWith("custom-1", "builtin-a"); + }); + it("updates manual rules, deletes rule rows, and renders empty state", () => { renderPanel({ 0: new Set(["custom-1"]) }); - mocks.captures.manualRows[0].onMove({ rule: { id: "manual-1" }, index: 0 }, { name: "Auto" }); - expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { target: "Auto" }); + mocks.captures.manualRows[0].onMove( + { rule: { id: "manual-1" }, index: 0 }, + { kind: "module", id: "auto", name: "Auto" }, + ); + expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { + target: { kind: "module", id: "auto" }, + }); mocks.captures.manualRows[0].onRemove({ index: 0 }); expect(mocks.store.removeCustomRule).toHaveBeenCalledWith(0); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx index 872909a..20f4946 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx @@ -48,6 +48,7 @@ export function ProxyGroupsCustomGroupsPanel({ proxyGroupNameOverrides = {}, customRules = [], customRuleSets = [], + builtinRuleEdits = {}, customProxyGroups = [], addCustomProxyGroup, removeCustomProxyGroup, @@ -125,7 +126,7 @@ export function ProxyGroupsCustomGroupsPanel({ const moveManualRule = React.useCallback( (item: { rule: { id: string }; index: number }, target: ProxyGroupRuleTarget) => { - updateCustomRule(item.rule.id, { target: target.name }); + updateCustomRule(item.rule.id, { target: { kind: target.kind, id: target.id } }); }, [updateCustomRule], ); @@ -248,7 +249,20 @@ export function ProxyGroupsCustomGroupsPanel({ customProxyGroups, }) === group.name, ); - const totalRules = groupRuleSets.length + manualRules.length; + const movedBuiltinRules = Object.entries(builtinRuleEdits).flatMap(([key, edit]) => { + if (edit?.enabled === false || !edit?.target) return []; + const match = key.match(/^module:([^:]+):(.+)$/); + if (!match) return []; + const sourceModule = PROXY_GROUP_MODULES.find((module) => module.id === match[1]); + const rule = sourceModule?.rules.find((item) => item.id === match[2]); + if (!sourceModule || !rule) return []; + const targetName = resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups, + }); + return targetName === group.name ? [{ sourceModule, rule }] : []; + }); + const totalRules = groupRuleSets.length + movedBuiltinRules.length + manualRules.length; const description = group.description?.trim() || "自定义代理组"; const nodeCount = nodeCounts?.get(group.name) ?? 0; @@ -326,6 +340,44 @@ export function ProxyGroupsCustomGroupsPanel({ } /> ))} + {movedBuiltinRules.map(({ sourceModule, rule }) => ( + + { + if (isRuleSetMoveTarget(target)) { + moveModuleRule(sourceModule.id, rule.id, target); + } + }} + /> + + + } + /> + ))} {manualRules.map((item) => ( undefined} onRemoveExtraRule={() => undefined} onMoveRule={() => undefined} - onMoveManualRule={(ruleId, targetName) => updateCustomRule(ruleId, { target: targetName })} + onMoveManualRule={(ruleId, target) => + updateCustomRule(ruleId, { target: { kind: target.kind, id: target.id } }) + } onRemoveManualRule={removeCustomRule} onRestoreRule={() => undefined} onResetRuleTarget={() => undefined} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts index e73a09b..4b082ac 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts @@ -276,9 +276,15 @@ describe("ProxyGroupsCustomRules", () => { "Legacy Target", ], existingRules: [], - onImport: mocks.store.addCustomRules, + onImport: expect.any(Function), }), ); + mocks.captures.batchDialogs[0].onImport([ + { id: "batch-1", type: "DOMAIN", value: "batch.example", target: "Custom Group" }, + ]); + expect(mocks.store.addCustomRules).toHaveBeenCalledWith([ + expect.objectContaining({ target: { kind: "custom", id: "custom-1" } }), + ]); mocks.captures.buttons .find((props) => props.children === "批量导入") @@ -348,7 +354,7 @@ describe("ProxyGroupsCustomRules", () => { id: "rule-1", type: "DOMAIN" as const, value: " edited.com ", - target: "Custom Group", + target: { kind: "custom", id: "custom-1" }, noResolve: true, }; const { html, setters } = renderRules({ @@ -412,7 +418,7 @@ describe("ProxyGroupsCustomRules", () => { expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("rule-1", { type: "DOMAIN", value: "edited.com", - target: "Custom Group", + target: { kind: "custom", id: "custom-1" }, noResolve: true, }); expect(setters[5]).toHaveBeenCalledWith(null); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx index 5525b2c..a4075e1 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx @@ -20,7 +20,7 @@ import { CUSTOM_RULE_TYPES, } from "@subboost/core/rules/custom-rule-utils"; import { useConfigStore } from "@subboost/ui/store/config-store"; -import type { CustomRule } from "@subboost/core/types/config"; +import type { CustomProxyGroup, CustomRule, ProxyGroupRuleTarget } from "@subboost/core/types/config"; import { useProductInteractionAdapter, type ProductRuleKind, @@ -88,6 +88,20 @@ function getTargetOptions(enabledGroupNames: string[], selected?: string) { return Array.from(new Set(options)); } +function toStableRuleTarget( + targetName: string, + moduleNames: Record, + customProxyGroups: CustomProxyGroup[], +): ProxyGroupRuleTarget { + const normalized = targetName.trim(); + for (const [id, name] of Object.entries(moduleNames)) { + if (name.trim() === normalized) return { kind: "module", id }; + } + const customGroup = customProxyGroups.find((group) => group.name.trim() === normalized); + if (customGroup) return { kind: "custom", id: customGroup.id }; + return normalized; +} + export function ProxyGroupsCustomRules() { const { customRules, @@ -174,7 +188,7 @@ export function ProxyGroupsCustomRules() { id: createCustomRuleId(), type: addedRuleType, value, - target: newRuleTarget, + target: toStableRuleTarget(newRuleTarget, moduleNames, customProxyGroups), noResolve: newRuleNoResolve, }); interactions.ruleAdded?.({ @@ -213,7 +227,7 @@ export function ProxyGroupsCustomRules() { updateCustomRule(editingRuleId, { type: editingRuleDraft.type, value, - target: editingRuleDraft.target, + target: toStableRuleTarget(resolveTargetName(editingRuleDraft.target), moduleNames, customProxyGroups), noResolve: Boolean(editingRuleDraft.noResolve), }); cancelEditingRule(); @@ -245,7 +259,14 @@ export function ProxyGroupsCustomRules() { defaultNoResolve={newRuleNoResolve} targetOptions={batchTargetOptions} existingRules={customRules} - onImport={addCustomRules} + onImport={(rules) => + addCustomRules( + rules.map((rule) => ({ + ...rule, + target: toStableRuleTarget(resolveTargetName(rule.target), moduleNames, customProxyGroups), + })), + ) + } />
diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx index cc08d63..8c382f4 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx @@ -176,7 +176,7 @@ export function ProxyGroupsModuleCard({ onAddRuleToCustomGroup: (groupId: string, rule: RuleSetDraft) => void; onRemoveExtraRule: (ruleId: string) => void; onMoveRule: (ruleId: string, target: { kind: "module" | "custom"; id: string }) => void; - onMoveManualRule: (ruleId: string, targetName: string) => void; + onMoveManualRule: (ruleId: string, target: ProxyGroupRuleTarget) => void; onRemoveManualRule: (index: number) => void; onRestoreRule: (ruleId: string) => void; onResetRuleTarget: (ruleId: string) => void; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.test.ts index f62bdff..d00a97b 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.test.ts @@ -229,8 +229,9 @@ describe("ProxyGroupsModuleRulesPanel", () => { mocks.captures.buttons.find((button) => button["aria-label"] === "恢复默认目标 Removed 规则集").onClick(); expect(props.onResetRuleTarget).toHaveBeenCalledWith("removed-rule"); - mocks.captures.manualRows[0].onMove(mocks.captures.manualRows[0].item, { name: "自动选择" }); - expect(props.onMoveManualRule).toHaveBeenCalledWith("manual-1", "自动选择"); + const manualTarget = { kind: "module", id: "auto", name: "自动选择" }; + mocks.captures.manualRows[0].onMove(mocks.captures.manualRows[0].item, manualTarget); + expect(props.onMoveManualRule).toHaveBeenCalledWith("manual-1", manualTarget); mocks.captures.manualRows[0].onRemove({ index: 0 }); expect(props.onRemoveManualRule).toHaveBeenCalledWith(0); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx index 41e1af8..5531003 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx @@ -119,7 +119,7 @@ export function ProxyGroupsModuleRulesPanel({ onAddRuleToCustomGroup: (groupId: string, rule: RuleSetDraft) => void; onRemoveRule: (ruleId: string) => void; onMoveRule: (ruleId: string, target: MoveTarget) => void; - onMoveManualRule: (ruleId: string, targetName: string) => void; + onMoveManualRule: (ruleId: string, target: ProxyGroupRuleTarget) => void; onRemoveManualRule: (index: number) => void; onRestoreRule: (ruleId: string) => void; onResetRuleTarget: (ruleId: string) => void; @@ -386,7 +386,7 @@ export function ProxyGroupsModuleRulesPanel({ item={item} targets={manualRuleTargets} currentTargetName={moduleDisplayName} - onMove={(nextItem, target) => onMoveManualRule(nextItem.rule.id, target.name)} + onMove={(nextItem, target) => onMoveManualRule(nextItem.rule.id, target)} onRemove={({ index }) => onRemoveManualRule(index)} /> ))} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts index 7ed964c..e5a6fc9 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts @@ -319,7 +319,7 @@ describe("ProxyGroupsRulesLibrary", () => { id: "telegram", name: "Telegram", behavior: "ipcidr", - path: "geoip/telegram.mrs", + path: "https://raw.example/geoip/telegram.mrs", noResolve: true, }, ]); @@ -340,7 +340,7 @@ describe("ProxyGroupsRulesLibrary", () => { id: "telegram", name: "Telegram", behavior: "ipcidr", - path: "geoip/telegram.mrs", + path: "https://raw.example/geoip/telegram.mrs", noResolve: true, }, ]); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx index fd7d975..7fac3cc 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx @@ -562,7 +562,13 @@ export function ProxyGroupsRulesLibrary() { .filter((r) => builtinRuleEdits?.[getModuleRuleOrderKey(mod.id, r.id)]?.enabled !== false) .map((r) => r.id), ...customRuleSets - .filter((r) => r.target === resolveModuleFullName(mod)) + .filter( + (rule) => + resolveProxyGroupTargetName(rule.target, { + moduleNames, + customProxyGroups, + }) === resolveModuleFullName(mod), + ) .map((r) => r.id), ], ); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts index ce271c1..ff52bbd 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts @@ -320,7 +320,7 @@ describe("RulesManagementSection", () => { it("moves editable rules by buttons, absolute order input, blur, and escape cleanup", () => { stateMock.value = { "custom:one": "1" }; - const tree = renderSection(); + const tree = renderSection({ ruleOrder: ["module:geo", "custom:one"] }); const orderInputs = collectElements(tree, (element) => (element.props as any).title === "最终规则行号(1=最前)"); const upButtons = collectElements(tree, (element) => element.type === "button" && element.props.title === "上移"); const downButtons = collectElements(tree, (element) => element.type === "button" && element.props.title === "下移"); @@ -346,6 +346,29 @@ describe("RulesManagementSection", () => { expect(mocks.store.setRuleOrder).toHaveBeenCalledWith(["custom:one", "module:geo"]); }); + it("does not turn legacy custom-only ordering into full ordering after a custom move", () => { + const tree = renderSection({ ruleOrder: ["custom:one"] }); + const orderInputs = collectElements(tree, (element) => (element.props as any).title === "最终规则行号(1=最前)"); + const upButtons = collectElements(tree, (element) => element.type === "button" && element.props.title === "上移"); + const downButtons = collectElements(tree, (element) => element.type === "button" && element.props.title === "下移"); + + stateMock.value = { "custom:one": "1" }; + orderInputs[1].props.onKeyDown({ key: "Enter" }); + upButtons[1].props.onClick(); + downButtons[1].props.onClick(); + + expect(upButtons[1].props.disabled).toBe(true); + expect(downButtons[1].props.disabled).toBe(true); + expect(mocks.store.setRuleOrder).not.toHaveBeenCalled(); + }); + + it("uses full non-MATCH ordering for a new empty rule-order state", () => { + const tree = renderSection({ ruleOrder: [] }); + const header = collectElements(tree, (element) => element.props.title === "规则管理")[0]; + expect(collectText(tree)).toContain("已开启全规则排序"); + expect(collectText(header.props.badge)).toContain("可调 2 / 全部 3"); + }); + it("keeps collapsed sections lightweight", () => { mocks.store = { enabledProxyGroups: [], diff --git a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx index c55c7b1..3b408f6 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx @@ -52,7 +52,7 @@ export function RulesManagementSection({ setRuleOrder, } = useConfigStore(); const [orderDrafts, setOrderDrafts] = React.useState>({}); - const allRulesMode = hasFullRuleOrderKeys(ruleOrder); + const allRulesMode = ruleOrder.length === 0 || hasFullRuleOrderKeys(ruleOrder); const entries = React.useMemo( () => @@ -85,6 +85,7 @@ export function RulesManagementSection({ const preMatchKeys = React.useMemo(() => preMatchEntries.map((entry) => entry.key), [preMatchEntries]); const editableEntries = React.useMemo(() => entries.filter((entry) => entry.editable), [entries]); const editableKeys = React.useMemo(() => editableEntries.map((entry) => entry.key), [editableEntries]); + const movableKeys = allRulesMode ? preMatchKeys : editableKeys; const applyRuleOrder = React.useCallback( (nextRuleOrder: string[]) => { @@ -102,30 +103,30 @@ export function RulesManagementSection({ const moveRule = React.useCallback( (key: string, direction: "up" | "down") => { - const from = preMatchKeys.indexOf(key); + const from = movableKeys.indexOf(key); if (from < 0) return; - const to = clamp(direction === "up" ? from - 1 : from + 1, 0, preMatchKeys.length - 1); + const to = clamp(direction === "up" ? from - 1 : from + 1, 0, movableKeys.length - 1); if (to === from) return; - const next = preMatchKeys.slice(); + const next = movableKeys.slice(); const [item] = next.splice(from, 1); next.splice(to, 0, item); applyRuleOrder(next); }, - [applyRuleOrder, preMatchKeys] + [applyRuleOrder, movableKeys] ); const setRuleAbsoluteOrder = React.useCallback( (key: string, absoluteOrder: number) => { - const from = preMatchKeys.indexOf(key); + const from = movableKeys.indexOf(key); if (from < 0 || !Number.isFinite(absoluteOrder)) return; - const to = clamp(Math.floor(absoluteOrder) - 1, 0, preMatchEntries.length - 1); + const to = clamp(Math.floor(absoluteOrder) - 1, 0, movableKeys.length - 1); if (to === from) return; - const next = preMatchKeys.slice(); + const next = movableKeys.slice(); const [item] = next.splice(from, 1); next.splice(to, 0, item); applyRuleOrder(next); }, - [applyRuleOrder, preMatchEntries.length, preMatchKeys] + [applyRuleOrder, movableKeys] ); const handleToggleAllRulesMode = React.useCallback( @@ -194,11 +195,11 @@ export function RulesManagementSection({
{entries.map((entry, index) => { - const fullIndex = preMatchKeys.indexOf(entry.key); + const movableIndex = movableKeys.indexOf(entry.key); const canEditOrder = entry.key !== "special:match" && (allRulesMode || entry.editable); - const canMoveUp = canEditOrder && fullIndex > 0; - const canMoveDown = canEditOrder && fullIndex >= 0 && fullIndex < preMatchKeys.length - 1; - const absoluteOrder = index + 1; + const canMoveUp = canEditOrder && movableIndex > 0; + const canMoveDown = canEditOrder && movableIndex >= 0 && movableIndex < movableKeys.length - 1; + const absoluteOrder = allRulesMode ? index + 1 : movableIndex + 1; const displayDetail = getRuleDisplayDetail(entry); const shouldShowSourceLabel = entry.sourceLabel.trim() !== entry.target.trim() && diff --git a/packages/ui/src/product/converter/quick-mode/constants.test.ts b/packages/ui/src/product/converter/quick-mode/constants.test.ts index 43c4dd5..76e6173 100644 --- a/packages/ui/src/product/converter/quick-mode/constants.test.ts +++ b/packages/ui/src/product/converter/quick-mode/constants.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { sourceTypeInfo, templates } from "./constants"; +import { getTemplateList } from "@subboost/core/templates"; describe("quick mode constants", () => { it("defines all supported source type labels and placeholders", () => { @@ -17,5 +18,11 @@ describe("quick mode constants", () => { expect(templates.map((item) => item.name)).toEqual(["精简版", "标准版", "完整版"]); expect(templates[0].groups).toBeLessThan(templates[2].groups); expect(templates[0].rules).toBeLessThan(templates[2].rules); + const currentCounts = new Map( + getTemplateList().map((template) => [template.id, [template.groupCount, template.ruleCount]]) + ); + for (const template of templates) { + expect([template.groups, template.rules]).toEqual(currentCounts.get(template.id)); + } }); }); diff --git a/packages/ui/src/product/converter/quick-mode/constants.ts b/packages/ui/src/product/converter/quick-mode/constants.ts index 7d314d0..a83c81e 100644 --- a/packages/ui/src/product/converter/quick-mode/constants.ts +++ b/packages/ui/src/product/converter/quick-mode/constants.ts @@ -1,5 +1,6 @@ import type { SourceType } from "@subboost/ui/store/config-store"; import type { TemplateType } from "@subboost/core/types/config"; +import { getTemplateList } from "@subboost/core/templates"; import { FileCode, Link2, Server } from "lucide-react"; export const sourceTypeInfo: Record< @@ -27,26 +28,31 @@ export const sourceTypeInfo: Record< }, }; +const templateCounts = new Map( + getTemplateList().map((template) => [template.id, { groups: template.groupCount, rules: template.ruleCount }]) +); + +function countsFor(id: TemplateType) { + return templateCounts.get(id) ?? { groups: 0, rules: 0 }; +} + export const templates = [ { id: "minimal" as TemplateType, name: "精简版", description: "基础代理组 + 国内外分流", - groups: 7, - rules: 5, + ...countsFor("minimal"), }, { id: "standard" as TemplateType, name: "标准版", description: "完整代理组 + 常用规则", - groups: 14, - rules: 15, + ...countsFor("standard"), }, { id: "full" as TemplateType, name: "完整版", description: "全部功能 + 扩展规则集", - groups: 20, - rules: 40, + ...countsFor("full"), }, ]; diff --git a/packages/ui/src/product/home/use-editing-subscription-loader.ts b/packages/ui/src/product/home/use-editing-subscription-loader.ts index 5a615c7..81aafe3 100644 --- a/packages/ui/src/product/home/use-editing-subscription-loader.ts +++ b/packages/ui/src/product/home/use-editing-subscription-loader.ts @@ -9,6 +9,7 @@ import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { migrateLegacyConfig } from "@subboost/core/migrations/legacy-config"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import { hasSubscriptionUserInfo, @@ -76,7 +77,9 @@ export function useEditingSubscriptionLoader({ } return out; })(); - const cfg = sub.config && typeof sub.config === "object" ? (sub.config as Record) : {}; + const cfg = migrateLegacyConfig( + sub.config && typeof sub.config === "object" ? (sub.config as Record) : {} + ); const subscriptionInfoFromRecord = normalizeSubscriptionUserInfo((sub as any).subscriptionInfo); const hasSubscriptionInfoFromRecord = hasSubscriptionUserInfo(subscriptionInfoFromRecord); const deletedNodesFromCfg = Array.isArray((cfg as any).deletedNodes) diff --git a/packages/ui/src/store/config-store/actions/custom-actions.test.ts b/packages/ui/src/store/config-store/actions/custom-actions.test.ts index 0404386..75c0cc9 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.test.ts @@ -76,9 +76,19 @@ describe("custom config-store actions", () => { it("adds, removes, and renames custom proxy groups while updating matching rules", () => { const { actions, getState } = createHarness({ - customRules: [rule({ id: "r1", target: "Old Group" }), rule({ id: "r2", target: "DIRECT" })], - customRuleSets: [{ id: "cg-rule-1", name: "CG Rule", behavior: "domain", path: "geosite/example.mrs", target: "Old Group" }], - builtinRuleEdits: { "module:ai:openai": { target: "Old Group" } }, + customRules: [ + rule({ id: "r1", target: "Old Group" }), + rule({ id: "r2", target: "DIRECT" }), + rule({ id: "r3", target: { kind: "custom", id: "custom-group-1767225600000" } }), + ], + customRuleSets: [ + { id: "cg-rule-1", name: "CG Rule", behavior: "domain", path: "geosite/example.mrs", target: "Old Group" }, + { id: "cg-rule-2", name: "CG Rule 2", behavior: "domain", path: "geosite/example-2.mrs", target: { kind: "custom", id: "custom-group-1767225600000" } }, + ], + builtinRuleEdits: { + "module:ai:openai": { target: "Old Group" }, + "module:ai:anthropic": { target: { kind: "custom", id: "custom-group-1767225600000" }, enabled: false }, + }, }); actions.addCustomProxyGroup({ name: "Old Group", emoji: "🧩", groupType: "select" }); @@ -100,10 +110,14 @@ describe("custom config-store actions", () => { expect(getState().customRules[1].target).toBe("DIRECT"); expect(getState().customRuleSets[0].target).toBe("New Group"); expect(getState().builtinRuleEdits["module:ai:openai"].target).toBe("New Group"); + expect(getState().customRules[2].target).toEqual({ kind: "custom", id: groupId }); actions.removeCustomProxyGroup(groupId); expect(getState().customProxyGroups).toEqual([]); expect(getState().customRuleSets).toEqual([]); + expect(getState().customRules).toEqual([expect.objectContaining({ id: "r2", target: "DIRECT" })]); + expect(getState().builtinRuleEdits["module:ai:openai"]).toBeUndefined(); + expect(getState().builtinRuleEdits["module:ai:anthropic"]).toEqual({ enabled: false }); }); it("normalizes advanced custom proxy group fields and keeps blank-name removals narrow", () => { diff --git a/packages/ui/src/store/config-store/actions/custom-actions.ts b/packages/ui/src/store/config-store/actions/custom-actions.ts index fa125a0..d7d5de7 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.ts @@ -7,6 +7,10 @@ import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import type { ConfigActions } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; +import { + compactBuiltinRuleEdits, + ruleTargetMatchesContainer, +} from "./proxy-group-rule-set-helpers"; type CustomActions = Pick< ConfigActions, @@ -160,19 +164,38 @@ export function createCustomActions( removeCustomProxyGroup: (id: string) => { setAndGenerateConfig((state) => { const removedGroup = state.customProxyGroups.find((g) => g.id === id); + if (!removedGroup) return state; const nextCustomProxyGroups = state.customProxyGroups.filter( (g) => g.id !== id, ); const removedGroupName = removedGroup?.name?.trim() || ""; - const nextCustomRuleSets = removedGroupName - ? state.customRuleSets.filter((ruleSet) => ruleSet.target !== removedGroupName) - : state.customRuleSets; + const removedTarget = { kind: "custom" as const, id }; + const nextCustomRules = state.customRules.filter( + (rule) => !ruleTargetMatchesContainer(rule.target, removedTarget, removedGroupName), + ); + const nextCustomRuleSets = state.customRuleSets.filter( + (ruleSet) => !ruleTargetMatchesContainer(ruleSet.target, removedTarget, removedGroupName), + ); + const nextBuiltinRuleEdits = compactBuiltinRuleEdits( + Object.fromEntries( + Object.entries(state.builtinRuleEdits || {}).map(([key, edit]) => { + if (!ruleTargetMatchesContainer(edit?.target, removedTarget, removedGroupName)) return [key, edit]; + const { target: _removedTarget, ...rest } = edit; + return [key, rest]; + }), + ), + ); return { customProxyGroups: nextCustomProxyGroups, + customRules: nextCustomRules, customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, ruleOrder: normalizeRuleOrderForState({ ...state, + customProxyGroups: nextCustomProxyGroups, + customRules: nextCustomRules, customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, }), }; }); diff --git a/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts index 6cfe8e3..894f6aa 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts @@ -187,7 +187,7 @@ describe("createProxyGroupActions", () => { expect(getState().builtinRuleEdits).toEqual({}); expect(getState().customRuleSets).toEqual([ - { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }, + { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: { kind: "module", id: "ai" } }, ]); const beforeDuplicateAdd = getState(); @@ -207,7 +207,7 @@ describe("createProxyGroupActions", () => { name: "Custom AI IP", behavior: "ipcidr", path: "geoip/custom-ai.mrs", - target: "🤖 AI 服务", + target: { kind: "module", id: "ai" }, noResolve: true, }); @@ -249,6 +249,12 @@ describe("createProxyGroupActions", () => { expect(getState().builtinRuleEdits).toEqual({ "module:ai:openai": { target: "Custom", enabled: false }, }); + + actions.restoreModuleRule("ai", "openai"); + + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: "Custom" }, + }); }); it("keeps missing rule-set targets as no-ops and retargets moved builtin edits", () => { @@ -279,7 +285,7 @@ describe("createProxyGroupActions", () => { actions.moveModuleRule("ai", "openai", { kind: "module", id: "youtube" }); expect(getState().enabledProxyGroups).toContain("youtube"); expect(getState().builtinRuleEdits).toEqual({ - "module:ai:openai": { target: "📹 油管视频" }, + "module:ai:openai": { target: { kind: "module", id: "youtube" } }, }); }); @@ -339,7 +345,7 @@ describe("createProxyGroupActions", () => { actions.moveModuleRule("streaming-west", "apple-tvplus", { kind: "module", id: "google" }); expect(getState().ruleOrder).toContain(appleTvPlusKey); - expect(getState().builtinRuleEdits[appleTvPlusKey]).toEqual({ target: "🔍 谷歌服务" }); + expect(getState().builtinRuleEdits[appleTvPlusKey]).toEqual({ target: { kind: "module", id: "google" } }); expect(getAppliedOrder().indexOf(appleTvPlusKey)).toBe(appleTvPlusIndex); actions.resetModuleRuleTarget("streaming-west", "apple-tvplus"); expect(getState().builtinRuleEdits).toEqual({}); @@ -366,7 +372,7 @@ describe("createProxyGroupActions", () => { ]); expect(getState().customRuleSets).toEqual([ - { id: "custom", name: "custom", behavior: "ipcidr", path: "geoip/custom.mrs", target: "Custom Module", noResolve: true }, + { id: "custom", name: "custom", behavior: "ipcidr", path: "geoip/custom.mrs", target: { kind: "custom", id: "custom-module" }, noResolve: true }, ]); const beforePresetNoop = getState(); @@ -421,7 +427,7 @@ describe("createProxyGroupActions", () => { name: "Telegram", behavior: "ipcidr", path: "geoip/telegram.mrs", - target: "Custom", + target: { kind: "custom", id: "custom-1" }, noResolve: true, }, ]); @@ -433,11 +439,11 @@ describe("createProxyGroupActions", () => { expect(getState().customRuleSets[0]).toMatchObject({ id: "telegram", name: "Telegram Custom", - target: "Custom", + target: { kind: "custom", id: "custom-1" }, }); actions.moveModuleRule("custom-1", "telegram", { kind: "custom", id: "custom-2" }); - expect(getState().customRuleSets[0].target).toBe("Target"); + expect(getState().customRuleSets[0].target).toEqual({ kind: "custom", id: "custom-2" }); actions.removeModuleRule("custom-2", "telegram"); expect(getState().customRuleSets).toEqual([]); @@ -447,7 +453,7 @@ describe("createProxyGroupActions", () => { const { actions, getState } = createHarness({ builtinRuleEdits: { "module:ai:openai": { enabled: false }, - "module:ai:anthropic": { enabled: false }, + "module:ai:anthropic": { target: "Custom", enabled: false }, "module:youtube:youtube": { enabled: false }, }, moduleRuleEditWarningAccepted: false, @@ -455,11 +461,17 @@ describe("createProxyGroupActions", () => { actions.restoreModuleDefaultRules("ai"); actions.restoreModuleDefaultRules("missing"); - expect(getState().builtinRuleEdits).toEqual({ "module:youtube:youtube": { enabled: false } }); + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:anthropic": { target: "Custom" }, + "module:youtube:youtube": { enabled: false }, + }); actions.restoreModuleDefaultRules(""); actions.restoreModuleDefaultRules("ai"); - expect(getState().builtinRuleEdits).toEqual({ "module:youtube:youtube": { enabled: false } }); + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:anthropic": { target: "Custom" }, + "module:youtube:youtube": { enabled: false }, + }); actions.acceptModuleRuleEditWarning(); expect(getState().moduleRuleEditWarningAccepted).toBe(true); @@ -505,20 +517,22 @@ describe("createProxyGroupActions", () => { actions.moveModuleRule("ai", "openai", { kind: "module", id: "youtube" }); expect(getState().enabledProxyGroups).toContain("youtube"); - expect(getState().builtinRuleEdits).toEqual({ "module:ai:openai": { target: "📹 油管视频" } }); + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: { kind: "module", id: "youtube" } }, + }); actions.moveModuleRule("ai", "anthropic", { kind: "custom", id: "custom-1" }); expect(getState().customRuleSets).toEqual([]); expect(getState().builtinRuleEdits).toEqual({ - "module:ai:openai": { target: "📹 油管视频" }, - "module:ai:anthropic": { target: "Custom" }, + "module:ai:openai": { target: { kind: "module", id: "youtube" } }, + "module:ai:anthropic": { target: { kind: "custom", id: "custom-1" } }, }); actions.moveModuleRule("ai", "anthropic", { kind: "custom", id: "custom-1" }); expect(getState().builtinRuleEdits).toEqual({ - "module:ai:openai": { target: "📹 油管视频" }, - "module:ai:anthropic": { target: "Custom" }, + "module:ai:openai": { target: { kind: "module", id: "youtube" } }, + "module:ai:anthropic": { target: { kind: "custom", id: "custom-1" } }, }); const beforeIgnoredMoves = getState(); @@ -546,7 +560,7 @@ describe("createProxyGroupActions", () => { expect(getState().enabledProxyGroups).toEqual(["select", "auto", "youtube"]); expect(getState().customRuleSets).toEqual([ - { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "📹 油管视频" }, + { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: { kind: "module", id: "youtube" } }, ]); }); @@ -594,7 +608,9 @@ describe("createProxyGroupActions", () => { actions.moveModuleRule("private", "private-ip", { kind: "custom", id: "custom-1" }); expect(getState().customRuleSets).toEqual([]); - expect(getState().builtinRuleEdits).toEqual({ "module:private:private-ip": { target: "Custom" } }); + expect(getState().builtinRuleEdits).toEqual({ + "module:private:private-ip": { target: { kind: "custom", id: "custom-1" } }, + }); }); it("renames non-core module groups and rewrites custom rule targets", () => { diff --git a/packages/ui/src/store/config-store/actions/proxy-group-actions.ts b/packages/ui/src/store/config-store/actions/proxy-group-actions.ts index ae03821..3d85e1a 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-actions.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-actions.ts @@ -1,5 +1,7 @@ import { + type CustomProxyGroup, type CustomRuleSet, + type ProxyGroupTargetRef, } from "@subboost/core/types/config"; import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; @@ -15,6 +17,7 @@ import { resolveMoveTargetName, resolveRuleSetContainerTargetName, retargetBuiltinRuleEdits, + ruleTargetMatchesContainer, updateBuiltinRuleEdit, } from "./proxy-group-rule-set-helpers"; @@ -63,6 +66,14 @@ function isSupportedProxyGroupOrderKey(key: string): boolean { ); } +function resolveRuleSetContainerTargetRef( + id: string, + customProxyGroups: CustomProxyGroup[], +): ProxyGroupTargetRef | null { + if (isBuiltinProxyGroup(id)) return { kind: "module", id }; + return customProxyGroups.some((group) => group.id === id) ? { kind: "custom", id } : null; +} + export function createProxyGroupActions( _set: SetState, _get: GetState, @@ -172,7 +183,8 @@ export function createProxyGroupActions( state.customProxyGroups, state.proxyGroupNameOverrides, ); - if (!target) return state; + const targetRef = resolveRuleSetContainerTargetRef(id, state.customProxyGroups); + if (!target || !targetRef) return state; const proxyModule = PROXY_GROUP_MODULES.find((item) => item.id === id); let nextBuiltinRuleEdits = state.builtinRuleEdits; const customDrafts: RuleSetDraft[] = []; @@ -189,7 +201,7 @@ export function createProxyGroupActions( } customDrafts.push(normalized); } - const nextCustomRuleSets = appendUniqueCustomRuleSets(state.customRuleSets, customDrafts, target); + const nextCustomRuleSets = appendUniqueCustomRuleSets(state.customRuleSets, customDrafts, targetRef); if ( nextCustomRuleSets.length === state.customRuleSets.length && nextBuiltinRuleEdits === state.builtinRuleEdits @@ -222,8 +234,11 @@ export function createProxyGroupActions( state.customProxyGroups, state.proxyGroupNameOverrides, ); - if (!target) return state; - const index = state.customRuleSets.findIndex((item) => item.id === rid && item.target === target); + const targetRef = resolveRuleSetContainerTargetRef(id, state.customProxyGroups); + if (!target || !targetRef) return state; + const index = state.customRuleSets.findIndex( + (item) => item.id === rid && ruleTargetMatchesContainer(item.target, targetRef, target), + ); if (index < 0) return state; const normalized = normalizeRuleSetDraft({ @@ -234,7 +249,7 @@ export function createProxyGroupActions( if (!normalized) return state; const nextCustomRuleSets = state.customRuleSets.map((item, itemIndex) => - itemIndex === index ? { ...normalized, target } : item + itemIndex === index ? { ...normalized, target: targetRef } : item ); return { @@ -271,8 +286,9 @@ export function createProxyGroupActions( state.customProxyGroups, state.proxyGroupNameOverrides, ); - if (!target) return state; - const movedBuiltinKey = findBuiltinRuleEditKeyByTarget(state.builtinRuleEdits, target, rid); + const targetRef = resolveRuleSetContainerTargetRef(id, state.customProxyGroups); + if (!target || !targetRef) return state; + const movedBuiltinKey = findBuiltinRuleEditKeyByTarget(state.builtinRuleEdits, targetRef, target, rid); if (movedBuiltinKey) { const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, movedBuiltinKey, { enabled: false }); return { @@ -284,7 +300,7 @@ export function createProxyGroupActions( }; } const nextCustomRuleSets = state.customRuleSets.filter( - (ruleSet) => !(ruleSet.id === rid && ruleSet.target === target) + (ruleSet) => !(ruleSet.id === rid && ruleTargetMatchesContainer(ruleSet.target, targetRef, target)) ); if (nextCustomRuleSets.length === state.customRuleSets.length) return state; return { @@ -317,7 +333,8 @@ export function createProxyGroupActions( state.customProxyGroups, state.proxyGroupNameOverrides, ); - if (!sourceTarget) return state; + const sourceTargetRef = resolveRuleSetContainerTargetRef(sourceId, state.customProxyGroups); + if (!sourceTarget || !sourceTargetRef) return state; if (sourceTarget === targetName) return state; if (target.kind === "module" && targetId === sourceId) return state; @@ -330,7 +347,8 @@ export function createProxyGroupActions( } const customRuleSetIndex = state.customRuleSets.findIndex( - (ruleSet) => ruleSet.id === rid && ruleSet.target === sourceTarget + (ruleSet) => + ruleSet.id === rid && ruleTargetMatchesContainer(ruleSet.target, sourceTargetRef, sourceTarget) ); if (customRuleSetIndex >= 0) { const targetModule = target.kind === "module" @@ -350,13 +368,13 @@ export function createProxyGroupActions( (ruleSet, index) => index !== customRuleSetIndex && ruleSet.id === rid && - ruleSet.target === targetName + ruleTargetMatchesContainer(ruleSet.target, target, targetName) ) ) { nextCustomRuleSets = state.customRuleSets.filter((_, index) => index !== customRuleSetIndex); } else { nextCustomRuleSets = state.customRuleSets.map((ruleSet, index) => - index === customRuleSetIndex ? { ...ruleSet, target: targetName } : ruleSet + index === customRuleSetIndex ? { ...ruleSet, target } : ruleSet ); } return { @@ -372,10 +390,15 @@ export function createProxyGroupActions( }; } - const movedBuiltinKey = findBuiltinRuleEditKeyByTarget(state.builtinRuleEdits, sourceTarget, rid); + const movedBuiltinKey = findBuiltinRuleEditKeyByTarget( + state.builtinRuleEdits, + sourceTargetRef, + sourceTarget, + rid, + ); if (movedBuiltinKey) { const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, movedBuiltinKey, { - target: targetName, + target, enabled: true, }); return { @@ -392,7 +415,7 @@ export function createProxyGroupActions( if (!sourceModule || !isPresetModuleRule(sourceModule, rid)) return state; const key = getModuleRuleOrderKey(sourceId, rid); const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, key, { - target: targetName, + target, enabled: true, }); return { @@ -417,8 +440,11 @@ export function createProxyGroupActions( if (!mod || !isPresetModuleRule(mod, rid)) return state; const key = getModuleRuleOrderKey(id, rid); - if (state.builtinRuleEdits?.[key]?.enabled !== false) return state; - const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, key, { enabled: true }); + const currentEdit = state.builtinRuleEdits?.[key]; + if (currentEdit?.enabled !== false) return state; + const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, key, { + enabled: true, + }); return { builtinRuleEdits: nextBuiltinRuleEdits, ruleOrder: normalizeRuleOrderForState({ @@ -459,7 +485,9 @@ export function createProxyGroupActions( for (const rule of proxyModule.rules) { const key = getModuleRuleOrderKey(id, rule.id); if (nextBuiltinRuleEdits?.[key]?.enabled === false) { - nextBuiltinRuleEdits = updateBuiltinRuleEdit(nextBuiltinRuleEdits, key, { enabled: true }); + nextBuiltinRuleEdits = updateBuiltinRuleEdit(nextBuiltinRuleEdits, key, { + enabled: true, + }); } } if (nextBuiltinRuleEdits === state.builtinRuleEdits) return state; diff --git a/packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts b/packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts index 08f9442..2286d37 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts @@ -1,11 +1,14 @@ import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; import { isValidRuleSetPathOrUrl, normalizeRuleSetPathInput } from "@subboost/core/rules/rule-model"; import type { BuiltinRuleEdits, CustomProxyGroup, CustomRuleSet, + ProxyGroupRuleTarget, + ProxyGroupTargetRef, RuleSetBehavior, } from "@subboost/core/types/config"; import type { RuleSetDraft } from "../definitions"; @@ -82,7 +85,8 @@ export function resolveRuleSetContainerTargetName( export function compactBuiltinRuleEdits(edits: BuiltinRuleEdits): BuiltinRuleEdits { const next: BuiltinRuleEdits = {}; for (const [key, edit] of Object.entries(edits || {})) { - const target = typeof edit?.target === "string" ? edit.target.trim() : ""; + const target = normalizeProxyGroupTargetRef(edit?.target) ?? + (typeof edit?.target === "string" ? edit.target.trim() : ""); const enabled = edit?.enabled === false ? false : undefined; if (!target && enabled !== false) continue; next[key] = { @@ -96,12 +100,13 @@ export function compactBuiltinRuleEdits(edits: BuiltinRuleEdits): BuiltinRuleEdi export function updateBuiltinRuleEdit( edits: BuiltinRuleEdits, key: string, - patch: { target?: string | null; enabled?: false | true | null } + patch: { target?: ProxyGroupRuleTarget | null; enabled?: false | true | null } ): BuiltinRuleEdits { const prev = edits?.[key] || {}; const next = { ...prev }; if ("target" in patch) { - const target = typeof patch.target === "string" ? patch.target.trim() : ""; + const target = normalizeProxyGroupTargetRef(patch.target) ?? + (typeof patch.target === "string" ? patch.target.trim() : ""); if (target) next.target = target; else delete next.target; } @@ -129,12 +134,13 @@ export function retargetBuiltinRuleEdits(edits: BuiltinRuleEdits, from: string, export function findBuiltinRuleEditKeyByTarget( edits: BuiltinRuleEdits, - target: string, + target: ProxyGroupTargetRef, + legacyTargetName: string, ruleId: string ): string | null { - if (!target || !ruleId) return null; + if (!target.id || !legacyTargetName || !ruleId) return null; for (const [key, edit] of Object.entries(edits || {})) { - if (edit?.target !== target) continue; + if (!ruleTargetMatchesContainer(edit?.target, target, legacyTargetName)) continue; const parts = key.split(":"); if (parts.length !== 3 || parts[0] !== "module") continue; if (parts[2] === ruleId) return key; @@ -145,7 +151,7 @@ export function findBuiltinRuleEditKeyByTarget( export function appendUniqueCustomRuleSets( existing: CustomRuleSet[], drafts: RuleSetDraft[], - target: string + target: ProxyGroupRuleTarget ): CustomRuleSet[] { const seen = new Set(existing.map((item) => item.id)); const next = [...existing]; @@ -157,3 +163,13 @@ export function appendUniqueCustomRuleSets( } return next; } + +export function ruleTargetMatchesContainer( + target: ProxyGroupRuleTarget | undefined, + container: ProxyGroupTargetRef, + legacyTargetName: string +): boolean { + const ref = normalizeProxyGroupTargetRef(target); + if (ref) return ref.kind === container.kind && ref.id === container.id; + return typeof target === "string" && target.trim() === legacyTargetName.trim(); +} diff --git a/packages/ui/src/store/config-store/actions/template-actions.ts b/packages/ui/src/store/config-store/actions/template-actions.ts index 206bdcd..d0d1781 100644 --- a/packages/ui/src/store/config-store/actions/template-actions.ts +++ b/packages/ui/src/store/config-store/actions/template-actions.ts @@ -5,6 +5,7 @@ import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; +import { migrateLegacyConfig } from "@subboost/core/migrations/legacy-config"; import type { ConfigActions, SubBoostTemplateConfig } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; @@ -79,8 +80,10 @@ export function createTemplateActions( // 应用模板配置(从模板库应用) applyTemplateConfig: (config: SubBoostTemplateConfig) => { if (!config || typeof config !== "object") return; + const migratedConfig = migrateLegacyConfig(config) as SubBoostTemplateConfig; setAndGenerateConfig((state) => { + const config = migratedConfig; const ruleModel = normalizeRuleModelFromConfig(config); const hasCustomProxyGroups = Array.isArray(config.customProxyGroups); const hasCustomRuleSets = Array.isArray(config.customRuleSets); diff --git a/packages/ui/src/store/config-store/auth-handoff.ts b/packages/ui/src/store/config-store/auth-handoff.ts index 79cee51..f1585c8 100644 --- a/packages/ui/src/store/config-store/auth-handoff.ts +++ b/packages/ui/src/store/config-store/auth-handoff.ts @@ -3,6 +3,7 @@ import { initialState } from "./definitions"; import { safeParseJsonObject } from "@subboost/core/json"; import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; +import { migrateLegacyConfig } from "@subboost/core/migrations/legacy-config"; export const AUTH_CONFIG_HANDOFF_STORAGE_NAME = "subboost-auth-config-handoff"; @@ -171,8 +172,10 @@ function buildHandoffState(state: ConfigState): Partial { }; } -function normalizeHandoffState(raw: unknown): Partial | null { - if (!isRecord(raw)) return null; +function normalizeHandoffState(rawValue: unknown): Partial | null { + const migratedRaw = migrateLegacyConfig(rawValue); + if (!isRecord(migratedRaw)) return null; + const raw = migratedRaw; const out: Partial = {}; const sources = sourceArray(raw.sources); From b485967cb600dc11056f655f8f0992e8488d7f98 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:58:48 +0800 Subject: [PATCH 02/36] fix: strengthen local runtime safeguards --- README-CN.md | 6 + README.md | 6 + local/Dockerfile | 28 +- local/app/api/auth/local-auth-routes.test.ts | 2 + local/app/api/auth/login/route.ts | 26 ++ local/app/api/setup/admin/route.test.ts | 30 ++ local/app/api/setup/admin/route.ts | 22 +- .../[id]/config.yaml/route.test.ts | 73 ++++ .../subscriptions/[id]/config.yaml/route.ts | 19 + local/local.env.example | 2 + local/package.json | 4 +- local/prisma/schema.prisma | 2 +- local/src/lib/cron-auth.ts | 2 +- local/src/lib/http.ts | 1 + local/src/lib/rate-limit.test.ts | 39 +++ local/src/lib/rate-limit.ts | 64 ++++ local/src/lib/subscription-service.test.ts | 25 ++ local/src/lib/subscription-service.ts | 8 +- local/test/cron-route-contract.test.ts | 3 +- package-lock.json | 200 +---------- package.json | 2 - packages/ui/package.json | 2 - packages/ui/src/components/ui/accordion.tsx | 56 --- packages/ui/src/components/ui/dialog.tsx | 4 +- .../ui/more-basic-components.test.ts | 7 +- .../src/components/ui/radix-wrappers.test.ts | 18 +- packages/ui/src/components/ui/select.tsx | 3 +- packages/ui/src/components/ui/separator.tsx | 31 -- .../converter/sections/basic-sections.test.ts | 168 --------- .../converter/sections/dns-config-section.tsx | 52 --- .../src/product/converter/sections/index.ts | 6 - .../sections/node-filter-section.tsx | 91 ----- .../sections/node-input-section.test.ts | 297 ---------------- .../converter/sections/node-input-section.tsx | 327 ------------------ .../converter/sections/section-header.tsx | 37 -- .../src/product/home/unsaved-prompt.test.ts | 121 +------ .../ui/src/product/home/unsaved-prompt.tsx | 113 +----- .../product/preview/diff-highlight.test.ts | 29 +- .../ui/src/product/preview/diff-highlight.tsx | 205 ----------- 39 files changed, 372 insertions(+), 1759 deletions(-) create mode 100644 local/app/api/subscriptions/[id]/config.yaml/route.test.ts create mode 100644 local/src/lib/rate-limit.test.ts create mode 100644 local/src/lib/rate-limit.ts delete mode 100644 packages/ui/src/components/ui/accordion.tsx delete mode 100644 packages/ui/src/components/ui/separator.tsx delete mode 100644 packages/ui/src/product/converter/sections/basic-sections.test.ts delete mode 100644 packages/ui/src/product/converter/sections/dns-config-section.tsx delete mode 100644 packages/ui/src/product/converter/sections/index.ts delete mode 100644 packages/ui/src/product/converter/sections/node-filter-section.tsx delete mode 100644 packages/ui/src/product/converter/sections/node-input-section.test.ts delete mode 100644 packages/ui/src/product/converter/sections/node-input-section.tsx delete mode 100644 packages/ui/src/product/converter/sections/section-header.tsx diff --git a/README-CN.md b/README-CN.md index dfe967a..ce6b202 100644 --- a/README-CN.md +++ b/README-CN.md @@ -39,6 +39,12 @@ - 部署文档:[高级部署 - 编译源码构建,速度慢配置要求高](https://docs.subboost.org/deploy/advanced) - 配置教程:[草履虫也能学会的 Clash 配置:UI 界面一键配置精确分流、链式代理](https://ryanvan.com/t/topic/59?u=ryan) +## 订阅链接安全 + +完整的订阅链接及其中的 token 属于“持有者即可使用”的访问凭证。任何拿到完整链接的人,都可能读取对应的生成配置并使用该订阅。请像保管密码一样保管它,不要把完整链接放进公开仓库、Issue、聊天记录、截图或日志;怀疑泄漏时,应立即删除并重新创建相应订阅,以更换 token。 + +通过该 token 发起的请求会按订阅所属用户计入访问和风控记录。这是预期的权限模型,因此订阅所有者有责任避免完整链接泄漏。 + ## 开发说明 开发者可以从源码启动本地开发环境: diff --git a/README.md b/README.md index caa58fa..f1f1d2c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ - Deployment docs: [Advanced deployment - compiles from source, slower with higher requirements](https://docs.subboost.org/deploy/advanced) - Configuration guide: [Clash configuration simple enough for a paramecium: configure precise routing and chained proxies from the UI in one click](https://ryanvan.com/t/topic/59?u=ryan) +## Subscription Link Security + +A complete subscription URL and its token are bearer credentials: anyone who obtains the full URL may read the generated configuration and use that subscription. Protect it like a password. Never place the complete URL in public repositories, issues, chat messages, screenshots, or logs. If exposure is suspected, delete and recreate the affected subscription to issue a new token. + +Requests made with a token are attributed to the user who owns the subscription for access controls and abuse prevention. This is the intended authorization model, so subscription owners are responsible for keeping complete subscription URLs confidential. + ## Development Notes Developers can start a local development environment from source: diff --git a/local/Dockerfile b/local/Dockerfile index c361758..21c5173 100644 --- a/local/Dockerfile +++ b/local/Dockerfile @@ -2,14 +2,14 @@ FROM node:22-alpine AS deps WORKDIR /package RUN apk add --no-cache openssl +COPY package.json package-lock.json ./ COPY packages/core packages/core COPY packages/server-core packages/server-core COPY packages/ui packages/ui COPY packages/config packages/config -COPY local/package*.json local/ +COPY local/package.json local/package.json -WORKDIR /package/local -RUN npm install +RUN npm ci FROM node:22-alpine AS builder WORKDIR /package @@ -24,8 +24,7 @@ ENV APP_BUILD_SHA=${APP_BUILD_SHA} ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION_TOKEN=${APP_VERSION_TOKEN} -COPY --from=deps /package/local/node_modules local/node_modules -RUN ln -s local/node_modules node_modules +COPY --from=deps /package/node_modules node_modules COPY packages/core packages/core COPY packages/server-core packages/server-core COPY packages/ui packages/ui @@ -35,6 +34,9 @@ COPY local local WORKDIR /package/local RUN npm run build +FROM deps AS production-deps +RUN npm prune --omit=dev + FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production @@ -48,14 +50,14 @@ ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION_TOKEN=${APP_VERSION_TOKEN} RUN apk add --no-cache openssl -COPY --from=builder /package/local/.next/standalone ./ -COPY --from=deps /package/local/node_modules local/node_modules -COPY --from=builder /package/local/.next/static local/.next/static -COPY --from=builder /package/local/public local/public -COPY --from=builder /package/local/prisma.config.ts local/prisma.config.ts -COPY --from=builder /package/local/prisma local/prisma -RUN if [ ! -e node_modules ] && [ -d local/node_modules ]; then ln -s local/node_modules node_modules; fi +COPY --chown=node:node --from=builder /package/local/.next/standalone ./ +COPY --chown=node:node --from=production-deps /package/node_modules ./node_modules +COPY --chown=node:node --from=builder /package/local/.next/static local/.next/static +COPY --chown=node:node --from=builder /package/local/public local/public +COPY --chown=node:node --from=builder /package/local/prisma.config.ts local/prisma.config.ts +COPY --chown=node:node --from=builder /package/local/prisma local/prisma WORKDIR /app/local +USER node EXPOSE 3000 -CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma && node server.js"] +CMD ["sh", "-c", "../node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma && node server.js"] diff --git a/local/app/api/auth/local-auth-routes.test.ts b/local/app/api/auth/local-auth-routes.test.ts index b419970..e793ea2 100644 --- a/local/app/api/auth/local-auth-routes.test.ts +++ b/local/app/api/auth/local-auth-routes.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearLocalRateLimitsForTests } from "@local/lib/rate-limit"; const mocks = vi.hoisted(() => ({ bcryptCompare: vi.fn(), @@ -49,6 +50,7 @@ async function readJson(response: Response) { describe("local auth and health routes", () => { beforeEach(() => { vi.clearAllMocks(); + clearLocalRateLimitsForTests(); }); it("logs in a valid local admin and sets the session cookie", async () => { diff --git a/local/app/api/auth/login/route.ts b/local/app/api/auth/login/route.ts index 949a36b..7a59498 100644 --- a/local/app/api/auth/login/route.ts +++ b/local/app/api/auth/login/route.ts @@ -3,13 +3,37 @@ import { NextResponse } from "next/server"; import { apiError, getStringField, readJsonBody } from "@local/lib/http"; import { prisma } from "@local/lib/prisma"; import { sessionCookieOptions, signSession, SESSION_COOKIE } from "@local/lib/session"; +import { + consumeLocalRateLimit, + hashLocalRateLimitKey, + localRateLimitResponse, + resetLocalRateLimit, +} from "@local/lib/rate-limit"; + +const LOGIN_WINDOW_MS = 15 * 60 * 1000; export async function POST(request: Request) { + const globalLimit = consumeLocalRateLimit("auth-login-global", "all", { + limit: 30, + windowMs: LOGIN_WINDOW_MS, + }); + if (!globalLimit.allowed) { + return localRateLimitResponse("Too many login attempts. Try again later.", globalLimit.retryAfterSeconds); + } + const body = await readJsonBody(request); if (!body) return apiError("Invalid JSON body.", "BAD_REQUEST", 400); const username = getStringField(body, "username"); const password = getStringField(body, "password"); + const usernameLimitKey = hashLocalRateLimitKey(username.toLowerCase() || "missing"); + const usernameLimit = consumeLocalRateLimit("auth-login-username", usernameLimitKey, { + limit: 8, + windowMs: LOGIN_WINDOW_MS, + }); + if (!usernameLimit.allowed) { + return localRateLimitResponse("Too many login attempts. Try again later.", usernameLimit.retryAfterSeconds); + } const admin = username ? await prisma.localAdmin.findUnique({ where: { username }, select: { id: true, username: true, passwordHash: true } }) : null; @@ -18,6 +42,8 @@ export async function POST(request: Request) { return apiError("Invalid username or password.", "UNAUTHORIZED", 401); } + resetLocalRateLimit("auth-login-username", usernameLimitKey); + await prisma.localAdmin.update({ where: { id: admin.id }, data: { lastLoginAt: new Date() } }); const response = NextResponse.json({ success: true, user: { id: admin.id, username: admin.username } }); response.cookies.set(SESSION_COOKIE, await signSession({ adminId: admin.id, username: admin.username }), sessionCookieOptions()); diff --git a/local/app/api/setup/admin/route.test.ts b/local/app/api/setup/admin/route.test.ts index 0c52218..c1c9e8a 100644 --- a/local/app/api/setup/admin/route.test.ts +++ b/local/app/api/setup/admin/route.test.ts @@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({ getStringField: vi.fn((body: Record, key: string) => (typeof body[key] === "string" ? String(body[key]).trim() : "")), count: vi.fn(), create: vi.fn(), + queryRaw: vi.fn(), + transaction: vi.fn(), signSession: vi.fn(), sessionCookieOptions: vi.fn(), })); @@ -19,6 +21,7 @@ vi.mock("@local/lib/http", () => ({ })); vi.mock("@local/lib/prisma", () => ({ prisma: { + $transaction: mocks.transaction, localAdmin: { count: mocks.count, create: mocks.create, @@ -32,6 +35,7 @@ vi.mock("@local/lib/session", () => ({ })); import { POST } from "./route"; +import { clearLocalRateLimitsForTests } from "@local/lib/rate-limit"; async function readJson(response: Response) { return { status: response.status, body: await response.json(), headers: response.headers }; @@ -40,9 +44,14 @@ async function readJson(response: Response) { describe("local setup admin route", () => { beforeEach(() => { vi.clearAllMocks(); + clearLocalRateLimitsForTests(); mocks.count.mockResolvedValue(0); mocks.hash.mockResolvedValue("hash"); mocks.create.mockResolvedValue({ id: "admin-1", username: "ry" }); + mocks.transaction.mockImplementation(async (callback) => callback({ + $queryRaw: mocks.queryRaw, + localAdmin: { count: mocks.count, create: mocks.create }, + })); mocks.signSession.mockResolvedValue("signed-session"); mocks.sessionCookieOptions.mockReturnValue({ httpOnly: true, path: "/" }); }); @@ -99,4 +108,25 @@ describe("local setup admin route", () => { expect(mocks.signSession).toHaveBeenCalledWith({ adminId: "admin-1", username: "ry" }); expect(result.headers.get("set-cookie")).toContain("subboost_local_session=signed-session"); }); + + it("rechecks setup state under a database lock", async () => { + mocks.readJsonBody.mockResolvedValue({ + username: "ry", + password: "long-password", + passwordConfirm: "long-password", + }); + mocks.count.mockResolvedValueOnce(0).mockResolvedValueOnce(1); + + const result = await readJson(await POST(new Request("https://local.test/api/setup/admin"))); + + expect(result).toMatchObject({ + status: 409, + body: { error: "已有管理员账号,请直接登录", code: "CONFLICT" }, + }); + expect(mocks.queryRaw).toHaveBeenCalledWith( + expect.arrayContaining(["SELECT pg_advisory_xact_lock(", ")"]), + 1_397_704_283 + ); + expect(mocks.create).not.toHaveBeenCalled(); + }); }); diff --git a/local/app/api/setup/admin/route.ts b/local/app/api/setup/admin/route.ts index 3b57f55..5159ad4 100644 --- a/local/app/api/setup/admin/route.ts +++ b/local/app/api/setup/admin/route.ts @@ -4,8 +4,17 @@ import { getLocalAdminSetupCredentialError, LOCAL_ADMIN_CREDENTIAL_MESSAGES } fr import { apiError, getStringField, readJsonBody } from "@local/lib/http"; import { prisma } from "@local/lib/prisma"; import { sessionCookieOptions, signSession, SESSION_COOKIE } from "@local/lib/session"; +import { consumeLocalRateLimit, localRateLimitResponse } from "@local/lib/rate-limit"; export async function POST(request: Request) { + const setupLimit = consumeLocalRateLimit("admin-setup", "all", { + limit: 5, + windowMs: 15 * 60 * 1000, + }); + if (!setupLimit.allowed) { + return localRateLimitResponse("Too many setup attempts. Try again later.", setupLimit.retryAfterSeconds); + } + const body = await readJsonBody(request); if (!body) return apiError(LOCAL_ADMIN_CREDENTIAL_MESSAGES.invalidJson, "BAD_REQUEST", 400); @@ -23,10 +32,17 @@ export async function POST(request: Request) { } const passwordHash = await bcrypt.hash(password, 12); - const admin = await prisma.localAdmin.create({ - data: { username, passwordHash, lastLoginAt: new Date() }, - select: { id: true, username: true }, + const admin = await prisma.$transaction(async (transaction) => { + await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${1_397_704_283})`; + if (await transaction.localAdmin.count()) return null; + return transaction.localAdmin.create({ + data: { username, passwordHash, lastLoginAt: new Date() }, + select: { id: true, username: true }, + }); }); + if (!admin) { + return apiError(LOCAL_ADMIN_CREDENTIAL_MESSAGES.adminExists, "CONFLICT", 409); + } const response = NextResponse.json({ success: true, diff --git a/local/app/api/subscriptions/[id]/config.yaml/route.test.ts b/local/app/api/subscriptions/[id]/config.yaml/route.test.ts new file mode 100644 index 0000000..6008a39 --- /dev/null +++ b/local/app/api/subscriptions/[id]/config.yaml/route.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + consumeLocalRateLimit: vi.fn(), + generateSubscriptionYaml: vi.fn(), + hashLocalRateLimitKey: vi.fn(() => "token-hash"), + localRateLimitResponse: vi.fn( + () => new Response(JSON.stringify({ error: "limited", code: "RATE_LIMITED" }), { status: 429 }) + ), +})); + +vi.mock("@local/lib/rate-limit", () => ({ + consumeLocalRateLimit: mocks.consumeLocalRateLimit, + hashLocalRateLimitKey: mocks.hashLocalRateLimitKey, + localRateLimitResponse: mocks.localRateLimitResponse, +})); +vi.mock("@local/lib/subscription-service", () => ({ + generateSubscriptionYaml: mocks.generateSubscriptionYaml, +})); + +import { GET } from "./route"; + +describe("local subscription YAML route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.consumeLocalRateLimit.mockReturnValue({ allowed: true, retryAfterSeconds: 0 }); + mocks.generateSubscriptionYaml.mockResolvedValue({ + yaml: "mixed-port: 7890\n", + name: "Test", + subscriptionInfo: {}, + cacheExpirySeconds: 3600, + autoUpdateIntervalSeconds: null, + isAdmin: true, + }); + }); + + it("applies global and per-token limits before generating YAML", async () => { + const response = await GET(new Request("https://local.test/config.yaml"), { + params: Promise.resolve({ id: "secret-token" }), + }); + + expect(response.status).toBe(200); + expect(mocks.hashLocalRateLimitKey).toHaveBeenCalledWith("secret-token"); + expect(mocks.consumeLocalRateLimit).toHaveBeenNthCalledWith( + 1, + "subscription-yaml-global", + "all", + { limit: 600, windowMs: 60_000 } + ); + expect(mocks.consumeLocalRateLimit).toHaveBeenNthCalledWith( + 2, + "subscription-yaml-token", + "token-hash", + { limit: 120, windowMs: 60_000 } + ); + expect(mocks.generateSubscriptionYaml).toHaveBeenCalledWith("secret-token"); + }); + + it("returns 429 before touching subscription data", async () => { + mocks.consumeLocalRateLimit.mockReturnValueOnce({ allowed: false, retryAfterSeconds: 17 }); + + const response = await GET(new Request("https://local.test/config.yaml"), { + params: Promise.resolve({ id: "secret-token" }), + }); + + expect(response.status).toBe(429); + expect(mocks.localRateLimitResponse).toHaveBeenCalledWith( + "Too many subscription requests. Try again later.", + 17 + ); + expect(mocks.generateSubscriptionYaml).not.toHaveBeenCalled(); + }); +}); diff --git a/local/app/api/subscriptions/[id]/config.yaml/route.ts b/local/app/api/subscriptions/[id]/config.yaml/route.ts index 38b8257..0a7d255 100644 --- a/local/app/api/subscriptions/[id]/config.yaml/route.ts +++ b/local/app/api/subscriptions/[id]/config.yaml/route.ts @@ -1,6 +1,11 @@ import { apiError } from "@local/lib/http"; import { generateSubscriptionYaml } from "@local/lib/subscription-service"; import { buildSubscriptionResponseHeaders } from "@subboost/server-core/subscription"; +import { + consumeLocalRateLimit, + hashLocalRateLimitKey, + localRateLimitResponse, +} from "@local/lib/rate-limit"; type RouteContext = { params: Promise<{ id: string }>; @@ -8,6 +13,20 @@ type RouteContext = { export async function GET(_request: Request, { params }: RouteContext) { const { id: token } = await params; + const globalLimit = consumeLocalRateLimit("subscription-yaml-global", "all", { + limit: 600, + windowMs: 60_000, + }); + if (!globalLimit.allowed) { + return localRateLimitResponse("Too many subscription requests. Try again later.", globalLimit.retryAfterSeconds); + } + const tokenLimit = consumeLocalRateLimit("subscription-yaml-token", hashLocalRateLimitKey(token), { + limit: 120, + windowMs: 60_000, + }); + if (!tokenLimit.allowed) { + return localRateLimitResponse("Too many subscription requests. Try again later.", tokenLimit.retryAfterSeconds); + } const result = await generateSubscriptionYaml(token); if (!result) return apiError("Subscription YAML not found.", "NOT_FOUND", 404); return new Response(result.yaml, { diff --git a/local/local.env.example b/local/local.env.example index ac3b24f..3463519 100644 --- a/local/local.env.example +++ b/local/local.env.example @@ -5,5 +5,7 @@ DATABASE_URL= ENCRYPTION_KEY= JWT_SECRET= CRON_SECRET= +# 仅开发/测试可显式开启;生产环境即使设置也不会绕过 CRON_SECRET。 +ALLOW_UNAUTHENTICATED_CRON=false APP_URL=http://localhost:3000 SUBBOOST_PORT=3000 diff --git a/local/package.json b/local/package.json index d294337..4d32242 100644 --- a/local/package.json +++ b/local/package.json @@ -24,7 +24,6 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -32,7 +31,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", @@ -50,6 +48,7 @@ "lucide-react": "^1.17.0", "next": "^16.2.7", "peggy": "^5.1.0", + "prisma": "^7.8.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", @@ -65,7 +64,6 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.7", "postcss": "8.5.15", - "prisma": "^7.8.0", "tailwindcss": "^4.3.0", "typescript": "^6.0.3" }, diff --git a/local/prisma/schema.prisma b/local/prisma/schema.prisma index fadee48..a0e5bf1 100644 --- a/local/prisma/schema.prisma +++ b/local/prisma/schema.prisma @@ -38,7 +38,7 @@ model Subscription { id String @id @default(cuid()) ownerId String name String - token String @unique @default(cuid()) + token String @unique isPrimary Boolean @default(false) encryptedUrls String @db.Text encryptedNodes String @db.Text diff --git a/local/src/lib/cron-auth.ts b/local/src/lib/cron-auth.ts index c4e86ae..f8912e7 100644 --- a/local/src/lib/cron-auth.ts +++ b/local/src/lib/cron-auth.ts @@ -20,7 +20,7 @@ export function requireLocalCronAuth(request: Request): ReturnType { + beforeEach(() => clearLocalRateLimitsForTests()); + + it("blocks requests above a fixed-window limit and resets after expiry", () => { + expect(consumeLocalRateLimit("login", "all", { limit: 2, windowMs: 10_000, now: 1_000 }).allowed).toBe(true); + expect(consumeLocalRateLimit("login", "all", { limit: 2, windowMs: 10_000, now: 2_000 }).allowed).toBe(true); + expect(consumeLocalRateLimit("login", "all", { limit: 2, windowMs: 10_000, now: 3_000 })).toEqual({ + allowed: false, + retryAfterSeconds: 8, + }); + expect(consumeLocalRateLimit("login", "all", { limit: 2, windowMs: 10_000, now: 11_000 }).allowed).toBe(true); + }); + + it("hashes sensitive keys and supports clearing successful identity buckets", () => { + const key = hashLocalRateLimitKey("secret-token"); + expect(key).toMatch(/^[a-f0-9]{64}$/); + expect(key).not.toContain("secret-token"); + consumeLocalRateLimit("token", key, { limit: 1, windowMs: 10_000, now: 1_000 }); + expect(consumeLocalRateLimit("token", key, { limit: 1, windowMs: 10_000, now: 2_000 }).allowed).toBe(false); + resetLocalRateLimit("token", key); + expect(consumeLocalRateLimit("token", key, { limit: 1, windowMs: 10_000, now: 2_000 }).allowed).toBe(true); + }); + + it("returns a structured 429 response with Retry-After", async () => { + const response = localRateLimitResponse("slow down", 7); + expect(response.status).toBe(429); + expect(response.headers.get("Retry-After")).toBe("7"); + await expect(response.json()).resolves.toEqual({ error: "slow down", code: "RATE_LIMITED" }); + }); +}); diff --git a/local/src/lib/rate-limit.ts b/local/src/lib/rate-limit.ts new file mode 100644 index 0000000..f1d11cf --- /dev/null +++ b/local/src/lib/rate-limit.ts @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { apiError } from "./http"; + +type RateLimitBucket = { + count: number; + resetAt: number; +}; + +export type LocalRateLimitResult = { + allowed: boolean; + retryAfterSeconds: number; +}; + +const buckets = new Map(); + +function cleanupExpiredBuckets(now: number): void { + if (buckets.size < 1024) return; + for (const [key, bucket] of buckets) { + if (bucket.resetAt <= now) buckets.delete(key); + } +} + +export function hashLocalRateLimitKey(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function consumeLocalRateLimit( + scope: string, + key: string, + options: { limit: number; windowMs: number; now?: number } +): LocalRateLimitResult { + const now = options.now ?? Date.now(); + const limit = Math.max(1, Math.floor(options.limit)); + const windowMs = Math.max(1000, Math.floor(options.windowMs)); + cleanupExpiredBuckets(now); + + const bucketKey = `${scope}:${key}`; + const current = buckets.get(bucketKey); + if (!current || current.resetAt <= now) { + buckets.set(bucketKey, { count: 1, resetAt: now + windowMs }); + return { allowed: true, retryAfterSeconds: 0 }; + } + + current.count += 1; + if (current.count <= limit) return { allowed: true, retryAfterSeconds: 0 }; + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), + }; +} + +export function resetLocalRateLimit(scope: string, key: string): void { + buckets.delete(`${scope}:${key}`); +} + +export function localRateLimitResponse(message: string, retryAfterSeconds: number): Response { + const response = apiError(message, "RATE_LIMITED", 429); + response.headers.set("Retry-After", String(Math.max(1, retryAfterSeconds))); + return response; +} + +export function clearLocalRateLimitsForTests(): void { + if (process.env.NODE_ENV === "test") buckets.clear(); +} diff --git a/local/src/lib/subscription-service.test.ts b/local/src/lib/subscription-service.test.ts index 472b42a..aca6030 100644 --- a/local/src/lib/subscription-service.test.ts +++ b/local/src/lib/subscription-service.test.ts @@ -287,6 +287,7 @@ describe("local subscription service", () => { data: expect.objectContaining({ ownerId: "owner-1", name: "Created", + token: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), encryptedUrls: JSON.stringify(["https://example.com/sub"]), encryptedNodes: expect.stringContaining('"name":"Node"'), encryptedConfig: expect.stringContaining('"smartNodeMatchingEnabled":false'), @@ -389,6 +390,30 @@ describe("local subscription service", () => { }); }); + it("replaces submitted config instead of retaining omitted stale fields", async () => { + mocks.prisma.subscription.findFirst.mockResolvedValueOnce( + row({ + encryptedConfig: JSON.stringify({ + staleSetting: true, + sources: [{ id: "old", type: "url", content: "https://old.example/sub" }], + }), + }) + ); + + await updateSubscription("owner-1", "sub-1", { + config: { + freshSetting: true, + sources: [{ id: "new", type: "url", content: "https://new.example/sub" }], + }, + }); + + const update = mocks.prisma.subscription.update.mock.calls.at(-1)?.[0]; + const encryptedConfig = update?.data?.encryptedConfig; + expect(typeof encryptedConfig).toBe("string"); + expect(JSON.parse(encryptedConfig)).toMatchObject({ freshSetting: true }); + expect(JSON.parse(encryptedConfig)).not.toHaveProperty("staleSetting"); + }); + it("builds fetch callbacks for refresh source imports", async () => { const callbacks = buildSubscriptionFetchCallbacks(); diff --git a/local/src/lib/subscription-service.ts b/local/src/lib/subscription-service.ts index 3850876..c5e3911 100644 --- a/local/src/lib/subscription-service.ts +++ b/local/src/lib/subscription-service.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { generateClashYaml } from "@subboost/core/generator"; import { buildGenerateOptionsFromConfig, getEffectiveTestOptions } from "@subboost/core/subscription/config-utils"; import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; @@ -118,11 +118,16 @@ function buildLocalSubscriptionConfig( existingConfig, idFactory: randomUUID, splitUrlLines: true, + mergeExistingConfig: false, defaultSmartNodeMatchingEnabled: true, } ); } +export function generateLocalSubscriptionToken(): string { + return randomBytes(32).toString("base64url"); +} + export function readSubscriptionSecrets(row: SubscriptionRow) { return { urls: decryptJson(row.encryptedUrls, []), @@ -187,6 +192,7 @@ export async function createSubscription(ownerId: string, body: unknown): Promis data: { ownerId, name, + token: generateLocalSubscriptionToken(), encryptedUrls: encryptJson(urls), encryptedNodes: encryptJson(nodes), encryptedConfig: encryptJson(config), diff --git a/local/test/cron-route-contract.test.ts b/local/test/cron-route-contract.test.ts index 34a9c76..817d2ad 100644 --- a/local/test/cron-route-contract.test.ts +++ b/local/test/cron-route-contract.test.ts @@ -62,7 +62,7 @@ describe("local cron routes", () => { it("rejects cron calls when CRON_SECRET is missing in production", async () => { vi.stubEnv("CRON_SECRET", ""); const response = await updateSubscriptionsRoute.POST(cronRequest()); - expect(response.status).toBe(500); + expect(response.status).toBe(503); expect(await readJson(response)).toEqual({ error: "CRON_SECRET not configured.", code: "CONFIGURATION_ERROR", @@ -120,4 +120,3 @@ describe("local cron routes", () => { expect((await readJson(response)).success).toBe(true); }); }); - diff --git a/package-lock.json b/package-lock.json index b2793aa..f69eea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -23,7 +22,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", @@ -72,7 +70,6 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -80,7 +77,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", @@ -98,6 +94,7 @@ "lucide-react": "^1.17.0", "next": "^16.2.7", "peggy": "^5.1.0", + "prisma": "^7.8.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", @@ -113,7 +110,6 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.7", "postcss": "8.5.15", - "prisma": "^7.8.0", "tailwindcss": "^4.3.0", "typescript": "^6.0.3" }, @@ -378,14 +374,12 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" @@ -398,7 +392,6 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", - "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "@electric-sql/pglite": "0.4.1" @@ -623,7 +616,6 @@ "version": "1.19.13", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1218,7 +1210,6 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "devOptional": true, "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { @@ -1512,7 +1503,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.3.4", @@ -1531,7 +1521,6 @@ "version": "0.24.3", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", - "devOptional": true, "license": "ISC", "dependencies": { "@electric-sql/pglite": "0.4.1", @@ -1566,7 +1555,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1580,14 +1568,12 @@ "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0" @@ -1597,7 +1583,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0", @@ -1609,7 +1594,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0" @@ -1619,7 +1603,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.2.0" @@ -1629,21 +1612,18 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/streams-local": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", @@ -1660,7 +1640,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1677,14 +1656,12 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true, "license": "MIT" }, "node_modules/@prisma/studio-core": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@radix-ui/react-toggle": "1.1.10", @@ -1712,37 +1689,6 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, - "node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1831,36 +1777,6 @@ } } }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -2521,52 +2437,6 @@ } } }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -2682,7 +2552,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -3162,7 +3031,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@subboost/config": { @@ -3546,7 +3414,6 @@ "version": "19.2.16", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4574,7 +4441,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0" @@ -4632,7 +4498,6 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", - "devOptional": true, "license": "MIT" }, "node_modules/brace-expansion": { @@ -4697,7 +4562,6 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", - "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^5.0.0", @@ -4833,7 +4697,6 @@ "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "devOptional": true, "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -4846,7 +4709,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -4925,7 +4787,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -4939,7 +4800,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4954,7 +4814,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -5047,7 +4906,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -5093,14 +4951,12 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, "license": "MIT" }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=0.10" @@ -5110,7 +4966,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -5173,7 +5028,6 @@ "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", - "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -5198,7 +5052,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -5222,7 +5075,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -5868,14 +5720,12 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, "funding": [ { "type": "individual", @@ -5898,7 +5748,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5949,7 +5798,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "devOptional": true, "funding": [ { "type": "github", @@ -6056,7 +5904,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -6129,7 +5976,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "is-property": "^1.0.2" @@ -6193,7 +6039,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", - "devOptional": true, "license": "MIT" }, "node_modules/get-proto": { @@ -6245,7 +6090,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", - "devOptional": true, "license": "MIT", "bin": { "giget": "dist/cli.mjs" @@ -6311,21 +6155,18 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, "license": "ISC" }, "node_modules/grammex": { "version": "3.1.12", "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", - "devOptional": true, "license": "MIT" }, "node_modules/graphmatch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", - "devOptional": true, "license": "MIT" }, "node_modules/has-bigints": { @@ -6443,7 +6284,6 @@ "version": "4.12.25", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -6453,14 +6293,12 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", - "devOptional": true, "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6799,7 +6637,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "devOptional": true, "license": "MIT" }, "node_modules/is-regex": { @@ -6958,7 +6795,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -6983,7 +6819,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7422,7 +7257,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/loose-envify": { @@ -7452,7 +7286,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", - "devOptional": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -7551,7 +7384,6 @@ "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", - "devOptional": true, "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -7572,7 +7404,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "devOptional": true, "license": "MIT", "dependencies": { "lru.min": "^1.1.0" @@ -7842,7 +7673,6 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, "license": "MIT" }, "node_modules/optionator": { @@ -7940,7 +7770,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7957,7 +7786,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, "license": "MIT" }, "node_modules/peggy": { @@ -7981,7 +7809,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "devOptional": true, "license": "MIT" }, "node_modules/pg": { @@ -8105,7 +7932,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -8155,7 +7981,6 @@ "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", - "devOptional": true, "license": "Unlicense", "engines": { "node": ">=12" @@ -8218,7 +8043,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -8264,7 +8088,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "devOptional": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -8276,7 +8099,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, "license": "ISC" }, "node_modules/punycode": { @@ -8293,7 +8115,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, "funding": [ { "type": "individual", @@ -8331,7 +8152,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", - "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.6", @@ -8439,7 +8259,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -8497,7 +8316,6 @@ "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/remeda" @@ -8507,7 +8325,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8561,7 +8378,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -8695,7 +8511,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, "license": "MIT" }, "node_modules/scheduler": { @@ -8717,8 +8532,7 @@ "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "devOptional": true + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -8831,7 +8645,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8844,7 +8657,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8937,7 +8749,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -8977,7 +8788,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9001,7 +8811,6 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -9656,7 +9465,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", - "devOptional": true, "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -9872,7 +9680,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10033,7 +9840,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", - "devOptional": true, "license": "MIT", "dependencies": { "grammex": "^3.1.11", @@ -10119,7 +9925,6 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -10127,7 +9932,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", diff --git a/package.json b/package.json index f95b720..a421add 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -33,7 +32,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 395a731..2fb3228 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,7 +9,6 @@ "./*": "./src/*" }, "dependencies": { - "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.0", @@ -17,7 +16,6 @@ "@radix-ui/react-popover": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", diff --git a/packages/ui/src/components/ui/accordion.tsx b/packages/ui/src/components/ui/accordion.tsx deleted file mode 100644 index b7e1c85..0000000 --- a/packages/ui/src/components/ui/accordion.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import * as React from "react"; -import * as AccordionPrimitive from "@radix-ui/react-accordion"; -import { ChevronDown } from "lucide-react"; -import { cn } from "@subboost/ui/lib/utils"; - -const Accordion = AccordionPrimitive.Root; - -const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AccordionItem.displayName = "AccordionItem"; - -const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - -)); -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; - -const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - -
{children}
-
-)); -AccordionContent.displayName = AccordionPrimitive.Content.displayName; - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index b259c16..ded4e9b 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef< ({ Root: mockPrimitive("label", "LabelRoot") })); -vi.mock("@radix-ui/react-separator", () => ({ Root: mockPrimitive("hr", "SeparatorRoot") })); import { artisticNavContainerClassName, @@ -24,17 +23,15 @@ import { import { Label } from "./label"; import { ProtocolBadge, getProtocolBadgeClass } from "./protocol-badge"; import { SafeImage } from "./safe-image"; -import { Separator } from "./separator"; import { Textarea } from "./textarea"; describe("additional basic UI components", () => { - it("renders label, separator, textarea, protocol badges, and safe images", () => { + it("renders label, textarea, protocol badges, and safe images", () => { const html = renderToStaticMarkup( React.createElement( "div", null, React.createElement(Label, { htmlFor: "name", className: "label-extra" }, "Name"), - React.createElement(Separator, { orientation: "vertical", className: "separator-extra" }), React.createElement(Textarea, { placeholder: "Write", className: "textarea-extra", readOnly: true }), React.createElement(ProtocolBadge, { type: "VMess", className: "badge-extra" }), React.createElement(SafeImage, { src: "https://example.test/a.png", alt: "avatar", fallback: React.createElement("span", null, "fallback") }), @@ -44,8 +41,6 @@ describe("additional basic UI components", () => { expect(html).toContain("label-extra"); expect(html).toContain("Name"); - expect(html).toContain("separator-extra"); - expect(html).toContain("h-full w-[1px]"); expect(html).toContain("textarea-extra"); expect(html).toContain("placeholder=\"Write\""); expect(html).toContain("VMess"); diff --git a/packages/ui/src/components/ui/radix-wrappers.test.ts b/packages/ui/src/components/ui/radix-wrappers.test.ts index 5d85972..5404e92 100644 --- a/packages/ui/src/components/ui/radix-wrappers.test.ts +++ b/packages/ui/src/components/ui/radix-wrappers.test.ts @@ -1,7 +1,6 @@ import * as React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion"; import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; import { Dialog, @@ -42,21 +41,11 @@ vi.mock("@radix-ui/react-dialog", async () => { }); describe("Radix UI wrappers", () => { - it("renders accordion, avatar, dialog, switch, and tabs wrappers with merged classes", () => { + it("renders avatar, dialog, switch, and tabs wrappers with merged classes", () => { const html = renderToStaticMarkup( React.createElement( "div", null, - React.createElement( - Accordion, - { type: "single", collapsible: true, defaultValue: "item-1" }, - React.createElement( - AccordionItem, - { value: "item-1", className: "item-extra" }, - React.createElement(AccordionTrigger, { className: "trigger-extra" }, "Question"), - React.createElement(AccordionContent, { className: "content-extra" }, "Answer") - ) - ), React.createElement( Avatar, { className: "avatar-extra" }, @@ -85,11 +74,6 @@ describe("Radix UI wrappers", () => { ) ); - expect(html).toContain("Question"); - expect(html).toContain("Answer"); - expect(html).toContain("item-extra"); - expect(html).toContain("trigger-extra"); - expect(html).toContain("content-extra"); expect(html).toContain("avatar-extra"); expect(html).toContain("fallback-extra"); expect(html).toContain("dialog-extra"); diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index 694eed9..ab774a4 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -75,7 +75,7 @@ const SelectContent = React.forwardRef< , - React.ComponentPropsWithoutRef ->( - ( - { className, orientation = "horizontal", decorative = true, ...props }, - ref - ) => ( - - ) -); -Separator.displayName = SeparatorPrimitive.Root.displayName; - -export { Separator }; - diff --git a/packages/ui/src/product/converter/sections/basic-sections.test.ts b/packages/ui/src/product/converter/sections/basic-sections.test.ts deleted file mode 100644 index 3815636..0000000 --- a/packages/ui/src/product/converter/sections/basic-sections.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import * as React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const captures = vi.hoisted(() => ({ - inputs: [] as any[], - textareas: [] as any[], -})); - -vi.mock("lucide-react", () => ({ - ChevronDown: () => React.createElement("span", null, "down-icon"), - ChevronRight: () => React.createElement("span", null, "right-icon"), - Filter: () => React.createElement("span", null, "filter-icon"), - Globe: () => React.createElement("span", null, "globe-icon"), -})); - -vi.mock("@subboost/ui/components/ui/badge", () => ({ - Badge: (props: any) => React.createElement("span", props, props.children), -})); - -vi.mock("@subboost/ui/components/ui/input", () => ({ - Input: (props: any) => { - captures.inputs.push(props); - return React.createElement("input", props); - }, -})); - -vi.mock("@subboost/ui/components/ui/textarea", () => ({ - Textarea: (props: any) => { - captures.textareas.push(props); - return React.createElement("textarea", props); - }, -})); - -vi.mock("@subboost/ui/lib/utils", () => ({ - cn: (...parts: Array) => parts.filter(Boolean).join(" "), -})); - -import { DNSConfigSection } from "./dns-config-section"; -import { NodeFilterSection } from "./node-filter-section"; -import { SectionHeader } from "./section-header"; - -function TestIcon() { - return React.createElement("span", null, "test-icon"); -} - -describe("converter shared sections", () => { - beforeEach(() => { - vi.clearAllMocks(); - captures.inputs = []; - captures.textareas = []; - }); - - it("renders section headers and forwards the toggle callback", () => { - const onToggle = vi.fn(); - const expanded = renderToStaticMarkup( - React.createElement(SectionHeader, { - expanded: true, - onToggle, - icon: TestIcon, - title: "标题", - badge: React.createElement("span", null, "badge"), - }) - ); - const collapsed = renderToStaticMarkup( - React.createElement(SectionHeader, { - expanded: false, - onToggle, - icon: TestIcon, - title: "折叠", - }) - ); - - expect(expanded).toContain("down-icon"); - expect(expanded).toContain("test-icon"); - expect(expanded).toContain("badge"); - expect(collapsed).toContain("right-icon"); - - const element = SectionHeader({ - expanded: true, - onToggle, - icon: TestIcon, - title: "标题", - }) as React.ReactElement<{ onClick: () => void }>; - element.props.onClick(); - expect(onToggle).toHaveBeenCalled(); - }); - - it("renders and updates DNS config when expanded", () => { - const onDnsYamlChange = vi.fn(); - const html = renderToStaticMarkup( - React.createElement(DNSConfigSection, { - expanded: true, - onToggle: vi.fn(), - dnsYaml: "dns: {}", - onDnsYamlChange, - }) - ); - - expect(html).toContain("基础和 DNS 配置"); - expect(html).toContain("直接编辑 YAML 格式的基础配置"); - expect(captures.textareas[0]).toMatchObject({ value: "dns: {}" }); - - captures.textareas[0].onChange({ target: { value: "mixed-port: 7890" } }); - expect(onDnsYamlChange).toHaveBeenCalledWith("mixed-port: 7890"); - }); - - it("hides DNS config body when collapsed", () => { - const html = renderToStaticMarkup( - React.createElement(DNSConfigSection, { - expanded: false, - onToggle: vi.fn(), - dnsYaml: "dns: {}", - onDnsYamlChange: vi.fn(), - }) - ); - - expect(html).toContain("基础和 DNS 配置"); - expect(html).not.toContain("直接编辑 YAML 格式的基础配置"); - expect(captures.textareas).toHaveLength(0); - }); - - it("renders node filter controls and dispatches include/exclude changes", () => { - const onFilterChange = vi.fn(); - const onFilterModeChange = vi.fn(); - const html = renderToStaticMarkup( - React.createElement(NodeFilterSection, { - expanded: true, - onToggle: vi.fn(), - filterKeyword: "香港", - onFilterChange, - filterMode: "include", - onFilterModeChange, - totalNodes: 10, - filteredCount: 3, - }) - ); - - expect(html).toContain("节点筛选"); - expect(html).toContain("3/10"); - expect(html).toContain("包含"); - expect(html).toContain("排除"); - expect(captures.inputs[0]).toMatchObject({ value: "香港" }); - - captures.inputs[0].onChange({ target: { value: "日本" } }); - expect(onFilterChange).toHaveBeenCalledWith("日本"); - expect(html).toContain("bg-green-500/20"); - }); - - it("omits filter body and badge when collapsed or empty", () => { - const html = renderToStaticMarkup( - React.createElement(NodeFilterSection, { - expanded: false, - onToggle: vi.fn(), - filterKeyword: " ", - onFilterChange: vi.fn(), - filterMode: "exclude", - onFilterModeChange: vi.fn(), - totalNodes: 0, - filteredCount: 0, - }) - ); - - expect(html).toContain("节点筛选"); - expect(html).not.toContain("0/0"); - expect(html).not.toContain("输入关键词过滤节点"); - }); -}); diff --git a/packages/ui/src/product/converter/sections/dns-config-section.tsx b/packages/ui/src/product/converter/sections/dns-config-section.tsx deleted file mode 100644 index 6cc8928..0000000 --- a/packages/ui/src/product/converter/sections/dns-config-section.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import { Globe } from "lucide-react"; -import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; -import { Textarea } from "@subboost/ui/components/ui/textarea"; -import { SectionHeader } from "./section-header"; - -interface DNSConfigSectionProps { - expanded: boolean; - onToggle: () => void; - dnsYaml: string; - onDnsYamlChange: (value: string) => void; -} - -export function DNSConfigSection({ - expanded, - onToggle, - dnsYaml, - onDnsYamlChange, -}: DNSConfigSectionProps) { - return ( -
- - {expanded && ( -
-

- 直接编辑 YAML 格式的基础配置(包括 mixed-port、allow-lan、dns、profile、sniffer 等) -

-