chanjs 2.7.5 → 2.7.6
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/core/BaseComponent.js +27 -0
- package/core/Container.js +19 -28
- package/core/Repository.js +13 -17
- package/core/bootstrap/error-handler.js +11 -14
- package/core/errors.js +29 -2
- package/index.js +2 -16
- package/middleware/favicon.js +9 -1
- package/middleware/waf.js +5 -3
- package/package.json +1 -1
- package/security/jwt.js +21 -3
- package/security/keywords.js +2 -2
- package/storage/cache.js +6 -7
- package/storage/redis.js +11 -10
- package/utils/index.js +3 -4
- package/utils/request.js +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getApp } from "./registry.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 轻量基础组件类:提供 app / config / db 快捷访问
|
|
5
|
+
* Controller / Service / Repository 统一继承此类,
|
|
6
|
+
* 不再强制绑定组件加载(Container)职责。
|
|
7
|
+
*/
|
|
8
|
+
export class BaseComponent {
|
|
9
|
+
/** 获取全局应用实例 */
|
|
10
|
+
get app() { return getApp(); }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 全局配置快捷访问
|
|
14
|
+
* 业务 Controller/Service/Repository 大量使用 `this.config.xxx` 读取配置项
|
|
15
|
+
* 统一从 app.config 取,未初始化时返回空对象避免 undefined.xxx 报错
|
|
16
|
+
*/
|
|
17
|
+
get config() {
|
|
18
|
+
return this.app?.config ?? {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 数据库连接快捷访问(默认连接) */
|
|
22
|
+
get db() {
|
|
23
|
+
return this.app?.db ?? null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default BaseComponent;
|
package/core/Container.js
CHANGED
|
@@ -1,44 +1,35 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Paths } from '../utils/paths.js';
|
|
4
|
-
import {
|
|
4
|
+
import { BaseComponent } from './BaseComponent.js';
|
|
5
5
|
import logger from "../utils/logger.js";
|
|
6
6
|
|
|
7
7
|
const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* 组件容器:按需动态加载 controller/service 组件(组合模式)
|
|
11
|
+
* 继承 BaseComponent 获得 app/config/db 访问,额外提供组件加载能力
|
|
11
12
|
*/
|
|
12
|
-
export class Container {
|
|
13
|
+
export class Container extends BaseComponent {
|
|
13
14
|
constructor(type = 'service') {
|
|
14
|
-
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
get app() { return getApp(); }
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* 全局配置快捷访问
|
|
23
|
-
* 业务 Controller/Service/Repository 大量使用 `this.config.xxx` 读取配置项
|
|
24
|
-
* 统一从 app.config 取,未初始化时返回空对象避免 undefined.xxx 报错
|
|
25
|
-
*/
|
|
26
|
-
get config() {
|
|
27
|
-
return this.app?.config ?? {};
|
|
15
|
+
super();
|
|
16
|
+
this._componentCache = new Map();
|
|
17
|
+
this._type = type;
|
|
18
|
+
this._baseDir = Paths.modulesPath;
|
|
28
19
|
}
|
|
29
20
|
|
|
30
21
|
/**
|
|
31
|
-
*
|
|
32
|
-
* @param {string} moduleName
|
|
33
|
-
* @param {string} fileName
|
|
22
|
+
* 按需获取组件实例,自动缓存
|
|
23
|
+
* @param {string} moduleName 模块名
|
|
24
|
+
* @param {string} fileName 组件文件名(不含 .js)
|
|
34
25
|
*/
|
|
35
26
|
async get(moduleName, fileName) {
|
|
36
27
|
const cacheKey = `${moduleName}.${fileName}`;
|
|
37
|
-
if (this.
|
|
28
|
+
if (this._componentCache.has(cacheKey)) return this._componentCache.get(cacheKey);
|
|
38
29
|
|
|
39
30
|
try {
|
|
40
|
-
const instance = await this.
|
|
41
|
-
if (!instance) logger.info(`[Container] ${moduleName}/${this.
|
|
31
|
+
const instance = await this._loadFile(moduleName, fileName);
|
|
32
|
+
if (!instance) logger.info(`[Container] ${moduleName}/${this._type}/${fileName}.js 导出实例为空`);
|
|
42
33
|
return instance;
|
|
43
34
|
} catch (err) {
|
|
44
35
|
logger.error(`[Container] 加载组件失败 ${moduleName}/${fileName}:${err.message}`);
|
|
@@ -47,15 +38,15 @@ export class Container {
|
|
|
47
38
|
}
|
|
48
39
|
|
|
49
40
|
/** 物理加载文件、导入模块、安全校验 */
|
|
50
|
-
async
|
|
41
|
+
async _loadFile(moduleName, fileName) {
|
|
51
42
|
const cacheKey = `${moduleName}.${fileName}`;
|
|
52
43
|
|
|
53
44
|
if (!NAME_REGEX.test(moduleName) || !NAME_REGEX.test(fileName)) {
|
|
54
45
|
throw new Error(`非法名称 ${moduleName}/${fileName}`);
|
|
55
46
|
}
|
|
56
47
|
|
|
57
|
-
const filePath = path.resolve(this.
|
|
58
|
-
const relativePath = path.relative(this.
|
|
48
|
+
const filePath = path.resolve(this._baseDir, moduleName, this._type, `${fileName}.js`);
|
|
49
|
+
const relativePath = path.relative(this._baseDir, filePath);
|
|
59
50
|
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
60
51
|
throw new Error(`路径越界拦截:${filePath}`);
|
|
61
52
|
}
|
|
@@ -63,13 +54,13 @@ export class Container {
|
|
|
63
54
|
try {
|
|
64
55
|
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
65
56
|
} catch {
|
|
66
|
-
logger.info(`[Container] 文件不存在:${moduleName}/${this.
|
|
57
|
+
logger.info(`[Container] 文件不存在:${moduleName}/${this._type}/${fileName}.js`);
|
|
67
58
|
return null;
|
|
68
59
|
}
|
|
69
60
|
|
|
70
61
|
const mod = await import(`file://${filePath}`);
|
|
71
62
|
const instance = mod.default;
|
|
72
|
-
this.
|
|
63
|
+
this._componentCache.set(cacheKey, instance);
|
|
73
64
|
return instance;
|
|
74
65
|
}
|
|
75
66
|
}
|
package/core/Repository.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { BaseComponent } from "./BaseComponent.js";
|
|
2
2
|
import logger from "../utils/logger.js";
|
|
3
3
|
import {
|
|
4
4
|
CODE_OK,
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../response/code.js";
|
|
9
9
|
|
|
10
10
|
const SORT_FIELD_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
11
|
-
const OPERATORS = { $like:'like', $gt:'>', $gte:'>=', $lt:'<', $lte:'<=', $ne:'<>'
|
|
11
|
+
const OPERATORS = { $like:'like', $gt:'>', $gte:'>=', $lt:'<', $lte:'<=', $ne:'<>' };
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* 拼接查询条件,支持操作符/等值/数组in
|
|
@@ -24,12 +24,12 @@ function applyQuery(dbQuery, query) {
|
|
|
24
24
|
}
|
|
25
25
|
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
26
26
|
const ops = Object.keys(val);
|
|
27
|
-
if (ops.length && ops.every(k => k in OPERATORS || k === '$null' || k === '$notNull')) {
|
|
27
|
+
if (ops.length && ops.every(k => k in OPERATORS || k === '$in' || k === '$null' || k === '$notNull')) {
|
|
28
28
|
for (const [op, operand] of Object.entries(val)) {
|
|
29
|
-
if (
|
|
30
|
-
dbQuery = dbQuery.where(field, OPERATORS[op], operand);
|
|
31
|
-
} else if (op === '$in') {
|
|
29
|
+
if (op === '$in') {
|
|
32
30
|
dbQuery = dbQuery.whereIn(field, Array.isArray(operand) ? operand : [operand]);
|
|
31
|
+
} else if (OPERATORS[op]) {
|
|
32
|
+
dbQuery = dbQuery.where(field, OPERATORS[op], operand);
|
|
33
33
|
} else if (op === '$null') {
|
|
34
34
|
operand ? dbQuery.whereNull(field) : dbQuery.whereNotNull(field);
|
|
35
35
|
} else if (op === '$notNull') {
|
|
@@ -71,10 +71,11 @@ function applySelectAndSort(q, fields, sort) {
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* 数据访问基类,封装通用CRUD操作
|
|
74
|
+
* 继承 BaseComponent 获得 app/config/db 快捷访问,不绑定组件加载职责
|
|
74
75
|
*/
|
|
75
|
-
class Repository extends
|
|
76
|
+
class Repository extends BaseComponent {
|
|
76
77
|
constructor(table=null, dbName=null, opts={}) {
|
|
77
|
-
super(
|
|
78
|
+
super();
|
|
78
79
|
this.tableName = table;
|
|
79
80
|
this._dbName = dbName;
|
|
80
81
|
this._customDb = null;
|
|
@@ -82,13 +83,13 @@ class Repository extends Container {
|
|
|
82
83
|
typeof this.on === 'function' && this.on();
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
/**
|
|
86
|
+
/** 动态获取数据库连接(支持多库:指定 dbName 时取对应连接,否则取默认) */
|
|
86
87
|
get db() {
|
|
87
88
|
if (this._dbName) {
|
|
88
|
-
this._customDb ??= this.app
|
|
89
|
+
this._customDb ??= this.app?.dbManager?.get(this._dbName);
|
|
89
90
|
return this._customDb;
|
|
90
91
|
}
|
|
91
|
-
return this.app
|
|
92
|
+
return this.app?.db ?? null;
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
/** 分页配置快捷读取 */
|
|
@@ -260,16 +261,11 @@ class Repository extends Container {
|
|
|
260
261
|
};
|
|
261
262
|
}
|
|
262
263
|
|
|
263
|
-
/**
|
|
264
|
+
/** 标准分页查询 */
|
|
264
265
|
async query(params={}) {
|
|
265
266
|
return this._doPaginate(params);
|
|
266
267
|
}
|
|
267
268
|
|
|
268
|
-
/** 标准分页查询(list 别名,向后兼容) */
|
|
269
|
-
async list(params={}) {
|
|
270
|
-
return this._doPaginate(params);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
269
|
/** 统计符合条件记录行数 */
|
|
274
270
|
async count(query={}) {
|
|
275
271
|
this._checkDB();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { routeNotFound, serializeError,
|
|
1
|
+
import { routeNotFound, serializeError, respondError } from "../../response/index.js";
|
|
2
2
|
import { CODE_NOT_FOUND, CODE_CONFLICT, CODE_BUSINESS_FAIL } from "../../response/code.js";
|
|
3
3
|
import {
|
|
4
4
|
AppError, isAppError, parseStack, describeError, wrapDbError,
|
|
@@ -84,21 +84,18 @@ export function registerErrorHandler(chan) {
|
|
|
84
84
|
// 双重安全校验:生产环境即使环境变量开了,也必须携带合法 Debug Token 才暴露堆栈/SQL
|
|
85
85
|
const exposeDetail = EXPOSE_ERR_DETAIL && (!IS_PROD || req.headers["x-debug-token"] === process.env.DEBUG_TOKEN);
|
|
86
86
|
|
|
87
|
-
// 统一判断并返回内置HTML页面(仅浏览器页面访问;API 请求一律 JSON)
|
|
88
|
-
const sendHtml = (status, message, bizCode) => {
|
|
89
|
-
if (req.xhr || req.accepts?.(["json", "html"]) === "json") return false;
|
|
90
|
-
if (!req.accepts?.("html")) return false;
|
|
91
|
-
res.status(status).type("html").send(buildErrorHtml(status, message, bizCode, req));
|
|
92
|
-
return true;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
87
|
if (isAppError(err)) {
|
|
96
|
-
|
|
97
|
-
|
|
88
|
+
// 统一通过 respondError 做 HTML/JSON 分流(复用 response 层纯函数,消除重复逻辑)
|
|
89
|
+
const serialized = serializeError(err, exposeDetail);
|
|
90
|
+
return respondError(res, req, {
|
|
91
|
+
httpStatus,
|
|
92
|
+
code: serialized.code,
|
|
93
|
+
msg: serialized.msg,
|
|
94
|
+
data: serialized.data ?? null,
|
|
95
|
+
});
|
|
98
96
|
}
|
|
99
97
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
res.status(500).json(serializeError(err, exposeDetail));
|
|
98
|
+
// 兆底分支(理论上 wrapDbError 已转 AppError,这里防万一)
|
|
99
|
+
respondError(res, req, { httpStatus: 500, code: 500, msg: "服务器内部错误" });
|
|
103
100
|
});
|
|
104
101
|
}
|
package/core/errors.js
CHANGED
|
@@ -45,7 +45,7 @@ export class AppError extends Error {
|
|
|
45
45
|
if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
48
|
+
/** 统一判断是否为业务错误实例(推荐使用独立导出函数 isAppError) */
|
|
49
49
|
static isAppError(err) {
|
|
50
50
|
return err instanceof AppError;
|
|
51
51
|
}
|
|
@@ -70,9 +70,30 @@ function createErrorClass(className) {
|
|
|
70
70
|
|
|
71
71
|
return class extends AppError {
|
|
72
72
|
constructor(msgOrOpts, ...rest) {
|
|
73
|
-
|
|
73
|
+
// 支持两种调用方式:
|
|
74
|
+
// new XxxError("消息")
|
|
75
|
+
// new XxxError({ msg: "消息", cause: err, ...extraProps })
|
|
76
|
+
let opts;
|
|
77
|
+
if (msgOrOpts && typeof msgOrOpts === 'object') {
|
|
78
|
+
opts = msgOrOpts;
|
|
79
|
+
} else {
|
|
80
|
+
opts = { msg: msgOrOpts };
|
|
81
|
+
// 将 extraProps 按顺序映射到 rest 参数
|
|
82
|
+
if (cfg.extraProps) {
|
|
83
|
+
cfg.extraProps.forEach((key, i) => {
|
|
84
|
+
if (rest[i] !== undefined) opts[key] = rest[i];
|
|
85
|
+
});
|
|
86
|
+
// extraProps 之后的参数视为 cause
|
|
87
|
+
const causeIdx = cfg.extraProps.length;
|
|
88
|
+
if (rest[causeIdx] !== undefined) opts.cause = rest[causeIdx];
|
|
89
|
+
} else if (rest[0] !== undefined) {
|
|
90
|
+
opts.cause = rest[0];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
super(cfg.code, opts.msg ?? cfg.defaultMsg, cfg.httpStatus, opts.cause ?? null);
|
|
75
95
|
this.name = className;
|
|
96
|
+
// 挂载额外属性(如 fields / retryAfter)
|
|
76
97
|
for (const [k, v] of Object.entries(opts)) {
|
|
77
98
|
if (k !== 'msg' && k !== 'cause' && v !== undefined) this[k] = v;
|
|
78
99
|
}
|
|
@@ -192,6 +213,12 @@ export function wrapDbError(err) {
|
|
|
192
213
|
|
|
193
214
|
if (isDbConnectionError(e)) return new DbConnectionError("数据库连接失败,请稍后重试", err);
|
|
194
215
|
|
|
216
|
+
// 模板/视图渲染错误:Express 的 res.render 找不到模板时抛出
|
|
217
|
+
const errMsg = String(e?.message || "").toLowerCase();
|
|
218
|
+
if (errMsg.includes("failed to lookup view") || errMsg.includes("no default engine")) {
|
|
219
|
+
return new SystemError("模板渲染异常:" + (e?.message || "视图文件不存在"), err);
|
|
220
|
+
}
|
|
221
|
+
|
|
195
222
|
const driverCode = e?.code;
|
|
196
223
|
switch (driverCode) {
|
|
197
224
|
case "ER_DUP_ENTRY":
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* await chan.start();
|
|
8
8
|
*
|
|
9
9
|
* 命名导出:
|
|
10
|
-
* import { Controller, Repository, Service,
|
|
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
13
|
*
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
// ===================== 核心基础类 =====================
|
|
20
|
+
export { BaseComponent } from "./core/BaseComponent.js";
|
|
20
21
|
export { default as Controller } from "./core/Controller.js";
|
|
21
22
|
export { default as Repository } from "./core/Repository.js";
|
|
22
23
|
export { Service } from "./core/Service.js";
|
|
@@ -55,21 +56,6 @@ export * as utils from "./utils/index.js";
|
|
|
55
56
|
export { cache, store } from "./storage/index.js";
|
|
56
57
|
export { Paths } from "./utils/paths.js";
|
|
57
58
|
|
|
58
|
-
// ===================== helper 聚合对象 =====================
|
|
59
|
-
// 为兼容老代码 import { helper } from "chanjs",薄封装:单一来源 = 命名导出 + 工具命名空间
|
|
60
|
-
import * as utilsNs from "./utils/index.js";
|
|
61
|
-
import { cache, store } from "./storage/index.js";
|
|
62
|
-
import { success, fail } from "./response/index.js";
|
|
63
|
-
import { setToken, getToken, aesEncrypt, aesDecrypt } from "./security/index.js";
|
|
64
|
-
import { createRateLimitMiddleware } from "./security/rate-limit.js";
|
|
65
|
-
import { filterXSS, checkKeywords } from "./security/index.js";
|
|
66
|
-
|
|
67
|
-
export const helper = Object.assign(Object.create(null), utilsNs, {
|
|
68
|
-
cache, store, success, fail,
|
|
69
|
-
setToken, getToken, aesEncrypt, aesDecrypt,
|
|
70
|
-
createRateLimitMiddleware, filterXSS, checkKeywords,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
59
|
// ===================== 默认导出应用主类 =====================
|
|
74
60
|
import Chan from "./core/App.js";
|
|
75
61
|
export default Chan;
|
package/middleware/favicon.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
import fs from "fs";
|
|
1
2
|
import path from "path";
|
|
2
3
|
import serveFavicon from "serve-favicon";
|
|
3
4
|
import { Paths } from "../utils/paths.js";
|
|
5
|
+
import logger from "../utils/logger.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* 网站图标中间件,读取public目录favicon.ico
|
|
9
|
+
* 文件不存在时静默跳过,不阻断启动
|
|
7
10
|
* @param {express.Application} app Express实例
|
|
8
11
|
*/
|
|
9
12
|
export const favicon = app => {
|
|
10
|
-
|
|
13
|
+
const icoPath = path.join(Paths.publicPath, "favicon.ico");
|
|
14
|
+
if (!fs.existsSync(icoPath)) {
|
|
15
|
+
logger.info("[Favicon] favicon.ico 不存在,跳过图标中间件");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
app.use(serveFavicon(icoPath));
|
|
11
19
|
};
|
package/middleware/waf.js
CHANGED
|
@@ -89,7 +89,7 @@ async function handleStrike(fingerprint, clientIp, keyword, blockCfg) {
|
|
|
89
89
|
|
|
90
90
|
/** 执行限流,返回是否已响应 */
|
|
91
91
|
const runRateLimit = (rateLimit, req, res) =>
|
|
92
|
-
new Promise(rateLimit
|
|
92
|
+
new Promise(resolve => rateLimit(req, res, resolve)).then(() => res.headersSent);
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
95
|
* 前置WAF中间件(body前)
|
|
@@ -162,14 +162,16 @@ const createWafMiddleware = wafConfig => {
|
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
164
|
* 已登录用户的管理接口路径前缀(跳过 body 关键词检测,避免误杀富文本/代码内容)
|
|
165
|
+
* 可通过 waf 配置 bodySkipPrefixes 字段自定义,默认包含常见管理模块前缀
|
|
165
166
|
*/
|
|
166
|
-
const
|
|
167
|
+
const DEFAULT_BODY_SKIP_PREFIXES = ["/cms/", "/base/", "/member/", "/book/", "/oss/", "/vip/"];
|
|
167
168
|
|
|
168
169
|
/**
|
|
169
170
|
* Body层WAF中间件(body解析后)
|
|
170
171
|
*/
|
|
171
172
|
const createWafBodyMiddleware = wafConfig => {
|
|
172
173
|
const blockCfg = { ...DEFAULT_BLOCK, ...wafConfig.block };
|
|
174
|
+
const bodySkipPrefixes = wafConfig.bodySkipPrefixes ?? DEFAULT_BODY_SKIP_PREFIXES;
|
|
173
175
|
|
|
174
176
|
return async (req, res, next) => {
|
|
175
177
|
try {
|
|
@@ -185,7 +187,7 @@ const createWafBodyMiddleware = wafConfig => {
|
|
|
185
187
|
if (!req.body || (typeof req.body === "object" && !Object.keys(req.body).length)) return next();
|
|
186
188
|
|
|
187
189
|
// 已登录用户的管理接口:跳过关键词检测(富文本/代码内容会误杀),仅做 XSS 过滤
|
|
188
|
-
const isAuthedAdmin = req.user?.uid &&
|
|
190
|
+
const isAuthedAdmin = req.user?.uid && bodySkipPrefixes.some(p => path.startsWith(p));
|
|
189
191
|
|
|
190
192
|
if (!isAuthedAdmin) {
|
|
191
193
|
// 序列化body文本,截断1w字符防绕过
|
package/package.json
CHANGED
package/security/jwt.js
CHANGED
|
@@ -12,12 +12,30 @@ const JWT_ERR_MSG = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
15
|
+
* 黑名单校验失败策略配置
|
|
16
|
+
* failOpen=true(默认):存储异常时放行(优先可用性)
|
|
17
|
+
* failOpen=false:存储异常时拦截(优先安全性,适用于金融/权限敏感场景)
|
|
18
|
+
* 可通过环境变量 JWT_REVOCATION_FAIL_CLOSE=true 全局切换为 fail-close
|
|
19
|
+
*/
|
|
20
|
+
const IS_FAIL_CLOSE = process.env.JWT_REVOCATION_FAIL_CLOSE === "true";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 校验token是否在黑名单
|
|
24
|
+
* fail-close 模式:存储异常视为已注销(拦截)
|
|
25
|
+
* fail-open 模式:存储异常视为未注销(放行)
|
|
16
26
|
*/
|
|
17
27
|
const isTokenRevoked = async token => {
|
|
18
28
|
if (!token) return false;
|
|
19
|
-
try {
|
|
20
|
-
|
|
29
|
+
try {
|
|
30
|
+
return !!await store.get(`${REVOKED_KEY_PREFIX}${token}`);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (IS_FAIL_CLOSE) {
|
|
33
|
+
logger.warn(`[JWT] 黑名单查询异常,fail-close 拦截:${err.message}`);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
logger.warn(`[JWT] 黑名单查询异常,fail-open 放行:${err.message}`);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
21
39
|
};
|
|
22
40
|
|
|
23
41
|
/**
|
package/security/keywords.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
const KEYWORD_RULES = Object.freeze({
|
|
5
5
|
wholeWord: [
|
|
6
|
-
"netcat", "
|
|
7
|
-
"
|
|
6
|
+
"netcat", "php-cgi", "child_process", "execSync", "mainModule",
|
|
7
|
+
"process.env", "require(", "exec("
|
|
8
8
|
],
|
|
9
9
|
extensions: [
|
|
10
10
|
".php", ".asp", ".aspx", ".jsp", ".jspx", ".do", ".action", ".cgi",
|
package/storage/cache.js
CHANGED
|
@@ -46,7 +46,7 @@ class Cache {
|
|
|
46
46
|
clear = () => this.map.clear();
|
|
47
47
|
size() { return this.map.size; }
|
|
48
48
|
|
|
49
|
-
/** 访问并刷新LRU
|
|
49
|
+
/** 访问并刷新LRU,过期项自动清理(单次 delete+set 维护 Map 插入序) */
|
|
50
50
|
_touch(key) {
|
|
51
51
|
const item = this.map.get(key);
|
|
52
52
|
if (!item) return null;
|
|
@@ -54,6 +54,7 @@ class Cache {
|
|
|
54
54
|
this.map.delete(key);
|
|
55
55
|
return null;
|
|
56
56
|
}
|
|
57
|
+
// 利用 Map 插入序特性:delete+set 将 key 移至末尾(最近访问)
|
|
57
58
|
this.map.delete(key);
|
|
58
59
|
this.map.set(key, item);
|
|
59
60
|
return item;
|
|
@@ -92,13 +93,11 @@ class Cache {
|
|
|
92
93
|
return true;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
/** LRU
|
|
96
|
+
/** LRU淘汰:删除最久未访问key(Map 头部即最早插入) */
|
|
96
97
|
_evictLRU() {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.map.delete(oldest);
|
|
101
|
-
}
|
|
98
|
+
if (this.map.size < this.maxSize) return;
|
|
99
|
+
const oldest = this.map.keys().next().value;
|
|
100
|
+
if (oldest !== undefined) this.map.delete(oldest);
|
|
102
101
|
}
|
|
103
102
|
|
|
104
103
|
/** 惰性批量清理:间隔10s一次,单次最多清理1000条防阻塞事件循环 */
|
package/storage/redis.js
CHANGED
|
@@ -129,30 +129,31 @@ class RedisBackend {
|
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* 自增计数,新建key使用全局默认TTL
|
|
132
|
-
*
|
|
133
|
-
*
|
|
132
|
+
* Lua 脚本保证 INCR + PEXPIRE 原子性,避免崩溃窗口内 key 无 TTL 内存泄漏
|
|
133
|
+
* 递增不刷新TTL,仅首次(count===1)设置过期
|
|
134
134
|
* @param {string} key 计数key
|
|
135
135
|
* @returns {number} 当前计数值
|
|
136
136
|
*/
|
|
137
137
|
async incr(key) {
|
|
138
138
|
const cli = await this._getClient();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
return cli.eval(
|
|
140
|
+
"local c=redis.call('INCR',KEYS[1]);if c==1 then redis.call('PEXPIRE',KEYS[1],ARGV[1])end;return c",
|
|
141
|
+
1, key, REDIS_CONST.DEFAULT_INCR_TTL
|
|
142
|
+
);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* 限流专用自增,自定义新建key
|
|
146
|
+
* 限流专用自增,自定义新建key过期时间(Lua 原子操作)
|
|
147
147
|
* @param {string} key 计数key
|
|
148
148
|
* @param {number} ttlMs 窗口过期时间
|
|
149
149
|
* @returns {number} 当前计数值
|
|
150
150
|
*/
|
|
151
151
|
async incrAndExpire(key, ttlMs) {
|
|
152
152
|
const cli = await this._getClient();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
return cli.eval(
|
|
154
|
+
"local c=redis.call('INCR',KEYS[1]);if c==1 then redis.call('PEXPIRE',KEYS[1],ARGV[1])end;return c",
|
|
155
|
+
1, key, ttlMs
|
|
156
|
+
);
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
/**
|
package/utils/index.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 工具聚合层
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* import {
|
|
6
|
-
* const { tree, getIp, formatDateFields } = helper;
|
|
4
|
+
* 业务侧使用方式(精确命名导入):
|
|
5
|
+
* import { tree, getIp, formatDateFields } from "chanjs/utils/index.js";
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* 也可从子模块单独导入:
|
|
9
8
|
* import { getIp } from "chanjs/utils/ip.js";
|
|
10
9
|
*/
|
|
11
10
|
|
package/utils/request.js
CHANGED
|
@@ -88,6 +88,7 @@ export async function request(url, options = {}) {
|
|
|
88
88
|
|
|
89
89
|
try {
|
|
90
90
|
const res = await fetch(rawUrl, { ...fetchOpts, signal: controller.signal });
|
|
91
|
+
if (timer) clearTimeout(timer);
|
|
91
92
|
|
|
92
93
|
if (!res.ok) {
|
|
93
94
|
return { success: false, error: `HTTP ${res.status} ${res.statusText}` };
|