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.
Files changed (67) hide show
  1. package/App.js +232 -16
  2. package/base/Aop.js +20 -3
  3. package/base/Container.js +80 -3
  4. package/base/Controller.js +38 -9
  5. package/base/Database.js +50 -0
  6. package/base/Event.js +12 -0
  7. package/base/{Service.js → Repository.js} +644 -539
  8. package/common/api.js +18 -8
  9. package/common/code.js +25 -15
  10. package/common/email.js +98 -17
  11. package/common/index.js +1 -1
  12. package/config/code.js +138 -82
  13. package/global/index.js +1 -1
  14. package/helper/index.js +43 -41
  15. package/index.js +19 -6
  16. package/loader/index.js +6 -0
  17. package/{helper → loader}/loader.js +41 -27
  18. package/middleware/compress.js +185 -0
  19. package/middleware/cors.js +36 -24
  20. package/middleware/header.js +5 -10
  21. package/middleware/index.js +1 -0
  22. package/middleware/log.js +27 -3
  23. package/middleware/setBody.js +9 -1
  24. package/middleware/static.js +2 -1
  25. package/middleware/template.js +139 -4
  26. package/middleware/waf.js +136 -76
  27. package/package.json +4 -4
  28. package/realtime/index.js +7 -0
  29. package/realtime/sse.js +424 -0
  30. package/realtime/websocket.js +540 -0
  31. package/response/index.js +12 -0
  32. package/response/response.js +258 -0
  33. package/schedule/index.js +6 -0
  34. package/schedule/schedule.js +491 -0
  35. package/{helper → security}/checker.js +23 -8
  36. package/security/index.js +14 -0
  37. package/{helper → security}/jwt.js +175 -107
  38. package/security/keywords.js +179 -0
  39. package/security/rate-limit.js +105 -0
  40. package/security/sign.js +210 -0
  41. package/security/xss-filter.js +63 -0
  42. package/storage/cache.js +258 -0
  43. package/storage/index.js +9 -0
  44. package/storage/redis.js +258 -0
  45. package/storage/store.js +266 -0
  46. package/{helper → utils}/file.js +106 -15
  47. package/{helper → utils}/filter.js +2 -1
  48. package/{helper → utils}/html.js +19 -1
  49. package/utils/index.js +34 -0
  50. package/{helper → utils}/ip.js +25 -16
  51. package/utils/request.js +172 -0
  52. package/{helper → utils}/time.js +1 -1
  53. package/utils/tree.js +121 -0
  54. package/common/category.js +0 -22
  55. package/common/sms.js +0 -104
  56. package/extend/art-template.js +0 -129
  57. package/extend/index.js +0 -6
  58. package/global/global.js +0 -63
  59. package/helper/cache.js +0 -187
  60. package/helper/keywords.js +0 -132
  61. package/helper/rate-limit.js +0 -116
  62. package/helper/request.js +0 -47
  63. package/helper/response.js +0 -180
  64. package/helper/sign.js +0 -96
  65. package/helper/tree.js +0 -77
  66. package/helper/xss-filter.js +0 -42
  67. /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 Service } from "./base/Service.js";
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 helper from "./helper/index.js";
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 ,cache} from "./helper/index.js";
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";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 加载器模块 - 配置加载/控制器加载/排序
3
+ * - loadConfig: 依赖 ESM 模块缓存(无 LRU,避免配置更新问题)
4
+ * - loadController: Symbol 标记幂等绑定
5
+ */
6
+ export { loaderSort, loadConfig, clearConfigCache, loadController } from "./loader.js";
@@ -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
- const config = module.default || module;
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
- * @description
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
- if (Chan.cache) {
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
- instance[methodName] = instance[methodName].bind(instance);
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
+ }
@@ -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
- * 支持自定义 CORS 配置选项
15
- * @example
16
- * Cors(app, {
17
- * origin: '*',
18
- * methods: ['GET', 'POST'],
19
- * allowedHeaders: ['Content-Type']
20
- * });
21
- */
22
- export const Cors = (app, _cors) => {
23
- app.use(cors(_cors));
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
+ };
@@ -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
- * 包含 Create-By、X-Powered-By、ChanCMS、Server 等头
11
+ * 为所有响应添加技术栈标识响应头
12
+ * 保留 ChanCMS 标识,移除版本号防止信息泄露
15
13
  * @example
16
- * setHeader(app, { APP_NAME: 'MyApp', APP_VERSION: '1.0.0' });
14
+ * setHeader(app, { APP_NAME: 'MyApp' });
17
15
  */
18
- export let setHeader = (app, { APP_NAME, APP_VERSION }) => {
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
  };
@@ -11,4 +11,5 @@ export { setHeader } from "./header.js";
11
11
  export { setTemplate } from "./template.js";
12
12
  export { Cors } from "./cors.js";
13
13
  export { waf } from "./waf.js";
14
+ export { compress } from "./compress.js";
14
15
  export { log } from "./log.js";
package/middleware/log.js CHANGED
@@ -1,5 +1,18 @@
1
1
  import morgan from "morgan";
2
- import { getIp } from "../helper/ip.js";
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 || "chancms";
40
- app.use(morgan(level === "chancms" ? "chancms" : level));
62
+ const level = logger?.level;
63
+ const format = ALLOWED_FORMATS.has(level) ? level : 'chancms';
64
+ app.use(morgan(format));
41
65
  };
@@ -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({ extended: false }));
25
+ app.use(express.urlencoded({
26
+ extended: true,
27
+ limit: JSON_LIMIT,
28
+ parameterLimit: 100,
29
+ }));
22
30
  };
23
31
 
24
32
  export { setBody };
@@ -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
- app.use(prefix, express.static(dir, { maxAge: maxAge || 0 }));
28
+ // dotfile: 'deny' 显式拒绝访问点文件(.env / .git/config 等),避免敏感配置泄漏
29
+ app.use(prefix, express.static(dir, { maxAge: maxAge || 0, dotfile: 'deny' }));
29
30
  });
30
31
  }
31
32
  };
@@ -1,10 +1,135 @@
1
- import "../extend/index.js";
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
- * 配置 HTML 模板渲染
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
- app.engine(".html", importjs("express-art-template"));
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
  };