qq-bot-roost 0.8.7 → 0.8.9

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 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.7-blueviolet" alt="版本 0.8.7">
13
+ <img src="https://img.shields.io/badge/version-0.8.8-blueviolet" alt="版本 0.8.8">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
 
16
16
  [快速开始](#3-分钟跑起来) | [写插件](docs/插件开发规范.md) | [外部插件协议](docs/插件协议.md) | [配置参考](docs/配置参考.md) | [常见问题](docs/常见问题.md) | [贡献](CONTRIBUTING.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -29,14 +29,17 @@
29
29
  "start": "node src/main.js",
30
30
  "mock": "node src/main.js --mock",
31
31
  "check": "node src/main.js --check",
32
- "test": "node test/selftest.mjs && node test/qrlogin.mjs",
32
+ "test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs",
33
33
  "test:e2e": "node test/e2e.mjs",
34
- "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/e2e.mjs && node test/live-driver.mjs",
34
+ "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node test/e2e.mjs && node test/live-driver.mjs",
35
35
  "check:secrets": "node scripts/check-secrets.mjs",
36
36
  "create": "node scripts/create-plugin.mjs",
37
37
  "test:plugin": "node scripts/test-plugin.mjs",
38
38
  "live": "node scripts/live-test.mjs",
39
- "market:index": "node scripts/build-market-index.mjs"
39
+ "market:index": "node scripts/build-market-index.mjs",
40
+ "sums": "node scripts/build-sums.mjs",
41
+ "sums:check": "node scripts/build-sums.mjs --check",
42
+ "sums:verify": "node scripts/build-sums.mjs --verify"
40
43
  },
