feat(plugin): 实现可插拔插件系统架构

重构API大锅饭功能为插件,新增插件管理器核心模块
支持插件生命周期管理、认证中间件、路由和钩子扩展
添加默认认证插件和API大锅饭插件
This commit is contained in:
hex2077 2026-01-09 18:02:56 +08:00
parent 148b7b2114
commit d639077bde
18 changed files with 2916 additions and 475 deletions

1
.gitignore vendored
View file

@ -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

View file

@ -0,0 +1,12 @@
{
"plugins": {
"api-potluck": {
"enabled": true,
"description": "API 大锅饭 - Key 管理和用量统计插件"
},
"default-auth": {
"enabled": true,
"description": "默认 API Key 认证插件(内置)"
}
}
}

View file

@ -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<Object>}
*/
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<boolean>}
*/
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<boolean>} - 是否处理了请求
*/
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;
}
}

View file

@ -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';

View file

@ -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);

View file

@ -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
*/

View file

@ -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) { /* 静默失败,不影响主流程 */ }
}
/**

View file

@ -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',

504
src/plugin-manager.js Normal file
View file

@ -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<void>
* @property {Function} [destroy] - 销毁钩子 () => Promise<void>
* @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<void>
* @property {Function} [hooks.onAfterResponse] - 响应后钩子 (req, res, config) => Promise<void>
* @property {Function} [hooks.onContentGenerated] - 内容生成后钩子 (config) => Promise<void>
*/
/**
* 插件管理器类
*/
class PluginManager {
constructor() {
/** @type {Map<string, Plugin>} */
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<boolean>} - 是否已处理
*/
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 };

View file

@ -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<Object>}
*/
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<boolean>}
*/
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<boolean>} - 是否处理了请求
*/
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<boolean>} - 是否处理了请求
*/
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<boolean>}
*/
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;
}
}

View file

@ -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
};

View file

@ -0,0 +1,89 @@
/**
* 默认认证插件 - 内置插件
*
* 提供基于 API Key 的默认认证机制
* 支持多种认证方式
* 1. Authorization: Bearer <key>
* 2. x-api-key: <key>
* 3. x-goog-api-key: <key>
* 4. URL query: ?key=<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;

View file

@ -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

View file

@ -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<boolean>} 始终返回 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);
}
});
});
}

1137
static/potluck-user.html Normal file

File diff suppressed because it is too large Load diff

View file

@ -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' });