dsh-folia 4.0.1 → 4.0.3

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
 
@@ -9,12 +9,12 @@
9
9
  * 播放走 `/folia/boot/clip`,**支持 Range**:注入的 `<video>` 与浏览器都靠它边下边播。
10
10
  * 这也是为什么片段必须先落盘 —— 启动路径上不能有网络。
11
11
  */
12
- import { createReadStream, existsSync, statSync, writeFileSync } from 'node:fs';
12
+ import { createReadStream, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
13
13
  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`),
@@ -112,13 +112,40 @@ async function fetchClipBytes(url, chunks = 4) {
112
112
  }
113
113
  }
114
114
 
115
+ /**
116
+ * 判定一段字节是不是**真的视频**,并给出它的 MIME。
117
+ *
118
+ * 为什么必须验文件头而不是 content-type:站点对未知路径会回退 index.html 并以 200 返回,
119
+ * 这种"伪视频"存成 .mp4 的结果是**每次开机黑屏**。
120
+ *
121
+ * 认两种容器(覆盖站点上实际会有的上传):
122
+ * MP4/MOV ISO BMFF —— 偏移 4..8 是 `ftyp`
123
+ * WebM EBML —— 开头 4 字节是 1A 45 DF A3
124
+ */
125
+ export function sniffVideo(buf) {
126
+ if (!buf || buf.length < 1024) return null;
127
+ if (buf.length >= 12 && buf.toString('latin1', 4, 8) === 'ftyp') {
128
+ const brand = buf.toString('latin1', 8, 12);
129
+ return { mime: 'video/mp4', ext: '.mp4', container: 'mp4', brand };
130
+ }
131
+ if (buf.length >= 4 && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) {
132
+ return { mime: 'video/webm', ext: '.webm', container: 'webm', brand: null };
133
+ }
134
+ return null;
135
+ }
136
+
115
137
  /** 读请求体里的 `{ id }`。 */
116
138
  async function readIdBody(req) {
117
139
  try {
118
140
  const text = await readBodyText(req, 64 * 1024);
119
141
  const doc = JSON.parse(text || '{}');
120
142
  const id = String((doc && doc.id) || '').trim();
121
- return /^[a-z0-9][a-z0-9._-]*$/i.test(id) ? id : null;
143
+ // id 来自站点 slug,**可以是中文**(实测有「痘印」)。这里只挡真正的危险输入:
144
+ // 路径分隔符、控制字符、`..`、超长 —— 其余一律放行,文件名由宿主安全化。
145
+ if (!id || id.length > 200) return null;
146
+ if (/[\u0000-\u001f\u007f/\\]/.test(id)) return null;
147
+ if (id === '.' || id === '..' || id.includes('..')) return null;
148
+ return id;
122
149
  } catch {
123
150
  return null;
124
151
  }
@@ -264,16 +291,19 @@ export function registerBootRoutes(webServer, c) {
264
291
  const buf = await fetchClipBytes(item.url, 4);
265
292
  const ct = 'video/mp4'; // 并行分段之后没有单一响应头可读,类型下面用 ftyp 验
266
293
  // 站点把未知路径回落到 HTML —— 这类"伪 200"绝不能当成视频存下来(否则每次开机黑屏)。
267
- // 只认文件头:`ftyp` 在偏移 4。比 content-type 更可靠(站点回退页是 HTML,
268
- // 而并行分段之后我们也拿不到单一响应头)。
269
- if (buf.length < 1024 || buf.toString('latin1', 4, 8) !== 'ftyp') {
294
+ // 只认文件头(mp4 的 ftyp / webm 的 EBML)。比 content-type 可靠,
295
+ // 而且并行分段之后本来也拿不到单一响应头。
296
+ const sniff = sniffVideo(buf);
297
+ if (!sniff) {
270
298
  sendJson(res, 422, { ok: false, error: 'not-a-video', detail: ct || 'no content-type' });
271
299
  return;
272
300
  }
273
301
  if (buf.length > CLIP_MAX_BYTES) { sendJson(res, 413, { ok: false, error: 'too-large' }); return; }
274
302
  writeFileSync(dest, buf);
275
303
  try {
276
- writeFileSync(join(bootDir(), id + '.json'), JSON.stringify({
304
+ writeFileSync(clipMetaFile(id), JSON.stringify({
305
+ id,
306
+ mime: sniff.mime,
277
307
  title: item.title, durationSec: item.durationSec, bytes: buf.length, sha256: item.sha256 || null,
278
308
  }, null, 2) + '\n');
279
309
  } catch { /* meta 只是锦上添花 */ }
@@ -363,7 +393,12 @@ export function registerBootRoutes(webServer, c) {
363
393
  if (!file || !existsSync(file)) { res.statusCode = 404; res.end('no clip'); return; }
364
394
  const size = statSync(file).size;
365
395
  const range = String((req.headers && req.headers.range) || '');
366
- const type = 'video/mp4';
396
+ // MIME 取自下载时记下的 meta:webm 用 video/webm,否则 video/mp4。
397
+ let type = 'video/mp4';
398
+ try {
399
+ const meta = JSON.parse(readFileSync(clipMetaFile(boot.clipId), 'utf8'));
400
+ if (meta && typeof meta.mime === 'string' && /^video\//.test(meta.mime)) type = meta.mime;
401
+ } catch { /* 没 meta 就按 mp4 */ }
367
402
  // Range:`bytes=start-end` —— Chromium 播放本地大文件时会先发一小段探测请求。
368
403
  const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
369
404
  if (m) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {