diff --git a/.gitignore b/.gitignore index e05a051..45981ee 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules CLAUDE.md config.json provider_pools.json +plugins.json fetch_system_prompt.txt input_system_prompt.txt token-store.json diff --git a/VERSION b/VERSION index 35d16fb..ecd7ee5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.7 +2.5.8 diff --git a/configs/plugins.json.example b/configs/plugins.json.example new file mode 100644 index 0000000..4f6311c --- /dev/null +++ b/configs/plugins.json.example @@ -0,0 +1,12 @@ +{ + "plugins": { + "api-potluck": { + "enabled": true, + "description": "API 大锅饭 - Key 管理和用量统计插件" + }, + "default-auth": { + "enabled": true, + "description": "默认 API Key 认证插件(内置)" + } + } +} \ No newline at end of file diff --git a/src/api-potluck/api-routes.js b/src/api-potluck/api-routes.js deleted file mode 100644 index 3819aaa..0000000 --- a/src/api-potluck/api-routes.js +++ /dev/null @@ -1,277 +0,0 @@ -/** - * API 大锅饭 - 管理 API 路由 - * 提供 Key 管理的 RESTful API - */ - -import { - createKey, - listKeys, - getKey, - deleteKey, - updateKeyLimit, - resetKeyUsage, - toggleKey, - updateKeyName, - getStats -} from './key-manager.js'; - -/** - * 解析请求体 - * @param {http.IncomingMessage} req - * @returns {Promise} - */ -function parseRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => { - body += chunk.toString(); - }); - req.on('end', () => { - try { - resolve(body ? JSON.parse(body) : {}); - } catch (error) { - reject(new Error('Invalid JSON format')); - } - }); - req.on('error', reject); - }); -} - -/** - * 发送 JSON 响应 - * @param {http.ServerResponse} res - * @param {number} statusCode - * @param {Object} data - */ -function sendJson(res, statusCode, data) { - res.writeHead(statusCode, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(data)); -} - -/** - * 验证管理员 Token - * @param {http.IncomingMessage} req - * @returns {Promise} - */ -async function checkAdminAuth(req) { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return false; - } - - // 动态导入 ui-manager 中的 token 验证逻辑 - try { - const { existsSync, readFileSync } = await import('fs'); - const { promises: fs } = await import('fs'); - const path = await import('path'); - - const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); - - if (!existsSync(TOKEN_STORE_FILE)) { - return false; - } - - const content = readFileSync(TOKEN_STORE_FILE, 'utf8'); - const tokenStore = JSON.parse(content); - const token = authHeader.substring(7); - const tokenInfo = tokenStore.tokens[token]; - - if (!tokenInfo) { - return false; - } - - // 检查是否过期 - if (Date.now() > tokenInfo.expiryTime) { - return false; - } - - return true; - } catch (error) { - console.error('[API Potluck] Auth check error:', error.message); - return false; - } -} - -/** - * 处理 Potluck 管理 API 请求 - * @param {string} method - HTTP 方法 - * @param {string} path - 请求路径 - * @param {http.IncomingMessage} req - HTTP 请求对象 - * @param {http.ServerResponse} res - HTTP 响应对象 - * @returns {Promise} - 是否处理了请求 - */ -export async function handlePotluckApiRoutes(method, path, req, res) { - // 只处理 /api/potluck 开头的请求 - if (!path.startsWith('/api/potluck')) { - return false; - } - - // 验证管理员权限 - const isAuthed = await checkAdminAuth(req); - if (!isAuthed) { - sendJson(res, 401, { - success: false, - error: { message: 'Unauthorized: Please login first', code: 'UNAUTHORIZED' } - }); - return true; - } - - try { - // GET /api/potluck/stats - 获取统计信息 - if (method === 'GET' && path === '/api/potluck/stats') { - const stats = await getStats(); - sendJson(res, 200, { success: true, data: stats }); - return true; - } - - // GET /api/potluck/keys - 获取所有 Key 列表 - if (method === 'GET' && path === '/api/potluck/keys') { - const keys = await listKeys(); - const stats = await getStats(); - sendJson(res, 200, { - success: true, - data: { - keys, - stats - } - }); - return true; - } - - // POST /api/potluck/keys - 创建新 Key - if (method === 'POST' && path === '/api/potluck/keys') { - const body = await parseRequestBody(req); - const { name, dailyLimit } = body; - const keyData = await createKey(name, dailyLimit); - sendJson(res, 201, { - success: true, - message: 'API Key created successfully', - data: keyData - }); - return true; - } - - // 处理带 keyId 的路由 - const keyIdMatch = path.match(/^\/api\/potluck\/keys\/([^\/]+)(\/.*)?$/); - if (keyIdMatch) { - const keyId = decodeURIComponent(keyIdMatch[1]); - const subPath = keyIdMatch[2] || ''; - - // GET /api/potluck/keys/:keyId - 获取单个 Key 详情 - if (method === 'GET' && !subPath) { - const keyData = await getKey(keyId); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); - return true; - } - sendJson(res, 200, { success: true, data: keyData }); - return true; - } - - // DELETE /api/potluck/keys/:keyId - 删除 Key - if (method === 'DELETE' && !subPath) { - const deleted = await deleteKey(keyId); - if (!deleted) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); - return true; - } - sendJson(res, 200, { success: true, message: 'Key deleted successfully' }); - return true; - } - - // PUT /api/potluck/keys/:keyId/limit - 更新每日限额 - if (method === 'PUT' && subPath === '/limit') { - const body = await parseRequestBody(req); - const { dailyLimit } = body; - - if (typeof dailyLimit !== 'number' || dailyLimit < 0) { - sendJson(res, 400, { - success: false, - error: { message: 'Invalid dailyLimit value' } - }); - return true; - } - - const keyData = await updateKeyLimit(keyId, dailyLimit); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); - return true; - } - sendJson(res, 200, { - success: true, - message: 'Daily limit updated successfully', - data: keyData - }); - return true; - } - - // POST /api/potluck/keys/:keyId/reset - 重置当天调用次数 - if (method === 'POST' && subPath === '/reset') { - const keyData = await resetKeyUsage(keyId); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); - return true; - } - sendJson(res, 200, { - success: true, - message: 'Usage reset successfully', - data: keyData - }); - return true; - } - - // POST /api/potluck/keys/:keyId/toggle - 切换启用/禁用状态 - if (method === 'POST' && subPath === '/toggle') { - const keyData = await toggleKey(keyId); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); - return true; - } - sendJson(res, 200, { - success: true, - message: `Key ${keyData.enabled ? 'enabled' : 'disabled'} successfully`, - data: keyData - }); - return true; - } - - // PUT /api/potluck/keys/:keyId/name - 更新 Key 名称 - if (method === 'PUT' && subPath === '/name') { - const body = await parseRequestBody(req); - const { name } = body; - - if (!name || typeof name !== 'string') { - sendJson(res, 400, { - success: false, - error: { message: 'Invalid name value' } - }); - return true; - } - - const keyData = await updateKeyName(keyId, name); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); - return true; - } - sendJson(res, 200, { - success: true, - message: 'Name updated successfully', - data: keyData - }); - return true; - } - } - - // 未匹配的 potluck 路由 - sendJson(res, 404, { success: false, error: { message: 'Potluck API endpoint not found' } }); - return true; - - } catch (error) { - console.error('[API Potluck] API error:', error); - sendJson(res, 500, { - success: false, - error: { message: error.message || 'Internal server error' } - }); - return true; - } -} diff --git a/src/api-potluck/index.js b/src/api-potluck/index.js deleted file mode 100644 index 4530c20..0000000 --- a/src/api-potluck/index.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * API 大锅饭 - 模块入口 - * 导出所有功能供外部使用 - */ - -// Key 管理 -export { - createKey, - listKeys, - getKey, - deleteKey, - updateKeyLimit, - resetKeyUsage, - toggleKey, - updateKeyName, - validateKey, - incrementUsage, - getStats, - KEY_PREFIX, - DEFAULT_DAILY_LIMIT -} from './key-manager.js'; - -// 中间件 -export { - extractPotluckKey, - isPotluckRequest, - potluckAuthMiddleware, - recordPotluckUsage, - sendPotluckError -} from './middleware.js'; - -// API 路由 -export { handlePotluckApiRoutes } from './api-routes.js'; diff --git a/src/api-server.js b/src/api-server.js index 342fac0..3a305eb 100644 --- a/src/api-server.js +++ b/src/api-server.js @@ -4,6 +4,7 @@ import { initApiService, autoLinkProviderConfigs } from './service-manager.js'; import { initializeUIManagement } from './ui-manager.js'; import { initializeAPIManagement } from './api-manager.js'; import { createRequestHandler } from './request-handler.js'; +import { discoverPlugins, getPluginManager } from './plugin-manager.js'; /** * @license @@ -224,6 +225,22 @@ async function startServer() { console.log('[Initialization] Checking for unlinked provider configs...'); await autoLinkProviderConfigs(CONFIG); + // Initialize plugin system + console.log('[Initialization] Discovering and initializing plugins...'); + await discoverPlugins(); + const pluginManager = getPluginManager(); + await pluginManager.initAll(CONFIG); + + // Log loaded plugins + const pluginList = pluginManager.getPluginList(); + if (pluginList.length > 0) { + console.log(`[Plugins] Loaded ${pluginList.length} plugin(s):`); + pluginList.forEach(p => { + const status = p.enabled ? '✓' : '✗'; + console.log(` ${status} ${p.name} v${p.version} - ${p.description}`); + }); + } + // Initialize API services const services = await initApiService(CONFIG); diff --git a/src/claude/claude-kiro.js b/src/claude/claude-kiro.js index b015eec..1929fa8 100644 --- a/src/claude/claude-kiro.js +++ b/src/claude/claude-kiro.js @@ -372,11 +372,19 @@ export class KiroApiService { } async initializeAuth(forceRefresh = false) { + // 如果已有 accessToken 且不是强制刷新,直接返回 if (this.accessToken && !forceRefresh) { console.debug('[Kiro Auth] Access token already available and not forced refresh.'); return; } + // 如果是强制刷新且已有 refreshToken,跳过凭证加载,直接刷新 + if (forceRefresh && this.refreshToken) { + console.debug('[Kiro Auth] Force refresh requested, skipping credential loading.'); + // 直接跳转到刷新逻辑 + return this._refreshAccessToken(); + } + // Helper to load credentials from a file const loadCredentialsFromFile = async (filePath) => { try { @@ -486,60 +494,9 @@ export class KiroApiService { console.warn(`[Kiro Auth] Error during credential loading: ${error.message}`); } - // Refresh token if forced or if access token is missing but refresh token is available - if (forceRefresh || (!this.accessToken && this.refreshToken)) { - if (!this.refreshToken) { - throw new Error('No refresh token available to refresh access token.'); - } - try { - const requestBody = { - refreshToken: this.refreshToken, - }; - - let refreshUrl = this.refreshUrl; - if (this.authMethod !== KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { - refreshUrl = this.refreshIDCUrl; - requestBody.clientId = this.clientId; - requestBody.clientSecret = this.clientSecret; - requestBody.grantType = 'refresh_token'; - } - - let response = null; - if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { - response = await this.axiosSocialRefreshInstance.post(refreshUrl, requestBody); - console.log('[Kiro Auth] Token refresh social response: ok'); - } else { - response = await this.axiosInstance.post(refreshUrl, requestBody); - console.log('[Kiro Auth] Token refresh idc response: ok'); - } - - if (response.data && response.data.accessToken) { - this.accessToken = response.data.accessToken; - this.refreshToken = response.data.refreshToken; - this.profileArn = response.data.profileArn; - const expiresIn = response.data.expiresIn; - const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); - this.expiresAt = expiresAt; - console.info('[Kiro Auth] Access token refreshed successfully'); - - // Update the token file - use specified path if configured, otherwise use default - const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); - const updatedTokenData = { - accessToken: this.accessToken, - refreshToken: this.refreshToken, - expiresAt: expiresAt, - }; - if (this.profileArn) { - updatedTokenData.profileArn = this.profileArn; - } - await saveCredentialsToFile(tokenFilePath, updatedTokenData); - } else { - throw new Error('Invalid refresh response: Missing accessToken'); - } - } catch (error) { - console.error('[Kiro Auth] Token refresh failed:', error.message); - throw new Error(`Token refresh failed: ${error.message}`); - } + // Refresh token if access token is missing but refresh token is available + if (!this.accessToken && this.refreshToken) { + await this._refreshAccessToken(); } if (!this.accessToken) { @@ -547,6 +504,88 @@ export class KiroApiService { } } + /** + * 内部方法:刷新 access token + * @private + */ + async _refreshAccessToken() { + if (!this.refreshToken) { + throw new Error('No refresh token available to refresh access token.'); + } + + // Helper to save credentials to a file + const saveCredentialsToFile = async (filePath, newData) => { + try { + let existingData = {}; + try { + const fileContent = await fs.readFile(filePath, 'utf8'); + existingData = JSON.parse(fileContent); + } catch (readError) { + if (readError.code === 'ENOENT') { + console.debug(`[Kiro Auth] Token file not found, creating new one: ${filePath}`); + } else { + console.warn(`[Kiro Auth] Could not read existing token file ${filePath}: ${readError.message}`); + } + } + const mergedData = { ...existingData, ...newData }; + await fs.writeFile(filePath, JSON.stringify(mergedData, null, 2), 'utf8'); + console.info(`[Kiro Auth] Updated token file: ${filePath}`); + } catch (error) { + console.error(`[Kiro Auth] Failed to write token to file ${filePath}: ${error.message}`); + } + }; + + try { + const requestBody = { + refreshToken: this.refreshToken, + }; + + let refreshUrl = this.refreshUrl; + if (this.authMethod !== KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { + refreshUrl = this.refreshIDCUrl; + requestBody.clientId = this.clientId; + requestBody.clientSecret = this.clientSecret; + requestBody.grantType = 'refresh_token'; + } + + let response = null; + if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { + response = await this.axiosSocialRefreshInstance.post(refreshUrl, requestBody); + console.log('[Kiro Auth] Token refresh social response: ok'); + } else { + response = await this.axiosInstance.post(refreshUrl, requestBody); + console.log('[Kiro Auth] Token refresh idc response: ok'); + } + + if (response.data && response.data.accessToken) { + this.accessToken = response.data.accessToken; + this.refreshToken = response.data.refreshToken; + this.profileArn = response.data.profileArn; + const expiresIn = response.data.expiresIn; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + this.expiresAt = expiresAt; + console.info('[Kiro Auth] Access token refreshed successfully'); + + // Update the token file - use specified path if configured, otherwise use default + const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); + const updatedTokenData = { + accessToken: this.accessToken, + refreshToken: this.refreshToken, + expiresAt: expiresAt, + }; + if (this.profileArn) { + updatedTokenData.profileArn = this.profileArn; + } + await saveCredentialsToFile(tokenFilePath, updatedTokenData); + } else { + throw new Error('Invalid refresh response: Missing accessToken'); + } + } catch (error) { + console.error('[Kiro Auth] Token refresh failed:', error.message); + throw new Error(`Token refresh failed: ${error.message}`); + } + } + /** * Extract text content from OpenAI message format */ diff --git a/src/common.js b/src/common.js index b71e4ba..1376444 100644 --- a/src/common.js +++ b/src/common.js @@ -4,6 +4,7 @@ import * as http from 'http'; // Add http for IncomingMessage and ServerResponse import * as crypto from 'crypto'; // Import crypto for MD5 hashing import { convertData, getOpenAIStreamChunkStop } from './convert.js'; import { ProviderStrategyFactory } from './provider-strategies.js'; +import { getPluginManager } from './plugin-manager.js'; // ==================== 网络错误处理 ==================== @@ -491,14 +492,11 @@ export async function handleContentGenerationRequest(req, res, service, endpoint await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName); } - // ============== API 大锅饭插件 - 开始 ============== - if (CONFIG.potluckApiKey) { - try { - const { recordPotluckUsage } = await import('./api-potluck/index.js'); - await recordPotluckUsage(CONFIG.potluckApiKey); - } catch (e) { /* 静默失败,不影响主流程 */ } - } - // ============== API 大锅饭插件 - 结束 ============== + // 执行插件钩子:内容生成后 + try { + const pluginManager = getPluginManager(); + await pluginManager.executeHook('onContentGenerated', CONFIG); + } catch (e) { /* 静默失败,不影响主流程 */ } } /** diff --git a/src/oauth-handlers.js b/src/oauth-handlers.js index e0428f1..0c42c87 100644 --- a/src/oauth-handlers.js +++ b/src/oauth-handlers.js @@ -1485,6 +1485,7 @@ export async function refreshIFlowTokens(refreshToken) { */ const KIRO_REFRESH_CONSTANTS = { REFRESH_URL: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', + REFRESH_IDC_URL: 'https://oidc.{{region}}.amazonaws.com/token', CONTENT_TYPE_JSON: 'application/json', AUTH_METHOD_SOCIAL: 'social', DEFAULT_PROVIDER: 'Google', diff --git a/src/plugin-manager.js b/src/plugin-manager.js new file mode 100644 index 0000000..2a8e96d --- /dev/null +++ b/src/plugin-manager.js @@ -0,0 +1,504 @@ +/** + * 插件管理器 - 可插拔插件系统核心 + * + * 功能: + * 1. 插件注册与加载 + * 2. 生命周期管理(init/destroy) + * 3. 扩展点管理(中间件、路由、钩子) + * 4. 插件配置管理 + */ + +import { promises as fs } from 'fs'; +import { existsSync } from 'fs'; +import path from 'path'; + +// 插件配置文件路径 +const PLUGINS_CONFIG_FILE = path.join(process.cwd(), 'configs', 'plugins.json'); + +/** + * 插件类型常量 + */ +export const PLUGIN_TYPE = { + AUTH: 'auth', // 认证插件,参与认证流程 + MIDDLEWARE: 'middleware' // 普通中间件,不参与认证 +}; + +/** + * 插件接口定义(JSDoc 类型) + * @typedef {Object} Plugin + * @property {string} name - 插件名称(唯一标识) + * @property {string} version - 插件版本 + * @property {string} [description] - 插件描述 + * @property {string} [type] - 插件类型:'auth'(认证插件)或 'middleware'(普通中间件,默认) + * @property {boolean} [enabled] - 是否启用(默认 true) + * @property {number} [_priority] - 优先级,数字越小越先执行(默认 100) + * @property {boolean} [_builtin] - 是否为内置插件(内置插件最后执行) + * @property {Function} [init] - 初始化钩子 (config) => Promise + * @property {Function} [destroy] - 销毁钩子 () => Promise + * @property {Function} [middleware] - 请求中间件 (req, res, requestUrl, config) => Promise<{handled: boolean, data?: Object}> + * @property {Function} [authenticate] - 认证方法(仅 type='auth' 时有效)(req, res, requestUrl, config) => Promise<{handled: boolean, authorized: boolean|null, error?: Object, data?: Object}> + * @property {Array<{method: string, path: string|RegExp, handler: Function}>} [routes] - 路由定义 + * @property {string[]} [staticPaths] - 静态文件路径(相对于 static 目录) + * @property {Object} [hooks] - 钩子函数 + * @property {Function} [hooks.onBeforeRequest] - 请求前钩子 (req, config) => Promise + * @property {Function} [hooks.onAfterResponse] - 响应后钩子 (req, res, config) => Promise + * @property {Function} [hooks.onContentGenerated] - 内容生成后钩子 (config) => Promise + */ + +/** + * 插件管理器类 + */ +class PluginManager { + constructor() { + /** @type {Map} */ + this.plugins = new Map(); + /** @type {Object} */ + this.pluginsConfig = { plugins: {} }; + /** @type {boolean} */ + this.initialized = false; + } + + /** + * 加载插件配置文件 + */ + async loadConfig() { + try { + if (existsSync(PLUGINS_CONFIG_FILE)) { + const content = await fs.readFile(PLUGINS_CONFIG_FILE, 'utf8'); + this.pluginsConfig = JSON.parse(content); + } else { + // 创建默认配置 + this.pluginsConfig = { + plugins: { + 'api-potluck': { + enabled: true, + description: 'API 大锅饭 - Key 管理和用量统计' + } + } + }; + await this.saveConfig(); + } + } catch (error) { + console.error('[PluginManager] Failed to load config:', error.message); + this.pluginsConfig = { plugins: {} }; + } + } + + /** + * 保存插件配置文件 + */ + async saveConfig() { + try { + const dir = path.dirname(PLUGINS_CONFIG_FILE); + if (!existsSync(dir)) { + await fs.mkdir(dir, { recursive: true }); + } + await fs.writeFile(PLUGINS_CONFIG_FILE, JSON.stringify(this.pluginsConfig, null, 2), 'utf8'); + } catch (error) { + console.error('[PluginManager] Failed to save config:', error.message); + } + } + + /** + * 注册插件 + * @param {Plugin} plugin - 插件对象 + */ + register(plugin) { + if (!plugin.name) { + throw new Error('Plugin must have a name'); + } + if (this.plugins.has(plugin.name)) { + console.warn(`[PluginManager] Plugin "${plugin.name}" is already registered, skipping`); + return; + } + this.plugins.set(plugin.name, plugin); + console.log(`[PluginManager] Registered plugin: ${plugin.name} v${plugin.version || '1.0.0'}`); + } + + /** + * 初始化所有已启用的插件 + * @param {Object} config - 服务器配置 + */ + async initAll(config) { + await this.loadConfig(); + + for (const [name, plugin] of this.plugins) { + const pluginConfig = this.pluginsConfig.plugins[name] || {}; + const enabled = pluginConfig.enabled !== false; // 默认启用 + + if (!enabled) { + console.log(`[PluginManager] Plugin "${name}" is disabled, skipping init`); + continue; + } + + try { + if (typeof plugin.init === 'function') { + await plugin.init(config); + console.log(`[PluginManager] Initialized plugin: ${name}`); + } + plugin._enabled = true; + } catch (error) { + console.error(`[PluginManager] Failed to init plugin "${name}":`, error.message); + plugin._enabled = false; + } + } + + this.initialized = true; + } + + /** + * 销毁所有插件 + */ + async destroyAll() { + for (const [name, plugin] of this.plugins) { + if (!plugin._enabled) continue; + + try { + if (typeof plugin.destroy === 'function') { + await plugin.destroy(); + console.log(`[PluginManager] Destroyed plugin: ${name}`); + } + } catch (error) { + console.error(`[PluginManager] Failed to destroy plugin "${name}":`, error.message); + } + } + this.initialized = false; + } + + /** + * 检查插件是否启用 + * @param {string} name - 插件名称 + * @returns {boolean} + */ + isEnabled(name) { + const plugin = this.plugins.get(name); + return plugin && plugin._enabled === true; + } + + /** + * 获取所有启用的插件(按优先级排序) + * 优先级数字越小越先执行,内置插件(_builtin: true)最后执行 + * @returns {Plugin[]} + */ + getEnabledPlugins() { + return Array.from(this.plugins.values()) + .filter(p => p._enabled) + .sort((a, b) => { + // 内置插件排在最后 + const aBuiltin = a._builtin ? 1 : 0; + const bBuiltin = b._builtin ? 1 : 0; + if (aBuiltin !== bBuiltin) return aBuiltin - bBuiltin; + + // 按优先级排序(数字越小越先执行) + const aPriority = a._priority || 100; + const bPriority = b._priority || 100; + return aPriority - bPriority; + }); + } + + /** + * 获取所有认证插件(按优先级排序) + * @returns {Plugin[]} + */ + getAuthPlugins() { + return this.getEnabledPlugins().filter(p => + p.type === PLUGIN_TYPE.AUTH && typeof p.authenticate === 'function' + ); + } + + /** + * 获取所有普通中间件插件(按优先级排序) + * @returns {Plugin[]} + */ + getMiddlewarePlugins() { + return this.getEnabledPlugins().filter(p => + p.type !== PLUGIN_TYPE.AUTH && typeof p.middleware === 'function' + ); + } + + /** + * 执行认证流程 + * 只有 type='auth' 的插件会参与认证 + * + * 认证插件返回值说明: + * - { handled: true } - 请求已被处理(如发送了错误响应),停止后续处理 + * - { authorized: true, data: {...} } - 认证成功,可附带数据 + * - { authorized: false } - 认证失败,已发送错误响应 + * - { authorized: null } - 此插件不处理该请求,继续下一个认证插件 + * + * @param {http.IncomingMessage} req - HTTP 请求 + * @param {http.ServerResponse} res - HTTP 响应 + * @param {URL} requestUrl - 解析后的 URL + * @param {Object} config - 服务器配置 + * @returns {Promise<{handled: boolean, authorized: boolean}>} + */ + async executeAuth(req, res, requestUrl, config) { + const authPlugins = this.getAuthPlugins(); + + for (const plugin of authPlugins) { + try { + const result = await plugin.authenticate(req, res, requestUrl, config); + + if (!result) continue; + + // 如果请求已被处理(如发送了错误响应),停止执行 + if (result.handled) { + return { handled: true, authorized: false }; + } + + // 如果认证失败,停止执行 + if (result.authorized === false) { + return { handled: true, authorized: false }; + } + + // 如果认证成功,合并数据并返回 + if (result.authorized === true) { + if (result.data) { + Object.assign(config, result.data); + } + return { handled: false, authorized: true }; + } + + // authorized === null 表示此插件不处理,继续下一个 + } catch (error) { + console.error(`[PluginManager] Auth error in plugin "${plugin.name}":`, error.message); + } + } + + // 没有任何认证插件处理,返回未授权 + return { handled: false, authorized: false }; + } + + /** + * 执行普通中间件 + * 只有 type!='auth' 的插件会执行 + * + * 中间件返回值说明: + * - { handled: true } - 请求已被处理,停止后续处理 + * - { handled: false, data: {...} } - 继续处理,可附带数据 + * - null/undefined - 继续执行下一个中间件 + * + * @param {http.IncomingMessage} req - HTTP 请求 + * @param {http.ServerResponse} res - HTTP 响应 + * @param {URL} requestUrl - 解析后的 URL + * @param {Object} config - 服务器配置 + * @returns {Promise<{handled: boolean}>} + */ + async executeMiddleware(req, res, requestUrl, config) { + const middlewarePlugins = this.getMiddlewarePlugins(); + + for (const plugin of middlewarePlugins) { + try { + const result = await plugin.middleware(req, res, requestUrl, config); + + if (!result) continue; + + // 如果请求已被处理,停止执行 + if (result.handled) { + return { handled: true }; + } + + // 合并数据 + if (result.data) { + Object.assign(config, result.data); + } + } catch (error) { + console.error(`[PluginManager] Middleware error in plugin "${plugin.name}":`, error.message); + } + } + + return { handled: false }; + } + + /** + * 执行所有插件的路由处理 + * @param {string} method - HTTP 方法 + * @param {string} path - 请求路径 + * @param {http.IncomingMessage} req - HTTP 请求 + * @param {http.ServerResponse} res - HTTP 响应 + * @returns {Promise} - 是否已处理 + */ + async executeRoutes(method, path, req, res) { + for (const plugin of this.getEnabledPlugins()) { + if (!Array.isArray(plugin.routes)) continue; + + for (const route of plugin.routes) { + const methodMatch = route.method === '*' || route.method.toUpperCase() === method; + if (!methodMatch) continue; + + let pathMatch = false; + if (route.path instanceof RegExp) { + pathMatch = route.path.test(path); + } else if (typeof route.path === 'string') { + pathMatch = path === route.path || path.startsWith(route.path + '/'); + } + + if (pathMatch) { + try { + const handled = await route.handler(method, path, req, res); + if (handled) return true; + } catch (error) { + console.error(`[PluginManager] Route error in plugin "${plugin.name}":`, error.message); + } + } + } + } + return false; + } + + /** + * 获取所有插件的静态文件路径 + * @returns {string[]} + */ + getStaticPaths() { + const paths = []; + for (const plugin of this.getEnabledPlugins()) { + if (Array.isArray(plugin.staticPaths)) { + paths.push(...plugin.staticPaths); + } + } + return paths; + } + + /** + * 检查路径是否是插件静态文件 + * @param {string} path - 请求路径 + * @returns {boolean} + */ + isPluginStaticPath(path) { + const staticPaths = this.getStaticPaths(); + return staticPaths.some(sp => path === sp || path === '/' + sp); + } + + /** + * 获取所有插件的公开 API 路径(不需要 UI 管理 API token 验证) + * @returns {string[]} + */ + getPublicApiPaths() { + const paths = []; + for (const plugin of this.getEnabledPlugins()) { + if (Array.isArray(plugin.publicApiPaths)) { + paths.push(...plugin.publicApiPaths); + } + } + return paths; + } + + /** + * 检查路径是否是插件公开 API 路径(不需要 UI 管理 API token 验证) + * @param {string} path - 请求路径 + * @returns {boolean} + */ + isPluginPublicApiPath(path) { + const publicPaths = this.getPublicApiPaths(); + return publicPaths.some(pp => path === pp || path.startsWith(pp + '/')); + } + + /** + * 执行钩子函数 + * @param {string} hookName - 钩子名称 + * @param {...any} args - 钩子参数 + */ + async executeHook(hookName, ...args) { + for (const plugin of this.getEnabledPlugins()) { + if (!plugin.hooks || typeof plugin.hooks[hookName] !== 'function') continue; + + try { + await plugin.hooks[hookName](...args); + } catch (error) { + console.error(`[PluginManager] Hook "${hookName}" error in plugin "${plugin.name}":`, error.message); + } + } + } + + /** + * 获取插件列表(用于 API) + * @returns {Object[]} + */ + getPluginList() { + const list = []; + for (const [name, plugin] of this.plugins) { + const pluginConfig = this.pluginsConfig.plugins[name] || {}; + list.push({ + name: plugin.name, + version: plugin.version || '1.0.0', + description: plugin.description || pluginConfig.description || '', + enabled: plugin._enabled === true, + hasMiddleware: typeof plugin.middleware === 'function', + hasRoutes: Array.isArray(plugin.routes) && plugin.routes.length > 0, + hasHooks: plugin.hooks && Object.keys(plugin.hooks).length > 0 + }); + } + return list; + } + + /** + * 启用/禁用插件 + * @param {string} name - 插件名称 + * @param {boolean} enabled - 是否启用 + */ + async setPluginEnabled(name, enabled) { + if (!this.pluginsConfig.plugins[name]) { + this.pluginsConfig.plugins[name] = {}; + } + this.pluginsConfig.plugins[name].enabled = enabled; + await this.saveConfig(); + + const plugin = this.plugins.get(name); + if (plugin) { + plugin._enabled = enabled; + } + } +} + +// 单例实例 +const pluginManager = new PluginManager(); + +/** + * 自动发现并加载插件 + * 扫描 src/plugins/ 目录下的所有插件 + */ +export async function discoverPlugins() { + const pluginsDir = path.join(process.cwd(), 'src', 'plugins'); + + try { + if (!existsSync(pluginsDir)) { + await fs.mkdir(pluginsDir, { recursive: true }); + console.log('[PluginManager] Created plugins directory'); + } + + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const pluginPath = path.join(pluginsDir, entry.name, 'index.js'); + if (!existsSync(pluginPath)) continue; + + try { + // 动态导入插件 + const pluginModule = await import(`file://${pluginPath}`); + const plugin = pluginModule.default || pluginModule; + + if (plugin && plugin.name) { + pluginManager.register(plugin); + } + } catch (error) { + console.error(`[PluginManager] Failed to load plugin from ${entry.name}:`, error.message); + } + } + } catch (error) { + console.error('[PluginManager] Failed to discover plugins:', error.message); + } +} + +/** + * 获取插件管理器实例 + * @returns {PluginManager} + */ +export function getPluginManager() { + return pluginManager; +} + +// 导出类和实例 +export { PluginManager, pluginManager }; \ No newline at end of file diff --git a/src/plugins/api-potluck/api-routes.js b/src/plugins/api-potluck/api-routes.js new file mode 100644 index 0000000..02aef7b --- /dev/null +++ b/src/plugins/api-potluck/api-routes.js @@ -0,0 +1,679 @@ +/** + * API 大锅饭 - 管理 API 路由 + * 提供 Key 管理的 RESTful API 和用户端查询 API + */ + +import { + createKey, + listKeys, + getKey, + deleteKey, + updateKeyLimit, + resetKeyUsage, + toggleKey, + updateKeyName, + getStats, + validateKey, + KEY_PREFIX +} from './key-manager.js'; +import path from 'path'; +import { promises as fs } from 'fs'; +import multer from 'multer'; +import { batchImportKiroRefreshTokensStream, importAwsCredentials } from '../../oauth-handlers.js'; +import { handleUploadOAuthCredentials } from '../../ui-manager.js'; +import { autoLinkProviderConfigs } from '../../service-manager.js'; +import { CONFIG } from '../../config-manager.js'; + +/** + * 解析请求体 + * @param {http.IncomingMessage} req + * @returns {Promise} + */ +function parseRequestBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (error) { + reject(new Error('Invalid JSON format')); + } + }); + req.on('error', reject); + }); +} + +/** + * 发送 JSON 响应 + * @param {http.ServerResponse} res + * @param {number} statusCode + * @param {Object} data + */ +function sendJson(res, statusCode, data) { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +} + +/** + * 验证管理员 Token + * @param {http.IncomingMessage} req + * @returns {Promise} + */ +async function checkAdminAuth(req) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return false; + } + + // 动态导入 ui-manager 中的 token 验证逻辑 + try { + const { existsSync, readFileSync } = await import('fs'); + const { promises: fs } = await import('fs'); + const path = await import('path'); + + const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); + + if (!existsSync(TOKEN_STORE_FILE)) { + return false; + } + + const content = readFileSync(TOKEN_STORE_FILE, 'utf8'); + const tokenStore = JSON.parse(content); + const token = authHeader.substring(7); + const tokenInfo = tokenStore.tokens[token]; + + if (!tokenInfo) { + return false; + } + + // 检查是否过期 + if (Date.now() > tokenInfo.expiryTime) { + return false; + } + + return true; + } catch (error) { + console.error('[API Potluck] Auth check error:', error.message); + return false; + } +} + +/** + * 处理 Potluck 管理 API 请求 + * @param {string} method - HTTP 方法 + * @param {string} path - 请求路径 + * @param {http.IncomingMessage} req - HTTP 请求对象 + * @param {http.ServerResponse} res - HTTP 响应对象 + * @returns {Promise} - 是否处理了请求 + */ +export async function handlePotluckApiRoutes(method, path, req, res) { + // 只处理 /api/potluck 开头的请求 + if (!path.startsWith('/api/potluck')) { + return false; + } + console.log('[API Potluck] Handling request:', method, path); + + // 验证管理员权限 + const isAuthed = await checkAdminAuth(req); + if (!isAuthed) { + sendJson(res, 401, { + success: false, + error: { message: 'Unauthorized: Please login first', code: 'UNAUTHORIZED' } + }); + return true; + } + + try { + // GET /api/potluck/stats - 获取统计信息 + if (method === 'GET' && path === '/api/potluck/stats') { + const stats = await getStats(); + sendJson(res, 200, { success: true, data: stats }); + return true; + } + + // GET /api/potluck/keys - 获取所有 Key 列表 + if (method === 'GET' && path === '/api/potluck/keys') { + const keys = await listKeys(); + const stats = await getStats(); + sendJson(res, 200, { + success: true, + data: { + keys, + stats + } + }); + return true; + } + + // POST /api/potluck/keys - 创建新 Key + if (method === 'POST' && path === '/api/potluck/keys') { + const body = await parseRequestBody(req); + const { name, dailyLimit } = body; + const keyData = await createKey(name, dailyLimit); + sendJson(res, 201, { + success: true, + message: 'API Key created successfully', + data: keyData + }); + return true; + } + + // 处理带 keyId 的路由 + const keyIdMatch = path.match(/^\/api\/potluck\/keys\/([^\/]+)(\/.*)?$/); + if (keyIdMatch) { + const keyId = decodeURIComponent(keyIdMatch[1]); + const subPath = keyIdMatch[2] || ''; + + // GET /api/potluck/keys/:keyId - 获取单个 Key 详情 + if (method === 'GET' && !subPath) { + const keyData = await getKey(keyId); + if (!keyData) { + sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + return true; + } + sendJson(res, 200, { success: true, data: keyData }); + return true; + } + + // DELETE /api/potluck/keys/:keyId - 删除 Key + if (method === 'DELETE' && !subPath) { + const deleted = await deleteKey(keyId); + if (!deleted) { + sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + return true; + } + sendJson(res, 200, { success: true, message: 'Key deleted successfully' }); + return true; + } + + // PUT /api/potluck/keys/:keyId/limit - 更新每日限额 + if (method === 'PUT' && subPath === '/limit') { + const body = await parseRequestBody(req); + const { dailyLimit } = body; + + if (typeof dailyLimit !== 'number' || dailyLimit < 0) { + sendJson(res, 400, { + success: false, + error: { message: 'Invalid dailyLimit value' } + }); + return true; + } + + const keyData = await updateKeyLimit(keyId, dailyLimit); + if (!keyData) { + sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + return true; + } + sendJson(res, 200, { + success: true, + message: 'Daily limit updated successfully', + data: keyData + }); + return true; + } + + // POST /api/potluck/keys/:keyId/reset - 重置当天调用次数 + if (method === 'POST' && subPath === '/reset') { + const keyData = await resetKeyUsage(keyId); + if (!keyData) { + sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + return true; + } + sendJson(res, 200, { + success: true, + message: 'Usage reset successfully', + data: keyData + }); + return true; + } + + // POST /api/potluck/keys/:keyId/toggle - 切换启用/禁用状态 + if (method === 'POST' && subPath === '/toggle') { + const keyData = await toggleKey(keyId); + if (!keyData) { + sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + return true; + } + sendJson(res, 200, { + success: true, + message: `Key ${keyData.enabled ? 'enabled' : 'disabled'} successfully`, + data: keyData + }); + return true; + } + + // PUT /api/potluck/keys/:keyId/name - 更新 Key 名称 + if (method === 'PUT' && subPath === '/name') { + const body = await parseRequestBody(req); + const { name } = body; + + if (!name || typeof name !== 'string') { + sendJson(res, 400, { + success: false, + error: { message: 'Invalid name value' } + }); + return true; + } + + const keyData = await updateKeyName(keyId, name); + if (!keyData) { + sendJson(res, 404, { success: false, error: { message: 'Key not found' } }); + return true; + } + sendJson(res, 200, { + success: true, + message: 'Name updated successfully', + data: keyData + }); + return true; + } + } + + // 未匹配的 potluck 路由 + sendJson(res, 404, { success: false, error: { message: 'Potluck API endpoint not found' } }); + return true; + + } catch (error) { + console.error('[API Potluck] API error:', error); + sendJson(res, 500, { + success: false, + error: { message: error.message || 'Internal server error' } + }); + return true; + } +} + +/** + * 从请求中提取 Potluck API Key + * @param {http.IncomingMessage} req - HTTP 请求对象 + * @returns {string|null} + */ +function extractApiKeyFromRequest(req) { + // 1. 检查 Authorization header + const authHeader = req.headers['authorization']; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + if (token.startsWith(KEY_PREFIX)) { + return token; + } + } + + // 2. 检查 x-api-key header + const xApiKey = req.headers['x-api-key']; + if (xApiKey && xApiKey.startsWith(KEY_PREFIX)) { + return xApiKey; + } + + return null; +} + +/** + * 处理用户端 API 请求 - 用户通过自己的 API Key 查询使用量 + * @param {string} method - HTTP 方法 + * @param {string} path - 请求路径 + * @param {http.IncomingMessage} req - HTTP 请求对象 + * @param {http.ServerResponse} res - HTTP 响应对象 + * @returns {Promise} - 是否处理了请求 + */ +export async function handlePotluckUserApiRoutes(method, path, req, res) { + // 只处理 /api/potluckuser 开头的请求 + if (!path.startsWith('/api/potluckuser')) { + return false; + } + console.log('[API Potluck User] Handling request:', method, path); + + try { + // 从请求中提取 API Key + const apiKey = extractApiKeyFromRequest(req); + + if (!apiKey) { + sendJson(res, 401, { + success: false, + error: { + message: 'API Key required. Please provide your API Key in Authorization header (Bearer maki_xxx) or x-api-key header.', + code: 'API_KEY_REQUIRED' + } + }); + return true; + } + + // 验证 API Key + const validation = await validateKey(apiKey); + + if (!validation.valid && validation.reason !== 'quota_exceeded') { + const errorMessages = { + 'invalid_format': 'Invalid API key format', + 'not_found': 'API key not found', + 'disabled': 'API key has been disabled' + }; + + sendJson(res, 401, { + success: false, + error: { + message: errorMessages[validation.reason] || 'Invalid API key', + code: validation.reason + } + }); + return true; + } + + // GET /api/potluckuser/usage - 获取当前用户的使用量信息 + if (method === 'GET' && path === '/api/potluckuser/usage') { + const keyData = await getKey(apiKey); + + if (!keyData) { + sendJson(res, 404, { + success: false, + error: { message: 'Key not found', code: 'KEY_NOT_FOUND' } + }); + return true; + } + + // 计算使用百分比 + const usagePercent = keyData.dailyLimit > 0 + ? Math.round((keyData.todayUsage / keyData.dailyLimit) * 100) + : 0; + + // 返回用户友好的使用量信息(隐藏敏感信息) + sendJson(res, 200, { + success: true, + data: { + name: keyData.name, + enabled: keyData.enabled, + usage: { + today: keyData.todayUsage, + limit: keyData.dailyLimit, + remaining: Math.max(0, keyData.dailyLimit - keyData.todayUsage), + percent: usagePercent, + resetDate: keyData.lastResetDate + }, + total: keyData.totalUsage, + lastUsedAt: keyData.lastUsedAt, + createdAt: keyData.createdAt, + // 显示部分遮蔽的 Key ID + maskedKey: `${apiKey.substring(0, 12)}...${apiKey.substring(apiKey.length - 4)}` + } + }); + return true; + } + + // POST /api/potluckuser/upload - 上传授权文件 + if (method === 'POST' && path === '/api/potluckuser/upload') { + return await handleUserUpload(req, res, apiKey); + } + + // POST /api/potluckuser/kiro/batch-import-tokens - 批量导入 Kiro refresh token + if (method === 'POST' && path === '/api/potluckuser/kiro/batch-import-tokens') { + return await handleKiroBatchImportTokens(req, res, apiKey); + } + + // POST /api/potluckuser/kiro/import-aws-credentials - 导入 AWS SSO 凭据 + if (method === 'POST' && path === '/api/potluckuser/kiro/import-aws-credentials') { + return await handleKiroImportAwsCredentials(req, res, apiKey); + } + + // 未匹配的用户端路由 + sendJson(res, 404, { + success: false, + error: { message: 'User API endpoint not found' } + }); + return true; + + } catch (error) { + console.error('[API Potluck] User API error:', error); + sendJson(res, 500, { + success: false, + error: { message: error.message || 'Internal server error' } + }); + return true; + } +} + +/** + * 提供商映射 + */ +const providerMap = { + 'gemini-cli-oauth': 'gemini', + 'gemini-antigravity': 'antigravity', + 'claude-kiro-oauth': 'kiro', + 'openai-qwen-oauth': 'qwen', + 'openai-iflow': 'iflow' +}; + +/** + * 配置 multer 用于用户上传 + */ +const userUploadStorage = multer.diskStorage({ + destination: async (req, file, cb) => { + try { + // 先使用临时目录 + const uploadPath = path.join(process.cwd(), 'configs', 'temp'); + await fs.mkdir(uploadPath, { recursive: true }); + cb(null, uploadPath); + } catch (error) { + cb(error); + } + }, + filename: (req, file, cb) => { + const timestamp = Date.now(); + const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); + cb(null, `${timestamp}_${sanitizedName}`); + } +}); + +const userUploadFileFilter = (req, file, cb) => { + const allowedTypes = ['.json', '.txt', '.key', '.pem', '.p12', '.pfx']; + const ext = path.extname(file.originalname).toLowerCase(); + if (allowedTypes.includes(ext)) { + cb(null, true); + } else { + cb(new Error('Unsupported file type'), false); + } +}; + +const userUpload = multer({ + storage: userUploadStorage, + fileFilter: userUploadFileFilter, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB 限制 + } +}); + +/** + * 处理用户上传授权文件(带自动绑定功能) + * @param {http.IncomingMessage} req + * @param {http.ServerResponse} res + * @param {string} apiKey - 用户的 API Key + * @returns {Promise} + */ +async function handleUserUpload(req, res, apiKey) { + // 创建一个包装的响应对象来捕获上传结果 + let uploadResult = null; + const originalEnd = res.end.bind(res); + const originalWriteHead = res.writeHead.bind(res); + let statusCode = 200; + + // 拦截响应以获取上传结果 + res.writeHead = function(code, headers) { + statusCode = code; + return originalWriteHead(code, headers); + }; + + res.end = function(data) { + if (statusCode === 200 && data) { + try { + uploadResult = JSON.parse(data); + } catch (e) { + // 忽略解析错误 + } + } + return originalEnd(data); + }; + + // 执行文件上传 + const handled = await handleUploadOAuthCredentials(req, res, { + providerMap: providerMap, + logPrefix: '[API Potluck User]', + userInfo: `user: ${apiKey.substring(0, 12)}...`, + customUpload: userUpload + }); + + // 如果上传成功,调用自动绑定功能扫描并绑定新上传的配置文件 + if (uploadResult && uploadResult.success && uploadResult.filePath) { + try { + console.log(`[API Potluck User] Triggering auto-link for uploaded file: ${uploadResult.filePath}`); + await autoLinkProviderConfigs(CONFIG); + } catch (linkError) { + // 自动绑定失败不影响上传结果,只记录日志 + console.warn(`[API Potluck User] Auto-link failed:`, linkError.message); + } + } + + return handled; +} + +/** + * 处理 Kiro 批量导入 Refresh Token + * @param {http.IncomingMessage} req + * @param {http.ServerResponse} res + * @param {string} apiKey - 用户的 API Key + */ +async function handleKiroBatchImportTokens(req, res, apiKey) { + try { + const body = await parseRequestBody(req); + const { refreshTokens, region } = body; + + if (!refreshTokens || !Array.isArray(refreshTokens) || refreshTokens.length === 0) { + sendJson(res, 400, { + success: false, + error: 'refreshTokens array is required and must not be empty' + }); + return true; + } + + console.log(`[API Potluck User] Starting batch import of ${refreshTokens.length} tokens (user: ${apiKey.substring(0, 12)}...)`); + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + + // 发送 SSE 事件的辅助函数 + const sendSSE = (event, data) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + // 发送开始事件 + sendSSE('start', { total: refreshTokens.length }); + + // 执行流式批量导入 + const result = await batchImportKiroRefreshTokensStream( + refreshTokens, + region || 'us-east-1', + (progress) => { + // 每处理完一个 token 发送进度更新 + sendSSE('progress', progress); + } + ); + + console.log(`[API Potluck User] Completed: ${result.success} success, ${result.failed} failed`); + + // 发送完成事件 + sendSSE('complete', { + success: true, + total: result.total, + successCount: result.success, + failedCount: result.failed, + details: result.details + }); + + res.end(); + return true; + + } catch (error) { + console.error('[API Potluck User] Kiro Batch Import Error:', error); + if (res.headersSent) { + res.write(`event: error\n`); + res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); + res.end(); + } else { + sendJson(res, 500, { + success: false, + error: error.message + }); + } + return true; + } +} + +/** + * 处理 Kiro 导入 AWS 凭据 + * @param {http.IncomingMessage} req + * @param {http.ServerResponse} res + * @param {string} apiKey - 用户的 API Key + */ +async function handleKiroImportAwsCredentials(req, res, apiKey) { + try { + const body = await parseRequestBody(req); + const { credentials } = body; + + if (!credentials || typeof credentials !== 'object') { + sendJson(res, 400, { + success: false, + error: 'credentials object is required' + }); + return true; + } + + // 验证必需字段 + const missingFields = []; + if (!credentials.clientId) missingFields.push('clientId'); + if (!credentials.clientSecret) missingFields.push('clientSecret'); + if (!credentials.accessToken) missingFields.push('accessToken'); + if (!credentials.refreshToken) missingFields.push('refreshToken'); + + if (missingFields.length > 0) { + sendJson(res, 400, { + success: false, + error: `Missing required fields: ${missingFields.join(', ')}` + }); + return true; + } + + console.log(`[API Potluck User] Starting AWS credentials import (user: ${apiKey.substring(0, 12)}...)`); + + const result = await importAwsCredentials(credentials); + + if (result.success) { + console.log(`[API Potluck User] Successfully imported credentials to: ${result.path}`); + sendJson(res, 200, { + success: true, + path: result.path, + message: 'AWS credentials imported successfully' + }); + } else { + const statusCode = result.error === 'duplicate' ? 409 : 500; + sendJson(res, statusCode, { + success: false, + error: result.error, + existingPath: result.existingPath || null + }); + } + return true; + + } catch (error) { + console.error('[API Potluck User] Kiro AWS Import Error:', error); + sendJson(res, 500, { + success: false, + error: error.message + }); + return true; + } +} diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js new file mode 100644 index 0000000..7e04c99 --- /dev/null +++ b/src/plugins/api-potluck/index.js @@ -0,0 +1,210 @@ +/** + * API 大锅饭插件 - 标准插件格式 + * + * 功能: + * 1. API Key 管理(创建、删除、启用/禁用) + * 2. 每日配额限制 + * 3. 用量统计 + * 4. 管理 API 接口 + */ + +import { + createKey, + listKeys, + getKey, + deleteKey, + updateKeyLimit, + resetKeyUsage, + toggleKey, + updateKeyName, + validateKey, + incrementUsage, + getStats, + KEY_PREFIX, + DEFAULT_DAILY_LIMIT +} from './key-manager.js'; + +import { + extractPotluckKey, + isPotluckRequest, + sendPotluckError +} from './middleware.js'; + +import { handlePotluckApiRoutes, handlePotluckUserApiRoutes } from './api-routes.js'; + +/** + * 插件定义 + */ +const apiPotluckPlugin = { + name: 'api-potluck', + version: '1.0.0', + description: 'API 大锅饭 - Key 管理和用量统计插件', + + // 插件类型:认证插件 + type: 'auth', + + // 优先级:数字越小越先执行,默认认证插件优先级为 9999 + _priority: 10, + + /** + * 初始化钩子 + * @param {Object} config - 服务器配置 + */ + async init(config) { + console.log('[API Potluck Plugin] Initializing...'); + // 插件初始化逻辑(如果需要) + }, + + /** + * 销毁钩子 + */ + async destroy() { + console.log('[API Potluck Plugin] Destroying...'); + // 清理逻辑(如果需要) + }, + + /** + * 静态文件路径 + */ + staticPaths: ['potluck.html', 'potluck-user.html'], + + /** + * 公开 API 路径(不需要 UI 管理 API 的 token 验证) + * 这些路径将跳过 handleUIApiRequests 中的 checkAuth 验证 + */ + publicApiPaths: ['/api/potluckuser'], + + /** + * 路由定义 + */ + routes: [ + { + method: '*', + path: '/api/potluckuser', + handler: handlePotluckUserApiRoutes + }, + { + method: '*', + path: '/api/potluck', + handler: handlePotluckApiRoutes + } + ], + + /** + * 认证方法 - 处理 Potluck Key 认证 + * @param {http.IncomingMessage} req - HTTP 请求 + * @param {http.ServerResponse} res - HTTP 响应 + * @param {URL} requestUrl - 解析后的 URL + * @param {Object} config - 服务器配置 + * @returns {Promise<{handled: boolean, authorized: boolean|null, error?: Object, data?: Object}>} + */ + async authenticate(req, res, requestUrl, config) { + const apiKey = extractPotluckKey(req, requestUrl); + + if (!apiKey) { + // 不是 potluck 请求,返回 null 让其他认证插件处理 + return { handled: false, authorized: null }; + } + + // 验证 Key + const validation = await validateKey(apiKey); + + if (!validation.valid) { + const errorMessages = { + 'invalid_format': 'Invalid API key format', + 'not_found': 'API key not found', + 'disabled': 'API key has been disabled', + 'quota_exceeded': 'Daily quota exceeded for this API key' + }; + + const statusCodes = { + 'invalid_format': 401, + 'not_found': 401, + 'disabled': 403, + 'quota_exceeded': 429 + }; + + const error = { + statusCode: statusCodes[validation.reason] || 401, + message: errorMessages[validation.reason] || 'Authentication failed', + code: validation.reason, + keyData: validation.keyData + }; + + // 发送错误响应 + sendPotluckError(res, error); + return { handled: true, authorized: false, error }; + } + + // 认证成功,返回数据供后续使用 + console.log(`[API Potluck Plugin] Authorized with key: ${apiKey.substring(0, 12)}...`); + return { + handled: false, + authorized: true, + data: { + potluckApiKey: apiKey, + potluckKeyData: validation.keyData + } + }; + }, + + /** + * 钩子函数 + */ + hooks: { + /** + * 内容生成后钩子 - 记录用量 + * @param {Object} config - 服务器配置 + */ + async onContentGenerated(config) { + if (config.potluckApiKey) { + try { + await incrementUsage(config.potluckApiKey); + } catch (e) { + // 静默失败,不影响主流程 + console.error('[API Potluck Plugin] Failed to record usage:', e.message); + } + } + } + }, + + // 导出内部函数供外部使用(可选) + exports: { + createKey, + listKeys, + getKey, + deleteKey, + updateKeyLimit, + resetKeyUsage, + toggleKey, + updateKeyName, + validateKey, + incrementUsage, + getStats, + KEY_PREFIX, + DEFAULT_DAILY_LIMIT, + extractPotluckKey, + isPotluckRequest + } +}; + +export default apiPotluckPlugin; + +// 也导出命名导出,方便直接引用 +export { + createKey, + listKeys, + getKey, + deleteKey, + updateKeyLimit, + resetKeyUsage, + toggleKey, + updateKeyName, + validateKey, + incrementUsage, + getStats, + KEY_PREFIX, + DEFAULT_DAILY_LIMIT, + extractPotluckKey, + isPotluckRequest +}; \ No newline at end of file diff --git a/src/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js similarity index 100% rename from src/api-potluck/key-manager.js rename to src/plugins/api-potluck/key-manager.js diff --git a/src/api-potluck/middleware.js b/src/plugins/api-potluck/middleware.js similarity index 100% rename from src/api-potluck/middleware.js rename to src/plugins/api-potluck/middleware.js diff --git a/src/plugins/default-auth/index.js b/src/plugins/default-auth/index.js new file mode 100644 index 0000000..d95109c --- /dev/null +++ b/src/plugins/default-auth/index.js @@ -0,0 +1,89 @@ +/** + * 默认认证插件 - 内置插件 + * + * 提供基于 API Key 的默认认证机制 + * 支持多种认证方式: + * 1. Authorization: Bearer + * 2. x-api-key: + * 3. x-goog-api-key: + * 4. URL query: ?key= + */ + +/** + * 检查请求是否已授权 + * @param {http.IncomingMessage} req - HTTP 请求 + * @param {URL} requestUrl - 解析后的 URL + * @param {string} requiredApiKey - 所需的 API Key + * @returns {boolean} + */ +function isAuthorized(req, requestUrl, requiredApiKey) { + const authHeader = req.headers['authorization']; + const queryKey = requestUrl.searchParams.get('key'); + const googApiKey = req.headers['x-goog-api-key']; + const claudeApiKey = req.headers['x-api-key']; + + // Check for Bearer token in Authorization header (OpenAI style) + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + if (token === requiredApiKey) { + return true; + } + } + + // Check for API key in URL query parameter (Gemini style) + if (queryKey === requiredApiKey) { + return true; + } + + // Check for API key in x-goog-api-key header (Gemini style) + if (googApiKey === requiredApiKey) { + return true; + } + + // Check for API key in x-api-key header (Claude style) + if (claudeApiKey === requiredApiKey) { + return true; + } + + return false; +} + +/** + * 默认认证插件定义 + */ +const defaultAuthPlugin = { + name: 'default-auth', + version: '1.0.0', + description: '默认 API Key 认证插件', + + // 插件类型:认证插件 + type: 'auth', + + // 标记为内置插件,优先级最低(最后执行) + _builtin: true, + _priority: 9999, + + /** + * 认证方法 - 默认 API Key 认证 + * @param {http.IncomingMessage} req - HTTP 请求 + * @param {http.ServerResponse} res - HTTP 响应 + * @param {URL} requestUrl - 解析后的 URL + * @param {Object} config - 服务器配置 + * @returns {Promise<{handled: boolean, authorized: boolean|null}>} + */ + async authenticate(req, res, requestUrl, config) { + // 执行默认认证 + if (isAuthorized(req, requestUrl, config.REQUIRED_API_KEY)) { + // 认证成功 + return { handled: false, authorized: true }; + } + + // 认证失败,记录日志但不发送响应(由 request-handler 统一处理) + console.log(`[Default Auth] Unauthorized request. Headers: Authorization=${req.headers['authorization'] ? 'present' : 'N/A'}, x-api-key=${req.headers['x-api-key'] || 'N/A'}, x-goog-api-key=${req.headers['x-goog-api-key'] || 'N/A'}`); + + // 返回 null 表示此插件不授权,让其他插件或默认逻辑处理 + return { handled: false, authorized: null }; + } +}; + +export default defaultAuthPlugin; \ No newline at end of file diff --git a/src/request-handler.js b/src/request-handler.js index eb5c4e0..1150d16 100644 --- a/src/request-handler.js +++ b/src/request-handler.js @@ -1,5 +1,5 @@ import deepmerge from 'deepmerge'; -import { handleError, isAuthorized } from './common.js'; +import { handleError } from './common.js'; import { handleUIApiRequests, serveStaticFiles } from './ui-manager.js'; import { handleAPIRequests } from './api-manager.js'; import { getApiService, getProviderStatus } from './service-manager.js'; @@ -7,10 +7,7 @@ import { getProviderPoolManager } from './service-manager.js'; import { MODEL_PROVIDER } from './common.js'; import { PROMPT_LOG_FILENAME } from './config-manager.js'; import { handleOllamaRequest, handleOllamaShow } from './ollama-handler.js'; - -// ============== API 大锅饭插件 - 开始 ============== -import { handlePotluckApiRoutes, potluckAuthMiddleware, sendPotluckError } from './api-potluck/index.js'; -// ============== API 大锅饭插件 - 结束 ============== +import { getPluginManager } from './plugin-manager.js'; /** * Parse request body as JSON @@ -58,8 +55,10 @@ export function createRequestHandler(config, providerPoolManager) { } // Serve static files for UI (除了登录页面需要认证) - // ============== API 大锅饭插件: 添加 /potluck.html ============== - if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path === '/login.html' || path === '/potluck.html') { + // 检查是否是插件静态文件 + const pluginManager = getPluginManager(); + const isPluginStatic = pluginManager.isPluginStaticPath(path); + if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path === '/login.html' || isPluginStatic) { const served = await serveStaticFiles(path, res); if (served) return; } @@ -67,10 +66,9 @@ export function createRequestHandler(config, providerPoolManager) { const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); if (uiHandled) return; - // ============== API 大锅饭插件 - 开始 ============== - const potluckRouteHandled = await handlePotluckApiRoutes(method, path, req, res); - if (potluckRouteHandled) return; - // ============== API 大锅饭插件 - 结束 ============== + // 执行插件路由 + const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res); + if (pluginRouteHandled) return; // Ollama show endpoint with model name if (method === 'POST' && path === '/ollama/api/show') { @@ -153,21 +151,25 @@ export function createRequestHandler(config, providerPoolManager) { } } - // Check authentication for API requests (before Ollama handling to allow unauthenticated Ollama endpoints) - // ============== API 大锅饭插件 - 开始 ============== - const potluckAuth = await potluckAuthMiddleware(req, requestUrl); - if (potluckAuth.authorized === false) { - sendPotluckError(res, potluckAuth.error); + // 1. 执行认证流程(只有 type='auth' 的插件参与) + const authResult = await pluginManager.executeAuth(req, res, requestUrl, currentConfig); + if (authResult.handled) { + // 认证插件已处理请求(如发送了错误响应) return; - } else if (potluckAuth.authorized === true) { - currentConfig.potluckApiKey = potluckAuth.apiKey; - console.log(`[API Potluck] Authorized with key: ${potluckAuth.apiKey.substring(0, 12)}...`); - } else if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) { - // ============== API 大锅饭插件 - 结束 ============== + } + if (!authResult.authorized) { + // 没有认证插件授权,返回 401 res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } })); return; } + + // 2. 执行普通中间件(type!='auth' 的插件) + const middlewareResult = await pluginManager.executeMiddleware(req, res, requestUrl, currentConfig); + if (middlewareResult.handled) { + // 中间件已处理请求 + return; + } // Handle Ollama request BEFORE getting apiService (Ollama endpoints handle their own provider selection) // This is important because Ollama /api/tags aggregates models from ALL providers, not just the default one diff --git a/src/ui-manager.js b/src/ui-manager.js index 8c15a64..e23ca22 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -56,6 +56,7 @@ import { getAllProviderModels, getProviderModels } from './provider-models.js'; import { CONFIG } from './config-manager.js'; import { serviceInstances, getServiceAdapter } from './adapter.js'; import { initApiService } from './service-manager.js'; +import { getPluginManager } from './plugin-manager.js'; import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth, handleKiroOAuth, handleIFlowOAuth, batchImportKiroRefreshTokens, batchImportKiroRefreshTokensStream, importAwsCredentials } from './oauth-handlers.js'; import { generateUUID, @@ -517,8 +518,10 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return true; } - // Handle UI management API requests (需要token验证,除了登录接口、健康检查和Events接口) - if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events') { + // Handle UI management API requests (需要token验证,除了登录接口、健康检查、Events接口和插件公开API路径) + const pluginManager = getPluginManager(); + const isPluginPublicApi = pluginManager.isPluginPublicApiPath(pathParam); + if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' && !isPluginPublicApi) { // 检查token验证 const isAuth = await checkAuth(req); if (!isAuth) { @@ -539,84 +542,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo // 文件上传API if (method === 'POST' && pathParam === '/api/upload-oauth-credentials') { - const uploadMiddleware = upload.single('file'); - - uploadMiddleware(req, res, async (err) => { - if (err) { - console.error('[UI API] File upload error:', err.message); - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: err.message || 'File upload failed' - } - })); - return; - } - - try { - if (!req.file) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'No file was uploaded' - } - })); - return; - } - - // multer执行完成后,表单字段已解析到req.body中 - const provider = req.body.provider || 'common'; - const tempFilePath = req.file.path; - - // 根据实际的provider移动文件到正确的目录 - let targetDir = path.join(process.cwd(), 'configs', provider); - - // 如果是kiro类型的凭证,需要再包裹一层文件夹 - if (provider === 'kiro') { - // 使用时间戳作为子文件夹名称,确保每个上传的文件都有独立的目录 - const timestamp = Date.now(); - const originalNameWithoutExt = path.parse(req.file.originalname).name; - const subFolder = `${timestamp}_${originalNameWithoutExt}`; - targetDir = path.join(targetDir, subFolder); - } - - await fs.mkdir(targetDir, { recursive: true }); - - const targetFilePath = path.join(targetDir, req.file.filename); - await fs.rename(tempFilePath, targetFilePath); - - const relativePath = path.relative(process.cwd(), targetFilePath); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'add', - filePath: relativePath, - provider: provider, - timestamp: new Date().toISOString() - }); - - console.log(`[UI API] OAuth credentials file uploaded: ${targetFilePath} (provider: ${provider})`); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'File uploaded successfully', - filePath: relativePath, - originalName: req.file.originalname, - provider: provider - })); - - } catch (error) { - console.error('[UI API] File upload processing error:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'File upload processing failed: ' + error.message - } - })); - } - }); - return true; + return handleUploadOAuthCredentials(req, res); } // Update admin password @@ -3342,3 +3268,110 @@ async function copyRecursive(src, dest) { await fs.copyFile(src, dest); } } + +/** + * 处理 OAuth 凭据文件上传 + * @param {http.IncomingMessage} req - HTTP 请求对象 + * @param {http.ServerResponse} res - HTTP 响应对象 + * @param {Object} options - 可选配置 + * @param {Object} options.providerMap - 提供商类型映射表 + * @param {string} options.logPrefix - 日志前缀 + * @param {string} options.userInfo - 用户信息(用于日志) + * @param {Object} options.customUpload - 自定义 multer 实例 + * @returns {Promise} 始终返回 true 表示请求已处理 + */ +export function handleUploadOAuthCredentials(req, res, options = {}) { + const { + providerMap = {}, + logPrefix = '[UI API]', + userInfo = '', + customUpload = null + } = options; + + const uploadMiddleware = customUpload ? customUpload.single('file') : upload.single('file'); + + return new Promise((resolve) => { + uploadMiddleware(req, res, async (err) => { + if (err) { + console.error(`${logPrefix} File upload error:`, err.message); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: err.message || 'File upload failed' + } + })); + resolve(true); + return; + } + + try { + if (!req.file) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'No file was uploaded' + } + })); + resolve(true); + return; + } + + // multer执行完成后,表单字段已解析到req.body中 + const providerType = req.body.provider || 'common'; + // 应用提供商映射(如果有) + const provider = providerMap[providerType] || providerType; + const tempFilePath = req.file.path; + + // 根据实际的provider移动文件到正确的目录 + let targetDir = path.join(process.cwd(), 'configs', provider); + + // 如果是kiro类型的凭证,需要再包裹一层文件夹 + if (provider === 'kiro') { + // 使用时间戳作为子文件夹名称,确保每个上传的文件都有独立的目录 + const timestamp = Date.now(); + const originalNameWithoutExt = path.parse(req.file.originalname).name; + const subFolder = `${timestamp}_${originalNameWithoutExt}`; + targetDir = path.join(targetDir, subFolder); + } + + await fs.mkdir(targetDir, { recursive: true }); + + const targetFilePath = path.join(targetDir, req.file.filename); + await fs.rename(tempFilePath, targetFilePath); + + const relativePath = path.relative(process.cwd(), targetFilePath); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'add', + filePath: relativePath, + provider: provider, + timestamp: new Date().toISOString() + }); + + const userInfoStr = userInfo ? `, ${userInfo}` : ''; + console.log(`${logPrefix} OAuth credentials file uploaded: ${targetFilePath} (provider: ${provider}${userInfoStr})`); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'File uploaded successfully', + filePath: relativePath, + originalName: req.file.originalname, + provider: provider + })); + resolve(true); + + } catch (error) { + console.error(`${logPrefix} File upload processing error:`, error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'File upload processing failed: ' + error.message + } + })); + resolve(true); + } + }); + }); +} diff --git a/static/potluck-user.html b/static/potluck-user.html new file mode 100644 index 0000000..0eea253 --- /dev/null +++ b/static/potluck-user.html @@ -0,0 +1,1137 @@ + + + + + + API 大锅饭 - 我的用量 + + + +
+
+

