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 +39 -10
- package/lib/routes/boot.js +43 -8
- package/package.json +1 -1
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
|
-
/**
|
|
71
|
-
|
|
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 (!
|
|
74
|
-
|
|
75
|
-
|
|
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()
|
|
90
|
-
|
|
91
|
-
|
|
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,
|
|
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 =
|
|
134
|
+
try { const m = clipMetaFile(id); if (m && existsSync(m)) rmSync(m); } catch { /* ignore */ }
|
|
106
135
|
return true;
|
|
107
136
|
}
|
|
108
137
|
|
package/lib/routes/boot.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
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(
|
|
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
|
-
|
|
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) {
|