chanjs 2.7.2 → 2.7.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/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 +2 -3
- 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/index.js
CHANGED
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
// 核心基础类导出
|
|
2
2
|
export { default as Controller } from "./base/Controller.js";
|
|
3
|
-
export { default as
|
|
3
|
+
export { default as Repository } from "./base/Repository.js";
|
|
4
4
|
export { default as DB } from "./base/Database.js";
|
|
5
5
|
export { Container } from "./base/Container.js";
|
|
6
6
|
export { Aop, aop } from "./base/Aop.js";
|
|
7
7
|
export { Event, event } from "./base/Event.js";
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
export * as
|
|
9
|
+
// 工具模块导出(按职责拆分,替代原 helper 大杂烩)
|
|
10
|
+
export * as storage from "./storage/index.js"; // 缓存/Redis/存储适配
|
|
11
|
+
export * as security from "./security/index.js"; // 关键词/XSS/签名/JWT/限流
|
|
12
|
+
export * as realtime from "./realtime/index.js"; // SSE/WebSocket
|
|
13
|
+
export * as loader from "./loader/index.js"; // 加载器
|
|
14
|
+
export * as response from "./response/index.js"; // 响应格式化
|
|
15
|
+
export * as utils from "./utils/index.js"; // 通用工具(时间/文件/HTML/IP/数据解析/树/过滤)
|
|
16
|
+
export * as helper from "./helper/index.js"; // 聚合导出层(从各子模块 re-export,业务侧兼容)
|
|
11
17
|
export * as middleware from "./middleware/index.js";
|
|
12
18
|
export * as config from "./config/index.js";
|
|
13
19
|
export * as common from "./common/index.js";
|
|
14
|
-
export * as extend from "./extend/index.js";
|
|
15
20
|
|
|
16
|
-
//
|
|
17
|
-
export { loadConfig, loaderSort, loadController
|
|
21
|
+
// 常用工具函数直接导出(顶层快捷访问)
|
|
22
|
+
export { loadConfig, loaderSort, loadController } from "./loader/index.js";
|
|
23
|
+
export { cache } from "./storage/index.js";
|
|
24
|
+
|
|
25
|
+
// 定时任务调度器
|
|
26
|
+
export { schedule, Schedule } from "./schedule/schedule.js";
|
|
27
|
+
|
|
28
|
+
// 实时通信:SSE + WebSocket
|
|
29
|
+
export { sse, SSEManager } from "./realtime/index.js";
|
|
30
|
+
export { websocket, WebSocketManager } from "./realtime/index.js";
|
|
18
31
|
|
|
19
32
|
// 路径配置
|
|
20
33
|
export { Paths } from "./config/paths.js";
|
package/loader/index.js
ADDED
|
@@ -27,26 +27,19 @@ export function loaderSort(modules = []) {
|
|
|
27
27
|
* @async
|
|
28
28
|
* @returns {Promise<Object>} 配置对象
|
|
29
29
|
* @description
|
|
30
|
-
* 从 config/index.js
|
|
31
|
-
*
|
|
30
|
+
* 从 config/index.js 加载配置
|
|
31
|
+
*
|
|
32
|
+
* 性能优化(P2 #21):
|
|
33
|
+
* 删除 LRU 缓存层,直接依赖 ESM 模块缓存
|
|
34
|
+
* - ESM import 同一文件返回缓存的模块实例(Node.js 内置机制)
|
|
35
|
+
* - 旧版 LRU 缓存有 5 分钟 TTL,配置变更后不生效,需要手动 clearConfigCache
|
|
36
|
+
* - 现在配置变更直接重启进程即可,符合生产实践
|
|
32
37
|
*/
|
|
33
38
|
export async function loadConfig() {
|
|
34
|
-
const cacheKey = 'chanjs:config';
|
|
35
|
-
|
|
36
|
-
if (Chan.cache && Chan.cache.has(cacheKey)) {
|
|
37
|
-
return Chan.cache.get(cacheKey);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
39
|
const configPath = path.join(Paths.configPath, "index.js");
|
|
41
40
|
try {
|
|
42
41
|
const module = await import(`file://${configPath}`);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (Chan.cache) {
|
|
46
|
-
Chan.cache.set(cacheKey, config, 5 * 60 * 1000);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return config;
|
|
42
|
+
return module.default || module;
|
|
50
43
|
} catch (error) {
|
|
51
44
|
console.error("[Config] 加载配置失败", error.message);
|
|
52
45
|
return {};
|
|
@@ -54,19 +47,23 @@ export async function loadConfig() {
|
|
|
54
47
|
}
|
|
55
48
|
|
|
56
49
|
/**
|
|
57
|
-
*
|
|
58
|
-
* @
|
|
59
|
-
*
|
|
60
|
-
* 下次调用 loadConfig 时会重新从文件加载
|
|
50
|
+
* 清除配置缓存(已废弃)
|
|
51
|
+
* @deprecated P2 #21 优化后,配置直接依赖 ESM 缓存,无需手动清理
|
|
52
|
+
* 保留函数仅为向后兼容,调用为空操作
|
|
61
53
|
* @example
|
|
62
|
-
* clearConfigCache();
|
|
54
|
+
* clearConfigCache(); // 空操作,无需调用
|
|
63
55
|
*/
|
|
64
56
|
export function clearConfigCache() {
|
|
65
|
-
|
|
66
|
-
Chan.cache.delete('chanjs:config');
|
|
67
|
-
}
|
|
57
|
+
// ESM 模块缓存由 Node.js 管理,无需手动清理
|
|
68
58
|
}
|
|
69
59
|
|
|
60
|
+
/**
|
|
61
|
+
* 已绑定方法标记(Symbol 幂等)
|
|
62
|
+
* 避免重复 loadController 时对同一方法多次 bind 形成嵌套
|
|
63
|
+
* @private
|
|
64
|
+
*/
|
|
65
|
+
const BOUND_SYMBOL = Symbol('chanjs:bound');
|
|
66
|
+
|
|
70
67
|
/**
|
|
71
68
|
* 加载指定模块的所有控制器
|
|
72
69
|
* @async
|
|
@@ -75,6 +72,12 @@ export function clearConfigCache() {
|
|
|
75
72
|
* @description
|
|
76
73
|
* 从 modules/{moduleName}/controller 目录加载所有 .js 文件
|
|
77
74
|
* 每个文件会被加载并绑定实例,然后以文件名作为键存储
|
|
75
|
+
*
|
|
76
|
+
* 性能优化(P2 #18):
|
|
77
|
+
* - 用 Symbol 标记已 bind 过的方法,避免重复 bind 形成嵌套
|
|
78
|
+
* - ESM 模块缓存导致同一实例可能被多次 loadController,原版每次都重新 bind
|
|
79
|
+
* - 现在首次 bind 后标记,后续调用直接跳过
|
|
80
|
+
*
|
|
78
81
|
* @example
|
|
79
82
|
* const controllers = await loadController('api');
|
|
80
83
|
* console.log(controllers.User); // User 控制器实例
|
|
@@ -98,8 +101,8 @@ export async function loadController(moduleName) {
|
|
|
98
101
|
try {
|
|
99
102
|
const module = await import(`file://${filePath}`);
|
|
100
103
|
let instance = module?.default || module;
|
|
101
|
-
|
|
102
|
-
//
|
|
104
|
+
|
|
105
|
+
// 绑定实例的所有方法(Symbol 幂等,避免重复 bind)
|
|
103
106
|
if (instance && typeof instance === 'object') {
|
|
104
107
|
const proto = Object.getPrototypeOf(instance);
|
|
105
108
|
if (proto) {
|
|
@@ -108,12 +111,23 @@ export async function loadController(moduleName) {
|
|
|
108
111
|
methodName !== "constructor" &&
|
|
109
112
|
typeof instance[methodName] === "function"
|
|
110
113
|
) {
|
|
111
|
-
|
|
114
|
+
const method = instance[methodName];
|
|
115
|
+
// 已 bind 过的方法直接跳过(避免重复 bind 嵌套)
|
|
116
|
+
if (method[BOUND_SYMBOL]) return;
|
|
117
|
+
// bind 创建新函数,标记后存回实例
|
|
118
|
+
const bound = method.bind(instance);
|
|
119
|
+
Object.defineProperty(bound, BOUND_SYMBOL, {
|
|
120
|
+
value: true,
|
|
121
|
+
enumerable: false,
|
|
122
|
+
writable: false,
|
|
123
|
+
configurable: false,
|
|
124
|
+
});
|
|
125
|
+
instance[methodName] = bound;
|
|
112
126
|
}
|
|
113
127
|
});
|
|
114
128
|
}
|
|
115
129
|
}
|
|
116
|
-
|
|
130
|
+
|
|
117
131
|
controller[name] = instance;
|
|
118
132
|
} catch (e) {
|
|
119
133
|
console.error(`加载控制器失败: ${filePath}`, e);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import zlib from 'zlib';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 响应压缩中间件(零依赖,基于 Node 原生 zlib)
|
|
5
|
+
* 改进点:
|
|
6
|
+
* 1. 支持 br / gzip / deflate,按 Accept-Encoding 优先级选择
|
|
7
|
+
* 2. 仅压缩可压缩文本类型(html/css/js/json/plain/svg+xml)
|
|
8
|
+
* 3. 跳过 SSE(text/event-stream)和 WebSocket 升级请求
|
|
9
|
+
* 4. 阈值 1KB,避免小包压缩负收益
|
|
10
|
+
* 5. 压缩失败自动回退原样输出,不影响请求
|
|
11
|
+
* 6. chunks 缓冲上限 10MB,超出后 bypass 直接放行不压缩,避免内存爆炸
|
|
12
|
+
* 7. 拦截 res.write/res.end 后在 finish/close 时恢复,避免泄漏
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// 压缩阈值:响应体 > 1KB 才压缩
|
|
16
|
+
const DEFAULT_THRESHOLD = 1024;
|
|
17
|
+
|
|
18
|
+
// chunks 缓冲上限:10MB,超出后 bypass 直接放行不压缩
|
|
19
|
+
const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
// 可压缩的 Content-Type 集合(精确匹配,charset 后缀在判断时已剥离)
|
|
22
|
+
const COMPRESSIBLE_TYPES = new Set([
|
|
23
|
+
'text/html',
|
|
24
|
+
'text/css',
|
|
25
|
+
'application/json',
|
|
26
|
+
'application/javascript',
|
|
27
|
+
'text/plain',
|
|
28
|
+
'image/svg+xml',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 根据 Accept-Encoding 选择最优编码
|
|
33
|
+
* 优先级:br > gzip > deflate
|
|
34
|
+
* @param {string} acceptEncoding - 请求头 Accept-Encoding 值
|
|
35
|
+
* @returns {'br'|'gzip'|'deflate'|null}
|
|
36
|
+
*/
|
|
37
|
+
function pickEncoding(acceptEncoding) {
|
|
38
|
+
if (!acceptEncoding) return null;
|
|
39
|
+
const enc = acceptEncoding.toLowerCase();
|
|
40
|
+
if (enc.includes('br')) return 'br';
|
|
41
|
+
if (enc.includes('gzip')) return 'gzip';
|
|
42
|
+
if (enc.includes('deflate')) return 'deflate';
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 使用 Node 原生 zlib 同步压缩 Buffer
|
|
48
|
+
* @param {Buffer} buf - 待压缩数据
|
|
49
|
+
* @param {'br'|'gzip'|'deflate'} encoding - 压缩编码
|
|
50
|
+
* @returns {Buffer} 压缩后的 Buffer
|
|
51
|
+
*/
|
|
52
|
+
function compressBuffer(buf, encoding) {
|
|
53
|
+
switch (encoding) {
|
|
54
|
+
case 'br': return zlib.brotliCompressSync(buf);
|
|
55
|
+
case 'gzip': return zlib.gzipSync(buf);
|
|
56
|
+
case 'deflate': return zlib.deflateSync(buf);
|
|
57
|
+
default: return buf;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 创建压缩中间件
|
|
63
|
+
* @param {Object} [options]
|
|
64
|
+
* @param {number} [options.threshold=1024] - 压缩阈值(字节),响应体小于该值不压缩
|
|
65
|
+
* @returns {Function} Express 中间件
|
|
66
|
+
*/
|
|
67
|
+
export function compress(options = {}) {
|
|
68
|
+
const threshold = options.threshold || DEFAULT_THRESHOLD;
|
|
69
|
+
|
|
70
|
+
return (req, res, next) => {
|
|
71
|
+
// WebSocket 升级请求直接跳过(不适用 HTTP 响应压缩)
|
|
72
|
+
if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') {
|
|
73
|
+
return next();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const encoding = pickEncoding(req.headers['accept-encoding'] || '');
|
|
77
|
+
// 客户端不支持任何压缩编码,直接放行
|
|
78
|
+
if (!encoding) return next();
|
|
79
|
+
|
|
80
|
+
// 缓存原方法(bind 避免 this 丢失)
|
|
81
|
+
const originalEnd = res.end.bind(res);
|
|
82
|
+
const originalWrite = res.write.bind(res);
|
|
83
|
+
const chunks = [];
|
|
84
|
+
let totalSize = 0;
|
|
85
|
+
let bypassed = false; // 超过缓冲上限后 bypass 标志
|
|
86
|
+
|
|
87
|
+
// 恢复 res.write/res.end 的函数:在 finish/close 时调用,避免拦截函数泄漏
|
|
88
|
+
const restore = () => {
|
|
89
|
+
res.write = originalWrite;
|
|
90
|
+
res.end = originalEnd;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// 拦截 res.write:收集 buffer 片段(流式输出场景)
|
|
94
|
+
res.write = (chunk, ...args) => {
|
|
95
|
+
if (bypassed) return originalWrite(chunk, ...args);
|
|
96
|
+
if (chunk) {
|
|
97
|
+
if (typeof chunk === 'string') {
|
|
98
|
+
chunk = Buffer.from(chunk, args[0] || 'utf8');
|
|
99
|
+
}
|
|
100
|
+
if (Buffer.isBuffer(chunk)) {
|
|
101
|
+
chunks.push(chunk);
|
|
102
|
+
totalSize += chunk.length;
|
|
103
|
+
// 缓冲超限:bypass 直接放行后续 write/end
|
|
104
|
+
if (totalSize > MAX_BUFFER_BYTES) {
|
|
105
|
+
console.warn(`[compress] 响应体超过 ${MAX_BUFFER_BYTES} 字节,bypass 直接放行不压缩`);
|
|
106
|
+
bypassed = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// 拦截 res.end:收集最后一片 buffer 后决定是否压缩
|
|
114
|
+
res.end = (chunk, encodingArg) => {
|
|
115
|
+
if (bypassed) {
|
|
116
|
+
// bypass 模式:直接走原 end,恢复后退出
|
|
117
|
+
restore();
|
|
118
|
+
if (chunk) return originalEnd(chunk, encodingArg);
|
|
119
|
+
return originalEnd();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// res.end 多种签名处理:end() / end(chunk) / end(chunk, encoding) / end(callback)
|
|
123
|
+
if (chunk) {
|
|
124
|
+
if (typeof chunk === 'string') {
|
|
125
|
+
const buf = Buffer.from(chunk, typeof encodingArg === 'string' ? encodingArg : 'utf8');
|
|
126
|
+
chunks.push(buf);
|
|
127
|
+
totalSize += buf.length;
|
|
128
|
+
} else if (Buffer.isBuffer(chunk)) {
|
|
129
|
+
chunks.push(chunk);
|
|
130
|
+
totalSize += chunk.length;
|
|
131
|
+
}
|
|
132
|
+
// function 类型为 end(callback) 签名,不收集
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const body = chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
136
|
+
|
|
137
|
+
// 取 Content-Type 主类型(去掉 charset 等参数)
|
|
138
|
+
const contentType = (res.getHeader('Content-Type') || '').toString().split(';')[0].trim().toLowerCase();
|
|
139
|
+
|
|
140
|
+
// 不满足压缩条件:原样输出
|
|
141
|
+
// - 响应体小于阈值
|
|
142
|
+
// - 非可压缩类型
|
|
143
|
+
// - SSE(text/event-stream)需要实时推送,不压缩
|
|
144
|
+
if (
|
|
145
|
+
body.length < threshold ||
|
|
146
|
+
!COMPRESSIBLE_TYPES.has(contentType) ||
|
|
147
|
+
contentType === 'text/event-stream'
|
|
148
|
+
) {
|
|
149
|
+
if (!res.headersSent) {
|
|
150
|
+
res.setHeader('Content-Length', body.length);
|
|
151
|
+
}
|
|
152
|
+
restore();
|
|
153
|
+
originalEnd(body);
|
|
154
|
+
return res;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 执行压缩
|
|
158
|
+
try {
|
|
159
|
+
const compressed = compressBuffer(body, encoding);
|
|
160
|
+
// 移除原 Content-Length,避免与压缩后大小不匹配
|
|
161
|
+
res.removeHeader('Content-Length');
|
|
162
|
+
res.setHeader('Content-Encoding', encoding);
|
|
163
|
+
res.setHeader('Vary', 'Accept-Encoding');
|
|
164
|
+
res.setHeader('Content-Length', compressed.length);
|
|
165
|
+
restore();
|
|
166
|
+
originalEnd(compressed);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
// 压缩失败:回退原样输出,避免请求中断
|
|
169
|
+
console.error('[compress] 压缩失败,回退原样输出:', e.message);
|
|
170
|
+
if (!res.headersSent) {
|
|
171
|
+
res.setHeader('Content-Length', body.length);
|
|
172
|
+
}
|
|
173
|
+
restore();
|
|
174
|
+
originalEnd(body);
|
|
175
|
+
}
|
|
176
|
+
return res;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// 响应结束后恢复,防止 res.write/res.end 拦截泄漏到后续中间件复用
|
|
180
|
+
res.on('finish', restore);
|
|
181
|
+
res.on('close', restore);
|
|
182
|
+
|
|
183
|
+
next();
|
|
184
|
+
};
|
|
185
|
+
}
|
package/middleware/cors.js
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
|
-
import cors from "cors";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* CORS 中间件配置
|
|
5
|
-
* 配置跨域资源共享
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* 设置 CORS 中间件
|
|
10
|
-
* @param {Object} app - Express 应用实例
|
|
11
|
-
* @param {Object} _cors - CORS 配置选项
|
|
12
|
-
* @description
|
|
13
|
-
* 为 Express 应用配置 CORS 中间件
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
import cors from "cors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CORS 中间件配置
|
|
5
|
+
* 配置跨域资源共享
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 设置 CORS 中间件
|
|
10
|
+
* @param {Object} app - Express 应用实例
|
|
11
|
+
* @param {Object} [_cors={}] - CORS 配置选项
|
|
12
|
+
* @description
|
|
13
|
+
* 为 Express 应用配置 CORS 中间件
|
|
14
|
+
*
|
|
15
|
+
* 安全改进:
|
|
16
|
+
* - 默认拒绝跨域(origin=false),避免未配置时全开放
|
|
17
|
+
* - 默认仅允许 GET/POST 方法
|
|
18
|
+
* - credentials 默认 false,避免 Cookie 跨域携带
|
|
19
|
+
* - 业务侧需显式开放时再传入完整 _cors 配置
|
|
20
|
+
* @example
|
|
21
|
+
* Cors(app, {
|
|
22
|
+
* origin: 'https://example.com',
|
|
23
|
+
* methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
|
24
|
+
* credentials: true
|
|
25
|
+
* });
|
|
26
|
+
*/
|
|
27
|
+
export const Cors = (app, _cors = {}) => {
|
|
28
|
+
const safeOptions = {
|
|
29
|
+
origin: _cors.origin || false,
|
|
30
|
+
methods: _cors.methods || ['GET', 'POST'],
|
|
31
|
+
allowedHeaders: _cors.allowedHeaders || ['Content-Type', 'Authorization'],
|
|
32
|
+
credentials: !!_cors.credentials,
|
|
33
|
+
maxAge: _cors.maxAge || 600,
|
|
34
|
+
};
|
|
35
|
+
app.use(cors(safeOptions));
|
|
36
|
+
};
|
package/middleware/header.js
CHANGED
|
@@ -6,21 +6,16 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* 设置响应头中间件
|
|
8
8
|
* @param {Object} app - Express 应用实例
|
|
9
|
-
* @param {Object} options -
|
|
10
|
-
* @param {string} options.APP_NAME - 应用名称
|
|
11
|
-
* @param {string} options.APP_VERSION - 应用版本
|
|
9
|
+
* @param {Object} options - 配置选项(保留参数兼容旧调用,不再使用 APP_VERSION)
|
|
12
10
|
* @description
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* 为所有响应添加技术栈标识响应头
|
|
12
|
+
* 保留 ChanCMS 标识,移除版本号防止信息泄露
|
|
15
13
|
* @example
|
|
16
|
-
* setHeader(app, { APP_NAME: 'MyApp'
|
|
14
|
+
* setHeader(app, { APP_NAME: 'MyApp' });
|
|
17
15
|
*/
|
|
18
|
-
export let setHeader = (app
|
|
16
|
+
export let setHeader = (app) => {
|
|
19
17
|
app.use((req, res, next) => {
|
|
20
|
-
res.setHeader("Create-By", "Chanjs");
|
|
21
18
|
res.setHeader("X-Powered-By", "ChanCMS");
|
|
22
|
-
res.setHeader("ChanCMS", APP_VERSION);
|
|
23
|
-
res.setHeader("Server", APP_NAME);
|
|
24
19
|
next();
|
|
25
20
|
});
|
|
26
21
|
};
|
package/middleware/index.js
CHANGED
package/middleware/log.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import morgan from "morgan";
|
|
2
|
-
import { getIp } from "../
|
|
2
|
+
import { getIp } from "../utils/ip.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* morgan 输出格式白名单
|
|
6
|
+
* 仅允许使用以下格式,避免任意 level 注入导致 morgan 内部异常
|
|
7
|
+
*/
|
|
8
|
+
const ALLOWED_FORMATS = new Set([
|
|
9
|
+
'chancms',
|
|
10
|
+
'combined',
|
|
11
|
+
'common',
|
|
12
|
+
'dev',
|
|
13
|
+
'short',
|
|
14
|
+
'tiny',
|
|
15
|
+
]);
|
|
3
16
|
|
|
4
17
|
// 自定义 IP 令牌
|
|
5
18
|
morgan.token("ip", (req, res) => {
|
|
@@ -35,7 +48,18 @@ morgan.format("chancms", (tokens, req, res) => {
|
|
|
35
48
|
].join(" ");
|
|
36
49
|
});
|
|
37
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 注册 morgan 日志中间件
|
|
53
|
+
* @param {Object} app - Express 应用实例
|
|
54
|
+
* @param {Object} [logger] - 日志配置
|
|
55
|
+
* @param {string} [logger.level='chancms'] - 日志格式,仅允许白名单内的格式
|
|
56
|
+
* @description
|
|
57
|
+
* 安全改进:
|
|
58
|
+
* - level 必须在白名单内,未配置或非法值默认 'chancms'
|
|
59
|
+
* - 避免任意字符串传入 morgan 导致格式解析异常
|
|
60
|
+
*/
|
|
38
61
|
export const log = (app, logger) => {
|
|
39
|
-
const level = logger?.level
|
|
40
|
-
|
|
62
|
+
const level = logger?.level;
|
|
63
|
+
const format = ALLOWED_FORMATS.has(level) ? level : 'chancms';
|
|
64
|
+
app.use(morgan(format));
|
|
41
65
|
};
|
package/middleware/setBody.js
CHANGED
|
@@ -12,13 +12,21 @@ import express from "express";
|
|
|
12
12
|
* @description
|
|
13
13
|
* 为 Express 应用配置请求体解析中间件
|
|
14
14
|
* 支持 JSON、URL 编码和 XML 格式的请求体
|
|
15
|
+
*
|
|
16
|
+
* 安全改进:
|
|
17
|
+
* - urlencoded 也设置 limit 和 parameterLimit,防止参数爆炸攻击
|
|
18
|
+
* - extended: true 支持嵌套对象(与 qs 行为对齐)
|
|
15
19
|
* @example
|
|
16
20
|
* setBody(app, '10mb');
|
|
17
21
|
*/
|
|
18
22
|
let setBody = function (app, JSON_LIMIT) {
|
|
19
23
|
app.use(express.raw({ type: "application/xml", limit: JSON_LIMIT }));
|
|
20
24
|
app.use(express.json({ limit: JSON_LIMIT }));
|
|
21
|
-
app.use(express.urlencoded({
|
|
25
|
+
app.use(express.urlencoded({
|
|
26
|
+
extended: true,
|
|
27
|
+
limit: JSON_LIMIT,
|
|
28
|
+
parameterLimit: 100,
|
|
29
|
+
}));
|
|
22
30
|
};
|
|
23
31
|
|
|
24
32
|
export { setBody };
|
package/middleware/static.js
CHANGED
|
@@ -25,7 +25,8 @@ export const setStatic = async function (app, statics) {
|
|
|
25
25
|
if (statics.length > 0) {
|
|
26
26
|
statics.forEach((item) => {
|
|
27
27
|
const { prefix, dir, maxAge } = item;
|
|
28
|
-
|
|
28
|
+
// dotfile: 'deny' 显式拒绝访问点文件(.env / .git/config 等),避免敏感配置泄漏
|
|
29
|
+
app.use(prefix, express.static(dir, { maxAge: maxAge || 0, dotfile: 'deny' }));
|
|
29
30
|
});
|
|
30
31
|
}
|
|
31
32
|
};
|
package/middleware/template.js
CHANGED
|
@@ -1,10 +1,135 @@
|
|
|
1
|
-
import "
|
|
1
|
+
import template from "art-template";
|
|
2
|
+
import dayjs from "dayjs";
|
|
3
|
+
import relativeTime from "dayjs/plugin/relativeTime.js";
|
|
4
|
+
import "dayjs/locale/zh-cn.js";
|
|
5
|
+
import { createRequire } from 'module';
|
|
2
6
|
import { importjs } from "../global/import.js";
|
|
7
|
+
import { filterXSS } from "../security/xss-filter.js";
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { marked } = require('marked');
|
|
11
|
+
|
|
12
|
+
// ============================================================
|
|
13
|
+
// art-template 过滤器注册(原 extend/art-template.js,合并至此)
|
|
14
|
+
// ============================================================
|
|
15
|
+
dayjs.extend(relativeTime);
|
|
16
|
+
dayjs.locale('zh-cn');
|
|
17
|
+
|
|
18
|
+
// 禁用原生模板引擎,防止模板直接调用 nodejs 语法
|
|
19
|
+
template.defaults.native = false;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 日期格式化过滤器
|
|
23
|
+
* @param {Date|string|number} date - 日期对象、日期字符串或时间戳
|
|
24
|
+
* @param {string} format - 日期格式字符串
|
|
25
|
+
* @returns {string} 格式化后的日期字符串
|
|
26
|
+
*/
|
|
27
|
+
template.defaults.imports.dateFormat = function (date, format) {
|
|
28
|
+
if (!date) return "";
|
|
29
|
+
if (date instanceof Date || typeof date === "string" || typeof date === "number") {
|
|
30
|
+
date = dayjs(date);
|
|
31
|
+
} else {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
return date.format(format);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 相对时间过滤器(如"刚刚"、"5分钟前"、"3小时前")
|
|
39
|
+
* @param {Date|string|number} date - 日期对象、日期字符串或时间戳
|
|
40
|
+
* @returns {string} 相对时间字符串
|
|
41
|
+
*/
|
|
42
|
+
template.defaults.imports.timeAgo = function (date) {
|
|
43
|
+
if (!date) return "";
|
|
44
|
+
const d = dayjs(date);
|
|
45
|
+
if (!d.isValid()) return "";
|
|
46
|
+
return d.fromNow();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 字符串截断过滤器
|
|
51
|
+
* @param {string} str - 原始字符串
|
|
52
|
+
* @param {number} length - 截断长度,默认10
|
|
53
|
+
* @returns {string} 截断后的字符串
|
|
54
|
+
*/
|
|
55
|
+
template.defaults.imports.truncate = (str, length = 10) => {
|
|
56
|
+
return str.length > length ? str.slice(0, length) + "..." : str;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 安全的 JSON 序列化过滤器(模板调试用)
|
|
61
|
+
* @param {Object} obj - 要序列化的对象
|
|
62
|
+
* @param {Array} keys - 可选,只返回指定的字段
|
|
63
|
+
* @returns {string} JSON字符串
|
|
64
|
+
* @description
|
|
65
|
+
* 安全改进:用 Object.prototype.hasOwnProperty.call 防止原型污染
|
|
66
|
+
*/
|
|
67
|
+
template.defaults.imports.safeStringify = (obj, keys) => {
|
|
68
|
+
if (!obj) return 'null';
|
|
69
|
+
if (keys && Array.isArray(keys) && keys.length > 0) {
|
|
70
|
+
const filteredObj = {};
|
|
71
|
+
keys.forEach(key => {
|
|
72
|
+
// 用原型上的方法调用,避免 obj 重写 hasOwnProperty 导致原型污染
|
|
73
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
74
|
+
filteredObj[key] = obj[key];
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return JSON.stringify(filteredObj, null, 2);
|
|
78
|
+
}
|
|
79
|
+
return JSON.stringify(obj, null, 2);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 获取对象所有 key 的过滤器(调试查看数据结构)
|
|
84
|
+
* @param {Object} obj - 要获取 key 的对象
|
|
85
|
+
* @param {string} separator - 分隔符,默认换行
|
|
86
|
+
* @returns {string} 所有 key 的字符串
|
|
87
|
+
*/
|
|
88
|
+
template.defaults.imports.objKeys = (obj, separator) => {
|
|
89
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
90
|
+
const sep = separator !== undefined ? separator : '\n';
|
|
91
|
+
return Object.keys(obj).join(sep);
|
|
92
|
+
};
|
|
3
93
|
|
|
4
94
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
95
|
+
* Markdown 渲染过滤器
|
|
96
|
+
* 自动检测内容是否为 Markdown 格式,渲染为 HTML
|
|
97
|
+
* @param {string} content - 文章内容
|
|
98
|
+
* @param {string} editorType - 编辑器类型,'md' 触发 Markdown 渲染
|
|
99
|
+
* @param {number} allowScript - 是否允许 script,1 允许
|
|
100
|
+
* @returns {string} 渲染后的 HTML
|
|
101
|
+
* @description
|
|
102
|
+
* 安全改进:
|
|
103
|
+
* - 渲染后通过 filterXSS 过滤危险标签(script、onerror、onload 等)
|
|
104
|
+
* - 仅在 allowScript=1 时跳过过滤(受信任内容)
|
|
7
105
|
*/
|
|
106
|
+
template.defaults.imports.renderContent = (content, editorType = 'rich', allowScript = 0) => {
|
|
107
|
+
if (!content || typeof content !== 'string') return content || '';
|
|
108
|
+
|
|
109
|
+
let html = content;
|
|
110
|
+
// Markdown 转换
|
|
111
|
+
if (editorType === 'md') {
|
|
112
|
+
try {
|
|
113
|
+
html = marked.parse(content);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error('[renderContent] Markdown 渲染失败:', err.message);
|
|
116
|
+
html = content;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// 非 allowScript=1 时通过 filterXSS 完整过滤,移除 script/onevent 等危险内容
|
|
120
|
+
if (Number(allowScript) !== 1) {
|
|
121
|
+
try {
|
|
122
|
+
html = filterXSS(html);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error('[renderContent] XSS 过滤失败:', err.message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return html;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ============================================================
|
|
131
|
+
// 模板引擎中间件配置
|
|
132
|
+
// ============================================================
|
|
8
133
|
|
|
9
134
|
/**
|
|
10
135
|
* 设置模板引擎中间件
|
|
@@ -33,5 +158,15 @@ export let setTemplate = (app, config) => {
|
|
|
33
158
|
});
|
|
34
159
|
app.set("view engine", "html");
|
|
35
160
|
app.set("views", all);
|
|
36
|
-
|
|
161
|
+
// 引擎加载容错:importjs 抛错时降级为内置默认 engineFn,避免启动失败
|
|
162
|
+
try {
|
|
163
|
+
app.engine(".html", importjs("express-art-template"));
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error('[setTemplate] 加载 express-art-template 失败,降级默认引擎:', err.message);
|
|
166
|
+
const engineFn = (path, options, callback) => {
|
|
167
|
+
// 兜底:直接返回模板路径,避免渲染崩溃
|
|
168
|
+
callback(null, `<pre>Template engine fallback: ${path}</pre>`);
|
|
169
|
+
};
|
|
170
|
+
app.engine(".html", engineFn);
|
|
171
|
+
}
|
|
37
172
|
};
|