whistle.mockbubu 2.0.0 → 2.1.1
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/README.md +38 -0
- package/index.js +3 -3
- package/lib/config/const.js +138 -0
- package/lib/config/rule-collector.js +81 -0
- package/lib/constants.js +62 -0
- package/lib/core/memory-buffer/index.js +207 -0
- package/lib/core/memory-buffer/shared-instance.js +15 -0
- package/lib/core/plugin-mode-manager.js +74 -0
- package/lib/core/resRulesServer.js +14 -0
- package/lib/core/rulesServer.js +31 -0
- package/lib/core/server-entry/capture-handler.js +191 -0
- package/lib/core/server-entry/request-interceptor.js +82 -0
- package/lib/core/server-entry/response-handler.js +147 -0
- package/lib/core/server-entry/server.js +230 -0
- package/lib/storage/group-manager.js +627 -0
- package/lib/storage/storage-adapter.js +712 -0
- package/lib/storage/storage-v3.js +1418 -0
- package/lib/uiServer/index.js +61 -24
- package/lib/uiServer/router/export/import-export-router.js +459 -0
- package/lib/uiServer/router/files/api-list-router.js +150 -0
- package/lib/uiServer/router/files/batch-operations-router.js +185 -0
- package/lib/uiServer/router/files/file-config-router.js +118 -0
- package/lib/uiServer/router/files/file-crud-router.js +212 -0
- package/lib/uiServer/router/files/file-save-router.js +146 -0
- package/lib/uiServer/router/files/version-router.js +260 -0
- package/lib/uiServer/router/global/plugin-control.js +135 -0
- package/lib/uiServer/router/global/system-info-router.js +386 -0
- package/lib/uiServer/router/{group-router.js → groups/group-router.js} +21 -20
- package/lib/uiServer/router/index.js +38 -1521
- package/lib/uiServer/utils/router-helpers.js +100 -0
- package/lib/uiServer/utils/util.js +172 -0
- package/lib/uiServer/{validator.js → utils/validator.js} +11 -6
- package/lib/utils/archive-utils.js +788 -0
- package/lib/utils/error-handler.js +173 -0
- package/lib/utils/logger.js +79 -0
- package/lib/utils/path-utils.js +147 -0
- package/lib/utils/performance.js +265 -0
- package/lib/utils/utils.js +541 -0
- package/package.json +2 -2
- package/public/js/app.js +3732 -1929
- package/public/js/app.js.map +1 -1
- package/public/js/chunk-vendors.js +5098 -3965
- package/public/js/chunk-vendors.js.map +1 -1
- package/rules.txt +1 -1
- package/CHANGELOG_GROUP_FEATURE.md +0 -468
- package/CHANGELOG_P0_FIXES.md +0 -412
- package/CHANGELOG_P1_OPTIMIZATIONS.md +0 -292
- package/CLAUDE.md +0 -436
- package/GROUP_FEATURE_DESIGN.md +0 -520
- package/lib/const.js +0 -47
- package/lib/group-manager.js +0 -491
- package/lib/resRulesServer.js +0 -9
- package/lib/server.js +0 -249
- package/lib/uiServer/router/version-router.js +0 -205
- package/lib/uiServer/util.js +0 -153
- package/lib/utils.js +0 -409
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件名: archive-utils.js
|
|
3
|
+
* 功能: 压缩包工具函数(tar.gz 格式)
|
|
4
|
+
* 依赖: Node.js 内置 API(无需额外依赖)
|
|
5
|
+
*
|
|
6
|
+
* 职责:
|
|
7
|
+
* - 创建 tar.gz 压缩包(手动实现 tar 格式)
|
|
8
|
+
* - 解压 tar.gz 压缩包
|
|
9
|
+
* - 目录递归复制和遍历
|
|
10
|
+
* - 文件名安全处理(防止路径遍历攻击)
|
|
11
|
+
* - Multipart 文件上传解析(原生实现)
|
|
12
|
+
*
|
|
13
|
+
* tar 格式说明:
|
|
14
|
+
* - tar 文件由一系列 512 字节的块组成
|
|
15
|
+
* - 每个文件包含:header (512 bytes) + content (对齐到 512 bytes)
|
|
16
|
+
* - header 采用 POSIX ustar 格式
|
|
17
|
+
* - 文件结束标记:两个连续的 512 字节零块
|
|
18
|
+
*
|
|
19
|
+
* 使用场景:
|
|
20
|
+
* - 导出组数据为 tar.gz 压缩包
|
|
21
|
+
* - 导入 tar.gz 压缩包并恢复组数据
|
|
22
|
+
*
|
|
23
|
+
* 使用示例:
|
|
24
|
+
* ```javascript
|
|
25
|
+
* const { createTarGz, extractTarGz } = require('./archive-utils')
|
|
26
|
+
*
|
|
27
|
+
* // 创建压缩包
|
|
28
|
+
* await createTarGz('/path/to/source', '/path/to/output.tar.gz')
|
|
29
|
+
*
|
|
30
|
+
* // 解压压缩包
|
|
31
|
+
* await extractTarGz('/path/to/archive.tar.gz', '/path/to/dest')
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('fs').promises
|
|
36
|
+
const path = require('path')
|
|
37
|
+
const zlib = require('zlib')
|
|
38
|
+
const { createReadStream, createWriteStream } = require('fs')
|
|
39
|
+
const { pipeline } = require('stream/promises')
|
|
40
|
+
const os = require('os')
|
|
41
|
+
const { createLogger } = require('./logger')
|
|
42
|
+
|
|
43
|
+
// 创建 logger 实例
|
|
44
|
+
const logger = createLogger('archive-utils')
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 递归复制目录(包括所有子目录和文件)
|
|
48
|
+
* 使用深度优先遍历,保持目录结构
|
|
49
|
+
*
|
|
50
|
+
* 工作流程:
|
|
51
|
+
* 1. 创建目标目录(如果不存在)
|
|
52
|
+
* 2. 读取源目录下的所有条目
|
|
53
|
+
* 3. 对于每个条目:
|
|
54
|
+
* - 如果是目录:递归复制
|
|
55
|
+
* - 如果是文件:直接复制
|
|
56
|
+
*
|
|
57
|
+
* @param {string} src - 源目录路径
|
|
58
|
+
* @param {string} dest - 目标目录路径
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* await copyDirectory('/path/to/source', '/path/to/dest')
|
|
63
|
+
* // 复制 source 目录下的所有内容到 dest 目录
|
|
64
|
+
*/
|
|
65
|
+
async function copyDirectory(src, dest) {
|
|
66
|
+
// 1. 创建目标目录(recursive: true 会自动创建父目录)
|
|
67
|
+
await fs.mkdir(dest, { recursive: true })
|
|
68
|
+
|
|
69
|
+
// 2. 读取源目录下的所有条目(withFileTypes 返回 Dirent 对象)
|
|
70
|
+
const entries = await fs.readdir(src, { withFileTypes: true })
|
|
71
|
+
|
|
72
|
+
// 3. 遍历所有条目
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const srcPath = path.join(src, entry.name)
|
|
75
|
+
const destPath = path.join(dest, entry.name)
|
|
76
|
+
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
// 递归复制子目录
|
|
79
|
+
await copyDirectory(srcPath, destPath)
|
|
80
|
+
} else {
|
|
81
|
+
// 复制文件
|
|
82
|
+
await fs.copyFile(srcPath, destPath)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 递归读取目录,返回所有文件路径(用于打包)
|
|
89
|
+
* 使用深度优先遍历,收集所有文件的绝对路径和相对路径
|
|
90
|
+
*
|
|
91
|
+
* 工作流程:
|
|
92
|
+
* 1. 读取目录下的所有条目
|
|
93
|
+
* 2. 对于每个条目:
|
|
94
|
+
* - 如果是目录:递归读取
|
|
95
|
+
* - 如果是文件:添加到结果列表
|
|
96
|
+
* 3. 返回包含所有文件信息的数组
|
|
97
|
+
*
|
|
98
|
+
* @param {string} dir - 要读取的目录路径
|
|
99
|
+
* @param {string} baseDir - 基准目录(用于计算相对路径,默认为 dir)
|
|
100
|
+
* @returns {Promise<Array>} 文件列表,每个文件包含 path(绝对路径)和 name(相对路径)
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* const files = await getAllFiles('/path/to/dir')
|
|
104
|
+
* files.forEach(file => {
|
|
105
|
+
* console.log('绝对路径:', file.path)
|
|
106
|
+
* console.log('相对路径:', file.name)
|
|
107
|
+
* })
|
|
108
|
+
* // 输出示例:
|
|
109
|
+
* // 绝对路径: /path/to/dir/file1.json
|
|
110
|
+
* // 相对路径: file1.json
|
|
111
|
+
* // 绝对路径: /path/to/dir/subdir/file2.json
|
|
112
|
+
* // 相对路径: subdir/file2.json
|
|
113
|
+
*/
|
|
114
|
+
async function getAllFiles(dir, baseDir = dir) {
|
|
115
|
+
const files = []
|
|
116
|
+
|
|
117
|
+
// 读取目录下的所有条目
|
|
118
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
119
|
+
|
|
120
|
+
// 遍历所有条目
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const fullPath = path.join(dir, entry.name)
|
|
123
|
+
|
|
124
|
+
if (entry.isDirectory()) {
|
|
125
|
+
// 递归读取子目录(展开数组)
|
|
126
|
+
files.push(...await getAllFiles(fullPath, baseDir))
|
|
127
|
+
} else {
|
|
128
|
+
// 添加文件信息
|
|
129
|
+
files.push({
|
|
130
|
+
path: fullPath, // 绝对路径
|
|
131
|
+
name: path.relative(baseDir, fullPath), // 相对于基准目录的路径
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return files
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 创建 tar.gz 压缩包(两步法)
|
|
141
|
+
* 使用 Node.js 内置 zlib + tar 格式手动实现
|
|
142
|
+
*
|
|
143
|
+
* 工作流程:
|
|
144
|
+
* 1. 递归遍历源目录,获取所有文件列表
|
|
145
|
+
* 2. 创建 tar 文件(按 POSIX ustar 格式)
|
|
146
|
+
* 3. 使用 gzip 压缩 tar 文件
|
|
147
|
+
* 4. 删除临时 tar 文件
|
|
148
|
+
*
|
|
149
|
+
* 文件格式:
|
|
150
|
+
* - 输出文件: .tar.gz(gzip 压缩的 tar 归档)
|
|
151
|
+
* - 中间文件: .tar(临时文件,最后会被删除)
|
|
152
|
+
*
|
|
153
|
+
* @param {string} sourceDir - 源目录路径
|
|
154
|
+
* @param {string} outputFile - 输出文件路径(必须以 .tar.gz 结尾)
|
|
155
|
+
* @returns {Promise<void>}
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* await createTarGz('/path/to/source', '/path/to/output.tar.gz')
|
|
159
|
+
* // 将 source 目录打包为 output.tar.gz
|
|
160
|
+
*/
|
|
161
|
+
async function createTarGz(sourceDir, outputFile) {
|
|
162
|
+
// 1. 获取源目录下的所有文件
|
|
163
|
+
const files = await getAllFiles(sourceDir)
|
|
164
|
+
logger.log(`准备打包 ${files.length} 个文件`)
|
|
165
|
+
|
|
166
|
+
// 2. 创建 tar 文件(临时文件)
|
|
167
|
+
const tarPath = outputFile.replace(/\.gz$/, '')
|
|
168
|
+
await createTar(sourceDir, tarPath, files)
|
|
169
|
+
|
|
170
|
+
// 3. 使用 gzip 压缩 tar 文件
|
|
171
|
+
await new Promise((resolve, reject) => {
|
|
172
|
+
const input = createReadStream(tarPath)
|
|
173
|
+
const output = createWriteStream(outputFile)
|
|
174
|
+
const gzip = zlib.createGzip()
|
|
175
|
+
|
|
176
|
+
pipeline(input, gzip, output)
|
|
177
|
+
.then(() => {
|
|
178
|
+
// 4. 删除临时 tar 文件
|
|
179
|
+
fs.unlink(tarPath).catch(() => {})
|
|
180
|
+
logger.log(`压缩完成: ${outputFile}`)
|
|
181
|
+
resolve()
|
|
182
|
+
})
|
|
183
|
+
.catch(reject)
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 创建 tar 文件(手动实现 POSIX ustar 格式)
|
|
189
|
+
*
|
|
190
|
+
* tar 文件结构:
|
|
191
|
+
* ```
|
|
192
|
+
* [File 1 Header (512 bytes)]
|
|
193
|
+
* [File 1 Content (对齐到 512 bytes)]
|
|
194
|
+
* [File 2 Header (512 bytes)]
|
|
195
|
+
* [File 2 Content (对齐到 512 bytes)]
|
|
196
|
+
* ...
|
|
197
|
+
* [End Marker (1024 bytes 的零)]
|
|
198
|
+
* ```
|
|
199
|
+
*
|
|
200
|
+
* 工作流程:
|
|
201
|
+
* 1. 对每个文件:
|
|
202
|
+
* a. 读取文件状态(大小)和内容
|
|
203
|
+
* b. 生成 tar header(512 字节)
|
|
204
|
+
* c. 写入 header
|
|
205
|
+
* d. 写入文件内容
|
|
206
|
+
* e. 填充零字节,对齐到 512 字节边界
|
|
207
|
+
* 2. 写入结束标记(两个连续的 512 字节零块)
|
|
208
|
+
*
|
|
209
|
+
* 对齐规则:
|
|
210
|
+
* - tar 格式要求所有块都是 512 字节的倍数
|
|
211
|
+
* - 文件内容后需要填充零字节
|
|
212
|
+
* - 例如:文件大小 100 字节,需要填充 412 字节(512 - 100 = 412)
|
|
213
|
+
*
|
|
214
|
+
* @param {string} sourceDir - 源目录路径(未使用,保留用于扩展)
|
|
215
|
+
* @param {string} outputFile - 输出 tar 文件路径
|
|
216
|
+
* @param {Array} files - 文件列表(来自 getAllFiles)
|
|
217
|
+
* @returns {Promise<void>}
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* const files = await getAllFiles('/path/to/source')
|
|
221
|
+
* await createTar('/path/to/source', '/path/to/output.tar', files)
|
|
222
|
+
*/
|
|
223
|
+
async function createTar(sourceDir, outputFile, files) {
|
|
224
|
+
const output = createWriteStream(outputFile)
|
|
225
|
+
|
|
226
|
+
// 遍历所有文件
|
|
227
|
+
for (const file of files) {
|
|
228
|
+
// 1. 读取文件状态和内容
|
|
229
|
+
const stat = await fs.stat(file.path)
|
|
230
|
+
const content = await fs.readFile(file.path)
|
|
231
|
+
|
|
232
|
+
// 2. 生成 tar header(512 字节)
|
|
233
|
+
const header = createTarHeader(file.name, stat.size)
|
|
234
|
+
|
|
235
|
+
// 3. 写入 header
|
|
236
|
+
output.write(header)
|
|
237
|
+
|
|
238
|
+
// 4. 写入文件内容
|
|
239
|
+
output.write(content)
|
|
240
|
+
|
|
241
|
+
// 5. 对齐到 512 字节边界
|
|
242
|
+
// 计算需要填充的字节数
|
|
243
|
+
const padding = 512 - (content.length % 512)
|
|
244
|
+
if (padding < 512) {
|
|
245
|
+
// 填充零字节
|
|
246
|
+
output.write(Buffer.alloc(padding, 0))
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 6. 写入结束标记(两个 512 字节的零块,共 1024 字节)
|
|
251
|
+
output.write(Buffer.alloc(1024, 0))
|
|
252
|
+
output.end()
|
|
253
|
+
|
|
254
|
+
// 等待写入完成
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
output.on('finish', resolve)
|
|
257
|
+
output.on('error', reject)
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 创建 tar header(POSIX ustar 格式,512 字节)
|
|
263
|
+
*
|
|
264
|
+
* POSIX ustar header 结构(所有数值都是 8 进制 ASCII 字符串):
|
|
265
|
+
* ```
|
|
266
|
+
* Offset | Size | Field | Description
|
|
267
|
+
* -------|------|---------------|----------------------------------
|
|
268
|
+
* 0 | 100 | name | 文件名(NUL 结尾)
|
|
269
|
+
* 100 | 8 | mode | 文件权限(8进制,如 '0000644 ')
|
|
270
|
+
* 108 | 8 | uid | 用户ID(8进制)
|
|
271
|
+
* 116 | 8 | gid | 组ID(8进制)
|
|
272
|
+
* 124 | 12 | size | 文件大小(8进制,11位数字 + 空格)
|
|
273
|
+
* 136 | 12 | mtime | 修改时间(8进制,Unix 时间戳)
|
|
274
|
+
* 148 | 8 | chksum | 校验和(8进制,6位数字 + NUL + 空格)
|
|
275
|
+
* 156 | 1 | typeflag | 文件类型('0' = 普通文件)
|
|
276
|
+
* 157 | 100 | linkname | 链接目标名(本实现不使用)
|
|
277
|
+
* 257 | 6 | magic | ustar 魔数('ustar')
|
|
278
|
+
* 263 | 2 | version | ustar 版本('00')
|
|
279
|
+
* 265 | 32 | uname | 用户名(本实现不使用)
|
|
280
|
+
* 297 | 32 | gname | 组名(本实现不使用)
|
|
281
|
+
* 329 | 8 | devmajor | 设备主号(本实现不使用)
|
|
282
|
+
* 337 | 8 | devminor | 设备次号(本实现不使用)
|
|
283
|
+
* 345 | 155 | prefix | 文件名前缀(本实现不使用)
|
|
284
|
+
* 500 | 12 | padding | 填充(全零)
|
|
285
|
+
* ```
|
|
286
|
+
*
|
|
287
|
+
* Checksum 计算方法:
|
|
288
|
+
* 1. 将 checksum 字段填充为 8 个空格
|
|
289
|
+
* 2. 将 header 的所有字节值相加
|
|
290
|
+
* 3. 将总和转换为 8 进制字符串,写入 checksum 字段
|
|
291
|
+
*
|
|
292
|
+
* @param {string} filename - 文件名(相对路径)
|
|
293
|
+
* @param {number} size - 文件大小(字节)
|
|
294
|
+
* @returns {Buffer} 512 字节的 tar header
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* const header = createTarHeader('path/to/file.json', 1024)
|
|
298
|
+
* // 返回 512 字节的 Buffer,符合 POSIX ustar 格式
|
|
299
|
+
*/
|
|
300
|
+
function createTarHeader(filename, size) {
|
|
301
|
+
// 分配 512 字节的 buffer,初始化为全零
|
|
302
|
+
const header = Buffer.alloc(512, 0)
|
|
303
|
+
|
|
304
|
+
// --- 字段 1: 文件名 (offset 0, size 100 bytes) ---
|
|
305
|
+
header.write(filename, 0, 100, 'utf8')
|
|
306
|
+
|
|
307
|
+
// --- 字段 2: 文件权限 (offset 100, size 8 bytes) ---
|
|
308
|
+
// 0644 = 所有者可读写,组和其他人只读
|
|
309
|
+
header.write('0000644 ', 100, 8, 'utf8')
|
|
310
|
+
|
|
311
|
+
// --- 字段 3: 用户ID (offset 108, size 8 bytes) ---
|
|
312
|
+
header.write('0000000 ', 108, 8, 'utf8')
|
|
313
|
+
|
|
314
|
+
// --- 字段 4: 组ID (offset 116, size 8 bytes) ---
|
|
315
|
+
header.write('0000000 ', 116, 8, 'utf8')
|
|
316
|
+
|
|
317
|
+
// --- 字段 5: 文件大小 (offset 124, size 12 bytes) ---
|
|
318
|
+
// 转换为 8 进制字符串,左侧补零到 11 位,末尾加空格
|
|
319
|
+
const sizeOctal = size.toString(8).padStart(11, '0') + ' '
|
|
320
|
+
header.write(sizeOctal, 124, 12, 'utf8')
|
|
321
|
+
|
|
322
|
+
// --- 字段 6: 修改时间 (offset 136, size 12 bytes) ---
|
|
323
|
+
// Unix 时间戳(秒),转换为 8 进制
|
|
324
|
+
const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + ' '
|
|
325
|
+
header.write(mtime, 136, 12, 'utf8')
|
|
326
|
+
|
|
327
|
+
// --- 字段 7: Checksum (offset 148, size 8 bytes) ---
|
|
328
|
+
// 先填充 8 个空格(计算 checksum 时需要)
|
|
329
|
+
header.write(' ', 148, 8, 'utf8')
|
|
330
|
+
|
|
331
|
+
// --- 字段 8: 文件类型 (offset 156, size 1 byte) ---
|
|
332
|
+
// '0' 或 '\0' = 普通文件
|
|
333
|
+
header.write('0', 156, 1, 'utf8')
|
|
334
|
+
|
|
335
|
+
// --- 字段 9: ustar 魔数 (offset 257, size 6 bytes) ---
|
|
336
|
+
// 'ustar' + NUL 结尾(自动添加)
|
|
337
|
+
header.write('ustar', 257, 6, 'utf8')
|
|
338
|
+
|
|
339
|
+
// --- 字段 10: ustar 版本 (offset 263, size 2 bytes) ---
|
|
340
|
+
// '00' 表示 POSIX ustar 格式
|
|
341
|
+
header.write('00', 263, 2, 'utf8')
|
|
342
|
+
|
|
343
|
+
// --- 计算并写入 Checksum ---
|
|
344
|
+
// 1. 将 header 的所有字节值相加
|
|
345
|
+
let checksum = 0
|
|
346
|
+
for (let i = 0; i < 512; i++) {
|
|
347
|
+
checksum += header[i]
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 2. 转换为 8 进制字符串(6位数字 + NUL + 空格)
|
|
351
|
+
const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 '
|
|
352
|
+
|
|
353
|
+
// 3. 写入 checksum 字段
|
|
354
|
+
header.write(checksumOctal, 148, 8, 'utf8')
|
|
355
|
+
|
|
356
|
+
return header
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 解压 tar.gz 文件到指定目录
|
|
361
|
+
* 这是 createTarGz 的反向操作
|
|
362
|
+
*
|
|
363
|
+
* 工作流程:
|
|
364
|
+
* 1. 使用 zlib.createGunzip() 解压 gzip 层,得到 .tar 文件(临时)
|
|
365
|
+
* 2. 调用 extractTar() 解压 tar 文件,提取所有文件到目标目录
|
|
366
|
+
* 3. 删除临时 .tar 文件
|
|
367
|
+
*
|
|
368
|
+
* 文件格式:
|
|
369
|
+
* - 输入文件: .tar.gz(gzip 压缩的 tar 归档)
|
|
370
|
+
* - 中间文件: .tar(临时文件,存储在系统临时目录)
|
|
371
|
+
* - 输出: 解压后的目录树
|
|
372
|
+
*
|
|
373
|
+
* 错误处理:
|
|
374
|
+
* - 如果 gzip 解压失败,会抛出异常
|
|
375
|
+
* - 如果 tar 解压失败,会抛出异常
|
|
376
|
+
* - 临时文件删除失败会被静默忽略
|
|
377
|
+
*
|
|
378
|
+
* @param {string} archiveFile - tar.gz 文件路径
|
|
379
|
+
* @param {string} destDir - 目标目录路径(会自动创建)
|
|
380
|
+
* @returns {Promise<void>}
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* await extractTarGz('/path/to/archive.tar.gz', '/path/to/output')
|
|
384
|
+
* // 将 archive.tar.gz 解压到 output 目录
|
|
385
|
+
*/
|
|
386
|
+
async function extractTarGz(archiveFile, destDir) {
|
|
387
|
+
// 先 gunzip 解压
|
|
388
|
+
const tarPath = path.join(os.tmpdir(), `extract-${Date.now()}.tar`)
|
|
389
|
+
|
|
390
|
+
await new Promise((resolve, reject) => {
|
|
391
|
+
const input = createReadStream(archiveFile)
|
|
392
|
+
const output = createWriteStream(tarPath)
|
|
393
|
+
const gunzip = zlib.createGunzip()
|
|
394
|
+
|
|
395
|
+
pipeline(input, gunzip, output)
|
|
396
|
+
.then(resolve)
|
|
397
|
+
.catch(reject)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
// 解压 tar
|
|
401
|
+
await extractTar(tarPath, destDir)
|
|
402
|
+
|
|
403
|
+
// 删除临时 tar 文件
|
|
404
|
+
await fs.unlink(tarPath).catch(() => {})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 解压 tar 文件(手动实现 POSIX ustar 格式解析)
|
|
409
|
+
* 这是 createTar 的反向操作
|
|
410
|
+
*
|
|
411
|
+
* 工作流程:
|
|
412
|
+
* 1. 读取整个 tar 文件到内存(Buffer)
|
|
413
|
+
* 2. 从头开始逐块解析:
|
|
414
|
+
* a. 读取 512 字节 header
|
|
415
|
+
* b. 从 header 提取文件名(offset 0-100)
|
|
416
|
+
* c. 从 header 提取文件大小(offset 124-136,8进制字符串)
|
|
417
|
+
* d. 跳过 header(512 字节)
|
|
418
|
+
* e. 读取文件内容(size 字节)
|
|
419
|
+
* f. 创建目录(递归)并写入文件
|
|
420
|
+
* g. 跳过填充字节(对齐到 512 字节边界)
|
|
421
|
+
* 3. 重复直到遇到结束标记(全零块)
|
|
422
|
+
*
|
|
423
|
+
* tar 文件结构:
|
|
424
|
+
* ```
|
|
425
|
+
* [File 1 Header (512 bytes)] ← 包含文件名和大小
|
|
426
|
+
* [File 1 Content (size bytes)]
|
|
427
|
+
* [Padding to 512 boundary]
|
|
428
|
+
* [File 2 Header (512 bytes)]
|
|
429
|
+
* [File 2 Content (size bytes)]
|
|
430
|
+
* [Padding to 512 boundary]
|
|
431
|
+
* ...
|
|
432
|
+
* [End Marker (1024 bytes of zeros)]
|
|
433
|
+
* ```
|
|
434
|
+
*
|
|
435
|
+
* Header 字段解析:
|
|
436
|
+
* - name (0-100): 文件名(NUL 结尾的 UTF-8 字符串)
|
|
437
|
+
* - size (124-136): 文件大小(8进制 ASCII 字符串)
|
|
438
|
+
*
|
|
439
|
+
* 安全性:
|
|
440
|
+
* - 自动创建目录结构(recursive: true)
|
|
441
|
+
* - 不验证路径安全性(调用方需使用 isPathSafe 检查)
|
|
442
|
+
*
|
|
443
|
+
* @param {string} tarFile - tar 文件路径
|
|
444
|
+
* @param {string} destDir - 目标目录路径(会自动创建子目录)
|
|
445
|
+
* @returns {Promise<void>}
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* await extractTar('/path/to/archive.tar', '/path/to/output')
|
|
449
|
+
* // 将 archive.tar 解压到 output 目录
|
|
450
|
+
*
|
|
451
|
+
* @example
|
|
452
|
+
* // 安全解压(防止路径遍历)
|
|
453
|
+
* const destDir = '/safe/path'
|
|
454
|
+
* await extractTar(tarFile, destDir)
|
|
455
|
+
* // 注意:需要在调用前使用 isPathSafe 验证文件名
|
|
456
|
+
*/
|
|
457
|
+
async function extractTar(tarFile, destDir) {
|
|
458
|
+
const buffer = await fs.readFile(tarFile)
|
|
459
|
+
let offset = 0
|
|
460
|
+
|
|
461
|
+
while (offset < buffer.length) {
|
|
462
|
+
// 检查是否到达结束标记
|
|
463
|
+
if (buffer[offset] === 0) {
|
|
464
|
+
break
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 读取 header
|
|
468
|
+
const header = buffer.subarray(offset, offset + 512)
|
|
469
|
+
|
|
470
|
+
// 读取文件名
|
|
471
|
+
const filename = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
|
|
472
|
+
if (!filename) break
|
|
473
|
+
|
|
474
|
+
// 读取文件大小
|
|
475
|
+
const sizeStr = header.subarray(124, 136).toString('utf8').trim()
|
|
476
|
+
const size = parseInt(sizeStr, 8)
|
|
477
|
+
|
|
478
|
+
// 跳过 header
|
|
479
|
+
offset += 512
|
|
480
|
+
|
|
481
|
+
// 读取文件内容
|
|
482
|
+
const content = buffer.subarray(offset, offset + size)
|
|
483
|
+
|
|
484
|
+
// 写入文件
|
|
485
|
+
const filepath = path.join(destDir, filename)
|
|
486
|
+
await fs.mkdir(path.dirname(filepath), { recursive: true })
|
|
487
|
+
await fs.writeFile(filepath, content)
|
|
488
|
+
|
|
489
|
+
// 移动到下一个文件(对齐到 512 字节)
|
|
490
|
+
const padding = 512 - (size % 512)
|
|
491
|
+
offset += size + (padding < 512 ? padding : 0)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* 文件名安全处理(移除特殊字符)
|
|
497
|
+
* 将 Windows 和 Unix 系统禁止的字符替换为下划线
|
|
498
|
+
*
|
|
499
|
+
* 替换的字符:
|
|
500
|
+
* - Windows 保留字符: `< > : " / \ | ? *`
|
|
501
|
+
* - 控制字符: `\x00-\x1F`(ASCII 0-31,如 NUL、换行符等)
|
|
502
|
+
*
|
|
503
|
+
* 使用场景:
|
|
504
|
+
* - 处理用户上传的文件名
|
|
505
|
+
* - 从 URL 生成文件名
|
|
506
|
+
* - 创建导出文件名
|
|
507
|
+
*
|
|
508
|
+
* 注意事项:
|
|
509
|
+
* - 不验证文件名长度(调用方需检查)
|
|
510
|
+
* - 不处理路径遍历(如 `../`,需使用 isPathSafe)
|
|
511
|
+
* - 保留文件扩展名
|
|
512
|
+
*
|
|
513
|
+
* @param {string} filename - 原始文件名(可能包含特殊字符)
|
|
514
|
+
* @returns {string} 安全的文件名(特殊字符被替换为下划线)
|
|
515
|
+
*
|
|
516
|
+
* @example
|
|
517
|
+
* sanitizeFilename('test<file>.json')
|
|
518
|
+
* // 返回: 'test_file_.json'
|
|
519
|
+
*
|
|
520
|
+
* @example
|
|
521
|
+
* sanitizeFilename('path/to/file.txt')
|
|
522
|
+
* // 返回: 'path_to_file.txt'
|
|
523
|
+
*
|
|
524
|
+
* @example
|
|
525
|
+
* sanitizeFilename('file\x00name.txt')
|
|
526
|
+
* // 返回: 'file_name.txt'
|
|
527
|
+
*/
|
|
528
|
+
function sanitizeFilename(filename) {
|
|
529
|
+
// eslint-disable-next-line no-control-regex
|
|
530
|
+
return filename.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* 检查路径是否在指定目录内(防止路径遍历攻击)
|
|
535
|
+
* 用于验证解压或文件操作时的路径安全性
|
|
536
|
+
*
|
|
537
|
+
* 工作原理:
|
|
538
|
+
* 1. 规范化文件路径(处理 `..`、`.`、多余斜杠)
|
|
539
|
+
* 2. 计算相对于基础目录的相对路径
|
|
540
|
+
* 3. 检查相对路径是否:
|
|
541
|
+
* - 不以 `..` 开头(不能跳出基础目录)
|
|
542
|
+
* - 不是绝对路径(不能访问系统其他位置)
|
|
543
|
+
*
|
|
544
|
+
* 安全性:
|
|
545
|
+
* - 防止路径遍历攻击(Path Traversal)
|
|
546
|
+
* - 防止访问基础目录之外的文件
|
|
547
|
+
* - 防止符号链接攻击(间接)
|
|
548
|
+
*
|
|
549
|
+
* 使用场景:
|
|
550
|
+
* - 解压 tar.gz 文件前验证文件路径
|
|
551
|
+
* - 处理用户上传的文件路径
|
|
552
|
+
* - 任何涉及文件系统操作的路径验证
|
|
553
|
+
*
|
|
554
|
+
* @param {string} filepath - 要检查的文件路径(可能包含 `..`)
|
|
555
|
+
* @param {string} baseDir - 基础目录路径(安全边界)
|
|
556
|
+
* @returns {boolean} true 表示路径安全(在基础目录内),false 表示不安全
|
|
557
|
+
*
|
|
558
|
+
* @example
|
|
559
|
+
* // 安全路径
|
|
560
|
+
* isPathSafe('/base/dir/file.txt', '/base/dir')
|
|
561
|
+
* // 返回: true
|
|
562
|
+
*
|
|
563
|
+
* @example
|
|
564
|
+
* // 路径遍历攻击
|
|
565
|
+
* isPathSafe('/base/dir/../../../etc/passwd', '/base/dir')
|
|
566
|
+
* // 返回: false(相对路径以 .. 开头)
|
|
567
|
+
*
|
|
568
|
+
* @example
|
|
569
|
+
* // 绝对路径攻击
|
|
570
|
+
* isPathSafe('/etc/passwd', '/base/dir')
|
|
571
|
+
* // 返回: false(是绝对路径)
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* // 实际使用(解压 tar 文件)
|
|
575
|
+
* const destDir = '/safe/path'
|
|
576
|
+
* const filename = header.readFilename() // 从 tar header 读取
|
|
577
|
+
* const filepath = path.join(destDir, filename)
|
|
578
|
+
* if (!isPathSafe(filepath, destDir)) {
|
|
579
|
+
* throw new Error('不安全的文件路径: ' + filename)
|
|
580
|
+
* }
|
|
581
|
+
* await fs.writeFile(filepath, content)
|
|
582
|
+
*/
|
|
583
|
+
function isPathSafe(filepath, baseDir) {
|
|
584
|
+
const normalized = path.normalize(filepath)
|
|
585
|
+
const relative = path.relative(baseDir, normalized)
|
|
586
|
+
return !relative.startsWith('..') && !path.isAbsolute(relative)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 解析 multipart/form-data 文件上传(原生实现)
|
|
591
|
+
* 从 Koa 请求中提取上传的文件,不依赖第三方库
|
|
592
|
+
*
|
|
593
|
+
* 工作流程:
|
|
594
|
+
* 1. 验证 Content-Type 是否为 `multipart/form-data`
|
|
595
|
+
* 2. 从 Content-Type 提取 boundary(分隔符)
|
|
596
|
+
* 3. 监听请求流,收集所有数据块到 Buffer
|
|
597
|
+
* 4. 调用 parseMultipartBuffer() 解析 Buffer
|
|
598
|
+
* 5. 返回第一个上传的文件对象
|
|
599
|
+
*
|
|
600
|
+
* HTTP multipart/form-data 格式:
|
|
601
|
+
* ```
|
|
602
|
+
* ------WebKitFormBoundary7MA4YWxkTrZu0gW
|
|
603
|
+
* Content-Disposition: form-data; name="file"; filename="test.txt"
|
|
604
|
+
* Content-Type: text/plain
|
|
605
|
+
*
|
|
606
|
+
* file content here
|
|
607
|
+
* ------WebKitFormBoundary7MA4YWxkTrZu0gW--
|
|
608
|
+
* ```
|
|
609
|
+
*
|
|
610
|
+
* 返回对象结构:
|
|
611
|
+
* ```javascript
|
|
612
|
+
* {
|
|
613
|
+
* fieldname: 'file', // 表单字段名
|
|
614
|
+
* filename: 'test.txt', // 原始文件名
|
|
615
|
+
* buffer: Buffer // 文件内容(Buffer)
|
|
616
|
+
* }
|
|
617
|
+
* ```
|
|
618
|
+
*
|
|
619
|
+
* 错误处理:
|
|
620
|
+
* - 非 multipart/form-data 请求会抛出异常
|
|
621
|
+
* - 缺少 boundary 会抛出异常
|
|
622
|
+
* - 解析失败会抛出异常
|
|
623
|
+
*
|
|
624
|
+
* @param {Object} ctx - Koa 上下文对象
|
|
625
|
+
* @param {Object} ctx.request - Koa 请求对象
|
|
626
|
+
* @param {Object} ctx.request.headers - 请求头
|
|
627
|
+
* @param {string} ctx.request.headers['content-type'] - Content-Type 头
|
|
628
|
+
* @param {Object} ctx.req - Node.js 原生请求对象(Stream)
|
|
629
|
+
* @returns {Promise<Object>} 文件对象 { fieldname, filename, buffer }
|
|
630
|
+
*
|
|
631
|
+
* @example
|
|
632
|
+
* // 在 Koa 路由中使用
|
|
633
|
+
* router.post('/upload', async (ctx) => {
|
|
634
|
+
* try {
|
|
635
|
+
* const file = await parseMultipartFile(ctx)
|
|
636
|
+
* console.log('上传文件:', file.filename)
|
|
637
|
+
* console.log('文件大小:', file.buffer.length)
|
|
638
|
+
* await fs.writeFile(`/uploads/${file.filename}`, file.buffer)
|
|
639
|
+
* ctx.body = { success: true, filename: file.filename }
|
|
640
|
+
* } catch (err) {
|
|
641
|
+
* ctx.status = 400
|
|
642
|
+
* ctx.body = { error: err.message }
|
|
643
|
+
* }
|
|
644
|
+
* })
|
|
645
|
+
*/
|
|
646
|
+
async function parseMultipartFile(ctx) {
|
|
647
|
+
return new Promise((resolve, reject) => {
|
|
648
|
+
const contentType = ctx.request.headers['content-type']
|
|
649
|
+
if (!contentType || !contentType.includes('multipart/form-data')) {
|
|
650
|
+
reject(new Error('不是 multipart/form-data 格式'))
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// 提取 boundary
|
|
655
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/)
|
|
656
|
+
if (!boundaryMatch) {
|
|
657
|
+
reject(new Error('缺少 boundary'))
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
const boundary = '--' + boundaryMatch[1]
|
|
661
|
+
|
|
662
|
+
const chunks = []
|
|
663
|
+
ctx.req.on('data', chunk => chunks.push(chunk))
|
|
664
|
+
ctx.req.on('end', () => {
|
|
665
|
+
try {
|
|
666
|
+
const buffer = Buffer.concat(chunks)
|
|
667
|
+
const file = parseMultipartBuffer(buffer, boundary)
|
|
668
|
+
resolve(file)
|
|
669
|
+
} catch (err) {
|
|
670
|
+
reject(err)
|
|
671
|
+
}
|
|
672
|
+
})
|
|
673
|
+
ctx.req.on('error', reject)
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 解析 multipart buffer(底层解析器)
|
|
679
|
+
* 从原始 Buffer 中提取文件数据,由 parseMultipartFile 调用
|
|
680
|
+
*
|
|
681
|
+
* 工作流程:
|
|
682
|
+
* 1. 从头开始遍历 buffer,查找 boundary 分隔符
|
|
683
|
+
* 2. 对每个 boundary 之间的部分:
|
|
684
|
+
* a. 查找下一个 boundary(确定部分边界)
|
|
685
|
+
* b. 提取部分内容(header + body)
|
|
686
|
+
* c. 查找 `\r\n\r\n`(header 和 body 的分隔符)
|
|
687
|
+
* d. 解析 Content-Disposition 头提取 filename 和 name
|
|
688
|
+
* e. 提取文件内容(去掉结尾的 `\r\n`)
|
|
689
|
+
* f. 添加到 parts 数组
|
|
690
|
+
* 3. 返回第一个文件对象
|
|
691
|
+
*
|
|
692
|
+
* Multipart 部分结构:
|
|
693
|
+
* ```
|
|
694
|
+
* ------WebKitFormBoundary... ← boundary 开始
|
|
695
|
+
* Content-Disposition: form-data; name="file"; filename="test.txt" ← header
|
|
696
|
+
* Content-Type: text/plain
|
|
697
|
+
* \r\n ← header 结束
|
|
698
|
+
* file content here ← body
|
|
699
|
+
* \r\n ← body 结束
|
|
700
|
+
* ------WebKitFormBoundary... ← 下一个 boundary
|
|
701
|
+
* ```
|
|
702
|
+
*
|
|
703
|
+
* 解析逻辑:
|
|
704
|
+
* - header 和 body 之间用 `\r\n\r\n` 分隔
|
|
705
|
+
* - body 结尾有 `\r\n`(需要去除)
|
|
706
|
+
* - boundary 前有 `--` 前缀
|
|
707
|
+
* - 最后的 boundary 后有 `--` 后缀
|
|
708
|
+
*
|
|
709
|
+
* 返回对象结构:
|
|
710
|
+
* ```javascript
|
|
711
|
+
* {
|
|
712
|
+
* fieldname: 'file', // 表单字段名(从 name 提取)
|
|
713
|
+
* filename: 'test.txt', // 原始文件名(从 filename 提取)
|
|
714
|
+
* buffer: Buffer // 文件内容(Buffer,已去除 \r\n)
|
|
715
|
+
* }
|
|
716
|
+
* ```
|
|
717
|
+
*
|
|
718
|
+
* 注意事项:
|
|
719
|
+
* - 仅返回第一个文件(不支持多文件上传)
|
|
720
|
+
* - 不验证 Content-Type
|
|
721
|
+
* - 不处理非文件字段(如文本输入)
|
|
722
|
+
*
|
|
723
|
+
* @param {Buffer} buffer - 完整的 multipart 请求体
|
|
724
|
+
* @param {string} boundary - boundary 字符串(已包含 `--` 前缀)
|
|
725
|
+
* @returns {Object} 文件对象 { fieldname, filename, buffer }
|
|
726
|
+
*
|
|
727
|
+
* @example
|
|
728
|
+
* const buffer = Buffer.from(
|
|
729
|
+
* '------WebKitFormBoundary...\r\n' +
|
|
730
|
+
* 'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' +
|
|
731
|
+
* '\r\n' +
|
|
732
|
+
* 'file content\r\n' +
|
|
733
|
+
* '------WebKitFormBoundary...--'
|
|
734
|
+
* )
|
|
735
|
+
* const file = parseMultipartBuffer(buffer, '------WebKitFormBoundary...')
|
|
736
|
+
* console.log(file.filename) // 'test.txt'
|
|
737
|
+
* console.log(file.buffer.toString()) // 'file content'
|
|
738
|
+
*/
|
|
739
|
+
function parseMultipartBuffer(buffer, boundary) {
|
|
740
|
+
const parts = []
|
|
741
|
+
let start = 0
|
|
742
|
+
|
|
743
|
+
while (start < buffer.length) {
|
|
744
|
+
// 查找 boundary
|
|
745
|
+
const boundaryIndex = buffer.indexOf(boundary, start)
|
|
746
|
+
if (boundaryIndex === -1) break
|
|
747
|
+
|
|
748
|
+
// 查找下一个 boundary
|
|
749
|
+
const nextBoundaryIndex = buffer.indexOf(boundary, boundaryIndex + boundary.length)
|
|
750
|
+
if (nextBoundaryIndex === -1) break
|
|
751
|
+
|
|
752
|
+
// 提取部分内容
|
|
753
|
+
const partBuffer = buffer.subarray(boundaryIndex + boundary.length, nextBoundaryIndex)
|
|
754
|
+
|
|
755
|
+
// 查找 headers 和 body 的分隔符(\r\n\r\n)
|
|
756
|
+
const headerEndIndex = partBuffer.indexOf('\r\n\r\n')
|
|
757
|
+
if (headerEndIndex !== -1) {
|
|
758
|
+
const headers = partBuffer.subarray(0, headerEndIndex).toString('utf8')
|
|
759
|
+
const body = partBuffer.subarray(headerEndIndex + 4, partBuffer.length - 2) // 去掉结尾的 \r\n
|
|
760
|
+
|
|
761
|
+
// 解析 Content-Disposition
|
|
762
|
+
const filenameMatch = headers.match(/filename="(.+?)"/)
|
|
763
|
+
const nameMatch = headers.match(/name="(.+?)"/)
|
|
764
|
+
|
|
765
|
+
if (filenameMatch) {
|
|
766
|
+
parts.push({
|
|
767
|
+
fieldname: nameMatch ? nameMatch[1] : 'file',
|
|
768
|
+
filename: filenameMatch[1],
|
|
769
|
+
buffer: body,
|
|
770
|
+
})
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
start = nextBoundaryIndex
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return parts[0] // 返回第一个文件
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
module.exports = {
|
|
781
|
+
copyDirectory,
|
|
782
|
+
getAllFiles,
|
|
783
|
+
createTarGz,
|
|
784
|
+
extractTarGz,
|
|
785
|
+
sanitizeFilename,
|
|
786
|
+
isPathSafe,
|
|
787
|
+
parseMultipartFile,
|
|
788
|
+
}
|