diff --git a/.gitignore b/.gitignore index c8b75b27..b6179027 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ pnpm-debug.log* # Claude specific directories .claude/ +# MCP configuration (local only) +.mcp.json + # Data directory (contains sensitive information) data/ !data/.gitkeep diff --git a/VERSION b/VERSION index b2f4ea62..1f837000 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.110 +1.1.114 diff --git a/src/app.js b/src/app.js index 2f6d09cb..1a6a7d56 100644 --- a/src/app.js +++ b/src/app.js @@ -141,10 +141,62 @@ class Application { if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) { throw new Error('Invalid JSON: empty body') } + + // Unicode 字符清理 - 清理无效的 UTF-16 代理对 + if (buf && buf.length) { + try { + const str = buf.toString(encoding || 'utf8') + // 移除无效的 UTF-16 代理对字符 + const cleanedStr = str.replace( + /[\uDC00-\uDFFF](?![\uD800-\uDBFF])|[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, + '\uFFFD' + ) + + // 如果字符串被清理过,重新写入buffer + if (cleanedStr !== str) { + logger.warn('🧹 Cleaned invalid Unicode characters from request body') + const cleanedBuf = Buffer.from(cleanedStr, encoding || 'utf8') + // 将清理后的内容复制回原buffer + cleanedBuf.copy(buf, 0) + // 调整buffer长度 + buf._charsWritten = cleanedBuf.length + } + } catch (error) { + logger.warn( + '⚠️ Unicode cleaning failed, proceeding with original buffer:', + error.message + ) + } + } } }) ) this.app.use(express.urlencoded({ extended: true, limit: '10mb' })) + + // 🧹 Unicode 错误处理中间件 + this.app.use((err, req, res, next) => { + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + // 检查是否是Unicode相关的JSON解析错误 + if ( + err.message.includes('surrogate') || + err.message.includes('UTF-16') || + err.message.includes('invalid character') + ) { + logger.warn('🧹 Detected Unicode JSON parsing error, attempting recovery:', err.message) + + return res.status(400).json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: + 'The request body contains invalid Unicode characters. Please ensure your text uses valid UTF-8 encoding and does not contain malformed surrogate pairs.' + } + }) + } + } + next(err) + }) + this.app.use(securityMiddleware) // 🎯 信任代理 diff --git a/src/routes/admin.js b/src/routes/admin.js index f6e9cf2d..7b674e6e 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -799,7 +799,105 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } }) -// 删除API Key +// 批量删除API Keys(必须在 :keyId 路由之前定义) +router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { keyIds } = req.body + + // 调试信息 + logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`) + logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`) + + // 参数验证 + if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { + logger.warn( + `🚨 Invalid keyIds: ${JSON.stringify({ keyIds, type: typeof keyIds, isArray: Array.isArray(keyIds) })}` + ) + return res.status(400).json({ + error: 'Invalid request', + message: 'keyIds 必须是一个非空数组' + }) + } + + if (keyIds.length > 100) { + return res.status(400).json({ + error: 'Too many keys', + message: '每次最多只能删除100个API Keys' + }) + } + + // 验证keyIds格式 + const invalidKeys = keyIds.filter((id) => !id || typeof id !== 'string') + if (invalidKeys.length > 0) { + return res.status(400).json({ + error: 'Invalid key IDs', + message: '包含无效的API Key ID' + }) + } + + logger.info( + `🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}` + ) + + const results = { + successCount: 0, + failedCount: 0, + errors: [] + } + + // 逐个删除,记录成功和失败情况 + for (const keyId of keyIds) { + try { + // 检查API Key是否存在 + const apiKey = await redis.getApiKey(keyId) + if (!apiKey || Object.keys(apiKey).length === 0) { + results.failedCount++ + results.errors.push({ keyId, error: 'API Key 不存在' }) + continue + } + + // 执行删除 + await apiKeyService.deleteApiKey(keyId) + results.successCount++ + + logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`) + } catch (error) { + results.failedCount++ + results.errors.push({ + keyId, + error: error.message || '删除失败' + }) + + logger.error(`❌ Batch delete failed for key ${keyId}:`, error) + } + } + + // 记录批量删除结果 + if (results.successCount > 0) { + logger.success( + `🎉 Batch delete completed: ${results.successCount} successful, ${results.failedCount} failed` + ) + } else { + logger.warn( + `⚠️ Batch delete completed with no successful deletions: ${results.failedCount} failed` + ) + } + + return res.json({ + success: true, + message: `批量删除完成`, + data: results + }) + } catch (error) { + logger.error('❌ Failed to batch delete API keys:', error) + return res.status(500).json({ + error: 'Batch delete failed', + message: error.message + }) + } +}) + +// 删除单个API Key(必须在批量删除路由之后定义) router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params diff --git a/src/routes/api.js b/src/routes/api.js index 3b1c4160..9f66bc6d 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -12,11 +12,52 @@ const sessionHelper = require('../utils/sessionHelper') const router = express.Router() +// 🧹 Unicode 字符清理函数 +function cleanUnicodeString(str) { + if (typeof str !== 'string') { + return str + } + + // 移除无效的 UTF-16 代理对字符 + // 匹配无效的低代理字符 (0xDC00-0xDFFF) 没有对应的高代理字符 + // 匹配无效的高代理字符 (0xD800-0xDBFF) 没有对应的低代理字符 + return str.replace( + /[\uDC00-\uDFFF](?![\uD800-\uDBFF])|[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, + '\uFFFD' + ) +} + +// 🧹 递归清理对象中的 Unicode 字符 +function cleanUnicodeInObject(obj) { + if (typeof obj === 'string') { + return cleanUnicodeString(obj) + } + + if (Array.isArray(obj)) { + return obj.map((item) => cleanUnicodeInObject(item)) + } + + if (obj && typeof obj === 'object') { + const cleaned = {} + for (const [key, value] of Object.entries(obj)) { + cleaned[cleanUnicodeString(key)] = cleanUnicodeInObject(value) + } + return cleaned + } + + return obj +} + // 🔧 共享的消息处理函数 async function handleMessagesRequest(req, res) { try { const startTime = Date.now() + // Unicode 字符清理 - 在输入验证之前清理请求体 + if (req.body) { + req.body = cleanUnicodeInObject(req.body) + } + // 严格的输入验证 if (!req.body || typeof req.body !== 'object') { return res.status(400).json({ diff --git a/web/admin-spa/src/config/api.js b/web/admin-spa/src/config/api.js index dbe02a5a..155d8bcf 100644 --- a/web/admin-spa/src/config/api.js +++ b/web/admin-spa/src/config/api.js @@ -152,9 +152,12 @@ class ApiClient { // DELETE 请求 async delete(url, options = {}) { const fullUrl = createApiUrl(url) + const { data, ...restOptions } = options + const config = this.buildConfig({ - ...options, - method: 'DELETE' + ...restOptions, + method: 'DELETE', + body: data ? JSON.stringify(data) : undefined }) try { diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index e8285841..fbf4dfcc 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -88,6 +88,19 @@ /> 刷新 + + +