chanjs 2.8.1 → 2.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/Container.js +11 -4
- package/core/Repository.js +25 -14
- package/core/bootstrap/error-handler.js +4 -1
- package/core/bootstrap/middleware.js +6 -5
- package/core/bootstrap/router-loader.js +2 -1
- package/core/loader.js +2 -1
- package/middleware/cookie.js +5 -0
- package/package.json +1 -1
- package/utils/logger.js +49 -2
- package/utils/signal.js +2 -0
package/core/Container.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { pathToFileURL } from "url";
|
|
4
3
|
import { Paths } from "../utils/paths.js";
|
|
5
4
|
import { BaseComponent } from "./BaseComponent.js";
|
|
5
|
+
import { importFile } from "./loader.js";
|
|
6
6
|
import logger from "../utils/logger.js";
|
|
7
7
|
|
|
8
8
|
const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
@@ -67,6 +67,11 @@ export class Container extends BaseComponent {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* 物理加载文件、导入模块、安全校验
|
|
70
|
+
*
|
|
71
|
+
* 职责分层(组件加载唯一核心):
|
|
72
|
+
* 1. 本方法只做「用户输入校验」(模块/文件名白名单 + 路径越界)与「存在性探测」
|
|
73
|
+
* 2. 文件导入统一委托 loader.importFile(access + pathToFileURL + default 提取 + IO 错误日志),
|
|
74
|
+
* 与 loadController/loadConfig 共享同一原语,避免两套导入实现行为漂移
|
|
70
75
|
* @private
|
|
71
76
|
*/
|
|
72
77
|
async _loadFile(moduleName, fileName, type = this._type) {
|
|
@@ -80,15 +85,17 @@ export class Container extends BaseComponent {
|
|
|
80
85
|
throw new Error(`路径越界: ${filePath}`);
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
// 存在性是预期内路径(按需探测),静默返回 null 交给 get 打 info 日志
|
|
83
89
|
try {
|
|
84
90
|
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
85
91
|
} catch {
|
|
86
92
|
return null;
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
// 项目约定组件必须是实例对象(export default new XxxService());
|
|
96
|
+
// 只导出类/函数的文件视为无效组件,不缓存不报错(与 loadController 的过滤口径一致)
|
|
97
|
+
const mod = await importFile(filePath);
|
|
98
|
+
return mod && typeof mod === "object" ? mod : null;
|
|
92
99
|
}
|
|
93
100
|
}
|
|
94
101
|
|
package/core/Repository.js
CHANGED
|
@@ -130,6 +130,19 @@ class Repository extends BaseComponent {
|
|
|
130
130
|
if (!this.db) throw new Error("Database connection not available");
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 写操作 WHERE 防御(fail-closed):条件字段全部非法时拒绝执行。
|
|
135
|
+
* 被丢弃的条件会让 del/update 退化为无 WHERE 的全表操作,统一在此拦截。
|
|
136
|
+
* @param {object} query WHERE 条件
|
|
137
|
+
* @returns {{success:false,code:number,msg:string,data:{}}|null} 非法返回错误体(调用方应 return),合法返回 null
|
|
138
|
+
*/
|
|
139
|
+
#assertValidWhere(query) {
|
|
140
|
+
if (!Object.keys(query).some(f => SORT_FIELD_REGEX.test(f))) {
|
|
141
|
+
return { success: false, code: CODE_PARAM_INVALID, msg: "操作条件非法", data: {} };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
/** 当前连接的数据库客户端类型(knex client:mysql2 / pg / better-sqlite3 ...) */
|
|
134
147
|
get _clientType() {
|
|
135
148
|
return this.db?.client?.config?.client || "";
|
|
@@ -234,11 +247,10 @@ class Repository extends BaseComponent {
|
|
|
234
247
|
async del(query={}) {
|
|
235
248
|
this._checkDB();
|
|
236
249
|
if (!Object.keys(query).length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
|
|
237
|
-
//
|
|
238
|
-
// 会生成不带 WHERE
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
250
|
+
// 防御纵深:条件字段全部非法(会被 applyQuery 静默丢弃)时,
|
|
251
|
+
// 会生成不带 WHERE 的全表删除,fail-closed 拒绝执行。
|
|
252
|
+
const invalid = this.#assertValidWhere(query);
|
|
253
|
+
if (invalid) return invalid;
|
|
242
254
|
const rows = await applyQuery(this.db(this.tableName), query).del();
|
|
243
255
|
return { success:true, code:CODE_OK, msg:"删除成功", data:{ affectedRows:rows } };
|
|
244
256
|
}
|
|
@@ -269,9 +281,8 @@ class Repository extends BaseComponent {
|
|
|
269
281
|
return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
|
|
270
282
|
}
|
|
271
283
|
// 防御纵深:与 del 同理,条件字段全部非法时会生成无 WHERE 的全表更新,fail-closed 拒绝。
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
284
|
+
const invalid = this.#assertValidWhere(query);
|
|
285
|
+
if (invalid) return invalid;
|
|
275
286
|
const rows = await applyQuery(this.db(this.tableName), query).update(this.#formatDate(data));
|
|
276
287
|
return { success:true, code:CODE_OK, msg:"更新成功", data:{ affectedRows:rows } };
|
|
277
288
|
}
|
|
@@ -299,18 +310,18 @@ class Repository extends BaseComponent {
|
|
|
299
310
|
if (!item.query || !Object.keys(item.query).length) {
|
|
300
311
|
return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件不能为空", data:{} };
|
|
301
312
|
}
|
|
302
|
-
// 防御纵深:与 update 同口径,条件字段全部非法时 fail-closed 拒绝,
|
|
313
|
+
// 防御纵深:与 del/update 同口径,条件字段全部非法时 fail-closed 拒绝,
|
|
303
314
|
// 避免生成无 WHERE 的全表更新
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
315
|
+
const invalid = this.#assertValidWhere(item.query);
|
|
316
|
+
if (invalid) return invalid;
|
|
307
317
|
}
|
|
308
318
|
const trx = await this.db.transaction();
|
|
309
319
|
let total = 0;
|
|
310
320
|
try {
|
|
321
|
+
// 与单条 update 同口径:条件统一过 applyQuery(操作符解析 + 非法字段过滤),不再裸用 knex where
|
|
311
322
|
for (const { query, data } of updates) {
|
|
312
|
-
const row = await trx(this.tableName)
|
|
313
|
-
row === 0 && logger.info(
|
|
323
|
+
const row = await applyQuery(trx(this.tableName), query).update(this.#formatDate(data));
|
|
324
|
+
row === 0 && logger.info(`[Repository] updateMany无匹配行 ${JSON.stringify(query)}`);
|
|
314
325
|
total += row;
|
|
315
326
|
}
|
|
316
327
|
await trx.commit();
|
|
@@ -68,7 +68,10 @@ export function registerErrorHandler(chan) {
|
|
|
68
68
|
const userId = req.user?.id ?? "-";
|
|
69
69
|
|
|
70
70
|
// 日志分级:业务错误 4xx → warn,系统错误 5xx → error(唯一打印点,杜绝重复刷屏)
|
|
71
|
-
|
|
71
|
+
// ⚠️ pino 的日志方法依赖 this,不能解引用后裸调(logFn = logger.warn; logFn(...) 会在
|
|
72
|
+
// pino/lib/tools.js 抛 TypeError: Cannot read properties of undefined (reading 'Symbol(pino.msgPrefix)'),
|
|
73
|
+
// 把真实的业务错误整个掩盖成 500),必须用箭头函数包一层保住 this
|
|
74
|
+
const logFn = httpStatus < 500 ? (msg) => logger.warn(msg) : (msg) => logger.error(msg);
|
|
72
75
|
logFn(JSON.stringify({
|
|
73
76
|
type: err.name || "UnknownError",
|
|
74
77
|
message: describeError(err), // 递归解析 cause 根因,支持 AggregateError
|
|
@@ -7,7 +7,7 @@ import { BODY_LIMIT } from "../../config/index.js";
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* 注册核心中间件(顺序固化:不可随意调整)
|
|
10
|
-
* WAF 前置 → Favicon → 静态 → Cookie → Body 解析 → WAF Body 检查 →
|
|
10
|
+
* WAF 前置 → 健康检查 → Favicon → 静态 → CORS → Cookie → Body 解析 → WAF Body 检查 → 日志 → 模板 → 响应头
|
|
11
11
|
* @param {object} chan 框架实例
|
|
12
12
|
*/
|
|
13
13
|
export async function registerCoreMiddleware(chan) {
|
|
@@ -21,18 +21,19 @@ export async function registerCoreMiddleware(chan) {
|
|
|
21
21
|
favicon(app);
|
|
22
22
|
// 3. 静态资源
|
|
23
23
|
staticMw(app, cfg.statics ?? []);
|
|
24
|
+
// 3.5 CORS(前移到 body 之前:OPTIONS 预检无需等待 body 解析即返回;
|
|
25
|
+
// WAF 拦截预检时响应也能携带 CORS 头,浏览器报错不再误导排障)
|
|
26
|
+
Cors(app, cfg.cors ?? {});
|
|
24
27
|
// 4. Cookie
|
|
25
28
|
cookie(app, cfg.cookieKey);
|
|
26
29
|
// 5. 请求体解析
|
|
27
30
|
body(app, cfg.BODY_LIMIT ?? BODY_LIMIT);
|
|
28
31
|
// 5.1 WAF Body 检查(须在 body 解析之后)
|
|
29
32
|
wafBody(app, cfg.waf ?? { enabled: false });
|
|
30
|
-
// 6. CORS
|
|
31
|
-
Cors(app, cfg.cors ?? {});
|
|
32
33
|
// 7. 请求日志
|
|
33
34
|
log(app, cfg.logger ?? {});
|
|
34
|
-
// 8.
|
|
35
|
-
template(app, { views: cfg.views ?? [], NODE_ENV: cfg.NODE_ENV ?? "
|
|
35
|
+
// 8. 模板渲染(NODE_ENV 兜底 production:与「默认生产口径」约束一致,生产默认开启模板缓存)
|
|
36
|
+
template(app, { views: cfg.views ?? [], NODE_ENV: cfg.NODE_ENV ?? "production" });
|
|
36
37
|
// 9. 响应头
|
|
37
38
|
header(app, { APP_NAME: cfg.APP_NAME ?? "ChanCMS", APP_VERSION: cfg.APP_VERSION ?? "1.0.0" });
|
|
38
39
|
}
|
|
@@ -19,7 +19,8 @@ async function registerRouterFile(filePath, app, router, config) {
|
|
|
19
19
|
await register(app, router, config);
|
|
20
20
|
}
|
|
21
21
|
} catch (err) {
|
|
22
|
-
logger
|
|
22
|
+
// 详情必须拼进 msg 本身:logger 的 messageFormat 仅输出 msg 字段,第二参数会被 pino-pretty 忽略导致错误原因丢失
|
|
23
|
+
logger.error(`[RouterLoader] 路由文件加载失败: ${filePath}\n${err?.stack || err}`);
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
package/core/loader.js
CHANGED
|
@@ -23,9 +23,10 @@ export async function importFile(filepath) {
|
|
|
23
23
|
const mod = await import(pathToFileURL(filepath).href);
|
|
24
24
|
return mod.default ?? mod;
|
|
25
25
|
} catch (err) {
|
|
26
|
+
// 详情必须拼进 msg 本身:logger 的 messageFormat 仅输出 msg 字段,第二参数会被 pino-pretty 忽略导致错误原因丢失
|
|
26
27
|
if (err.code === "ENOENT") logger.error(`[Loader] 文件不存在: ${filepath}`);
|
|
27
28
|
else if (err.code === "EACCES") logger.error(`[Loader] 无访问权限: ${filepath}`);
|
|
28
|
-
else logger.error(`[Loader] 导入失败 ${filepath}
|
|
29
|
+
else logger.error(`[Loader] 导入失败 ${filepath}: ${err?.message || err}`);
|
|
29
30
|
return null;
|
|
30
31
|
}
|
|
31
32
|
}
|
package/middleware/cookie.js
CHANGED
|
@@ -3,6 +3,11 @@ import logger from "../utils/logger.js";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Cookie解析中间件,支持签名校验
|
|
6
|
+
*
|
|
7
|
+
* 职责边界:本中间件只负责「解析」(含签名校验);「写入侧」的安全属性
|
|
8
|
+
* (httpOnly / sameSite / secure / maxAge)由业务写入时的 res.cookie(name, value, opts)
|
|
9
|
+
* 决定,框架不越俎代庖。业务侧统一在 app/common/auth-cookie.js 收口:
|
|
10
|
+
* sameSite=prd:strict / dev:lax,secure 可配,httpOnly=false 为统一登录态方案的已确认权衡。
|
|
6
11
|
* @param {express.Application} app Express实例
|
|
7
12
|
* @param {string} [cookieKey] Cookie签名密钥
|
|
8
13
|
*/
|
package/package.json
CHANGED
package/utils/logger.js
CHANGED
|
@@ -11,7 +11,7 @@ const timestamp = () => {
|
|
|
11
11
|
|
|
12
12
|
// 输出格式:时间 + 空格 + 正文(pino-pretty 默认模板是 [time]: msg,方括号和冒号去不掉,
|
|
13
13
|
// 故忽略 time 字段、改由 messageFormat 把时间拼在行首)
|
|
14
|
-
|
|
14
|
+
const root = pino(
|
|
15
15
|
{ level: (process.env.LOG_LEVEL || "info").trim(), base: null, timestamp },
|
|
16
16
|
pretty({
|
|
17
17
|
colorize: false,
|
|
@@ -19,4 +19,51 @@ export const root = pino(
|
|
|
19
19
|
messageFormat: "{time} {msg}",
|
|
20
20
|
}),
|
|
21
21
|
);
|
|
22
|
-
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 附加参数序列化:error/warn 的第二参(Error/对象)拼进消息本身。
|
|
25
|
+
*
|
|
26
|
+
* 背景:pino-pretty 的 messageFormat 字符串模板只渲染 msg 字段,
|
|
27
|
+
* 「logger.error(msg, err)」的 err 会被整体丢弃(连堆栈都不输出),
|
|
28
|
+
* 曾导致 Gather.js 加载失败只剩「导入失败 xxx.js:」空冒号、无法定位根因。
|
|
29
|
+
* 在封装层统一序列化,保证文档承诺的「第二参数自动解析堆栈」真实生效。
|
|
30
|
+
* @param {unknown[]} args error/warn 的除消息外全部参数
|
|
31
|
+
* @returns {string} 可读文本(Error 优先 stack,对象 JSON,其余 String)
|
|
32
|
+
*/
|
|
33
|
+
function serializeExtra(args) {
|
|
34
|
+
return args
|
|
35
|
+
.map(v => {
|
|
36
|
+
if (v == null) return "";
|
|
37
|
+
if (v instanceof Error) return v.stack || `${v.name}: ${v.message}`;
|
|
38
|
+
if (typeof v === "object") {
|
|
39
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
40
|
+
}
|
|
41
|
+
return String(v);
|
|
42
|
+
})
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.join(" ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 统一日志门面:与文档用法对齐(logger.error("xx", err) 自动带出堆栈)
|
|
49
|
+
* 全部级别统一将附加参数序列化进 msg(pino-pretty messageFormat 只渲染 msg 字段,
|
|
50
|
+
* 任何直接透传的第二参都会被丢弃),保证 info("xx", obj) 与 error 行为一致
|
|
51
|
+
*/
|
|
52
|
+
const logger = {
|
|
53
|
+
info(msg, ...extra) {
|
|
54
|
+
extra.length ? root.info(`${msg} ${serializeExtra(extra)}`) : root.info(msg);
|
|
55
|
+
},
|
|
56
|
+
debug: root.debug.bind(root),
|
|
57
|
+
trace: root.trace.bind(root),
|
|
58
|
+
fatal: root.fatal.bind(root),
|
|
59
|
+
child: root.child.bind(root),
|
|
60
|
+
warn(msg, ...extra) {
|
|
61
|
+
extra.length ? root.warn(`${msg} ${serializeExtra(extra)}`) : root.warn(msg);
|
|
62
|
+
},
|
|
63
|
+
error(msg, ...extra) {
|
|
64
|
+
extra.length ? root.error(`${msg} ${serializeExtra(extra)}`) : root.error(msg);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export { root };
|
|
69
|
+
export default logger;
|
package/utils/signal.js
CHANGED
|
@@ -23,6 +23,8 @@ async function doShutdown(chan) {
|
|
|
23
23
|
|
|
24
24
|
if (server) {
|
|
25
25
|
try {
|
|
26
|
+
// 先断开空闲 keep-alive 连接,避免 server.close 等待存量长连接拖满停机超时
|
|
27
|
+
typeof server.closeIdleConnections === "function" && server.closeIdleConnections();
|
|
26
28
|
await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
|
|
27
29
|
logger.info("[Chan] HTTP服务已停止接受新连接");
|
|
28
30
|
} catch (e) {
|