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 +39 -10
- package/lib/routes/boot.js +9 -3
- 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
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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 只是锦上添花 */ }
|