41
44
  "author": {
42
45
  "name": "倦飞之鸟"
package/src/fsutil.js CHANGED
@@ -23,7 +23,20 @@ export async function writeJsonAtomic(file, data) {
23
23
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
24
24
  const text = `${JSON.stringify(data, null, 2)}\n`;
25
25
  try {
26
- await fs.writeFile(tmp, text, 'utf8');
26
+ /**
27
+ * `mode: 0o600` —— 只有属主能读写。
28
+ *
29
+ * 这些 JSON 里有 `config/bot.json`、`config/bots/<id>.json`,都带明文
30
+ * AppSecret;还有权限表、群档案。Linux 默认 umask 022 下新建文件是 644,
31
+ * 同机其他用户直接 `cat` 就能拿到机器人凭证。
32
+ *
33
+ * 注意两点:
34
+ * - mode 只在**创建**时生效,而这里的 tmp 每次都是新文件,所以一定吃到
35
+ * - rename 会保留临时文件的权限,覆盖掉旧文件原来的 644 —— 也就是说
36
+ * 用户只要保存过一次配置,老文件的权限就被修正了,不需要单独做迁移
37
+ * - Windows 上 mode 被忽略,不影响运行
38
+ */
39
+ await fs.writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
27
40
  await fs.rename(tmp, file);
28
41
  } catch (e) {
29
42
  await fs.rm(tmp, { force: true }).catch(() => {});
package/src/hashdir.js CHANGED
@@ -77,16 +77,24 @@ async function collect(root) {
77
77
  }
78
78
 
79
79
  /**
80
- * 算目录的内容指纹。
80
+ * 按给定的相对路径清单算指纹。
81
81
  *
82
- * @param {string} dir 插件目录
82
+ * 为什么要单独一个入口:仓库根目录里有 `data/`、`logs/`、`config/`、`plugins/`
83
+ * 这些**不会被 tarball 打包**的东西,直接 `hashDir(ROOT)` 会把它们算进去,
84
+ * 跟「解包后的 tarball」永远对不上。
85
+ *
86
+ * 所以发布流程用 `git ls-files` 拿到「真正会被打进包的文件」,再喂给这里;
87
+ * 更新校验则用 `hashDir(解包目录)`。两边必须用同一套拼接方式,否则校验必失败。
88
+ *
89
+ * @param {string} root 根目录
90
+ * @param {string[]} rels 相对路径(`/` 分隔);内部会去重并排序,顺序无关
83
91
  * @returns {Promise<string>} 64 位十六进制
84
92
  */
85
- export async function hashDir(dir) {
86
- const files = await collect(dir);
93
+ export async function hashFileList(root, rels) {
94
+ const files = [...new Set(rels)].sort();
87
95
  const h = crypto.createHash('sha256');
88
96
  for (const rel of files) {
89
- const buf = normalize(await fs.readFile(path.join(dir, rel)));
97
+ const buf = normalize(await fs.readFile(path.join(root, rel)));
90
98
  h.update(rel);
91
99
  h.update('\0');
92
100
  h.update(String(buf.length));
@@ -96,3 +104,38 @@ export async function hashDir(dir) {
96
104
  }
97
105
  return h.digest('hex');
98
106
  }
107
+
108
+ /**
109
+ * 算目录的内容指纹。
110
+ *
111
+ * @param {string} dir 插件目录
112
+ * @param {{exclude?: string[], only?: string[]}} [opts]
113
+ * - `exclude`:精确匹配要跳过的相对路径(如 `SHA256SUMS` 这种会自引用的文件)
114
+ * - `only`:只算这些路径(`src` 这类目录前缀,或 `package.json` 这类文件名)。
115
+ * 用途:GitHub 的 tarball 是**全量**(含 .github、scripts、sdk),
116
+ * 而 Gitee 源只拉会被更新覆盖的那几个条目,两边目录内容天生不同。
117
+ * 用 `only` 把范围收窄到「真正会被替换的代码」,两边才算得出同一个值。
118
+ * @returns {Promise<string>} 64 位十六进制
119
+ */
120
+ export async function hashDir(dir, opts = {}) {
121
+ /**
122
+ * 根目录不存在时必须报错,**不能静默返回空哈希**。
123
+ *
124
+ * `collect()` 里对每个目录都有 try/catch(遍历时子目录读不到要跳过),
125
+ * 但连根目录都不存在时它也会安静地返回空数组,算出来就是
126
+ * `e3b0c442…`(空字符串的 sha256)—— 那是一个**合法值**,会像真指纹一样
127
+ * 参与比对,现象是「内容全变了」而不是「路径写错了」,排查时非常误导。
128
+ */
129
+ const st = await fs.stat(dir).catch(() => null);
130
+ if (!st?.isDirectory()) throw new Error(`算指纹失败:目录不存在或不是目录 —— ${dir}`);
131
+
132
+ const skip = new Set(opts.exclude ?? []);
133
+ const only = (opts.only ?? []).map((p) => String(p));
134
+ let files = (await collect(dir)).filter((rel) => !skip.has(rel));
135
+ if (only.length) {
136
+ files = files.filter((rel) =>
137
+ only.some((p) => rel === p || rel.startsWith(p.endsWith('/') ? p : `${p}/`)),
138
+ );
139
+ }
140
+ return hashFileList(dir, files);
141
+ }
package/src/mirror.js CHANGED
@@ -86,9 +86,36 @@ const AUTO_TTL = 10 * 60_000;
86
86
  export function configureMirror(globalCfg) {
87
87
  const n = globalCfg?.network ?? {};
88
88
  const id = String(n.mirror ?? 'auto').trim() || 'auto';
89
+
90
+ /**
91
+ * 自定义下载前缀必须是 https。
92
+ *
93
+ * 这个值会被直接拼在 tarball 地址前面(见 applyMirror),而这个项目**更新包
94
+ * 没有内容校验**(updater.js 的 expectSha256 恒为 null)。也就是说:
95
+ * 填一个 http 地址,等于让链路上的任何人决定你下一版跑什么代码。
96
+ *
97
+ * 不合法就退回默认(不用它),而不是抛错 —— 配置文件写错了不该让整个框架起不来。
98
+ */
99
+ let custom = String(n.mirrorCustom ?? '').trim();
100
+ if (custom) {
101
+ let u = null;
102
+ try {
103
+ u = new URL(custom.includes('://') ? custom : `https://${custom}`);
104
+ } catch {
105
+ u = null;
106
+ }
107
+ if (!u || u.protocol !== 'https:') {
108
+ log.warn(
109
+ `network.mirrorCustom 必须是 https:// 开头,已忽略:${custom}\n` +
110
+ ' (自定义下载源会决定你下一版跑什么代码,明文 http 等于把它交给链路上的任何人)',
111
+ );
112
+ custom = '';
113
+ }
114
+ }
115
+
89
116
  current = {
90
117
  id: PRESETS.some((p) => p.id === id) ? id : 'auto',
91
- custom: String(n.mirrorCustom ?? '').trim(),
118
+ custom,
92
119
  // Gitee 私人令牌:选填。公开仓库匿名也能用,填了只是把 API 配额提上去
93
120
  giteeToken: String(n.giteeToken ?? '').trim(),
94
121
  };
@@ -160,6 +187,17 @@ export function applyMirror(url, preset, custom = current.custom) {
160
187
  // 允许用户只填域名,自动补协议和结尾斜杠
161
188
  let b = base.trim();
162
189
  if (!/^https?:\/\//i.test(b)) b = `https://${b}`;
190
+ /**
191
+ * 自定义前缀必须是 https —— 这里再挡一道。
192
+ *
193
+ * configureMirror 已经校验过配置里的值,但 `probeMirrors` 的 custom 是
194
+ * 面板请求体直接传进来的(「测速」按钮),不经过 configureMirror。
195
+ * 不合法就**退回原始地址**,不要拼一个 http 前缀上去。
196
+ */
197
+ if (preset.kind === 'custom' && !/^https:\/\//i.test(b)) {
198
+ log.warn(`自定义下载源必须是 https,已忽略:${base}`);
199
+ return url;
200
+ }
163
201
  if (!b.endsWith('/')) b += '/';
164
202
  // 已经是这个前缀了就别套第二次
165
203
  if (url.startsWith(b)) return url;
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync, readdirSync } from 'node:fs';
2
3
  import fs from 'node:fs/promises';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
6
  import { createHash } from 'node:crypto';
5
7
  import { rel } from '../paths.js';
@@ -74,9 +76,65 @@ async function usable(cmd, args = ['--version']) {
74
76
 
75
77
  let cachedRuntimes = null;
76
78
 
79
+ const IS_WIN = process.platform === 'win32';
80
+
81
+ /** npm 的安装参数。`--ignore-scripts` 见下面调用处的注释 */
82
+ const NPM_ARGS = ['install', '--omit=dev', '--no-audit', '--no-fund', '--ignore-scripts'];
83
+
84
+ /**
85
+ * pnpm 的安装参数。
86
+ *
87
+ * `--config.node-linker=hoisted`:默认的 pnpm 用软链把 `node_modules` 指到全局
88
+ * store,插件是被人手动丢进 `plugins/` 的,不归 pnpm 管;store 一清,
89
+ * `import` 就断。hoisted 给的是跟 npm 一样的扁平目录,行为最可预期。
90
+ *
91
+ * `--ignore-workspace`:插件目录万一落在某个 pnpm workspace 里面(用户把
92
+ * Roost 放在自己项目里就会),pnpm 会拒绝安装并让你去 workspace 根装。
93
+ */
94
+ const PNPM_ARGS = ['install', '--prod', '--ignore-scripts', '--config.node-linker=hoisted', '--ignore-workspace'];
95
+
96
+ /**
97
+ * DSH 桌面版自带的运行时目录。
98
+ *
99
+ * **为什么必须找它**:DSH 是 Electron,安装目录里既没有 `node.exe` 也没有
100
+ * npm;但它自己带了一整套运行时(node / pnpm / python),放在
101
+ * `~/.dsh/dsh-runtimes/<runtime>/dependencies/` 下——DSH 装插件用的就是那套 pnpm。
102
+ *
103
+ * 只查 PATH 的后果很严重:没单独装过 Node.js 的用户 PATH 里当然没有 npm,
104
+ * 于是任何带依赖的插件都会「缺少 npm」而**整个加载失败**,
105
+ * 明明机器上有一套现成能用的。
106
+ */
107
+ export function bundledRuntimeRoots({ home = os.homedir() } = {}) {
108
+ const base = path.join(home, '.dsh', 'dsh-runtimes');
109
+ const out = [];
110
+ try {
111
+ for (const name of readdirSync(base)) {
112
+ const dir = path.join(base, name, 'dependencies');
113
+ if (existsSync(dir)) out.push(dir);
114
+ }
115
+ } catch {
116
+ // 没有这个目录很正常:用户可能根本没装 DSH 桌面版
117
+ }
118
+ // 名字里带 primary 的排前面——DSH 自己也优先用它
119
+ return out.sort((a, b) => Number(b.includes('primary')) - Number(a.includes('primary')));
120
+ }
121
+
122
+ /** 在若干运行时根目录里找同一个相对路径,找到就返回绝对路径 */
123
+ function pickIn(roots, ...segs) {
124
+ for (const r of roots) {
125
+ const p = path.join(r, ...segs);
126
+ if (existsSync(p)) return p;
127
+ }
128
+ return null;
129
+ }
130
+
77
131
  /**
78
- * 找出这台机器上能用的 Python / Node 命令。
132
+ * 找出这台机器上能用的 Python / Node 命令,以及「用谁来装 Node 依赖」。
79
133
  * 只在第一次调用时真正探测,之后走缓存。
134
+ *
135
+ * 返回里的 `installer` 是个描述符 `{ kind, cmd, args, label }`——
136
+ * 因为 npm 和 pnpm 的参数不一样,而且 DSH 自带的 pnpm 还得靠自带的 node 去跑,
137
+ * 光有一个命令名表达不了。
80
138
  */
81
139
  export async function detectRuntimes({ refresh = false } = {}) {
82
140
  if (cachedRuntimes && !refresh) return cachedRuntimes;
@@ -103,7 +161,50 @@ export async function detectRuntimes({ refresh = false } = {}) {
103
161
  }
104
162
  }
105
163
 
106
- cachedRuntimes = { python, npm, node: process.execPath };
164
+ // PATH 上的 pnpm:装了 Node.js 的人常常也装了它,参数比 npm 好控
165
+ let pnpm = null;
166
+ for (const c of IS_WIN ? ['pnpm.cmd', 'pnpm'] : ['pnpm']) {
167
+ if (await usable(c)) {
168
+ pnpm = c;
169
+ break;
170
+ }
171
+ }
172
+
173
+ /* ---------- DSH 自带运行时(PATH 上找不到时的兜底) ---------- */
174
+ const roots = bundledRuntimeRoots();
175
+ const bundledNode = pickIn(roots, 'node', 'bin', IS_WIN ? 'node.exe' : 'node');
176
+ const bundledPnpm = pickIn(roots, 'pnpm', 'bin', 'pnpm.cjs');
177
+ const bundledPython = IS_WIN
178
+ ? pickIn(roots, 'python', 'python.exe')
179
+ : (pickIn(roots, 'python', 'bin', 'python3') || pickIn(roots, 'python', 'bin', 'python'));
180
+
181
+ // 系统 Python 优先(用户可能装了带自己库的那个),找不到才用自带的
182
+ if (!python && bundledPython) python = bundledPython;
183
+
184
+ /**
185
+ * 装依赖用哪个工具。顺序:npm → pnpm → DSH 自带 pnpm。
186
+ *
187
+ * 自带 pnpm 必须用**自带的 node** 去跑 `pnpm.cjs`,不能用 `process.execPath`:
188
+ * 那是 DSH 的 Electron 主程序,只有在带 `ELECTRON_RUN_AS_NODE` 时才当 node 用,
189
+ * 而这个变量不在 `buildPluginEnv` 的白名单里,直接跑会把 GUI 拉起来。
190
+ */
191
+ let installer = null;
192
+ if (npm) {
193
+ installer = { kind: 'npm', cmd: npm, args: NPM_ARGS, label: 'npm' };
194
+ } else if (pnpm) {
195
+ installer = { kind: 'pnpm', cmd: pnpm, args: PNPM_ARGS, label: 'pnpm' };
196
+ } else if (bundledPnpm && bundledNode) {
197
+ installer = { kind: 'dsh-pnpm', cmd: bundledNode, args: [bundledPnpm, ...PNPM_ARGS], label: 'DSH 自带 pnpm' };
198
+ }
199
+
200
+ cachedRuntimes = {
201
+ python,
202
+ npm,
203
+ pnpm,
204
+ node: process.execPath,
205
+ installer,
206
+ bundled: { node: bundledNode, pnpm: bundledPnpm, python: bundledPython },
207
+ };
107
208
  return cachedRuntimes;
108
209
  }
109
210
 
@@ -146,15 +247,22 @@ function venvPython(venvDir) {
146
247
  * @param {object} p.manifest plugin.json 内容
147
248
  * @param {object} p.log 日志器
148
249
  * @param {boolean} [p.enabled] 是否允许自动安装(来自 config.plugins.autoInstallDeps)
250
+ * @param {object} [p.runtimes] 直接指定运行时探测结果,跳过 detectRuntimes()。
251
+ * **只给测试用**:`detectRuntimes()` 带全局缓存,测「一个工具都没有」这条分支
252
+ * 只能靠伪造 PATH / HOME,会把别的测试带歪。
149
253
  * @returns {Promise<{python?:string, installed:string[], skipped:string[], warnings:string[]}>}
150
254
  */
151
- export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled = true }) {
255
+ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled = true, runtimes = null }) {
152
256
  const result = { python: null, installed: [], skipped: [], warnings: [] };
153
257
  // 统一转绝对路径:下面给 pip / npm 传的路径和 cwd 都以它为准,避免相对路径被拼两次
154
258
  dir = path.resolve(dir);
155
259
  const runtime = String(manifest.runtime ?? '').toLowerCase();
156
260
  const wantsPython = runtime === 'python' || runtime === 'python3';
157
261
 
262
+ // 一次探测,两处用;注入的优先
263
+ let rt = runtimes;
264
+ const getRt = async () => (rt ??= await detectRuntimes());
265
+
158
266
  const reqFile = path.join(dir, 'requirements.txt');
159
267
  const pkgFile = path.join(dir, 'package.json');
160
268
  const requirements = await readIfExists(reqFile);
@@ -162,10 +270,10 @@ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled
162
270
 
163
271
  // 先确定 Python 解释器:有 venv 用 venv 的,没有就找系统的
164
272
  if (wantsPython) {
165
- const rt = await detectRuntimes();
273
+ const rt = await getRt();
166
274
  if (!rt.python) {
167
275
  throw new Error(
168
- '这个插件需要 Python,但系统里找不到 python / python3 / py。\n' +
276
+ '这个插件需要 Python,但系统里找不到 python / python3 / py,也没找到 DSH 自带的 Python。\n' +
169
277
  '请先安装 Python 3.9+(https://www.python.org/downloads/),装完重试。',
170
278
  );
171
279
  }
@@ -250,8 +358,20 @@ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled
250
358
  } catch {
251
359
  result.warnings.push('package.json 不是合法 JSON,已跳过依赖安装');
252
360
  }
253
- const deps = pkg ? Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.optionalDependencies ?? {}) }) : [];
254
- if (deps.length) {
361
+
362
+ /**
363
+ * **必装和可选必须分开。**
364
+ *
365
+ * `dependencies` 缺了插件就是坏的;`optionalDependencies` 缺了只是某个功能
366
+ * 用不了。以前两者合并成一个列表,于是「语音这种可选能力」能把整个插件
367
+ * 拦在门外:找不到 npm 就 throw,`loader.js` 接住之后插件直接不加载。
368
+ * ai-chat 就是这么在没装 Node.js 的机器上整个起不来的。
369
+ */
370
+ const required = pkg ? Object.keys(pkg.dependencies ?? {}) : [];
371
+ const optional = pkg ? Object.keys(pkg.optionalDependencies ?? {}) : [];
372
+ const all = [...new Set([...required, ...optional])];
373
+
374
+ if (all.length) {
255
375
  const hasModules = await fs
256
376
  .access(path.join(dir, 'node_modules'))
257
377
  .then(() => true)
@@ -265,27 +385,41 @@ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled
265
385
  '检测到 package.json 依赖需要安装,但 config 里 plugins.autoInstallDeps = false,已跳过',
266
386
  );
267
387
  } else {
268
- const rt = await detectRuntimes();
269
- if (!rt.npm) {
270
- throw new Error('这个插件需要 npm 安装依赖,但系统里找不到 npm(通常随 Node.js 一起安装)');
271
- }
272
- log?.info(`安装 Node 依赖(${deps.length} 个),首次会慢一点…`);
273
- // --ignore-scripts:依赖的 postinstall 就是任意代码执行入口,
274
- // 装依赖不该顺带跑别人的安装脚本
275
- const r = await run(
276
- rt.npm,
277
- ['install', '--omit=dev', '--no-audit', '--no-fund', '--ignore-scripts'],
278
- { cwd: dir },
279
- );
280
- if (!r.ok) {
281
- throw new Error(
282
- `安装 Node 依赖失败:${r.reason || (r.err || r.out).slice(-600)}\n` +
283
- `可以手动执行:cd ${rel(dir)} && npm install`,
388
+ const rt = await getRt();
389
+ if (!rt.installer) {
390
+ // 必装缺工具才算致命;可选缺工具只丢功能,不能让插件起不来
391
+ if (required.length) {
392
+ throw new Error(
393
+ `这个插件需要 ${required.length} 个 Node 依赖(${required.join('、')}),`
394
+ + '但系统里既没有 npm 也没有 pnpm,也没找到 DSH 自带的运行时。\n'
395
+ + '装一个 Node.js(https://nodejs.org/)再重启即可。',
396
+ );
397
+ }
398
+ result.warnings.push(
399
+ `有 ${optional.length} 个可选依赖(${optional.join('、')})没装:`
400
+ + '找不到 npm / pnpm,也没找到 DSH 自带的运行时。相关功能会不可用,其余功能正常。',
401
+ );
402
+ } else {
403
+ log?.info(
404
+ `安装 Node 依赖(${all.length} 个,用 ${rt.installer.label})`
405
+ + `${optional.length && !required.length ? ',都是可选的' : ''},首次会慢一点…`,
284
406
  );
407
+ // --ignore-scripts:依赖的 postinstall 就是任意代码执行入口,
408
+ // 装依赖不该顺带跑别人的安装脚本
409
+ const r = await run(rt.installer.cmd, rt.installer.args, { cwd: dir });
410
+ const detail = `${r.reason || (r.err || r.out).slice(-600)}\n`
411
+ + `可以手动执行:cd ${rel(dir)} && ${rt.installer.label} install`;
412
+ if (!r.ok) {
413
+ if (required.length) throw new Error(`安装 Node 依赖失败:${detail}`);
414
+ result.warnings.push(`可选依赖没装上(不影响主功能):${detail}`);
415
+ } else {
416
+ result.installed.push(`Node 依赖(${rt.installer.label})`);
417
+ }
418
+ // 装过就记账(成不成功都记)。**不能靠它拦住重试**——
419
+ // 失败时 node_modules 仍然不存在,上面 `!hasModules` 会让下次加载再试一遍
420
+ marker.packageJson = wantPkg;
421
+ await writeMarker(dir, marker);
285
422
  }
286
- result.installed.push('Node 依赖');
287
- marker.packageJson = wantPkg;
288
- await writeMarker(dir, marker);
289
423
  }
290
424
  }
291
425
  }
@@ -17,8 +17,23 @@
17
17
  * - 页面在 iframe 里渲染:CSS 不会漏到面板上,脚本也拿不到面板的 DOM
18
18
  *
19
19
  * 关于安全:插件**本身**就是可信代码(内建插件跑在主进程,外部插件是子进程),
20
- * 所以这里不做权限校验。iframe 的作用是样式隔离和避免互相干扰,不是沙箱边界。
20
+ * 所以这里不做权限校验。iframe 的作用是样式隔离和避免互相干扰。
21
+ *
22
+ * ## 页面令牌(page token)
23
+ *
24
+ * 2026-10-28 加:以前 iframe 地址上直接拼面板的 accessKey,插件页一行
25
+ * `location.search` 就能拿到面板全权(装插件 = 交出面板密码)。现在每个页面
26
+ * 注册时生成一个随机 token,iframe 只带 `?pt=<token>`,并且在面板侧配
27
+ * `sandbox="allow-scripts allow-forms"`(不给 allow-same-origin),
28
+ * 插件页既读不到父页 localStorage,也拿不到面板密钥。
29
+ *
30
+ * token 的权限边界由 server.js 的 `authorized()` 决定:只放行**该插件自己**的
31
+ * 页面与接口(`/api/pages/<id>:<key>/html`、`/api/plugins/<id>/panel`、
32
+ * `/api/plugins/<id>/r/*`),不放行 `/api/config`、`/api/logs` 等面板接口。
33
+ * 方法不限于 GET —— 斗罗那类运营后台要 POST 改数据,只读会直接废掉它,
34
+ * 而限制「插件写自己的数据」没有安全收益(插件本来就有子进程执行权)。
21
35
  */
36
+ import crypto from 'node:crypto';
22
37
  import { createLogger } from '../logger.js';
23
38
 
24
39
  const log = createLogger('pages');
@@ -51,7 +66,13 @@ export class PageRegistry {
51
66
  }
52
67
 
53
68
  const full = `${pluginId}:${key}`;
54
- this.pages.set(full, { key: full, shortKey: key, label, html, pluginId });
69
+ /**
70
+ * 页面令牌:24 字节随机(48 位十六进制),进程内有效。
71
+ * 插件重载会重新 register,于是自动换一把新的——旧页面标签刷一下就拿不到了,
72
+ * 这正是想要的(卸载/重载后旧链接失效)。
73
+ */
74
+ const token = crypto.randomBytes(24).toString('hex');
75
+ this.pages.set(full, { key: full, shortKey: key, label, html, pluginId, token });
55
76
  log.info(`插件 ${pluginId} 注册了面板页面「${label}」(${full})`);
56
77
  return full;
57
78
  }
@@ -75,9 +96,24 @@ export class PageRegistry {
75
96
  key: p.key,
76
97
  label: p.label,
77
98
  pluginId: p.pluginId,
99
+ // 前端拼 iframe 地址用。列表接口本身是鉴权过的,所以这里给得出去
100
+ token: p.token,
78
101
  }));
