chanjs 2.7.8 → 2.7.11

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 (41) hide show
  1. package/README.md +261 -363
  2. package/config/index.js +4 -2
  3. package/core/App.js +35 -0
  4. package/core/Container.js +56 -29
  5. package/core/Database.js +58 -8
  6. package/core/EventBus.js +88 -0
  7. package/core/Lang.js +56 -0
  8. package/core/Repository.js +34 -2
  9. package/core/Task.js +87 -0
  10. package/core/errors.js +0 -5
  11. package/doc/00-README.md +208 -0
  12. package/doc/01-/346/240/270/345/277/203/347/261/273Controller-Service-Repository.md +432 -0
  13. package/doc/02-/345/223/215/345/272/224/344/270/216/351/224/231/350/257/257.md +255 -0
  14. package/doc/03-/345/256/211/345/205/250/346/250/241/345/235/227.md +264 -0
  15. package/doc/04-/345/255/230/345/202/250/344/270/216/347/274/223/345/255/230.md +157 -0
  16. package/doc/05-/345/267/245/345/205/267/344/270/216/346/240/241/351/252/214.md +309 -0
  17. package/doc/06-/345/272/224/347/224/250/347/224/237/345/221/275/345/221/250/346/234/237.md +207 -0
  18. package/doc/07-/344/272/213/344/273/266/347/263/273/347/273/237EventBus.md +324 -0
  19. package/doc/08-/345/256/232/346/227/266/344/273/273/345/212/241Task.md +262 -0
  20. package/doc/09-/345/233/275/351/231/205/345/214/226Lang.md +220 -0
  21. package/index.js +30 -1
  22. package/middleware/log.js +48 -31
  23. package/middleware/waf.js +22 -90
  24. package/package.json +20 -2
  25. package/response/code.js +0 -12
  26. package/response/response.js +8 -2
  27. package/security/checker.js +14 -7
  28. package/security/keywords.js +2 -3
  29. package/utils/logger.js +60 -91
  30. package/utils/pages.js +13 -12
  31. package/utils/signal.js +21 -2
  32. package/USAGE.md +0 -533
  33. package/doc/Cache.md +0 -333
  34. package/doc/Common.md +0 -638
  35. package/doc/Controller.md +0 -223
  36. package/doc/Help.md +0 -390
  37. package/doc/QuickStart.md +0 -116
  38. package/doc/Repository.md +0 -560
  39. package/doc/Service.md +0 -240
  40. package/publish.bat +0 -4
  41. package/todo.md +0 -1
