chanjs 2.7.3 → 2.7.5

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 (93) hide show
  1. package/USAGE.md +533 -0
  2. package/config/index.js +37 -6
  3. package/core/App.js +166 -0
  4. package/core/Container.js +77 -0
  5. package/core/Controller.js +29 -0
  6. package/core/Database.js +93 -0
  7. package/core/Repository.js +327 -0
  8. package/core/Service.js +11 -0
  9. package/core/bootstrap/error-handler.js +104 -0
  10. package/core/bootstrap/hook-runner.js +64 -0
  11. package/core/bootstrap/middleware.js +35 -0
  12. package/core/bootstrap/router-loader.js +53 -0
  13. package/core/errors.js +224 -0
  14. package/core/loader.js +89 -0
  15. package/core/registry.js +17 -0
  16. package/doc/Cache.md +279 -106
  17. package/doc/Common.md +590 -134
  18. package/doc/Controller.md +166 -95
  19. package/doc/Help.md +299 -698
  20. package/doc/QuickStart.md +116 -0
  21. package/doc/Repository.md +560 -0
  22. package/doc/Service.md +201 -527
  23. package/index.js +75 -37
  24. package/middleware/body.js +17 -0
  25. package/middleware/cookie.js +7 -15
  26. package/middleware/cors.js +9 -27
  27. package/middleware/favicon.js +7 -17
  28. package/middleware/header.js +15 -16
  29. package/middleware/index.js +11 -11
  30. package/middleware/log.js +26 -56
  31. package/middleware/static.js +15 -28
  32. package/middleware/template.js +75 -115
  33. package/middleware/validate.js +79 -0
  34. package/middleware/waf.js +174 -197
  35. package/package.json +11 -3
  36. package/response/code.js +73 -0
  37. package/response/index.js +9 -6
  38. package/response/response.js +82 -236
  39. package/security/checker.js +26 -74
  40. package/security/index.js +4 -9
  41. package/security/jwt.js +69 -142
  42. package/security/keywords.js +32 -136
  43. package/security/rate-limit.js +38 -80
  44. package/security/sign.js +83 -176
  45. package/security/xss-filter.js +21 -53
  46. package/storage/cache.js +57 -196
  47. package/storage/index.js +3 -6
  48. package/storage/redis.js +123 -181
  49. package/storage/store.js +163 -188
  50. package/utils/data-parse.js +42 -186
  51. package/utils/file.js +73 -244
  52. package/utils/filter.js +22 -25
  53. package/utils/html.js +49 -33
  54. package/utils/index.js +21 -7
  55. package/utils/ip.js +31 -71
  56. package/utils/logger.js +117 -0
  57. package/utils/pages.js +55 -0
  58. package/utils/paths.js +18 -0
  59. package/utils/request.js +94 -136
  60. package/utils/signal.js +87 -0
  61. package/utils/time.js +33 -75
  62. package/utils/tree.js +112 -104
  63. package/App.js +0 -533
  64. package/base/Aop.js +0 -195
  65. package/base/Container.js +0 -161
  66. package/base/Controller.js +0 -65
  67. package/base/Database.js +0 -133
  68. package/base/Event.js +0 -61
  69. package/base/Repository.js +0 -644
  70. package/common/api.js +0 -35
  71. package/common/code.js +0 -52
  72. package/common/email.js +0 -191
  73. package/common/index.js +0 -5
  74. package/common/pages.js +0 -120
  75. package/common/utils.js +0 -73
  76. package/config/code.js +0 -166
  77. package/config/paths.js +0 -60
  78. package/doc/Aop.md +0 -269
  79. package/doc/Email.md +0 -114
  80. package/doc/Event.md +0 -232
  81. package/global/env.js +0 -11
  82. package/global/import.js +0 -39
  83. package/global/index.js +0 -8
  84. package/helper/index.js +0 -79
  85. package/loader/index.js +0 -6
  86. package/loader/loader.js +0 -138
  87. package/middleware/compress.js +0 -185
  88. package/middleware/setBody.js +0 -32
  89. package/realtime/index.js +0 -7
  90. package/realtime/sse.js +0 -424
  91. package/realtime/websocket.js +0 -540
  92. package/schedule/index.js +0 -6
  93. package/schedule/schedule.js +0 -491
