mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 18:55:13 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -0,0 +1,392 @@
|
||||
-- controller/admin/substore.lua — LuCI 路由注册(Lua 兼容模式)
|
||||
|
||||
module("luci.controller.admin.substore", package.seeall)
|
||||
|
||||
local function back_to_list()
|
||||
local http = require("luci.http")
|
||||
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "list"))
|
||||
end
|
||||
|
||||
function index()
|
||||
entry({"admin", "services", "substore"}, alias("admin", "services", "substore", "list"), nil)
|
||||
entry({"admin", "services", "substore", "list"}, template("substore/subscriptions"), _("Subscriptions"), 10)
|
||||
entry({"admin", "services", "substore", "form"}, template("substore/form"), nil)
|
||||
entry({"admin", "services", "substore", "localform"}, template("substore/local_form"), nil)
|
||||
entry({"admin", "services", "substore", "nodes"}, template("substore/nodes"), nil)
|
||||
entry({"admin", "services", "substore", "output"}, template("substore/output"), nil)
|
||||
entry({"admin", "services", "substore", "combo"}, template("substore/combo"), nil)
|
||||
entry({"admin", "services", "substore", "create"}, call("action_create"), nil)
|
||||
entry({"admin", "services", "substore", "save"}, call("action_save"), nil)
|
||||
entry({"admin", "services", "substore", "local_create"}, call("action_local_create"), nil)
|
||||
entry({"admin", "services", "substore", "local_save"}, call("action_local_save"), nil)
|
||||
entry({"admin", "services", "substore", "combo_save"}, call("action_combo_save"), nil)
|
||||
entry({"admin", "services", "substore", "node_edit"}, template("substore/node_edit"), nil)
|
||||
entry({"admin", "services", "substore", "node_save"}, call("action_node_save"), nil)
|
||||
entry({"admin", "services", "substore", "node_delete"}, call("action_node_delete"), nil)
|
||||
entry({"admin", "services", "substore", "node_set_group"}, call("action_node_set_group"), nil)
|
||||
entry({"admin", "services", "substore", "delete"}, call("action_delete"), nil)
|
||||
entry({"admin", "services", "substore", "update"}, call("action_update"), nil)
|
||||
entry({"admin", "services", "substore", "probe"}, call("action_probe"), nil)
|
||||
-- public download endpoint token based, no login, for Passwall OpenClash
|
||||
entry({"substore", "download"}, call("action_download"), nil)
|
||||
end
|
||||
|
||||
local function post_ok()
|
||||
-- 拒绝缺失 CSRF token 的 POST
|
||||
local http = require("luci.http")
|
||||
return http.formvalue("token") ~= nil
|
||||
end
|
||||
|
||||
-- read cron fields from form, validate cron expression, return cron_enable cron_time
|
||||
local function read_cron_fields()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local cron_enable = http.formvalue("cron_enable") or "0"
|
||||
local m = http.formvalue("cron_min") or "0"
|
||||
local h = http.formvalue("cron_hour") or "3"
|
||||
local dom = http.formvalue("cron_dom") or "*"
|
||||
local mon = http.formvalue("cron_mon") or "*"
|
||||
local dow = http.formvalue("cron_dow") or "*"
|
||||
local cron_time = table.concat({m,h,dom,mon,dow}, " ")
|
||||
if not core.cron_time_valid(cron_time) then
|
||||
cron_enable = "0"
|
||||
cron_time = ""
|
||||
end
|
||||
if cron_enable ~= "1" then cron_enable = "0" end
|
||||
return cron_enable, cron_time
|
||||
end
|
||||
|
||||
-- read subscription level rules from form, return rules table
|
||||
local function read_rules_fields()
|
||||
local http = require("luci.http")
|
||||
local proto_list = {}
|
||||
for _, p in ipairs({"vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"}) do
|
||||
if http.formvalue("proto_filter_"..p) then proto_list[#proto_list+1]=p end
|
||||
end
|
||||
local rules_enable = http.formvalue("rules_enable") or "0"
|
||||
if rules_enable ~= "1" then rules_enable = "0" end
|
||||
return {
|
||||
rules_enable = rules_enable,
|
||||
proto_filter = table.concat(proto_list, ","),
|
||||
keyword_include = http.formvalue("keyword_include") or "",
|
||||
keyword_exclude = http.formvalue("keyword_exclude") or "",
|
||||
dedup = http.formvalue("dedup") or "0",
|
||||
rename_map = http.formvalue("rename_map") or "",
|
||||
}
|
||||
end
|
||||
|
||||
function action_create()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local proxy_enable = http.formvalue("proxy_enable") or "0"
|
||||
if proxy_enable ~= "1" then proxy_enable = "0" end
|
||||
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if name ~= "" and url ~= "" then
|
||||
local cron_enable, cron_time = read_cron_fields()
|
||||
local rules = read_rules_fields()
|
||||
core.add(name, url, {
|
||||
proxy_enable = proxy_enable, proxy = proxy,
|
||||
cron_enable = cron_enable, cron_time = cron_time,
|
||||
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup, rename_map = rules.rename_map,
|
||||
})
|
||||
core.write_cron()
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_save()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local proxy_enable = http.formvalue("proxy_enable") or "0"
|
||||
if proxy_enable ~= "1" then proxy_enable = "0" end
|
||||
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if name ~= "" and url ~= "" then
|
||||
local cron_enable, cron_time = read_cron_fields()
|
||||
local rules = read_rules_fields()
|
||||
core.save_meta(id, {
|
||||
name = name, url = url, proxy_enable = proxy_enable, proxy = proxy,
|
||||
cron_enable = cron_enable, cron_time = cron_time,
|
||||
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup, rename_map = rules.rename_map,
|
||||
})
|
||||
core.write_cron()
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_local_create()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local content = http.formvalue("content") or ""
|
||||
local local_mode = http.formvalue("local_mode") or "text"
|
||||
local rules = read_rules_fields()
|
||||
if name ~= "" and content ~= "" then
|
||||
core.add_local(name, content, local_mode, {
|
||||
rules_enable = rules.rules_enable,
|
||||
proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include,
|
||||
keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup,
|
||||
})
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_local_save()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local content = http.formvalue("content") or ""
|
||||
local local_mode = http.formvalue("local_mode") or "text"
|
||||
local rules = read_rules_fields()
|
||||
if id ~= "" and name ~= "" and content ~= "" then
|
||||
core.save_meta(id, {
|
||||
name = name,
|
||||
raw_content = content,
|
||||
local_mode = local_mode,
|
||||
rules_enable = rules.rules_enable,
|
||||
proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include,
|
||||
keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup,
|
||||
})
|
||||
core.sync(id)
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_delete()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
core.remove(http.formvalue("id") or "")
|
||||
core.write_cron()
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
-- 节点页返回链接:保留当前筛选参数
|
||||
local function back_to_nodes(http)
|
||||
local id = http.formvalue("id") or ""
|
||||
local qs = "?id=" .. luci.util.urlencode(id)
|
||||
for _, k in ipairs({ "proto", "keyword", "group", "sort", "desc" }) do
|
||||
local v = http.formvalue(k)
|
||||
if v and v ~= "" then qs = qs .. "&" .. k .. "=" .. luci.util.urlencode(v) end
|
||||
end
|
||||
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "nodes") .. qs)
|
||||
end
|
||||
|
||||
-- 单节点保存:表单 JSON 解析后经 merge_form_node 合并到原节点(保留 raw/tags 等非表单字段)
|
||||
function action_node_save()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local parser = require("substore.parser")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local idx = tonumber(http.formvalue("idx") or "")
|
||||
local content = http.formvalue("content") or ""
|
||||
local nodes = core.read_nodes(id)
|
||||
if idx and nodes[idx] and content ~= "" then
|
||||
local res = parser.parse_local(content, "form")
|
||||
local newn = res and res.nodes and res.nodes[1]
|
||||
if newn then
|
||||
nodes[idx] = core.merge_form_node(nodes[idx], newn)
|
||||
if core.write_nodes(id, nodes) then
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
back_to_nodes(http)
|
||||
end
|
||||
|
||||
-- 单节点删除
|
||||
function action_node_delete()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local idx = tonumber(http.formvalue("idx") or "")
|
||||
local nodes = core.read_nodes(id)
|
||||
if idx and nodes[idx] then
|
||||
table.remove(nodes, idx)
|
||||
if core.write_nodes(id, nodes) then
|
||||
core.save_meta(id, { node_count = #nodes })
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
back_to_nodes(http)
|
||||
end
|
||||
|
||||
-- 单节点分组快速设置(XHR,JSON 响应)
|
||||
function action_node_set_group()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
http.prepare_content("application/json")
|
||||
if not post_ok() then
|
||||
http.write(util.json_encode({ ok = false, err = "forbidden" }))
|
||||
return
|
||||
end
|
||||
local id = http.formvalue("id") or ""
|
||||
local idx = tonumber(http.formvalue("idx") or "")
|
||||
local group = util.trim(http.formvalue("group") or "")
|
||||
local nodes = core.read_nodes(id)
|
||||
if not idx or not nodes[idx] then
|
||||
http.write(util.json_encode({ ok = false, err = "node not found" }))
|
||||
return
|
||||
end
|
||||
nodes[idx].group = group ~= "" and group or nil
|
||||
if not core.write_nodes(id, nodes) then
|
||||
http.write(util.json_encode({ ok = false, err = "write failed" }))
|
||||
return
|
||||
end
|
||||
http.write(util.json_encode({ ok = true }))
|
||||
end
|
||||
|
||||
-- combo save: id empty create else edit, sources from src_id checkboxes and reuse rules
|
||||
function action_combo_save()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
if post_ok() then
|
||||
local id = util.trim(http.formvalue("id") or "")
|
||||
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local sources = {}
|
||||
for _, it in ipairs(core.list()) do
|
||||
if not it.combo and http.formvalue("src_" .. it.id) then
|
||||
sources[#sources + 1] = it.id
|
||||
end
|
||||
end
|
||||
if name ~= "" then
|
||||
local rules = read_rules_fields()
|
||||
local o = {
|
||||
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
|
||||
dedup = rules.dedup,
|
||||
}
|
||||
if id == "" then
|
||||
core.add_combo(name, sources, o)
|
||||
else
|
||||
core.save_combo(id, name, sources, o)
|
||||
end
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
function action_update()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
if post_ok() then
|
||||
local id = http.formvalue("id") or ""
|
||||
local meta = core.get(id)
|
||||
if meta and meta["local"] then
|
||||
-- local subscription does not support auto update
|
||||
return
|
||||
end
|
||||
-- pcall fallback: parse or write error will not 500 the page, error saved to list status
|
||||
local ok, err = pcall(function()
|
||||
return core.sync(id)
|
||||
end)
|
||||
if not ok then
|
||||
core.save_meta(id, { error = tostring(err), last_update = os.time() })
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
end
|
||||
|
||||
-- node probe endpoint: POST id + mode ping tcping url + proto + keyword, return JSON
|
||||
function action_probe()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local node = require("substore.node")
|
||||
local util = require("substore.util")
|
||||
local probe = require("substore.probe")
|
||||
if not post_ok() then
|
||||
http.status(403, "Forbidden")
|
||||
http.prepare_content("text/plain; charset=utf-8")
|
||||
http.write("invalid token")
|
||||
return
|
||||
end
|
||||
local id = util.trim(http.formvalue("id") or "")
|
||||
local mode = util.trim(http.formvalue("mode") or "")
|
||||
if mode ~= "ping" and mode ~= "tcping" and mode ~= "url" then
|
||||
http.status(400, "Bad Request")
|
||||
http.prepare_content("text/plain; charset=utf-8")
|
||||
http.write("bad mode")
|
||||
return
|
||||
end
|
||||
local meta = core.get(id)
|
||||
if not meta then
|
||||
http.status(404, "Not Found")
|
||||
http.prepare_content("text/plain; charset=utf-8")
|
||||
http.write("not found")
|
||||
return
|
||||
end
|
||||
-- 与 nodes 页一致的过滤:按当前 proto / keyword 过滤后逐个探测
|
||||
local nodes = core.read_nodes(id)
|
||||
local proto = util.trim(http.formvalue("proto") or "")
|
||||
local keyword = util.trim(http.formvalue("keyword") or "")
|
||||
if proto ~= "" then nodes = node.filter(nodes, { proto = proto }) end
|
||||
if keyword ~= "" then nodes = node.filter(nodes, { keyword = keyword }) end
|
||||
nodes = node.sort(nodes, "name", false)
|
||||
|
||||
local results = probe.probe(nodes, mode)
|
||||
local ok_count, sum = 0, 0
|
||||
for _, r in ipairs(results) do
|
||||
if r.latency then
|
||||
ok_count = ok_count + 1
|
||||
sum = sum + r.latency
|
||||
end
|
||||
end
|
||||
local avg = ok_count > 0 and math.floor(sum / ok_count + 0.5) or nil
|
||||
http.prepare_content("application/json; charset=utf-8")
|
||||
http.write(util.json_encode({
|
||||
ok = true, mode = mode, total = #results,
|
||||
ok_count = ok_count, avg = avg, results = results,
|
||||
}))
|
||||
end
|
||||
|
||||
-- 公开下载端点:GET /substore/download?token=<token>&target=<format>
|
||||
function action_download()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
local token = util.trim(http.formvalue("token") or "")
|
||||
local target = util.trim(http.formvalue("target") or "ClashMeta")
|
||||
|
||||
local content, ct, filename, err = core.generate_link(token, target)
|
||||
if not content then
|
||||
http.status(404, "Not Found")
|
||||
http.prepare_content("text/plain; charset=utf-8")
|
||||
http.write(err or "not found")
|
||||
return
|
||||
end
|
||||
|
||||
http.prepare_content(ct)
|
||||
-- 文件名白名单:剥离引号/换行/控制字符,防 HTTP 头注入
|
||||
local safe_name = (filename or "subscription.txt"):gsub("[^%w%-%._]", "_")
|
||||
http.header("Content-Disposition",
|
||||
"attachment; filename=\"" .. safe_name .. "\"")
|
||||
http.write(content)
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local id = http.formvalue("id") or ""
|
||||
local it = id ~= "" and core.get(id) or nil
|
||||
local is_edit = it ~= nil
|
||||
local rules_enabled = it and (it.rules_enable == true or it.rules_enable == "1") or false
|
||||
local ki = (it and it.keyword_include) or ""
|
||||
local ke = (it and it.keyword_exclude) or ""
|
||||
local dedup = it and (it.dedup == "1" or it.dedup == true)
|
||||
local sel = {}
|
||||
if it and type(it.sources) == "table" then
|
||||
for _, s in ipairs(it.sources) do sel[s] = true end
|
||||
end
|
||||
local srcs = {}
|
||||
for _, x in ipairs(core.list()) do
|
||||
if not x.combo then srcs[#srcs + 1] = x end
|
||||
end
|
||||
%>
|
||||
<h2><%=is_edit and luci.i18n.translate("Edit combination") or luci.i18n.translate("Add combination")%></h2>
|
||||
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/combo_save')%>" class="cbi-map">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<% if is_edit then %><input type="hidden" name="id" value="<%=it.id%>"/><% end %>
|
||||
|
||||
<div class="cbi-section">
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
|
||||
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Source subscriptions%></label>
|
||||
<div style="display:inline-block">
|
||||
<% for _, x in ipairs(srcs) do %>
|
||||
<div style="margin:0.25em 0">
|
||||
<input type="checkbox" id="src_<%=x.id%>" name="src_<%=x.id%>" value="1" <%=sel[x.id] and "checked" or ""%>/>
|
||||
<label for="src_<%=x.id%>"><%=x.name%>(<%=x.node_count or 0%>)</label>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if #srcs == 0 then %>
|
||||
<span style="color:#808080"><%:No subscriptions yet.%></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em">启用规则</label>
|
||||
<input type="checkbox" name="rules_enable" value="1" <%=rules_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('rules_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
<span class="cbi-value-description">按关键词包含 / 排除、去重规则筛选合并后的节点</span>
|
||||
</div>
|
||||
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">关键词包含</label>
|
||||
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">关键词排除</label>
|
||||
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">去重</label>
|
||||
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。</span>
|
||||
</div>
|
||||
</div>
|
||||
<% if is_edit then %>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<span style="color:#808080"><%:Nodes:%> <%=it.node_count or 0%> | <%:Last updated:%> <%=it.last_update and os.date("%Y-%m-%d %H:%M", it.last_update) or "-"%></span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="cbi-page-actions">
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,112 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local id = http.formvalue("id") or ""
|
||||
local it = id ~= "" and core.get(id) or nil
|
||||
local is_edit = it ~= nil
|
||||
local cron_enabled = it and (it.cron_enable == true or it.cron_enable == "1") or false
|
||||
local cron = (it and it.cron_time and it.cron_time ~= "") and it.cron_time or "0 3 * * *"
|
||||
local cm, ch, cdom, cmon, cdow = cron:match("^(%S+) (%S+) (%S+) (%S+) (%S+)$")
|
||||
cm = cm or "0"; ch = ch or "3"; cdom = cdom or "*"; cmon = cmon or "*"; cdow = cdow or "*"
|
||||
local rules_enabled = it and (it.rules_enable == true or it.rules_enable == "1") or false
|
||||
local ki = (it and it.keyword_include) or ""
|
||||
local ke = (it and it.keyword_exclude) or ""
|
||||
local dedup = it and (it.dedup == "1" or it.dedup == true)
|
||||
local proxy_enabled = it and (it.proxy_enable == true or it.proxy_enable == "1") or false
|
||||
local util = require("substore.util")
|
||||
-- 剩余流量 / 剩余时长(仅编辑页且有 subscription-userinfo 数据时显示,首页不显示)
|
||||
local traffic_str = nil
|
||||
local time_str = nil
|
||||
if is_edit and it then
|
||||
if it.total then
|
||||
local rem = (tonumber(it.total) or 0) - (tonumber(it.upload) or 0) - (tonumber(it.download) or 0)
|
||||
if rem < 0 then rem = 0 end
|
||||
traffic_str = util.human_bytes(rem)
|
||||
end
|
||||
if it.expire then
|
||||
time_str = util.human_duration((tonumber(it.expire) or 0) - os.time())
|
||||
end
|
||||
end
|
||||
%>
|
||||
<h2><%=is_edit and luci.i18n.translate("Edit subscription") or luci.i18n.translate("Add subscription")%></h2>
|
||||
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore', is_edit and 'save' or 'create')%>" class="cbi-map">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<% if is_edit then %><input type="hidden" name="id" value="<%=it.id%>"/><% end %>
|
||||
|
||||
<div class="cbi-section">
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
|
||||
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label for="url" style="display:inline-block;min-width:10em"><%:Subscription URL%></label>
|
||||
<input type="text" id="url" name="url" value="<%=is_edit and it.url or ''%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em">订阅代理</label>
|
||||
<input type="checkbox" name="proxy_enable" value="1" <%=proxy_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('proxy_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
</div>
|
||||
<div id="proxy_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=proxy_enabled and "block" or "none"%>">
|
||||
<label for="proxy" style="display:inline-block;min-width:10em">代理地址</label>
|
||||
<input type="text" id="proxy" name="proxy" value="<%=is_edit and it.proxy or ''%>" style="width:60%"/>
|
||||
<div style="color:#808080;margin-top:0.25em">通过代理下载订阅(留空则不使用),支持 http:// 或 socks5:// 地址,如 socks5://192.168.5.1:1080</div>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em">启用定时更新</label>
|
||||
<input type="checkbox" name="cron_enable" value="1" <%=cron_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('cron_time_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
</div>
|
||||
<div id="cron_time_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=cron_enabled and "block" or "none"%>">
|
||||
<label style="display:inline-block;min-width:10em;vertical-align:middle">定时更新时间</label>
|
||||
<span style="display:inline-flex;flex-wrap:wrap;gap:0.5em;vertical-align:middle">
|
||||
<select name="cron_min"><% for i=0,59 do %><option value="<%=i%>" <%=tonumber(cm)==i and "selected" or ""%>><%=i%></option><% end %></select> 分钟
|
||||
<select name="cron_hour"><% for i=0,23 do %><option value="<%=i%>" <%=tonumber(ch)==i and "selected" or ""%>><%=i%></option><% end %></select> 小时
|
||||
<select name="cron_dom"><% for _,v in ipairs({"*","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24","25","26","27","28","29","30","31"}) do %><option value="<%=v%>" <%=cdom==v and "selected" or ""%>><%=v%></option><% end %></select> 日
|
||||
<select name="cron_mon"><% for _,v in ipairs({"*","1","2","3","4","5","6","7","8","9","10","11","12"}) do %><option value="<%=v%>" <%=cmon==v and "selected" or ""%>><%=v%></option><% end %></select> 月
|
||||
<select name="cron_dow"><% for _,v in ipairs({"*","0","1","2","3","4","5","6"}) do %><option value="<%=v%>" <%=cdow==v and "selected" or ""%>><%=v%></option><% end %></select> 周
|
||||
</span>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em">启用规则</label>
|
||||
<input type="checkbox" name="rules_enable" value="1" <%=rules_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('rules_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
<span class="cbi-value-description">启用后,更新订阅时按关键词包含/排除、去重规则处理节点</span>
|
||||
</div>
|
||||
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">关键词包含</label>
|
||||
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">关键词排除</label>
|
||||
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em">去重</label>
|
||||
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。</span>
|
||||
</div>
|
||||
</div>
|
||||
<% if is_edit and it.last_update then %>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<span style="color:#808080">
|
||||
<%:Nodes:%> <%=it.node_count or 0%>
|
||||
<% if traffic_str then %> | <%:Remaining traffic:%> <%=traffic_str%><% end %>
|
||||
<% if time_str then %> | <%:Remaining time:%> <%=time_str%><% end %>
|
||||
| <%:Last updated:%> <%=os.date("%Y-%m-%d %H:%M", it.last_update)%>
|
||||
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=it.error%></span><% end %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="cbi-page-actions">
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,172 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local id = http.formvalue("id") or ""
|
||||
local it = id ~= "" and core.get(id) or nil
|
||||
local is_edit = it ~= nil
|
||||
local rules_enabled = it and (it.rules_enable == true or it.rules_enable == "1") or false
|
||||
local ki = (it and it.keyword_include) or ""
|
||||
local ke = (it and it.keyword_exclude) or ""
|
||||
local dedup = it and (it.dedup == "1" or it.dedup == true)
|
||||
local local_mode = (it and it.local_mode) or "text"
|
||||
local content = (it and it.raw_content) or ""
|
||||
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
|
||||
local util = require("substore.util")
|
||||
local js_content = (util.json_encode(content) or '""'):gsub("</", "<\\/")
|
||||
%>
|
||||
<h2><%=is_edit and luci.i18n.translate("Edit local subscription") or luci.i18n.translate("Add local subscription")%></h2>
|
||||
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore', is_edit and 'local_save' or 'local_create')%>" class="cbi-map"
|
||||
onsubmit="return substore_local_prepare()">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<% if is_edit then %><input type="hidden" name="id" value="<%=it.id%>"/><% end %>
|
||||
|
||||
<div class="cbi-section">
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
|
||||
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Subscription info%></label>
|
||||
<div style="display:inline-block;width:80%">
|
||||
<div style="margin-bottom:0.5em">
|
||||
<select name="local_mode" id="local_mode_sel" onchange="substore_local_switch(this.value)">
|
||||
<option value="text" <%=local_mode=="text" and "selected" or ""%>><%:Text import%></option>
|
||||
<option value="form" <%=local_mode=="form" and "selected" or ""%>><%:Form import%></option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="text_area_wrap" style="display:<%=local_mode=="text" and "block" or "none"%>">
|
||||
<textarea id="content_text" name="content" rows="16" style="width:100%"
|
||||
<%=local_mode=="text" and "" or "disabled"%>><%=luci.util.pcdata(content)%></textarea>
|
||||
<div style="color:#808080;margin-top:0.25em"><%:Paste YAML/URI/JSON node list here. Mixed protocols supported.%></div>
|
||||
</div>
|
||||
<div id="form_nodes_container" style="display:<%=local_mode=="form" and "block" or "none"%>">
|
||||
<div style="margin-bottom:0.5em;color:#808080"><%:Add nodes via form below. Each node will be saved as JSON and parsed on save.%></div>
|
||||
<div id="nodes_list"></div>
|
||||
<button type="button" class="cbi-button cbi-button-apply" onclick="addNodeRow()">+ <%:Add node%></button>
|
||||
<input type="hidden" name="content" id="form_content" value=""
|
||||
<%=local_mode=="form" and "" or "disabled"%>/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<label style="display:inline-block;min-width:10em"><%:Enable rules%></label>
|
||||
<input type="checkbox" name="rules_enable" value="1" <%=rules_enabled and "checked" or ""%>
|
||||
onchange="var r=document.getElementById('rules_row');if(r)r.style.display=this.checked?'block':'none';"/>
|
||||
<span class="cbi-value-description"><%:When enabled, apply keyword include/exclude and dedup rules to nodes%></span>
|
||||
</div>
|
||||
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em"><%:Keyword include%></label>
|
||||
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em"><%:Keyword exclude%></label>
|
||||
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
|
||||
</div>
|
||||
<div class="cbi-section-descr" style="margin:0.25em 0">
|
||||
<label style="display:inline-block;min-width:10em"><%:Dedup%></label>
|
||||
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
|
||||
</div>
|
||||
</div>
|
||||
<% if is_edit and it.last_update then %>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0">
|
||||
<span style="color:#808080">
|
||||
<%:Nodes:%> <%=it.node_count or 0%>
|
||||
| <%:Last updated:%> <%=os.date("%Y-%m-%d %H:%M", it.last_update)%>
|
||||
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=it.error%></span><% end %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="cbi-page-actions">
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// 编辑时的初始内容(文本或 JSON),由服务端注入
|
||||
var SUBSTORE_INIT_CONTENT = <%=js_content%>;
|
||||
var SUBSTORE_INIT_MODE = "<%=local_mode%>";
|
||||
|
||||
// 字段标签与界面文本(nodeform.js 读取;翻译由服务端按系统语言渲染)
|
||||
var FIELD_LABELS = {
|
||||
"name": "<%:Name%>",
|
||||
"group": "<%:Group%>",
|
||||
"protocol": "<%:Protocol%>",
|
||||
"server": "<%:Server%>",
|
||||
"port": "<%:Port%>",
|
||||
"password": "<%:Password%>",
|
||||
"uuid": "UUID",
|
||||
"alterId": "Alter ID",
|
||||
"cipher": "<%:Cipher%>",
|
||||
"method": "<%:Encryption method%>",
|
||||
"security": "<%:Security%>",
|
||||
"net": "<%:Network%>",
|
||||
"headerType": "<%:Camouflage type%>",
|
||||
"flow": "Flow",
|
||||
"path": "<%:Path%>",
|
||||
"host": "Host",
|
||||
"sni": "SNI",
|
||||
"tls": "TLS",
|
||||
"obfs": "<%:Obfs%>",
|
||||
"obfs-param": "<%:Obfs param%>",
|
||||
"obfs-password": "<%:Obfs password%>",
|
||||
"protocol-param": "<%:Protocol param%>",
|
||||
"udp": "UDP",
|
||||
"skip-cert-verify": "<%:Skip cert verify%>",
|
||||
"private-key": "<%:Private key%>",
|
||||
"peer-public-key": "<%:Peer public key%>"
|
||||
};
|
||||
var NODEFORM_I18N = {
|
||||
"node": "<%:Node%>",
|
||||
"defaultOpt": "<%:Default%>"
|
||||
};
|
||||
</script>
|
||||
<script src="/luci-static/resources/substore/nodeform.js?v=2.1.3"></script>
|
||||
<script>
|
||||
// 切换导入模式:仅切换显示/禁用,不提交表单
|
||||
function substore_local_switch(mode) {
|
||||
document.getElementById("text_area_wrap").style.display = (mode === "text") ? "block" : "none";
|
||||
document.getElementById("form_nodes_container").style.display = (mode === "form") ? "block" : "none";
|
||||
document.getElementById("content_text").disabled = (mode !== "text");
|
||||
document.getElementById("form_content").disabled = (mode !== "form");
|
||||
if (mode === "form" && document.getElementById("nodes_list").children.length === 0) addNodeRow();
|
||||
}
|
||||
|
||||
// 提交前:表单模式把节点序列化为 JSON 写入隐藏 content 字段
|
||||
function substore_local_prepare() {
|
||||
var mode = document.getElementById("local_mode_sel").value;
|
||||
if (mode === "form") {
|
||||
var hidden = document.getElementById("form_content");
|
||||
hidden.disabled = false;
|
||||
hidden.value = JSON.stringify(collectNodes());
|
||||
document.getElementById("content_text").disabled = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initLocalForm() {
|
||||
if (SUBSTORE_INIT_MODE !== "form") return;
|
||||
var data = null;
|
||||
try {
|
||||
data = JSON.parse(SUBSTORE_INIT_CONTENT || "[]");
|
||||
} catch (e) {
|
||||
data = null;
|
||||
}
|
||||
if (data && Object.prototype.toString.call(data) === "[object Array]" && data.length > 0) {
|
||||
for (var i = 0; i < data.length; i++) addNodeRow(data[i]);
|
||||
} else {
|
||||
addNodeRow();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initLocalForm);
|
||||
} else {
|
||||
initLocalForm();
|
||||
}
|
||||
</script>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,99 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local util = require("substore.util")
|
||||
local id = http.formvalue("id") or ""
|
||||
local idx = tonumber(http.formvalue("idx") or "") or 0
|
||||
local meta = id ~= "" and core.get(id) or nil
|
||||
local nodes = id ~= "" and core.read_nodes(id) or {}
|
||||
local n = nodes[idx]
|
||||
-- 注入 JS 的节点对象:转义 "</" 防止提前闭合 script
|
||||
local js_node = n and (util.json_encode(n):gsub("</", "<\\/")) or "null"
|
||||
local back_url = luci.dispatcher.build_url('admin/services/substore/nodes') .. "?id=" .. luci.util.urlencode(id)
|
||||
%>
|
||||
<h2><%:Edit node%> — <%=meta and meta.name or ""%></h2>
|
||||
|
||||
<% if not n then %>
|
||||
<div class="cbi-section">
|
||||
<p style="color:#cc0000"><%:Node not found.%></p>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=back_url%>"><%:Back%></a>
|
||||
</div>
|
||||
<% else %>
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_save')%>" class="cbi-map"
|
||||
onsubmit="return node_edit_prepare()">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value="<%=id%>"/>
|
||||
<input type="hidden" name="idx" value="<%=idx%>"/>
|
||||
<div class="cbi-section">
|
||||
<div id="nodes_list"></div>
|
||||
<input type="hidden" name="content" id="form_content" value=""/>
|
||||
<div class="cbi-section-descr" style="margin:0.5em 0;color:#808080">
|
||||
<%:Note: edits to this node are overwritten the next time the subscription is updated or re-saved.%>
|
||||
</div>
|
||||
<div class="cbi-page-actions">
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Save%>"/>
|
||||
<a class="cbi-button cbi-button-reset" href="<%=back_url%>"><%:Back%></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
var FIELD_LABELS = {
|
||||
"name": "<%:Name%>",
|
||||
"group": "<%:Group%>",
|
||||
"protocol": "<%:Protocol%>",
|
||||
"server": "<%:Server%>",
|
||||
"port": "<%:Port%>",
|
||||
"password": "<%:Password%>",
|
||||
"uuid": "UUID",
|
||||
"alterId": "Alter ID",
|
||||
"cipher": "<%:Cipher%>",
|
||||
"method": "<%:Encryption method%>",
|
||||
"security": "<%:Security%>",
|
||||
"net": "<%:Network%>",
|
||||
"headerType": "<%:Camouflage type%>",
|
||||
"flow": "Flow",
|
||||
"path": "<%:Path%>",
|
||||
"host": "Host",
|
||||
"sni": "SNI",
|
||||
"tls": "TLS",
|
||||
"obfs": "<%:Obfs%>",
|
||||
"obfs-param": "<%:Obfs param%>",
|
||||
"obfs-password": "<%:Obfs password%>",
|
||||
"protocol-param": "<%:Protocol param%>",
|
||||
"udp": "UDP",
|
||||
"skip-cert-verify": "<%:Skip cert verify%>",
|
||||
"private-key": "<%:Private key%>",
|
||||
"peer-public-key": "<%:Peer public key%>"
|
||||
};
|
||||
var NODEFORM_I18N = {
|
||||
"node": "<%:Node%>",
|
||||
"defaultOpt": "<%:Default%>"
|
||||
};
|
||||
var NODE_EDIT_DATA = <%=js_node%>;
|
||||
</script>
|
||||
<script src="/luci-static/resources/substore/nodeform.js?v=2.1.3"></script>
|
||||
<script>
|
||||
function node_edit_prepare() {
|
||||
var arr = collectNodes();
|
||||
if (arr.length === 0) return false;
|
||||
document.getElementById("form_content").value = JSON.stringify(arr);
|
||||
return true;
|
||||
}
|
||||
|
||||
function initNodeEdit() {
|
||||
var row = addNodeRow(NODE_EDIT_DATA);
|
||||
// 编辑页只有一个节点行,不允许移除
|
||||
var rm = row && row.querySelector(".node_remove");
|
||||
if (rm) rm.style.display = "none";
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initNodeEdit);
|
||||
} else {
|
||||
initNodeEdit();
|
||||
}
|
||||
</script>
|
||||
<% end %>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,203 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local node = require("substore.node")
|
||||
local ctx = luci.dispatcher.context or {}
|
||||
local q = ctx.query or {}
|
||||
local id = http.formvalue("id") or q.id or ""
|
||||
local meta = id ~= "" and core.get(id) or nil
|
||||
local nodes = id ~= "" and core.read_nodes(id) or {}
|
||||
local proto = http.formvalue("proto") or q.proto or ""
|
||||
local keyword = http.formvalue("keyword") or q.keyword or ""
|
||||
local group = http.formvalue("group") or q.group or ""
|
||||
local sort_by = http.formvalue("sort") or q.sort or "name"
|
||||
local sort_desc = http.formvalue("desc") == "1" or q.desc == "1"
|
||||
|
||||
-- 标注原始数组下标(filter 保留表引用、sort 原地排序,下标可穿透筛选排序)
|
||||
for i, nn in ipairs(nodes) do nn.__idx = i end
|
||||
-- 分组下拉选项:当前订阅节点的去重分组集合
|
||||
local group_set, group_list = {}, {}
|
||||
for _, nn in ipairs(nodes) do
|
||||
local g = nn.group
|
||||
if g and g ~= "" and not group_set[g] then
|
||||
group_set[g] = true
|
||||
group_list[#group_list + 1] = g
|
||||
end
|
||||
end
|
||||
table.sort(group_list)
|
||||
|
||||
if proto ~= "" then nodes = node.filter(nodes, {proto = proto}) end
|
||||
if group ~= "" then nodes = node.filter(nodes, {group = group}) end
|
||||
if keyword ~= "" then nodes = node.filter(nodes, {keyword = keyword}) end
|
||||
nodes = node.sort(nodes, sort_by, sort_desc)
|
||||
local node_edit_base = luci.dispatcher.build_url('admin/services/substore/node_edit')
|
||||
%>
|
||||
<h2><%:Nodes%> — <%=meta and meta.name or ""%></h2>
|
||||
<div style="margin:0.5em 0">
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
|
||||
<form method="get" action="<%=luci.dispatcher.build_url('admin/services/substore/nodes')%>" class="cbi-map">
|
||||
<input type="hidden" name="id" value="<%=id%>"/>
|
||||
<div style="display:flex;gap:0.5em;align-items:center;flex-wrap:wrap;margin:0.5em 0">
|
||||
<label><%:Protocol%>:
|
||||
<select name="proto">
|
||||
<option value=""><%:All%></option>
|
||||
<% for _, p in ipairs({"vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"}) do %>
|
||||
<option value="<%=p%>" <%=proto==p and "selected" or ""%>><%=p%></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</label>
|
||||
<label><%:Group%>:
|
||||
<select name="group">
|
||||
<option value=""><%:All%></option>
|
||||
<% for _, g in ipairs(group_list) do %>
|
||||
<option value="<%=luci.util.pcdata(g)%>" <%=group==g and "selected" or ""%>><%=g%></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</label>
|
||||
<label><%:Keyword%>: <input type="text" name="keyword" value="<%=keyword%>"/></label>
|
||||
<label><%:Sort by%>:
|
||||
<select name="sort">
|
||||
<option value="name" <%=sort_by=="name" and "selected" or ""%>>name</option>
|
||||
<option value="server" <%=sort_by=="server" and "selected" or ""%>>server</option>
|
||||
<option value="proto" <%=sort_by=="proto" and "selected" or ""%>>proto</option>
|
||||
<option value="port" <%=sort_by=="port" and "selected" or ""%>>port</option>
|
||||
<option value="group" <%=sort_by=="group" and "selected" or ""%>>group</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="margin-left:0.5em"><input type="checkbox" name="desc" value="1" <%=sort_desc and "checked" or ""%>/> <%:Desc%></label>
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Filter%>"/>
|
||||
<button type="button" class="cbi-button" onclick="runProbe('ping')">Ping</button>
|
||||
<button type="button" class="cbi-button" onclick="runProbe('tcping')">TCPing</button>
|
||||
<button type="button" class="cbi-button" onclick="runProbe('url')"><%:URL Test%></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table class="cbi-section-table">
|
||||
<tr class="cbi-section-table-titles">
|
||||
<th><%:Name%></th>
|
||||
<th><%:Group%></th>
|
||||
<th><%:Protocol%></th>
|
||||
<th><%:Server%></th>
|
||||
<th><%:Port%></th>
|
||||
<th><%:Latency%></th>
|
||||
<th><%:Actions%></th>
|
||||
</tr>
|
||||
<% for _, n in ipairs(nodes) do %>
|
||||
<tr class="cbi-section-table-row" data-server="<%=n.server or ""%>" data-port="<%=n.port or ""%>">
|
||||
<td><%=n.name or ""%></td>
|
||||
<td>
|
||||
<input type="text" class="group-input" style="width:8em" value="<%=luci.util.pcdata(n.group or "")%>"
|
||||
data-idx="<%=n.__idx%>" onchange="substore_set_group(this)"/>
|
||||
</td>
|
||||
<td><%=n.proto or ""%></td>
|
||||
<td><%=n.server or ""%></td>
|
||||
<td><%=n.port or ""%></td>
|
||||
<td class="node-latency">-</td>
|
||||
<td style="white-space:nowrap">
|
||||
<a class="cbi-button cbi-button-apply" href="<%=node_edit_base%>?id=<%=luci.util.urlencode(id)%>&idx=<%=n.__idx%>"><%:Edit%></a>
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_delete')%>" style="display:inline"
|
||||
onsubmit="return confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete this node?'))%>');">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value="<%=id%>"/>
|
||||
<input type="hidden" name="idx" value="<%=n.__idx%>"/>
|
||||
<input type="hidden" name="proto" value="<%=proto%>"/>
|
||||
<input type="hidden" name="keyword" value="<%=keyword%>"/>
|
||||
<input type="hidden" name="group" value="<%=group%>"/>
|
||||
<input type="hidden" name="sort" value="<%=sort_by%>"/>
|
||||
<% if sort_desc then %><input type="hidden" name="desc" value="1"/><% end %>
|
||||
<input type="submit" class="cbi-button cbi-button-reset" value="<%:Delete%>"/>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% if #nodes == 0 then %>
|
||||
<tr class="cbi-section-table-row"><td colspan="7" style="text-align:center;color:#808080"><%:No nodes found.%></td></tr>
|
||||
<% end %>
|
||||
</table>
|
||||
<div id="probe_area" style="display:none;margin:0.5em 0">
|
||||
<div id="probe_summary" style="margin:0.5em 0;color:#808080"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
function substore_set_group(el) {
|
||||
var oldBg = el.style.background;
|
||||
el.disabled = true;
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '<%=luci.dispatcher.build_url('admin/services/substore/node_set_group')%>', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState !== 4) return;
|
||||
el.disabled = false;
|
||||
var ok = false;
|
||||
try { ok = xhr.status === 200 && JSON.parse(xhr.responseText).ok === true; } catch (e) {}
|
||||
if (ok) {
|
||||
el.style.background = '#e6ffed';
|
||||
setTimeout(function() { el.style.background = oldBg; }, 800);
|
||||
} else {
|
||||
el.style.background = '#ffe6e6';
|
||||
alert('<%:Failed to set group%>');
|
||||
}
|
||||
};
|
||||
xhr.send('id=' + encodeURIComponent('<%=id%>')
|
||||
+ '&idx=' + encodeURIComponent(el.getAttribute('data-idx'))
|
||||
+ '&group=' + encodeURIComponent(el.value)
|
||||
+ '&token=' + encodeURIComponent('<%=token%>'));
|
||||
}
|
||||
function runProbe(mode) {
|
||||
var area = document.getElementById('probe_area');
|
||||
var sum = document.getElementById('probe_summary');
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '<%=luci.dispatcher.build_url('admin/services/substore/probe')%>', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
area.style.display = 'none';
|
||||
sum.textContent = 'Testing...';
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState !== 4) return;
|
||||
if (xhr.status !== 200) {
|
||||
sum.textContent = 'Test failed (HTTP ' + xhr.status + ')';
|
||||
return;
|
||||
}
|
||||
var r;
|
||||
try { r = JSON.parse(xhr.responseText); } catch (e) { sum.textContent = 'Parse error'; return; }
|
||||
// Update latency cells in the main table by matching server+port
|
||||
for (var i = 0; i < r.results.length; i++) {
|
||||
var it = r.results[i];
|
||||
var lat = (it.latency === null || it.latency === undefined)
|
||||
? '<span style="color:#cc0000">Fail</span>'
|
||||
: Math.round(it.latency) + ' ms';
|
||||
// Find row by server and port
|
||||
var rows = document.querySelectorAll('tr[data-server][data-port]');
|
||||
for (var j = 0; j < rows.length; j++) {
|
||||
var row = rows[j];
|
||||
var server = row.getAttribute('data-server');
|
||||
var port = row.getAttribute('data-port');
|
||||
if (server === (it.server || '') && port === String(it.port || '')) {
|
||||
var cell = row.querySelector('.node-latency');
|
||||
if (cell) {
|
||||
cell.innerHTML = lat;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var avgTxt = (r.avg === null || r.avg === undefined) ? '' : ', avg ' + r.avg + ' ms';
|
||||
sum.textContent = r.ok_count + ' / ' + r.total + ' ok' + avgTxt;
|
||||
};
|
||||
var idEl = document.getElementsByName('id')[0];
|
||||
var protoEl = document.getElementsByName('proto')[0];
|
||||
var kwEl = document.getElementsByName('keyword')[0];
|
||||
var params = 'id=' + encodeURIComponent(idEl ? idEl.value : '')
|
||||
+ '&mode=' + encodeURIComponent(mode)
|
||||
+ '&proto=' + encodeURIComponent(protoEl ? protoEl.value : '')
|
||||
+ '&keyword=' + encodeURIComponent(kwEl ? kwEl.value : '')
|
||||
+ '&token=' + encodeURIComponent('<%=token%>');
|
||||
xhr.send(params);
|
||||
}
|
||||
</script>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,63 @@
|
||||
<%+header%>
|
||||
<%
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local output = require("substore.output")
|
||||
local util = require("substore.util")
|
||||
local ctx = luci.dispatcher.context or {}
|
||||
local q = ctx.query or {}
|
||||
local id = http.formvalue("id") or q.id or ""
|
||||
local fmt = http.formvalue("fmt") or q.fmt or "clashmeta"
|
||||
local items = core.list()
|
||||
local meta = id ~= "" and core.get(id) or nil
|
||||
local nodes = {}
|
||||
if id ~= "" then
|
||||
nodes = core.read_nodes(id)
|
||||
else
|
||||
-- 合并所有已启用的订阅
|
||||
local ids = {}
|
||||
for _, it in ipairs(items) do
|
||||
if it.enabled then ids[#ids + 1] = it.id end
|
||||
end
|
||||
nodes = core.merge(ids)
|
||||
end
|
||||
local out, err = output.generate(nodes, fmt)
|
||||
%>
|
||||
<h2><%:Output%> — <%=meta and meta.name or luci.i18n.translate("Merged")%></h2>
|
||||
<div style="margin:0.5em 0">
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
</div>
|
||||
|
||||
<form method="get" action="<%=luci.dispatcher.build_url('admin/services/substore/output')%>" class="cbi-map">
|
||||
<input type="hidden" name="id" value="<%=id%>"/>
|
||||
<div style="display:flex;gap:0.5em;align-items:center;margin:0.5em 0">
|
||||
<label><%:Format%>:
|
||||
<select name="fmt">
|
||||
<option value="clashmeta" <%=fmt=="clashmeta" and "selected" or ""%>>Clash.Meta / Mihomo</option>
|
||||
<option value="stash" <%=fmt=="stash" and "selected" or ""%>>Stash</option>
|
||||
<option value="surge" <%=fmt=="surge" and "selected" or ""%>>Surge</option>
|
||||
<option value="surfboard" <%=fmt=="surfboard" and "selected" or ""%>>Surfboard</option>
|
||||
<option value="surgemac" <%=fmt=="surgemac" and "selected" or ""%>>SurgeMac</option>
|
||||
<option value="loon" <%=fmt=="loon" and "selected" or ""%>>Loon</option>
|
||||
<option value="egern" <%=fmt=="egern" and "selected" or ""%>>Egern</option>
|
||||
<option value="qx" <%=fmt=="qx" and "selected" or ""%>>Quantumult X</option>
|
||||
<option value="shadowrocket" <%=fmt=="shadowrocket" and "selected" or ""%>>Shadowrocket</option>
|
||||
<option value="singbox" <%=fmt=="singbox" and "selected" or ""%>>sing-box</option>
|
||||
<option value="v2ray" <%=fmt=="v2ray" and "selected" or ""%>>V2Ray</option>
|
||||
<option value="v2rayuri" <%=fmt=="v2rayuri" and "selected" or ""%>>V2Ray URI</option>
|
||||
<option value="plain" <%=fmt=="plain" and "selected" or ""%>>Plain JSON</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Generate%>"/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<% if err then %>
|
||||
<div style="color:#cc0000"><%=err%></div>
|
||||
<% else %>
|
||||
<textarea id="out_text" readonly style="width:100%;height:300px;font-family:monospace"><%=luci.util.pcdata(out)%></textarea>
|
||||
<div style="margin-top:0.5em">
|
||||
<button onclick="var t=document.getElementById('out_text');t.select();document.execCommand('copy');" class="cbi-button cbi-button-apply"><%:Copy%></button>
|
||||
</div>
|
||||
<% end %>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1,157 @@
|
||||
<%+header%>
|
||||
<h2><%:Subscriptions%></h2>
|
||||
|
||||
<div class="cbi-section">
|
||||
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/form')%>">
|
||||
<%:Add subscription link%>
|
||||
</a>
|
||||
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/localform')%>">
|
||||
<%:Add local subscription%>
|
||||
</a>
|
||||
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/combo')%>">
|
||||
<%:Add combination%>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="cbi-section-table">
|
||||
<tr class="cbi-section-table-titles">
|
||||
<th><%:Name%></th>
|
||||
<th><%:URL%></th>
|
||||
<th><%:Nodes%></th>
|
||||
<th><%:Updated%></th>
|
||||
<th><%:Status%></th>
|
||||
<th><%:Subscription link%></th>
|
||||
<th><%:Actions%></th>
|
||||
</tr>
|
||||
|
||||
<%
|
||||
local core = require("substore.core")
|
||||
local http_env = require("luci.http")
|
||||
local items = core.list()
|
||||
local base = luci.dispatcher.build_url("substore", "download")
|
||||
local scheme = (http_env.getenv("HTTPS") and http_env.getenv("HTTPS") ~= "off") and "https" or "http"
|
||||
local host = http_env.getenv("HTTP_HOST")
|
||||
or (http_env.getenv("SERVER_NAME") and http_env.getenv("SERVER_NAME") .. ":" .. (http_env.getenv("SERVER_PORT") or "80"))
|
||||
or "127.0.0.1"
|
||||
local prefix = scheme .. "://" .. host .. base
|
||||
local name_by_id = {}
|
||||
for _, it2 in ipairs(items) do name_by_id[it2.id] = it2.name end
|
||||
for _, it in ipairs(items) do
|
||||
local status_color = "#808080"
|
||||
local status_text = luci.i18n.translate("Pending")
|
||||
if it.error and it.error ~= "" then
|
||||
status_color = "#cc0000"
|
||||
status_text = it.error
|
||||
elseif it.node_count and it.node_count > 0 then
|
||||
status_color = "#006600"
|
||||
status_text = luci.i18n.translate("OK")
|
||||
end
|
||||
local sub_token = core.ensure_token(it.id)
|
||||
local sub_link_base = prefix .. "?token=" .. sub_token .. "&target="
|
||||
%>
|
||||
<tr class="cbi-section-table-row">
|
||||
<td>
|
||||
<% if it.combo then %>
|
||||
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=it.name%>
|
||||
<% else %>
|
||||
<%=it.name%>
|
||||
<% end %>
|
||||
<% if it.cron_enable == true or it.cron_enable == "1" then %>
|
||||
<div class="cbi-value-description">定时: <%=luci.util.pcdata(it.cron_time or "")%></div>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="cbi-value-description" style="word-break:break-all"><%
|
||||
if it.combo then
|
||||
local names = {}
|
||||
if type(it.sources) == "table" then
|
||||
for _, sid in ipairs(it.sources) do names[#names + 1] = name_by_id[sid] or sid end
|
||||
end
|
||||
%><span style="color:#808080"><%=table.concat(names, " + ")%></span><%
|
||||
elseif it["local"] then
|
||||
%><span style="color:#0066aa">[<%:Local%>]</span><%
|
||||
else
|
||||
%><%=it.url%><%
|
||||
end
|
||||
%></td>
|
||||
<td><%=it.node_count or 0%></td>
|
||||
<td><%=it.last_update and os.date("%Y-%m-%d %H:%M", it.last_update) or "-"%></td>
|
||||
<td style="color:<%=status_color%>"><%=status_text%></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:0.25em;align-items:center">
|
||||
<select id="fmt_<%=it.id%>" data-base="<%=luci.util.pcdata(sub_link_base)%>"
|
||||
onchange="substore_setlink(this, '<%=it.id%>')">
|
||||
<option value=""><%:Select format%></option>
|
||||
<option value="clashmeta">Clash.Meta / Mihomo</option>
|
||||
<option value="stash">Stash</option>
|
||||
<option value="surge">Surge</option>
|
||||
<option value="surfboard">Surfboard</option>
|
||||
<option value="surgemac">SurgeMac</option>
|
||||
<option value="loon">Loon</option>
|
||||
<option value="egern">Egern</option>
|
||||
<option value="qx">Quantumult X</option>
|
||||
<option value="shadowrocket">Shadowrocket</option>
|
||||
<option value="singbox">sing-box</option>
|
||||
<option value="v2ray">V2Ray</option>
|
||||
<option value="v2rayuri">V2Ray URI</option>
|
||||
<option value="plain">Plain JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="linkwrap_<%=it.id%>" style="display:none;margin-top:0.25em">
|
||||
<div style="display:flex;gap:0.25em;align-items:center">
|
||||
<input id="link_<%=it.id%>" type="text" readonly style="width:280px" value=""/>
|
||||
<button type="button" class="cbi-button cbi-button-apply"
|
||||
onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value-description"><%:选择格式后生成订阅链接,格式可随时切换%></div>
|
||||
</td>
|
||||
<td>
|
||||
<% if it["local"] then %>
|
||||
<span class="cbi-button cbi-button-action" style="opacity:0.5;cursor:not-allowed"><%:Update%></span>
|
||||
<% else %>
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/update')%>" style="display:inline">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value="<%=it.id%>"/>
|
||||
<input type="submit" class="cbi-button cbi-button-action" value="<%:Update%>"/>
|
||||
</form>
|
||||
<% end %>
|
||||
<% if it.combo then %>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/combo')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Edit%></a>
|
||||
<% elseif it["local"] then %>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/localform')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Edit%></a>
|
||||
<% else %>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/form')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Edit%></a>
|
||||
<% end %>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/nodes')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Nodes%></a>
|
||||
<a class="cbi-button cbi-button-apply" href="<%=luci.dispatcher.build_url('admin/services/substore/output')%>?id=<%=luci.util.urlencode(it.id)%>"><%:Output%></a>
|
||||
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/delete')%>" style="display:inline"
|
||||
onsubmit="return confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete this subscription?'))%>');">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value="<%=it.id%>"/>
|
||||
<input type="submit" class="cbi-button cbi-button-reset" value="<%:Delete%>"/>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<%
|
||||
end
|
||||
if #items == 0 then
|
||||
%>
|
||||
<tr class="cbi-section-table-row">
|
||||
<td colspan="7" style="text-align:center;color:#808080"><%:No subscriptions yet.%></td>
|
||||
</tr>
|
||||
<%
|
||||
end
|
||||
%>
|
||||
</table>
|
||||
<script>
|
||||
function substore_setlink(sel, id) {
|
||||
var wrap = document.getElementById('linkwrap_' + id);
|
||||
if (!wrap) return;
|
||||
if (!sel.value) { wrap.style.display = 'none'; return; }
|
||||
var inp = document.getElementById('link_' + id);
|
||||
var base = sel.getAttribute('data-base') || '';
|
||||
inp.value = base + encodeURIComponent(sel.value);
|
||||
wrap.style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
<%+footer%>
|
||||
Reference in new issue
Block a user