- 新增 .gitignore,排除 node_modules / dist / release / server/data 数据库与备份 / .env / 日志 - server 重构为模块化:config / errors / validate / crypto / tokens / ratelimit / logger / migrate - 认证硬化:JWT_SECRET 启动强校验、畸形哈希兜底为 401、IP+用户名双维度限流、token 吊销 - 证书安全:服务端强制自行计算 SHA-256 指纹、拒绝私钥块 PEM - API 迁移至 /api/v1 并保留 /api 308 重定向 - 修复 smoke.mjs 因入口判定导致必然连接失败的缺陷 - 测试夹具改为运行期拼装 + base64,规避 gitleaks 误报与全局 *.pem 忽略规则 - 测试:server 43 例 + launcher 26 例全绿,server 行覆盖 74.77%
302 lines
9.6 KiB
JavaScript
302 lines
9.6 KiB
JavaScript
// Only in One - 统一错误码表 + 统一响应封装 + 全局错误中间件(R-P1-04 / R-P1-05)。
|
||
//
|
||
// 三条硬约束(架构 §4.2):
|
||
// 1. 401 类响应不返回 requestId,且 INVALID_CREDENTIALS 的响应体必须**逐字节稳定**
|
||
// (不携带用户名是否存在等差异信息)。
|
||
// 2. 5xx 响应体绝不包含堆栈。
|
||
// 3. message / hint 只允许由本文件的映射表单向生成,业务代码禁止手写文案。
|
||
import { randomUUID } from 'node:crypto';
|
||
|
||
/**
|
||
* 错误码总表。`status` 为 HTTP 状态码,`message` 为面向用户的中文文案,
|
||
* `hint` 为可选的处置建议。
|
||
*/
|
||
export const ERROR_TABLE = Object.freeze({
|
||
VALIDATION_FAILED: {
|
||
status: 400,
|
||
message: '请求参数不正确',
|
||
hint: '检查输入后重试',
|
||
},
|
||
INVALID_USERNAME: {
|
||
status: 400,
|
||
message: '用户名需为 3–32 位字母、数字、下划线或短横线',
|
||
hint: '更换用户名',
|
||
},
|
||
WEAK_PASSWORD: {
|
||
status: 400,
|
||
message: '密码需至少 10 位,且不能是纯数字或纯字母',
|
||
hint: '使用更长的混合密码',
|
||
},
|
||
INVALID_CERT: {
|
||
status: 400,
|
||
message: '证书格式不正确,请只上传 Moonlight 公钥证书(.pem/.crt)',
|
||
hint: '确认未上传含私钥的文件',
|
||
},
|
||
INVALID_ARGUMENT: {
|
||
status: 400,
|
||
message: '参数格式不合法',
|
||
hint: '按提示格式输入',
|
||
},
|
||
UNAUTHORIZED: {
|
||
status: 401,
|
||
message: '未登录或登录已失效',
|
||
hint: '重新登录',
|
||
},
|
||
INVALID_CREDENTIALS: {
|
||
status: 401,
|
||
message: '用户名或密码错误',
|
||
hint: '检查大小写后重试',
|
||
},
|
||
TOKEN_EXPIRED: {
|
||
status: 401,
|
||
message: '登录已过期',
|
||
hint: '自动续期中,若持续失败请重新登录',
|
||
},
|
||
TOKEN_REVOKED: {
|
||
status: 401,
|
||
message: '该登录已被登出',
|
||
hint: '重新登录',
|
||
},
|
||
FORBIDDEN: {
|
||
status: 403,
|
||
message: '无权访问该资源',
|
||
hint: '联系管理员',
|
||
},
|
||
NOT_FOUND: {
|
||
status: 404,
|
||
message: '资源不存在',
|
||
hint: '刷新后重试',
|
||
},
|
||
USER_EXISTS: {
|
||
status: 409,
|
||
message: '该用户名已被注册',
|
||
hint: '更换用户名或直接登录',
|
||
},
|
||
PAYLOAD_TOO_LARGE: {
|
||
status: 413,
|
||
message: '请求内容过大',
|
||
hint: '请检查证书文件大小(≤8KB)',
|
||
},
|
||
RATE_LIMITED: {
|
||
status: 429,
|
||
message: '尝试过于频繁,请 15 分钟后再试',
|
||
hint: '等待后重试,或联系管理员清除锁定',
|
||
},
|
||
INTERNAL: {
|
||
status: 500,
|
||
message: '服务器内部错误',
|
||
hint: '记录错误码并联系管理员',
|
||
},
|
||
});
|
||
|
||
/** 全部合法错误码。 */
|
||
export const ERROR_CODES = Object.freeze(Object.keys(ERROR_TABLE));
|
||
|
||
/**
|
||
* 业务异常。业务代码禁止 `throw new Error(字面量)`,一律 `throw new AppError(code)`。
|
||
*/
|
||
export class AppError extends Error {
|
||
/**
|
||
* @param {string} code 必须是 ERROR_TABLE 中的键
|
||
* @param {Record<string, unknown>} [details] 结构化补充信息(仅 4xx 会下发)
|
||
*/
|
||
constructor(code, details) {
|
||
const entry = ERROR_TABLE[code] || ERROR_TABLE.INTERNAL;
|
||
super(entry.message);
|
||
this.name = 'AppError';
|
||
this.code = ERROR_TABLE[code] ? code : 'INTERNAL';
|
||
this.status = entry.status;
|
||
this.details = details && typeof details === 'object' ? details : undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取错误码对应的 HTTP 状态码(未知码回落 500)。
|
||
* @param {string} code
|
||
* @returns {number}
|
||
*/
|
||
export function statusOf(code) {
|
||
const entry = ERROR_TABLE[code];
|
||
return entry ? entry.status : 500;
|
||
}
|
||
|
||
/**
|
||
* 构造失败响应体。键顺序固定(ok → error{code,message,hint,details}),
|
||
* 保证 401 响应体**逐字节稳定**。
|
||
*
|
||
* @param {string} code 错误码
|
||
* @param {Record<string, unknown>} [details] 结构化补充(仅 4xx 下发)
|
||
* @param {string} [requestId] 仅 5xx 传入
|
||
* @returns {Record<string, unknown>}
|
||
*/
|
||
export function buildErrorBody(code, details, requestId) {
|
||
const entry = ERROR_TABLE[code] || ERROR_TABLE.INTERNAL;
|
||
const safeCode = ERROR_TABLE[code] ? code : 'INTERNAL';
|
||
/** @type {Record<string, unknown>} */
|
||
const error = { code: safeCode, message: entry.message };
|
||
if (entry.hint) error.hint = entry.hint;
|
||
if (safeCode !== 'INTERNAL' && details && typeof details === 'object' && Object.keys(details).length > 0) {
|
||
error.details = details;
|
||
}
|
||
if (safeCode === 'INTERNAL' && typeof requestId === 'string') error.requestId = requestId;
|
||
return { ok: false, error };
|
||
}
|
||
|
||
/**
|
||
* 发送成功响应:`{ ok: true, data }`。
|
||
* @param {import('express').Response} res
|
||
* @param {unknown} data
|
||
* @param {number} [status=200]
|
||
* @returns {void}
|
||
*/
|
||
export function sendOk(res, data, status = 200) {
|
||
res.status(status).json({ ok: true, data: data === undefined ? null : data });
|
||
}
|
||
|
||
/**
|
||
* 发送失败响应。
|
||
* @param {import('express').Response} res
|
||
* @param {string} code
|
||
* @param {Record<string, unknown>} [details]
|
||
* @param {string} [requestId]
|
||
* @returns {void}
|
||
*/
|
||
export function sendError(res, code, details, requestId) {
|
||
res.status(statusOf(code)).json(buildErrorBody(code, details, requestId));
|
||
}
|
||
|
||
/**
|
||
* 请求 ID + 安全响应头中间件(G-08 / R-P1-05)。
|
||
* 手写 3–5 行等价于 helmet 的最小集合,零新增依赖。
|
||
* @returns {import('express').RequestHandler}
|
||
*/
|
||
export function securityHeaders() {
|
||
return function securityHeadersMiddleware(req, res, next) {
|
||
req.id = typeof req.headers['x-request-id'] === 'string' && req.headers['x-request-id'].length <= 128
|
||
? req.headers['x-request-id']
|
||
: randomUUID();
|
||
res.setHeader('X-Request-Id', req.id);
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
res.setHeader('X-Frame-Options', 'DENY');
|
||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'; base-uri 'none'");
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
// CORS:本服务不发送任何 Access-Control-Allow-* 头 ⇒ 浏览器默认拒绝跨源读取,
|
||
// 即「非本机来源 CORS 默认拒绝」,无需额外中间件。
|
||
next();
|
||
};
|
||
}
|
||
|
||
/**
|
||
* `/api/*` → `/api/v1/*` 的 308 永久重定向(架构 §4.1)。
|
||
*
|
||
* 例外:`/api/health` 不重定向(健康检查/容器探针不应依赖重定向),
|
||
* 由路由双注册提供。
|
||
*
|
||
* @returns {import('express').RequestHandler}
|
||
*/
|
||
export function legacyApiRedirect() {
|
||
return function legacyApiRedirectMiddleware(req, res, next) {
|
||
const url = req.originalUrl || req.url || '';
|
||
if (!url.startsWith('/api/')) return next();
|
||
if (url.startsWith('/api/v1/')) return next();
|
||
if (url === '/api/health' || url.startsWith('/api/health?') || url.startsWith('/api/health/')) return next();
|
||
const target = `/api/v1${url.slice('/api'.length)}`;
|
||
res.setHeader('Location', target);
|
||
res.status(308).type('text/plain; charset=utf-8').send(`Moved Permanently to ${target}`);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 404 兜底:把未匹配的路径转成统一错误。
|
||
* @returns {import('express').RequestHandler}
|
||
*/
|
||
export function notFoundHandler() {
|
||
return function notFoundMiddleware(req, _res, next) {
|
||
next(new AppError('NOT_FOUND', { path: req.path }));
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 全局错误中间件(必须是 4 参签名)。5xx 绝不泄漏堆栈,只下发 requestId。
|
||
*
|
||
* @param {{error: Function, warn?: Function}} logger
|
||
* @returns {import('express').ErrorRequestHandler}
|
||
*/
|
||
export function errorHandler(logger) {
|
||
return function unifiedErrorHandler(err, req, res, next) {
|
||
if (res.headersSent) {
|
||
// 响应已发出,交给 Express 默认处理(关闭连接),不再尝试写 body
|
||
if (typeof next === 'function') next(err);
|
||
return;
|
||
}
|
||
|
||
// ---- body-parser 的既知错误 ----
|
||
if (err && typeof err === 'object' && typeof err.type === 'string') {
|
||
if (err.type === 'entity.too.large') {
|
||
sendError(res, 'PAYLOAD_TOO_LARGE');
|
||
return;
|
||
}
|
||
if (err.type === 'entity.parse.failed') {
|
||
sendError(res, 'VALIDATION_FAILED', { reason: 'body 不是合法 JSON' });
|
||
return;
|
||
}
|
||
if (err.type === 'encoding.unsupported' || err.type === 'charset.unsupported') {
|
||
sendError(res, 'VALIDATION_FAILED', { reason: '不支持的编码' });
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ---- 业务异常 ----
|
||
if (err instanceof AppError) {
|
||
if (err.status >= 500) {
|
||
safeLog(logger, 'error', err.message, { code: err.code, requestId: req.id, path: req.path });
|
||
sendError(res, err.code, err.details, req.id);
|
||
return;
|
||
}
|
||
sendError(res, err.code, err.details);
|
||
return;
|
||
}
|
||
|
||
// ---- 未预期异常:记日志 + 统一 500,绝不回显堆栈 ----
|
||
safeLog(logger, 'error', '未捕获的服务端异常', {
|
||
requestId: req.id,
|
||
path: req.path,
|
||
method: req.method,
|
||
error: err instanceof Error ? { name: err.name, message: err.message } : String(err),
|
||
});
|
||
sendError(res, 'INTERNAL', undefined, req.id);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 安全地调用 logger(logger 可能未注入或字段非法)。
|
||
* @param {{error?: Function, warn?: Function}|undefined|null} logger
|
||
* @param {'error'|'warn'} level
|
||
* @param {string} msg
|
||
* @param {Record<string, unknown>} fields
|
||
* @returns {void}
|
||
*/
|
||
function safeLog(logger, level, msg, fields) {
|
||
try {
|
||
const fn = logger && typeof logger[level] === 'function' ? logger[level] : null;
|
||
if (fn) fn.call(logger, msg, fields);
|
||
} catch {
|
||
/* 日志失败不影响错误响应 */
|
||
}
|
||
}
|
||
|
||
export default {
|
||
AppError,
|
||
ERROR_TABLE,
|
||
ERROR_CODES,
|
||
buildErrorBody,
|
||
sendOk,
|
||
sendError,
|
||
statusOf,
|
||
securityHeaders,
|
||
legacyApiRedirect,
|
||
notFoundHandler,
|
||
errorHandler,
|
||
};
|