package/security/jwt.js CHANGED
@@ -1,175 +1,102 @@
1
1
  import jwt from "jsonwebtoken";
2
2
  import { store } from "../storage/store.js";
3
+ import logger from "../utils/logger.js";
3
4
 
4
- /**
5
- * JWT 黑名单 Key 前缀(store 适配层,内存/Redis 双后端)
6
- * 用于实现 token 主动失效机制
7
- */
8
- const REVOKED_KEY_PREFIX = 'jwt:revoked:';
5
+ const REVOKED_KEY_PREFIX = "jwt:revoked:";
6
+ const ALLOWED_ALGORITHMS = ["HS256"];
7
+
8
+ const JWT_ERR_MSG = {
9
+ TokenExpiredError: "令牌已过期",
10
+ JsonWebTokenError: "无效的令牌",
11
+ default: "令牌验证失败",
12
+ };
9
13
 
10
14
  /**
11
- * 默认签名算法白名单(强制 HS256,防止 alg=none 绕过)
15
+ * 校验token是否在黑名单(fail-open:存储异常直接放行)
12
16
  */
13
- const ALLOWED_ALGORITHMS = ['HS256'];
17
+ const isTokenRevoked = async token => {
18
+ if (!token) return false;
19
+ try { return !!await store.get(`${REVOKED_KEY_PREFIX}${token}`); }
20
+ catch { return false; }
21
+ };
14
22
 
15
23
  /**
16
- * 生成 JWT 令牌
17
- * @param {Object} data - 要编码到令牌中的数据
18
- * @param {string} secretKey - JWT 密钥
19
- * @param {string} [time="7d"] - 令牌过期时间,默认 7 天
20
- * @returns {string|null} 生成的 JWT 令牌,失败时返回 null
21
- * @description
22
- * 使用 HS256 算法生成 JWT 令牌
23
- * 如果 secretKey 未配置,会输出安全错误并返回 null
24
- * @example
25
- * const token = setToken({ userId: 123 }, 'my-secret-key', '1h');
26
- * console.log(token); // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
24
+ * 签发JWT令牌
25
+ * @param {object} data 载荷数据
26
+ * @param {string} secretKey 签名密钥
27
+ * @param {string} [expire='7d'] 有效期
28
+ * @returns {string|null}
27
29
  */
28
- export function setToken(data = {}, secretKey, time = "7d") {
30
+ export const setToken = (data = {}, secretKey, expire = "7d") => {
29
31
  if (!secretKey) {
30
- console.error('[安全错误] JWT_SECRET 必须配置');
32
+ logger.error("[安全错误] JWT_SECRET 未配置");
31
33
  return null;
32
34
  }
33
35
  try {
34
- return jwt.sign(data, secretKey, {
35
- expiresIn: time,
36
- algorithm: "HS256",
37
- });
38
- } catch (error) {
39
- console.error("令牌生成失败:", error.message);
36
+ return jwt.sign(data, secretKey, { algorithm: "HS256", expiresIn: expire });
37
+ } catch (err) {
38
+ logger.error("令牌生成失败", err.message);
40
39
  return null;
41
40
  }
42
- }
41
+ };
43
42
 
44
43
  /**
45
- * 验证并解析 JWT 令牌(异步)
46
- * @async
47
- * @param {string} token - 要验证的 JWT 令牌
48
- * @param {string} secretKey - JWT 密钥
49
- * @returns {Promise<Object|null>} 解码后的令牌数据,验证失败时返回 null
50
- * @description
51
- * 验证 JWT 令牌的有效性并返回解码后的数据
52
- * - 强制校验 algorithms: ['HS256'],防止 alg=none 攻击
53
- * - 自动检查黑名单,已撤销的 token 返回 null
54
- * 支持的错误类型:
55
- * - TokenExpiredError: 令牌已过期
56
- * - JsonWebTokenError: 无效的令牌格式
57
- * @example
58
- * const data = await getToken('eyJhbGciOiJIUzI1NiIs...', 'my-secret-key');
59
- * if (data) {
60
- * console.log(data.userId); // 123
61
- * }
44
+ * 校验并解析JWT,先校验黑名单
45
+ * @param {string} token
46
+ * @param {string} secretKey
47
+ * @returns {Promise<{valid:boolean, reason:string, payload?:object, error?:Error}>}
62
48
  */
