From 6a5cfd35c9a88e1b832376b61da05b352208ab6d Mon Sep 17 00:00:00 2001 From: nitezs Date: Mon, 25 Sep 2023 15:43:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=BE=93=E5=87=BANod?= =?UTF-8?q?eList=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API_README.md | 2 +- README.md | 2 +- api/controller/clash.go | 11 + api/controller/meta.go | 11 + api/route.go | 25 +- api/static/index.html | 258 +++++++ api/static/index.js | 415 +++++++++++ api/templates/index.html | 688 ------------------- model/{contry_map.go => country_code_map.go} | 0 model/proxy.go | 18 - model/proxy_vmess.go | 18 + model/sub.go | 4 + validator/sub.go | 1 + 13 files changed, 741 insertions(+), 712 deletions(-) create mode 100644 api/static/index.html create mode 100644 api/static/index.js delete mode 100644 api/templates/index.html rename model/{contry_map.go => country_code_map.go} (100%) diff --git a/API_README.md b/API_README.md index 0462aa8..0959b57 100644 --- a/API_README.md +++ b/API_README.md @@ -19,7 +19,7 @@ # `/short` 获取短链,Content-Type 为 `application/json` -具体参考使用可以参考 [api\templates\index.html](./api/templates/index.html) +具体参考使用可以参考 [api\templates\index.html](api/static/index.html) | Body 参数 | 类型 | 是否必须 | 默认值 | 说明 | |----------|--------|------|-----|------------------| diff --git a/README.md b/README.md index e178a88..3d87036 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ ### 模板 -可以通过变量自定义模板中的策略组代理节点 +可以通过变量自定义模板中的策略组代理节点 解释的不太清楚,可以参考下方默认模板 - `` 为添加所有节点 diff --git a/api/controller/clash.go b/api/controller/clash.go index 4b873ef..ee24966 100644 --- a/api/controller/clash.go +++ b/api/controller/clash.go @@ -22,6 +22,17 @@ func SubmodHandler(c *gin.Context) { return } // 输出 + if query.NodeListMode { + nodelist := model.NodeList{} + nodelist.Proxies = sub.Proxies + marshal, err := yaml.Marshal(nodelist) + if err != nil { + c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error()) + return + } + c.String(http.StatusOK, string(marshal)) + return + } marshal, err := yaml.Marshal(sub) if err != nil { c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error()) diff --git a/api/controller/meta.go b/api/controller/meta.go index f6a2fe9..57a1f2c 100644 --- a/api/controller/meta.go +++ b/api/controller/meta.go @@ -23,6 +23,17 @@ func SubHandler(c *gin.Context) { return } // 输出 + if query.NodeListMode { + nodelist := model.NodeList{} + nodelist.Proxies = sub.Proxies + marshal, err := yaml.Marshal(nodelist) + if err != nil { + c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error()) + return + } + c.String(http.StatusOK, string(marshal)) + return + } marshal, err := yaml.Marshal(sub) if err != nil { c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error()) diff --git a/api/route.go b/api/route.go index 17d54b4..b518f6d 100644 --- a/api/route.go +++ b/api/route.go @@ -4,23 +4,40 @@ import ( "embed" "github.com/gin-gonic/gin" "html/template" + "log" + "net/http" "sub2clash/api/controller" "sub2clash/config" "sub2clash/middleware" ) -//go:embed templates/* -var templates embed.FS +//go:embed static +var staticFiles embed.FS func SetRoute(r *gin.Engine) { r.Use(middleware.ZapLogger()) + // 使用内嵌的模板文件 - r.SetHTMLTemplate(template.Must(template.New("").ParseFS(templates, "templates/*"))) + tpl, err := template.ParseFS(staticFiles, "static/*") + if err != nil { + log.Fatalf("Error parsing templates: %v", err) + } + r.SetHTMLTemplate(tpl) + + r.GET( + "/static/*filepath", func(c *gin.Context) { + c.FileFromFS("static/"+c.Param("filepath"), http.FS(staticFiles)) + }, + ) r.GET( "/", func(c *gin.Context) { + version := config.Version + if len(config.Version) > 7 { + version = config.Version[:7] + } c.HTML( 200, "index.html", gin.H{ - "Version": config.Version, + "Version": version, }, ) }, diff --git a/api/static/index.html b/api/static/index.html new file mode 100644 index 0000000..15d2490 --- /dev/null +++ b/api/static/index.html @@ -0,0 +1,258 @@ + + + + + + sub2clash + + + + + + + + + + +
+
+