79
102
  }
80
103
 
104
+ /**
105
+ * 按页面令牌反查。给 `authorized()` 用:iframe 是 opaque origin,
106
+ * 拿不到 Cookie,只能靠这个 token 证明「我是这个面板页」。
107
+ * @returns {{key:string, pluginId:string, token:string}|null}
108
+ */
109
+ findByToken(token) {
110
+ if (!token) return null;
111
+ for (const p of this.pages.values()) {
112
+ if (p.token === token) return p;
113
+ }
114
+ return null;
115
+ }
116
+
81
117
  get(key) {
82
118
  return this.pages.get(String(key)) ?? null;
83
119
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 更新会替换的代码条目(单一来源)
3
+ * ================================
4
+ *
5
+ * 三处共用:
6
+ * 1. `updater.js` 备份与替换(rsync 式覆盖,绝不碰 config/data/logs/plugins)
7
+ * 2. `gitee.js` 的前缀过滤(Gitee 没有 archive 直链,只能按清单逐个拉)
8
+ * 3. `scripts/build-sums.mjs` 算内容指纹的范围
9
+ *
10
+ * 为什么指纹也按这个范围算:GitHub 的 tarball 是**全量**(还带 .github、
11
+ * scripts、sdk、types),而 Gitee 源只拉下面这几个条目。如果指纹按「包里的
12
+ * 全部文件」算,两条下载路径永远对不上 —— 一边写死校验失败,一边等于没校验。
13
+ *
14
+ * 改这个清单要连带重跑 `npm run sums`,否则已发布的指纹会全部失配。
15
+ */
16
+ export const CODE_ENTRIES = [
17
+ 'src',
18
+ 'web',
19
+ 'test',
20
+ 'docs',
21
+ 'package.json',
22
+ 'README.md',
23
+ 'LICENSE',
24
+ 'start.bat',
25
+ ];
26
+
27
+ /** 绝对不能删/覆盖的目录(双保险) */
28
+ export const PROTECTED_ENTRIES = new Set(['config', 'data', 'logs', 'plugins', 'node_modules', '.git']);
package/src/updater.js CHANGED
@@ -20,6 +20,8 @@ import { createLogger } from './logger.js';
20
20
  import { fetchAndExtract } from './tarball.js';
21
21
  import { fetchViaMirror, currentMirror } from './mirror.js';
22
22
  import { fetchGiteeTree, detectGiteeRef, giteeRawUrl } from './gitee.js';
23
+ import { hashDir } from './hashdir.js';
24
+ import { CODE_ENTRIES, PROTECTED_ENTRIES as PROTECTED } from './update-scope.js';
23
25
  import { ROOT } from './config.js';
24
26
 
25
27
  const log = createLogger('update');
@@ -27,11 +29,11 @@ const log = createLogger('update');
27
29
  /** 仓库信息。换成自己的 fork 就改这里 */
28
30
  const REPO = 'tdcq9594/qq-bot-roost';
29
31
 
30
- /** 允许被更新覆盖的顶层条目。**用户数据一个都不在里面** */
31
- const CODE_ENTRIES = ['src', 'web', 'test', 'docs', 'package.json', 'README.md', 'LICENSE', 'start.bat'];
32
-
33
- /** 绝对不能删的目录,双保险 */
34
- const PROTECTED = new Set(['config', 'data', 'logs', 'plugins', 'node_modules', '.git']);
32
+ /**
33
+ * 允许被更新覆盖的顶层条目、以及绝对不能碰的目录,都在 ./update-scope.js。
34
+ * 内容指纹用的是**同一份清单**——GitHub tarball 是全量、Gitee 只拉这几个条目,
35
+ * 范围不一致的话两条下载路径算出来的指纹永远对不上。
36
+ */
35
37
 
36
38
  /**
37
39
  * Gitee 源下拉取的文件范围:只拉会被更新覆盖的代码条目。
@@ -100,9 +102,58 @@ async function updatedButNotRestarted() {
100
102
  * 包一层而不是往 checkUpdate 里塞:里面有好几个返回分支(Gitee /
101
103
  * 主站 302 / API 兜底 / 404 / 失败),每个都手写一遍 needsRestart 迟早漏一个。
102
104
  */
105
+ /**
106
+ * 拉 SHA256SUMS,得到「版本 → 内容指纹」的映射。
107
+ *
108
+ * 两个源都试:GitHub raw(经下载源改写)和 Gitee raw。任何一边拿到都行,
109
+ * 因为指纹是「代码内容」的,与从哪个源下载无关。
110
+ *
111
+ * @returns {Promise<Map<string,string>>} 版本号 → 64 位十六进制
112
+ */
113
+ async function fetchSumsMap() {
114
+ const urls = [
115
+ `https://raw.githubusercontent.com/${REPO}/master/SHA256SUMS`,
116
+ `https://gitee.com/${REPO}/raw/master/SHA256SUMS`,
117
+ ];
118
+ for (const u of urls) {
119
+ try {
120
+ const res = await fetchViaMirror(u, {
121
+ headers: { 'User-Agent': 'qq-bot-roost-updater' },
122
+ signal: AbortSignal.timeout(8000),
123
+ });
124
+ if (!res.ok) continue;
125
+ const text = await res.text();
126
+ const map = new Map();
127
+ for (const line of text.split(/\r?\n/)) {
128
+ const m = /^([a-f0-9]{64})\s+v?([0-9][\w.+-]*)$/i.exec(line.trim());
129
+ if (m) map.set(m[2].replace(/^v/i, ''), m[1].toLowerCase());
130
+ }
131
+ if (map.size) return map;
132
+ } catch {
133
+ // 换下一个源
134
+ }
135
+ }
136
+ return new Map();
137
+ }
138
+
139
+ /**
140
+ * 查某个版本的内容指纹。拿不到返回 null —— 真正的拒绝发生在 applyUpdate,
141
+ * 因为「只检查不安装」时面板仍应正常显示有新版本,只是点更新会失败。
142
+ */
143
+ async function fetchTreeSha256(version) {
144
+ try {
145
+ const map = await fetchSumsMap();
146
+ return map.get(String(version).replace(/^v/, '')) ?? null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
103
152
  export async function checkUpdate() {
104
153
  const needsRestart = await updatedButNotRestarted();
105
154
  const r = await checkUpdateCore();
155
+ // 有新版本才值得多跑一次网络请求
156
+ if (r?.ok && r.hasUpdate) r.treeSha256 = await fetchTreeSha256(r.latest);
106
157
  return { ...r, needsRestart };
107
158
  }
108
159
 
@@ -442,6 +493,37 @@ async function applyUpdateOnce(info) {
442
493
  throw new Error('下载的包里没有 src/main.js,不是合法的框架包,已中止');
443
494
  });
444
495
 
496
+ /**
497
+ * 内容指纹校验 —— **必须放在备份和替换之前**。
498
+ *
499
+ * 这一步失败就等于什么都没发生过:文件没动、备份也没建。
500
+ *
501
+ * 为什么必须严格:更新会直接覆盖 `src/`、`web/` 这些要执行的代码。
502
+ * 只要能改掉下载地址(自定义镜像、上游仓库被入侵、链路劫持),
503
+ * 就能决定用户下一版跑什么。指纹对不上就说明「下载到的东西不是作者发的」。
504
+ *
505
+ * 为什么没有指纹也要拒绝:留一个「没指纹就放行」的口子,攻击者只要把
506
+ * SHA256SUMS 里那条删掉或让仓库回到没有该文件的形态就能绕过。
507
+ * 代价是发布流程必须每版都带指纹 —— 这由 CI 的 `npm run sums:check` 守着。
508
+ */
509
+ if (!rel.treeSha256) {
510
+ throw new Error(
511
+ `v${rel.latest} 没有发布内容指纹(SHA256SUMS 里查不到),已拒绝自动更新。\n` +
512
+ '这不是你的网络问题:作者这一版可能漏了生成指纹。\n' +
513
+ '可以手动到 GitHub Releases 下载安装,或等下一版。你的代码没有任何改动。',
514
+ );
515
+ }
516
+ const gotHash = await hashDir(workDir, { only: CODE_ENTRIES });
517
+ if (gotHash !== rel.treeSha256) {
518
+ throw new Error(
519
+ `更新内容校验失败,已中止(你的代码没有任何改动)。\n` +
520
+ ` 期望 ${rel.treeSha256}\n` +
521
+ ` 实际 ${gotHash}\n` +
522
+ '可能是下载源被篡改,或者发布时改了代码却忘了重跑 npm run sums。',
523
+ );
524
+ }
525
+ log.info(`内容指纹校验通过(${gotHash.slice(0, 12)}…)`);
526
+
445
527
  // 备份现有代码
446
528
  await fs.mkdir(backupDir, { recursive: true });
447
529
  for (const name of CODE_ENTRIES) {