🍲 API 大锅饭

+

使用 API Key 登录查看用量

+
+ + + + + +
+
+

正在加载...

+
+ + +
+
⚠️
+
登录失败
+
请检查您的 API Key 是否正确
+
+ + +
+ +
+
+ + 启用 +
+ + +
+
+ 今日使用量 + 0 / 0 +
+
+
+
+
+ 剩余 0 + 0% +
+
+ + +
+
+
今日已用
+
0
+
+
+
每日限额
+
0
+
+
+
累计调用
+
0
+
+
+ + + +
+ + +
+

📤 上传授权文件

+

上传您的 OAuth 授权文件到对应的提供商目录

+ +
+
+
🔷
+
Gemini CLI
+
gemini
+ + +
+
+ +
+
🌀
+
Antigravity
+
antigravity
+ + +
+
+ +
+
🤖
+
Kiro (Claude)
+
kiro
+ + +
+ + +
+
+
+ +
+
🌐
+
Qwen (OpenAI)
+
qwen
+ + +
+
+ +
+
🔄
+
iFlow (OpenAI)
+
iflow
+ + +
+
+
+
+ + +
+

📊 详细信息

+
+
+ 最后调用时间 + - +
+
+ 配额重置日期 + - +
+
+ Key 创建时间 + - +
+
+
+
+ + +
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/static/potluck.html b/static/potluck.html index 6af88f7..1b4e823 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -227,8 +227,37 @@ document.getElementById('keyName').value = ''; document.getElementById('keyLimit').value = '1000'; } else { showToast(result?.error?.message || '创建失败', 'error'); } } - function copyKey() { navigator.clipboard.writeText(currentNewKey).then(() => showToast('已复制到剪贴板', 'success')); } - function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => showToast('Key 已复制', 'success')); } + function copyToClipboardFallback(text) { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + textArea.style.top = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + showToast('已复制到剪贴板', 'success'); + } catch (err) { + showToast('复制失败,请手动复制', 'error'); + } + document.body.removeChild(textArea); + } + function copyKey() { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(currentNewKey).then(() => showToast('已复制到剪贴板', 'success')).catch(() => copyToClipboardFallback(currentNewKey)); + } else { + copyToClipboardFallback(currentNewKey); + } + } + function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(() => showToast('Key 已复制', 'success')).catch(() => copyToClipboardFallback(text)); + } else { + copyToClipboardFallback(text); + } + } async function resetUsage(keyId) { if (!confirm('确定要重置该 Key 的今日调用次数吗?')) return; const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}/reset`, { method: 'POST' });