chanjs 2.8.2 → 2.8.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.
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
- // 统一 pathToFileURL(与 loader.js 一致,Windows/跨盘安全)
90
- const mod = await import(pathToFileURL(filePath).href);
91
- return mod.default;
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
 
@@ -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 || "";
@@ -175,21 +188,26 @@ class Repository extends BaseComponent {
175
188
  return row;
176
189
  }
177
190
 
178
- /** 查询全部,默认上限1000条 */
179
- async all({ query={}, sort={}, fields=[], limit=1000 }={}) {
191
+ /**
192
+ * 查询全部。上限口径统一收口到 config.LIMIT_MAX(this.limit):
193
+ * 未传 limit 时默认取 this.limit;显式传入的 limit 也会被钳制到 [1, this.limit],
194
+ * 避免"all 默认 1000 / 分页上限 300"两套口径并存。
195
+ */
196
+ async all({ query={}, sort={}, fields=[], limit }={}) {
180
197
  const err = this._guard({ query });
181
198
  if (err) return err;
182
- const list = await this._buildBaseQuery({query,sort,fields}).limit(limit);
199
+ const size = Math.min(Math.max(Number(limit) || this.limit, 1), this.limit);
200
+ const list = await this._buildBaseQuery({query,sort,fields}).limit(size);
183
201
  return { success:true, code:CODE_OK, msg:"查询成功", data: list };
184
202
  }
185
203
 
186
- /** 分页偏移查询 */
204
+ /** 分页偏移查询(limit 同 all() 口径,钳制到 config.LIMIT_MAX) */
187
205
  async find({ query={}, sort={}, fields=[], limit, offset }={}) {
188
206
  const err = this._guard({ query });
189
207
  if (err) return err;
190
208
  let q = this._buildBaseQuery({query,sort,fields});
191
209
  typeof offset === 'number' && (q = q.offset(offset));
192
- typeof limit === 'number' && (q = q.limit(limit));
210
+ typeof limit === 'number' && (q = q.limit(Math.min(Math.max(limit, 1), this.limit)));
193
211
  return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
194
212
  }
195
213
 
@@ -234,11 +252,10 @@ class Repository extends BaseComponent {
234
252
  async del(query={}) {
235
253
  this._checkDB();
236
254
  if (!Object.keys(query).length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
237
- // 防御纵深:若传入的查询条件字段全部非法(被 applyQuery 静默丢弃),
238
- // 会生成不带 WHERE 的全表删除,这里 fail-closed 拒绝执行。
239
- if (!Object.keys(query).some(f => SORT_FIELD_REGEX.test(f))) {
240
- return { success:false, code:CODE_PARAM_INVALID, msg:"删除条件非法", data:{} };
241
- }
255
+ // 防御纵深:条件字段全部非法(会被 applyQuery 静默丢弃)时,
256
+ // 会生成不带 WHERE 的全表删除,fail-closed 拒绝执行。
257
+ const invalid = this.#assertValidWhere(query);
258
+ if (invalid) return invalid;
242
259
  const rows = await applyQuery(this.db(this.tableName), query).del();
243
260
  return { success:true, code:CODE_OK, msg:"删除成功", data:{ affectedRows:rows } };
244
261
  }
@@ -269,9 +286,8 @@ class Repository extends BaseComponent {
269
286
  return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
270
287
  }
271
288
  // 防御纵深:与 del 同理,条件字段全部非法时会生成无 WHERE 的全表更新,fail-closed 拒绝。
272
- if (!Object.keys(query).some(f => SORT_FIELD_REGEX.test(f))) {
273
- return { success:false, code:CODE_PARAM_INVALID, msg:"更新条件非法", data:{} };
274
- }
289
+ const invalid = this.#assertValidWhere(query);
290
+ if (invalid) return invalid;
275
291
  const rows = await applyQuery(this.db(this.tableName), query).update(this.#formatDate(data));
276
292
  return { success:true, code:CODE_OK, msg:"更新成功", data:{ affectedRows:rows } };
277
293
  }
@@ -299,18 +315,18 @@ class Repository extends BaseComponent {
299
315
  if (!item.query || !Object.keys(item.query).length) {
300
316
  return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件不能为空", data:{} };
301
317
  }
302
- // 防御纵深:与 update 同口径,条件字段全部非法时 fail-closed 拒绝,
318
+ // 防御纵深:与 del/update 同口径,条件字段全部非法时 fail-closed 拒绝,
303
319
  // 避免生成无 WHERE 的全表更新
304
- if (!Object.keys(item.query).some(f => SORT_FIELD_REGEX.test(f))) {
305
- return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件非法", data:{} };
306
- }
320
+ const invalid = this.#assertValidWhere(item.query);
321
+ if (invalid) return invalid;
307
322
  }
308
323
  const trx = await this.db.transaction();
309
324
  let total = 0;
310
325
  try {
326
+ // 与单条 update 同口径:条件统一过 applyQuery(操作符解析 + 非法字段过滤),不再裸用 knex where
311
327
  for (const { query, data } of updates) {
312
- const row = await trx(this.tableName).where(query).update(this.#formatDate(data));
313
- row === 0 && logger.info("[Repository] updateMany无匹配行", query);
328
+ const row = await applyQuery(trx(this.tableName), query).update(this.#formatDate(data));
329
+ row === 0 && logger.info(`[Repository] updateMany无匹配行 ${JSON.stringify(query)}`);
314
330
  total += row;
315
331
  }
316
332
  await trx.commit();
@@ -4,10 +4,11 @@ import {
4
4
  } from "../../middleware/index.js";
5
5
  import { health } from "../../middleware/health.js";
6
6
  import { BODY_LIMIT } from "../../config/index.js";
7
+ import logger from "../../utils/logger.js";
7
8
 
8
9
  /**
9
10
  * 注册核心中间件(顺序固化:不可随意调整)
10
- * WAF 前置 → Favicon → 静态 → Cookie → Body 解析 → WAF Body 检查 → CORS → 日志 → 模板 → 响应头
11
+ * WAF 前置 → 健康检查 → Favicon → 静态 → CORS → Cookie → Body 解析 → WAF Body 检查 → 日志 → 模板 → 响应头
11
12
  * @param {object} chan 框架实例
12
13
  */
13
14
  export async function registerCoreMiddleware(chan) {
@@ -17,22 +18,36 @@ export async function registerCoreMiddleware(chan) {
17
18
  await waf(app, cfg.waf ?? { enabled: false });
18
19
  // 1.5 健康检查端点(默认开启,config.health = {enabled,path} 可调;浅模式零 IO)
19
20
  app.use(health(chan, cfg.health ?? {}));
21
+ // 1.6 宿主全局中间件插槽:config.middleware 数组,按声明顺序注册。
22
+ // 元素支持两种形态:express 中间件本身 (req,res,next);或工厂 () => express 中间件(需闭包 chan/config 时用)。
23
+ // 注册位置在核心链早期、模块路由之前,保证对所有响应(含静态资源)生效。
24
+ // 注意:app/router.js(loadCommonRouter)的执行时机在模块路由之后,app.use 在那里
25
+ // 只罩得住 404 兜底请求,安全响应头等全局策略应经本插槽注册而非写在 router.js。
26
+ for (const mw of cfg.middleware ?? []) {
27
+ const fn = typeof mw === "function" && mw.length <= 1 ? mw(chan) : mw;
28
+ if (typeof fn !== "function") {
29
+ logger.warn(`[Chan] config.middleware 含非法项(须为中间件或中间件工厂),已跳过: ${String(mw)}`);
30
+ continue;
31
+ }
32
+ app.use(fn);
33
+ }
20
34
  // 2. Favicon
21
35
  favicon(app);
22
36
  // 3. 静态资源
23
37
  staticMw(app, cfg.statics ?? []);
38
+ // 3.5 CORS(前移到 body 之前:OPTIONS 预检无需等待 body 解析即返回;
39
+ // WAF 拦截预检时响应也能携带 CORS 头,浏览器报错不再误导排障)
40
+ Cors(app, cfg.cors ?? {});
24
41
  // 4. Cookie
25
42
  cookie(app, cfg.cookieKey);
26
43
  // 5. 请求体解析
27
44
  body(app, cfg.BODY_LIMIT ?? BODY_LIMIT);
28
45
  // 5.1 WAF Body 检查(须在 body 解析之后)
29
46
  wafBody(app, cfg.waf ?? { enabled: false });
30
- // 6. CORS
31
- Cors(app, cfg.cors ?? {});
32
47
  // 7. 请求日志
33
48
  log(app, cfg.logger ?? {});
34
- // 8. 模板渲染
35
- template(app, { views: cfg.views ?? [], NODE_ENV: cfg.NODE_ENV ?? "dev" });
49
+ // 8. 模板渲染(NODE_ENV 兜底 production:与「默认生产口径」约束一致,生产默认开启模板缓存)
50
+ template(app, { views: cfg.views ?? [], NODE_ENV: cfg.NODE_ENV ?? "production" });
36
51
  // 9. 响应头
37
52
  header(app, { APP_NAME: cfg.APP_NAME ?? "ChanCMS", APP_VERSION: cfg.APP_VERSION ?? "1.0.0" });
38
53
  }
@@ -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.error(`[RouterLoader] 路由文件加载失败: ${filePath}`, err);
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}:`, err.message);
29
+ else logger.error(`[Loader] 导入失败 ${filepath}: ${err?.message || err}`);
29
30
  return null;
30
31
  }
31
32
  }
@@ -172,7 +172,8 @@ const otherDb = app.dbManager.get("logs");
172
172
  | `REDIS_ENABLED` / `REDIS` | Redis 配置 |
173
173
  | `JWT_SECRET` | JWT 签名密钥 |
174
174
  | `LOCALE` | i18n 默认语言(默认 `zh-CN`)|
175
- | `LIMIT_MAX` | Repository 单页上限(默认 300)|
175
+ | `LIMIT_MAX` | Repository 单页上限(默认 300);`all()`/`find()` 显式 limit 亦被钳制到该值 |
176
+ | `middleware` | 宿主全局中间件插槽:数组,元素为 express 中间件或中间件工厂 `(chan) => mw`,注册于核心链早期(健康检查后、静态资源前),对所有响应生效 |
176
177
  | `SLOW_THRESHOLD` | 数据库慢查询阈值(ms,默认 200),超过记 warn 日志 |
177
178
  | `SHUTDOWN_TIMEOUT` | 优雅停机强制退出超时(ms,默认 5000)|
178
179
  | `BODY_LIMIT` | 请求体上限(默认 `10mb`)|
@@ -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
  */
@@ -1,20 +1,22 @@
1
1
  /**
2
- * 自定义响应头中间件,隐藏原生Express标识,统一输出ChanCMS
3
- * @param {express.Application} app Express实例
4
- * @param {{APP_NAME?: string, APP_VERSION?: string}} [opts] 配置选项
2
+ * 品牌响应头中间件:隐藏原生 Express 标识,统一输出 ChanCMS。
3
+ *
4
+ * 注:安全响应头(X-Content-Type-Options / HSTS / Referrer-Policy /
5
+ * Permissions-Policy / CSP 等)属于宿主业务策略,不在框架层默认设置——
6
+ * 由各项目的 security-headers 中间件统一收敛(本项目见 app/middleware/security-headers.js)。
7
+ * 尤其 CSP frame-ancestors 一旦在框架写死 'self',会与需要被第三方
8
+ * iframe 嵌入的宿主业务直接冲突,故不在此声明。
5
9
  */
6
10
  export const header = (app, opts = {}) => {
7
11
  const appName = opts.APP_NAME || "ChanCMS";
8
12
  const appVer = opts.APP_VERSION || "";
9
13
  const poweredBy = appVer ? `${appName}/${appVer}` : appName;
14
+ // 静态资源在核心链第 3 步即被接住、到不了本中间件,Express 会默认补
15
+ // X-Powered-By: Express 泄露技术栈——先禁用默认注入,品牌头只由下方显式设置
16
+ app.disable("x-powered-by");
10
17
 
11
18
  app.use((_, res, next) => {
12
19
  res.setHeader("X-Powered-By", poweredBy);
13
- // 常驻安全响应头:无论 WAF 是否启用都生效
14
- // 禁止 MIME 嗅探(防 XSS 利用 Content-Type 歧义)
15
- res.setHeader("X-Content-Type-Options", "nosniff");
16
- // 防点击劫持:仅允许同源 iframe 嵌套(如需被第三方嵌入请按需放宽)
17
- res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
18
20
  next();
19
21
  });
20
- };
22
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "chanjs",
4
- "version": "2.8.2",
4
+ "version": "2.8.4",
5
5
  "description": "chanjs基于 Node.js + Express 5 的标准 HMVC 框架(NHMVC),纯 JavaScript(ESM)开发。",
6
6
  "main": "index.js",
7
7
  "module": "index.js",
package/utils/logger.js CHANGED
@@ -11,7 +11,7 @@ const timestamp = () => {
11
11
 
12
12
  // 输出格式:时间 + 空格 + 正文(pino-pretty 默认模板是 [time]: msg,方括号和冒号去不掉,
13
13
  // 故忽略 time 字段、改由 messageFormat 把时间拼在行首)
14
- export const root = pino(
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
- export default root;
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) {