chanjs 2.7.2 → 2.7.4
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/App.js +232 -16
- package/base/Aop.js +20 -3
- package/base/Container.js +80 -3
- package/base/Controller.js +38 -9
- package/base/Database.js +50 -0
- package/base/Event.js +12 -0
- package/base/{Service.js → Repository.js} +644 -539
- package/common/api.js +18 -8
- package/common/code.js +25 -15
- package/common/email.js +98 -17
- package/common/index.js +1 -1
- package/config/code.js +138 -82
- package/global/index.js +1 -1
- package/helper/index.js +43 -41
- package/index.js +19 -6
- package/loader/index.js +6 -0
- package/{helper → loader}/loader.js +41 -27
- package/middleware/compress.js +185 -0
- package/middleware/cors.js +36 -24
- package/middleware/header.js +5 -10
- package/middleware/index.js +1 -0
- package/middleware/log.js +27 -3
- package/middleware/setBody.js +9 -1
- package/middleware/static.js +2 -1
- package/middleware/template.js +139 -4
- package/middleware/waf.js +136 -76
- package/package.json +4 -4
- package/realtime/index.js +7 -0
- package/realtime/sse.js +424 -0
- package/realtime/websocket.js +540 -0
- package/response/index.js +12 -0
- package/response/response.js +258 -0
- package/schedule/index.js +6 -0
- package/schedule/schedule.js +491 -0
- package/{helper → security}/checker.js +23 -8
- package/security/index.js +14 -0
- package/{helper → security}/jwt.js +175 -107
- package/security/keywords.js +179 -0
- package/security/rate-limit.js +105 -0
- package/security/sign.js +210 -0
- package/security/xss-filter.js +63 -0
- package/storage/cache.js +258 -0
- package/storage/index.js +9 -0
- package/storage/redis.js +258 -0
- package/storage/store.js +266 -0
- package/{helper → utils}/file.js +106 -15
- package/{helper → utils}/filter.js +2 -1
- package/{helper → utils}/html.js +19 -1
- package/utils/index.js +34 -0
- package/{helper → utils}/ip.js +25 -16
- package/utils/request.js +172 -0
- package/{helper → utils}/time.js +1 -1
- package/utils/tree.js +121 -0
- package/common/category.js +0 -22
- package/common/sms.js +0 -104
- package/extend/art-template.js +0 -129
- package/extend/index.js +0 -6
- package/global/global.js +0 -63
- package/helper/cache.js +0 -187
- package/helper/keywords.js +0 -132
- package/helper/rate-limit.js +0 -116
- package/helper/request.js +0 -47
- package/helper/response.js +0 -180
- package/helper/sign.js +0 -96
- package/helper/tree.js +0 -77
- package/helper/xss-filter.js +0 -42
- /package/{helper → utils}/data-parse.js +0 -0
package/{helper → utils}/file.js
RENAMED
|
@@ -17,6 +17,34 @@ export function dirname(importMetaUrl) {
|
|
|
17
17
|
return path.dirname(url.pathname);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 检查路径是否包含目录穿越字符或绝对路径
|
|
22
|
+
* @private
|
|
23
|
+
* @param {string} inputPath - 待检查的路径
|
|
24
|
+
* @returns {boolean} 是否安全
|
|
25
|
+
*/
|
|
26
|
+
function _isPathSafe(inputPath) {
|
|
27
|
+
if (!inputPath || typeof inputPath !== 'string') return false;
|
|
28
|
+
// 拒绝绝对路径(/etc/passwd)和 Windows 盘符(C:\)
|
|
29
|
+
if (path.isAbsolute(inputPath) && !inputPath.startsWith(process.cwd())) return false;
|
|
30
|
+
// 拒绝目录穿越
|
|
31
|
+
const normalized = path.normalize(inputPath);
|
|
32
|
+
if (normalized.includes('..')) return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 校验 fullPath 是否落在 process.cwd() 内
|
|
38
|
+
* 用 path.relative 避免 startsWith 前缀绕过(如 /project-evil 匹配 /project)
|
|
39
|
+
* @private
|
|
40
|
+
* @param {string} fullPath - 已解析的绝对路径
|
|
41
|
+
* @returns {boolean} 是否在 cwd 内
|
|
42
|
+
*/
|
|
43
|
+
function _isWithinCwd(fullPath) {
|
|
44
|
+
const rel = path.relative(process.cwd(), fullPath);
|
|
45
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
/**
|
|
21
49
|
* 删除图片文件
|
|
22
50
|
* @param {string} filePath - 相对于项目根目录的文件路径
|
|
@@ -24,7 +52,11 @@ export function dirname(importMetaUrl) {
|
|
|
24
52
|
* @description
|
|
25
53
|
* 删除指定路径的图片文件
|
|
26
54
|
* 路径是相对于项目根目录(process.cwd())的
|
|
27
|
-
*
|
|
55
|
+
*
|
|
56
|
+
* 安全改进:
|
|
57
|
+
* - 增加防御性路径校验,拒绝绝对路径和目录穿越
|
|
58
|
+
* - 解析后最终路径必须落在 process.cwd() 内,否则拒绝
|
|
59
|
+
* - 业务层即使忘做校验也不会被利用删除任意文件
|
|
28
60
|
* @example
|
|
29
61
|
* const deleted = delImg('/uploads/avatar/123.jpg');
|
|
30
62
|
* if (deleted) {
|
|
@@ -32,7 +64,16 @@ export function dirname(importMetaUrl) {
|
|
|
32
64
|
* }
|
|
33
65
|
*/
|
|
34
66
|
export function delImg(filePath) {
|
|
35
|
-
|
|
67
|
+
if (!_isPathSafe(filePath)) {
|
|
68
|
+
console.error('[delImg] 拒绝不安全路径:', filePath);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const fullPath = path.resolve(process.cwd(), filePath);
|
|
72
|
+
// 二次校验:解析后必须仍在 cwd 内(用 relative 避免 startsWith 前缀绕过)
|
|
73
|
+
if (!_isWithinCwd(fullPath)) {
|
|
74
|
+
console.error('[delImg] 路径越界:', filePath);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
36
77
|
if (fs.existsSync(fullPath)) {
|
|
37
78
|
fs.unlinkSync(fullPath);
|
|
38
79
|
return true;
|
|
@@ -52,6 +93,11 @@ export function delImg(filePath) {
|
|
|
52
93
|
* // 返回: [{ name: 'file.html', path: '/path/to/directory/file.html', type: 'file' }, ...]
|
|
53
94
|
*/
|
|
54
95
|
export function getFileTree(dirPath, recursive = true, basePath = null) {
|
|
96
|
+
// 路径安全校验:拒绝不安全路径直接返回空数组
|
|
97
|
+
if (!_isPathSafe(dirPath)) {
|
|
98
|
+
console.error('[getFileTree] 拒绝不安全路径:', dirPath);
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
55
101
|
if (!fs.existsSync(dirPath)) {
|
|
56
102
|
return [];
|
|
57
103
|
}
|
|
@@ -61,29 +107,36 @@ export function getFileTree(dirPath, recursive = true, basePath = null) {
|
|
|
61
107
|
return [];
|
|
62
108
|
}
|
|
63
109
|
|
|
110
|
+
// 二次校验:解析后必须仍在 cwd 内
|
|
111
|
+
const fullPath = path.resolve(process.cwd(), dirPath);
|
|
112
|
+
if (!_isWithinCwd(fullPath)) {
|
|
113
|
+
console.error('[getFileTree] 路径越界:', dirPath);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
|
|
64
117
|
const items = [];
|
|
65
118
|
const files = fs.readdirSync(dirPath);
|
|
66
119
|
|
|
67
120
|
const base = basePath || process.cwd();
|
|
68
121
|
|
|
69
122
|
for (const file of files) {
|
|
70
|
-
const
|
|
71
|
-
const fileStats = fs.statSync(
|
|
123
|
+
const filePath = path.join(dirPath, file);
|
|
124
|
+
const fileStats = fs.statSync(filePath);
|
|
72
125
|
|
|
73
126
|
if (fileStats.isDirectory() && recursive) {
|
|
74
|
-
const children = getFileTree(
|
|
127
|
+
const children = getFileTree(filePath, recursive, basePath);
|
|
75
128
|
items.push({
|
|
76
129
|
name: file,
|
|
77
|
-
path:
|
|
130
|
+
path: filePath,
|
|
78
131
|
type: 'directory',
|
|
79
132
|
children: children
|
|
80
133
|
});
|
|
81
134
|
} else if (fileStats.isFile()) {
|
|
82
|
-
const relativePath = path.relative(base,
|
|
135
|
+
const relativePath = path.relative(base, filePath).replace(/\\/g, '/');
|
|
83
136
|
items.push({
|
|
84
137
|
name: file,
|
|
85
|
-
path:
|
|
86
|
-
relativePath: relativePath.startsWith('public') ? '/' + relativePath.replace(/^\//, '') :
|
|
138
|
+
path: filePath,
|
|
139
|
+
relativePath: relativePath.startsWith('public') ? '/' + relativePath.replace(/^\//, '') : filePath,
|
|
87
140
|
type: 'file',
|
|
88
141
|
size: fileStats.size
|
|
89
142
|
});
|
|
@@ -99,20 +152,33 @@ export function getFileTree(dirPath, recursive = true, basePath = null) {
|
|
|
99
152
|
* @returns {string} 文件内容
|
|
100
153
|
* @description
|
|
101
154
|
* 读取指定文件的内容
|
|
155
|
+
*
|
|
156
|
+
* 安全改进:
|
|
157
|
+
* - 增加防御性路径校验,拒绝绝对路径和目录穿越
|
|
158
|
+
* - 解析后最终路径必须落在 process.cwd() 内,否则拒绝
|
|
159
|
+
* - 与 saveFileContent 保持一致的安全策略
|
|
102
160
|
* @example
|
|
103
161
|
* const content = readFileContent('/path/to/file.html');
|
|
104
162
|
*/
|
|
105
163
|
export function readFileContent(filePath) {
|
|
106
|
-
if (!
|
|
164
|
+
if (!_isPathSafe(filePath)) {
|
|
165
|
+
throw new Error(`[readFileContent] 拒绝不安全路径: ${filePath}`);
|
|
166
|
+
}
|
|
167
|
+
const fullPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
|
168
|
+
// 二次校验:解析后必须仍在 cwd 内(用 relative 避免 startsWith 前缀绕过)
|
|
169
|
+
if (!_isWithinCwd(fullPath)) {
|
|
170
|
+
throw new Error(`[readFileContent] 路径越界: ${filePath}`);
|
|
171
|
+
}
|
|
172
|
+
if (!fs.existsSync(fullPath)) {
|
|
107
173
|
throw new Error('文件不存在');
|
|
108
174
|
}
|
|
109
175
|
|
|
110
|
-
const stats = fs.statSync(
|
|
176
|
+
const stats = fs.statSync(fullPath);
|
|
111
177
|
if (!stats.isFile()) {
|
|
112
178
|
throw new Error('路径不是文件');
|
|
113
179
|
}
|
|
114
180
|
|
|
115
|
-
return fs.readFileSync(
|
|
181
|
+
return fs.readFileSync(fullPath, 'utf-8');
|
|
116
182
|
}
|
|
117
183
|
|
|
118
184
|
/**
|
|
@@ -122,17 +188,31 @@ export function readFileContent(filePath) {
|
|
|
122
188
|
* @returns {void}
|
|
123
189
|
* @description
|
|
124
190
|
* 将内容保存到指定文件,如果目录不存在会自动创建
|
|
191
|
+
*
|
|
192
|
+
* 安全改进:
|
|
193
|
+
* - 增加防御性路径校验,拒绝绝对路径和目录穿越
|
|
194
|
+
* - 解析后最终路径必须落在 process.cwd() 内,否则拒绝
|
|
195
|
+
* - 业务层即使忘做校验也不会被写入任意路径
|
|
125
196
|
* @example
|
|
126
197
|
* saveFileContent('/path/to/file.html', '<html>...</html>');
|
|
127
198
|
*/
|
|
128
199
|
export function saveFileContent(filePath, content) {
|
|
129
|
-
|
|
130
|
-
|
|
200
|
+
if (!_isPathSafe(filePath)) {
|
|
201
|
+
throw new Error(`[saveFileContent] 拒绝不安全路径: ${filePath}`);
|
|
202
|
+
}
|
|
203
|
+
const fullPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
|
204
|
+
// 二次校验:解析后必须仍在 cwd 内
|
|
205
|
+
if (!_isWithinCwd(fullPath)) {
|
|
206
|
+
throw new Error(`[saveFileContent] 路径越界: ${filePath}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const dirPath = path.dirname(fullPath);
|
|
210
|
+
|
|
131
211
|
if (!fs.existsSync(dirPath)) {
|
|
132
212
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
133
213
|
}
|
|
134
214
|
|
|
135
|
-
fs.writeFileSync(
|
|
215
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
136
216
|
}
|
|
137
217
|
|
|
138
218
|
/**
|
|
@@ -168,9 +248,20 @@ export function isPathSafe(targetPath, basePath) {
|
|
|
168
248
|
* // 返回: ['default', 'test', ...]
|
|
169
249
|
*/
|
|
170
250
|
export function getFolders(folderPath) {
|
|
251
|
+
// 路径安全校验:拒绝不安全路径直接返回空数组
|
|
252
|
+
if (!_isPathSafe(folderPath)) {
|
|
253
|
+
console.error('[getFolders] 拒绝不安全路径:', folderPath);
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
171
256
|
if (!fs.existsSync(folderPath)) {
|
|
172
257
|
return [];
|
|
173
258
|
}
|
|
259
|
+
// 二次校验:解析后必须仍在 cwd 内
|
|
260
|
+
const fullPath = path.resolve(process.cwd(), folderPath);
|
|
261
|
+
if (!_isWithinCwd(fullPath)) {
|
|
262
|
+
console.error('[getFolders] 路径越界:', folderPath);
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
174
265
|
|
|
175
266
|
const items = fs.readdirSync(folderPath);
|
|
176
267
|
const folders = [];
|
|
@@ -25,7 +25,8 @@ export function filterFields(data, fields) {
|
|
|
25
25
|
return data.map((item) => {
|
|
26
26
|
const filteredItem = {};
|
|
27
27
|
for (const field of fields) {
|
|
28
|
-
|
|
28
|
+
// 用原型上的方法调用,避免 item 重写 hasOwnProperty 导致原型污染
|
|
29
|
+
if (Object.prototype.hasOwnProperty.call(item, field)) {
|
|
29
30
|
filteredItem[field] = item[field];
|
|
30
31
|
}
|
|
31
32
|
}
|
package/{helper → utils}/html.js
RENAMED
|
@@ -25,6 +25,7 @@ export const escapeScript = (str) => {
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* HTML 解码
|
|
28
|
+
* 支持:命名实体(amp/lt/gt/quot/apos/nbsp)+ 十六进制数字实体(&#x...;)+ 十进制数字实体(&#...;)
|
|
28
29
|
*/
|
|
29
30
|
export function htmlDecode(str) {
|
|
30
31
|
if (typeof str !== 'string') return '';
|
|
@@ -36,5 +37,22 @@ export function htmlDecode(str) {
|
|
|
36
37
|
''': "'",
|
|
37
38
|
' ': ' ',
|
|
38
39
|
};
|
|
39
|
-
return str
|
|
40
|
+
return str
|
|
41
|
+
.replace(/&(amp|lt|gt|quot|apos|nbsp);/g, m => entities[m])
|
|
42
|
+
// 十六进制数字实体:< → <(含 2/3/4/5/6 位 Unicode)
|
|
43
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => {
|
|
44
|
+
try {
|
|
45
|
+
return String.fromCodePoint(parseInt(h, 16));
|
|
46
|
+
} catch {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
// 十进制数字实体:< → <
|
|
51
|
+
.replace(/&#(\d+);/g, (_, d) => {
|
|
52
|
+
try {
|
|
53
|
+
return String.fromCodePoint(parseInt(d, 10));
|
|
54
|
+
} catch {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
});
|
|
40
58
|
}
|
package/utils/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用工具模块 - 时间/文件/HTML/IP/数据解析/树/过滤
|
|
3
|
+
*/
|
|
4
|
+
// 时间处理
|
|
5
|
+
export { formatTime, formatDateFields } from "./time.js";
|
|
6
|
+
|
|
7
|
+
// 文件操作
|
|
8
|
+
export {
|
|
9
|
+
dirname,
|
|
10
|
+
delImg,
|
|
11
|
+
getFileTree,
|
|
12
|
+
readFileContent,
|
|
13
|
+
saveFileContent,
|
|
14
|
+
isPathSafe,
|
|
15
|
+
getFolders,
|
|
16
|
+
} from "./file.js";
|
|
17
|
+
|
|
18
|
+
// HTML 处理
|
|
19
|
+
export { htmlDecode, htmlEncode, escapeScript } from "./html.js";
|
|
20
|
+
|
|
21
|
+
// IP 获取
|
|
22
|
+
export { getIp } from "./ip.js";
|
|
23
|
+
|
|
24
|
+
// 网络请求
|
|
25
|
+
export { request } from "./request.js";
|
|
26
|
+
|
|
27
|
+
// 数据解析
|
|
28
|
+
export { dataParse, arrToObj, parseJsonFields, buildTree } from "./data-parse.js";
|
|
29
|
+
|
|
30
|
+
// 树形结构
|
|
31
|
+
export { tree, treeById } from "./tree.js";
|
|
32
|
+
|
|
33
|
+
// 字段过滤
|
|
34
|
+
export { filterFields } from "./filter.js";
|
package/{helper → utils}/ip.js
RENAMED
|
@@ -4,24 +4,29 @@
|
|
|
4
4
|
* @param {string} [req.ip] - Express 解析的 IP(已包含代理头部解析)
|
|
5
5
|
* @param {Object} [req.headers] - 请求头对象
|
|
6
6
|
* @param {string} [req.headers.cf-connecting-ip] - Cloudflare 连接 IP
|
|
7
|
+
* @param {string} [req.headers.cf-ray] - Cloudflare 请求追踪 ID
|
|
7
8
|
* @returns {string} 客户端 IP 地址,如果无法获取则返回 "0.0.0.0"
|
|
8
9
|
* @description
|
|
9
10
|
* 从 Express 的 req.ip 获取客户端 IP。
|
|
10
|
-
*
|
|
11
|
+
*
|
|
11
12
|
* 注意:需要在 App.js 中设置 trust proxy:
|
|
12
13
|
* app.set("trust proxy", true);
|
|
13
|
-
*
|
|
14
|
+
*
|
|
14
15
|
* 设置 trust proxy 后,Express 会自动从以下头部解析真实 IP:
|
|
15
16
|
* - X-Forwarded-For(反向代理,如 Nginx)
|
|
16
17
|
* - X-Real-IP
|
|
17
18
|
* - CF-Connecting-IP(Cloudflare CDN)
|
|
18
|
-
*
|
|
19
|
+
*
|
|
19
20
|
* 此方法按优先级从以下来源获取 IP 地址:
|
|
20
|
-
* 1. cf-connecting-ip
|
|
21
|
+
* 1. cf-connecting-ip 请求头(仅当同时存在 cf-ray 时才信任,防止伪造)
|
|
21
22
|
* 2. req.ip(Express 自动解析,已包含代理头部处理)
|
|
22
23
|
* 3. 默认值 "0.0.0.0"
|
|
23
|
-
*
|
|
24
|
-
*
|
|
24
|
+
*
|
|
25
|
+
* 安全改进:
|
|
26
|
+
* - 仅当同时存在 cf-ray 头时才信任 cf-connecting-ip
|
|
27
|
+
* - 避免攻击者直接伪造 cf-connecting-ip 头绕过 IP 限流
|
|
28
|
+
* - Cloudflare CDN 总是会同时下发这两个头
|
|
29
|
+
*
|
|
25
30
|
* @example
|
|
26
31
|
* app.use((req, res, next) => {
|
|
27
32
|
* const ip = getIp(req);
|
|
@@ -30,34 +35,38 @@
|
|
|
30
35
|
* });
|
|
31
36
|
*/
|
|
32
37
|
export function getIp(req) {
|
|
33
|
-
// 1. 优先获取 Cloudflare CDN 透传的真实 IP(优先级最高)
|
|
34
38
|
const headers = req.headers || {};
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
|
|
40
|
+
// 1. 仅在 CF_ENABLED=true 时信任 cf-* 头部
|
|
41
|
+
// 单凭 cf-ray 头部仍可被伪造,必须显式声明部署在 Cloudflare 后才信任
|
|
42
|
+
// 否则攻击者可任意伪造 cf-connecting-ip / cf-ray 绕过 IP 限流
|
|
43
|
+
if (process.env.CF_ENABLED === 'true' && headers["cf-ray"]) {
|
|
44
|
+
let cfIp = headers["cf-connecting-ip"];
|
|
39
45
|
if (cfIp) {
|
|
40
|
-
|
|
46
|
+
cfIp = cfIp.split(',').map(ip => ip.trim())[0];
|
|
47
|
+
if (cfIp) {
|
|
48
|
+
return cfIp;
|
|
49
|
+
}
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
// 2. 其次使用 Express 解析的 req.ip(已处理代理头部)
|
|
45
54
|
if (req.ip) {
|
|
46
55
|
let ip = req.ip;
|
|
47
|
-
|
|
56
|
+
|
|
48
57
|
// 处理 IPv6 映射的 IPv4 地址(如 ::ffff:192.168.1.1 → 192.168.1.1)
|
|
49
58
|
if (ip.startsWith('::ffff:')) {
|
|
50
59
|
ip = ip.substring(7);
|
|
51
60
|
}
|
|
52
|
-
|
|
61
|
+
|
|
53
62
|
// 处理本地回环地址(::1 → 127.0.0.1)
|
|
54
63
|
if (ip === '::1') {
|
|
55
64
|
ip = '127.0.0.1';
|
|
56
65
|
}
|
|
57
|
-
|
|
66
|
+
|
|
58
67
|
return ip;
|
|
59
68
|
}
|
|
60
|
-
|
|
69
|
+
|
|
61
70
|
// 3. 无法获取时返回约定的默认值
|
|
62
71
|
return "0.0.0.0";
|
|
63
72
|
}
|
package/utils/request.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 请求工具函数
|
|
3
|
+
* 提供统一的请求发送方法
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 默认请求超时时间(毫秒)
|
|
8
|
+
* 防止无超时控制的 fetch 挂起导致连接堆积
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_TIMEOUT = 10000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 最大响应体大小(10MB)
|
|
14
|
+
* 超过此大小直接拒绝,防止远端返回超大响应导致内存爆炸
|
|
15
|
+
*/
|
|
16
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 内网/特殊地址检查(SSRF 防护)
|
|
20
|
+
* 拒绝请求内网 IP 和非 http/https 协议
|
|
21
|
+
* @param {string} urlStr - 待检查的 URL
|
|
22
|
+
* @returns {Promise<boolean>} true 表示不安全(应拒绝)
|
|
23
|
+
* @private
|
|
24
|
+
*/
|
|
25
|
+
async function _isPrivateUrl(urlStr) {
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(urlStr);
|
|
28
|
+
// 仅允许 http/https 协议
|
|
29
|
+
if (!['http:', 'https:'].includes(u.protocol)) return true;
|
|
30
|
+
const host = u.hostname;
|
|
31
|
+
if (!host) return true;
|
|
32
|
+
// 拒绝 localhost 和内网 IP
|
|
33
|
+
if (host === 'localhost' || /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.|0\.)/.test(host)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
} catch {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 发送 HTTP 请求
|
|
44
|
+
* @async
|
|
45
|
+
* @param {string} url - 请求 URL
|
|
46
|
+
* @param {Object} [options={}] - 请求选项
|
|
47
|
+
* @param {string} [options.method='GET'] - HTTP 方法
|
|
48
|
+
* @param {Object} [options.headers] - 请求头
|
|
49
|
+
* @param {Object|string} [options.body] - 请求体
|
|
50
|
+
* @param {number} [options.timeout=10000] - 请求超时时间(毫秒),0 表示不超时
|
|
51
|
+
* @param {number} [options.retry=0] - 失败重试次数,默认 0
|
|
52
|
+
* @param {number} [options.maxResponseBytes=10485760] - 最大响应字节数,默认 10MB
|
|
53
|
+
* @returns {Promise<Object|null>} 响应数据,失败时返回 null
|
|
54
|
+
* @description
|
|
55
|
+
* 发送 HTTP 请求并返回 JSON 格式的响应数据
|
|
56
|
+
* - 自动将对象类型的 body 转换为 JSON 字符串
|
|
57
|
+
* - 默认 Content-Type 为 application/json
|
|
58
|
+
* - 内置 AbortController 超时控制,默认 10 秒
|
|
59
|
+
* - SSRF 防护:拒绝请求内网/localhost 地址
|
|
60
|
+
* - 响应大小限制:超过 maxResponseBytes 拒绝
|
|
61
|
+
* - response.ok 校验:HTTP 状态码非 2xx 时返回 null
|
|
62
|
+
* - 失败重试:网络错误时按 retry 次数重试
|
|
63
|
+
* @example
|
|
64
|
+
* const data = await request('https://api.example.com/users', {
|
|
65
|
+
* method: 'POST',
|
|
66
|
+
* body: { name: '张三' },
|
|
67
|
+
* timeout: 5000,
|
|
68
|
+
* retry: 2
|
|
69
|
+
* });
|
|
70
|
+
*/
|
|
71
|
+
export async function request(url, options = {}) {
|
|
72
|
+
const {
|
|
73
|
+
timeout = DEFAULT_TIMEOUT,
|
|
74
|
+
retry = 0,
|
|
75
|
+
maxResponseBytes = MAX_RESPONSE_BYTES,
|
|
76
|
+
...restOptions
|
|
77
|
+
} = options;
|
|
78
|
+
|
|
79
|
+
// SSRF 防护:拒绝内网/特殊地址
|
|
80
|
+
if (await _isPrivateUrl(url)) {
|
|
81
|
+
console.error(`[Request] SSRF 防护:拒绝请求内网/非法地址: ${url}`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const defaultOptions = {
|
|
86
|
+
method: 'GET',
|
|
87
|
+
headers: {
|
|
88
|
+
'Content-Type': 'application/json',
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const finalOptions = { ...defaultOptions, ...restOptions };
|
|
93
|
+
|
|
94
|
+
if (finalOptions.body && typeof finalOptions.body !== 'string') {
|
|
95
|
+
finalOptions.body = JSON.stringify(finalOptions.body);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 重试逻辑
|
|
99
|
+
let lastError = null;
|
|
100
|
+
for (let attempt = 0; attempt <= retry; attempt++) {
|
|
101
|
+
// 超时控制:通过 AbortController 在指定时间后中止请求
|
|
102
|
+
let controller = null;
|
|
103
|
+
let timer = null;
|
|
104
|
+
if (timeout > 0) {
|
|
105
|
+
controller = new AbortController();
|
|
106
|
+
finalOptions.signal = controller.signal;
|
|
107
|
+
timer = setTimeout(() => controller.abort(), timeout);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const response = await fetch(url, finalOptions);
|
|
112
|
+
|
|
113
|
+
// HTTP 状态码校验:非 2xx 视为失败
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
console.error(`[Request] HTTP ${response.status} ${response.statusText}: ${url}`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 响应大小校验:content-length 超限直接拒绝
|
|
120
|
+
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
|
|
121
|
+
if (contentLength && contentLength > maxResponseBytes) {
|
|
122
|
+
console.error(`[Request] 响应体过大 (${contentLength} bytes > ${maxResponseBytes}): ${url}`);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 流式读取时累计字节数,超过上限中止
|
|
127
|
+
const reader = response.body?.getReader();
|
|
128
|
+
if (reader) {
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let totalSize = 0;
|
|
131
|
+
let done = false;
|
|
132
|
+
while (!done) {
|
|
133
|
+
const { done: rDone, value } = await reader.read();
|
|
134
|
+
done = rDone;
|
|
135
|
+
if (value) {
|
|
136
|
+
totalSize += value.length;
|
|
137
|
+
if (totalSize > maxResponseBytes) {
|
|
138
|
+
console.error(`[Request] 流式响应体超过上限 (${totalSize} > ${maxResponseBytes}): ${url}`);
|
|
139
|
+
await reader.cancel?.();
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
chunks.push(value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const buf = Buffer.concat(chunks);
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(buf.toString('utf8'));
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.error('[Request] JSON 解析失败:', e.message);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 无 body 流的兜底(理论上极少走到)
|
|
155
|
+
const data = await response.json();
|
|
156
|
+
return data;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error.name === 'AbortError') {
|
|
159
|
+
console.error(`[Request] 请求超时 (${timeout}ms): ${url}`);
|
|
160
|
+
} else {
|
|
161
|
+
console.error(`[Request] 请求失败 (attempt ${attempt + 1}/${retry + 1}):`, error.message);
|
|
162
|
+
}
|
|
163
|
+
lastError = error;
|
|
164
|
+
// 仅网络错误重试,超时不重试(避免雪崩)
|
|
165
|
+
if (error.name === 'AbortError') break;
|
|
166
|
+
} finally {
|
|
167
|
+
if (timer) clearTimeout(timer);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return null;
|
|
172
|
+
}
|
package/{helper → utils}/time.js
RENAMED
|
@@ -77,7 +77,7 @@ export function formatDateFields(data, fields) {
|
|
|
77
77
|
|
|
78
78
|
const result = { ...data };
|
|
79
79
|
|
|
80
|
-
const defaultFields = ['createdAt', 'updatedAt', 'created_at', 'updated_at'
|
|
80
|
+
const defaultFields = ['createdAt', 'updatedAt', 'created_at', 'updated_at'];
|
|
81
81
|
const targetFields = fields || defaultFields;
|
|
82
82
|
|
|
83
83
|
for (const field of targetFields) {
|
package/utils/tree.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 树形结构工具函数
|
|
3
|
+
* 提供数组转树形结构和路径查找功能
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 默认最大递归深度,防止循环引用导致栈溢出
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_MAX_DEPTH = 20;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 将扁平数组转换为树形结构
|
|
13
|
+
* @param {Array<Object>} arr - 扁平化的数据数组
|
|
14
|
+
* @param {number} [pid=0] - 父节点 ID,默认为 0
|
|
15
|
+
* @param {Object} [opts] - 选项
|
|
16
|
+
* @param {number} [opts.maxDepth=20] - 最大递归深度
|
|
17
|
+
* @param {Set} [opts._visited] - 已访问节点 id 集合(防循环引用,内部用)
|
|
18
|
+
* @param {number} [opts._depth=0] - 当前递归深度(内部用)
|
|
19
|
+
* @returns {Array<Object>} 树形结构数组
|
|
20
|
+
* @description
|
|
21
|
+
* 递归构建树形结构,为每个节点添加 level 属性
|
|
22
|
+
* 如果有子节点,则添加 children 属性
|
|
23
|
+
*
|
|
24
|
+
* 安全改进:
|
|
25
|
+
* - maxDepth 限制递归深度,防止恶意数据导致栈溢出
|
|
26
|
+
* - _visited 跟踪已访问的 id,遇到循环引用时立即终止
|
|
27
|
+
* @example
|
|
28
|
+
* const arr = [
|
|
29
|
+
* { id: 1, pid: 0, name: '根节点' },
|
|
30
|
+
* { id: 2, pid: 1, name: '子节点' },
|
|
31
|
+
* { id: 3, pid: 2, name: '孙节点' }
|
|
32
|
+
* ];
|
|
33
|
+
* const tree = tree(arr);
|
|
34
|
+
* // 返回带有 children 和 level 的树形结构
|
|
35
|
+
*/
|
|
36
|
+
export function tree(arr, pid = 0, opts = {}) {
|
|
37
|
+
const maxDepth = opts.maxDepth || DEFAULT_MAX_DEPTH;
|
|
38
|
+
const visited = opts._visited || new Set();
|
|
39
|
+
let depth = opts._depth || 0;
|
|
40
|
+
|
|
41
|
+
// 空数组 / 超过最大深度 / 循环引用检测
|
|
42
|
+
if (!Array.isArray(arr) || arr.length === 0) return [];
|
|
43
|
+
if (depth >= maxDepth) {
|
|
44
|
+
console.warn(`[tree] 已达到最大递归深度 ${maxDepth},可能存在循环引用`);
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
if (visited.has(pid)) {
|
|
48
|
+
console.warn(`[tree] 检测到循环引用,pid=${pid} 已访问过`);
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
visited.add(pid);
|
|
52
|
+
|
|
53
|
+
let result = [];
|
|
54
|
+
arr.forEach((item) => {
|
|
55
|
+
if (item.pid === pid) {
|
|
56
|
+
let children = tree(arr, item.id, { maxDepth, _visited: visited, _depth: depth + 1 });
|
|
57
|
+
if (children.length) {
|
|
58
|
+
item.children = children;
|
|
59
|
+
}
|
|
60
|
+
item.level = 1;
|
|
61
|
+
result.push(item);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 根据 ID 查找节点路径
|
|
69
|
+
* @param {number} id - 要查找的节点 ID
|
|
70
|
+
* @param {Array<Object>} source - 数据源数组
|
|
71
|
+
* @param {Object} [opts] - 选项
|
|
72
|
+
* @param {number} [opts.maxDepth=20] - 最大递归深度
|
|
73
|
+
* @returns {Array<Object>} 从根节点到目标节点的路径数组
|
|
74
|
+
* @description
|
|
75
|
+
* 递归查找指定 ID 的节点及其所有父节点
|
|
76
|
+
* 为每个节点添加 path 属性,包含拼音路径
|
|
77
|
+
*
|
|
78
|
+
* 安全改进:
|
|
79
|
+
* - maxDepth 限制递归深度,防止恶意数据导致栈溢出
|
|
80
|
+
* - 用 visited 集合检测循环引用
|
|
81
|
+
* @example
|
|
82
|
+
* const arr = [
|
|
83
|
+
* { id: 1, pid: 0, pinyin: 'root' },
|
|
84
|
+
* { id: 2, pid: 1, pinyin: 'child' }
|
|
85
|
+
* ];
|
|
86
|
+
* const path = treeById(2, arr);
|
|
87
|
+
* // 返回包含 root 和 child 节点的数组
|
|
88
|
+
*/
|
|
89
|
+
export function treeById(id, source, opts = {}) {
|
|
90
|
+
const maxDepth = opts.maxDepth || DEFAULT_MAX_DEPTH;
|
|
91
|
+
const arr = [];
|
|
92
|
+
const visited = new Set();
|
|
93
|
+
|
|
94
|
+
const findId = (id, source, depth = 0) => {
|
|
95
|
+
if (depth >= maxDepth) {
|
|
96
|
+
console.warn(`[treeById] 已达到最大递归深度 ${maxDepth},可能存在循环引用`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (visited.has(id)) {
|
|
100
|
+
console.warn(`[treeById] 检测到循环引用,id=${id} 已访问过`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
visited.add(id);
|
|
104
|
+
for (let i = 0, item; i < source.length; i++) {
|
|
105
|
+
item = source[i];
|
|
106
|
+
if (item.id == id) {
|
|
107
|
+
arr.unshift(item);
|
|
108
|
+
if (item.pid != 0) {
|
|
109
|
+
findId(item.pid, source, depth + 1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
findId(id, source);
|
|
115
|
+
const _path = [];
|
|
116
|
+
arr.forEach((item) => {
|
|
117
|
+
_path.push("/" + item.pinyin);
|
|
118
|
+
item.path = _path.join("");
|
|
119
|
+
});
|
|
120
|
+
return arr;
|
|
121
|
+
}
|