🎈 Sync 2026-09-29 22:47:56

This commit is contained in:
github-actions[bot] committed 2026-09-29 22:47:57 +08:00
1 parent 97287119b0
commit 4c053b8bdb
29 files changed
+1015 -157

No files matched your search

@@ -3,7 +3,8 @@
# 用法:substore-cron.sh [订阅ID](不传则更新全部已启用订阅)
# 注意:显式设置 package.path,确保独立 cron 进程能找到 /usr/lib/lua 下的模块
DATA_DIR="/etc/substore"
# 数据目录可被环境变量覆盖,仅用于测试;生产环境仍为 /etc/substore
DATA_DIR="${SUBSTORE_DATA_DIR:-/etc/substore}"
LIST_FILE="$DATA_DIR/subscriptions.json"
SUB_ID="${1:-}"
LUA_BIN=""
@@ -23,19 +24,24 @@ fi
[ -f "$LIST_FILE" ] || exit 0
export SUB_ID
OUT=$(
LUA_PATH="/usr/lib/lua/?.lua;/usr/share/lua/?.lua;${LUA_PATH:-}" \
"$LUA_BIN" -e '
local core = require("substore.core")
local sub_id = os.getenv("SUB_ID")
local ok_count, fail_count = 0, 0
-- core.sync 失败时是「返回 nil, err」而非抛异常,因此 pcall 必须处理两层结果:
-- pcall 返回 true 只说明没有抛错,第二个返回值才是 sync 自身的结果。
-- 只判断第一层会把「sync 返回 nil, err」统计成成功。
local function sync_one(id)
local ok, err = pcall(function() return core.sync(id) end)
if ok then
local ok, res, err = pcall(core.sync, id)
if ok and res then
ok_count = ok_count + 1
else
fail_count = fail_count + 1
io.stderr:write("Failed sync " .. tostring(id) .. ": " .. tostring(err) .. "\n")
local reason = ok and err or res
io.stderr:write("Failed sync " .. tostring(id) .. ": " .. tostring(reason) .. "\n")
end
end
@@ -47,4 +53,12 @@ else
end
end
io.stdout:write(string.format("substore cron done: %d ok, %d failed\n", ok_count, fail_count))
' 2>&1 | logger -t luci-app-substore
' 2>&1
)
# 输出到 stdout(cron 会按需邮件/记录),同时写 syslog
printf '%s\n' "$OUT"
if command -v logger >/dev/null 2>&1; then
printf '%s\n' "$OUT" | logger -t luci-app-substore
fi
exit 0
@@ -2,9 +2,15 @@
module("luci.controller.admin.substore", package.seeall)
local function back_to_list()
-- 返回列表页。err 非空时把错误带到列表页显示(§18:失败必须让用户看见,
-- 不能「失败了却看起来像成功」)。沿用 LuCI 既有的 query + 模板渲染,不引入新 framework。
local function back_to_list(err)
local http = require("luci.http")
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "list"))
local url = luci.dispatcher.build_url("admin", "services", "substore", "list")
if err ~= nil and tostring(err) ~= "" then
url = url .. "?err=" .. luci.util.urlencode(tostring(err))
end
http.redirect(url)
end
function index()
@@ -84,18 +90,20 @@ function action_create()
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()
if name == "" or url == "" then
return back_to_list("名称和 URL 不能为空")
end
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
local id, err = 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,
})
if not id then return back_to_list(err or "创建订阅失败") end
core.write_cron()
end
back_to_list()
end
@@ -110,18 +118,21 @@ function action_save()
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()
if id == "" then return back_to_list("缺少订阅 ID") end
if name == "" or url == "" then
return back_to_list("名称和 URL 不能为空")
end
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
local ok, err = 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,
})
if not ok then return back_to_list(err or "保存失败") end
core.write_cron()
end
back_to_list()
end
@@ -134,15 +145,17 @@ function action_local_create()
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
-- §19:空名称 / 空内容必须明确报错,不能无声创建无效订阅
if name == "" then return back_to_list("名称不能为空") end
if content == "" then return back_to_list("订阅内容不能为空") end
local id, err = 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,
})
if not id then return back_to_list(err or "创建本地订阅失败") end
end
back_to_list()
end
@@ -156,19 +169,23 @@ function action_local_save()
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
if id == "" then return back_to_list("缺少订阅 ID") end
if name == "" then return back_to_list("名称不能为空") end
if content == "" then return back_to_list("订阅内容不能为空") end
local ok, err = 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,
})
if not ok then return back_to_list(err or "保存失败") end
-- 解析失败会写进 meta.error(列表页 Status 列可见),此处再明确提示一次
local sok, serr = core.sync(id)
if not sok then return back_to_list(serr or "解析订阅内容失败") end
end
back_to_list()
end
@@ -315,12 +332,15 @@ function action_update()
-- 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)
-- pcall 只保证「不抛异常」;core.sync 的失败是「返回 nil, err」而非抛错,
-- 因此必须同时检查两层结果,否则失败永远不会写回列表状态。
local ok, res, err = pcall(core.sync, id)
if not ok then
core.save_meta(id, { error = tostring(err), last_update = os.time() })
-- 抛异常:第二个返回值是错误信息
core.save_meta(id, { error = tostring(res), last_update = os.time() })
elseif not res then
-- 正常返回但失败:第三个返回值是错误信息
core.save_meta(id, { error = tostring(err or "更新失败"), last_update = os.time() })
end
end
back_to_list()
@@ -27,7 +27,7 @@
<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 ''%>"/>
<input type="text" id="name" name="name" value="<%=luci.util.pcdata(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>
@@ -52,11 +52,11 @@
<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%"/>
<input type="text" name="keyword_include" value="<%=luci.util.pcdata(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%"/>
<input type="text" name="keyword_exclude" value="<%=luci.util.pcdata(ke)%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">去重</label>
@@ -38,11 +38,11 @@
<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 ''%>"/>
<input type="text" id="name" name="name" value="<%=luci.util.pcdata(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%"/>
<input type="text" id="url" name="url" value="<%=luci.util.pcdata(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>
@@ -51,7 +51,7 @@
</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%"/>
<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">
@@ -78,11 +78,11 @@
<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%"/>
<input type="text" name="keyword_include" value="<%=luci.util.pcdata(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%"/>
<input type="text" name="keyword_exclude" value="<%=luci.util.pcdata(ke)%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">去重</label>
@@ -99,7 +99,7 @@
<% 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 %>
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=luci.util.pcdata(it.error)%></span><% end %>
</span>
</div>
<% end %>
@@ -14,6 +14,10 @@
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
local util = require("substore.util")
local js_content = (util.json_encode(content) or '""'):gsub("</", "<\\/")
-- local_mode 是用户可提交的字段,直接拼进 JS 字符串会被闭合引号注入;
-- 归一为白名单取值后用 json_encode 输出,保证是合法的 JS 字符串字面量
if local_mode ~= "form" then local_mode = "text" end
local js_mode = util.json_encode(local_mode) or '"text"'
%>
<h2><%=is_edit and luci.i18n.translate("Edit local subscription") or luci.i18n.translate("Add local subscription")%></h2>
@@ -25,7 +29,7 @@
<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 ''%>"/>
<input type="text" id="name" name="name" value="<%=luci.util.pcdata(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>
@@ -59,11 +63,11 @@
<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%"/>
<input type="text" name="keyword_include" value="<%=luci.util.pcdata(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%"/>
<input type="text" name="keyword_exclude" value="<%=luci.util.pcdata(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>
@@ -75,7 +79,7 @@
<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 %>
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=luci.util.pcdata(it.error)%></span><% end %>
</span>
</div>
<% end %>
@@ -89,7 +93,7 @@
<script>
// 编辑时的初始内容(文本或 JSON),由服务端注入
var SUBSTORE_INIT_CONTENT = <%=js_content%>;
var SUBSTORE_INIT_MODE = "<%=local_mode%>";
var SUBSTORE_INIT_MODE = <%=js_mode%>;
// 字段标签与界面文本(nodeform.js 读取;翻译由服务端按系统语言渲染)
var FIELD_LABELS = {
@@ -9,10 +9,10 @@
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 js_node = n and ((util.json_encode(n) or "null"):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>
<h2><%:Edit node%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
<% if not n then %>
<div class="cbi-section">
@@ -23,8 +23,8 @@
<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%>"/>
<input type="hidden" name="id" value="<%=luci.util.pcdata(id)%>"/>
<input type="hidden" name="idx" value="<%=luci.util.pcdata(tostring(idx or ""))%>"/>
<div class="cbi-section">
<div id="nodes_list"></div>
<input type="hidden" name="content" id="form_content" value=""/>
@@ -3,6 +3,7 @@
local http = require("luci.http")
local core = require("substore.core")
local node = require("substore.node")
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 ""
@@ -13,6 +14,8 @@
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"
-- id 会进入 <script> 里的 JS 字符串字面量,用 JSON 编码而不是 HTML 转义
local js_id = util.json_encode(id) or '""'
-- 标注原始数组下标(filter 保留表引用、sort 原地排序,下标可穿透筛选排序)
for i, nn in ipairs(nodes) do nn.__idx = i end
@@ -33,13 +36,13 @@
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>
<h2><%:Nodes%> — <%=luci.util.pcdata(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%>"/>
<input type="hidden" name="id" value="<%=luci.util.pcdata(id)%>"/>
<div style="display:flex;gap:0.5em;align-items:center;flex-wrap:wrap;margin:0.5em 0">
<label><%:Type%>:
<select name="proto">
@@ -57,7 +60,7 @@
<% end %>
</select>
</label>
<label><%:Keyword%>: <input type="text" name="keyword" size="12" value="<%=keyword%>"/></label>
<label><%:Keyword%>: <input type="text" name="keyword" size="12" value="<%=luci.util.pcdata(keyword)%>"/></label>
<label><%:Sort by%>:
<select name="sort">
<option value="name" <%=sort_by=="name" and "selected" or ""%>>name</option>
@@ -80,12 +83,12 @@
<!-- 勾选批量删除用的隐藏表单:JS 收集选中下标填入 idx(逗号分隔)后提交 -->
<form id="batch_delete_form" method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_delete')%>" style="display:none">
<input type="hidden" name="token" value="<%=token%>"/>
<input type="hidden" name="id" value="<%=id%>"/>
<input type="hidden" name="id" value="<%=luci.util.pcdata(id)%>"/>
<input type="hidden" name="idx" value=""/>
<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%>"/>
<input type="hidden" name="proto" value="<%=luci.util.pcdata(proto)%>"/>
<input type="hidden" name="keyword" value="<%=luci.util.pcdata(keyword)%>"/>
<input type="hidden" name="group" value="<%=luci.util.pcdata(group)%>"/>
<input type="hidden" name="sort" value="<%=luci.util.pcdata(sort_by)%>"/>
<% if sort_desc then %><input type="hidden" name="desc" value="1"/><% end %>
</form>
@@ -101,28 +104,28 @@
<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 ""%>">
<tr class="cbi-section-table-row" data-server="<%=luci.util.pcdata(n.server or "")%>" data-port="<%=luci.util.pcdata(tostring(n.port or ""))%>">
<td><input type="checkbox" class="node-check" value="<%=n.__idx%>"/></td>
<td><%=n.name or ""%></td>
<td><%=luci.util.pcdata(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><%=luci.util.pcdata(n.proto or "")%></td>
<td><%=luci.util.pcdata(n.server or "")%></td>
<td><%=luci.util.pcdata(tostring(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="id" value="<%=luci.util.pcdata(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%>"/>
<input type="hidden" name="proto" value="<%=luci.util.pcdata(proto)%>"/>
<input type="hidden" name="keyword" value="<%=luci.util.pcdata(keyword)%>"/>
<input type="hidden" name="group" value="<%=luci.util.pcdata(group)%>"/>
<input type="hidden" name="sort" value="<%=luci.util.pcdata(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>
@@ -160,7 +163,7 @@ function substore_set_group(el) {
alert('<%:Failed to set group%>');
}
};
xhr.send('id=' + encodeURIComponent('<%=id%>')
xhr.send('id=' + encodeURIComponent(<%=js_id%>)
+ '&idx=' + encodeURIComponent(el.getAttribute('data-idx'))
+ '&group=' + encodeURIComponent(el.value)
+ '&token=' + encodeURIComponent('<%=token%>'));
@@ -23,13 +23,13 @@
end
local out, err = output.generate(nodes, fmt)
%>
<h2><%:Output%> — <%=meta and meta.name or luci.i18n.translate("Merged")%></h2>
<h2><%:Output%> — <%=luci.util.pcdata(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%>"/>
<input type="hidden" name="id" value="<%=luci.util.pcdata(id)%>"/>
<div style="display:flex;gap:0.5em;align-items:center;margin:0.5em 0">
<label><%:Format%>:
<select name="fmt">
@@ -41,7 +41,7 @@
</form>
<% if err then %>
<div style="color:#cc0000"><%=err%></div>
<div style="color:#cc0000"><%=luci.util.pcdata(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">
@@ -1,6 +1,17 @@
<%+header%>
<h2><%:Subscriptions%></h2>
<%
-- §18:控制器把失败原因放在 ?err= 上带回来,这里渲染成可见的提示。
-- pcdata 转义:err 可能包含用户提交的内容,不能直接当 HTML 输出。
local flash_err = require("luci.http").formvalue("err")
if flash_err and flash_err ~= "" then
%>
<div class="cbi-section" style="border-left:4px solid #cc0000;padding:0.5em 0.75em;background:#fdf0f0;color:#cc0000">
<strong><%:操作失败%>:</strong> <%=luci.util.pcdata(flash_err)%>
</div>
<% end %>
<div class="cbi-section">
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/form')%>">
<%:Add subscription link%>
@@ -53,9 +64,9 @@
<tr class="cbi-section-table-row">
<td>
<% if it.combo then %>
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=it.name%>
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=luci.util.pcdata(it.name or "")%>
<% else %>
<%=it.name%>
<%=luci.util.pcdata(it.name or "")%>
<% 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>
@@ -67,16 +78,16 @@
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><%
%><span style="color:#808080"><%=luci.util.pcdata(table.concat(names, " + "))%></span><%
elseif it["local"] then
%><span style="color:#0066aa">[<%:Local%>]</span><%
else
%><%=it.url%><%
%><%=luci.util.pcdata(it.url or "")%><%
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 style="color:<%=status_color%>"><%=luci.util.pcdata(status_text or "")%></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)%>"
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
local M = {}
M.version = "2.1.3"
M.version = "2.5.0"
M.DATA_DIR = "/etc/substore"
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
M.NODES_DIR = M.DATA_DIR .. "/nodes"
@@ -243,13 +243,14 @@ function M.sync(id)
log("Sync local subscription")
local content = meta.raw_content or ""
if content == "" then
M.save_meta(id, { error = "本地订阅内容为空", node_count = 0, last_update = os.time() })
-- 失败路径一律不改 node_count:磁盘上的旧节点仍在,订阅链接仍在下发(§35)
M.save_meta(id, { error = "本地订阅内容为空", last_update = os.time() })
return nil, "本地订阅内容为空"
end
local res, perr = parser.parse_local(content, meta.local_mode or "text")
if not res or not res.nodes then
log("Parse local fail: " .. tostring(perr))
M.save_meta(id, { error = perr or "本地解析失败", node_count = 0, last_update = os.time() })
M.save_meta(id, { error = perr or "本地解析失败", last_update = os.time() })
return nil, perr or "本地解析失败"
end
log("Parse local ok nodes="..#res.nodes)
@@ -267,23 +268,29 @@ function M.sync(id)
end
if not meta.url or meta.url == "" then log("Sync fail: no URL"); return nil, "无订阅 URL" end
-- 订阅代理:开启且代理地址有效时,通过代理下载订阅
-- 订阅代理:开启时代理地址必须有效。无效就明确失败——
-- 静默直连会让用户以为流量走了代理,属于必须避免的 silent fallback(§12)。
local proxy = ""
if meta.proxy_enable == true or meta.proxy_enable == "1" then
local p, perr = http.parse_proxy(meta.proxy or "")
if p and p ~= "" then
proxy = p
elseif perr then
log("Proxy ignored: " .. tostring(perr))
if not p or p == "" then
local msg = perr or "代理地址为空"
log("Proxy invalid: " .. tostring(msg))
M.save_meta(id, { error = "代理配置无效: " .. tostring(msg), last_update = os.time() })
return nil, "代理配置无效: " .. tostring(msg)
end
proxy = p
end
if proxy ~= "" then log("Using proxy " .. proxy) end
-- 日志不记录代理凭据(§39)
if proxy ~= "" then log("Using proxy " .. http.redact_proxy(proxy)) end
local content, headers, err = http.download(meta.url, { max_size = M.MAX_SIZE, timeout = M.TIMEOUT, proxy = proxy })
if not content then
log("Download fail: " .. tostring(err))
M.save_meta(id, { error = err, last_update = os.time() })
return nil, err
-- 下载工具的报错可能回显含凭据的 URL,写日志与入库前先抹掉(§39)
local safe_err = http.scrub_credentials(err or "下载失败")
log("Download fail: " .. safe_err)
M.save_meta(id, { error = safe_err, last_update = os.time() })
return nil, safe_err
end
log("Download ok size="..#content)
@@ -293,7 +300,7 @@ function M.sync(id)
local res, perr = parser.parse(content)
if not res or not res.nodes then
log("Parse fail: " .. tostring(perr))
M.save_meta(id, { error = perr or "解析失败", node_count = 0, last_update = os.time() })
M.save_meta(id, { error = perr or "解析失败", last_update = os.time() })
return nil, perr or "解析失败"
end
log("Parse ok nodes="..#res.nodes)
@@ -200,12 +200,52 @@ local function resolve_url(base, loc)
if loc:find("://", 1, true) then return loc end
local scheme, host = base:match("^([%w]+)://([^/]+)")
if not scheme then return loc end
-- 协议相对地址(//host/path):沿用 scheme,但主机取自 Location 本身,
-- 否则会被误当成同主机的路径而漏掉跨主机跳转
if loc:sub(1, 2) == "//" then return scheme .. ":" .. loc end
if loc:sub(1, 1) == "/" then return scheme .. "://" .. host .. loc end
local base_path = base:match("^[%w]+://[^/]+(.*)$") or "/"
local dir = base_path:match("^(.*)/[^/]*$") or ""
return scheme .. "://" .. host .. dir .. "/" .. loc
end
-- 从 wget -S 日志中按出现顺序提取重定向目标(Location)
local function locations_from_log(raw)
local locs = {}
for line in (raw or ""):gmatch("[^\r\n]+") do
local v = line:match("^%s*[Ll]ocation:%s*(.+)$")
if v then locs[#locs + 1] = util.trim(v) end
end
return locs
end
-- 复检重定向链:每一跳都必须通过 check_public。
-- busybox wget 没有 --max-redirect,无法在发出请求前拦住重定向,只能在 -S 日志里
-- 逐跳校验;任一跳不安全即整体失败并丢弃响应体(§14/§16)。
-- 纯函数,不触网,便于离线测试。返回 ok, err
function M.validate_redirect_chain(base_url, log)
for _, loc in ipairs(locations_from_log(log)) do
local next_url = resolve_url(base_url, loc)
local np = M.parse_url(next_url)
if not np then return false, "重定向目标无效: " .. tostring(loc) end
local ok, re = M.check_public(np.host)
if not ok then return false, "重定向目标不安全: " .. (re or "") end
end
return true
end
-- 代理地址去凭据,用于日志(§39:不记录代理密码)
function M.redact_proxy(p)
local scheme, rest = tostring(p or ""):match("^(%a[%w]*)://(.*)$")
if not scheme then return "***" end
return scheme .. "://" .. (rest:gsub("^[^@]*@", ""))
end
-- 抹掉文本中的 URL 凭据(//user:pass@),用于日志与错误信息(§39)
function M.scrub_credentials(text)
return (tostring(text or ""):gsub("//([^%s/@:]*)%:([^%s/@]*)@", "//***@"))
end
local function fetch_curl(url, parsed, opts)
local max, t = opts.max_size, opts.timeout
local proxy_arg = ""
@@ -253,17 +293,38 @@ local function fetch_curl(url, parsed, opts)
return nil, "重定向次数过多"
end
-- wget 后端的代理环境变量。busybox wget 只能通过 http_proxy/https_proxy 环境变量
-- 使用 http(s) 代理;遇到它不支持的协议(socks*)必须明确报错,
-- 不能丢掉代理静默直连(§12)。返回 env 或 nil, err
function M.wget_proxy_env(proxy)
if proxy == nil or proxy == "" then return "" end
local scheme = tostring(proxy):match("^(%a[%w]*)://")
scheme = scheme and scheme:lower() or ""
if scheme ~= "http" and scheme ~= "https" then
return nil, "当前下载后端 (wget) 不支持 " .. string.upper(scheme) ..
" 代理,请安装 curl 或改用 http 代理"
end
return string.format("http_proxy=%q https_proxy=%q ", proxy, proxy)
end
local function fetch_wget(url, parsed, opts)
local max, t = opts.max_size, opts.timeout
local proxy_env, perr = M.wget_proxy_env(opts.proxy)
if not proxy_env then return nil, perr end
local tmp = "/tmp/substore_dl_wget.tmp"
os.remove(tmp)
-- 仅 http/https 代理可用环境变量传递(busybox wget 不支持 socks 代理)
local proxy_env = ""
if opts.proxy and opts.proxy ~= "" and opts.proxy:match("^https?://") then
proxy_env = string.format("http_proxy=%q https_proxy=%q ", opts.proxy, opts.proxy)
end
local cmd = proxy_env .. string.format("wget -q -T %d -O %q %q 2>/dev/null", t, tmp, url)
local log = tmp .. ".log"
os.remove(tmp); os.remove(log)
-- -S 打印响应头(含整条重定向链),据此逐跳复检 SSRF;
-- 日志与响应体分流:体写 tmp,链写 log
local cmd = proxy_env .. string.format("wget -S -q -T %d -O %q %q >%q 2>&1", t, tmp, url, log)
os.execute(cmd)
local logtext = util.read_file(log) or ""
os.remove(log)
local safe, reason = M.validate_redirect_chain(url, logtext)
if not safe then
os.remove(tmp)
return nil, reason
end
local size = util.file_size(tmp)
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" end
@@ -112,12 +112,23 @@ function M.filter(nodes, opts)
return out
end
-- 去重:按 proto+server+port 唯一
-- WireGuard peer 公钥。字段名以 parser 产出的 "public-key" 为准,
-- 其余为历史/导入别名(output_wireguard_conf 同样接受这几种写法)
local function wg_public_key(n)
return n["public-key"] or n.public_key or n["peer-public-key"] or n.peer_public_key
end
-- 去重:按 proto+server+port 唯一。
-- WireGuard/AmneziaWG 例外:同一个 endpoint 上不同 peer 公钥是**不同**的节点,
-- 只按 server+port 去重会把它们错误合并(§32/§43),因此把公钥并入去重键。
function M.dedup(nodes)
local seen = {}
local out = {}
for _, n in ipairs(nodes) do
local key = (n.proto or "") .. "|" .. (n.server or "") .. "|" .. tostring(n.port or "")
if n.proto == "wireguard" or n.proto == "wg" then
key = key .. "|" .. tostring(wg_public_key(n) or "")
end
if not seen[key] then
seen[key] = true
out[#out + 1] = n
@@ -11,9 +11,21 @@ local function is_wireguard(n)
return p == "wireguard" or p == "wg"
end
-- amnezia-wg-option 键 → .conf 键:首字母大写(jc → Jc,jmin → Jmin,itime → Itime)
-- amnezia-wg-option 子键 → .conf 键。
-- 只输出能**确定**名字的键:多词键并不是把首字母大写就能得到 .conf 名
-- (header-protection-key 的正确写法是 HeaderProtectionKey,不是 Header-protection-key),
-- 靠规则猜出来的名字会被 AmneziaWG 客户端当成未知键,可能整份配置被拒。
-- 因此用显式映射,映射不到的键一律不输出(§110:不输出未经验证的 AWG 字段)。
local AWG_CONF_KEY_MAP = {
jc = "Jc", jmin = "Jmin", jmax = "Jmax",
s1 = "S1", s2 = "S2", s3 = "S3", s4 = "S4",
h1 = "H1", h2 = "H2", h3 = "H3", h4 = "H4",
i1 = "I1", i2 = "I2", i3 = "I3", i4 = "I4", i5 = "I5",
j1 = "J1", j2 = "J2", j3 = "J3", itime = "Itime",
}
local function conf_key(k)
return (k:gsub("^%l", string.upper))
return AWG_CONF_KEY_MAP[k]
end
-- 值可能是标量或数组,统一转为 "a, b, c"
@@ -60,14 +72,18 @@ local function build_section(n)
put("MTU", n.mtu)
put("DNS", join_list(n.dns))
-- AmneziaWG 参数:按 .conf 键名排序输出,保证可 diff
-- AmneziaWG 参数:按 .conf 键名排序输出,保证可 diff;
-- 无法确定 .conf 名的键直接跳过(见 AWG_CONF_KEY_MAP 说明)
local opt = n["amnezia-wg-option"]
if type(opt) == "table" then
local keys = {}
for k in pairs(opt) do keys[#keys + 1] = k end
table.sort(keys)
for _, k in ipairs(keys) do
put(conf_key(k), tostring(opt[k]))
local mapped = {}
for k, v in pairs(opt) do
local ck = conf_key(k)
if ck then mapped[#mapped + 1] = { ck, v } end
end
table.sort(mapped, function(x, y) return x[1] < y[1] end)
for _, kv in ipairs(mapped) do
put(kv[1], tostring(kv[2]))
end
end
@@ -85,23 +101,30 @@ local function build_section(n)
end
-- nodes → wg-quick .conf 文本
-- 注意:wg-quick / AmneziaWG 客户端一个文件导入一条隧道。单节点时输出即为标准单接口
-- .conf;多节点时各段以 "# <名称>" 注释分隔,便于查看与手工拆分。
-- §55/§56:一个 .conf 文件 = 一个 [Interface]。wg-quick / AmneziaWG 客户端按「单条隧道」
-- 导入,把多个 [Interface] 段拼进同一个文件会得到客户端无法导入(或只取首段)的畸形配置,
-- 因此多于一个 WireGuard 节点时明确报错,而不是静默拼接。
function M.generate(nodes, options)
local blocks = {}
local wg = {}
for _, n in ipairs(nodes or {}) do
if type(n) == "table" and is_wireguard(n) then
local sec = build_section(n)
local named = { "# " .. (n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
for _, line in ipairs(sec) do named[#named + 1] = line end
blocks[#blocks + 1] = table.concat(named, "\n")
wg[#wg + 1] = n
end
end
if #blocks == 0 then
if #wg == 0 then
-- 明确报错优于返回空文件:用户能知道是"没有 WireGuard 节点"而非订阅坏了
return nil, "没有可导出的 WireGuard 节点"
end
return table.concat(blocks, "\n\n") .. "\n"
if #wg > 1 then
return nil, string.format(
"WireGuard .conf 每个文件只能包含一条隧道,当前有 %d 个 WireGuard 节点;请只导出单个节点",
#wg)
end
local n = wg[1]
local lines = { "# " .. (n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
for _, line in ipairs(build_section(n)) do lines[#lines + 1] = line end
return table.concat(lines, "\n") .. "\n"
end
return M