🌈 Sync 2026-10-01 13:09:37

This commit is contained in:
github-actions[bot] committed 2026-10-01 13:09:37 +08:00
1 parent dceb6de0e4
commit 889951b6d0
116 files changed
+6376 -1093

No files matched your search

@@ -37,22 +37,50 @@ function index()
entry({"substore", "download"}, call("action_download"), nil)
end
-- 表单字段统一取字符串。
--
-- LuCI 的 formvalue **不保证返回字符串**:同名字段提交多次时返回的是 table。
-- 依据上游 luci/http.lua 的 urldecode_message_body:
-- elseif what == parser.VALUE and name then
-- local val = msg.params[name]
-- if type(val) == "table" then val[#val+1] = ...
-- elseif val ~= nil then msg.params[name] = { val, ... } -- ← 第二次出现变成 table
-- 而 formvalue 原样返回 msg.params[name];上游 luadoc 也写着
-- "@return HTTP input value or table of all input value"。
--
-- 本项目多处直接对返回值 :gsub / util.trim / urlencode,遇到 table 会抛
-- "attempt to index a table value" → HTTP 500。攻击面不限于已登录用户:
-- /substore/download 是**无需登录**的入口(供 Passwall / OpenClash 拉取),
-- 对它 POST 一个重复的 token 字段即可触发。统一收敛后这类输入不再崩溃。
--
-- 取值语义:table 取最后一个(与「同名参数后者覆盖前者」的直觉一致),
-- nil 保持 nil(post_ok 依赖这一点区分「无 token」与「空 token」)。
--
-- 注意:必须定义在 post_ok **之前** —— Lua 的 local function 只捕获定义时
-- 已可见的局部变量,写在后面会让 post_ok 里的 fv 落到全局(nil)。
local function fv(http, key)
local v = http.formvalue(key)
if type(v) == "table" then v = v[#v] end
if v == nil then return nil end
return tostring(v)
end
local function post_ok()
-- 拒绝缺失 CSRF token 的 POST
local http = require("luci.http")
return http.formvalue("token") ~= nil
return fv(http, "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_enable = fv(http, "cron_enable") or "0"
local m = fv(http, "cron_min") or "0"
local h = fv(http, "cron_hour") or "3"
local dom = fv(http, "cron_dom") or "*"
local mon = fv(http, "cron_mon") or "*"
local dow = fv(http, "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"
@@ -65,19 +93,21 @@ end
-- read subscription level rules from form, return rules table
local function read_rules_fields()
local http = require("luci.http")
local core = require("substore.core")
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
-- 协议清单来自 core.RULE_PROTOS,与表单模板共用一份(见该常量的说明)
for _, p in ipairs(core.RULE_PROTOS) do
if fv(http, "proto_filter_"..p) then proto_list[#proto_list+1]=p end
end
local rules_enable = http.formvalue("rules_enable") or "0"
local rules_enable = fv(http, "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 "",
keyword_include = fv(http, "keyword_include") or "",
keyword_exclude = fv(http, "keyword_exclude") or "",
dedup = fv(http, "dedup") or "0",
rename_map = fv(http, "rename_map") or "",
}
end
@@ -85,11 +115,11 @@ 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"
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local url = (fv(http, "url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy_enable = fv(http, "proxy_enable") or "0"
if proxy_enable ~= "1" then proxy_enable = "0" end
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy = (fv(http, "proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
if name == "" or url == "" then
return back_to_list("名称和 URL 不能为空")
end
@@ -112,12 +142,12 @@ 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"
local id = fv(http, "id") or ""
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local url = (fv(http, "url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy_enable = fv(http, "proxy_enable") or "0"
if proxy_enable ~= "1" then proxy_enable = "0" end
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy = (fv(http, "proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
if id == "" then return back_to_list("缺少订阅 ID") end
if name == "" or url == "" then
return back_to_list("名称和 URL 不能为空")
@@ -141,9 +171,9 @@ 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 name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = fv(http, "content") or ""
local local_mode = fv(http, "local_mode") or "text"
local rules = read_rules_fields()
-- §19:空名称 / 空内容必须明确报错,不能无声创建无效订阅
if name == "" then return back_to_list("名称不能为空") end
@@ -164,10 +194,10 @@ 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 id = fv(http, "id") or ""
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = fv(http, "content") or ""
local local_mode = fv(http, "local_mode") or "text"
local rules = read_rules_fields()
if id == "" then return back_to_list("缺少订阅 ID") end
if name == "" then return back_to_list("名称不能为空") end
@@ -194,7 +224,11 @@ function action_delete()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
core.remove(http.formvalue("id") or "")
-- §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()
end
back_to_list()
@@ -204,10 +238,10 @@ end
-- 返回节点页。err 非空时把失败原因带到页面显示(§18:失败必须让用户看见,
-- 不能「失败了却看起来像成功」)。与 back_to_list 同一套约定。
local function back_to_nodes(http, err)
local id = http.formvalue("id") or ""
local id = fv(http, "id") or ""
local qs = "?id=" .. luci.util.urlencode(id)
for _, k in ipairs({ "proto", "keyword", "group", "sort", "desc" }) do
local v = http.formvalue(k)
local v = fv(http, k)
if v and v ~= "" then qs = qs .. "&" .. k .. "=" .. luci.util.urlencode(v) end
end
if err ~= nil and tostring(err) ~= "" then
@@ -222,9 +256,9 @@ function action_node_save()
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 id = fv(http, "id") or ""
local idx = tonumber(fv(http, "idx") or "")
local content = fv(http, "content") or ""
local nodes = core.read_nodes(id)
-- §18:任何一条失败路径都必须把原因带回页面。
-- 原来只在成功分支里做事,其余情况一律静默重定向,
@@ -258,14 +292,18 @@ 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_param = http.formvalue("idx") or ""
if type(idx_param) == "table" then idx_param = table.concat(idx_param, ",") end
local id = fv(http, "id") or ""
local idx_param = fv(http, "idx") or ""
local nodes = core.read_nodes(id)
local idxs = {}
for s in tostring(idx_param):gmatch("%d+") do
idxs[#idxs + 1] = tonumber(s)
end
-- §18:什么都没删掉时必须说清楚,不能静默跳回列表。
if #idxs == 0 then
back_to_nodes(http, "未指定要删除的节点")
return
end
-- 倒序删除,避免 table.remove 后下标偏移
table.sort(idxs, function(a, b) return a > b end)
local removed = false
@@ -275,10 +313,16 @@ function action_node_delete()
removed = true
end
end
if removed and core.write_nodes(id, nodes) then
core.save_meta(id, { node_count = #nodes })
core.refresh_combos(id)
if not removed then
back_to_nodes(http, "节点不存在或已被删除")
return
end
if not core.write_nodes(id, nodes) then
back_to_nodes(http, "写入节点数据失败")
return
end
core.save_meta(id, { node_count = #nodes })
core.refresh_combos(id)
end
back_to_nodes(http)
end
@@ -293,9 +337,9 @@ function action_node_set_group()
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 id = fv(http, "id") or ""
local idx = tonumber(fv(http, "idx") or "")
local group = util.trim(fv(http, "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" }))
@@ -315,27 +359,32 @@ function action_combo_save()
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 id = util.trim(fv(http, "id") or "")
local name = (fv(http, "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
if not it.combo and fv(http, "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
-- §18:此前名称留空、或一个来源都没勾选时,这里整段跳过、直接跳回列表 ——
-- 用户填了表单却什么都没发生,页面上也没有任何提示。
if name == "" then return back_to_list("名称不能为空") end
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,
}
-- 返回值此前被整个丢弃:add_combo / save_combo 的失败(非法 ID、
-- 未选来源、写入失败)一律静默。
local nid, err
if id == "" then
nid, err = core.add_combo(name, sources, o)
else
nid, err = core.save_combo(id, name, sources, o)
end
if not nid then return back_to_list(err or "保存组合订阅失败") end
end
back_to_list()
end
@@ -344,12 +393,15 @@ function action_update()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local id = http.formvalue("id") or ""
local id = fv(http, "id") or ""
local meta = core.get(id)
if meta and meta["local"] then
-- local subscription does not support auto update
return
end
-- §18:订阅不存在(ID 拼错 / 已被删除)时此前静默跳回列表,
-- 用户点了「更新」却看不到任何反馈。
if not meta then return back_to_list("订阅不存在") end
-- pcall 只保证「不抛异常」;core.sync 的失败是「返回 nil, err」而非抛错,
-- 因此必须同时检查两层结果,否则失败永远不会写回列表状态。
local ok, res, err = pcall(core.sync, id)
@@ -377,8 +429,8 @@ function action_probe()
http.write("invalid token")
return
end
local id = util.trim(http.formvalue("id") or "")
local mode = util.trim(http.formvalue("mode") or "")
local id = util.trim(fv(http, "id") or "")
local mode = util.trim(fv(http, "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")
@@ -394,8 +446,8 @@ function action_probe()
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 "")
local proto = util.trim(fv(http, "proto") or "")
local keyword = util.trim(fv(http, "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)
@@ -421,8 +473,8 @@ 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 token = util.trim(fv(http, "token") or "")
local target = util.trim(fv(http, "target") or "ClashMeta")
local content, ct, filename, err = core.generate_link(token, target)
if not content then
@@ -9,6 +9,13 @@
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)
-- 协议筛选 / 重命名:控制器一直在读这两项(read_rules_fields),但此前三个表单
-- 都没有提交它们的控件 —— 字段永远写空,且每次保存会把 UCI 里已有的值重置为 ""。
local proto_sel = {}
for p in ((it and it.proto_filter) or ""):gmatch("[^,]+") do
proto_sel[p:match("^%s*(.-)%s*$")] = true
end
local rm = (it and it.rename_map) or ""
local sel = {}
if it and type(it.sources) == "table" then
for _, s in ipairs(it.sources) do sel[s] = true end
@@ -63,7 +70,21 @@
<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>
<label style="display:inline-block;min-width:10em;vertical-align:top">协议筛选</label>
<% for _, p in ipairs(core.RULE_PROTOS) do %>
<label style="margin-right:0.75em;white-space:nowrap">
<input type="checkbox" name="proto_filter_<%=p%>" value="1" <%=proto_sel[p] and "checked" or ""%>/> <%=p%>
</label>
<% end %>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top">重命名</label>
<textarea name="rename_map" rows="3" style="width:60%"><%=luci.util.pcdata(rm)%></textarea>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。<br/>
协议筛选:全部不勾选 = 不筛选(保留所有协议)。<br/>
重命名每行一条:<code>旧名称=新名称</code>(精确匹配)、<code>模式 -> 替换</code>(正则替换)、<code>{server}_{port}_{proto}</code>(占位符模板)。</span>
</div>
</div>
<% if is_edit then %>
@@ -13,6 +13,13 @@
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)
-- 协议筛选 / 重命名:控制器一直在读这两项(read_rules_fields),但此前三个表单
-- 都没有提交它们的控件 —— 字段永远写空,且每次保存会把 UCI 里已有的值重置为 ""。
local proto_sel = {}
for p in ((it and it.proto_filter) or ""):gmatch("[^,]+") do
proto_sel[p:match("^%s*(.-)%s*$")] = true
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
local util = require("substore.util")
-- 剩余流量 / 剩余时长(仅编辑页且有 subscription-userinfo 数据时显示,首页不显示)
@@ -89,7 +96,21 @@
<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>
<label style="display:inline-block;min-width:10em;vertical-align:top">协议筛选</label>
<% for _, p in ipairs(core.RULE_PROTOS) do %>
<label style="margin-right:0.75em;white-space:nowrap">
<input type="checkbox" name="proto_filter_<%=p%>" value="1" <%=proto_sel[p] and "checked" or ""%>/> <%=p%>
</label>
<% end %>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top">重命名</label>
<textarea name="rename_map" rows="3" style="width:60%"><%=luci.util.pcdata(rm)%></textarea>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。<br/>
协议筛选:全部不勾选 = 不筛选(保留所有协议)。<br/>
重命名每行一条:<code>旧名称=新名称</code>(精确匹配)、<code>模式 -> 替换</code>(正则替换)、<code>{server}_{port}_{proto}</code>(占位符模板)。</span>
</div>
</div>
<% if is_edit and it.last_update then %>
@@ -9,6 +9,13 @@
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)
-- 协议筛选 / 重命名:控制器一直在读这两项(read_rules_fields),但此前三个表单
-- 都没有提交它们的控件 —— 字段永远写空,且每次保存会把 UCI 里已有的值重置为 ""。
local proto_sel = {}
for p in ((it and it.proto_filter) or ""):gmatch("[^,]+") do
proto_sel[p:match("^%s*(.-)%s*$")] = true
end
local rm = (it and it.rename_map) or ""
local local_mode = (it and it.local_mode) or "text"
local content = (it and it.raw_content) or ""
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
@@ -77,6 +84,22 @@
<label style="display:inline-block;min-width:10em"><%:Dedup%></label>
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Protocol filter%></label>
<% for _, p in ipairs(core.RULE_PROTOS) do %>
<label style="margin-right:0.75em;white-space:nowrap">
<input type="checkbox" name="proto_filter_<%=p%>" value="1" <%=proto_sel[p] and "checked" or ""%>/> <%=p%>
</label>
<% end %>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Rename%></label>
<textarea name="rename_map" rows="3" style="width:60%"><%=luci.util.pcdata(rm)%></textarea>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080"><%:Protocol filter: with none ticked no filtering is applied.%><br/>
<%:One rename rule per line: OLD=NEW (exact), PATTERN -> REPLACEMENT (regex), or {server}_{port}_{proto} (placeholder template).%></span>
</div>
</div>
<% if is_edit and it.last_update then %>
<div class="cbi-section-descr" style="margin:0.5em 0">