63
- export async function getToken(token, secretKey) {
64
- if (!secretKey) {
65
- console.error('[安全错误] JWT_SECRET 必须配置');
66
- return null;
67
- }
68
- // 黑名单校验:已撤销的 token 直接拒绝
69
- if (await isTokenRevoked(token)) {
70
- console.error('[安全错误] 令牌已被撤销');
71
- return null;
72
- }
73
- return new Promise((resolve) => {
74
- // 强制指定算法白名单,防止 alg=none 等绕过
75
- jwt.verify(token, secretKey, { algorithms: ALLOWED_ALGORITHMS }, (err, decode) => {
76
- if (err) {
77
- let errorMessage = "令牌验证失败";
78
- if (err.name === "TokenExpiredError") {
79
- errorMessage = "令牌已过期";
80
- } else if (err.name === "JsonWebTokenError") {
81
- errorMessage = "无效的令牌";
82
- }
83
- console.error(errorMessage, "令牌异常信息:", err.message);
84
- resolve(null);
85
- } else {
86
- resolve(decode);
87
- }
88
- });
89
- });
90
- }
49
+ export const verifyToken = async (token, secretKey) => {
50
+ if (!secretKey) return { valid: false, reason: "missing_secret" };
51
+ if (!token) return { valid: false, reason: "missing" };
91
52
 
92
- /**
93
- * 验证 JWT 令牌(同步)
94
- * @param {string} token - 要验证的 JWT 令牌
95
- * @param {string} secretKey - JWT 密钥
96
- * @returns {Object|null} 解码后的令牌数据,验证失败时返回 null
97
- * @description
98
- * 同步版本的令牌验证方法
99
- * - 强制校验 algorithms: ['HS256']
100
- * - 注意:同步版本无法检查黑名单(黑名单走异步 store),如需校验请用 getToken
101
- * 如果令牌无效或过期,静默返回 null
102
- * @example
103
- * const data = verifyToken('eyJhbGciOiJIUzI1NiIs...', 'my-secret-key');
104
- * if (data) {
105
- * console.log('令牌有效:', data);
106
- * }
107
- */
108
- export function verifyToken(token, secretKey) {
53
+ // 先验签(无效/过期/伪造 token 不查 Redis)
54
+ let payload;
109
55
  try {
110
- // 强制指定算法白名单,防止 alg=none 等绕过
111
- return jwt.verify(token, secretKey, { algorithms: ALLOWED_ALGORITHMS });
56
+ payload = await new Promise((resolve, reject) => {
57
+ jwt.verify(token, secretKey, { algorithms: ALLOWED_ALGORITHMS }, (err, decoded) => {
58
+ err ? reject(err) : resolve(decoded);
59
+ });
60
+ });
112
61
  } catch (err) {
113
- return null;
62
+ const reason = err.name === "TokenExpiredError" ? "expired" : "invalid";
63
+ logger.error(JWT_ERR_MSG[err.name] ?? JWT_ERR_MSG.default, err.message);
64
+ return { valid: false, reason, error: err };
114
65
  }
115
- }
66
+
67
+ // 签名合法后再查黑名单
68
+ if (await isTokenRevoked(token)) {
69
+ logger.error("[安全拦截] 令牌已注销");
70
+ return { valid: false, reason: "revoked" };
71
+ }
72
+ return { valid: true, reason: "ok", payload };
73
+ };
116
74
 
