Merge branch 'justlovemaki:main' into fix-400-kiro
This commit is contained in:
commit
49382bf96b
19 changed files with 2917 additions and 476 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.5.7
|
||||
2.5.8
|
||||
|
|
|
|||
12
configs/plugins.json.example
Normal file
12
configs/plugins.json.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"plugins": {
|
||||
"api-potluck": {
|
||||
"enabled": true,
|
||||
"description": "API 大锅饭 - Key 管理和用量统计插件"
|
||||
},
|
||||
"default-auth": {
|
||||
"enabled": true,
|
||||
"description": "默认 API Key 认证插件(内置)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) { /* 静默失败,不影响主流程 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
504
src/plugin-manager.js
Normal 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 };
|
||||
679
src/plugins/api-potluck/api-routes.js
Normal file
679
src/plugins/api-potluck/api-routes.js
Normal 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;
|
||||
}
|
||||
}
|
||||
210
src/plugins/api-potluck/index.js
Normal file
210
src/plugins/api-potluck/index.js
Normal 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
|
||||
};
|
||||
89
src/plugins/default-auth/index.js
Normal file
89
src/plugins/default-auth/index.js
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1137
static/potluck-user.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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' });
|
||||
|
|
|
|||
Loading…
Reference in a new issue