qq-bot-roost 0.8.11 → 0.8.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/gitee.js +23 -3
- package/src/plugin/market.js +107 -4
- package/src/web/server.js +59 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
|
|
11
11
|
<img src="https://img.shields.io/badge/tests-600%2B%20passed-brightgreen" alt="测试 600+ 项">
|
|
12
12
|
<img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
|
|
13
|
-
<img src="https://img.shields.io/badge/version-0.8.
|
|
13
|
+
<img src="https://img.shields.io/badge/version-0.8.13-blueviolet" alt="版本 0.8.13">
|
|
14
14
|
<img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
|
|
15
15
|
<a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
|
|
16
16
|
|
package/package.json
CHANGED
package/src/gitee.js
CHANGED
|
@@ -69,7 +69,14 @@ export function giteeRawUrl(repo, ref, filePath) {
|
|
|
69
69
|
* 如 ['src/', 'package.json'];不传拉全仓
|
|
70
70
|
* @param {number} [o.timeoutMs] 单文件超时
|
|
71
71
|
* @param {(msg:string)=>void} [o.onProgress] 每拉完一个文件调一次
|
|
72
|
-
* @
|
|
72
|
+
* @param {boolean} [o.allowPartial] 有文件拉不下时**不抛错**,把失败清单放进
|
|
73
|
+
* 返回值的 `failed` 里,交给调用方用别的源补拉。默认 false(整体中止)。
|
|
74
|
+
*
|
|
75
|
+
* 为什么要这个开关:Gitee 会对个别文件返回 451(内容审查),而**同一个
|
|
76
|
+
* 文件在 GitHub 上是好的**(实测 douluo 的 content-pack.sql 等 4 个文件)。
|
|
77
|
+
* 以前只要有一个失败就整包中止,等于一个文件被屏蔽 → 整个插件装不上。
|
|
78
|
+
*
|
|
79
|
+
* @returns {Promise<{files:number, skipped:number, failed:Array<{path:string,error:string}>}>}
|
|
73
80
|
*/
|
|
74
81
|
export async function fetchGiteeTree({
|
|
75
82
|
repo,
|
|
@@ -79,6 +86,7 @@ export async function fetchGiteeTree({
|
|
|
79
86
|
only = null,
|
|
80
87
|
timeoutMs = 120000,
|
|
81
88
|
onProgress,
|
|
89
|
+
allowPartial = false,
|
|
82
90
|
}) {
|
|
83
91
|
if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error(`Gitee 仓库名不合法:${repo}`);
|
|
84
92
|
if (!ref) throw new Error('Gitee 下载缺少分支/tag');
|
|
@@ -121,6 +129,7 @@ export async function fetchGiteeTree({
|
|
|
121
129
|
await fs.mkdir(destDir, { recursive: true });
|
|
122
130
|
let done = 0;
|
|
123
131
|
let failed = 0;
|
|
132
|
+
const failedList = [];
|
|
124
133
|
let cursor = 0;
|
|
125
134
|
|
|
126
135
|
async function worker() {
|
|
@@ -145,7 +154,15 @@ export async function fetchGiteeTree({
|
|
|
145
154
|
signal: AbortSignal.timeout(timeoutMs),
|
|
146
155
|
});
|
|
147
156
|
if (res.ok) break;
|
|
148
|
-
|
|
157
|
+
/**
|
|
158
|
+
* 451 = Unavailable For Legal Reasons。Gitee 会对个别文件返回它
|
|
159
|
+
* (内容审查),而同一个文件在 GitHub 上是 200。光写「HTTP 451」
|
|
160
|
+
* 没人看得懂该去换下载源,所以翻成人话。
|
|
161
|
+
*/
|
|
162
|
+
lastErr =
|
|
163
|
+
res.status === 451
|
|
164
|
+
? new Error('HTTP 451(Gitee 上这个文件不可用,多半被内容审查挡了)')
|
|
165
|
+
: new Error(`HTTP ${res.status}`);
|
|
149
166
|
if (!isRetryable(res, null)) {
|
|
150
167
|
res = null;
|
|
151
168
|
break;
|
|
@@ -169,6 +186,7 @@ export async function fetchGiteeTree({
|
|
|
169
186
|
onProgress?.(`${done}/${files.length} ${f.path}`);
|
|
170
187
|
} catch (e) {
|
|
171
188
|
failed += 1;
|
|
189
|
+
failedList.push({ path: f.path, error: e.message });
|
|
172
190
|
log.warn(`拉取失败 ${f.path}: ${e.message}`);
|
|
173
191
|
}
|
|
174
192
|
}
|
|
@@ -177,9 +195,11 @@ export async function fetchGiteeTree({
|
|
|
177
195
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, files.length) }, worker));
|
|
178
196
|
|
|
179
197
|
if (failed) {
|
|
198
|
+
// 调用方要自己补拉就不抛 —— 它补完还失败会自己报错,那时信息更全
|
|
199
|
+
if (allowPartial) return { files: done, skipped: files.length - done, failed: failedList };
|
|
180
200
|
throw new Error(`Gitee 下载 ${repo}@${ref}:${files.length} 个文件里 ${failed} 个失败,已中止`);
|
|
181
201
|
}
|
|
182
|
-
return { files: done, skipped: files.length - done };
|
|
202
|
+
return { files: done, skipped: files.length - done, failed: [] };
|
|
183
203
|
}
|
|
184
204
|
|
|
185
205
|
/**
|
package/src/plugin/market.js
CHANGED
|
@@ -251,6 +251,69 @@ export async function verifyPluginDir(entry, dir) {
|
|
|
251
251
|
* @param {number} [o.timeoutMs]
|
|
252
252
|
* @param {(msg:string)=>void} [o.onRetry]
|
|
253
253
|
*/
|
|
254
|
+
/**
|
|
255
|
+
* 单个文件的 GitHub raw 地址。
|
|
256
|
+
* 走 `fetchViaMirror`,所以自定义镜像 / 代理一样生效。
|
|
257
|
+
*/
|
|
258
|
+
function githubRawUrl(repo, ref, filePath) {
|
|
259
|
+
const p = String(filePath ?? '')
|
|
260
|
+
.split('/')
|
|
261
|
+
.map((s) => encodeURIComponent(s))
|
|
262
|
+
.join('/');
|
|
263
|
+
return `https://raw.githubusercontent.com/${repo}/${encodeURIComponent(ref)}/${p}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 把 Gitee 拉不下来的那几个文件,**逐个**从 GitHub 补回来。
|
|
268
|
+
*
|
|
269
|
+
* 为什么只补这几个而不是整包重拉:Gitee 的 451 是**按文件**来的 —— douluo
|
|
270
|
+
* 那么大一个插件只有 4 个文件被挡,其余都拉成功了。为 4 个文件重拉整包
|
|
271
|
+
* (几十上百个文件)纯属浪费,国内网络下很容易再断一次。
|
|
272
|
+
*
|
|
273
|
+
* @param {Array<{path:string,error:string}>} failed fetchGiteeTree 返回的失败清单
|
|
274
|
+
* @param {(url:string, init?:object)=>Promise<Response>} [fetcher] 取数函数,默认
|
|
275
|
+
* 走镜像。**给测试注入假网络用**,正常调用不需要传。
|
|
276
|
+
* @returns {Promise<Array<{path:string,error:string}>>} 补拉后仍然失败的(空数组 = 全补齐了)
|
|
277
|
+
*
|
|
278
|
+
* @internal 导出只为可测(selftest 里用它跑假网络),不是公共 API。
|
|
279
|
+
*/
|
|
280
|
+
export async function backfillFromGitHub({
|
|
281
|
+
repo,
|
|
282
|
+
branch,
|
|
283
|
+
destDir,
|
|
284
|
+
failed,
|
|
285
|
+
timeoutMs = 120000,
|
|
286
|
+
onProgress,
|
|
287
|
+
fetcher = fetchViaMirror,
|
|
288
|
+
}) {
|
|
289
|
+
const still = [];
|
|
290
|
+
for (const item of failed) {
|
|
291
|
+
const dest = path.resolve(path.join(destDir, item.path));
|
|
292
|
+
// 清单来自服务端,仍按不可信处理,防路径穿越
|
|
293
|
+
const base = path.resolve(destDir);
|
|
294
|
+
if (dest !== base && !dest.startsWith(base + path.sep)) {
|
|
295
|
+
still.push({ path: item.path, error: '越界路径' });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const res = await fetcher(githubRawUrl(repo, branch, item.path), {
|
|
300
|
+
headers: { 'User-Agent': 'qq-bot-roost' },
|
|
301
|
+
signal: AbortSignal.timeout(Math.min(timeoutMs, 30000)),
|
|
302
|
+
});
|
|
303
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
304
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
305
|
+
if (!buf.length) throw new Error('内容是空的');
|
|
306
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
307
|
+
await fs.writeFile(dest, buf);
|
|
308
|
+
log.info(`已从 GitHub 补齐 ${item.path}(Gitee 上拉不到)`);
|
|
309
|
+
onProgress?.(`补齐 ${item.path}`);
|
|
310
|
+
} catch (e) {
|
|
311
|
+
still.push({ path: item.path, error: `Gitee 失败后 GitHub 也失败:${e.message}` });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return still;
|
|
315
|
+
}
|
|
316
|
+
|
|
254
317
|
async function downloadRepoContents({
|
|
255
318
|
repo,
|
|
256
319
|
branch,
|
|
@@ -259,13 +322,53 @@ async function downloadRepoContents({
|
|
|
259
322
|
expectSha256 = null,
|
|
260
323
|
timeoutMs = 120000,
|
|
261
324
|
onRetry,
|
|
325
|
+
onProgress,
|
|
262
326
|
}) {
|
|
263
327
|
const id = currentMirror().id;
|
|
264
328
|
const token = currentMirror().giteeToken;
|
|
265
329
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Gitee 路径统一走这里:允许部分失败,再用 GitHub 把缺口补上。
|
|
332
|
+
* `allowPartial` 打开后 fetchGiteeTree 不再抛错,失败清单交给下面处理。
|
|
333
|
+
*/
|
|
334
|
+
const viaGitee = async () => {
|
|
335
|
+
const r = await fetchGiteeTree({
|
|
336
|
+
repo,
|
|
337
|
+
ref: branch,
|
|
338
|
+
destDir,
|
|
339
|
+
only,
|
|
340
|
+
timeoutMs,
|
|
341
|
+
token,
|
|
342
|
+
allowPartial: true,
|
|
343
|
+
});
|
|
344
|
+
if (!r.failed?.length) return r;
|
|
345
|
+
log.warn(
|
|
346
|
+
`Gitee 上有 ${r.failed.length} 个文件拉不到(常见原因是 451 内容审查),` +
|
|
347
|
+
`改用 GitHub 逐个补齐:${r.failed.slice(0, 3).map((f) => f.path).join('、')}` +
|
|
348
|
+
(r.failed.length > 3 ? ' 等' : ''),
|
|
349
|
+
);
|
|
350
|
+
const still = await backfillFromGitHub({
|
|
351
|
+
repo,
|
|
352
|
+
branch,
|
|
353
|
+
destDir,
|
|
354
|
+
failed: r.failed,
|
|
355
|
+
timeoutMs,
|
|
356
|
+
onProgress,
|
|
357
|
+
});
|
|
358
|
+
if (still.length) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`${still.length} 个文件在 Gitee 和 GitHub 上都拉不到:\n ` +
|
|
361
|
+
still
|
|
362
|
+
.slice(0, 5)
|
|
363
|
+
.map((f) => `${f.path}(${f.error})`)
|
|
364
|
+
.join('\n ') +
|
|
365
|
+
'\n可以在面板「下载源」里换一个镜像再试。',
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return { ...r, failed: [], backfilled: r.failed.length };
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
if (id === 'gitee') return viaGitee();
|
|
269
372
|
|
|
270
373
|
try {
|
|
271
374
|
return await fetchAndExtract(branchTarballUrl(repo, branch), destDir, {
|
|
@@ -280,7 +383,7 @@ async function downloadRepoContents({
|
|
|
280
383
|
log.warn(`GitHub 下载失败(${e.message}),改用 Gitee 镜像`);
|
|
281
384
|
// 清掉可能写了一半的残留,再整树重拉
|
|
282
385
|
await fs.rm(destDir, { recursive: true, force: true }).catch(() => {});
|
|
283
|
-
return
|
|
386
|
+
return viaGitee();
|
|
284
387
|
}
|
|
285
388
|
}
|
|
286
389
|
|
package/src/web/server.js
CHANGED
|
@@ -111,6 +111,57 @@ const CSP_PAGE = [
|
|
|
111
111
|
"connect-src http: https:",
|
|
112
112
|
].join('; ');
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* 插件页的「自动带令牌」补丁 —— 注入到插件页 HTML 最前面。
|
|
116
|
+
*
|
|
117
|
+
* **为什么需要(0.8.12 修的回归)**:iframe 地址上带的凭证以前是面板密钥
|
|
118
|
+
* `?key=`,插件页面自己把它转发给接口。0.8.9 起换成了一次性页面令牌
|
|
119
|
+
* `?pt=`,但**已有插件的页面还在转发 `key`** —— 于是它们发出的请求一个
|
|
120
|
+
* 凭证都没带;iframe 又是 opaque origin(`Origin: null`),`isSameOrigin()`
|
|
121
|
+
* 判跨站 → 403,页面永远停在「加载中…」,控制台只看到 `Failed to load
|
|
122
|
+
* resource: 403`。dice 的战绩页就是这么挂的。
|
|
123
|
+
*
|
|
124
|
+
* 不能指望每个插件作者都回来改这一行(市场里的插件都在外部仓库),
|
|
125
|
+
* 所以框架替页面把 `pt` 补上。
|
|
126
|
+
*
|
|
127
|
+
* **安全边界没变**:
|
|
128
|
+
* - 只补发给**面板自己**(目标 origin 必须等于文档基准的 origin),
|
|
129
|
+
* 第三方站点拿不到令牌;
|
|
130
|
+
* - 能碰哪些路径仍由 `pageTokenAllows()` 决定,拿着令牌也调不了
|
|
131
|
+
* /api/config、/api/logs、/api/bots。
|
|
132
|
+
*
|
|
133
|
+
* @param {string} pt 本次请求的页面令牌
|
|
134
|
+
* @returns {string} 一段 `<script>`,无令牌时返回空串
|
|
135
|
+
*/
|
|
136
|
+
export const pageTokenPatch = (pt) => {
|
|
137
|
+
const token = String(pt ?? '').replace(/[^A-Za-z0-9]/g, '');
|
|
138
|
+
if (!token) return '';
|
|
139
|
+
return (
|
|
140
|
+
'<script>(function(){var PT=' +
|
|
141
|
+
JSON.stringify(token) +
|
|
142
|
+
',BASE=document.baseURI||location.href,SELF="";' +
|
|
143
|
+
'try{SELF=new URL(BASE).origin}catch(e){}' +
|
|
144
|
+
// 只给面板自己的地址补令牌:第三方 origin 原样发出,绝不外泄
|
|
145
|
+
'function add(u){try{var n=new URL(u,BASE);' +
|
|
146
|
+
'if(SELF&&n.origin!==SELF)return u;' +
|
|
147
|
+
'if(n.searchParams.get("pt"))return n.href;' +
|
|
148
|
+
'n.searchParams.set("pt",PT);return n.href}catch(e){return u}}' +
|
|
149
|
+
'var f=window.fetch;if(f)window.fetch=function(i,o){try{' +
|
|
150
|
+
'if(typeof i==="string")i=add(i);' +
|
|
151
|
+
'else if(i instanceof URL)i=new URL(add(i.href));' +
|
|
152
|
+
'else if(typeof Request!=="undefined"&&i instanceof Request){var h=add(i.url);' +
|
|
153
|
+
'if(h!==i.url)i=new Request(h,i)}}catch(e){}return f.call(window,i,o)};' +
|
|
154
|
+
'var X=window.XMLHttpRequest;' +
|
|
155
|
+
'if(X&&X.prototype&&X.prototype.open){var O=X.prototype.open;' +
|
|
156
|
+
'X.prototype.open=function(m,u){if(typeof u==="string"){' +
|
|
157
|
+
'try{arguments[1]=add(u)}catch(e){}}return O.apply(this,arguments)}};' +
|
|
158
|
+
'var E=window.EventSource;' +
|
|
159
|
+
'if(E){var W=function(u,c){return new E(add(String(u)),c)};W.prototype=E.prototype;' +
|
|
160
|
+
'try{W.CONNECTING=E.CONNECTING;W.OPEN=E.OPEN;W.CLOSED=E.CLOSED}catch(e){}' +
|
|
161
|
+
'window.EventSource=W}})();<\/script>'
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
114
165
|
const MIME = {
|
|
115
166
|
'.html': 'text/html; charset=utf-8',
|
|
116
167
|
'.css': 'text/css; charset=utf-8',
|
|
@@ -1637,9 +1688,15 @@ export async function startWebServer({
|
|
|
1637
1688
|
* 所以有 doctype 就插在它后面。
|
|
1638
1689
|
*/
|
|
1639
1690
|
const style = PAGE_THEME[theme];
|
|
1691
|
+
/**
|
|
1692
|
+
* 自动带令牌的补丁必须比页面自己的脚本**先**执行,所以跟主题样式
|
|
1693
|
+
* 一起插在最前面(doctype 之后 / 没有 doctype 就文档开头)。
|
|
1694
|
+
*/
|
|
1695
|
+
const patch = pageTokenPatch(url.searchParams.get('pt') || ptPage?.token || '');
|
|
1696
|
+
const prefix = patch + style;
|
|
1640
1697
|
const html = /^<!doctype/i.test(p.html.trimStart())
|
|
1641
|
-
? p.html.replace(/^(\s*<!doctype[^>]*>)/i, `$1${
|
|
1642
|
-
:
|
|
1698
|
+
? p.html.replace(/^(\s*<!doctype[^>]*>)/i, `$1${prefix}`)
|
|
1699
|
+
: prefix + p.html;
|
|
1643
1700
|
return res.end(html);
|
|
1644
1701
|
}
|
|
1645
1702
|
|