Files
jkooe 7ddd344d7f chore: 初始化仓库并落地 T-1 批次1(安全地基与仓库卫生)
- 新增 .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%
2026-10-26 00:10:01 +08:00

302 lines
9.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};