dsh-folia 4.0.1 → 4.0.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.
package/lib/boot/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * 内联 JSON,启动时连一次请求都不发。
9
9
  * 3. **任何异常都必须放行**。注入的脚本顶层 try/catch,超时自毁,绝不把用户挡在门外。
10
10
  */
11
+ import { createHash } from 'node:crypto';
11
12
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
13
  import { homedir } from 'node:os';
13
14
  import { extname, join } from 'node:path';
@@ -67,12 +68,33 @@ export function writeBoot(patch) {
67
68
  return readBoot();
68
69
  }
69
70
 
70
- /** 允许的 id:目录名安全字符,防止拿它拼路径时被穿越。 */
71
- const ID_RE = /^[a-z0-9][a-z0-9._-]*$/i;
71
+ /**
72
+ * id → **磁盘文件名**:ASCII 化 + 短哈希。
73
+ *
74
+ * 为什么不再拿 id 直接当文件名:站点上的 slug 允许中文(实测有一条叫「痘印」),
75
+ * 而严格的正则把它判成非法 ⇒ 用户点下载永远是 `bad-id`。id 可以任意宽容,
76
+ * **文件名必须安全** —— 这两件事不该绑在一起。
77
+ *
78
+ * 规则:非 `[a-zA-Z0-9._-]` 一律折成 `-`,再拼 8 位 sha1 前缀,保证
79
+ * · 永远不会出现路径分隔符 / `..` / 控制字符;
80
+ * · 不同 id 即使折成同一个 ASCII 名也不会互相覆盖;
81
+ * · 原始 id 存在同名 `.json` 里,列表读回来还是原来那个。
82
+ */
83
+ function clipStem(id) {
84
+ const raw = String(id || '');
85
+ const ascii = raw.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 40) || 'clip';
86
+ const hash = createHash('sha1').update(raw).digest('hex').slice(0, 8);
87
+ return ascii + '-' + hash;
88
+ }
89
+ /** 片段文件。id 可以是任意字符串(含中文),只有磁盘名会被 ASCII 化。 */
72
90
  export function clipFile(id) {
73
- if (!ID_RE.test(String(id || ''))) return null;
74
- const p = join(bootDir(), String(id) + '.mp4');
75
- return p;
91
+ if (!String(id || '')) return null;
92
+ return join(bootDir(), clipStem(id) + '.mp4');
93
+ }
94
+ /** 片段元数据文件(存原始 id / 标题 / 时长)。 */
95
+ export function clipMetaFile(id) {
96
+ if (!String(id || '')) return null;
97
+ return join(bootDir(), clipStem(id) + '.json');
76
98
  }
77
99
  export function clipExists(id) {
78
100
  const p = clipFile(id);
@@ -85,14 +107,21 @@ export function listLocalClips() {
85
107
  const out = [];
86
108
  let names = [];
87
109
  try { names = readdirSync(dir); } catch { return out; }
110
+ // 以 `.json` 为主:里面有**原始 id**(可能是中文),文件名只是它的 ASCII 代理。
111
+ const stems = new Set();
88
112
  for (const n of names) {
89
- if (extname(n).toLowerCase() !== '.mp4') continue;
90
- const id = n.slice(0, -4);
91
- const abs = join(dir, n);
113
+ if (extname(n).toLowerCase() === '.json') stems.add(n.slice(0, -5));
114
+ if (extname(n).toLowerCase() === '.mp4') stems.add(n.slice(0, -4));
115
+ }
116
+ for (const stem of stems) {
117
+ const abs = join(dir, stem + '.mp4');
118
+ if (!existsSync(abs)) continue;
92
119
  let bytes = 0;
93
120
  try { bytes = statSync(abs).size; } catch { continue; }
121
+ if (bytes <= 0) continue;
94
122
  let meta = {};
95
- try { meta = JSON.parse(readFileSync(join(dir, id + '.json'), 'utf8')) || {}; } catch { /* 没 meta 也能用 */ }
123
+ try { meta = JSON.parse(readFileSync(join(dir, stem + '.json'), 'utf8')) || {}; } catch { /* 没 meta 也能用 */ }
124
+ const id = String(meta.id || stem);
96
125
  out.push({ id, bytes, title: meta.title || id, durationSec: meta.durationSec || null });
97
126
  }
98
127
  return out;
@@ -102,7 +131,7 @@ export function removeClip(id) {
102
131
  const p = clipFile(id);
103
132
  if (!p) return false;
104
133
  try { if (existsSync(p)) rmSync(p); } catch { return false; }
105
- try { const m = join(bootDir(), String(id) + '.json'); if (existsSync(m)) rmSync(m); } catch { /* ignore */ }
134
+ try { const m = clipMetaFile(id); if (m && existsSync(m)) rmSync(m); } catch { /* ignore */ }
106
135
  return true;
107
136
  }
108
137
 
@@ -14,7 +14,7 @@ import { join } from 'node:path';
14
14
  import { sendJson } from '../json-response.js';
15
15
  import { bodyReader } from '../http-body.js';
16
16
  import { resolveModules } from './modules.js';
17
- import { bootDir, clipExists, clipFile, listLocalClips, readBoot, removeClip, writeBoot } from '../boot/index.js';
17
+ import { clipExists, clipFile, clipMetaFile, listLocalClips, readBoot, removeClip, writeBoot } from '../boot/index.js';
18
18
 
19
19
  /**
20
20
  * 站点基址。开机动画的候选**从站点分类取**(`?category=boot-animation`),
@@ -118,7 +118,12 @@ async function readIdBody(req) {
118
118
  const text = await readBodyText(req, 64 * 1024);
119
119
  const doc = JSON.parse(text || '{}');
120
120
  const id = String((doc && doc.id) || '').trim();
121
- return /^[a-z0-9][a-z0-9._-]*$/i.test(id) ? id : null;
121
+ // id 来自站点 slug,**可以是中文**(实测有「痘印」)。这里只挡真正的危险输入:
122
+ // 路径分隔符、控制字符、`..`、超长 —— 其余一律放行,文件名由宿主安全化。
123
+ if (!id || id.length > 200) return null;
124
+ if (/[\u0000-\u001f\u007f/\\]/.test(id)) return null;
125
+ if (id === '.' || id === '..' || id.includes('..')) return null;
126
+ return id;
122
127
  } catch {
123
128
  return null;
124
129
  }
@@ -273,7 +278,8 @@ export function registerBootRoutes(webServer, c) {
273
278
  if (buf.length > CLIP_MAX_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
274
279
  writeFileSync(dest, buf);
275
280
  try {
276
- writeFileSync(join(bootDir(), id + '.json'), JSON.stringify({
281
+ writeFileSync(clipMetaFile(id), JSON.stringify({
282
+ id,
277
283
  title: item.title, durationSec: item.durationSec, bytes: buf.length, sha256: item.sha256 || null,
278
284
  }, null, 2) + '\n');
279
285
  } catch { /* meta 只是锦上添花 */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.0.1",
3
+ "version": "4.0.2",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {