chanjs 2.8.9 → 2.8.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.
- package/core/Repository.js +53 -4
- package/core/bootstrap/error-handler.js +2 -3
- package/core/loader.js +2 -2
- package/index.js +3 -0
- package/middleware/validate.js +1 -1
- package/middleware/waf.js +2 -3
- package/package.json +1 -1
- package/utils/escape.js +8 -0
- package/utils/index.js +2 -1
- package/utils/logger.js +1 -2
- package/utils/pages.js +67 -0
package/core/Repository.js
CHANGED
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
CODE_PARAM_MISSING,
|
|
8
8
|
} from "../response/code.js";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// 合法标识符:列名或「表别名.列名」(join 查询需显式前缀消歧,如 p.price)
|
|
11
|
+
const SORT_FIELD_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
|
|
11
12
|
const OPERATORS = { $like:'like', $gt:'>', $gte:'>=', $lt:'<', $lte:'<=', $ne:'<>' };
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -69,6 +70,52 @@ function applyQuery(dbQuery, query) {
|
|
|
69
70
|
return dbQuery;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* URL 平铺查询参数 → applyQuery 条件对象(通用搜索基建)
|
|
75
|
+
*
|
|
76
|
+
* @param {object} query - 平铺查询参数(如 req.query),值为字符串/数字
|
|
77
|
+
* @param {Object<string, Function>} fieldMap - 筛选字段白名单映射表:
|
|
78
|
+
* { urlParam: (value) => 条件片段 | null }
|
|
79
|
+
* - 仅声明的参数会进入查询,未声明的一律忽略(防任意列注入)
|
|
80
|
+
* - 转换函数负责类型收敛与 like 转义(utils.escapeLike),返回 null/undefined 表示无效值,忽略
|
|
81
|
+
* @returns {object} 条件对象,可直接交给 applyQuery 拼接 knex where
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* const FILTERS = {
|
|
85
|
+
* keyword: v => ({ title: { $like: `%${escapeLike(v)}%` } }),
|
|
86
|
+
* price_min: v => Number.isFinite(Number(v)) ? { price: { $gte: Number(v) } } : null,
|
|
87
|
+
* };
|
|
88
|
+
* mapUrlFilters({ keyword: "液压", price_min: "100" }, FILTERS);
|
|
89
|
+
* // → { title: { $like: "%液压%" }, price: { $gte: 100 } }
|
|
90
|
+
*/
|
|
91
|
+
function mapUrlFilters(query, fieldMap) {
|
|
92
|
+
const filter = {};
|
|
93
|
+
for (const [key, map] of Object.entries(fieldMap || {})) {
|
|
94
|
+
const raw = query?.[key];
|
|
95
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") continue;
|
|
96
|
+
let piece;
|
|
97
|
+
try {
|
|
98
|
+
piece = map(String(raw).trim());
|
|
99
|
+
} catch {
|
|
100
|
+
continue; // 转换函数异常视为无效参数,静默忽略(面向用户输入,前台容错)
|
|
101
|
+
}
|
|
102
|
+
if (!piece || typeof piece !== "object") continue;
|
|
103
|
+
for (const [field, cond] of Object.entries(piece)) {
|
|
104
|
+
const prev = filter[field];
|
|
105
|
+
// 同字段多操作符深合并(如 price 的 $gte 与 $lte 共存成区间)
|
|
106
|
+
if (
|
|
107
|
+
prev && typeof prev === "object" && !Array.isArray(prev) &&
|
|
108
|
+
cond && typeof cond === "object" && !Array.isArray(cond)
|
|
109
|
+
) {
|
|
110
|
+
filter[field] = { ...prev, ...cond };
|
|
111
|
+
} else {
|
|
112
|
+
filter[field] = cond;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return filter;
|
|
117
|
+
}
|
|
118
|
+
|
|
72
119
|
/**
|
|
73
120
|
* 应用字段白名单 + 排序白名单到 query
|
|
74
121
|
* @param {import('knex').Knex.QueryBuilder} q
|
|
@@ -270,9 +317,8 @@ class Repository extends BaseComponent {
|
|
|
270
317
|
|
|
271
318
|
/**
|
|
272
319
|
* 更新内部原语(私有方法):子类无法覆写。
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* {query,data} 被解构成 id=undefined → 更新静默失效(无 SQL、仍返回旧数据)。
|
|
320
|
+
* 业务 Service 普遍以扁平 body 形状覆写 update(body),若框架内部走多态的 this.update,
|
|
321
|
+
* {query,data} 会被子类签名解构致 id=undefined、更新静默失效。
|
|
276
322
|
* 框架内部路径必须走本私有方法,杜绝该类命名冲突。
|
|
277
323
|
*/
|
|
278
324
|
async #updateRaw(query, data) {
|
|
@@ -490,4 +536,7 @@ class Repository extends BaseComponent {
|
|
|
490
536
|
}
|
|
491
537
|
}
|
|
492
538
|
|
|
539
|
+
// 查询条件组装原语(业务 service 可直接组合使用,不必继承 Repository)
|
|
540
|
+
export { applyQuery, mapUrlFilters };
|
|
541
|
+
|
|
493
542
|
export default Repository;
|
|
@@ -68,9 +68,8 @@ export function registerErrorHandler(chan) {
|
|
|
68
68
|
const userId = req.user?.id ?? "-";
|
|
69
69
|
|
|
70
70
|
// 日志分级:业务错误 4xx → warn,系统错误 5xx → error(唯一打印点,杜绝重复刷屏)
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// 把真实的业务错误整个掩盖成 500),必须用箭头函数包一层保住 this
|
|
71
|
+
// pino 的日志方法依赖 this,不能解引用后裸调(会抛 TypeError 把真实业务错误掩盖成 500),
|
|
72
|
+
// 必须用箭头函数包一层保住 this
|
|
74
73
|
const logFn = httpStatus < 500 ? (msg) => logger.warn(msg) : (msg) => logger.error(msg);
|
|
75
74
|
logFn(JSON.stringify({
|
|
76
75
|
type: err.name || "UnknownError",
|
package/core/loader.js
CHANGED
|
@@ -63,8 +63,8 @@ export async function loadController(moduleName) {
|
|
|
63
63
|
// 原型方法批量bind,幂等标记避免重复绑定
|
|
64
64
|
// 遍历整条原型链(含父类继承的方法):子类方法定义在自身原型、父类模板方法定义在父类原型,
|
|
65
65
|
// 只 bind 直接原型会导致继承方法 this 丢失(Express 非点调用时 this=undefined)。
|
|
66
|
-
//
|
|
67
|
-
//
|
|
66
|
+
// 必须用 getOwnPropertyDescriptor 取 value:BaseComponent 的 app/config/db 是 getter-only,
|
|
67
|
+
// 其中 db 返回 knex 实例(typeof 为 function),直接 inst[key] 会触发 getter 且赋值只读属性抛错。
|
|
68
68
|
let proto = Object.getPrototypeOf(inst);
|
|
69
69
|
while (proto && proto !== Object.prototype) {
|
|
70
70
|
Object.getOwnPropertyNames(proto).forEach(key => {
|
package/index.js
CHANGED
|
@@ -22,6 +22,9 @@ export { BaseComponent } from "./core/BaseComponent.js";
|
|
|
22
22
|
export { default as Controller } from "./core/Controller.js";
|
|
23
23
|
export { default as Repository } from "./core/Repository.js";
|
|
24
24
|
export { Service } from "./core/Service.js";
|
|
25
|
+
// 查询条件组装原语(通用搜索基建:URL 平铺参数 → knex 安全 where)
|
|
26
|
+
export { applyQuery, mapUrlFilters } from "./core/Repository.js";
|
|
27
|
+
export { escapeLike } from "./utils/escape.js";
|
|
25
28
|
|
|
26
29
|
// ===================== 基础设施(事件总线 / 定时任务 / i18n)=====================
|
|
27
30
|
export { EventBus, event } from "./core/EventBus.js";
|
package/middleware/validate.js
CHANGED
package/middleware/waf.js
CHANGED
|
@@ -19,9 +19,8 @@ const WAF_KNOWN_KEYS = new Set(["enabled", "rateLimit", "ignorePaths", "bodyXssP
|
|
|
19
19
|
|
|
20
20
|
const TRUSTED_IPS = new Set(["127.0.0.1", "::1"]);
|
|
21
21
|
|
|
22
|
-
// URL/query
|
|
23
|
-
//
|
|
24
|
-
// 这些在正常 URL(?debug=1、/internal/、/.well-known/、wget/scp/dd 等)极易误伤正常请求。
|
|
22
|
+
// URL/query 关键词检测只保留真正危险、几乎不会在正常 URL 中出现的攻击特征,
|
|
23
|
+
// 避免 ?debug=1、/internal/、/.well-known/、wget/scp/dd 等正常请求被误伤。
|
|
25
24
|
const URL_SCAN_CATEGORIES = ["sqlInjection", "xss", "pathTraversal", "encoding"];
|
|
26
25
|
|
|
27
26
|
/**
|
package/package.json
CHANGED
package/utils/escape.js
ADDED
package/utils/index.js
CHANGED
package/utils/logger.js
CHANGED
|
@@ -24,8 +24,7 @@ const root = pino(
|
|
|
24
24
|
* 附加参数序列化:error/warn 的第二参(Error/对象)拼进消息本身。
|
|
25
25
|
*
|
|
26
26
|
* 背景:pino-pretty 的 messageFormat 字符串模板只渲染 msg 字段,
|
|
27
|
-
* 「logger.error(msg, err)」的 err
|
|
28
|
-
* 曾导致 Gather.js 加载失败只剩「导入失败 xxx.js:」空冒号、无法定位根因。
|
|
27
|
+
* 「logger.error(msg, err)」的 err 会被整体丢弃(连堆栈都不输出)。
|
|
29
28
|
* 在封装层统一序列化,保证文档承诺的「第二参数自动解析堆栈」真实生效。
|
|
30
29
|
* @param {unknown[]} args error/warn 的除消息外全部参数
|
|
31
30
|
* @returns {string} 可读文本(Error 优先 stack,对象 JSON,其余 String)
|
package/utils/pages.js
CHANGED
|
@@ -4,6 +4,73 @@
|
|
|
4
4
|
* 样式由项目 CSS(如 .blince-pagination)统一维护,不依赖 Tailwind 等第三方框架
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* 解析页码:非数字/0/负数统一回落 1,防 NaN 或负数穿透进 SQL LIMIT/OFFSET
|
|
9
|
+
* (?page=abc 这类脏值若直接 parseInt 后进查询,NaN 会让 knex LIMIT 报错)
|
|
10
|
+
* @param {*} value 页码来源(req.query / req.params 原始值)
|
|
11
|
+
* @returns {number} 至少为 1 的整数页码
|
|
12
|
+
*/
|
|
13
|
+
export function parsePage(value) {
|
|
14
|
+
return Math.max(1, parseInt(value, 10) || 1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 解析每页条数:非数字回落 fallback,并钳制 [1, max] 区间防大值拖垮 DB
|
|
19
|
+
* @param {*} value pageSize 来源(req.query / req.params 原始值)
|
|
20
|
+
* @param {number} [fallback=10] 非法/缺省时的默认条数
|
|
21
|
+
* @param {number} [max=100] 上限(来自公开 query 的值必须有上限)
|
|
22
|
+
* @returns {number} 钳制后的整数条数
|
|
23
|
+
*/
|
|
24
|
+
export function parsePageSize(value, fallback = 10, max = 100) {
|
|
25
|
+
return Math.min(Math.max(parseInt(value, 10) || fallback, 1), max);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 渲染 query 式分页 HTML(?current=N,保留筛选参数)
|
|
30
|
+
* 与 pages() 的分工:pages() 面向 /list-2.html 路径式分页(SEO 静态 URL,带省略号);
|
|
31
|
+
* 本函数面向带筛选条件的列表页——URLSearchParams 自动编码参数,天然防注入
|
|
32
|
+
* @param {Object} opts
|
|
33
|
+
* @param {string} opts.baseUrl 基础路径(如 /blog.html)
|
|
34
|
+
* @param {Object} [opts.query] 当前 URL query 对象,翻页时原样保留(剔除 current)
|
|
35
|
+
* @param {number} opts.total 总记录数
|
|
36
|
+
* @param {number} [opts.pageSize=10] 每页条数
|
|
37
|
+
* @param {number} [opts.current=1] 当前页码
|
|
38
|
+
* @param {string} [opts.unit="篇"] 统计文案量词(篇/家/条…)
|
|
39
|
+
* @param {number} [opts.window=2] 当前页前后各显示页数(窗口共 2*window+1 页)
|
|
40
|
+
* @returns {string} 总页数 ≤1 时返回空串
|
|
41
|
+
*/
|
|
42
|
+
export function pageHtml({ baseUrl, query, total, pageSize = 10, current = 1, unit = "篇", window = 2 }) {
|
|
43
|
+
total = parseInt(total) || 0;
|
|
44
|
+
current = parsePage(current);
|
|
45
|
+
pageSize = parsePageSize(pageSize);
|
|
46
|
+
const totalPage = Math.max(1, Math.ceil(total / pageSize));
|
|
47
|
+
if (totalPage <= 1) return "";
|
|
48
|
+
|
|
49
|
+
const base = new URLSearchParams();
|
|
50
|
+
Object.entries(query || {}).forEach(([k, v]) => {
|
|
51
|
+
if (k === "current" || v === undefined || v === null || v === "") return;
|
|
52
|
+
base.set(k, v);
|
|
53
|
+
});
|
|
54
|
+
const link = (n) => {
|
|
55
|
+
const p = new URLSearchParams(base);
|
|
56
|
+
if (n > 1) p.set("current", n);
|
|
57
|
+
const s = p.toString();
|
|
58
|
+
return `${baseUrl}${s ? "?" + s : ""}`;
|
|
59
|
+
};
|
|
60
|
+
const pageLink = (n) =>
|
|
61
|
+
n === current ? `<span class="current">${n}</span>` : `<a href="${link(n)}">${n}</a>`;
|
|
62
|
+
|
|
63
|
+
let html = "";
|
|
64
|
+
if (current > 1) html += `<a href="${link(current - 1)}">上一页</a>`;
|
|
65
|
+
let start = Math.max(1, current - window);
|
|
66
|
+
let end = Math.min(totalPage, start + window * 2);
|
|
67
|
+
start = Math.max(1, end - window * 2);
|
|
68
|
+
for (let i = start; i <= end; i++) html += pageLink(i);
|
|
69
|
+
if (current < totalPage) html += `<a href="${link(current + 1)}">下一页</a>`;
|
|
70
|
+
html += `<span class="disabled">共 ${total} ${unit} / ${totalPage} 页</span>`;
|
|
71
|
+
return html;
|
|
72
|
+
}
|
|
73
|
+
|
|
7
74
|
/**
|
|
8
75
|
* 渲染分页HTML
|
|
9
76
|
* @param {number} current 当前页码
|