dsh-folia 4.9.1 → 4.12.2

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.
@@ -0,0 +1,397 @@
1
+ /**
2
+ * routes/skins.js — **助手皮肤**页签的宿主半边:列站点皮肤、安装到皮肤中心目录、卸载。
3
+ *
4
+ * 为什么是这个形态(而不是另造一套渲染):皮肤中心扫的是 `$DSH_HOME/skins/<id>/`
5
+ * (`skin.json` + `skin.css` + 可选 `hooks.mjs` + `assets/`)。我们只要**把站点上的 zip 解到那个目录**,
6
+ * 皮肤中心下一次 catalog 扫描就会认领它 —— 它的缓存键里带父目录 `mtimeMs`,新增目录必然穿透缓存。
7
+ * 所以这里不"通知"皮肤中心,也不需要它配合:写盘即生效。
8
+ *
9
+ * 数据契约(站点一条 = 一个 zip;解出来必须是 v2 皮肤目录):
10
+ * <皮肤根>/<id>/skin.json 必填(id/name 从里面取,**不信任 URL 里的名字**)
11
+ * <皮肤根>/<id>/skin.css 必填(contributes.stylesheet 指向它)
12
+ * hooks.mjs 可选;带它时皮肤中心会要求 `dsh-market.provenance.json` 字节对得上才执行 ——
13
+ * 我们的 zip 是原样打的,所以 provenance 一起解出来,语义与市场安装一致。
14
+ *
15
+ * 纪律:
16
+ * · 只写 `$DSH_HOME/skins`,删也只删它下面(realpath 前缀校验,防符号链接逃逸);
17
+ * · 下载白名单 = 站点 host(挡 SSRF);只认 zip 魔数;单包上限 200 MB;
18
+ * · 解包拒路径穿越(整包拒绝,不是跳过);可执行后缀一律不落盘;
19
+ * · 失败清干净:绝不留下没有 skin.json 的残缺目录(否则皮肤中心会把它算进"坏皮肤")。
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ import { inflateRawSync } from 'node:zlib';
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+
27
+ /** 注入契约(ADR-0013 D12)。 */
28
+ export { unzipSkinZip, safeSkinId };
29
+ export const DEPS = ['disposers', 'base', 'log'];
30
+
31
+ /** 站点分类 slug:助手皮肤。 */
32
+ const SKIN_CATEGORY = 'assistant-skin';
33
+ const MAX_ZIP_BYTES = 200 * 1024 * 1024;
34
+ const FETCH_TIMEOUT_MS = 60000;
35
+ const SKIN_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
36
+ /** 可执行/脚本后缀:皮肤包不需要,一律不写(防"皮肤包里塞个 exe")。 */
37
+ const SKIP_EXEC_RE = /\.(exe|dll|bat|cmd|com|scr|ps1|sh|msi|vbs|wsf|hta|lnk|jar)$/i;
38
+
39
+ function dshHome() {
40
+ return process.env.DSH_HOME || join(process.env.APPDATA || homedir(), 'dsh-desktop', 'harness');
41
+ }
42
+ /** 皮肤根:与皮肤中心同一个目录(它读 `$DSH_HOME/skins`)。 */
43
+ function skinsRoot() { return join(dshHome(), 'skins'); }
44
+
45
+ function siteBase() {
46
+ const raw = process.env.DSH_FOLIA_SITE;
47
+ if (typeof raw === 'string' && raw.trim().toLowerCase() === 'off') return null;
48
+ const base = (typeof raw === 'string' && raw.trim()) ? raw.trim() : 'https://folia-5ve.pages.dev';
49
+ return base.replace(/\/+$/, '');
50
+ }
51
+
52
+ /**
53
+ * 皮肤中心是否在线。
54
+ *
55
+ * 为什么由宿主探而不是前端探:两个插件跑在同一个 webServer 上,用请求自带的 Host 就能问到自己 ——
56
+ * 前端也能问,但宿主这一侧拿到的结果与"装没装"一一对应,而且不受浏览器缓存/跨源策略影响。
57
+ * 判据用**它自己的 catalog 路由**:该路由不需要鉴权(实测 200),404 就是没装。
58
+ */
59
+ const skinCenterProbe = { at: 0, state: 'unknown' };
60
+ async function probeSkinCenter(hostHeader, force) {
61
+ const now = Date.now();
62
+ if (!force && skinCenterProbe.at && now - skinCenterProbe.at < 30000) return skinCenterProbe.state;
63
+ let state = 'unknown';
64
+ if (hostHeader) {
65
+ try {
66
+ const r = await fetch('http://' + hostHeader + '/api/skin-center/v2/catalog', { signal: AbortSignal.timeout(4000) });
67
+ state = r.status === 404 ? 'absent' : (r.ok ? 'present' : 'present');
68
+ // 401/403 也说明插件在(只是该路由被别的版本加了鉴权);只有 404 才代表没装。
69
+ } catch { state = 'unknown'; }
70
+ }
71
+ skinCenterProbe.at = now;
72
+ skinCenterProbe.state = state;
73
+ return state;
74
+ }
75
+
76
+ /** 站点列表缓存(30 秒):面板打开与"刷新"共用,避免每次都打站点。 */
77
+ const siteCache = { at: 0, items: [], error: null };
78
+ const SITE_TTL_MS = 30000;
79
+
80
+ async function fetchSiteSkins(force) {
81
+ const now = Date.now();
82
+ if (!force && siteCache.at && now - siteCache.at < SITE_TTL_MS) return siteCache;
83
+ const base = siteBase();
84
+ if (!base) { siteCache.at = now; siteCache.items = []; siteCache.error = 'site-off'; return siteCache; }
85
+ try {
86
+ const url = base + '/api/public/wallpapers?category=' + encodeURIComponent(SKIN_CATEGORY) + '&limit=100';
87
+ const res = await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20000) });
88
+ if (!res.ok) throw new Error('http-' + res.status);
89
+ const body = await res.json();
90
+ const items = (body && body.data && body.data.items) || [];
91
+ siteCache.items = items.map((it) => ({
92
+ id: String(it.slug || ''),
93
+ title: String(it.title || it.slug || ''),
94
+ bytes: Number(it.bytes) || 0,
95
+ mime: String(it.mime || ''),
96
+ url: String(it.url || ''),
97
+ thumbUrl: it.thumbUrl ? String(it.thumbUrl) : '',
98
+ tags: (() => { try { return JSON.parse(it.tags || '[]'); } catch { return []; } })(),
99
+ }));
100
+ siteCache.error = null;
101
+ } catch (e) {
102
+ siteCache.error = (e && e.message) || 'fetch-failed';
103
+ siteCache.items = siteCache.items || [];
104
+ }
105
+ siteCache.at = now;
106
+ return siteCache;
107
+ }
108
+
109
+ /** 本机已装皮肤(只读 `$DSH_HOME/skins`)。 */
110
+ function listInstalled() {
111
+ const root = skinsRoot();
112
+ let names = [];
113
+ try { names = readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return []; }
114
+ const out = [];
115
+ for (const dirId of names) {
116
+ const dir = join(root, dirId);
117
+ let manifest;
118
+ try { manifest = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8')); } catch { continue; }
119
+ let bytes = 0;
120
+ try { bytes = dirBytes(dir); } catch { /* 忽略 */ }
121
+ out.push({
122
+ dirId,
123
+ id: String(manifest.id || dirId),
124
+ name: String(manifest.name || manifest.nameEn || manifest.id || dirId),
125
+ nameEn: manifest.nameEn ? String(manifest.nameEn) : '',
126
+ version: String(manifest.version || ''),
127
+ author: String(manifest.author || ''),
128
+ accent: manifest.accent ? String(manifest.accent) : '',
129
+ hasHooks: existsSync(join(dir, 'hooks.mjs')),
130
+ hasProvenance: existsSync(join(dir, 'dsh-market.provenance.json')),
131
+ bytes,
132
+ });
133
+ }
134
+ out.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
135
+ return out;
136
+ }
137
+
138
+ function dirBytes(dir) {
139
+ let total = 0;
140
+ for (const name of readdirSync(dir)) {
141
+ const abs = join(dir, name);
142
+ const st = statSync(abs);
143
+ total += st.isDirectory() ? dirBytes(abs) : st.size;
144
+ }
145
+ return total;
146
+ }
147
+
148
+ /* ── zip 读取(只读,够用即可;判据见 test/skins-http.mjs)────────────────── */
149
+ /**
150
+ * 解一个 zip 缓冲成条目数组。
151
+ *
152
+ * @param {Buffer} buf
153
+ * @param {{ maxEntries?: number, maxEntryBytes?: number, maxTotalBytes?: number }} [limits]
154
+ * @returns {{ name: string, data: Buffer, isDir: boolean }[]}
155
+ * @throws {Error} code: `not-a-zip` / `bad-zip` / `unsafe-entry` / `too-large`
156
+ */
157
+ function unzipSkinZip(buf, limits = {}) {
158
+ const maxEntries = limits.maxEntries || 4000;
159
+ const maxEntry = limits.maxEntryBytes || 256 * 1024 * 1024;
160
+ const maxTotal = limits.maxTotalBytes || 512 * 1024 * 1024;
161
+ if (!Buffer.isBuffer(buf) || buf.length < 22) throw Object.assign(new Error('not a zip'), { code: 'not-a-zip' });
162
+ const magic = buf.readUInt32LE(0);
163
+ if (magic !== 0x04034b50 && magic !== 0x06054b50) throw Object.assign(new Error('not a zip'), { code: 'not-a-zip' });
164
+ let eocd = -1;
165
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i -= 1) {
166
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
167
+ }
168
+ if (eocd < 0) throw Object.assign(new Error('no EOCD'), { code: 'bad-zip' });
169
+ const count = buf.readUInt16LE(eocd + 10);
170
+ const cdSize = buf.readUInt32LE(eocd + 12);
171
+ const cdOffset = buf.readUInt32LE(eocd + 16);
172
+ if (count > maxEntries) throw Object.assign(new Error('too many entries'), { code: 'too-large' });
173
+ if (cdOffset + cdSize > buf.length) throw Object.assign(new Error('CD out of range'), { code: 'bad-zip' });
174
+ const out = [];
175
+ let p = cdOffset;
176
+ let total = 0;
177
+ for (let i = 0; i < count; i += 1) {
178
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50) throw Object.assign(new Error('bad CD'), { code: 'bad-zip' });
179
+ const method = buf.readUInt16LE(p + 10);
180
+ const compSize = buf.readUInt32LE(p + 20);
181
+ const uncompSize = buf.readUInt32LE(p + 24);
182
+ const nameLen = buf.readUInt16LE(p + 28);
183
+ const extraLen = buf.readUInt16LE(p + 30);
184
+ const commentLen = buf.readUInt16LE(p + 32);
185
+ const localOffset = buf.readUInt32LE(p + 42);
186
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
187
+ p += 46 + nameLen + extraLen + commentLen;
188
+ const norm = String(name || '').replace(/\\/g, '/');
189
+ if (!norm || norm.includes('\0') || norm.startsWith('/') || /^[a-zA-Z]:/.test(norm) || norm.split('/').some((s) => s === '..')) {
190
+ throw Object.assign(new Error('unsafe entry: ' + name), { code: 'unsafe-entry' });
191
+ }
192
+ if (norm.endsWith('/')) { out.push({ name: norm, data: Buffer.alloc(0), isDir: true }); continue; }
193
+ if (uncompSize > maxEntry) throw Object.assign(new Error('entry too large'), { code: 'too-large' });
194
+ total += uncompSize;
195
+ if (total > maxTotal) throw Object.assign(new Error('archive too large'), { code: 'too-large' });
196
+ if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== 0x04034b50) throw Object.assign(new Error('bad local header'), { code: 'bad-zip' });
197
+ const lNameLen = buf.readUInt16LE(localOffset + 26);
198
+ const lExtraLen = buf.readUInt16LE(localOffset + 28);
199
+ const dataStart = localOffset + 30 + lNameLen + lExtraLen;
200
+ const dataEnd = dataStart + compSize;
201
+ if (dataEnd > buf.length) throw Object.assign(new Error('truncated'), { code: 'bad-zip' });
202
+ const raw = buf.subarray(dataStart, dataEnd);
203
+ let data;
204
+ if (method === 0) data = Buffer.from(raw);
205
+ else if (method === 8) {
206
+ try { data = inflateRawSync(raw); } catch (e) { throw Object.assign(new Error('inflate failed'), { code: 'bad-zip' }); }
207
+ } else throw Object.assign(new Error('unsupported method ' + method), { code: 'bad-zip' });
208
+ out.push({ name: norm, data, isDir: false });
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /** 比对本机/站点 id 用的归一键:去掉可选的 `skin-` 前缀(站点 slug 为避撞名会加)。 */
214
+ function normSkinId(raw) {
215
+ const s = String(raw || '').trim().toLowerCase();
216
+ return s.startsWith('skin-') ? s.slice(5) : s;
217
+ }
218
+
219
+ /** 皮肤 id 只允许安全字符(要当目录名用)。 */
220
+ function safeSkinId(raw) {
221
+ const s = String(raw || '').trim();
222
+ return SKIN_ID_RE.test(s) ? s : '';
223
+ }
224
+
225
+ /** 校验"删/写这个目录"确实在皮肤根下面(realpath 前缀,挡符号链接逃逸)。 */
226
+ function resolveInside(root, id) {
227
+ const dir = join(root, id);
228
+ const realRoot = existsSync(root) ? realpathSync(root) : root;
229
+ const realDir = existsSync(dir) ? realpathSync(dir) : dir;
230
+ const withSep = realRoot.endsWith('\\') || realRoot.endsWith('/') ? realRoot : realRoot + (process.platform === 'win32' ? '\\' : '/');
231
+ return realDir === realRoot || realDir.startsWith(withSep) ? dir : '';
232
+ }
233
+
234
+ function sendJson(res, code, payload) {
235
+ res.statusCode = code;
236
+ res.setHeader('content-type', 'application/json; charset=utf-8');
237
+ res.end(JSON.stringify(payload));
238
+ }
239
+
240
+ function readJsonBody(req, limit = 64 * 1024) {
241
+ return new Promise((resolve) => {
242
+ let acc = '';
243
+ let done = false;
244
+ req.on('data', (d) => {
245
+ if (done) return;
246
+ acc += d;
247
+ if (acc.length > limit) { done = true; resolve(null); }
248
+ });
249
+ req.on('end', () => { if (!done) { done = true; try { resolve(JSON.parse(acc || '{}')); } catch { resolve(null); } } });
250
+ req.on('error', () => { if (!done) { done = true; resolve(null); } });
251
+ });
252
+ }
253
+
254
+ export function registerSkinsRoutes(webServer, c) {
255
+ const { disposers, base, log } = c;
256
+
257
+ /* ① 列表:站点可装的 + 本机已装的(同一份响应,面板一次拿全) */
258
+ disposers.push(webServer.register({
259
+ kind: 'exact',
260
+ path: `${base}/skins/list`,
261
+ handler: async (req, res) => {
262
+ const force = /[?&]refresh=1/.test(req.url || '');
263
+ const site = await fetchSiteSkins(force);
264
+ const installed = listInstalled();
265
+ const skinCenter = await probeSkinCenter(req.headers && req.headers.host, force);
266
+ // ⚠️ 站点 slug 与本机皮肤 id **不总是同一个字符串**:站点上 slug 是全站唯一的,
267
+ // 撞名时上传工具会加 `skin-` 前缀(实测 blue-throated-bee-eater 同时是桌宠 id)。
268
+ // 所以比对要归一:两边都去掉可选的 `skin-` 前缀再比,否则会显示成"未安装"。
269
+ const installedKeys = new Set();
270
+ for (const s of installed) {
271
+ installedKeys.add(normSkinId(s.id));
272
+ installedKeys.add(normSkinId(s.dirId));
273
+ }
274
+ sendJson(res, 200, {
275
+ ok: true,
276
+ skinCenter,
277
+ siteBase: siteBase(),
278
+ siteError: site.error,
279
+ installed,
280
+ site: site.items.map((it) => ({ ...it, installed: installedKeys.has(normSkinId(it.id)) })),
281
+ });
282
+ },
283
+ }));
284
+
285
+ /* ② 安装:下载 zip → 解到 $DSH_HOME/skins/<id> */
286
+ disposers.push(webServer.register({
287
+ kind: 'exact',
288
+ path: `${base}/skins/install`,
289
+ handler: async (req, res) => {
290
+ if ((req.method || 'GET').toUpperCase() !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
291
+ const body = await readJsonBody(req);
292
+ if (!body) { sendJson(res, 400, { ok: false, error: 'bad-body' }); return; }
293
+ const base0 = siteBase();
294
+ if (!base0) { sendJson(res, 400, { ok: false, error: 'site-off' }); return; }
295
+ let url;
296
+ try { url = new URL(String(body.url || ''), base0); } catch { sendJson(res, 400, { ok: false, error: 'bad-url' }); return; }
297
+ const allowHost = new URL(base0).host.toLowerCase();
298
+ if (url.host.toLowerCase() !== allowHost) { sendJson(res, 400, { ok: false, error: 'host-not-allowed' }); return; }
299
+
300
+ const root = skinsRoot();
301
+ // 幂等:站点 id 与本机目录同名时直接返回(用户"再点一次"应该是立刻生效,而不是重下一遍)
302
+ const hinted = safeSkinId(body.id);
303
+ if (hinted && existsSync(join(root, hinted, 'skin.json'))) {
304
+ sendJson(res, 200, { ok: true, exists: true, id: hinted, dir: join(root, hinted) });
305
+ return;
306
+ }
307
+
308
+ let buf;
309
+ try {
310
+ const r = await fetch(url.toString(), { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
311
+ if (!r.ok) { sendJson(res, 502, { ok: false, error: 'http-' + r.status }); return; }
312
+ const len = Number(r.headers.get('content-length') || 0);
313
+ if (len && len > MAX_ZIP_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
314
+ buf = Buffer.from(await r.arrayBuffer());
315
+ if (buf.length > MAX_ZIP_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
316
+ } catch (e) {
317
+ sendJson(res, 502, { ok: false, error: e && e.name === 'TimeoutError' ? 'timeout' : 'network' });
318
+ return;
319
+ }
320
+
321
+ let entries;
322
+ try { entries = unzipSkinZip(buf); } catch (e) {
323
+ sendJson(res, 422, { ok: false, error: (e && e.code) || 'bad-zip' });
324
+ return;
325
+ }
326
+ const manifestEntry = entries.find((e) => !e.isDir && /^skin\.json$/i.test(e.name));
327
+ if (!manifestEntry) { sendJson(res, 422, { ok: false, error: 'no-skin-json' }); return; }
328
+ let manifest;
329
+ try { manifest = JSON.parse(manifestEntry.data.toString('utf8')); } catch { sendJson(res, 422, { ok: false, error: 'bad-skin-json' }); return; }
330
+ // 目录名以 skin.json 的 id 为准(不信任 URL/调用方给的名字)。
331
+ const skinId = safeSkinId(manifest && manifest.id) || hinted;
332
+ if (!skinId) { sendJson(res, 422, { ok: false, error: 'bad-skin-id' }); return; }
333
+ const dir = resolveInside(root, skinId);
334
+ if (!dir) { sendJson(res, 400, { ok: false, error: 'unsafe-id' }); return; }
335
+ if (existsSync(join(dir, 'skin.json'))) {
336
+ sendJson(res, 200, { ok: true, exists: true, id: skinId, dir });
337
+ return;
338
+ }
339
+
340
+ try {
341
+ rmSync(dir, { recursive: true, force: true });
342
+ mkdirSync(dir, { recursive: true });
343
+ let written = 0;
344
+ let skipped = 0;
345
+ for (const e of entries) {
346
+ if (e.isDir) continue;
347
+ if (SKIP_EXEC_RE.test(e.name)) { skipped += 1; continue; }
348
+ const target = join(dir, e.name);
349
+ const parent = join(target, '..');
350
+ mkdirSync(parent, { recursive: true });
351
+ writeFileSync(target, e.data);
352
+ written += 1;
353
+ }
354
+ const check = JSON.parse(readFileSync(join(dir, 'skin.json'), 'utf8'));
355
+ if (!check || !check.id) throw new Error('manifest lost after write');
356
+ sendJson(res, 200, {
357
+ ok: true,
358
+ id: skinId,
359
+ name: String(check.name || check.nameEn || skinId),
360
+ dir,
361
+ written,
362
+ skipped,
363
+ bytes: buf.length,
364
+ installedDir: root,
365
+ });
366
+ } catch (e) {
367
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
368
+ try { log && log('skins install failed: ' + (e && e.message)); } catch { /* ignore */ }
369
+ sendJson(res, 500, { ok: false, error: 'extract-failed', detail: String(e && e.message).slice(0, 200) });
370
+ }
371
+ },
372
+ }));
373
+
374
+ /* ③ 卸载:只删皮肤根下的那个目录 */
375
+ disposers.push(webServer.register({
376
+ kind: 'exact',
377
+ path: `${base}/skins/remove`,
378
+ handler: async (req, res) => {
379
+ if ((req.method || 'GET').toUpperCase() !== 'POST') { sendJson(res, 405, { ok: false, error: 'method-not-allowed' }); return; }
380
+ const body = await readJsonBody(req);
381
+ const id = safeSkinId(body && body.id);
382
+ if (!id) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
383
+ const root = skinsRoot();
384
+ const dir = resolveInside(root, id);
385
+ if (!dir) { sendJson(res, 400, { ok: false, error: 'unsafe-id' }); return; }
386
+ if (!existsSync(dir)) { sendJson(res, 404, { ok: false, error: 'not-installed' }); return; }
387
+ try { rmSync(dir, { recursive: true, force: true }); } catch (e) {
388
+ sendJson(res, 500, { ok: false, error: 'remove-failed', detail: String(e && e.message).slice(0, 160) });
389
+ return;
390
+ }
391
+ sendJson(res, 200, { ok: true, id });
392
+ },
393
+ }));
394
+
395
+ // 切换皮肤与"当前皮肤"都由**浏览器**直接读写皮肤中心的 /api/skin-center/v2/active:
396
+ // DSH 对该路径的读写做会话鉴权(cookie),宿主 fetch 没有会话 ⇒ 代理只会得到 401(实测)。
397
+ }