117
75
  /**
118
- * 生成 JWT 令牌(setToken 的别名)
119
- * @param {Object} data - 要编码到令牌中的数据
120
- * @param {string} secretKey - JWT 密钥
121
- * @param {string} [expiresIn="7d"] - 令牌过期时间,默认 7 天
122
- * @returns {string|null} 生成的 JWT 令牌,失败时返回 null
123
- * @description
124
- * setToken 函数的别名,提供更语义化的函数名
125
- * @example
126
- * const token = generateToken({ userId: 123 }, 'my-secret-key', '1h');
76
+ * 校验并解析JWT,成功返回载荷
127
77
  */
128
- export function generateToken(data, secretKey, expiresIn = "7d") {
129
- return setToken(data, secretKey, expiresIn);
130
- }
78
+ export const getToken = async (token, secretKey) => {
79
+ const { valid, payload } = await verifyToken(token, secretKey);
80
+ return valid ? payload : null;
81
+ };
131
82
 
132
83
  /**
133
- * 撤销 JWT 令牌(写入黑名单)
134
- * @async
135
- * @param {string} token - 要撤销的令牌
136
- * @param {number} [ttlMs] - 黑名单保留时长(毫秒),默认与 token 剩余有效期对齐
137
- * @returns {Promise<boolean>} 是否撤销成功
138
- * @description
139
- * 将 token 写入黑名单,使其立即失效
140
- * 用于:用户登出、修改密码、踢人下线等场景
141
- * @example
142
- * await revokeToken(token, 7 * 24 * 60 * 60 * 1000);
84
+ * 注销令牌,加入黑名单
85
+ * @param {string} token
86
+ * @param {number} ttl 令牌剩余有效毫秒数
87
+ * @returns {Promise<boolean>}
143
88
  */