@@ -0,0 +1,220 @@
1
+ # 国际化 i18n(Lang)
2
+
3
+ > 基于 `i18next` 的多语言支持,启动时扫描 `lang/` 目录加载全部 JSON 语言包,运行时 O(1) 查词。
4
+ > 对外通过 `import { initLang } from "chanjs"` 或 `this.app.lang`(Chan 应用实例已内置)使用。
5
+
6
+ ---
7
+
8
+ ## 一、设计理念
9
+
10
+ | 决策 | 说明 |
11
+ |------|------|
12
+ | 成熟库 | 采用 `i18next`,复用其插值、复数、命名空间等成熟能力,不重复造轮子 |
13
+ | 启动加载 | 启动时扫描 `lang/` 目录,把全部语言包读进内存,运行时零 I/O |
14
+ | 全局单例 | `initLang` 返回的就是 i18next 单例,`this.app.lang` 指向同一实例 |
15
+ | 后端不转义 | `interpolation.escapeValue: false`,JSON 输出 / 邮件等后端场景不自动转义 |
16
+
17
+ ---
18
+
19
+ ## 二、语言包目录结构
20
+
21
+ 在项目**根目录**下建 `lang/`,每个语言一个子目录,每个命名空间一个 JSON 文件:
22
+
23
+ ```
24
+ lang/
25
+ ├── zh-CN/
26
+ │ ├── common.json # 默认命名空间
27
+ │ └── user.json # 也可按模块拆命名空间
28
+ ├── en/
29
+ │ ├── common.json
30
+ │ └── user.json
31
+ └── ja/
32
+ └── common.json
33
+ ```
34
+
35
+ `lang/zh-CN/common.json` 示例:
36
+
37
+ ```json
38
+ {
39
+ "user": {
40
+ "welcome": "欢迎,{{name}}",
41
+ "login": "登录",
42
+ "logout": "退出"
43
+ },
44
+ "order": {
45
+ "created": "订单创建成功,编号 {{id}}"
46
+ }
47
+ }
48
+ ```
49
+
50
+ > 语言目录名需匹配 `^[a-zA-Z_-]+$`(如 `zh-CN`、`en`、`ja`)。命名空间 = JSON 文件名(如 `common`)。
51
+
52
+ ---
53
+
54
+ ## 三、初始化 initLang
55
+
56
+ ### 应用实例内置(推荐)
57
+
58
+ `Chan` 应用启动时会自动调用 `initLang`,并把结果挂到 `this.app.lang`:
59
+
60
+ ```javascript
61
+ // app/app.js
62
+ import Chan from "chanjs";
63
+
64
+ const chan = new Chan();
65
+ await chan.start(); // 内部已初始化 i18n,默认语言来自 config
66
+ ```
67
+
68
+ 启动后业务代码直接用 `this.app.lang`:
69
+
70
+ ```javascript
71
+ const msg = this.app.lang.t("user.welcome", { name: "张三" }); // → "欢迎,张三"
72
+ ```
73
+
74
+ ### 手动初始化(中间件 / 独立脚本)
75
+
76
+ ```javascript
77
+ import { initLang } from "chanjs";
78
+
79
+ const i18n = await initLang("zh-CN"); // 返回 i18next 实例
80
+ i18n.t("user.welcome", { name: "张三" }); // → "欢迎,张三"
81
+ ```
82
+
83
+ | 参数 | 类型 | 说明 |
84
+ |------|------|------|
85
+ | `locale` | `string` | 默认语言,默认 `"zh-CN"` |
86
+ | **返回** | `Promise<i18next>` | i18next 实例 |
87
+
88
+ ---
89
+
90
+ ## 四、翻译 `t(key, opts?)`
91
+
92
+ ### 4.1 基础查词
93
+
94
+ ```javascript
95
+ this.app.lang.t("user.login"); // → "登录"
96
+ ```
97
+
98
+ ### 4.2 插值(`{{变量}}`)
99
+
100
+ ```javascript
101
+ this.app.lang.t("user.welcome", { name: "张三" }); // → "欢迎,张三"
102
+ ```
103
+
104
+ ### 4.3 复数(i18next 原生支持)
105
+
106
+ 语言包:
107
+
108
+ ```json
109
+ { "item": {
110
+ "one": "有 {{count}} 个商品",
111
+ "other": "有 {{count}} 个商品"
112
+ } }
113
+ ```
114
+
115
+ ```javascript
116
+ this.app.lang.t("item", { count: 1 }); // → "有 1 个商品"
117
+ this.app.lang.t("item", { count: 5 }); // → "有 5 个商品"
118
+ ```
119
+
120
+ ### 4.4 指定命名空间
121
+
122
+ 语言包拆了多个命名空间时,用 `ns` 指定:
123
+
124
+ ```javascript
125
+ // lang/user.json: { "title": "用户中心" }
126
+ this.app.lang.t("title", { ns: "user" }); // → "用户中心"
127
+ ```
128
+
129
+ > 未指定 `ns` 时默认用 `common` 命名空间。
130
+
131
+ ---
132
+
133
+ ## 五、动态切换语言 changeLanguage
134
+
135
+ 业务运行时(登录后 / 用户偏好 / 请求头)可随时切换:
136
+
137
+ ```javascript
138
+ // 切换语言并等待资源生效
139
+ await this.app.lang.changeLanguage("en");
140
+
141
+ // 切换后立即按新语言取词
142
+ const msg = this.app.lang.t("user.welcome", { name: "Zhang San" }); // 英文包
143
+ ```
144
+
145
+ ### 按请求动态切换(多语言站点)
146
+
147
+ ```javascript
148
+ // app/middleware/i18n.js —— 每个请求按 Accept-Language 切换
149
+ import { event } from "chanjs";
150
+
151
+ export function i18nMw(app) {
152
+ app.use(async (req, res, next) => {
153
+ const lang = req.headers["accept-language"]?.split(",")[0] || "zh-CN";
154
+ await app.lang.changeLanguage(lang);
155
+ next();
156
+ });
157
+ }
158
+ ```
159
+
160
+ > 注意:`changeLanguage` 是异步的,切换后记得 `await` 再取词。
161
+
162
+ ---
163
+
164
+ ## 六、使用场景
165
+
166
+ ### 场景一:接口返回多语言文案
167
+
168
+ ```javascript
169
+ // Controller 里
170
+ async greet(req, res) {
171
+ const name = req.query.name || "朋友";
172
+ return this.success({
173
+ data: {
174
+ msg: this.app.lang.t("user.welcome", { name }),
175
+ },
176
+ });
177
+ }
178
+ ```
179
+
180
+ ### 场景二:邮件 / 短信模板
181
+
182
+ ```javascript
183
+ const subject = this.app.lang.t("email.subject", { ns: "mail" });
184
+ const body = this.app.lang.t("email.reset_body", { name: user.name, link });
185
+ await sendMail(user.email, subject, body);
186
+ ```
187
+
188
+ ### 场景三:错误提示本地化
189
+
190
+ ```javascript
191
+ const KeyedError = new AppError(this.app.lang.t("error.order_not_found"));
192
+ throw KeyedError; // → 按当前语言给出错误提示
193
+ ```
194
+
195
+ ---
196
+
197
+ ## 七、注意事项
198
+
199
+ ### 1. 语言包必须存在,否则回退 zh-CN
200
+
201
+ `fallbackLng` 固定为 `zh-CN`。某语言缺词时自动回退到中文包,不会报错。
202
+
203
+ ### 2. `escapeValue: false`(后端不转义)
204
+
205
+ 这是后端场景特意关闭的。若用于**前端渲染用户输入**,请自行 `XSS` 过滤(见 `filterXSS`),不要直接拼接。
206
+
207
+ ### 3. 单文件解析失败不阻断
208
+
209
+ 某个语言包的 JSON 解析失败会被跳过,不影响其它语言和应用启动。
210
+
211
+ ### 4. 与 `changeLanguage` 配合 requestId
212
+
213
+ `changeLanguage` 是异步切换,多语言站点建议在请求期间保持语言一致,避免并发切换串语言。
214
+
215
+ ### 5. 新增语言步骤
216
+
217
+ 1. 在 `lang/` 下建语言目录(如 `lang/fr/`)
218
+ 2. 放入 `common.json`(及需要的命名空间文件)
219
+ 3. 重启应用(启动时扫描加载)
220
+ 4. 用 `changeLanguage("fr")` 切换后按 `fr` 渲染
package/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * import { Controller, Repository, Service, cache, Paths, loader, utils, getApp } from "chanjs";
11
11
  * import { validate, validateAll } from "chanjs"; // zod 校验中间件
