mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 16:15:13 +08:00
🦄 Sync 2026-10-02 14:52:05
This commit is contained in:
1 parent
4c85793342
commit
0bfe84f1e8
133 files changed
+7983
-3225
No files matched your search
@@ -65,10 +65,38 @@ local function fv(http, key)
|
||||
return tostring(v)
|
||||
end
|
||||
|
||||
-- post_ok 失败的原因,供调用方回显(§18:失败必须让用户看见)。
|
||||
-- 每个请求是一个独立的 Lua 进程,不存在跨请求残留;同一进程内多次调用时
|
||||
-- post_ok 成功会把它清空,因此不会把上一次的失败带进这一次。
|
||||
local post_fail_msg = nil
|
||||
|
||||
-- POST 请求的 token 校验。
|
||||
--
|
||||
-- 上游 LuCI 渲染表单时,模板变量 `token` 就是 luci.dispatcher.context.authtoken
|
||||
-- (modules/luci-lua-runtime/luasrc/template.lua 的 viewns 元表:
|
||||
-- elseif key == "token" then return disp.context.authtoken),
|
||||
-- 所以「提交的 token == authtoken」正是框架自己那套判定,
|
||||
-- 不可能误拒合法表单 —— 合法表单提交的就是 authtoken。
|
||||
--
|
||||
-- 此前的判定是 `token ~= nil`:`token=`(空串)也能通过,等于没有校验。
|
||||
-- 取不到 authtoken 时(老版本 LuCI / 非标准上下文)退回原判定,
|
||||
-- 不能因为拿不到框架内部字段就把所有表单都拒掉。
|
||||
local function post_ok()
|
||||
-- 拒绝缺失 CSRF token 的 POST
|
||||
local http = require("luci.http")
|
||||
return fv(http, "token") ~= nil
|
||||
local tok = fv(http, "token")
|
||||
if tok == nil then
|
||||
post_fail_msg = "请求校验失败(缺少 token),请刷新页面后重试"
|
||||
return false
|
||||
end
|
||||
local ok, authtoken = pcall(function()
|
||||
return require("luci.dispatcher").context.authtoken
|
||||
end)
|
||||
if ok and type(authtoken) == "string" and authtoken ~= "" and tok ~= authtoken then
|
||||
post_fail_msg = "请求校验失败(token 不匹配),请刷新页面后重试"
|
||||
return false
|
||||
end
|
||||
post_fail_msg = nil
|
||||
return true
|
||||
end
|
||||
|
||||
-- read cron fields from form, validate cron expression, return cron_enable cron_time
|
||||
@@ -90,6 +118,20 @@ local function read_cron_fields()
|
||||
return cron_enable, cron_time
|
||||
end
|
||||
|
||||
-- read subscription client type (User-Agent) from form, return ua string, err
|
||||
--
|
||||
-- 部分机场按 User-Agent 决定返回真实节点还是占位内容。表单提交的是
|
||||
-- 「预设 key(ua_preset)+ 自定义文本(ua_custom)」,这里解析成最终要存的 UA。
|
||||
-- 非法值必须回显(与规则校验同理):静默存空会让用户以为设置生效了,
|
||||
-- 实际更新出来的仍是占位节点。
|
||||
local function read_ua_fields()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local ua, err = core.resolve_user_agent(fv(http, "ua_preset"), fv(http, "ua_custom"))
|
||||
if not ua then return nil, err end
|
||||
return ua
|
||||
end
|
||||
|
||||
-- read subscription level rules from form, return rules table, err
|
||||
--
|
||||
-- 第二个返回值是**用户可见**的校验错误。目前只有重命名规则的正则需要校验:
|
||||
@@ -135,8 +177,10 @@ function action_create()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local user_agent, ua_err = read_ua_fields()
|
||||
if user_agent == nil then return back_to_list(ua_err or "订阅客户端类型无效") end
|
||||
local id, err = core.add(name, url, {
|
||||
proxy_enable = proxy_enable, proxy = proxy,
|
||||
proxy_enable = proxy_enable, proxy = proxy, user_agent = user_agent,
|
||||
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,
|
||||
@@ -145,7 +189,7 @@ function action_create()
|
||||
if not id then return back_to_list(err or "创建订阅失败") end
|
||||
core.write_cron()
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_save()
|
||||
@@ -166,8 +210,11 @@ function action_save()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local user_agent, ua_err = read_ua_fields()
|
||||
if user_agent == nil then return back_to_list(ua_err or "订阅客户端类型无效") end
|
||||
local ok, err = core.save_meta(id, {
|
||||
name = name, url = url, proxy_enable = proxy_enable, proxy = proxy,
|
||||
user_agent = user_agent,
|
||||
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,
|
||||
@@ -176,7 +223,7 @@ function action_save()
|
||||
if not ok then return back_to_list(err or "保存失败") end
|
||||
core.write_cron()
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_local_create()
|
||||
@@ -201,7 +248,7 @@ function action_local_create()
|
||||
})
|
||||
if not id then return back_to_list(err or "创建本地订阅失败") end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_local_save()
|
||||
@@ -233,7 +280,7 @@ function action_local_save()
|
||||
local sok, serr = core.sync(id)
|
||||
if not sok then return back_to_list(serr or "解析订阅内容失败") end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_delete()
|
||||
@@ -243,11 +290,41 @@ function action_delete()
|
||||
-- §18:删除失败必须让用户看见。core.remove 对「非法 ID / 订阅不存在」
|
||||
-- 返回 false(或 false, err),此前返回值被整个丢弃 —— 用户点了删除,
|
||||
-- 页面正常跳回,订阅却还在,看起来像「删了但没生效」。
|
||||
local ok, err = core.remove(fv(http, "id") or "")
|
||||
if not ok then return back_to_list(err or "删除失败:订阅不存在") end
|
||||
core.write_cron()
|
||||
--
|
||||
-- id 支持单个(行内删除按钮)或逗号分隔多个(勾选批量删除),
|
||||
-- 与节点页 action_node_delete 的 idx 同一套约定。订阅 id 是 "s%08x",
|
||||
-- 不含逗号,切分不会切坏。
|
||||
local ids = {}
|
||||
for s in tostring(fv(http, "id") or ""):gmatch("[^,%s]+") do
|
||||
ids[#ids + 1] = s
|
||||
end
|
||||
if #ids == 0 then return back_to_list("未指定要删除的订阅") end
|
||||
local removed, first_err = 0, nil
|
||||
for _, id in ipairs(ids) do
|
||||
local ok, err = core.remove(id)
|
||||
if ok then
|
||||
removed = removed + 1
|
||||
elseif not first_err then
|
||||
first_err = err
|
||||
end
|
||||
end
|
||||
-- 部分失败也必须说:勾了 5 个只删掉 3 个却显示「成功」,
|
||||
-- 用户不会再回头管剩下那 2 个。
|
||||
--
|
||||
-- 只要**有订阅真的被删掉**就要重写 cron —— 部分失败的分支也不例外:
|
||||
-- 被删掉的订阅的 cron 行若留着,substore-cron.sh 会拿着已不存在的 id
|
||||
-- 反复执行,每次都以非 0 退出(见脚本末尾的 FAILED 判断),
|
||||
-- 在日志里刷失败、并让监控误报。
|
||||
if removed > 0 then core.write_cron() end
|
||||
if removed < #ids then
|
||||
if removed == 0 then
|
||||
return back_to_list(first_err or "删除失败:订阅不存在")
|
||||
end
|
||||
return back_to_list(string.format("已删除 %d 个,另有 %d 个删除失败",
|
||||
removed, #ids - removed))
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
-- 节点页返回链接:保留当前筛选参数
|
||||
@@ -300,7 +377,7 @@ function action_node_save()
|
||||
end
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
back_to_nodes(http)
|
||||
back_to_nodes(http, post_fail_msg)
|
||||
end
|
||||
|
||||
-- 节点删除:idx 支持单个(行内删除按钮)或逗号分隔多个(勾选批量删除)
|
||||
@@ -340,7 +417,7 @@ function action_node_delete()
|
||||
core.save_meta(id, { node_count = #nodes })
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
back_to_nodes(http)
|
||||
back_to_nodes(http, post_fail_msg)
|
||||
end
|
||||
|
||||
-- 单节点分组快速设置(XHR,JSON 响应)
|
||||
@@ -366,6 +443,9 @@ function action_node_set_group()
|
||||
http.write(util.json_encode({ ok = false, err = "write failed" }))
|
||||
return
|
||||
end
|
||||
-- 与 node_save / node_delete 一致:group 是组合订阅筛选与重命名规则的输入,
|
||||
-- 改了不同步组合的话,组合的下载链接会一直吐旧分组的数据。
|
||||
core.refresh_combos(id)
|
||||
http.write(util.json_encode({ ok = true }))
|
||||
end
|
||||
|
||||
@@ -404,7 +484,7 @@ function action_combo_save()
|
||||
end
|
||||
if not nid then return back_to_list(err or "保存组合订阅失败") end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
function action_update()
|
||||
@@ -431,7 +511,7 @@ function action_update()
|
||||
core.save_meta(id, { error = tostring(err or "更新失败"), last_update = os.time() })
|
||||
end
|
||||
end
|
||||
back_to_list()
|
||||
back_to_list(post_fail_msg)
|
||||
end
|
||||
|
||||
-- node probe endpoint: POST id + mode ping tcping url + proto + keyword, return JSON
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
end
|
||||
local rm = (it and it.rename_map) or ""
|
||||
local proxy_enabled = it and (it.proxy_enable == true or it.proxy_enable == "1") or false
|
||||
-- 订阅客户端类型(User-Agent)。部分机场按 UA 决定返回真实节点还是占位内容:
|
||||
-- 同一个订单链接只有用绑定的客户端 UA 才拿得到真节点。预设见 core.UA_PRESETS。
|
||||
local ua_preset = core.ua_preset_of(it and it.user_agent)
|
||||
local ua_custom = (ua_preset == "custom") and (it and it.user_agent or "") or ""
|
||||
local util = require("substore.util")
|
||||
-- 剩余流量 / 剩余时长(仅编辑页且有 subscription-userinfo 数据时显示,首页不显示)
|
||||
local traffic_str = nil
|
||||
@@ -61,6 +65,29 @@
|
||||
<input type="text" id="proxy" name="proxy" value="<%=luci.util.pcdata(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 for="ua_preset" style="display:inline-block;min-width:10em">订阅客户端类型</label>
|
||||
<select id="ua_preset" name="ua_preset" onchange="var r=document.getElementById('ua_custom_row');if(r)r.style.display=this.value=='custom'?'block':'none';">
|
||||
<option value="" <%=ua_preset == "" and "selected" or ""%>>默认(不设置)</option>
|
||||
<% for _, p in ipairs(core.UA_PRESETS) do %>
|
||||
<option value="<%=p.key%>" <%=ua_preset == p.key and "selected" or ""%>><%=p.label%></option>
|
||||
<% end %>
|
||||
<option value="custom" <%=ua_preset == "custom" and "selected" or ""%>>自定义</option>
|
||||
</select>
|
||||
<div style="color:#808080;margin-top:0.25em">
|
||||
部分机场按客户端类型下发节点:同一链接只有选对类型才解析得出真实节点,选错会得到「与您使用客户端不兼容」的占位节点。不确定就保持「默认」。<br/>
|
||||
「自定义」用于预设里没有的客户端:填<b>要原样发送的 User-Agent 请求头</b>,格式与预设里的值同形,即
|
||||
<code>客户端名/版本号</code>,例如 <code>clash-verge/v2.5.0</code>、<code>v2rayN/7.22.0</code>、
|
||||
<code>mihomo.party/v2.0.0 (clash.meta)</code>。<br/>
|
||||
可以含空格、括号、分号、下划线等可见字符(整串原样发送,不解析、不改写);不能含换行等控制字符,
|
||||
最长 256 字符。版本号填<b>机场要求的最低可用版本</b>,具体填什么以机场或客户端说明为准。
|
||||
选「自定义」但留空 = 不发送 User-Agent(等同「默认」)。
|
||||
</div>
|
||||
</div>
|
||||
<div id="ua_custom_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=ua_preset == "custom" and "block" or "none"%>">
|
||||
<label for="ua_custom" style="display:inline-block;min-width:10em">自定义 User-Agent</label>
|
||||
<input type="text" id="ua_custom" name="ua_custom" value="<%=luci.util.pcdata(ua_custom)%>" 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="cron_enable" value="1" <%=cron_enabled and "checked" or ""%>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<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 style="color:#808080;margin-top:0.25em"><%:Paste a node list here — one format at a time (URI / Base64 / YAML / JSON / WireGuard .conf); protocols may be mixed within that format.%></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>
|
||||
@@ -146,6 +146,8 @@ var FIELD_LABELS = {
|
||||
"obfs": "Obfs",
|
||||
"obfs-param": "Obfs Param",
|
||||
"obfs-password": "Obfs Password",
|
||||
// SIP003 插件串,形如 obfs-local;obfs=http;obfs-host=www.baidu.com
|
||||
"plugin": "Plugin (SIP003)",
|
||||
"protocol": "Protocol",
|
||||
"protocol-param": "Protocol Param",
|
||||
"udp": "UDP",
|
||||
|
||||
@@ -66,6 +66,8 @@ var FIELD_LABELS = {
|
||||
"obfs": "Obfs",
|
||||
"obfs-param": "Obfs Param",
|
||||
"obfs-password": "Obfs Password",
|
||||
// SIP003 插件串,形如 obfs-local;obfs=http;obfs-host=www.baidu.com
|
||||
"plugin": "Plugin (SIP003)",
|
||||
"protocol": "Protocol",
|
||||
"protocol-param": "Protocol Param",
|
||||
"udp": "UDP",
|
||||
|
||||
@@ -22,10 +22,20 @@
|
||||
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/combo')%>">
|
||||
<%:Add combination%>
|
||||
</a>
|
||||
<!-- 勾选批量删除。与节点页同一套交互:没勾选时点击不做任何删除(只提示)。 -->
|
||||
<button type="button" class="cbi-button cbi-button-reset" onclick="substore_delete_selected()"><%:Delete%></button>
|
||||
</div>
|
||||
|
||||
<!-- 批量删除用的隐藏表单:JS 把选中订阅的 id 收集到 id(逗号分隔)后提交。
|
||||
必须放在表格**外面** —— 每一行里已经各有一个删除表单,HTML 不允许表单嵌套。 -->
|
||||
<form id="batch_delete_form" method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/delete')%>" style="display:none">
|
||||
<input type="hidden" name="token" value="<%=token%>"/>
|
||||
<input type="hidden" name="id" value=""/>
|
||||
</form>
|
||||
|
||||
<table class="cbi-section-table">
|
||||
<tr class="cbi-section-table-titles">
|
||||
<th style="width:1em"><input type="checkbox" onclick="substore_toggle_all(this)"/></th>
|
||||
<th><%:Name%></th>
|
||||
<th><%:URL%></th>
|
||||
<th><%:Nodes%></th>
|
||||
@@ -59,9 +69,17 @@
|
||||
status_text = luci.i18n.translate("OK")
|
||||
end
|
||||
local sub_token = core.ensure_token(it.id)
|
||||
local sub_link_base = prefix .. "?token=" .. sub_token .. "&target="
|
||||
-- ensure_token 在「token 写不进列表文件」时返回 nil:此时链接必然无效,
|
||||
-- 用空 token 拼出来,让下载端报「无效的订阅 token」,
|
||||
-- 而不是在模板里对 nil 做字符串拼接直接 500。
|
||||
local sub_link_base = prefix .. "?token=" .. (sub_token or "") .. "&target="
|
||||
-- 还没解析出节点时禁用格式下拉:刚添加还没点「更新」、更新失败、
|
||||
-- 或解析结果为空的订阅,转出来的内容必然是空的。让用户能选,
|
||||
-- 等于引导他生成一个无效的订阅链接(选了也只是得到一个空结果)。
|
||||
local fmt_disabled = (tonumber(it.node_count) or 0) <= 0
|
||||
%>
|
||||
<tr class="cbi-section-table-row">
|
||||
<td><input type="checkbox" class="sub-check" value="<%=luci.util.pcdata(it.id)%>"/></td>
|
||||
<td>
|
||||
<% if it.combo then %>
|
||||
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=luci.util.pcdata(it.name or "")%>
|
||||
@@ -91,7 +109,9 @@
|
||||
<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%>')">
|
||||
onchange="substore_setlink(this, '<%=it.id%>')"<%
|
||||
if fmt_disabled then %> disabled="disabled" title="<%:Update and parse nodes before choosing a format%>"
|
||||
style="opacity:0.5;cursor:not-allowed"<% end %>>
|
||||
<option value=""><%:Select format%></option>
|
||||
<% for _, fo in ipairs(output.FORMAT_OPTIONS) do %><option value="<%=fo[1]%>"><%=fo[2]%></option><% end %>
|
||||
</select>
|
||||
@@ -103,7 +123,13 @@
|
||||
onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value-description"><%:Pick a format to generate the subscription link; you can switch formats at any time%></div>
|
||||
<div class="cbi-value-description"><%
|
||||
if fmt_disabled then
|
||||
%><%:Update and parse nodes before choosing a format%><%
|
||||
else
|
||||
%><%:Pick a format to generate the subscription link; you can switch formats at any time%><%
|
||||
end
|
||||
%></div>
|
||||
</td>
|
||||
<td>
|
||||
<% if it["local"] then %>
|
||||
@@ -137,7 +163,7 @@
|
||||
if #items == 0 then
|
||||
%>
|
||||
<tr class="cbi-section-table-row">
|
||||
<td colspan="7" style="text-align:center;color:#808080"><%:No subscriptions yet.%></td>
|
||||
<td colspan="8" style="text-align:center;color:#808080"><%:No subscriptions yet.%></td>
|
||||
</tr>
|
||||
<%
|
||||
end
|
||||
@@ -153,5 +179,20 @@ function substore_setlink(sel, id) {
|
||||
inp.value = base + encodeURIComponent(sel.value);
|
||||
wrap.style.display = 'block';
|
||||
}
|
||||
function substore_toggle_all(el) {
|
||||
var boxes = document.querySelectorAll('.sub-check');
|
||||
for (var i = 0; i < boxes.length; i++) boxes[i].checked = el.checked;
|
||||
}
|
||||
function substore_delete_selected() {
|
||||
var boxes = document.querySelectorAll('.sub-check:checked');
|
||||
// 一个都没勾选时什么都不删(否则「删除」会变成「清空全部」)。
|
||||
if (boxes.length === 0) { alert('<%:Please select subscriptions first%>'); return; }
|
||||
if (!confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete selected subscriptions?'))%>')) return;
|
||||
var ids = [];
|
||||
for (var i = 0; i < boxes.length; i++) ids.push(boxes[i].value);
|
||||
var form = document.getElementById('batch_delete_form');
|
||||
form.elements['id'].value = ids.join(',');
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
<%+footer%>
|
||||
Reference in new issue
Block a user