144
- export async function revokeToken(token, ttlMs = 7 * 24 * 60 * 60 * 1000) {
145
- if (!token || typeof token !== 'string') return false;
146
- try {
147
- const key = `${REVOKED_KEY_PREFIX}${token}`;
148
- await store.set(key, { revokedAt: Date.now() }, ttlMs);
89
+ export const revokeToken = async (token, ttl) => {
90
+ if (!token) return false;
91
+ if (!ttl || ttl <= 0) {
92
+ logger.info("[JWT] token 已过期或无剩余有效期,无需注销");
149
93
  return true;
150
- } catch (err) {
151
- console.error('[JWT] 撤销令牌失败:', err.message);
152
- return false;
153
94
  }
154
- }
155
-
156
- /**
157
- * 检查 token 是否在黑名单中
158
- * @async
159
- * @param {string} token - 待检查的令牌
160
- * @returns {Promise<boolean>} true 表示已被撤销
161
- * @description
162
- * 用于在 verify 前主动检查 token 是否已撤销
163
- * 异常时返回 false(fail-open,避免 store 故障导致所有用户登出)
164
- */
165
- export async function isTokenRevoked(token) {
166
- if (!token || typeof token !== 'string') return false;
167
95
  try {
168
- const key = `${REVOKED_KEY_PREFIX}${token}`;
169
- const v = await store.get(key);
170
- return !!v;
171
- } catch {
172
- // fail-open:store 异常时放行,避免误伤所有用户
96
+ await store.set(`${REVOKED_KEY_PREFIX}${token}`, 1, ttl);
97
+ return true;
98
+ } catch (err) {
99
+ logger.error("令牌注销失败", err.message);
173
100
  return false;
174
101
  }
175
- }
102
+ };
@@ -1,179 +1,75 @@
1
1
  /**
2
- * 安全关键词规则定义
3
- * 定义各类恶意攻击的关键词检测规则
4
- *
5
- * 性能优化:
6
- * 1. 每类关键词合并成一个大正则做预检(单次 test 完成整类匹配)
7
- * 2. 预检命中后再遍历该类的具体关键词定位命中项
8
- * 3. 正常请求:8 个大正则 test,比原版 80+ 次正则 test 快 10 倍
9
- * 4. 恶意请求:大正则 + 该类关键词遍历,开销略增但可接受
2
+ * 安全检测恶意关键词规则库
10
3
  */
11
-
12
- /**
13
- * 关键词规则定义
14
- * @type {Object}
15
- */
16
- export const KEYWORD_RULES = {
17
- /**
18
- * 整词匹配规则(需完整匹配)
19
- */
4
+ const KEYWORD_RULES = Object.freeze({
20
5
  wholeWord: [
21
- "netcat", "nc", "php-cgi", "process", "require","exec","import",
6
+ "netcat", "nc", "php-cgi", "process", "require", "exec", "import",
22
7
  "child_process", "execSync", "mainModule"
23
8
  ],
24
- /**
25
- * 敏感文件扩展名
26
- */
27
9
  extensions: [
28
10
  ".php", ".asp", ".aspx", ".jsp", ".jspx", ".do", ".action", ".cgi",
29
- ".py", ".pl", ".cfm", ".jhtml", ".shtml",".sql",".env",".git"
11
+ ".py", ".pl", ".cfm", ".jhtml", ".shtml", ".sql", ".env", ".git"
30
12
  ],
31
- /**
32
- * 敏感目录名称
33
- */
34
13
  directories: [
35
14
  "/administrator", "/wp-admin", "phpMyAdmin", "cgi-bin",
36
15
  "setup", "staging", "internal", "debug", "metadata", "secret"
37
16
  ],
38
- /**
39
- * SQL 注入关键词
40
- * 已移除过于宽泛的词:benchmark( concat( version( 等正常业务可能出现的字符
41
- * 仅保留强 SQL 注入特征词
42
- */
43
17
  sqlInjection: [
44
18
  "sleep(", "extractvalue(", "updatexml(",
45
19
  "union select", "union all", "select @@", "drop ", "alter ", "truncate ",
46
20
  "(select", "information_schema", "load_file(", "into outfile", "into dumpfile"
47
21
  ],
48
- /**
49
- * 命令注入关键词
50
- * 已移除过宽词:kill / su / ssh / chmod / halt / reboot / mount / ln -s / benchmark( / concat( / version(
51
- * 这些词在正常业务字符串中频繁出现,误伤率高
52
- * 保留强命令注入特征词
53
- */
54
22
  commandInjection: [
55
23
  "cmd=", "system(", "exec(", "shell_exec(", "passthru(",
56
24
  "eval(", "assert(", "preg_replace", "bash -i", "rm -rf",
57
25
  "wget ", "curl ", "base64_decode", "phpinfo()",
58
- "killall", "shutdown", "fdisk",
59
- "mkfs", "dd ", "scp ", "rsync", "nc ",
60
- "netcat", "nmap", "iptables", "systemctl", "service",
61
- "crontab", "sudo", "useradd",
62
- "userdel", "usermod", "groupadd", "groupdel", "passwd",
63
- "chpasswd"
26
+ "killall", "shutdown", "fdisk", "mkfs", "dd ",
27
+ "scp ", "rsync", "nc ", "netcat", "nmap", "iptables",
28
+ "systemctl", "service", "crontab", "sudo", "useradd",
29
+ "userdel", "usermod", "groupadd", "groupdel", "passwd", "chpasswd"
64
30
  ],
65
- /**
66
- * 路径遍历关键词
67
- */
68
31
  pathTraversal: [
69
32
  "../", "..\\", "/etc/passwd", "/etc/shadow", "/etc/hosts",
70
- "/etc/", "/var/www/", "/app/", "/root/", "__dirname", "__filename"
33
+ "/root/", "__dirname", "__filename"
71
34
  ],
72
- /**
73
- * XSS 攻击关键词
74
- */
75
35
  xss: [
76
36
  "<script", "javascript:", "onerror=", "onload=", "onclick=",
77
37
  "alert(", "document.cookie", "document.write"
78
38
  ],
79
- /**
80
- * 编码绕过关键词
81
- */
82
- encoding: [
83
- "0x7e", "UNION%20SELECT", "%27OR%27", "{{", "}}", "${", "1+1"
84
- ],
85
- /**
86
- * 敏感标识符
87
- */
39
+ encoding: ["0x7e", "UNION%20SELECT", "%27OR%27"],
88
40
  sensitiveIdentifiers: [
89
- "wp-", "smtp", "redirect", "configs", ".well-known/",
41
+ "wp-", ".well-known/",
90
42
  "fs.readFile", "fs.existsSync", "process.env", "process.argv"
91
43
  ],
92
- };
44
+ });
93
45
 
94
- /**
95
- * 转义正则特殊字符 + 空格转 \s
96
- * @private
97
- */
98
- const REGEX_SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g;
99
- function escapeKeyword(keyword, isWholeWord = false) {
100
- const escaped = keyword.replace(REGEX_SPECIAL_CHARS, "\\$&").replace(/ /g, "\\s");
101
- return isWholeWord ? `\\b${escaped}\\b` : escaped;
102
- }
46
+ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
47
+ const PRIORITY = ["sqlInjection", "xss", "commandInjection"];
103
48
 
104
- /**
105
- * 把同类的关键词数组合并成一个大正则(用 | 连接)
106
- * (?:...) 非捕获组,避免捕获组过多影响性能
107
- * @param {Array<string>} keywords - 关键词数组
108
- * @param {boolean} isWholeWord - 是否整词匹配
109
- * @returns {RegExp|null} 合并后的大正则,空数组返回 null
110
- * @private
111
- */
112
- function buildMergedRegex(keywords, isWholeWord = false) {
113
- if (!keywords || !keywords.length) return null;
114
- const parts = keywords.map(k => escapeKeyword(k, isWholeWord));
115
- // 合并成一个大正则,用非捕获组 (?:...) 避免 capture 开销
116
- return new RegExp(`(?:${parts.join('|')})`, 'i');
117
- }
49
+ const escapeKeyword = (keyword, wholeWord = false) => {
50
+ const str = keyword.replace(REGEX_SPECIAL, "\\$&").replace(/ /g, "\\s");
51
+ return wholeWord ? `\\b${str}\\b` : str;
52
+ };
118
53
 
119
54
  /**
120
- * 关键词检测缓存(双层结构)
121
- * - mergedRegex: 该类别所有关键词合并的大正则,用于快速预检
122
- * - patterns: 原始关键词数组 + 各自正则,用于命中后定位具体关键词
123
- * @type {Object}
55
+ * 预构建每个分类的合并正则(priority 在前,其余按原顺序)
124
56
  */
125
- export let keywordRegexCache = (() => {
126
- const cache = {};
127
-
128
- for (const [category, keywords] of Object.entries(KEYWORD_RULES)) {
129
- if (!Array.isArray(keywords)) continue;
130
-
131
- const isWholeWord = category === 'wholeWord';
132
-
133
- // 单关键词正则(命中后定位用)
134
- const patterns = keywords.map((keyword) => ({
135
- keyword,
136
- regex: new RegExp(
137
- isWholeWord ? `\\b${escapeKeyword(keyword)}\\b` : escapeKeyword(keyword),
138
- "i"
139
- ),
140
- }));
141
-
142
- // 合并大正则(预检用)
143
- const mergedRegex = buildMergedRegex(keywords, isWholeWord);
57
+ const buildCategoryRegex = (cat, words) => {
58
+ const wholeWord = cat === "wholeWord";
59
+ return new RegExp(words.map(k => `(${escapeKeyword(k, wholeWord)})`).join("|"), "i");
60
+ };
144
61
 
145
- cache[category] = { mergedRegex, patterns };
62
+ const keywordRegexCache = (() => {
63
+ const cache = {};
64
+ for (const [cat, words] of Object.entries(KEYWORD_RULES)) {
65
+ cache[cat] = { merged: buildCategoryRegex(cat, words) };
146
66
  }
147
-
148
67
  return cache;
149
68
  })();
150
69
 
151
- /**
152
- * 更新关键词缓存
153
- * @param {Object} newRules - 新的关键词规则
154
- * @description
155
- * 允许动态添加或更新关键词检测规则
156
- * @example
157
- * updateKeywordCache({
158
- * customKeywords: ['malicious', 'attack']
159
- * });
160
- */
161
- export function updateKeywordCache(newRules = {}) {
162
- for (const [category, keywords] of Object.entries(newRules)) {
163
- if (!Array.isArray(keywords)) continue;
70
+ const SORTED_CATEGORIES = [
71
+ ...PRIORITY,
72
+ ...Object.keys(KEYWORD_RULES).filter(c => !PRIORITY.includes(c)),
73
+ ];
164
74
 
165
- const isWholeWord = category === 'wholeWord';
166
- const patterns = keywords.map((keyword) => ({
167
- keyword,
168
- regex: new RegExp(
169
- isWholeWord ? `\\b${escapeKeyword(keyword)}\\b` : escapeKeyword(keyword),
170
- "i"
171
- ),
172
- }));
173
-
174
- keywordRegexCache[category] = {
175
- mergedRegex: buildMergedRegex(keywords, isWholeWord),
176
- patterns,
177
- };
178
- }
179
- }
75
+ export { KEYWORD_RULES, keywordRegexCache, SORTED_CATEGORIES };
@@ -1,105 +1,63 @@
1
1
  import { isIgnored } from "./checker.js";
2
2
  import { getIp } from "../utils/ip.js";
3
3
  import { store } from "../storage/store.js";
4
+ import { CODE_RATE_LIMIT } from "../response/code.js";
5
+ import logger from "../utils/logger.js";
6
+
7
+ const TIME_UNIT = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 };
8
+ const DEFAULT_MAX = 60;
9
+ const DEFAULT_WINDOW_MS = 60 * 1000;
10
+ const KEY_PREFIX = "ratelimit:";
4
11
 
5
12
  /**
6
- * 访问频率限制工具
7
- * 改用服务端存储(Map+TTL 或 Redis),按 IP 维度限流
8
- * 彻底废弃 Cookie 存储方案(旧版可被任意绕过)
13
+ * 解析限流时间窗口,支持数字毫秒 / 1s/1m/1h/1d 字符串
9
14
  */
15
+ const parseWindowMs = time => {
16
+ if (typeof time === "number") return time;
17
+ if (typeof time !== "string") return DEFAULT_WINDOW_MS;
18
+ const m = time.match(/^(\d+)([smhd])$/);
19
+ return m ? Number(m[1]) * TIME_UNIT[m[2]] : DEFAULT_WINDOW_MS;
20
+ };
10
21
 
11
22
  /**
12
- * 解析时间窗口参数
13
- * @private
23
+ * 限流拦截响应
14
24
  */
15
- function parseWindowMs(time) {
16
- if (typeof time === 'number') return time;
17
- if (typeof time !== 'string') return 60 * 1000;
18
-
19
- const match = time.match(/^(\d+)([smhd])$/);
20
- if (!match) return 60 * 1000;
21
-
22
- const value = parseInt(match[1], 10);
23
- const unit = match[2];
24
- const multipliers = {
25
- 's': 1000,
26
- 'm': 60 * 1000,
27
- 'h': 60 * 60 * 1000,
28
- 'd': 24 * 60 * 60 * 1000,
29
- };
30
- return value * (multipliers[unit] || 1000);
31
- }
25
+ const deny = (res, retryAfter, msg) =>
26
+ res.status(429).json({ success: false, code: CODE_RATE_LIMIT, msg, data: { retryAfter } });
32
27
 
33
28
  /**
34
- * 创建限流中间件
35
- * @param {Object} rateLimitConfig - 限流配置
36
- * @param {number|string} rateLimitConfig.windowMs - 时间窗口
37
- * @param {number} rateLimitConfig.max - 最大请求次数
38
- * @param {Array<string>} [rateLimitConfig.ignorePaths] - 忽略的路径数组
39
- * @returns {Function} Express 中间件函数
40
- * @description
41
- * 基于 IP 的限流算法,状态存储在服务端
42
- * - Redis 模式(启用时):跨实例共享,适合多机部署
43
- * - 内存模式(默认):Map+TTL+LRU,上限 10 万条/约 20MB,单机可用
44
- *
45
- * @example
46
- * const rateLimiter = createRateLimitMiddleware({
47
- * windowMs: '1m',
48
- * max: 60,
49
- * ignorePaths: ['/health']
50
- * });
29
+ * 生成IP+UID双维度限流中间件
51
30
  */
52
- export function createRateLimitMiddleware(rateLimitConfig) {
53
- const config = {
54
- windowMs: parseWindowMs(rateLimitConfig?.windowMs),
55
- max: rateLimitConfig?.max || 60,
56
- ignorePaths: rateLimitConfig?.ignorePaths || [],
57
- };
58
-
59
- const KEY_PREFIX = 'ratelimit:';
31
+ export const createRateLimitMiddleware = cfg => {
32
+ const windowMs = parseWindowMs(cfg?.windowMs);
33
+ const max = cfg?.max ?? DEFAULT_MAX;
34
+ const ignorePaths = cfg?.ignorePaths ?? [];
60
35
 
61
36
  return async (req, res, next) => {
62
37
  try {
63
- if (isIgnored(req.path, config.ignorePaths)) {
64
- return next();
65
- }
38
+ if (isIgnored(req.path, ignorePaths)) return next();
66
39
 
67
- const ip = getIp(req);
68
- // 双维度限流:IP + uid(登录用户),避免分布式 IP 攻击时单 IP 维度失效
69
- // - 未登录用户:仅按 IP 限流
70
- // - 登录用户:IP 和 uid 各自独立计数,任一超限即拒绝
40
+ const ip = getIp(req) || "unknown";
71
41
  const uid = req.user?.uid;
72
42
  const ipKey = `${KEY_PREFIX}ip:${ip}`;
73
43
  const uidKey = uid ? `${KEY_PREFIX}uid:${uid}` : null;
74
44
 
75
- // IP 维度自增
76
- const ipCount = await store.incrAndExpire(ipKey, config.windowMs);
77
- // uid 维度自增(登录用户才走)
78
- const uidCount = uidKey ? await store.incrAndExpire(uidKey, config.windowMs) : 0;
45
+ // 计数自增(一次操作完成限流判定,存储异常 fail-close 拦截)
46
+ const [ipCount, uidCount] = await Promise.all([
47
+ store.incrAndExpire(ipKey, windowMs),
48
+ uidKey ? store.incrAndExpire(uidKey, windowMs) : 0,
49
+ ]);
79
50
 
80
- if (ipCount > config.max || uidCount > config.max) {
81
- // 返回窗口剩余等待时间(秒)
82
- const retryAfter = Math.ceil(config.windowMs / 1000);
83
- const dim = ipCount > config.max ? 'IP' : 'UID';
84
-
85
- console.error(`[WAF 限流拦截] 路径:${req.path} 维度:${dim} IP计数:${ipCount}/${config.max} UID计数:${uidCount}/${config.max} IP:${ip} UID:${uid || '-'}`);
86
- return res.status(429).json({
87
- code: 429,
88
- success: false,
89
- msg: '请求过于频繁,请稍后重试',
90
- retryAfter,
91
- });
51
+ if (ipCount > max || uidCount > max) {
52
+ const dim = ipCount > max ? "IP" : "UID";
53
+ logger.error(`[WAF限流] path:${req.path} dim:${dim} ipCnt:${ipCount}/${max} uidCnt:${uidCount}/${max} ip:${ip} uid:${uid ?? "-"}`);
54
+ return deny(res, Math.ceil(windowMs / 1000), "请求过于频繁,请稍后重试");
92
55
  }
93
-
94
56
  next();
95
- } catch (error) {
96
- console.error(`[WAF 限流异常] 路径:${req.path} 错误:${error.message}`);
97
- // 异常时拒绝请求,避免限流服务故障时被绕过
98
- return res.status(429).json({
99
- code: 429,
100
- success: false,
101
- msg: '限流服务暂时不可用',
102
- });
57
+ } catch (err) {
58
+ logger.error(`[WAF限流异常] path:${req.path} err:${err.message}`);
59
+ // fail-close:存储异常直接拦截
60
+ return res.status(429).json({ success: false, code: CODE_RATE_LIMIT, msg: "限流服务暂时不可用" });
103
61
  }
104
62
  };
105
- }
63
+ };