12
12
  * import { AppError, NotFoundError, ValidationError } from "chanjs"; // 错误类
13
+ * import { event, EventBus, Task, initLang } from "chanjs"; // 基础设施
13
14
  *
14
15
  * 多实例:
15
16
  * 每个 Chan 实例持有独立 config/db/paths/dbManager(注册表管理),
@@ -22,6 +23,11 @@ export { default as Controller } from "./core/Controller.js";
22
23
  export { default as Repository } from "./core/Repository.js";
23
24
  export { Service } from "./core/Service.js";
24
25
 
26
+ // ===================== 基础设施(事件总线 / 定时任务 / i18n)=====================
27
+ export { EventBus, event } from "./core/EventBus.js";
28
+ export { default as Task } from "./core/Task.js";
29
+ export { initLang } from "./core/Lang.js";
30
+
25
31
  // ===================== 错误体系 =====================
26
32
  export {
27
33
  AppError, AuthError, TokenExpiredError, ForbiddenError,
@@ -33,6 +39,29 @@ export {
33
39
  // ===================== 统一响应工具(纯函数,可复用于接口 / 定时任务 / RPC)=====================
34
40
  export { success, fail, routeNotFound, serializeError, buildErrorHtml, respondError } from "./response/index.js";
35
41
 
42
+ // ===================== 业务状态码 =====================
43
+ export {
44
+ CODE,
45
+ getCodeMsg,
46
+ CODE_OK,
47
+ CODE_AUTH_FAILED,
48
+ CODE_TOKEN_EXPIRED,
49
+ CODE_FORBIDDEN,
50
+ CODE_NOT_FOUND,
51
+ CODE_CONFLICT,
52
+ CODE_PARAM_INVALID,
53
+ CODE_PARAM_MISSING,
54
+ CODE_BUSINESS_FAIL,
55
+ CODE_RATE_LIMIT,
56
+ CODE_DEVICE_ERROR,
57
+ CODE_BLOCKED,
58
+ CODE_SYSTEM_ERROR,
59
+ CODE_SERVICE_BUSY,
60
+ CODE_DB_CONNECTION_ERROR,
61
+ CODE_DB_ACCESS_DENIED,
62
+ CODE_DB_OPERATION_TIMEOUT,
63
+ } from "./response/code.js";
64
+
36
65
  // ===================== 安全工具 =====================
37
66
  export { setToken, getToken, verifyToken, revokeToken } from "./security/jwt.js";
38
67
  export { aesEncrypt, aesDecrypt } from "./security/sign.js";
@@ -58,4 +87,4 @@ export { Paths } from "./utils/paths.js";
58
87
 
59
88
  // ===================== 默认导出应用主类 =====================
60
89
  import Chan from "./core/App.js";
61
- export default Chan;
90
+ export default Chan;
package/middleware/log.js CHANGED
@@ -1,35 +1,52 @@
1
- import morgan from "morgan";
1
+ import { randomUUID } from "crypto";
2
+ import pinoHttp from "pino-http";
2
3
  import { getIp } from "../utils/ip.js";
3
-
4
- // 日志格式白名单,防止非法格式注入
5
- const ALLOWED_FORMATS = new Set(["chancms", "combined", "common", "dev", "short", "tiny"]);
6
-
7
- // 自定义token
8
- morgan.token("ip", req => getIp(req));
9
- morgan.token("user", req => req.user ? `${req.user.uid}:${req.user.username}` : "-");
10
- morgan.token("datetime", () => new Date().toISOString());
11
-
12
- // 自定义ChanCMS日志输出模板
13
- morgan.format("chancms", (tokens, req, res) => [
14
- tokens.datetime(req, res),
15
- tokens.ip(req, res),
16
- tokens.user(req, res),
17
- tokens.method(req, res),
18
- tokens.url(req, res),
19
- tokens.status(req, res),
20
- tokens.res(req, res, "content-length") || "-",
21
- "-",
22
- tokens["response-time"](req, res),
23
- "ms"
24
- ].join(" "));
4
+ import { root } from "../utils/logger.js";
25
5
 
26
6
  /**
27
- * 请求日志中间件(morgan),内置格式白名单防注入
28
- * @param {express.Application} app Express实例
29
- * @param {{level?: string}} [logger={}] 日志配置
7
+ * 请求日志中间件(pino-http)
8
+ *
9
+ * 自动为每个请求生成 requestId,挂载 req.log(带 requestId 的子 logger)。
10
+ * 业务 Controller 中可通过 req.log.info("xxx") 输出带 requestId 的日志。
11
+ *
12
+ * @param {object} app - Express 实例
13
+ * @param {object} [config={}] - 配置对象
30
14
  */
31
- export const log = (app, logger = {}) => {
32
- const level = logger.level;
33
- const format = ALLOWED_FORMATS.has(level) ? level : "chancms";
34
- app.use(morgan(format));
35
- };
15
+ export function log(app, config = {}) {
16
+ app.use(pinoHttp({
17
+ logger: root,
18
+ // 自动记录请求日志(静态资源跳过)
19
+ autoLogging: {
20
+ ignore: (req) => {
21
+ const url = req.url || "";
22
+ return /\.(ico|png|jpg|jpeg|gif|svg|css|js|woff|woff2)$/.test(url);
23
+ },
24
+ },
25
+ // requestId 生成:优先复用上游 X-Request-Id
26
+ genReqId: (req, res) => {
27
+ const id = req.headers["x-request-id"] || randomUUID();
28
+ res.setHeader("X-Request-Id", id);
29
+ return id;
30
+ },
31
+ // 日志级别按状态码区分
32
+ customLogLevel: (req, res, err) => {
33
+ if (err || res.statusCode >= 500) return "error";
34
+ if (res.statusCode >= 400) return "warn";
35
+ return "info";
36
+ },
37
+ customSuccessMessage: (req, res) => {
38
+ return `${req.method} ${req.originalUrl || req.url} ${res.statusCode}`;
39
+ },
40
+ customErrorMessage: (req, res, err) => {
41
+ return `${req.method} ${req.originalUrl || req.url} ${res.statusCode} ${err.message}`;
42
+ },
43
+ serializers: {
44
+ req: (req) => ({
45
+ method: req.method,
46
+ url: req.originalUrl || req.url,
47
+ ip: getIp(req),
48
+ }),
49
+ res: (res) => ({ statusCode: res.statusCode }),
50
+ },
51
+ }));
52
+ }
package/middleware/waf.js CHANGED
@@ -1,16 +1,10 @@
1
- import crypto from "crypto";
2
1
  import { getIp } from "../utils/ip.js";
3
2
  import { checkKeywords } from "../security/checker.js";
4
3
  import { filterXSS } from "../security/xss-filter.js";
5
4
  import { createRateLimitMiddleware } from "../security/rate-limit.js";
6
- import { store } from "../storage/store.js";
7
5
  import { CODE_BLOCKED } from "../response/code.js";
8
6
  import logger from "../utils/logger.js";
9
7
 
10
- const BLOCK_KEY_PREFIX = "waf:block:";
11
- const STRIKE_KEY_PREFIX = "waf:strike:";
12
-
13
- // 放行路径白名单
14
8
  const WAF_PATH_WHITELIST = [
15
9
  "/.well-known/appspecific/",
16
10
  "/.well-known/change-password",
@@ -22,24 +16,19 @@ const WAF_PATH_WHITELIST = [
22
16
 
23
17
  const TRUSTED_IPS = new Set(["127.0.0.1", "::1"]);
24
18
 
25
- const DEFAULT_BLOCK = {
26
- BLOCK_DURATION: 30 * 60 * 1000,
27
- STRIKE_THRESHOLD: 3,
28
- STRIKE_WINDOW: 60 * 60 * 1000,
29
- };
19
+ // URL/query 关键词检测保留的分类——只保留真正危险、几乎不会在正常 URL 中出现的攻击特征。
20
+ // 去掉 wholeWord / extensions / directories / sensitiveIdentifiers / commandInjection,
21
+ // 这些在正常 URL(?debug=1、/internal/、/.well-known/、wget/scp/dd 等)极易误伤正常请求。
22
+ const URL_SCAN_CATEGORIES = ["sqlInjection", "xss", "pathTraversal", "encoding"];
30
23
 
31
24
  /**
32
- * 判断内网/回环可信IP
25
+ * 判断本机回环可信IP:仅 127.0.0.1 / ::1 无条件放行 WAF 的 query 关键词检测与 XSS 净化。
26
+ * 不再放行整个内网段(如 10.x、192.168.x、172.16-31.x),
27
+ * 否则当 Nginx 部署在另一台内网机、Express 未解析 XFF 时,全站 WAF 会静默失效。
33
28
  */
34
29
  const isTrustedIp = ip => {
35
30
  if (!ip) return false;
36
- if (TRUSTED_IPS.has(ip)) return true;
37
- if (ip.startsWith("10.") || ip.startsWith("192.168.")) return true;
38
- if (ip.startsWith("172.")) {
39
- const seg = Number(ip.split(".")[1]);
40
- return seg >= 16 && seg <= 31;
41
- }
42
- return false;
31
+ return TRUSTED_IPS.has(ip);
43
32
  };
44
33
 
45
34
  /**
@@ -47,12 +36,6 @@ const isTrustedIp = ip => {
47
36
  */
48
37
  const isWhitelistedPath = path => WAF_PATH_WHITELIST.some(p => path === p || path.startsWith(p));
49
38
 
50
- /**
51
- * IP+UA指纹 sha256(前32位)
52
- */
53
- const buildFingerprint = (ip, ua = "") =>
54
- crypto.createHash("sha256").update(`${ip}|${ua}`).digest("hex").slice(0, 32);
55
-
56
39
  /**
57
40
  * 覆盖Express5只读req.query
58
41
  */
@@ -72,22 +55,8 @@ const respondWaf = (res, status, msg, data) =>
72
55
  res.status(status).json({ code: CODE_BLOCKED, success: false, msg, data });
73
56
 
74
57
  /**
75
- * 关键词命中计数+封禁逻辑
58
+ * 限流,返回是否已响应
76
59
  */
77
- async function handleStrike(fingerprint, clientIp, keyword, blockCfg) {
78
- const strikeKey = `${STRIKE_KEY_PREFIX}${fingerprint}`;
79
- const strikes = await store.incrAndExpire(strikeKey, blockCfg.STRIKE_WINDOW);
80
-
81
- if (strikes >= blockCfg.STRIKE_THRESHOLD) {
82
- const blockKey = `${BLOCK_KEY_PREFIX}${fingerprint}`;
83
- await store.set(blockKey, { ip: clientIp, reason: keyword, expireAt: Date.now() + blockCfg.BLOCK_DURATION }, blockCfg.BLOCK_DURATION);
84
- logger.error(`[WAF封禁] IP:${clientIp} 累计命中:${strikes}次 封禁${blockCfg.BLOCK_DURATION / 60000}分钟`);
85
- return { blocked: true, strikes };
86
- }
87
- return { blocked: false, strikes };
88
- }
89
-
90
- /** 执行限流,返回是否已响应 */
91
60
  const runRateLimit = (rateLimit, req, res) =>
92
61
  new Promise(resolve => rateLimit(req, res, resolve)).then(() => res.headersSent);
93
62
 
@@ -96,7 +65,6 @@ const runRateLimit = (rateLimit, req, res) =>
96
65
  */
97
66
  const createWafMiddleware = wafConfig => {
98
67
  const rateLimit = createRateLimitMiddleware(wafConfig.rateLimit);
99
- const blockCfg = { ...DEFAULT_BLOCK, ...wafConfig.block };
100
68
 
101
69
  return async (req, res, next) => {
102
70
  try {
@@ -104,8 +72,6 @@ const createWafMiddleware = wafConfig => {
104
72
 
105
73
  const clientIp = getIp(req);
106
74
  const path = req.path || "";
107
- const ua = req.headers["user-agent"] || "";
108
- const fp = buildFingerprint(clientIp, ua);
109
75
  const whitePath = isWhitelistedPath(path);
110
76
 
111
77
  // 可信IP直接放行,仅做query XSS过滤
@@ -117,32 +83,24 @@ const createWafMiddleware = wafConfig => {
117
83
  // 限流
118
84
  if (await runRateLimit(rateLimit, req, res)) return;
119
85
 
120
- // 已封禁直接拦截
121
- const blockInfo = await store.get(`${BLOCK_KEY_PREFIX}${fp}`);
122
- if (blockInfo) {
123
- logger.error(`[WAF拦截封禁] IP:${clientIp} UA:${ua.slice(0, 50)}`);
124
- return respondWaf(res, 403, "检测到恶意访问,您的访问已被限制", { retryAfter: Math.ceil(blockCfg.BLOCK_DURATION / 1000) });
125
- }
126
-
127
86
  // 白名单路径跳过关键词检测
128
87
  if (whitePath) {
129
88
  if (req.query && Object.keys(req.query).length) overrideQuery(req, filterXSS(req.query));
130
89
  return next();
131
90
  }
132
91
 
133
- // 路径+query拼接检测恶意关键词
92
+ // 路径+query拼接检测恶意关键词(仅保留真正危险分类,避免 debug/secret/internal/.env 等正常词误伤)
134
93
  let checkText = path;
135
94
  if (Object.keys(req.query ?? {}).length) {
136
95
  const queryStr = Object.entries(req.query).map(([k, v]) => `${k}=${v}`).join(" ");
137
96
  checkText += ` ${queryStr}`;
138
97
  }
139
- const hit = checkKeywords(checkText);
98
+ const hit = checkKeywords(checkText, { categories: URL_SCAN_CATEGORIES });
140
99
  if (hit) {
141
100
  const { keyword, category } = hit;
142
101
  logger.error(`[WAF拦截-URL] IP:${clientIp} Path:${path} Key:${keyword} Type:${category}`);
143
- const { blocked, strikes } = await handleStrike(fp, clientIp, keyword, blockCfg);
144
- if (blocked) return respondWaf(res, 403, "检测到恶意访问,您的访问已被限制", { retryAfter: Math.ceil(blockCfg.BLOCK_DURATION / 1000) });
145
- return respondWaf(res, 403, "检测到非法内容,请求已被拦截", { strikes, threshold: blockCfg.STRIKE_THRESHOLD });
102
+ // 仅当次拦截,不再累计封禁 IP(避免误报把整 IP 30 分钟)
103
+ return respondWaf(res, 403, "检测到非法内容,请求已被拦截");
146
104
  }
147
105
 
148
106
  // query XSS净化
@@ -160,19 +118,10 @@ const createWafMiddleware = wafConfig => {
160
118
  };
161
119
  };
162
120
 
163
- /**
164
- * 已登录用户的管理接口路径前缀(跳过 body 关键词检测,避免误杀富文本/代码内容)
165
- * 可通过 waf 配置 bodySkipPrefixes 字段自定义,默认包含常见管理模块前缀
166
- */
167
- const DEFAULT_BODY_SKIP_PREFIXES = ["/cms/", "/base/", "/member/", "/book/", "/oss/", "/vip/"];
168
-
169
121
  /**
170
122
  * Body层WAF中间件(body解析后)
171
123
  */
172
124
  const createWafBodyMiddleware = wafConfig => {
173
- const blockCfg = { ...DEFAULT_BLOCK, ...wafConfig.block };
174
- const bodySkipPrefixes = wafConfig.bodySkipPrefixes ?? DEFAULT_BODY_SKIP_PREFIXES;
175
-
176
125
  return async (req, res, next) => {
177
126
  try {
178
127
  if (!wafConfig.enabled) return next();
@@ -186,35 +135,18 @@ const createWafBodyMiddleware = wafConfig => {
186
135
  // 空body直接放行
187
136
  if (!req.body || (typeof req.body === "object" && !Object.keys(req.body).length)) return next();
188
137
 
189
- // 已登录用户的管理接口:跳过关键词检测(富文本/代码内容会误杀),仅做 XSS 过滤
190
- const isAuthedAdmin = req.user?.uid && bodySkipPrefixes.some(p => path.startsWith(p));
191
-
192
- if (!isAuthedAdmin) {
193
- // 序列化body文本,截断1w字符防绕过
194
- let bodyText = "";
195
- try {
196
- bodyText = (typeof req.body === "string" ? req.body : JSON.stringify(req.body)).slice(0, 10000);
197
- } catch (e) {
198
- logger.error(`[WAF警告] body序列化失败 IP:${clientIp} Err:${e.message}`);
199
- }
200
-
201
- // body关键词检测拦截(scope='body' 跳过路径/文件特征类规则,降低正文误报)
202
- if (bodyText) {
203
- const hit = checkKeywords(bodyText, { scope: 'body' });
204
- if (hit) {
205
- const { keyword, category } = hit;
206
- const fp = buildFingerprint(clientIp, req.headers["user-agent"] || "");
207
- logger.error(`[WAF拦截-Body] IP:${clientIp} Path:${path} Key:${keyword} Type:${category}`);
208
- const { blocked, strikes } = await handleStrike(fp, clientIp, keyword, blockCfg);
209
- if (blocked) return respondWaf(res, 403, "检测到恶意访问,您的访问已被限制", { retryAfter: Math.ceil(blockCfg.BLOCK_DURATION / 1000) });
210
- return respondWaf(res, 403, "检测到非法内容,请求已被拦截", { strikes, threshold: blockCfg.STRIKE_THRESHOLD });
211
- }
212
- }
213
- }
138
+ // 请求体关键词拦截已移除:
139
+ // 项目 DB 全部走 knex 参数化查询(天然防 SQL 注入),后端也无任何
140
+ // 把用户输入交给 shell 执行的路径;而关键词扫描对留言/评论/注册等正常正文
141
+ // 误报极高(drop / select / alert / onclick 等普通词都会被拦),代价远大于收益。
142
+ // XSS 防护由下方 filterXSS 负责(输出编码),SQL 注入由参数化查询负责。
214
143
 
215
144
  // body XSS过滤
145
+ // 例外:base/config 系统配置写入接口跳过 XSS 过滤——sys_config 配置值须原样存储
146
+ // (含「名称 <邮箱>」发件人格式、HTML 邮件模板等),且该接口仅管理员可访问,
147
+ // 配置值由后端逻辑消费而非直接反射到页面,无 XSS 风险。
216
148
  try {
217
- if (typeof req.body === "object") req.body = filterXSS(req.body);
149
+ if (typeof req.body === "object" && !path.startsWith("/base/config")) req.body = filterXSS(req.body);
218
150
  } catch (e) {
219
151
  logger.error(`[WAF警告] body过滤失败 IP:${clientIp} Err:${e.message}`);
220
152
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "chanjs",
4
- "version": "2.7.8",
4
+ "version": "2.7.11",
5
5
  "description": "chanjs基于express5 纯js研发的轻量级mvc框架。",
6
6
  "main": "index.js",
7
7
  "module": "index.js",
@@ -18,6 +18,18 @@
18
18
  },
19
19
  "author": "明空",
20
20
  "license": "ISC",
21
+ "files": [
22
+ "index.js",
23
+ "app/",
24
+ "config/",
25
+ "core/",
26
+ "middleware/",
27
+ "response/",
28
+ "security/",
29
+ "storage/",
30
+ "utils/",
31
+ "doc/"
32
+ ],
21
33
  "dependencies": {
22
34
  "art-template": "^4.13.4",
23
35
  "cookie-parser": "^1.4.7",
@@ -26,15 +38,21 @@
26
38
  "dotenv": "^17.4.2",
27
39
  "express": "^5.2.1",
28
40
  "express-art-template": "^1.0.1",
41
+ "i18next": "^24.2.0",
29
42
  "jsonwebtoken": "^9.0.3",
30
43
  "knex": "^3.2.10",
31
44
  "marked": "^18.0.3",
32
- "morgan": "^1.10.1",
33
45
  "mysql2": "^3.22.3",
46
+ "node-cron": "^3.0.3",
47
+ "pino": "^9.5.0",
48
+ "pino-http": "^10.3.0",
34
49
  "serve-favicon": "^2.5.1",
35
50
  "xss": "^1.0.15",
36
51
  "ioredis": "^5.4.6"
37
52
  },
53
+ "devDependencies": {
54
+ "pino-pretty": "^11.3.0"
55
+ },
38
56
  "peerDependencies": {
39
57
  "zod": "^4.4.3"
40
58
  },
package/response/code.js CHANGED
@@ -52,18 +52,6 @@ export const CODE_DB_CONNECTION_ERROR = 6001;
52
52
  export const CODE_DB_ACCESS_DENIED = 6002;
53
53
  export const CODE_DB_OPERATION_TIMEOUT = 6007;
54
54
 
55
- // 数据库原生错误标识 → 业务码映射
56
- export const DB_ERROR = Object.freeze({
57
- ECONNREFUSED: 6001,
58
- ER_ACCESS_DENIED_ERROR: 6002,
59
- ER_ROW_IS_REFERENCED_2: 6003,
60
- ER_BAD_FIELD_ERROR: 6004,
61
- ER_DUP_ENTRY: 6005,
62
- ER_NO_SUCH_TABLE: 6006,
63
- ETIMEDOUT: 6007,
64
- ER_TABLE_EXISTS_ERROR: 1005,
65
- });
66
-
67
55
  /**
68
56
  * 根据业务码获取默认提示文案
69
57
  * @param {number} code 业务状态码
@@ -31,7 +31,12 @@ export function serializeError(err, exposeDetail = false) {
31
31
  if (isAppError(err)) {
32
32
  const result = { success: false, code: err.code, msg: err.message };
33
33
  const extra = {};
34
+ // 框架内部字段永远不外泄;stack/cause(可能含SQL) 仅 dev(exposeDetail=true) 时附带
35
+ const NEVER_EXPOSE = new Set(["code", "message", "name", "meta", "httpStatus"]);
36
+ const SENSITIVE = new Set(["stack", "cause"]);
34
37
  for (const key of Object.getOwnPropertyNames(err)) {
38
+ if (NEVER_EXPOSE.has(key)) continue;
39
+ if (SENSITIVE.has(key) && !exposeDetail) continue; // 生产环境不泄露堆栈/根因(SQL)
35
40
  const val = err[key];
36
41
  if (val === undefined || val === null) continue;
37
42
  if (typeof val === "function" || typeof val === "symbol") continue;
@@ -42,11 +47,12 @@ export function serializeError(err, exposeDetail = false) {
42
47
  return result;
43
48
  }
44
49
 
45
- // 原生未知系统错误:生产环境强制脱敏,不泄露 SQL / 堆栈
50
+ // 原生未知系统错误:生产环境强制脱敏,不泄露 SQL / 堆栈 / 根因
46
51
  return {
47
52
  success: false,
48
53
  code: CODE_SYSTEM_ERROR,
49
- msg: err?.message || "系统内部错误",
54
+ // 生产环境不暴露原始 message(可能含 SQL 片段),仅 dev 透出
55
+ msg: exposeDetail ? (err?.message || "系统内部错误") : "系统内部错误",
50
56
  ...(exposeDetail && err?.stack ? { data: { stack: err.stack } } : {}),
51
57
  };
52
58
  }