sub2clash

+ 通用订阅链接转 Clash(Meta) 配置工具 + 使用文档 +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + + + +
+
+ +
+

+ Powered by + sub2clash +

+

Version {{.Version}}

+
+
+ + diff --git a/api/static/index.js b/api/static/index.js new file mode 100644 index 0000000..2e9d9ea --- /dev/null +++ b/api/static/index.js @@ -0,0 +1,415 @@ +function clearExistingValues() { + // 清除简单输入框和复选框的值 + document.getElementById("endpoint").value = "clash"; + document.getElementById("sub").value = ""; + document.getElementById("proxy").value = ""; + document.getElementById("refresh").checked = false; + document.getElementById("autoTest").checked = false; + document.getElementById("lazy").checked = false; + document.getElementById("template").value = ""; + document.getElementById("sort").value = "nameasc"; + document.getElementById("remove").value = ""; + document.getElementById("apiLink").value = ""; + document.getElementById("apiShortLink").value = ""; + document.getElementById("password").value = ""; + document.getElementById("nodeList").checked = false; + + // 清除由 createRuleProvider, createReplace, 和 createRule 创建的所有额外输入组 + clearInputGroup("ruleProviderGroup"); + clearInputGroup("replaceGroup"); + clearInputGroup("ruleGroup"); +} + +function generateURI() { + const queryParams = []; + + // 获取 API Endpoint + const endpoint = document.getElementById("endpoint").value; + + // 获取并组合订阅链接 + let subLines = document + .getElementById("sub") + .value.split("\n") + .filter((line) => line.trim() !== ""); + let noSub = false; + // 去除 subLines 中空元素 + subLines = subLines.map((item) => { + if (item !== "") { + return item; + } + }); + if (subLines.length > 0) { + queryParams.push(`sub=${encodeURIComponent(subLines.join(","))}`); + } else { + noSub = true; + } + + // 获取并组合节点分享链接 + let proxyLines = document + .getElementById("proxy") + .value.split("\n") + .filter((line) => line.trim() !== ""); + let noProxy = false; + // 去除 proxyLines 中空元素 + proxyLines = proxyLines.map((item) => { + if (item !== "") { + return item; + } + }); + if (proxyLines.length > 0) { + queryParams.push(`proxy=${encodeURIComponent(proxyLines.join(","))}`); + } else { + noProxy = true; + } + if (noSub && noProxy) { + alert("订阅链接和节点分享链接不能同时为空!"); + return ""; + } + // 获取复选框的值 + const refresh = document.getElementById("refresh").checked; + queryParams.push(`refresh=${refresh ? "true" : "false"}`); + const autoTest = document.getElementById("autoTest").checked; + queryParams.push(`autoTest=${autoTest ? "true" : "false"}`); + const lazy = document.getElementById("lazy").checked; + queryParams.push(`lazy=${lazy ? "true" : "false"}`); + const nodeList = document.getElementById("nodeList").checked; + queryParams.push(`nodeList=${nodeList ? "true" : "false"}`); + + // 获取模板链接或名称(如果存在) + const template = document.getElementById("template").value; + if (template.trim() !== "") { + queryParams.push(`template=${encodeURIComponent(template)}`); + } + + // 获取Rule Provider和规则 + const ruleProviders = document.getElementsByName("ruleProvider"); + const rules = document.getElementsByName("rule"); + let providers = []; + for (let i = 0; i < ruleProviders.length / 5; i++) { + let baseIndex = i * 5; + let behavior = ruleProviders[baseIndex].value; + let url = ruleProviders[baseIndex + 1].value; + let group = ruleProviders[baseIndex + 2].value; + let prepend = ruleProviders[baseIndex + 3].value; + let name = ruleProviders[baseIndex + 4].value; + // 是否存在空值 + if ( + behavior.trim() === "" || + url.trim() === "" || + group.trim() === "" || + prepend.trim() === "" || + name.trim() === "" + ) { + alert("Rule Provider 中存在空值,请检查后重试!"); + return ""; + } + providers.push(`[${behavior},${url},${group},${prepend},${name}]`); + } + queryParams.push(`ruleProvider=${encodeURIComponent(providers.join(","))}`); + + let ruleList = []; + for (let i = 0; i < rules.length / 3; i++) { + if (rules[i * 3].value.trim() !== "") { + let rule = rules[i * 3].value; + let prepend = rules[i * 3 + 1].value; + let group = rules[i * 3 + 2].value; + // 是否存在空值 + if (rule.trim() === "" || prepend.trim() === "" || group.trim() === "") { + alert("Rule 中存在空值,请检查后重试!"); + return ""; + } + ruleList.push(`[${rule},${prepend},${group}]`); + } + } + queryParams.push(`rule=${encodeURIComponent(ruleList.join(","))}`); + + // 获取排序策略 + const sort = document.getElementById("sort").value; + queryParams.push(`sort=${sort}`); + + // 获取删除节点的正则表达式 + const remove = document.getElementById("remove").value; + if (remove.trim() !== "") { + queryParams.push(`remove=${encodeURIComponent(remove)}`); + } + + // 获取替换节点名称的正则表达式 + let replaceList = []; + const replaces = document.getElementsByName("replace"); + for (let i = 0; i < replaces.length / 2; i++) { + let replaceStr = `<${replaces[i * 2].value}>`; + let replaceTo = `<${replaces[i * 2 + 1].value}>`; + if (replaceStr.trim() === "") { + alert("重命名设置中存在空值,请检查后重试!"); + return ""; + } + replaceList.push(`[${replaceStr},${replaceTo}]`); + } + queryParams.push(`replace=${encodeURIComponent(replaceList.join(","))}`); + + return `${endpoint}?${queryParams.join("&")}`; +} + +// 将输入框中的 URL 解析为参数 +function parseInputURL() { + // 获取输入框中的 URL + const inputURL = document.getElementById("urlInput").value; + + if (!inputURL) { + alert("请输入有效的链接!"); + return; + } + + let url; + try { + url = new URL(inputURL); + } catch (_) { + alert("无效的链接!"); + return; + } + + // 清除现有的输入框值 + clearExistingValues(); + + // 获取查询参数 + const params = new URLSearchParams(url.search); + + // 分配值到对应的输入框 + const pathSections = url.pathname.split("/"); + const lastSection = pathSections[pathSections.length - 1]; + const clientTypeSelect = document.getElementById("endpoint"); + switch (lastSection.toLowerCase()) { + case "meta": + clientTypeSelect.value = "meta"; + break; + case "clash": + default: + clientTypeSelect.value = "clash"; + break; + } + + if (params.has("sub")) { + document.getElementById("sub").value = decodeURIComponent(params.get("sub")) + .split(",") + .join("\n"); + } + + if (params.has("proxy")) { + document.getElementById("proxy").value = decodeURIComponent( + params.get("proxy"), + ) + .split(",") + .join("\n"); + } + + if (params.has("refresh")) { + document.getElementById("refresh").checked = + params.get("refresh") === "true"; + } + + if (params.has("autoTest")) { + document.getElementById("autoTest").checked = + params.get("autoTest") === "true"; + } + + if (params.has("lazy")) { + document.getElementById("lazy").checked = params.get("lazy") === "true"; + } + + if (params.has("template")) { + document.getElementById("template").value = decodeURIComponent( + params.get("template"), + ); + } + + if (params.has("sort")) { + document.getElementById("sort").value = params.get("sort"); + } + + if (params.has("remove")) { + document.getElementById("remove").value = decodeURIComponent( + params.get("remove"), + ); + } + + if (params.has("replace")) { + parseAndFillReplaceParams(decodeURIComponent(params.get("replace"))); + } + + if (params.has("ruleProvider")) { + parseAndFillRuleProviderParams( + decodeURIComponent(params.get("ruleProvider")), + ); + } + + if (params.has("rule")) { + parseAndFillRuleParams(decodeURIComponent(params.get("rule"))); + } + + if (params.has("nodeList")) { + document.getElementById("nodeList").checked = + params.get("nodeList") === "true"; + } +} + +function clearInputGroup(groupId) { + // 清空第二个之后的child + const group = document.getElementById(groupId); + while (group.children.length > 2) { + group.removeChild(group.lastChild); + } +} + +function parseAndFillReplaceParams(replaceParams) { + const replaceGroup = document.getElementById("replaceGroup"); + let matches; + const regex = /\[(<.*?>),(<.*?>)\]/g; + const str = decodeURIComponent(replaceParams); + while ((matches = regex.exec(str)) !== null) { + const div = createReplace(); + const original = matches[1].slice(1, -1); // Remove < and > + const replacement = matches[2].slice(1, -1); // Remove < and > + + div.children[0].value = original; + div.children[1].value = replacement; + replaceGroup.appendChild(div); + } +} + +function parseAndFillRuleProviderParams(ruleProviderParams) { + const ruleProviderGroup = document.getElementById("ruleProviderGroup"); + let matches; + const regex = /\[(.*?),(.*?),(.*?),(.*?),(.*?)\]/g; + const str = decodeURIComponent(ruleProviderParams); + while ((matches = regex.exec(str)) !== null) { + const div = createRuleProvider(); + div.children[0].value = matches[1]; + div.children[1].value = matches[2]; + div.children[2].value = matches[3]; + div.children[3].value = matches[4]; + div.children[4].value = matches[5]; + ruleProviderGroup.appendChild(div); + } +} + +function parseAndFillRuleParams(ruleParams) { + const ruleGroup = document.getElementById("ruleGroup"); + let matches; + const regex = /\[(.*?),(.*?),(.*?)\]/g; + const str = decodeURIComponent(ruleParams); + while ((matches = regex.exec(str)) !== null) { + const div = createRule(); + div.children[0].value = matches[1]; + div.children[1].value = matches[2]; + div.children[2].value = matches[3]; + ruleGroup.appendChild(div); + } +} + +async function copyToClipboard(elem, e) { + const apiLinkInput = document.querySelector(`#${elem}`).value; + try { + await navigator.clipboard.writeText(apiLinkInput); + let text = e.textContent; + e.addEventListener("mouseout", function () { + e.textContent = text; + }); + e.textContent = "复制成功"; + } catch (err) { + console.error("复制到剪贴板失败:", err); + } +} + +function createRuleProvider() { + const div = document.createElement("div"); + div.classList.add("input-group", "mb-2"); + div.innerHTML = ` + + + + + + + `; + return div; +} + +function createReplace() { + const div = document.createElement("div"); + div.classList.add("input-group", "mb-2"); + div.innerHTML = ` + + + + `; + return div; +} + +function createRule() { + const div = document.createElement("div"); + div.classList.add("input-group", "mb-2"); + div.innerHTML = ` + + + + + `; + return div; +} + +function addRuleProvider() { + const div = createRuleProvider(); + document.getElementById("ruleProviderGroup").appendChild(div); +} + +function addRule() { + const div = createRule(); + document.getElementById("ruleGroup").appendChild(div); +} + +function addReplace() { + const div = createReplace(); + document.getElementById("replaceGroup").appendChild(div); +} + +function removeElement(button) { + button.parentElement.remove(); +} + +function generateURL() { + const apiLink = document.getElementById("apiLink"); + let uri = generateURI(); + if (uri === "") { + return; + } + apiLink.value = `${window.location.origin}${window.location.pathname}${uri}`; +} + +function generateShortLink() { + const apiShortLink = document.getElementById("apiShortLink"); + const password = document.getElementById("password"); + let uri = generateURI(); + if (uri === "") { + return; + } + axios + .post( + "./short", + { + url: uri, + password: password.value.trim(), + }, + { + headers: { + "Content-Type": "application/json", + }, + }, + ) + .then((response) => { + apiShortLink.value = `${window.location.origin}${window.location.pathname}s/${response.data}`; + }) + .catch((error) => { + console.log(error); + alert("生成短链失败,请重试!"); + }); +} diff --git a/api/templates/index.html b/api/templates/index.html deleted file mode 100644 index 8ddf9dc..0000000 --- a/api/templates/index.html +++ /dev/null @@ -1,688 +0,0 @@ - - - - - - sub2clash - - - - - - - - - - - - - - -
-
-

sub2clash

- 通用订阅链接转 Clash(Meta) 配置工具 - 使用文档 -
- -
- -
- -
- - -
-
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
-
- - -
- -
- - - -
-
- - - - -
-
- - -
-

- Powered by - sub2clash -

-

Version {{.Version}}

-
-
- - - - diff --git a/model/contry_map.go b/model/country_code_map.go similarity index 100% rename from model/contry_map.go rename to model/country_code_map.go diff --git a/model/proxy.go b/model/proxy.go index 290101d..b6784a9 100644 --- a/model/proxy.go +++ b/model/proxy.go @@ -4,24 +4,6 @@ type SmuxStruct struct { Enabled bool `yaml:"enable"` } -type VmessJson struct { - V string `json:"v"` - Ps string `json:"ps"` - Add string `json:"add"` - Port interface{} `json:"port"` - Id string `json:"id"` - Aid interface{} `json:"aid"` - Scy string `json:"scy"` - Net string `json:"net"` - Type string `json:"type"` - Host string `json:"host"` - Path string `json:"path"` - Tls string `json:"tls"` - Sni string `json:"sni"` - Alpn string `json:"alpn"` - Fp string `json:"fp"` -} - type Proxy struct { Name string `yaml:"name,omitempty"` Server string `yaml:"server,omitempty"` diff --git a/model/proxy_vmess.go b/model/proxy_vmess.go index 5753a3e..f70d41f 100644 --- a/model/proxy_vmess.go +++ b/model/proxy_vmess.go @@ -27,6 +27,24 @@ type WSOptions struct { EarlyDataHeaderName string `proxy:"early-data-header-name,omitempty"` } +type VmessJson struct { + V string `json:"v"` + Ps string `json:"ps"` + Add string `json:"add"` + Port interface{} `json:"port"` + Id string `json:"id"` + Aid interface{} `json:"aid"` + Scy string `json:"scy"` + Net string `json:"net"` + Type string `json:"type"` + Host string `json:"host"` + Path string `json:"path"` + Tls string `json:"tls"` + Sni string `json:"sni"` + Alpn string `json:"alpn"` + Fp string `json:"fp"` +} + type Vmess struct { Type string `yaml:"type"` Name string `yaml:"name"` diff --git a/model/sub.go b/model/sub.go index 7d255b6..2bab7b7 100644 --- a/model/sub.go +++ b/model/sub.go @@ -12,3 +12,7 @@ type Subscription struct { Rules []string `yaml:"rules,omitempty"` RuleProviders map[string]RuleProvider `yaml:"rule-providers,omitempty,omitempty"` } + +type NodeList struct { + Proxies []Proxy `yaml:"proxies,omitempty"` +} diff --git a/validator/sub.go b/validator/sub.go index aca9116..14e0fff 100644 --- a/validator/sub.go +++ b/validator/sub.go @@ -29,6 +29,7 @@ type SubValidator struct { Replace string `form:"replace" binding:""` ReplaceKeys []string `form:"-" binding:""` ReplaceTo []string `form:"-" binding:""` + NodeListMode bool `form:"nodeList,default=false" binding:""` } type RuleProviderStruct struct {