dsh-plugin-pack 0.0.0-stage → 1.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/inbox.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 拖放进来的文件/目录落盘。
3
+ *
4
+ * 浏览器的安全模型拿不到绝对路径,拖进来只有文件名和内容。
5
+ * 所以界面把内容发上来,宿主写到临时目录,再走已有的导入流程:
6
+ *
7
+ * zip → $DSH_HOME/plugin-pack/inbox/<时间戳>/<原名>.zip → importBundle
8
+ * 目录 → $DSH_HOME/plugin-pack/inbox/<时间戳>/<目录名>/... → importLocalPlugin
9
+ *
10
+ * 用 multipart/form-data 而不是 base64 JSON:几百 MB 的包不能膨胀 33%。
11
+ *
12
+ * @module dsh-plugin-pack/inbox
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ import { stamp } from './home.js';
18
+ import { ensureDir, removeTree } from './util.js';
19
+
20
+ /** 单次落盘上限(默认 1 GiB,够大包也够防呆)。 */
21
+ const MAX_BYTES = 1024 * 1024 * 1024;
22
+
23
+ /**
24
+ * 从 multipart/form-data 里解析出文件条目。
25
+ *
26
+ * 只实现够用的一部分:单层 boundary、每个 part 有 name/filename、内容不二次解码。
27
+ * 不引第三方库。
28
+ *
29
+ * @param {Buffer} body 整个请求体
30
+ * @param {string} boundary boundary 字符串(不含前置 --)
31
+ * @returns {Array<{ name: string, filename: string|null, data: Buffer }>}
32
+ */
33
+ export function parseMultipart(body, boundary) {
34
+ const delimiter = Buffer.from(`--${boundary}`);
35
+ const parts = [];
36
+ let cursor = 0;
37
+
38
+ while (cursor < body.length) {
39
+ const start = body.indexOf(delimiter, cursor);
40
+ if (start < 0) break;
41
+ const afterHead = start + delimiter.length;
42
+ // 结束标记 --boundary--
43
+ if (body[afterHead] === 0x2d && body[afterHead + 1] === 0x2d) break;
44
+
45
+ // 跳过分隔符后的 CRLF
46
+ let headStart = afterHead;
47
+ while (headStart < body.length && (body[headStart] === 0x0d || body[headStart] === 0x0a)) headStart += 1;
48
+
49
+ const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headStart);
50
+ if (headerEnd < 0) break;
51
+
52
+ const headerText = body.toString('utf8', headStart, headerEnd);
53
+ const dataStart = headerEnd + 4;
54
+
55
+ const next = body.indexOf(delimiter, dataStart);
56
+ if (next < 0) break;
57
+
58
+ // 数据到下一个分隔符前,去掉尾部 CRLF
59
+ let dataEnd = next;
60
+ if (dataEnd >= 2 && body[dataEnd - 2] === 0x0d && body[dataEnd - 1] === 0x0a) dataEnd -= 2;
61
+
62
+ const nameMatch = /name="([^"]*)"/i.exec(headerText);
63
+ const fileMatch = /filename="([^"]*)"/i.exec(headerText);
64
+ parts.push({
65
+ name: nameMatch ? nameMatch[1] : '',
66
+ filename: fileMatch ? fileMatch[1] : null,
67
+ data: body.subarray(dataStart, dataEnd),
68
+ });
69
+
70
+ cursor = next;
71
+ }
72
+
73
+ return parts;
74
+ }
75
+
76
+ /** 从请求里取 boundary。 */
77
+ export function boundaryOf(contentType) {
78
+ const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(String(contentType ?? ''));
79
+ if (!match) return null;
80
+ return (match[1] ?? match[2] ?? '').trim();
81
+ }
82
+
83
+ /**
84
+ * 把一个上传的 zip 落到 inbox,返回绝对路径。
85
+ *
86
+ * @param {object} options
87
+ * @param {string} options.home $DSH_HOME
88
+ * @param {string} options.filename 原始文件名
89
+ * @param {Buffer} options.data 内容
90
+ * @returns {{ file: string, bytes: number }}
91
+ */
92
+ export function saveIncomingZip({ home, filename, data }) {
93
+ if (!data?.length) {
94
+ const error = new Error('上传内容为空');
95
+ error.code = 'BAD_UPLOAD';
96
+ throw error;
97
+ }
98
+ if (data.length > MAX_BYTES) {
99
+ const error = new Error(`文件太大: ${(data.length / 1048576).toFixed(0)} MiB`);
100
+ error.code = 'TOO_LARGE';
101
+ throw error;
102
+ }
103
+ const base = safeName(String(filename ?? '').trim() || `bundle-${stamp()}.zip`);
104
+ const dir = path.join(home, 'plugin-pack', 'inbox', stamp());
105
+ ensureDir(dir);
106
+ // 保留原始扩展名:.zip = 整合包,.tgz/.tar.gz = 单个插件的 npm tarball
107
+ const lower = base.toLowerCase();
108
+ const hasKnownExt = lower.endsWith('.zip') || lower.endsWith('.tgz') || lower.endsWith('.tar.gz');
109
+ const file = path.join(dir, hasKnownExt ? base : `${base}.zip`);
110
+ fs.writeFileSync(file, data);
111
+ return { file, bytes: data.length };
112
+ }
113
+
114
+ /**
115
+ * 把一个上传的目录(多文件 + 相对路径)落到 inbox,返回目录绝对路径。
116
+ *
117
+ * @param {object} options
118
+ * @param {string} options.home
119
+ * @param {string} options.rootName 拖进来的目录名
120
+ * @param {Array<{ rel: string, data: Buffer }>} options.files
121
+ * @returns {{ dir: string, files: number, bytes: number }}
122
+ */
123
+ export function saveIncomingDir({ home, rootName, files }) {
124
+ if (!Array.isArray(files) || !files.length) {
125
+ const error = new Error('目录里没有文件');
126
+ error.code = 'BAD_UPLOAD';
127
+ throw error;
128
+ }
129
+ const root = safeName(String(rootName ?? '').trim() || `plugin-${stamp()}`);
130
+ const dir = path.join(home, 'plugin-pack', 'inbox', stamp(), root);
131
+ ensureDir(dir);
132
+
133
+ let bytes = 0;
134
+ let count = 0;
135
+ const base = path.resolve(dir);
136
+ for (const item of files) {
137
+ let rel = String(item.rel ?? '').replace(/\\/g, '/');
138
+ if (!rel || rel.includes('\0')) continue;
139
+ // 拖目录时 rel 通常已带根目录名(my-plugin/lib/x.js);
140
+ // 如果第一段就是 root,去掉它,避免 my-plugin/my-plugin/lib/x.js
141
+ const segmentsAll = rel.split('/').filter(Boolean);
142
+ if (segmentsAll.length && segmentsAll[0] === root) segmentsAll.shift();
143
+ if (!segmentsAll.length) continue;
144
+ if (segmentsAll.some((seg) => seg === '..' || seg === '.')) continue; // 防穿越
145
+ const target = path.join(base, ...segmentsAll);
146
+ if (!path.resolve(target).startsWith(base)) continue;
147
+ ensureDir(path.dirname(target));
148
+ fs.writeFileSync(target, item.data);
149
+ bytes += item.data.length;
150
+ count += 1;
151
+ if (bytes > MAX_BYTES) {
152
+ removeTree(dir);
153
+ const error = new Error('目录太大');
154
+ error.code = 'TOO_LARGE';
155
+ throw error;
156
+ }
157
+ }
158
+ return { dir, files: count, bytes };
159
+ }
160
+
161
+ /** 文件/目录名去危险字符。 */
162
+ function safeName(name) {
163
+ return (
164
+ String(name)
165
+ .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
166
+ .replace(/\.\./g, '_')
167
+ .slice(0, 120) || `item-${stamp()}`
168
+ );
169
+ }
170
+
171
+ /** 清掉超过 N 天的 inbox(避免临时目录越攒越多)。 */
172
+ export function pruneInbox(home, days = 7) {
173
+ const root = path.join(home, 'plugin-pack', 'inbox');
174
+ let removed = 0;
175
+ try {
176
+ const now = Date.now();
177
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
178
+ if (!entry.isDirectory()) continue;
179
+ const full = path.join(root, entry.name);
180
+ try {
181
+ const stat = fs.statSync(full);
182
+ if (now - stat.mtimeMs > days * 86400000) {
183
+ removeTree(full);
184
+ removed += 1;
185
+ }
186
+ } catch {
187
+ /* 忽略 */
188
+ }
189
+ }
190
+ } catch {
191
+ /* inbox 还不存在 */
192
+ }
193
+ return removed;
194
+ }